Merge branch 'develop' into fea/dat-20042026-CMS-Partnerships-Accreditation-History-Admissions-Policies
@@ -23,3 +23,6 @@ pids
|
||||
.cursor
|
||||
package-lock.json
|
||||
AGENTS.md
|
||||
|
||||
.vscode
|
||||
.kiro/
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
const About = require("../models/about");
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
|
||||
// Helpers
|
||||
const getAboutDoc = async () => About.findOne().sort({ updatedAt: -1 });
|
||||
const getAboutData = async () => (await getAboutDoc())?.toObject() || {};
|
||||
|
||||
const getDefaultAboutData = () => ({
|
||||
hero: {
|
||||
badge: "",
|
||||
title: "",
|
||||
description: "",
|
||||
studentCount: "",
|
||||
image: "",
|
||||
imageAlt: "",
|
||||
coreValues: [],
|
||||
},
|
||||
leadership: {
|
||||
heading: "",
|
||||
description: "",
|
||||
members: [],
|
||||
},
|
||||
learningModel: {
|
||||
heading: "",
|
||||
description: "",
|
||||
async: {
|
||||
title: "",
|
||||
description: "",
|
||||
features: [],
|
||||
},
|
||||
sync: {
|
||||
title: "",
|
||||
description: "",
|
||||
features: [],
|
||||
},
|
||||
},
|
||||
accreditation: {
|
||||
heading: "",
|
||||
description: "",
|
||||
badges: [],
|
||||
stats: [],
|
||||
},
|
||||
successStories: {
|
||||
heading: "",
|
||||
description: "",
|
||||
stories: [],
|
||||
},
|
||||
cta: {
|
||||
heading: "",
|
||||
description: "",
|
||||
primaryButton: { label: "", href: "" },
|
||||
secondaryButton: { label: "", href: "" },
|
||||
},
|
||||
});
|
||||
|
||||
// Admin: render management view with data from MongoDB
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
let data = await getAboutData();
|
||||
const defaults = getDefaultAboutData();
|
||||
|
||||
// Merge defaults for any missing sections
|
||||
const sections = Object.keys(defaults);
|
||||
sections.forEach((s) => {
|
||||
data[s] = data[s] || defaults[s];
|
||||
});
|
||||
|
||||
return res.render("admin/about/index", {
|
||||
layout: "layouts/main",
|
||||
title: "About Management",
|
||||
data,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("About index error:", err);
|
||||
req.flash("error_msg", "Error loading about data");
|
||||
return req.session.save(() => res.redirect("/admin/dashboard"));
|
||||
}
|
||||
};
|
||||
|
||||
// Admin: parse req.body sections and save to MongoDB
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const sections = [
|
||||
"hero",
|
||||
"leadership",
|
||||
"learningModel",
|
||||
"accreditation",
|
||||
"successStories",
|
||||
"cta",
|
||||
];
|
||||
|
||||
let doc = await getAboutDoc();
|
||||
|
||||
if (!doc) {
|
||||
doc = new About({});
|
||||
}
|
||||
|
||||
let hasChanges = false;
|
||||
|
||||
for (const section of sections) {
|
||||
if (req.body[section]) {
|
||||
try {
|
||||
const payload = JSON.parse(req.body[section]);
|
||||
doc[section] = payload;
|
||||
doc.markModified(section);
|
||||
hasChanges = true;
|
||||
} catch (e) {
|
||||
console.error(`Invalid JSON for ${section}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasChanges) {
|
||||
req.flash("info_msg", "No changes were made");
|
||||
return req.session.save(() => res.redirect("/admin/about-us"));
|
||||
}
|
||||
|
||||
await doc.save();
|
||||
|
||||
req.flash("success_msg", "About page configuration has been updated!");
|
||||
return req.session.save(() => res.redirect("/admin/about-us"));
|
||||
} catch (err) {
|
||||
console.error("About update error:", err);
|
||||
req.flash("error_msg", `Update error: ${err.message}`);
|
||||
return req.session.save(() => res.redirect("/admin/about-us"));
|
||||
}
|
||||
};
|
||||
|
||||
// Recursively prepend BACKEND_URL to any relative /uploads/... paths in an object
|
||||
function resolveImageUrls(obj, baseUrl) {
|
||||
if (!obj || typeof obj !== "object") return obj;
|
||||
if (Array.isArray(obj)) return obj.map((item) => resolveImageUrls(item, baseUrl));
|
||||
const result = {};
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
if (typeof val === "string" && val.startsWith("/uploads/")) {
|
||||
result[key] = `${baseUrl}${val}`;
|
||||
} else {
|
||||
result[key] = resolveImageUrls(val, baseUrl);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Public API: return JSON data for frontend
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
let data = await getAboutData();
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
data = getDefaultAboutData();
|
||||
}
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
|
||||
return res.json(resolveImageUrls(data, baseUrl));
|
||||
} catch (err) {
|
||||
console.error("About API error:", err);
|
||||
return res.status(500).json({ error: "Error loading about data" });
|
||||
}
|
||||
};
|
||||
@@ -1,450 +0,0 @@
|
||||
const AppointmentSubmission = require("../models/appointmentSubmission");
|
||||
const Appointment = require("../models/appointment");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
|
||||
// ==================== 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" });
|
||||
|
||||
// Capture before state for audit logging
|
||||
const beforeState = appointment
|
||||
? JSON.parse(JSON.stringify(appointment.toObject()))
|
||||
: null;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// Capture after state for audit logging
|
||||
const afterState = JSON.parse(JSON.stringify(appointment.toObject()));
|
||||
|
||||
// Generate changes diff
|
||||
const changes = beforeState ? diffObject(beforeState, afterState) : [];
|
||||
|
||||
// Write audit log
|
||||
await writeAuditLog({
|
||||
model: "Appointment",
|
||||
documentId: appointment._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_APPOINTMENT,
|
||||
before: beforeState,
|
||||
after: afterState,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
// Get the appointment before update for audit logging
|
||||
const beforeAppointment = await AppointmentSubmission.findById(id);
|
||||
if (!beforeAppointment) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Appointment not found",
|
||||
});
|
||||
}
|
||||
|
||||
const beforeState = JSON.parse(
|
||||
JSON.stringify(beforeAppointment.toObject()),
|
||||
);
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
// Capture after state for audit logging
|
||||
const afterState = JSON.parse(JSON.stringify(appointment.toObject()));
|
||||
|
||||
// Generate changes diff
|
||||
const changes = diffObject(beforeState, afterState);
|
||||
|
||||
// Write audit log
|
||||
await writeAuditLog({
|
||||
model: "AppointmentSubmission",
|
||||
documentId: appointment._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_APPOINTMENT_STATUS,
|
||||
before: beforeState,
|
||||
after: afterState,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// Get the appointment before deletion for audit logging
|
||||
const appointment = await AppointmentSubmission.findById(id);
|
||||
if (!appointment) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Appointment not found",
|
||||
});
|
||||
}
|
||||
|
||||
const beforeState = JSON.parse(JSON.stringify(appointment.toObject()));
|
||||
|
||||
// Delete the appointment
|
||||
await AppointmentSubmission.findByIdAndDelete(id);
|
||||
|
||||
// Write audit log
|
||||
await writeAuditLog({
|
||||
model: "AppointmentSubmission",
|
||||
documentId: appointment._id,
|
||||
action: AUDIT_ACTIONS.DELETE_APPOINTMENT,
|
||||
before: beforeState,
|
||||
after: null,
|
||||
changes: [],
|
||||
req,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Appointment deleted successfully",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error deleting appointment:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error deleting appointment",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,549 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const Booking = require("../models/booking");
|
||||
|
||||
// -------------------- Public helpers --------------------
|
||||
const getBookingData = async () => {
|
||||
const booking = await Booking.findOne().sort({ updatedAt: -1 });
|
||||
return booking ? (booking.toObject ? booking.toObject() : booking) : null;
|
||||
};
|
||||
|
||||
// Load static booking JSON from `data/booking.json` (if present)
|
||||
const loadStaticBooking = () => {
|
||||
try {
|
||||
const p = path.join(__dirname, '..', 'data', 'booking.json');
|
||||
if (!fs.existsSync(p)) return null;
|
||||
const raw = fs.readFileSync(p, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.error('booking.loadStaticBooking error:', e && e.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Normalize booking shape: ensure configuration exists with discounts/vouchers
|
||||
const normalizeBookingShape = (booking) => {
|
||||
if (!booking || typeof booking !== 'object') return booking;
|
||||
const b = JSON.parse(JSON.stringify(booking));
|
||||
|
||||
if (!b.configuration || typeof b.configuration !== 'object') {
|
||||
b.configuration = { currency: 'USD', discounts: [], vouchers: [] };
|
||||
}
|
||||
|
||||
// Ensure configuration.discounts and configuration.vouchers exist
|
||||
if (!Array.isArray(b.configuration.discounts)) {
|
||||
b.configuration.discounts = [];
|
||||
}
|
||||
if (!Array.isArray(b.configuration.vouchers)) {
|
||||
b.configuration.vouchers = [];
|
||||
}
|
||||
|
||||
return b;
|
||||
};
|
||||
|
||||
// Deep merge: properties from `overrides` replace / merge into `base`.
|
||||
const deepMerge = (base, overrides) => {
|
||||
if (overrides === undefined) return base;
|
||||
if (base === undefined || base === null) return overrides;
|
||||
if (Array.isArray(overrides)) return overrides;
|
||||
if (typeof overrides !== 'object' || overrides === null) return overrides;
|
||||
const out = Object.assign({}, base);
|
||||
Object.keys(overrides).forEach((k) => {
|
||||
if (Array.isArray(overrides[k]) || typeof overrides[k] !== 'object' || overrides[k] === null) {
|
||||
out[k] = overrides[k];
|
||||
} else {
|
||||
out[k] = deepMerge(base[k], overrides[k]);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
// Ensure booking data fields have the expected shapes to avoid runtime errors
|
||||
const sanitizeBookingData = (raw) => {
|
||||
const defaults = {
|
||||
hero: { title: '', backgroundImage: '' },
|
||||
searchBar: { locationLabel: '', holidaySeasonLabel: '', searchButtonText: '' },
|
||||
filterPanel: {
|
||||
title: '',
|
||||
priceTitle: '',
|
||||
priceLabel: '',
|
||||
pricePlaceholder: '',
|
||||
priceMin: 0,
|
||||
priceMax: 0,
|
||||
ageTitle: '',
|
||||
ageMin: 0,
|
||||
ageMax: 0,
|
||||
ageSelectPlaceholder: '',
|
||||
activitiesTitle: '',
|
||||
ratingTitle: '',
|
||||
ratingOptions: [],
|
||||
resetButtonText: ''
|
||||
},
|
||||
programs: [],
|
||||
holidays: [],
|
||||
locations: [],
|
||||
camps: [],
|
||||
configuration: { currency: 'USD', discounts: [], vouchers: [] },
|
||||
formSteps: [],
|
||||
validation: {}
|
||||
};
|
||||
|
||||
if (!raw || typeof raw !== 'object') return defaults;
|
||||
|
||||
// Use raw data first, then fill in missing fields with defaults
|
||||
const safe = Object.assign({}, raw);
|
||||
|
||||
// Ensure nested objects/arrays have correct types (use raw data if valid, otherwise defaults)
|
||||
safe.hero = (safe.hero && typeof safe.hero === 'object') ? safe.hero : defaults.hero;
|
||||
safe.searchBar = (safe.searchBar && typeof safe.searchBar === 'object') ? safe.searchBar : defaults.searchBar;
|
||||
safe.filterPanel = (safe.filterPanel && typeof safe.filterPanel === 'object') ? safe.filterPanel : defaults.filterPanel;
|
||||
|
||||
if (!Array.isArray(safe.filterPanel.ratingOptions)) safe.filterPanel.ratingOptions = defaults.filterPanel.ratingOptions;
|
||||
|
||||
safe.programs = Array.isArray(safe.programs) ? safe.programs : defaults.programs;
|
||||
safe.holidays = Array.isArray(safe.holidays) ? safe.holidays : defaults.holidays;
|
||||
safe.locations = Array.isArray(safe.locations) ? safe.locations : defaults.locations;
|
||||
safe.camps = Array.isArray(safe.camps) ? safe.camps : defaults.camps;
|
||||
|
||||
// Ensure configuration has proper structure
|
||||
if (!safe.configuration || typeof safe.configuration !== 'object') {
|
||||
safe.configuration = defaults.configuration;
|
||||
}
|
||||
if (!Array.isArray(safe.configuration.discounts)) {
|
||||
safe.configuration.discounts = defaults.configuration.discounts;
|
||||
}
|
||||
if (!Array.isArray(safe.configuration.vouchers)) {
|
||||
safe.configuration.vouchers = defaults.configuration.vouchers;
|
||||
}
|
||||
|
||||
// Ensure formSteps and validation have correct types
|
||||
safe.formSteps = Array.isArray(safe.formSteps) ? safe.formSteps : defaults.formSteps;
|
||||
safe.validation = (safe.validation && typeof safe.validation === 'object' && !Array.isArray(safe.validation)) ? safe.validation : defaults.validation;
|
||||
|
||||
return safe;
|
||||
};
|
||||
|
||||
// Safe JSON parse with better error handling - handles double-encoded JSON and JS object notation
|
||||
const safeParse = (value, fieldName = 'unknown') => {
|
||||
// If already an object or array, return as-is
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// If string, try to parse
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
let cleaned = value.trim();
|
||||
|
||||
// Check if it looks like JavaScript object notation (has single quotes or unquoted keys)
|
||||
if (cleaned.includes("'") || /\{\s*\w+:/.test(cleaned)) {
|
||||
console.warn(`safeParse: Converting JS notation to JSON for "${fieldName}"`);
|
||||
|
||||
// Aggressive conversion approach
|
||||
cleaned = cleaned
|
||||
.replace(/'/g, '"') // Replace ALL single quotes with double quotes
|
||||
.replace(/\r?\n|\r/g, ' ') // Remove all newlines
|
||||
.replace(/\s+/g, ' ') // Normalize multiple spaces to single space
|
||||
.replace(/,(\s*[}\]])/g, '$1') // Remove trailing commas before } or ]
|
||||
.replace(/([{,]\s*)(\w+):/g, '$1"$2":'); // Quote unquoted object keys
|
||||
}
|
||||
|
||||
// Try parsing
|
||||
let parsed = JSON.parse(cleaned);
|
||||
|
||||
// If result is still a string, try parsing again (double-encoded)
|
||||
if (typeof parsed === 'string') {
|
||||
console.warn(`safeParse: Double-encoded JSON detected for "${fieldName}"`);
|
||||
parsed = JSON.parse(parsed);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
console.error(`safeParse: Failed to parse field "${fieldName}"`, {
|
||||
error: e.message,
|
||||
valuePreview: value.substring(0, 200)
|
||||
});
|
||||
|
||||
throw new Error(`Invalid JSON format for field: ${fieldName}. Error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// For other types, return empty array or object
|
||||
console.warn(`safeParse: Unexpected type for field "${fieldName}": ${typeof value}`);
|
||||
return Array.isArray(value) ? [] : {};
|
||||
};
|
||||
|
||||
// Validate booking data structure
|
||||
const validateBookingData = (data) => {
|
||||
const errors = [];
|
||||
|
||||
// Check required fields
|
||||
if (!data.hero || typeof data.hero !== 'object') {
|
||||
errors.push('Hero data is required and must be an object');
|
||||
}
|
||||
|
||||
if (!data.searchBar || typeof data.searchBar !== 'object') {
|
||||
errors.push('SearchBar data is required and must be an object');
|
||||
}
|
||||
|
||||
if (!data.filterPanel || typeof data.filterPanel !== 'object') {
|
||||
errors.push('FilterPanel data is required and must be an object');
|
||||
}
|
||||
|
||||
// Validate arrays
|
||||
if (data.programs && !Array.isArray(data.programs)) {
|
||||
errors.push('Programs must be an array');
|
||||
}
|
||||
|
||||
if (data.holidays && !Array.isArray(data.holidays)) {
|
||||
errors.push('Holidays must be an array');
|
||||
}
|
||||
|
||||
if (data.locations && !Array.isArray(data.locations)) {
|
||||
errors.push('Locations must be an array');
|
||||
}
|
||||
|
||||
if (data.camps && !Array.isArray(data.camps)) {
|
||||
errors.push('Camps must be an array');
|
||||
}
|
||||
|
||||
// Validate configuration structure
|
||||
if (data.configuration) {
|
||||
if (typeof data.configuration !== 'object') {
|
||||
errors.push('Configuration must be an object');
|
||||
} else {
|
||||
if (data.configuration.discounts && !Array.isArray(data.configuration.discounts)) {
|
||||
errors.push('Configuration.discounts must be an array');
|
||||
}
|
||||
if (data.configuration.vouchers && !Array.isArray(data.configuration.vouchers)) {
|
||||
errors.push('Configuration.vouchers must be an array');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate formSteps and validation structure if provided
|
||||
if (data.formSteps && !Array.isArray(data.formSteps)) {
|
||||
errors.push('formSteps must be an array');
|
||||
}
|
||||
|
||||
if (data.validation && (typeof data.validation !== 'object' || Array.isArray(data.validation))) {
|
||||
errors.push('validation must be an object');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
};
|
||||
|
||||
// -------------------- Public endpoints --------------------
|
||||
// Public endpoint: return Booking JSON
|
||||
exports.page = async (req, res) => {
|
||||
try {
|
||||
const dbBooking = await getBookingData();
|
||||
const staticBooking = loadStaticBooking();
|
||||
|
||||
// Normalize shapes so `configuration.discounts`/`configuration.vouchers` exist
|
||||
const normStatic = normalizeBookingShape(staticBooking);
|
||||
const normDb = normalizeBookingShape(dbBooking);
|
||||
|
||||
// Build final payload according to BOOKING_MODE env var
|
||||
const finalBooking = getFinalBooking(normStatic, normDb);
|
||||
|
||||
if (!finalBooking) {
|
||||
return res.status(404).json({
|
||||
error: "No booking data found",
|
||||
message: "Please configure booking data in admin panel"
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processed = addBaseUrlToImages(finalBooking, baseUrl);
|
||||
|
||||
return res.json(processed);
|
||||
} catch (err) {
|
||||
console.error("booking.page error:", err);
|
||||
return res.status(500).json({
|
||||
error: "Error loading booking data",
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// API endpoint to return booking JSON
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const dbBooking = await getBookingData();
|
||||
const staticBooking = loadStaticBooking();
|
||||
|
||||
// Normalize shapes so `configuration.discounts`/`configuration.vouchers` exist
|
||||
const normStatic = normalizeBookingShape(staticBooking);
|
||||
const normDb = normalizeBookingShape(dbBooking);
|
||||
|
||||
const finalBooking = getFinalBooking(normStatic, normDb);
|
||||
|
||||
if (!finalBooking) {
|
||||
return res.status(404).json({
|
||||
error: "No booking data found",
|
||||
message: "Please configure booking data in admin panel"
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processed = addBaseUrlToImages(finalBooking, baseUrl);
|
||||
|
||||
return res.json(processed);
|
||||
} catch (err) {
|
||||
console.error("booking.api error:", err);
|
||||
return res.status(500).json({
|
||||
error: "Error loading booking data",
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------- Admin endpoints --------------------
|
||||
// Display Booking management page
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const dbBooking = await getBookingData();
|
||||
const staticBooking = loadStaticBooking();
|
||||
|
||||
// Merge static booking with DB data (use same merge logic as public endpoints)
|
||||
const normStatic = normalizeBookingShape(staticBooking);
|
||||
const normDb = normalizeBookingShape(dbBooking);
|
||||
const mergedData = getFinalBooking(normStatic, normDb);
|
||||
|
||||
// Normalize again after merge to ensure discounts/vouchers are synced to top-level
|
||||
const data = normalizeBookingShape(mergedData);
|
||||
|
||||
// Sanitize data to ensure nested fields are objects/arrays (fixes malformed DB entries)
|
||||
const safeData = sanitizeBookingData(data);
|
||||
|
||||
res.render("admin/booking/index", {
|
||||
layout: "layouts/main",
|
||||
title: "Booking Management",
|
||||
data: safeData,
|
||||
frontendUrl: process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("booking.index error:", err);
|
||||
req.flash("error_msg", "Error loading booking page");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Update booking data
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
// ADD THIS DEBUG LOG
|
||||
console.log('=== RAW REQUEST BODY ===');
|
||||
console.log('Discounts type:', typeof req.body.discounts);
|
||||
console.log('Discounts first 500 chars:', req.body.discounts?.substring(0, 500));
|
||||
console.log('Vouchers type:', typeof req.body.vouchers);
|
||||
console.log('Vouchers first 500 chars:', req.body.vouchers?.substring(0, 500));
|
||||
console.log('========================');
|
||||
const {
|
||||
hero,
|
||||
searchBar,
|
||||
filterPanel,
|
||||
programs,
|
||||
holidays,
|
||||
locations,
|
||||
camps,
|
||||
discounts,
|
||||
vouchers,
|
||||
formSteps,
|
||||
validation: validationRaw
|
||||
} = req.body;
|
||||
|
||||
// Parse JSON strings
|
||||
const errors = [];
|
||||
let updateData = {};
|
||||
|
||||
try {
|
||||
console.log('Raw discounts from req.body:', typeof discounts, discounts);
|
||||
console.log('Raw vouchers from req.body:', typeof vouchers, vouchers);
|
||||
|
||||
const parsedDiscounts = safeParse(discounts, 'discounts');
|
||||
const parsedVouchers = safeParse(vouchers, 'vouchers');
|
||||
|
||||
console.log('Parsed discounts:', typeof parsedDiscounts, Array.isArray(parsedDiscounts), parsedDiscounts);
|
||||
console.log('Parsed vouchers:', typeof parsedVouchers, Array.isArray(parsedVouchers), parsedVouchers);
|
||||
|
||||
updateData = {
|
||||
hero: safeParse(hero, 'hero'),
|
||||
searchBar: safeParse(searchBar, 'searchBar'),
|
||||
filterPanel: safeParse(filterPanel, 'filterPanel'),
|
||||
programs: safeParse(programs, 'programs'),
|
||||
holidays: safeParse(holidays, 'holidays'),
|
||||
locations: safeParse(locations, 'locations'),
|
||||
camps: safeParse(camps, 'camps'),
|
||||
formSteps: safeParse(formSteps, 'formSteps'),
|
||||
validation: safeParse(validationRaw, 'validation'),
|
||||
configuration: {
|
||||
currency: 'USD',
|
||||
discounts: parsedDiscounts,
|
||||
vouchers: parsedVouchers
|
||||
}
|
||||
};
|
||||
} catch (parseError) {
|
||||
console.error('booking.update: Parse error', parseError);
|
||||
req.flash("error_msg", `Data processing error: ${parseError.message}`);
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
|
||||
// Validate data structure
|
||||
const validation = validateBookingData(updateData);
|
||||
if (!validation.isValid) {
|
||||
console.error('booking.update: Validation failed', validation.errors);
|
||||
req.flash("error_msg", `Validation failed: ${validation.errors[0]}`);
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
|
||||
console.log('Final updateData keys:', Object.keys(updateData));
|
||||
console.log('updateData.discounts:', updateData.discounts);
|
||||
console.log('updateData.configuration:', updateData.configuration);
|
||||
|
||||
// CRITICAL: Remove any top-level discounts/vouchers to prevent schema conflicts
|
||||
// These should ONLY exist in configuration object
|
||||
delete updateData.discounts;
|
||||
delete updateData.vouchers;
|
||||
|
||||
// Update or create booking document
|
||||
let result;
|
||||
try {
|
||||
if (id && id !== 'undefined') {
|
||||
result = await Booking.findByIdAndUpdate(
|
||||
id,
|
||||
{
|
||||
...updateData,
|
||||
$unset: { discounts: "", vouchers: "" } // Remove old top-level fields
|
||||
},
|
||||
{
|
||||
new: true,
|
||||
runValidators: false, // TẮT validator để tránh lỗi cast
|
||||
strict: false // TẮT strict mode
|
||||
}
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
req.flash("error_msg", "Booking document not found");
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
} else {
|
||||
// Upsert: update existing or create new
|
||||
result = await Booking.findOneAndUpdate(
|
||||
{},
|
||||
{
|
||||
...updateData,
|
||||
$unset: { discounts: "", vouchers: "" } // Remove old top-level fields
|
||||
},
|
||||
{
|
||||
upsert: true,
|
||||
new: true,
|
||||
runValidators: false, // TẮT validator
|
||||
strict: false // TẮT strict mode
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
req.flash("success_msg", "Booking data updated successfully");
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
} catch (dbError) {
|
||||
console.error("booking.update: Database error", dbError);
|
||||
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("booking.update error:", err);
|
||||
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
};
|
||||
|
||||
// Booking selection mode: 'merge' (default) = static base, DB overrides;
|
||||
// 'static' = use `data/booking.json` only; 'db' = use DB only.
|
||||
const getFinalBooking = (staticBooking, dbBooking) => {
|
||||
const mode = (process.env.BOOKING_MODE || 'merge').toLowerCase();
|
||||
if (mode === 'static') return staticBooking || dbBooking || null;
|
||||
if (mode === 'db') return dbBooking || staticBooking || null;
|
||||
// default: merge static (base) with DB overrides
|
||||
// If both static and db present, attempt to map DB primitive lists (e.g. ["915"]) to
|
||||
// full objects from the static file (e.g. {id:"915", name:..., type:...}).
|
||||
const mapDbPrimitivesToObjects = (db, stat) => {
|
||||
if (!db || !stat) return db;
|
||||
const dbCfg = db.configuration || {};
|
||||
const statCfg = stat.configuration || {};
|
||||
|
||||
console.log('DB discounts/vouchers:', db.discounts, db.vouchers);
|
||||
console.log('DB config:', dbCfg.discounts, dbCfg.vouchers);
|
||||
console.log('Static config:', statCfg.discounts, statCfg.vouchers);
|
||||
|
||||
// Handle legacy: if top-level discounts/vouchers exist as strings, migrate to configuration
|
||||
if (Array.isArray(db.discounts) && db.discounts.length > 0 && (!dbCfg.discounts || dbCfg.discounts.length === 0)) {
|
||||
const statDiscountById = {};
|
||||
if (statCfg.discounts) {
|
||||
statCfg.discounts.forEach(d => { if (d && d.id) statDiscountById[String(d.id)] = d; });
|
||||
}
|
||||
if (typeof db.discounts[0] === 'string') {
|
||||
dbCfg.discounts = db.discounts.map(s => statDiscountById[String(s)] || { id: s, name: '', type: 'percentage', value: 0 });
|
||||
} else {
|
||||
dbCfg.discounts = db.discounts;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(db.vouchers) && db.vouchers.length > 0 && (!dbCfg.vouchers || dbCfg.vouchers.length === 0)) {
|
||||
const statVouchByCode = {};
|
||||
if (statCfg.vouchers) {
|
||||
statCfg.vouchers.forEach(v => { if (v && v.validCodes) statVouchByCode[String(v.validCodes)] = v; });
|
||||
}
|
||||
if (typeof db.vouchers[0] === 'string') {
|
||||
dbCfg.vouchers = db.vouchers.map(s => statVouchByCode[String(s)] || { validCodes: s, type: 'percentage', value: 0 });
|
||||
} else {
|
||||
dbCfg.vouchers = db.vouchers;
|
||||
}
|
||||
}
|
||||
|
||||
// If DB configuration still empty, use static data
|
||||
if (Array.isArray(dbCfg.discounts) && dbCfg.discounts.length === 0 && statCfg.discounts && statCfg.discounts.length > 0) {
|
||||
dbCfg.discounts = statCfg.discounts;
|
||||
} else if (Array.isArray(dbCfg.discounts) && dbCfg.discounts.length > 0 && typeof dbCfg.discounts[0] === 'string') {
|
||||
// Map string IDs to full objects from static
|
||||
const statDiscountById = {};
|
||||
if (statCfg.discounts) {
|
||||
statCfg.discounts.forEach(d => { if (d && d.id) statDiscountById[String(d.id)] = d; });
|
||||
}
|
||||
dbCfg.discounts = dbCfg.discounts.map(s => statDiscountById[String(s)] || { id: s, name: '', type: 'percentage', value: 0 });
|
||||
}
|
||||
|
||||
if (Array.isArray(dbCfg.vouchers) && dbCfg.vouchers.length === 0 && statCfg.vouchers && statCfg.vouchers.length > 0) {
|
||||
dbCfg.vouchers = statCfg.vouchers;
|
||||
} else if (Array.isArray(dbCfg.vouchers) && dbCfg.vouchers.length > 0 && typeof dbCfg.vouchers[0] === 'string') {
|
||||
// Map string codes to full objects from static
|
||||
const statVouchByCode = {};
|
||||
if (statCfg.vouchers) {
|
||||
statCfg.vouchers.forEach(v => { if (v && v.validCodes) statVouchByCode[String(v.validCodes)] = v; });
|
||||
}
|
||||
dbCfg.vouchers = dbCfg.vouchers.map(s => statVouchByCode[String(s)] || { validCodes: s, type: 'percentage', value: 0 });
|
||||
}
|
||||
|
||||
return Object.assign({}, db, { configuration: dbCfg });
|
||||
};
|
||||
|
||||
const mappedDb = mapDbPrimitivesToObjects(dbBooking, staticBooking);
|
||||
const merged = staticBooking ? deepMerge(staticBooking, mappedDb || {}) : (mappedDb || null);
|
||||
|
||||
// Clean up: remove top-level discounts/vouchers after migrating to configuration
|
||||
if (merged) {
|
||||
delete merged.discounts;
|
||||
delete merged.vouchers;
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
@@ -1,558 +0,0 @@
|
||||
const BookingSubmission = require('../models/bookingSubmission');
|
||||
const Activity = require('../models/activity');
|
||||
|
||||
// API endpoint để tạo booking submission mới
|
||||
exports.submitBooking = async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
activityId,
|
||||
sessionId,
|
||||
parentFirstName,
|
||||
parentLastName,
|
||||
email,
|
||||
phone,
|
||||
address,
|
||||
city,
|
||||
country,
|
||||
postalCode,
|
||||
participantFirstName,
|
||||
participantLastName,
|
||||
participantBirthDate,
|
||||
participantGender,
|
||||
numberOfParticipants,
|
||||
medicalConditions,
|
||||
dietaryRestrictions,
|
||||
specialRequests,
|
||||
emergencyContact,
|
||||
emergencyPhone,
|
||||
agreeTerms,
|
||||
agreeNewsletter
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!activityId || !sessionId || !parentFirstName || !parentLastName ||
|
||||
!email || !phone || !address || !city || !country || !postalCode ||
|
||||
!participantFirstName || !participantLastName || !participantBirthDate ||
|
||||
!participantGender || !emergencyContact || !emergencyPhone || !agreeTerms) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields',
|
||||
message: 'Please fill in all required fields'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify activity exists
|
||||
const activity = await Activity.findById(activityId);
|
||||
if (!activity) {
|
||||
return res.status(404).json({
|
||||
error: 'Activity not found',
|
||||
message: 'The selected activity does not exist'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify session exists and is active
|
||||
const session = activity.bookingSessions?.find(s => s.sessionId === sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).json({
|
||||
error: 'Session not found',
|
||||
message: 'The selected session does not exist'
|
||||
});
|
||||
}
|
||||
|
||||
if (!session.isActive) {
|
||||
return res.status(400).json({
|
||||
error: 'Session not available',
|
||||
message: 'The selected session is no longer available for booking'
|
||||
});
|
||||
}
|
||||
|
||||
// Check availability based on participant gender
|
||||
const currentBookings = await BookingSubmission.countDocuments({
|
||||
activityId,
|
||||
sessionId,
|
||||
participantGender,
|
||||
status: { $in: ['pending', 'confirmed'] }
|
||||
});
|
||||
|
||||
const availableSpots = participantGender === 'male'
|
||||
? session.totalMaleSpots - session.bookedMaleSpots
|
||||
: session.totalFemaleSpots - session.bookedFemaleSpots;
|
||||
|
||||
if (currentBookings >= availableSpots) {
|
||||
return res.status(400).json({
|
||||
error: 'Session full',
|
||||
message: `No more spots available for ${participantGender} participants in this session`
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate total amount based on activity price and number of participants
|
||||
const totalAmount = (activity.price || 0) * (parseInt(numberOfParticipants) || 1);
|
||||
|
||||
// Create booking submission
|
||||
const bookingSubmission = new BookingSubmission({
|
||||
activityId,
|
||||
sessionId,
|
||||
parentFirstName: parentFirstName.trim(),
|
||||
parentLastName: parentLastName.trim(),
|
||||
email: email.toLowerCase().trim(),
|
||||
phone: phone.trim(),
|
||||
address: address.trim(),
|
||||
city: city.trim(),
|
||||
country: country.trim(),
|
||||
postalCode: postalCode.trim(),
|
||||
participantFirstName: participantFirstName.trim(),
|
||||
participantLastName: participantLastName.trim(),
|
||||
participantBirthDate: new Date(participantBirthDate),
|
||||
participantGender,
|
||||
numberOfParticipants: parseInt(numberOfParticipants) || 1,
|
||||
medicalConditions: (medicalConditions || '').trim(),
|
||||
dietaryRestrictions: dietaryRestrictions || 'none',
|
||||
specialRequests: (specialRequests || '').trim(),
|
||||
emergencyContact: emergencyContact.trim(),
|
||||
emergencyPhone: emergencyPhone.trim(),
|
||||
agreeTerms: Boolean(agreeTerms),
|
||||
agreeNewsletter: Boolean(agreeNewsletter),
|
||||
totalAmount,
|
||||
status: 'pending',
|
||||
paymentStatus: 'pending'
|
||||
});
|
||||
|
||||
await bookingSubmission.save();
|
||||
|
||||
// Update session booked spots
|
||||
const updateField = participantGender === 'male' ? 'bookingSessions.$.bookedMaleSpots' : 'bookingSessions.$.bookedFemaleSpots';
|
||||
await Activity.updateOne(
|
||||
{ _id: activityId, 'bookingSessions.sessionId': sessionId },
|
||||
{ $inc: { [updateField]: 1 } }
|
||||
);
|
||||
|
||||
// Populate activity info for response
|
||||
await bookingSubmission.populate('activityId', 'name price');
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
message: 'Booking submitted successfully',
|
||||
booking: {
|
||||
id: bookingSubmission._id,
|
||||
activityName: bookingSubmission.activityId.name,
|
||||
sessionId: bookingSubmission.sessionId,
|
||||
participantName: `${bookingSubmission.participantFirstName} ${bookingSubmission.participantLastName}`,
|
||||
totalAmount: bookingSubmission.totalAmount,
|
||||
status: bookingSubmission.status
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('submitBooking error:', error);
|
||||
|
||||
// Handle validation errors
|
||||
if (error.name === 'ValidationError') {
|
||||
const validationErrors = Object.values(error.errors).map(err => err.message);
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
message: validationErrors.join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Server error',
|
||||
message: 'An error occurred while processing your booking. Please try again.'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint để lấy thông tin session availability
|
||||
exports.getSessionAvailability = async (req, res) => {
|
||||
try {
|
||||
const { activityId, sessionId } = req.params;
|
||||
|
||||
const activity = await Activity.findById(activityId);
|
||||
if (!activity) {
|
||||
return res.status(404).json({ error: 'Activity not found' });
|
||||
}
|
||||
|
||||
const session = activity.bookingSessions?.find(s => s.sessionId === sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
// Get current booking counts
|
||||
const maleBookings = await BookingSubmission.countDocuments({
|
||||
activityId,
|
||||
sessionId,
|
||||
participantGender: 'male',
|
||||
status: { $in: ['pending', 'confirmed'] }
|
||||
});
|
||||
|
||||
const femaleBookings = await BookingSubmission.countDocuments({
|
||||
activityId,
|
||||
sessionId,
|
||||
participantGender: 'female',
|
||||
status: { $in: ['pending', 'confirmed'] }
|
||||
});
|
||||
|
||||
return res.json({
|
||||
sessionId,
|
||||
isActive: session.isActive,
|
||||
startDate: session.startDate,
|
||||
endDate: session.endDate,
|
||||
overnightStays: session.overnightStays,
|
||||
price: session.price || activity.price,
|
||||
availability: {
|
||||
male: {
|
||||
total: session.totalMaleSpots,
|
||||
booked: maleBookings,
|
||||
available: Math.max(0, session.totalMaleSpots - maleBookings)
|
||||
},
|
||||
female: {
|
||||
total: session.totalFemaleSpots,
|
||||
booked: femaleBookings,
|
||||
available: Math.max(0, session.totalFemaleSpots - femaleBookings)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('getSessionAvailability error:', error);
|
||||
return res.status(500).json({ error: 'Error loading session availability' });
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint để lấy tất cả sessions có sẵn cho một activity
|
||||
exports.getAvailableSessions = async (req, res) => {
|
||||
try {
|
||||
const { activityId } = req.params;
|
||||
|
||||
const activity = await Activity.findById(activityId);
|
||||
if (!activity) {
|
||||
return res.status(404).json({ error: 'Activity not found' });
|
||||
}
|
||||
|
||||
const sessions = activity.bookingSessions || [];
|
||||
const availableSessions = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!session.isActive) continue;
|
||||
|
||||
// Get current booking counts
|
||||
const maleBookings = await BookingSubmission.countDocuments({
|
||||
activityId,
|
||||
sessionId: session.sessionId,
|
||||
participantGender: 'male',
|
||||
status: { $in: ['pending', 'confirmed'] }
|
||||
});
|
||||
|
||||
const femaleBookings = await BookingSubmission.countDocuments({
|
||||
activityId,
|
||||
sessionId: session.sessionId,
|
||||
participantGender: 'female',
|
||||
status: { $in: ['pending', 'confirmed'] }
|
||||
});
|
||||
|
||||
const maleAvailable = Math.max(0, session.totalMaleSpots - maleBookings);
|
||||
const femaleAvailable = Math.max(0, session.totalFemaleSpots - femaleBookings);
|
||||
|
||||
// Only include sessions that have available spots
|
||||
if (maleAvailable > 0 || femaleAvailable > 0) {
|
||||
availableSessions.push({
|
||||
sessionId: session.sessionId,
|
||||
startDate: session.startDate,
|
||||
endDate: session.endDate,
|
||||
overnightStays: session.overnightStays,
|
||||
price: session.price || activity.price,
|
||||
availability: {
|
||||
male: {
|
||||
total: session.totalMaleSpots,
|
||||
booked: maleBookings,
|
||||
available: maleAvailable
|
||||
},
|
||||
female: {
|
||||
total: session.totalFemaleSpots,
|
||||
booked: femaleBookings,
|
||||
available: femaleAvailable
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
activityId,
|
||||
activityName: activity.name,
|
||||
sessions: availableSessions
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('getAvailableSessions error:', error);
|
||||
return res.status(500).json({ error: 'Error loading available sessions' });
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint để cập nhật booking submission
|
||||
exports.updateBookingSubmission = async (req, res) => {
|
||||
try {
|
||||
const { bookingId } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
// Find the booking
|
||||
let booking = await BookingSubmission.findById(bookingId);
|
||||
|
||||
// If not found as a separate document, try to find it as an embedded booking in Activity.bookingSessions
|
||||
let activityContaining = null;
|
||||
let sessionIndex = -1;
|
||||
let bookingIndex = -1;
|
||||
if (!booking) {
|
||||
activityContaining = await Activity.findOne({ 'bookingSessions.bookingList._id': bookingId });
|
||||
if (!activityContaining) {
|
||||
return res.status(404).json({
|
||||
error: 'Booking not found',
|
||||
message: 'The booking submission does not exist'
|
||||
});
|
||||
}
|
||||
|
||||
// locate the exact session and booking positions
|
||||
for (let si = 0; si < activityContaining.bookingSessions.length; si++) {
|
||||
const bl = activityContaining.bookingSessions[si].bookingList || [];
|
||||
const bi = bl.findIndex(b => b._id && b._id.toString() === bookingId.toString());
|
||||
if (bi !== -1) {
|
||||
sessionIndex = si;
|
||||
bookingIndex = bi;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionIndex === -1 || bookingIndex === -1) {
|
||||
return res.status(404).json({ error: 'Booking not found', message: 'The booking submission does not exist' });
|
||||
}
|
||||
|
||||
booking = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
|
||||
}
|
||||
|
||||
// Define allowed fields to update
|
||||
const allowedUpdates = [
|
||||
'status',
|
||||
'paymentStatus',
|
||||
'paidAmount',
|
||||
'totalAmount',
|
||||
'adminNotes',
|
||||
'emergencyContact',
|
||||
'emergencyPhone',
|
||||
'medicalConditions',
|
||||
'dietaryRestrictions',
|
||||
'specialRequests'
|
||||
];
|
||||
|
||||
// Build update object with only allowed fields
|
||||
const updateFields = {};
|
||||
for (const field of allowedUpdates) {
|
||||
if (updateData[field] !== undefined) {
|
||||
updateFields[field] = updateData[field];
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateFields).length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'No valid fields to update',
|
||||
message: 'Please provide at least one valid field to update'
|
||||
});
|
||||
}
|
||||
|
||||
// If booking is a separate document, update the BookingSubmission collection
|
||||
if (activityContaining === null) {
|
||||
const updatedBooking = await BookingSubmission.findByIdAndUpdate(
|
||||
bookingId,
|
||||
updateFields,
|
||||
{ new: true, runValidators: true }
|
||||
).populate('activityId', 'name price');
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Booking updated successfully',
|
||||
booking: updatedBooking
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise update the embedded booking in the Activity document
|
||||
const currentBooking = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
|
||||
|
||||
// Handle status updates and spot adjustments
|
||||
const newStatus = updateData.status || updateData.bookingStatus;
|
||||
const currentStatus = currentBooking.status || currentBooking.bookingStatus;
|
||||
|
||||
// Apply allowed updates to the embedded booking
|
||||
const allowedEmbeddedUpdates = [
|
||||
'status', 'bookingStatus', 'paymentStatus', 'paidAmount', 'totalAmount', 'adminNotes',
|
||||
'emergencyContact', 'emergencyPhone', 'medicalConditions', 'dietaryRestrictions', 'specialRequests'
|
||||
];
|
||||
|
||||
for (const field of allowedEmbeddedUpdates) {
|
||||
if (updateData[field] !== undefined) {
|
||||
if (field === 'status') {
|
||||
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex].status = updateData.status;
|
||||
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex].bookingStatus = updateData.status;
|
||||
} else {
|
||||
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex][field] = updateData[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If status change affects spots, adjust counts
|
||||
if (newStatus && newStatus !== currentStatus) {
|
||||
const numberOfParticipants = currentBooking.numberOfParticipants || 1;
|
||||
const participantGender = currentBooking.participantGender;
|
||||
|
||||
// If booking is being cancelled, free up spots
|
||||
if (newStatus === 'cancelled' && currentStatus !== 'cancelled') {
|
||||
if (participantGender === 'male') {
|
||||
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedMaleSpots - numberOfParticipants);
|
||||
} else if (participantGender === 'female') {
|
||||
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots - numberOfParticipants);
|
||||
}
|
||||
}
|
||||
|
||||
// If restoring from cancelled, ensure capacity then book spots
|
||||
if (currentStatus === 'cancelled' && newStatus !== 'cancelled') {
|
||||
if (participantGender === 'male') {
|
||||
const totalMale = activityContaining.bookingSessions[sessionIndex].totalMaleSpots;
|
||||
const currentMale = activityContaining.bookingSessions[sessionIndex].bookedMaleSpots;
|
||||
if (currentMale + numberOfParticipants > totalMale) {
|
||||
return res.status(400).json({ error: "Not enough male spots available to restore this booking" });
|
||||
}
|
||||
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots += numberOfParticipants;
|
||||
} else if (participantGender === 'female') {
|
||||
const totalFemale = activityContaining.bookingSessions[sessionIndex].totalFemaleSpots;
|
||||
const currentFemale = activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots;
|
||||
if (currentFemale + numberOfParticipants > totalFemale) {
|
||||
return res.status(400).json({ error: "Not enough female spots available to restore this booking" });
|
||||
}
|
||||
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots += numberOfParticipants;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await activityContaining.save();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Embedded booking updated successfully',
|
||||
booking: activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('updateBookingSubmission error:', error);
|
||||
|
||||
// Handle validation errors
|
||||
if (error.name === 'ValidationError') {
|
||||
const validationErrors = Object.values(error.errors).map(err => err.message);
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
message: validationErrors.join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Server error',
|
||||
message: 'An error occurred while updating the booking'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint để xóa booking submission
|
||||
exports.deleteBookingSubmission = async (req, res) => {
|
||||
try {
|
||||
const { bookingId } = req.params;
|
||||
|
||||
// Find and delete the booking
|
||||
let booking = await BookingSubmission.findById(bookingId);
|
||||
|
||||
// If not found in separate collection, try to delete embedded booking in Activity
|
||||
if (!booking) {
|
||||
const activityContaining = await Activity.findOne({ 'bookingSessions.bookingList._id': bookingId });
|
||||
if (!activityContaining) {
|
||||
return res.status(404).json({
|
||||
error: 'Booking not found',
|
||||
message: 'The booking submission does not exist'
|
||||
});
|
||||
}
|
||||
|
||||
// locate session and booking
|
||||
let sessionIndex = -1;
|
||||
let bookingIndex = -1;
|
||||
for (let si = 0; si < activityContaining.bookingSessions.length; si++) {
|
||||
const bl = activityContaining.bookingSessions[si].bookingList || [];
|
||||
const bi = bl.findIndex(b => b._id && b._id.toString() === bookingId.toString());
|
||||
if (bi !== -1) {
|
||||
sessionIndex = si;
|
||||
bookingIndex = bi;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionIndex === -1 || bookingIndex === -1) {
|
||||
return res.status(404).json({ error: 'Booking not found', message: 'The booking submission does not exist' });
|
||||
}
|
||||
|
||||
const bookingToDelete = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
|
||||
|
||||
// Free up spots if booking is not cancelled
|
||||
if ((bookingToDelete.bookingStatus || bookingToDelete.status) !== 'cancelled') {
|
||||
const numberOfParticipants = bookingToDelete.numberOfParticipants || 1;
|
||||
const participantGender = bookingToDelete.participantGender;
|
||||
|
||||
if (participantGender === 'male') {
|
||||
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedMaleSpots - numberOfParticipants);
|
||||
} else if (participantGender === 'female') {
|
||||
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots - numberOfParticipants);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove booking and save
|
||||
activityContaining.bookingSessions[sessionIndex].bookingList.splice(bookingIndex, 1);
|
||||
await activityContaining.save();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Embedded booking deleted successfully',
|
||||
booking: {
|
||||
id: bookingId,
|
||||
participantName: `${bookingToDelete.participantFirstName} ${bookingToDelete.participantLastName}`,
|
||||
email: bookingToDelete.email
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Store info for session spot adjustment
|
||||
const { activityId, sessionId, participantGender, numberOfParticipants } = booking;
|
||||
|
||||
// Delete the booking
|
||||
await BookingSubmission.findByIdAndDelete(bookingId);
|
||||
|
||||
// Update session booked spots (decrease the count)
|
||||
if (booking.status !== 'cancelled') {
|
||||
const updateField = participantGender === 'male'
|
||||
? 'bookingSessions.$.bookedMaleSpots'
|
||||
: 'bookingSessions.$.bookedFemaleSpots';
|
||||
|
||||
await Activity.updateOne(
|
||||
{ _id: activityId, 'bookingSessions.sessionId': sessionId },
|
||||
{ $inc: { [updateField]: -numberOfParticipants } }
|
||||
);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Booking deleted successfully',
|
||||
booking: {
|
||||
id: bookingId,
|
||||
participantName: `${booking.participantFirstName} ${booking.participantLastName}`,
|
||||
email: booking.email
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('deleteBookingSubmission error:', error);
|
||||
return res.status(500).json({
|
||||
error: 'Server error',
|
||||
message: 'An error occurred while deleting the booking'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,169 +1,125 @@
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const Footer = require("../models/footer");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
|
||||
// GET /api/footer - Public API cho website và CMS load dữ liệu
|
||||
exports.getFooter = async (req, res) => {
|
||||
try {
|
||||
const footer = await Footer.getSingle();
|
||||
const processedData = addBaseUrlToImages(footer.toObject());
|
||||
// Helpers
|
||||
const getFooterDoc = async () => Footer.findOne().sort({ updatedAt: -1 });
|
||||
const getFooterData = async () => (await getFooterDoc())?.toObject() || {};
|
||||
|
||||
res.json(processedData);
|
||||
} catch (error) {
|
||||
console.error("Error getting footer:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to get footer data",
|
||||
});
|
||||
}
|
||||
};
|
||||
const getDefaultFooterData = () => ({
|
||||
brand: {
|
||||
logo: {
|
||||
image: "",
|
||||
href: "/",
|
||||
},
|
||||
description: "",
|
||||
social: [],
|
||||
},
|
||||
explore: {
|
||||
heading: "",
|
||||
links: [],
|
||||
},
|
||||
contact: {
|
||||
heading: "",
|
||||
address: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
},
|
||||
newsletter: {
|
||||
heading: "",
|
||||
description: "",
|
||||
placeholder: "",
|
||||
buttonText: "",
|
||||
},
|
||||
bottom: {
|
||||
copyright: "",
|
||||
links: [],
|
||||
},
|
||||
});
|
||||
|
||||
// PUT /api/admin/footer - Update toàn bộ footer cho CMS
|
||||
exports.updateFooter = async (req, res) => {
|
||||
try {
|
||||
let updateData = req.body;
|
||||
|
||||
console.log("=== FOOTER UPDATE REQUEST RECEIVED ===");
|
||||
console.log("Raw body:", JSON.stringify(req.body, null, 2));
|
||||
|
||||
// Nếu có footerJson, parse nó (tương tự Header logic)
|
||||
if (updateData.footerJson && typeof updateData.footerJson === "string") {
|
||||
try {
|
||||
const parsedData = JSON.parse(updateData.footerJson);
|
||||
console.log("✓ Parsed footerJson successfully:", parsedData);
|
||||
updateData = parsedData;
|
||||
} catch (e) {
|
||||
console.error("✗ Error parsing footerJson:", e.message);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Invalid JSON in footerJson: " + e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Lấy footer hiện tại hoặc tạo mới (giống Header logic)
|
||||
let footer = await Footer.findOne();
|
||||
|
||||
if (!footer) {
|
||||
console.log("No existing footer found, creating new one");
|
||||
footer = new Footer(updateData);
|
||||
await footer.save();
|
||||
console.log("✓ Footer created:", footer._id);
|
||||
} else {
|
||||
console.log("✓ Found existing footer:", footer._id);
|
||||
// Merge với dữ liệu cũ thay vì overwrite (giống Header)
|
||||
Object.assign(footer, updateData);
|
||||
await footer.save();
|
||||
console.log("✓ Footer updated successfully");
|
||||
}
|
||||
|
||||
const processedData = addBaseUrlToImages(footer.toObject());
|
||||
|
||||
console.log("Updated footer data:", JSON.stringify(processedData, null, 2));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Footer updated successfully",
|
||||
data: processedData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("✗ Error updating footer:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to update footer: " + error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Render admin view (giữ lại cho UI hiện tại)
|
||||
// Admin: render management view with data from MongoDB
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const data = await Footer.getSingle();
|
||||
const processedData = addBaseUrlToImages(data.toObject());
|
||||
let data = await getFooterData();
|
||||
const defaults = getDefaultFooterData();
|
||||
|
||||
res.render("admin/footer/index", {
|
||||
title: "Footer Management",
|
||||
data: processedData,
|
||||
// Merge defaults for any missing sections
|
||||
const sections = Object.keys(defaults);
|
||||
sections.forEach((s) => {
|
||||
data[s] = data[s] || defaults[s];
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in footer index:", error);
|
||||
req.flash("error_msg", "An error occurred while loading the page");
|
||||
res.redirect("/admin/dashboard");
|
||||
|
||||
return res.render("admin/footer/index", {
|
||||
layout: "layouts/main",
|
||||
title: "Footer Management",
|
||||
data,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Footer index error:", err);
|
||||
req.flash("error_msg", "Error loading footer data");
|
||||
return req.session.save(() => res.redirect("/admin/dashboard"));
|
||||
}
|
||||
};
|
||||
|
||||
// Update method cho form hiện tại (giống Header pattern)
|
||||
// Admin: parse req.body sections and save to MongoDB
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
let updateData = req.body;
|
||||
const sections = ["brand", "explore", "contact", "newsletter", "bottom"];
|
||||
|
||||
console.log("=== FOOTER FORM UPDATE REQUEST RECEIVED ===");
|
||||
console.log("Raw body:", JSON.stringify(req.body, null, 2));
|
||||
let doc = await getFooterDoc();
|
||||
|
||||
// Nếu có footerJson, parse nó (giống Header logic)
|
||||
if (updateData.footerJson && typeof updateData.footerJson === "string") {
|
||||
if (!doc) {
|
||||
doc = new Footer({});
|
||||
}
|
||||
|
||||
let hasChanges = false;
|
||||
|
||||
for (const section of sections) {
|
||||
if (req.body[section]) {
|
||||
try {
|
||||
const parsedData = JSON.parse(updateData.footerJson);
|
||||
console.log("✓ Parsed footerJson successfully:", parsedData);
|
||||
updateData = parsedData;
|
||||
const payload = JSON.parse(req.body[section]);
|
||||
doc[section] = payload;
|
||||
doc.markModified(section);
|
||||
hasChanges = true;
|
||||
} catch (e) {
|
||||
console.error("✗ Error parsing footerJson:", e.message);
|
||||
req.flash("error_msg", "Invalid JSON in footerJson: " + e.message);
|
||||
return res.redirect("/admin/footer");
|
||||
console.error(`Invalid JSON for ${section}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lấy footer hiện tại hoặc tạo mới (giống Header)
|
||||
let footer = await Footer.findOne();
|
||||
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeData = footer
|
||||
? JSON.parse(JSON.stringify(footer.toObject()))
|
||||
: {};
|
||||
|
||||
if (!footer) {
|
||||
console.log("No existing footer found, creating new one");
|
||||
footer = new Footer(updateData);
|
||||
await footer.save();
|
||||
console.log("✓ Footer created:", footer._id);
|
||||
req.flash("success_msg", "Footer created successfully");
|
||||
} else {
|
||||
console.log("✓ Found existing footer:", footer._id);
|
||||
// Merge với dữ liệu cũ (giống Header)
|
||||
Object.assign(footer, updateData);
|
||||
await footer.save();
|
||||
|
||||
// ✅ Capture AFTER state
|
||||
const afterData = JSON.parse(JSON.stringify(footer.toObject()));
|
||||
|
||||
// ✅ AUDIT LOGGING - Footer Updated
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Footer",
|
||||
documentId: footer._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_FOOTER,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
if (!hasChanges) {
|
||||
req.flash("info_msg", "No changes were made");
|
||||
return req.session.save(() => res.redirect("/admin/footer"));
|
||||
}
|
||||
|
||||
console.log("✓ Footer updated successfully");
|
||||
req.flash("success_msg", "Footer updated successfully");
|
||||
}
|
||||
await doc.save();
|
||||
|
||||
const activeTab = req.body.activeTab || "about";
|
||||
res.redirect(`/admin/footer?activeTab=${activeTab}`);
|
||||
req.flash("success_msg", "Footer configuration has been updated!");
|
||||
return req.session.save(() => res.redirect("/admin/footer"));
|
||||
} catch (err) {
|
||||
console.error("✗ Error updating footer:", err);
|
||||
req.flash("error_msg", err.message || "Error updating footer");
|
||||
res.redirect("/admin/footer");
|
||||
console.error("Footer update error:", err);
|
||||
req.flash("error_msg", `Update error: ${err.message}`);
|
||||
return req.session.save(() => res.redirect("/admin/footer"));
|
||||
}
|
||||
};
|
||||
|
||||
// Legacy API endpoints (giữ lại cho tương thích)
|
||||
exports.api = exports.getFooter;
|
||||
exports.getFooterData = exports.getFooter;
|
||||
// Public API: return JSON data for frontend
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
let data = await getFooterData();
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
data = getDefaultFooterData();
|
||||
}
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
|
||||
return res.json(addBaseUrlToImages(data, baseUrl));
|
||||
} catch (err) {
|
||||
console.error("Footer API error:", err);
|
||||
return res.status(500).json({ error: "Error loading footer data" });
|
||||
}
|
||||
};
|
||||
|
||||
// Aliases for routes/index.js compatibility
|
||||
exports.getFooter = exports.api;
|
||||
exports.updateFooter = exports.update;
|
||||
|
||||
@@ -33,29 +33,23 @@ exports.index = async (req, res) => {
|
||||
// Prepare data for view
|
||||
const data = header
|
||||
? {
|
||||
topbar: {
|
||||
contactInfo: {
|
||||
phone: header.top?.phone || "",
|
||||
email: header.top?.email || "",
|
||||
location: header.top?.location || "",
|
||||
},
|
||||
socialLinks: header.top?.socialLinks || [],
|
||||
},
|
||||
logo: header.logo?.light || "",
|
||||
signInButton: header.signInButton || {
|
||||
label: "Sign In",
|
||||
href: "/signin",
|
||||
},
|
||||
ctaButton: header.ctaButton || {
|
||||
label: "Request Info",
|
||||
href: "/request",
|
||||
},
|
||||
}
|
||||
: {
|
||||
topbar: {
|
||||
contactInfo: {
|
||||
phone: "",
|
||||
email: "",
|
||||
location: "",
|
||||
},
|
||||
socialLinks: [],
|
||||
},
|
||||
logo: "",
|
||||
signInButton: { label: "Sign In", href: "/signin" },
|
||||
ctaButton: { label: "Request Info", href: "/request" },
|
||||
};
|
||||
|
||||
const activeTab = req.query.tab || "topbar";
|
||||
const activeTab = req.query.tab || "logo";
|
||||
|
||||
// Always fetch menu items to ensure they are available even if the user
|
||||
// switches tabs client-side
|
||||
@@ -123,14 +117,12 @@ exports.show = async (req, res) => {
|
||||
// Admin: Create header
|
||||
exports.store = async (req, res) => {
|
||||
try {
|
||||
const { top, offcanvas, menu, logo, ctaButton, status, order } = req.body;
|
||||
const { logo, signInButton, ctaButton, status, order } = req.body;
|
||||
|
||||
const header = new Header({
|
||||
top,
|
||||
offcanvas,
|
||||
menu,
|
||||
logo,
|
||||
ctaButton,
|
||||
logo: logo ? { light: logo } : {},
|
||||
signInButton: signInButton || { label: "Sign In", href: "/signin" },
|
||||
ctaButton: ctaButton || {},
|
||||
status: status || "active",
|
||||
order: order || 1,
|
||||
});
|
||||
@@ -152,129 +144,81 @@ exports.store = async (req, res) => {
|
||||
// Admin: Update header
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
let { top, topbarJson, offcanvas, menu, logo, ctaButton, status, order } =
|
||||
req.body;
|
||||
const { logo, signInButton, ctaButton, status, order } = req.body;
|
||||
|
||||
console.log("=== UPDATE REQUEST RECEIVED ===");
|
||||
console.log("Raw body:", JSON.stringify(req.body, null, 2));
|
||||
console.log("topbarJson type:", typeof topbarJson);
|
||||
console.log("topbarJson value:", topbarJson);
|
||||
|
||||
// Nếu có topbarJson, parse nó
|
||||
if (topbarJson && typeof topbarJson === "string") {
|
||||
try {
|
||||
const parsedData = JSON.parse(topbarJson);
|
||||
console.log("✓ Parsed topbarJson successfully:", parsedData);
|
||||
// Chuyển đổi từ topbarData sang top format
|
||||
top = {
|
||||
phone: parsedData.contactInfo?.phone || "",
|
||||
email: parsedData.contactInfo?.email || "",
|
||||
location: parsedData.contactInfo?.location || "",
|
||||
socialLinks: parsedData.socialLinks || [],
|
||||
};
|
||||
|
||||
if (logo) {
|
||||
updateData.logo = logoData;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Preparing to update header with data:",
|
||||
JSON.stringify(updateData, null, 2),
|
||||
);
|
||||
|
||||
const updatedHeader = await Header.findByIdAndUpdate(
|
||||
headerId,
|
||||
updateData,
|
||||
{ new: true, runValidators: true },
|
||||
);
|
||||
|
||||
if (!updatedHeader) {
|
||||
console.error("✗ Header not found with ID:", headerId);
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "Header not found",
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Header updated successfully",
|
||||
data: updatedHeader,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("✗ Error updating header:", error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Nếu không có id, tìm header đầu tiên hoặc tạo mới
|
||||
let headerId = req.params.id;
|
||||
|
||||
if (!headerId) {
|
||||
// Tìm header đầu tiên
|
||||
// Upsert logic: find existing header or prepare to create new one
|
||||
let header = await Header.findOne().sort({ order: 1 });
|
||||
let headerId = header?._id;
|
||||
|
||||
// Capture BEFORE state for audit logging
|
||||
const beforeData = header
|
||||
? JSON.parse(JSON.stringify(header.toObject()))
|
||||
: {};
|
||||
|
||||
if (!header) {
|
||||
console.log("No existing header found, creating new one");
|
||||
// Tạo header mới nếu chưa có
|
||||
// Create new header document
|
||||
header = new Header({
|
||||
top,
|
||||
offcanvas,
|
||||
menu,
|
||||
logo: logo ? { light: logo } : {},
|
||||
ctaButton,
|
||||
signInButton: signInButton || { label: "Sign In", href: "/signin" },
|
||||
ctaButton: ctaButton || {},
|
||||
status: status || "active",
|
||||
order: order || 1,
|
||||
});
|
||||
await header.save();
|
||||
console.log("✓ Header created:", header._id);
|
||||
|
||||
// Audit log for creation
|
||||
const afterData = JSON.parse(JSON.stringify(header.toObject()));
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Header",
|
||||
documentId: header._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_HEADER,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "Header created successfully",
|
||||
data: header,
|
||||
});
|
||||
}
|
||||
headerId = header._id;
|
||||
console.log("✓ Found existing header:", headerId);
|
||||
}
|
||||
|
||||
// Chuẩn bị dữ liệu logo - merge với dữ liệu cũ
|
||||
let logoData = {};
|
||||
// Prepare logo data - merge with existing data
|
||||
let logoData = header.logo || {};
|
||||
if (logo) {
|
||||
// Nếu có logo mới, lấy dữ liệu cũ và update light
|
||||
const existingHeader = await Header.findById(headerId);
|
||||
logoData = {
|
||||
light: logo,
|
||||
dark: existingHeader?.logo?.dark || "",
|
||||
alt: existingHeader?.logo?.alt || "",
|
||||
dark: header.logo?.dark || "",
|
||||
alt: header.logo?.alt || "",
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
top,
|
||||
offcanvas,
|
||||
menu,
|
||||
ctaButton,
|
||||
status,
|
||||
order,
|
||||
logo: logoData,
|
||||
signInButton: signInButton || header.signInButton,
|
||||
ctaButton: ctaButton || header.ctaButton,
|
||||
};
|
||||
|
||||
if (logo) {
|
||||
updateData.logo = logoData;
|
||||
}
|
||||
if (status !== undefined) updateData.status = status;
|
||||
if (order !== undefined) updateData.order = order;
|
||||
|
||||
console.log(
|
||||
"Preparing to update header with data:",
|
||||
JSON.stringify(updateData, null, 2),
|
||||
);
|
||||
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeHeader = await Header.findById(headerId);
|
||||
const beforeData = beforeHeader
|
||||
? JSON.parse(JSON.stringify(beforeHeader.toObject()))
|
||||
: {};
|
||||
|
||||
// Update existing header
|
||||
const updatedHeader = await Header.findByIdAndUpdate(headerId, updateData, {
|
||||
new: true,
|
||||
runValidators: true,
|
||||
@@ -288,10 +232,10 @@ exports.update = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ Capture AFTER state
|
||||
// Capture AFTER state for audit logging
|
||||
const afterData = JSON.parse(JSON.stringify(updatedHeader.toObject()));
|
||||
|
||||
// ✅ AUDIT LOGGING - Header Updated
|
||||
// Audit logging - Header Updated
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
@@ -384,23 +328,41 @@ exports.destroy = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Public API: Get active header
|
||||
// Public API: Get header (returns header regardless of status)
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const header = await Header.findOne({ status: "active" }).sort({
|
||||
const header = await Header.findOne().sort({
|
||||
order: 1,
|
||||
});
|
||||
|
||||
if (!header) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "No active header found",
|
||||
message: "No header found",
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = (process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
|
||||
const rawLogoPath = header.logo?.light || '';
|
||||
const logoImage = rawLogoPath.startsWith('http') ? rawLogoPath : `${baseUrl}${rawLogoPath}`;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: header,
|
||||
data: {
|
||||
logo: {
|
||||
image: logoImage,
|
||||
href: "/",
|
||||
},
|
||||
signInButton: {
|
||||
label: header.signInButton?.label || "Sign In",
|
||||
href: header.signInButton?.href || "/signin",
|
||||
},
|
||||
ctaButton: {
|
||||
label: header.ctaButton?.label || "Request Info",
|
||||
href: header.ctaButton?.href || "/request",
|
||||
},
|
||||
status: header.status || "active",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
@@ -410,28 +372,3 @@ exports.api = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Public API: Get menu tree structure
|
||||
exports.getMenuTreeAPI = async (req, res) => {
|
||||
try {
|
||||
const header = await Header.findOne({ status: "active" }).sort({
|
||||
order: 1,
|
||||
});
|
||||
|
||||
if (!header || !header.menu) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "No active menu found",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: header.menu,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ const buildMenuTree = (items, parentId = null, isPublic = false) => {
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
type: item.type,
|
||||
status: item.status || 'active',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,7 +197,7 @@ exports.reorder = async (req, res) => {
|
||||
// Public API: Get active menu as clean tree
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const items = await HeaderMenu.find({ status: "active" }).sort({ order: 1 });
|
||||
const items = await HeaderMenu.find().sort({ order: 1 });
|
||||
const tree = buildMenuTree(items, null, true);
|
||||
res.json({ success: true, data: tree });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,99 +1,63 @@
|
||||
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
|
||||
const Home = require("../models/home");
|
||||
const Blog = require("../models/blog");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
|
||||
// Các hàm hỗ trợ
|
||||
// Helpers
|
||||
const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 });
|
||||
const getHomeData = async () => (await getHomeDoc())?.toObject() || {};
|
||||
|
||||
const getDefaultHomeData = () => ({
|
||||
hero: {
|
||||
backgroundImage: "",
|
||||
slides: [],
|
||||
badge: "",
|
||||
title: "",
|
||||
subtitle: "",
|
||||
description: "",
|
||||
heroImage: "",
|
||||
videoUrl: "",
|
||||
primaryButton: {},
|
||||
secondaryButton: {},
|
||||
searchPlaceholder: "",
|
||||
buttonLabel: "",
|
||||
image: "",
|
||||
imageAlt: "",
|
||||
floatingBadge: {
|
||||
icon: "",
|
||||
value: "",
|
||||
label: "",
|
||||
},
|
||||
whyChooseUs: {
|
||||
heading: "",
|
||||
subheading: "",
|
||||
},
|
||||
quickLinks: [],
|
||||
valueProp: {
|
||||
badge: "",
|
||||
title: "",
|
||||
description: "",
|
||||
highlightWord: "",
|
||||
mainImage: "",
|
||||
secondaryImage: "",
|
||||
items: [],
|
||||
features: [],
|
||||
ctaButton: {},
|
||||
stats: [],
|
||||
},
|
||||
visaSolutions: { heading: "", subheading: "", items: [] },
|
||||
visaCountries: {
|
||||
programs: {
|
||||
heading: "",
|
||||
subheading: "",
|
||||
description: "",
|
||||
countries: [],
|
||||
ctaButton: {},
|
||||
},
|
||||
testimonials: {
|
||||
heading: "",
|
||||
subheading: "",
|
||||
videoUrl: "",
|
||||
videoThumbnail: "",
|
||||
items: [],
|
||||
},
|
||||
videoGallery: { heading: "", videoUrl: "", thumbnail: "" },
|
||||
faq: {
|
||||
requestInfo: {
|
||||
heading: "",
|
||||
subheading: "",
|
||||
description: "",
|
||||
ctaButton: {},
|
||||
items: [],
|
||||
},
|
||||
achievements: { heading: "", subheading: "", items: [] },
|
||||
partners: { visaConsultancy: { items: [] }, brands: { items: [] } },
|
||||
blogPreview: {
|
||||
heading: "Latest Insights & Updates",
|
||||
subheading: "Visa Tips & Guides",
|
||||
ctaButton: { label: "View All Articles", href: "/blog" },
|
||||
items: [],
|
||||
selectedBlogIds: [], // Array of manually selected blog IDs
|
||||
phone: "",
|
||||
email: "",
|
||||
programs: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Admin: Xem trang quản lý
|
||||
// Admin: render management view with data from MongoDB
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
let data = await getHomeData();
|
||||
const defaults = getDefaultHomeData();
|
||||
|
||||
// Merge dữ liệu mặc định cho tất cả các phần
|
||||
// Merge defaults for any missing sections
|
||||
const sections = Object.keys(defaults);
|
||||
sections.forEach((s) => {
|
||||
data[s] = data[s] || defaults[s];
|
||||
});
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
|
||||
|
||||
// Lấy tất cả blog để chọn trong CMS
|
||||
const allBlogs = await Blog.find({ status: "published" })
|
||||
.sort({ createdAt: -1 })
|
||||
.lean();
|
||||
|
||||
return res.render("admin/home/index", {
|
||||
layout: "layouts/main",
|
||||
title: "Home Management",
|
||||
data,
|
||||
allBlogs,
|
||||
frontendUrl,
|
||||
backendUrl,
|
||||
getFullImageUrl,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
@@ -104,43 +68,34 @@ exports.index = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Admin: Cập nhật dữ liệu (tập trung vào achievements, partners; các phần khác giữ nguyên nếu không có dữ liệu mới)
|
||||
// Admin: parse req.body sections and save to MongoDB
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const sections = [
|
||||
"hero",
|
||||
"whyChooseUs",
|
||||
"visaSolutions",
|
||||
"visaCountries",
|
||||
"testimonials",
|
||||
"videoGallery",
|
||||
"faq",
|
||||
"achievements",
|
||||
"partners",
|
||||
"blogPreview",
|
||||
"quickLinks",
|
||||
"valueProp",
|
||||
"programs",
|
||||
"requestInfo",
|
||||
];
|
||||
|
||||
let doc = await getHomeDoc();
|
||||
const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {};
|
||||
|
||||
if (!doc) {
|
||||
doc = new Home({});
|
||||
}
|
||||
|
||||
let hasChanges = false;
|
||||
const updatedSections = [];
|
||||
|
||||
for (const section of sections) {
|
||||
if (req.body[section]) {
|
||||
try {
|
||||
const payload = JSON.parse(req.body[section]);
|
||||
// Gán trực tiếp vào doc, Mongoose sẽ tự check schema
|
||||
doc[section] = payload;
|
||||
doc.markModified(section);
|
||||
hasChanges = true;
|
||||
updatedSections.push(section);
|
||||
} catch (e) {
|
||||
console.error(`Invalid JSON for ${section}:`, e);
|
||||
console.error(`Invalid JSON for ${section}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,21 +106,6 @@ exports.update = async (req, res) => {
|
||||
}
|
||||
|
||||
await doc.save();
|
||||
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||
|
||||
// ✅ AUDIT LOGGING - Home Update
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Home",
|
||||
documentId: doc._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_HOME,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
req.flash("success_msg", "Home page configuration has been updated!");
|
||||
return req.session.save(() => res.redirect("/admin/home"));
|
||||
@@ -176,72 +116,15 @@ exports.update = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Public API// API lấy danh sách blog cho CMS
|
||||
exports.apiGetBlogs = async (req, res) => {
|
||||
try {
|
||||
const blogs = await Blog.find({ status: "published" })
|
||||
.sort({ createdAt: -1 })
|
||||
.select("title slug featuredImage author publishedAt")
|
||||
.lean();
|
||||
res.json(blogs);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
};
|
||||
// Public API: return JSON data for frontend
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
let data = await getHomeData();
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
|
||||
// === Xử lý Blog Preview động ===
|
||||
const blogPreview = data.blogPreview || {};
|
||||
let blogs = [];
|
||||
|
||||
// Nếu có chọn blog cụ thể
|
||||
if (blogPreview.selectedBlogIds && blogPreview.selectedBlogIds.length > 0) {
|
||||
blogs = await Blog.find({
|
||||
_id: { $in: blogPreview.selectedBlogIds },
|
||||
status: "published",
|
||||
}).lean();
|
||||
|
||||
// Sắp xếp theo thứ tự đã chọn trong selectedBlogIds
|
||||
blogs.sort((a, b) => {
|
||||
return (
|
||||
blogPreview.selectedBlogIds.indexOf(a._id.toString()) -
|
||||
blogPreview.selectedBlogIds.indexOf(b._id.toString())
|
||||
);
|
||||
});
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
data = getDefaultHomeData();
|
||||
}
|
||||
|
||||
// Nếu không chọn hoặc chọn nhưng không đủ, lấy thêm 3 bài mới nhất (hoặc bù vào)
|
||||
if (blogs.length === 0) {
|
||||
blogs = await Blog.find({ status: "published" })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(3)
|
||||
.lean();
|
||||
}
|
||||
|
||||
// Map dữ liệu blog sang format mà frontend mong đợi
|
||||
blogPreview.items = blogs.map((blog) => ({
|
||||
title: blog.title,
|
||||
excerpt: blog.excerpt,
|
||||
category: blog.category && blog.category[0] ? blog.category[0] : "Visa",
|
||||
date: blog.publishedAt || blog.createdAt,
|
||||
author: {
|
||||
name: blog.author || "Admin",
|
||||
avatar: "", // Frontend đang tự xử lý hoặc dùng logo hệ thống
|
||||
},
|
||||
comments: blog.commentsCount || 0,
|
||||
link: `/blog/${blog.slug}`,
|
||||
thumbnail: blog.featuredImage,
|
||||
}));
|
||||
|
||||
data.blogPreview = blogPreview;
|
||||
// ===============================
|
||||
|
||||
const processed = addBaseUrlToImages(data, baseUrl);
|
||||
return res.json(processed);
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
|
||||
return res.json(addBaseUrlToImages(data, baseUrl));
|
||||
} catch (err) {
|
||||
console.error("Home API error:", err);
|
||||
return res.status(500).json({ error: "Error loading home data" });
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
const Pricing = require("../models/pricing");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
|
||||
// ==================== 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" });
|
||||
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeData = pricing
|
||||
? JSON.parse(JSON.stringify(pricing.toObject()))
|
||||
: {};
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ Capture AFTER state
|
||||
const afterData = JSON.parse(JSON.stringify(pricing.toObject()));
|
||||
|
||||
// ✅ AUDIT LOGGING - Pricing Updated
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Pricing",
|
||||
documentId: pricing._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_PRICING,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,396 +0,0 @@
|
||||
const { getServiceData } = require("../services/service.service");
|
||||
const Service = require("../models/service");
|
||||
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
|
||||
const slugify = require("slugify");
|
||||
|
||||
// Admin page - Service list
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const data = await getServiceData();
|
||||
console.log(data.services.items.image);
|
||||
res.render("admin/service/index", {
|
||||
title: "Service Management",
|
||||
data,
|
||||
layout: "layouts/main",
|
||||
getFullImageUrl, // Truyền helper function vào view
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", "Error loading service data");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Admin page - Service edit
|
||||
exports.edit = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const data = await getServiceData();
|
||||
|
||||
const service = data.services?.items?.find((item) => item.slug === slug);
|
||||
if (!service) {
|
||||
req.flash("error_msg", "Service not found");
|
||||
return res.redirect("/admin/service");
|
||||
}
|
||||
|
||||
res.render("admin/service/edit", {
|
||||
title: `Edit Service - ${service.name}`,
|
||||
service,
|
||||
layout: "layouts/main",
|
||||
getFullImageUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", "Error loading service for editing");
|
||||
res.redirect("/admin/service");
|
||||
}
|
||||
};
|
||||
|
||||
// Update single service
|
||||
exports.updateService = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const currentData = await getServiceData();
|
||||
|
||||
const serviceIndex = currentData.services?.items?.findIndex(
|
||||
(item) => item.slug === slug,
|
||||
);
|
||||
if (serviceIndex === -1) {
|
||||
req.flash("error_msg", "Service not found");
|
||||
return res.redirect("/admin/service");
|
||||
}
|
||||
|
||||
const oldItem = JSON.parse(
|
||||
JSON.stringify(currentData.services.items[serviceIndex]),
|
||||
);
|
||||
|
||||
// Update service data
|
||||
const updatedData = { ...currentData.toObject?.() };
|
||||
updatedData.services.items[serviceIndex] = {
|
||||
...updatedData.services.items[serviceIndex],
|
||||
name: req.body.name,
|
||||
slug: req.body.slug,
|
||||
description: req.body.description,
|
||||
image: req.body.image,
|
||||
layout: req.body.layout,
|
||||
};
|
||||
|
||||
if (currentData._id) {
|
||||
await Service.findByIdAndUpdate(currentData._id, updatedData);
|
||||
} else {
|
||||
await Service.create(updatedData);
|
||||
}
|
||||
const newItem = updatedData.services.items[serviceIndex];
|
||||
|
||||
const changes = diffObject(oldItem, newItem);
|
||||
console.log("USER:", req.session?.user || req.user || "No user found");
|
||||
|
||||
await writeAuditLog({
|
||||
model: "Service",
|
||||
documentId: currentData._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_SERVICE,
|
||||
before: oldItem,
|
||||
after: newItem,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
req.flash("success_msg", "Service updated successfully");
|
||||
res.redirect("/admin/service");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", err.message);
|
||||
res.redirect("/admin/service");
|
||||
}
|
||||
};
|
||||
|
||||
// Admin page - Service details
|
||||
exports.details = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const data = await getServiceData();
|
||||
|
||||
const service = data.services?.items?.find((item) => item.slug === slug);
|
||||
if (!service) {
|
||||
req.flash("error_msg", "Service not found");
|
||||
return res.redirect("/admin/service");
|
||||
}
|
||||
|
||||
res.render("admin/service/details", {
|
||||
title: `Service Details - ${service.name}`,
|
||||
service,
|
||||
layout: "layouts/main",
|
||||
getFullImageUrl, // Truyền helper function vào view
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", "Error loading service details");
|
||||
res.redirect("/admin/service");
|
||||
}
|
||||
};
|
||||
|
||||
// Update service list
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const currentData = await getServiceData();
|
||||
const sections = [
|
||||
"pageTitle",
|
||||
"services",
|
||||
"destinations",
|
||||
"visas",
|
||||
"reviews",
|
||||
];
|
||||
|
||||
let updatedData = { ...currentData.toObject?.() };
|
||||
let hasChanges = false;
|
||||
|
||||
sections.forEach((section) => {
|
||||
if (!req.body[section]) return;
|
||||
|
||||
const newData = JSON.parse(req.body[section]);
|
||||
if (JSON.stringify(newData) !== JSON.stringify(currentData[section])) {
|
||||
updatedData[section] = newData;
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasChanges) {
|
||||
req.flash("info_msg", "No changes were made");
|
||||
return res.redirect("/admin/service");
|
||||
}
|
||||
|
||||
if (currentData._id) {
|
||||
await Service.findByIdAndUpdate(currentData._id, updatedData);
|
||||
} else {
|
||||
await Service.create(updatedData);
|
||||
}
|
||||
|
||||
req.flash("success_msg", "Service updated successfully");
|
||||
res.redirect("/admin/service");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", err.message);
|
||||
res.redirect("/admin/service");
|
||||
}
|
||||
};
|
||||
|
||||
// Update service details
|
||||
exports.updateDetails = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const currentData = await getServiceData();
|
||||
|
||||
const serviceIndex = currentData.services?.items?.findIndex(
|
||||
(item) => item.slug === slug,
|
||||
);
|
||||
if (serviceIndex === -1) {
|
||||
req.flash("error_msg", "Service not found");
|
||||
return res.redirect("/admin/service");
|
||||
}
|
||||
const beforeDetails = JSON.parse(
|
||||
JSON.stringify(currentData.services.items[serviceIndex].details || {}),
|
||||
);
|
||||
// Parse features and FAQ from JSON strings
|
||||
const features = req.body.features ? JSON.parse(req.body.features) : [];
|
||||
const faq = req.body.faq ? JSON.parse(req.body.faq) : [];
|
||||
|
||||
// Update service details
|
||||
const updatedData = { ...currentData.toObject?.() };
|
||||
const updatedDetails = {
|
||||
title: req.body.title,
|
||||
description: req.body.description,
|
||||
mainImage: req.body.mainImage,
|
||||
overviewTitle: req.body.overviewTitle,
|
||||
overviewDescription: req.body.overviewDescription,
|
||||
additionalDescription: req.body.additionalDescription,
|
||||
keyFeaturesTitle: req.body.keyFeaturesTitle,
|
||||
keyFeaturesImage: req.body.keyFeaturesImage,
|
||||
features,
|
||||
faqTitle: req.body.faqTitle,
|
||||
faqImage: req.body.faqImage,
|
||||
faq,
|
||||
};
|
||||
|
||||
updatedData.services.items[serviceIndex].details = updatedDetails;
|
||||
if (currentData._id) {
|
||||
await Service.findByIdAndUpdate(currentData._id, updatedData);
|
||||
} else {
|
||||
await Service.create(updatedData);
|
||||
}
|
||||
const changes = diffObject(beforeDetails, updatedDetails);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Service",
|
||||
documentId: currentData._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_SERVICE_DETAILS,
|
||||
before: beforeDetails,
|
||||
after: updatedDetails,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
req.flash("success_msg", "Service details updated successfully");
|
||||
res.redirect(`/admin/service/${slug}/details`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", err.message);
|
||||
res.redirect("/admin/service");
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const serviceData = await getServiceData();
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
|
||||
const processedData = addBaseUrlToImages(serviceData, baseUrl);
|
||||
res.json(processedData);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Error loading service data" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get service details by slug - API endpoint
|
||||
*/
|
||||
exports.getServiceBySlug = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
|
||||
const serviceDoc = await Service.findOne().lean();
|
||||
|
||||
if (!serviceDoc) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "Service data not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Find service by slug
|
||||
const service = serviceDoc.services?.items?.find(
|
||||
(item) => item.slug === slug,
|
||||
);
|
||||
|
||||
if (!service) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: `Service with slug '${slug}' not found`,
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
|
||||
// Return service details in the expected format
|
||||
const responseData = {
|
||||
pageTitle: serviceDoc.pageTitle,
|
||||
breadcrumb: {
|
||||
...serviceDoc.breadcrumb,
|
||||
title: "Service Details",
|
||||
items: [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Services", href: "/services" },
|
||||
{ label: service.name, href: `/services/${slug}` },
|
||||
],
|
||||
},
|
||||
serviceDetails: {
|
||||
content: service.details,
|
||||
keyFeatures: {
|
||||
title: service.details.keyFeaturesTitle || "Key Features",
|
||||
sideImage: service.details.keyFeaturesImage || "img/default.jpg",
|
||||
items: service.details.features || [],
|
||||
},
|
||||
faq: {
|
||||
title: service.details.faqTitle || "Frequently Asked Questions",
|
||||
sideImage: service.details.faqImage || "img/default.jpg",
|
||||
items: service.details.faq || [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const processedData = addBaseUrlToImages(responseData, baseUrl);
|
||||
res.json(processedData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching service by slug:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate slug from text - API endpoint
|
||||
*/
|
||||
exports.generateSlug = async (req, res) => {
|
||||
try {
|
||||
const { text } = req.body;
|
||||
|
||||
if (!text || typeof text !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Text is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate slug using slugify library with Vietnamese support
|
||||
const slug = slugify(text, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
locale: "vi",
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
slug: slug,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating slug:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all service slugs - API endpoint
|
||||
*/
|
||||
exports.getServiceSlugs = async (req, res) => {
|
||||
try {
|
||||
const serviceDoc = await Service.findOne().lean();
|
||||
|
||||
if (!serviceDoc?.services?.items) {
|
||||
return res.json({
|
||||
success: true,
|
||||
slugs: [],
|
||||
});
|
||||
}
|
||||
|
||||
const slugs = serviceDoc.services.items.map((item) => ({
|
||||
slug: item.slug,
|
||||
name: item.name,
|
||||
id: item.id,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
slugs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching service slugs:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,290 +0,0 @@
|
||||
const Travel = require("../models/travel");
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
|
||||
/**
|
||||
* Hàm Helper: Trích xuất ID YouTube từ nhiều định dạng link khác nhau
|
||||
*/
|
||||
function extractYouTubeId(url) {
|
||||
if (!url || typeof url !== "string") return null;
|
||||
// Hỗ trợ: watch?v=, embed/, youtu.be/, shorts/
|
||||
const regex =
|
||||
/(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/;
|
||||
const match = url.match(regex);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hàm Helper: Làm sạch danh sách blocks của Editor.js
|
||||
* Loại bỏ duplicate video (vừa embed vừa text link) và paragraph rỗng
|
||||
*/
|
||||
function sanitizeContentBlocks(blocks) {
|
||||
if (!blocks || !Array.isArray(blocks)) return [];
|
||||
|
||||
const seenVideoIds = new Set();
|
||||
|
||||
// Bước 1: Duyệt qua để chuẩn hóa block embed và lấy danh sách ID video
|
||||
const processedBlocks = blocks.map((block) => {
|
||||
if (block.type === "embed") {
|
||||
const url = block.data.source || block.data.embed || "";
|
||||
const videoId = extractYouTubeId(url);
|
||||
if (videoId) {
|
||||
seenVideoIds.add(videoId);
|
||||
// Cập nhật lại data chuẩn cho Editor.js
|
||||
block.data.embed = `https://www.youtube.com/embed/${videoId}`;
|
||||
block.data.source = url;
|
||||
block.data.videoId = videoId;
|
||||
block.data.service = "youtube";
|
||||
}
|
||||
}
|
||||
return block;
|
||||
});
|
||||
|
||||
// Bước 2: Lọc bỏ paragraph rác
|
||||
return processedBlocks.filter((block) => {
|
||||
if (block.type === "paragraph") {
|
||||
const text = (block.data?.text || "").trim();
|
||||
|
||||
// Xóa paragraph rỗng
|
||||
if (text === "" || text === "<br>" || text === " ") return false;
|
||||
|
||||
// Xóa paragraph nếu nó chỉ chứa 1 link YouTube đã được embed ở trên
|
||||
const videoIdInText = extractYouTubeId(text);
|
||||
if (videoIdInText && seenVideoIds.has(videoIdInText)) {
|
||||
console.log(
|
||||
`[Sanitizer] Removed duplicate text link for video: ${videoIdInText}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// GET: Show travel editor
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const travel = await Travel.findOne();
|
||||
|
||||
if (!travel) {
|
||||
return res.render("admin/travel/index", {
|
||||
title: "Travel Management",
|
||||
data: {
|
||||
page: {
|
||||
title: "Travel Information",
|
||||
description: "",
|
||||
metadata: { title: "", description: "" },
|
||||
},
|
||||
hero: { title: "Travel Information", backgroundImage: "" },
|
||||
content: { blocks: [] },
|
||||
enableScrollspy: false,
|
||||
},
|
||||
message: "No travel data found. Please run migration first.",
|
||||
});
|
||||
}
|
||||
|
||||
res.render("admin/travel/index", {
|
||||
title: "Travel Management",
|
||||
data: travel,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error loading travel page:", error);
|
||||
res.status(500).send("Error loading travel page");
|
||||
}
|
||||
};
|
||||
|
||||
// POST: Update travel information
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const { page, hero, content, enableScrollspy } = req.body;
|
||||
|
||||
// Get current data for before state
|
||||
const currentTravel = await Travel.findOne();
|
||||
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeData = currentTravel
|
||||
? JSON.parse(
|
||||
JSON.stringify(
|
||||
currentTravel.toObject ? currentTravel.toObject() : currentTravel,
|
||||
),
|
||||
)
|
||||
: {};
|
||||
|
||||
const updateData = {};
|
||||
|
||||
if (page) updateData.page = JSON.parse(page);
|
||||
if (hero) updateData.hero = JSON.parse(hero);
|
||||
|
||||
if (content) {
|
||||
let contentObj = JSON.parse(content);
|
||||
// Áp dụng bộ lọc dọn dẹp nội dung
|
||||
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
|
||||
updateData.content = contentObj;
|
||||
}
|
||||
|
||||
if (enableScrollspy !== undefined) {
|
||||
updateData.enableScrollspy =
|
||||
enableScrollspy === "true" || enableScrollspy === true;
|
||||
}
|
||||
|
||||
const updatedTravel = await Travel.findOneAndUpdate({}, updateData, {
|
||||
upsert: true,
|
||||
new: true,
|
||||
});
|
||||
|
||||
// ✅ Capture AFTER state
|
||||
const afterData = JSON.parse(
|
||||
JSON.stringify(
|
||||
updatedTravel.toObject ? updatedTravel.toObject() : updatedTravel,
|
||||
),
|
||||
);
|
||||
|
||||
// ✅ AUDIT LOGGING - Travel Updated
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Travel",
|
||||
documentId: updatedTravel._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_TRAVEL,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
req.flash(
|
||||
"success",
|
||||
"Travel information updated and sanitized successfully",
|
||||
);
|
||||
res.redirect("/admin/travel");
|
||||
} catch (error) {
|
||||
console.error("Error updating travel:", error);
|
||||
req.flash("error", "Error updating travel information");
|
||||
res.redirect("/admin/travel");
|
||||
}
|
||||
};
|
||||
|
||||
// GET: Travel data API (Sử dụng cho Frontend/Public)
|
||||
exports.api = exports.getTravelData = async (req, res) => {
|
||||
try {
|
||||
const travel = await Travel.findOne();
|
||||
if (!travel) {
|
||||
return res.status(404).json({ error: "Travel data not found" });
|
||||
}
|
||||
|
||||
const travelObj = travel.toObject();
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
const processed = addBaseUrlToImages(travelObj, baseUrl);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
hero: processed.hero,
|
||||
page: processed.page,
|
||||
content: processed.content,
|
||||
enableScrollspy: processed.enableScrollspy,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching travel data:", error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: Preview travel
|
||||
exports.preview = async (req, res) => {
|
||||
try {
|
||||
const { content, pageTitle, heroTitle, heroBackgroundImage, pageYear } =
|
||||
req.body;
|
||||
|
||||
// Preview cũng cần được sanitize để hiển thị đúng thực tế khi lưu
|
||||
let contentObj = JSON.parse(content);
|
||||
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
|
||||
|
||||
const previewData = {
|
||||
page: {
|
||||
title: pageTitle || "Travel Information",
|
||||
year: pageYear || "",
|
||||
},
|
||||
hero: {
|
||||
title: heroTitle || "Travel Information",
|
||||
backgroundImage: heroBackgroundImage || "",
|
||||
},
|
||||
content: contentObj,
|
||||
enableScrollspy: false,
|
||||
};
|
||||
|
||||
res.render("page/travel", {
|
||||
title: "Travel Preview",
|
||||
data: previewData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating preview:", error);
|
||||
res.status(500).send("Error generating preview");
|
||||
}
|
||||
};
|
||||
|
||||
// GET: Seed/Import from JSON
|
||||
exports.seed = async (req, res) => {
|
||||
try {
|
||||
const jsonPath = path.join(__dirname, "../data/travel.json");
|
||||
const jsonData = await fs.readFile(jsonPath, "utf-8");
|
||||
const jsonTravelData = JSON.parse(jsonData);
|
||||
|
||||
let contentBlocks = [];
|
||||
|
||||
// Trường hợp JSON đã có định dạng bài viết (blog format)
|
||||
if (
|
||||
Array.isArray(jsonTravelData.posts) &&
|
||||
jsonTravelData.posts.length > 0
|
||||
) {
|
||||
const firstPost = jsonTravelData.posts[0];
|
||||
contentBlocks =
|
||||
firstPost.content && firstPost.content.blocks
|
||||
? firstPost.content.blocks
|
||||
: [];
|
||||
}
|
||||
// Trường hợp format cũ (legacy)
|
||||
else {
|
||||
// ... (Logic chuyển đổi legacy format giữ nguyên nhưng bọc qua sanitize)
|
||||
// Ví dụ: push các header, paragraph từ locations vào contentBlocks
|
||||
}
|
||||
|
||||
// Luôn làm sạch dữ liệu trước khi seed vào DB
|
||||
const cleanedBlocks = sanitizeContentBlocks(contentBlocks);
|
||||
|
||||
const travelData = {
|
||||
page: {
|
||||
title:
|
||||
jsonTravelData.page?.title || "Go and Grow Camp Travel Information",
|
||||
year: jsonTravelData.page?.year || "",
|
||||
metadata: {
|
||||
title: "Travel Guide - Go and Grow Camp",
|
||||
description:
|
||||
"Everything you need to know about traveling to our camps",
|
||||
},
|
||||
},
|
||||
hero: {
|
||||
title: jsonTravelData.hero?.title || "Travel Information",
|
||||
backgroundImage: jsonTravelData.hero?.backgroundImage || "",
|
||||
},
|
||||
content: { blocks: cleanedBlocks },
|
||||
enableScrollspy: true,
|
||||
};
|
||||
|
||||
await Travel.findOneAndUpdate({}, travelData, { upsert: true, new: true });
|
||||
|
||||
req.flash("success", "Travel data seeded and sanitized successfully");
|
||||
res.redirect("/admin/travel");
|
||||
} catch (error) {
|
||||
console.error("Error seeding travel data:", error);
|
||||
req.flash("error", "Failed to seed travel data");
|
||||
res.redirect("/admin/travel");
|
||||
}
|
||||
};
|
||||
@@ -1,695 +0,0 @@
|
||||
// controllers/visaController.js
|
||||
|
||||
const addBaseUrlToImages = (data, baseUrl) => {
|
||||
if (!data) return data;
|
||||
|
||||
// Nếu là mảng, duyệt từng phần tử
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item) => addBaseUrlToImages(item, baseUrl));
|
||||
}
|
||||
|
||||
// Nếu là object, duyệt từng key
|
||||
if (typeof data === "object") {
|
||||
const newObj = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// Kiểm tra nếu key là các trường chứa ảnh và value là string
|
||||
const imageKeys = ["icon", "mainImage", "bannerImage", "image"];
|
||||
|
||||
if (
|
||||
imageKeys.includes(key) &&
|
||||
typeof value === "string" &&
|
||||
!value.startsWith("http")
|
||||
) {
|
||||
newObj[key] = `${baseUrl}/${value}`
|
||||
.replace(/\/+/g, "/")
|
||||
.replace(":/", "://");
|
||||
}
|
||||
// Xử lý riêng cho mảng gallery (mảng các chuỗi)
|
||||
else if (key === "gallery" && Array.isArray(value)) {
|
||||
newObj[key] = value.map((img) =>
|
||||
img.startsWith("http")
|
||||
? img
|
||||
: `${baseUrl}/${img}`.replace(/\/+/g, "/").replace(":/", "://"),
|
||||
);
|
||||
}
|
||||
// Nếu là object hoặc mảng con khác, đệ quy tiếp
|
||||
else if (typeof value === "object" && value !== null) {
|
||||
newObj[key] = addBaseUrlToImages(value, baseUrl);
|
||||
} else {
|
||||
newObj[key] = value;
|
||||
}
|
||||
}
|
||||
return newObj;
|
||||
}
|
||||
return data;
|
||||
};
|
||||
const Visa = require("../models/visa");
|
||||
const slugify = require("slugify");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
const createSlug = (text) => {
|
||||
return slugify(text, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
locale: "en",
|
||||
trim: true,
|
||||
});
|
||||
};
|
||||
// -------------------- Helper Functions --------------------
|
||||
|
||||
// Get visa data from MongoDB
|
||||
const getVisaData = async () => {
|
||||
const visa = await Visa.findOne().sort({ updatedAt: -1 });
|
||||
return visa || {};
|
||||
};
|
||||
|
||||
// Get default visa data structure (updated to match new JSON)
|
||||
const getDefaultVisaData = () => ({
|
||||
hero: {
|
||||
title: "Visa Service",
|
||||
summaryList: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function: Generate next country ID
|
||||
const getNextCountryId = (countries) => {
|
||||
if (!Array.isArray(countries) || countries.length === 0) return 1;
|
||||
return Math.max(...countries.map((c) => c.id || 0)) + 1;
|
||||
};
|
||||
|
||||
// -------------------- Admin Exports --------------------
|
||||
|
||||
// Display visa management page
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
// Fetch Visa data
|
||||
let data = await getVisaData();
|
||||
|
||||
// If no data exists, use default
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
data = getDefaultVisaData();
|
||||
} else {
|
||||
// Merge with defaults to ensure all fields exist
|
||||
const defaultData = getDefaultVisaData();
|
||||
|
||||
// Ensure hero section exists with defaults
|
||||
data.hero = data.hero || defaultData.hero;
|
||||
data.hero.title = data.hero.title || "Visa Service";
|
||||
data.hero.summaryList = data.hero.summaryList || [];
|
||||
}
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
|
||||
res.render("admin/visa/index", {
|
||||
layout: "layouts/main",
|
||||
title: "Visa Management",
|
||||
data,
|
||||
frontendUrl,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
// return res.json(data);
|
||||
} catch (err) {
|
||||
console.error("Visa index error:", err);
|
||||
req.flash("error_msg", "Error loading visa data");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Get single country for edit
|
||||
exports.getCountry = async (req, res) => {
|
||||
console.log("--------------------------------------------------");
|
||||
console.log("🚀 [GET] Request nhận được tại /visa/edit/:id");
|
||||
|
||||
try {
|
||||
const { id } = req.params;
|
||||
console.log("📍 ID từ Params (URL):", id, "| Kiểu dữ liệu:", typeof id);
|
||||
|
||||
const visaData = await getVisaData();
|
||||
|
||||
// Kiểm tra cấu trúc dữ liệu tổng
|
||||
if (!visaData) {
|
||||
console.error("❌ Lỗi: Hàm getVisaData() trả về null/undefined");
|
||||
return res.status(404).json({ error: "Dữ liệu gốc không tồn tại" });
|
||||
}
|
||||
|
||||
if (!visaData.hero || !visaData.hero.summaryList) {
|
||||
console.error("❌ Lỗi: Cấu trúc visaData.hero.summaryList không hợp lệ");
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Không tìm thấy danh sách quốc gia" });
|
||||
}
|
||||
|
||||
console.log(
|
||||
"📊 Tổng số quốc gia hiện có trong mảng:",
|
||||
visaData.hero.summaryList.length,
|
||||
);
|
||||
|
||||
// 2. Tìm quốc gia theo ID
|
||||
const targetId = parseInt(id);
|
||||
console.log("🔍 Đang tìm kiếm Quốc gia có ID (sau khi parse):", targetId);
|
||||
|
||||
const country = visaData.hero.summaryList.find((c) => {
|
||||
// Log từng phần tử để kiểm tra kiểu dữ liệu của c.id trong DB
|
||||
// console.log(`Checking country: ${c.name} | ID in DB: ${c.id} (${typeof c.id})`);
|
||||
return c.id === targetId;
|
||||
});
|
||||
|
||||
if (!country) {
|
||||
console.warn(`⚠️ Không tìm thấy quốc gia nào khớp với ID: ${targetId}`);
|
||||
// In ra danh sách ID hiện có để so sánh
|
||||
const existingIds = visaData.hero.summaryList.map((c) => c.id);
|
||||
console.log("🆔 Các ID hiện có trong Database:", existingIds);
|
||||
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: `Không tìm thấy quốc gia có ID: ${id}`,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Tìm thấy dữ liệu quốc gia:", country.name);
|
||||
|
||||
// 3. Trả về dữ liệu
|
||||
res.json({
|
||||
success: true,
|
||||
country: country,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("🔴 Lỗi nghiêm trọng tại getCountry Controller:", err);
|
||||
res.status(500).json({ error: "Lỗi hệ thống khi tải thông tin quốc gia" });
|
||||
}
|
||||
};
|
||||
|
||||
// Update visa data (hero title only)
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
// Get current data
|
||||
const currentData = await getVisaData();
|
||||
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeData = currentData
|
||||
? JSON.parse(
|
||||
JSON.stringify(
|
||||
currentData.toObject ? currentData.toObject() : currentData,
|
||||
),
|
||||
)
|
||||
: {};
|
||||
|
||||
// Create updated data object
|
||||
const updatedData = {
|
||||
...(currentData.toObject ? currentData.toObject() : currentData),
|
||||
};
|
||||
|
||||
// Ensure hero structure exists
|
||||
updatedData.hero = updatedData.hero || {
|
||||
title: "Visa Service",
|
||||
summaryList: [],
|
||||
};
|
||||
|
||||
// Update hero title
|
||||
if (req.body.heroTitle) {
|
||||
updatedData.hero.title = req.body.heroTitle;
|
||||
}
|
||||
|
||||
// Update or create document
|
||||
try {
|
||||
let savedData;
|
||||
if (currentData._id) {
|
||||
savedData = await Visa.findByIdAndUpdate(currentData._id, updatedData, {
|
||||
new: true,
|
||||
});
|
||||
} else {
|
||||
savedData = await Visa.create(updatedData);
|
||||
}
|
||||
|
||||
// ✅ Capture AFTER state
|
||||
const afterData = JSON.parse(JSON.stringify(savedData.toObject()));
|
||||
|
||||
// ✅ AUDIT LOGGING - Visa Updated
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Visa",
|
||||
documentId: savedData._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_VISA,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
console.log(
|
||||
`✅ Audit log created for Visa update: ${changes.length} changes`,
|
||||
);
|
||||
} else {
|
||||
console.log("ℹ️ No changes detected for Visa update");
|
||||
}
|
||||
|
||||
req.flash("success_msg", "Visa data updated successfully");
|
||||
return req.session.save(() => res.redirect("/admin/visa"));
|
||||
} catch (dbError) {
|
||||
console.error("Database error:", dbError);
|
||||
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
|
||||
return req.session.save(() => res.redirect("/admin/visa"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Update error:", err);
|
||||
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
|
||||
return req.session.save(() => res.redirect("/admin/visa"));
|
||||
}
|
||||
};
|
||||
|
||||
// Add new country
|
||||
exports.addCountry = async (req, res) => {
|
||||
try {
|
||||
let visaData = await getVisaData();
|
||||
|
||||
// Initialize hero structure if not exist
|
||||
if (!visaData.hero || !visaData.hero.summaryList) {
|
||||
visaData = getDefaultVisaData();
|
||||
}
|
||||
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeData = JSON.parse(
|
||||
JSON.stringify(visaData.toObject ? visaData.toObject() : visaData),
|
||||
);
|
||||
|
||||
// Validate required fields
|
||||
if (!req.body.name) {
|
||||
return res.status(400).json({ error: "Name is required" });
|
||||
}
|
||||
const finalSlug = req.body.slug
|
||||
? createSlug(req.body.slug)
|
||||
: createSlug(req.body.name);
|
||||
|
||||
// Parse services array
|
||||
let services = [];
|
||||
if (req.body.services) {
|
||||
if (typeof req.body.services === "string") {
|
||||
try {
|
||||
services = JSON.parse(req.body.services);
|
||||
} catch (e) {
|
||||
services = [req.body.services];
|
||||
}
|
||||
} else if (Array.isArray(req.body.services)) {
|
||||
services = req.body.services;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse detailedView if provided (optional)
|
||||
let detailedView = null;
|
||||
if (req.body.detailedView) {
|
||||
try {
|
||||
detailedView =
|
||||
typeof req.body.detailedView === "string"
|
||||
? JSON.parse(req.body.detailedView)
|
||||
: req.body.detailedView;
|
||||
} catch (e) {
|
||||
console.warn("Could not parse detailedView, creating without it");
|
||||
}
|
||||
}
|
||||
|
||||
// Create new country object
|
||||
const newCountry = {
|
||||
id: req.body.id || getNextCountryId(visaData.hero.summaryList),
|
||||
name: req.body.name,
|
||||
slug: finalSlug,
|
||||
icon: req.body.icon || "",
|
||||
services: services,
|
||||
...(detailedView && { detailedView }),
|
||||
};
|
||||
|
||||
// Add new country to summaryList
|
||||
visaData.hero.summaryList.push(newCountry);
|
||||
|
||||
// Update database
|
||||
const updatedData = {
|
||||
...(visaData.toObject ? visaData.toObject() : visaData),
|
||||
};
|
||||
|
||||
let savedData;
|
||||
if (visaData._id) {
|
||||
savedData = await Visa.findByIdAndUpdate(visaData._id, updatedData, {
|
||||
new: true,
|
||||
});
|
||||
} else {
|
||||
savedData = await Visa.create(updatedData);
|
||||
}
|
||||
|
||||
// ✅ Capture AFTER state
|
||||
const afterData = JSON.parse(
|
||||
JSON.stringify(savedData.toObject ? savedData.toObject() : savedData),
|
||||
);
|
||||
|
||||
// ✅ AUDIT LOGGING - Visa Country Added
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Visa",
|
||||
documentId: savedData._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_VISA,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
console.log(
|
||||
`✅ Audit log created for Visa country addition: ${changes.length} changes`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`✅ Country "${newCountry.name}" added successfully`);
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Country "${newCountry.name}" added successfully`,
|
||||
country: newCountry,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Add country error:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Update single country
|
||||
exports.updateCountry = async (req, res) => {
|
||||
try {
|
||||
// 1. Lấy ID từ params (URL)
|
||||
const { id } = req.params;
|
||||
let visaData = await getVisaData();
|
||||
|
||||
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Cấu trúc dữ liệu Visa không hợp lệ" });
|
||||
}
|
||||
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeData = JSON.parse(
|
||||
JSON.stringify(visaData.toObject ? visaData.toObject() : visaData),
|
||||
);
|
||||
|
||||
// 2. Tìm index theo ID (Chuyển về Number để so sánh chính xác)
|
||||
const countryIndex = visaData.hero.summaryList.findIndex(
|
||||
(c) => c.id === parseInt(id),
|
||||
);
|
||||
|
||||
if (countryIndex === -1) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: `Không tìm thấy quốc gia có ID: ${id}` });
|
||||
}
|
||||
|
||||
const currentCountry = visaData.hero.summaryList[countryIndex];
|
||||
let finalSlug = currentCountry.slug;
|
||||
if (req.body.name) {
|
||||
// Nếu name thay đổi, ta có thể cập nhật lại slug (tùy nhu cầu SEO)
|
||||
// Ở đây ưu tiên: Nếu có slug mới truyền lên thì dùng, không thì tạo từ name mới
|
||||
finalSlug = req.body.slug
|
||||
? createSlug(req.body.slug)
|
||||
: createSlug(req.body.name);
|
||||
}
|
||||
// 3. Xử lý dữ liệu từ req.body
|
||||
// Vì Client đã gửi JSON stringify, ta lấy trực tiếp hoặc parse nếu cần
|
||||
let services = req.body.services;
|
||||
if (typeof services === "string") {
|
||||
try {
|
||||
services = JSON.parse(services);
|
||||
} catch (e) {
|
||||
services = [services];
|
||||
}
|
||||
}
|
||||
|
||||
let detailedView = req.body.detailedView;
|
||||
if (typeof detailedView === "string") {
|
||||
try {
|
||||
detailedView = JSON.parse(detailedView);
|
||||
} catch (e) {
|
||||
detailedView = currentCountry.detailedView;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Cập nhật Object quốc gia
|
||||
const updatedCountry = {
|
||||
...currentCountry, // Giữ các trường cũ
|
||||
id: parseInt(id), // Đảm bảo ID không đổi
|
||||
name: req.body.name || currentCountry.name,
|
||||
slug: finalSlug,
|
||||
icon: req.body.icon || currentCountry.icon,
|
||||
services: Array.isArray(services) ? services : currentCountry.services,
|
||||
detailedView: detailedView || currentCountry.detailedView,
|
||||
};
|
||||
|
||||
// 5. Cập nhật vào mảng chính
|
||||
visaData.hero.summaryList[countryIndex] = updatedCountry;
|
||||
|
||||
// 6. Lưu vào Database
|
||||
if (visaData.markModified) {
|
||||
// Bắt buộc với Mongoose khi thay đổi nội dung bên trong Array/Object
|
||||
visaData.markModified("hero.summaryList");
|
||||
}
|
||||
|
||||
let savedData;
|
||||
if (visaData._id) {
|
||||
savedData = await visaData.save(); // Sử dụng save() trực tiếp nếu visaData là Mongoose Document
|
||||
} else {
|
||||
savedData = await Visa.create(visaData);
|
||||
}
|
||||
|
||||
// ✅ Capture AFTER state
|
||||
const afterData = JSON.parse(
|
||||
JSON.stringify(savedData.toObject ? savedData.toObject() : savedData),
|
||||
);
|
||||
|
||||
// ✅ AUDIT LOGGING - Visa Country Updated
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Visa",
|
||||
documentId: savedData._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_VISA,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
console.log(
|
||||
`✅ Audit log created for Visa country update: ${changes.length} changes`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ Country "${updatedCountry.name}" updated successfully by ID: ${id}`,
|
||||
);
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Quốc gia "${updatedCountry.name}" đã được cập nhật thành công`,
|
||||
country: updatedCountry,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Update country error:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Delete country
|
||||
exports.deleteCountry = async (req, res) => {
|
||||
try {
|
||||
// 1. Lấy id từ params
|
||||
const { id } = req.params;
|
||||
let visaData = await getVisaData();
|
||||
|
||||
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Cấu trúc dữ liệu Visa không hợp lệ" });
|
||||
}
|
||||
|
||||
// 2. Tìm index theo ID (Chuyển về Number để so sánh chính xác)
|
||||
const countryIndex = visaData.hero.summaryList.findIndex(
|
||||
(c) => c.id === parseInt(id),
|
||||
);
|
||||
|
||||
if (countryIndex === -1) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: `Không tìm thấy quốc gia có ID: ${id}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Xóa phần tử khỏi mảng
|
||||
const deletedCountry = visaData.hero.summaryList[countryIndex];
|
||||
visaData.hero.summaryList.splice(countryIndex, 1);
|
||||
|
||||
// 4. Cập nhật vào Database
|
||||
if (visaData.markModified) {
|
||||
visaData.markModified("hero.summaryList");
|
||||
}
|
||||
|
||||
if (visaData._id) {
|
||||
await visaData.save();
|
||||
} else {
|
||||
await Visa.create(visaData);
|
||||
}
|
||||
|
||||
console.log(`✅ Deleted Successfully: "${deletedCountry.name}"`);
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `Country "${deletedCountry.name}" Deleted Successfully`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("❌ Error Delete:", err);
|
||||
return res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------- Public API Exports --------------------
|
||||
|
||||
// API to get all visa data for frontend
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const visaData = await getVisaData();
|
||||
if (!visaData) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Visa data not found",
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
const heroData = visaData?.hero;
|
||||
|
||||
// 2. Lấy riêng phần hero (Dùng biến mới, không gán đè vào const)
|
||||
const processedData = heroData;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
hero: processedData,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Visa API error:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error loading visa data",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API to get all countries (summaryList only)
|
||||
exports.apiCountries = async (req, res) => {
|
||||
try {
|
||||
const visaData = await getVisaData();
|
||||
|
||||
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Countries data not found",
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Lọc bỏ 'viewDetail' khỏi từng quốc gia trong danh sách
|
||||
const filteredCountries = visaData.hero.summaryList.map((item) => {
|
||||
// Tách detailedView ra, gom phần còn lại vào countryInfo
|
||||
const { detailedView, ...countryInfo } = item;
|
||||
|
||||
return {
|
||||
...countryInfo,
|
||||
// Lấy mainImage từ sâu bên trong detailedView và gán vào key mới
|
||||
mainImage: detailedView?.activeCountry?.mainImage || "",
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Gắn baseUrl vào ảnh cho danh sách đã lọc
|
||||
const processedData = filteredCountries;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: processedData, // Lúc này data chỉ chứa thông tin quốc gia, không có viewDetail
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Countries API error:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error loading countries data",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API to get single country by slug
|
||||
exports.apiCountry = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const visaData = await getVisaData();
|
||||
|
||||
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Visa data not found",
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Tìm quốc gia khớp với slug
|
||||
const country = visaData.hero.summaryList.find((c) => c.slug === slug);
|
||||
|
||||
// 2. Kiểm tra nếu không thấy quốc gia hoặc quốc gia đó không có viewDetail
|
||||
if (!country || !country.viewDetail) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: `Detailed information for country "${slug}" not found`,
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
// 3. Chỉ lấy phần chi tiết (detailed view)
|
||||
// Lưu ý: Chúng ta copy ra object mới để tránh tham chiếu dữ liệu gốc
|
||||
const detailedData = JSON.parse(JSON.stringify(country.viewDetail));
|
||||
|
||||
// 4. Gắn baseUrl vào các ảnh nằm trong phần chi tiết này
|
||||
const processedData = detailedData;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: processedData,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Visa country API error:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error loading country detailed data",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API to get hero data (title + summaryList)
|
||||
exports.apiHero = async (req, res) => {
|
||||
try {
|
||||
const visaData = await getVisaData();
|
||||
|
||||
// 1. Kiểm tra dữ liệu gốc
|
||||
|
||||
if (!visaData || !visaData.hero) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Hero data not found",
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
const { summaryList, ...heroData } = JSON.parse(
|
||||
JSON.stringify(visaData.hero),
|
||||
);
|
||||
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
const processedData = addBaseUrlToImages(heroData, baseUrl);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: processedData,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Visa hero API error:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error loading hero data",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,91 +1,111 @@
|
||||
{
|
||||
"hero": {
|
||||
"title": "About Us",
|
||||
"breadcrumb": [
|
||||
"Home",
|
||||
"About Us"
|
||||
],
|
||||
"backgroundImage": "/uploads/about/breadcrumb.jpg"
|
||||
"badge": "Our Mission",
|
||||
"title": "Education Without Boundaries.",
|
||||
"description": "We believe high-quality education should be accessible and affordable for everyone. Our mission is to empower global learners through flexible, innovative online models that fit real lives.",
|
||||
"studentCount": "50,000+",
|
||||
"image": "/assets/img/hero.png",
|
||||
"imageAlt": "Diverse group of university students collaborating online",
|
||||
"coreValues": ["Radical Affordability", "Absolute Flexibility", "Global Inclusivity"]
|
||||
},
|
||||
"intro": {
|
||||
"subheading": "Company Intro",
|
||||
"heading": "Building Pathways to Your Immigration Success",
|
||||
"description": "We provide expert guidance, personalized solutions, and transparent processes to help you achieve your immigration goals. Our dedicated team ensures a smooth journey, building pathways to your international success.",
|
||||
"image": "/uploads/about/businessman.jpg"
|
||||
},
|
||||
"mission": {
|
||||
"subheading": "About Our Consultancy",
|
||||
"heading": "Turning Study Abroad Dreams Into Reality",
|
||||
"description": "We guide students with expert visa consulting, ensuring a smooth process from application to approval, turning study abroad aspirations into life-changing opportunities for a brighter future.",
|
||||
"images": {
|
||||
"main": "/uploads/about/375x419.jpg",
|
||||
"secondary": "/uploads/about/375x419.jpg",
|
||||
"bgShape": "/assets/img/home-1/about/Vector.png",
|
||||
"planeShape": "/assets/img/home-1/about/plane.png",
|
||||
"topShape": "/assets/img/home-1/about/shape.png",
|
||||
"globeShape": "/assets/img/home-1/about/globe.png"
|
||||
},
|
||||
"items": [
|
||||
"leadership": {
|
||||
"heading": "Guided by Visionaries",
|
||||
"description": "Our leadership team brings decades of experience from top-tier academic institutions and leading technology companies.",
|
||||
"members": [
|
||||
{
|
||||
"icon": "/assets/img/home-1/icon/01.svg",
|
||||
"label": "Global Reach",
|
||||
"description": "Expanding Opportunities Worldwide"
|
||||
"name": "Dr. Sarah Chen",
|
||||
"role": "President & Founder",
|
||||
"bio": "Former Dean of Innovation at Stanford, pioneer in scalable ed-tech solutions.",
|
||||
"avatar": "https://storage.googleapis.com/uxpilot-auth.appspot.com/avatars/avatar-5.jpg",
|
||||
"social": { "linkedin": "#", "twitter": "#" }
|
||||
},
|
||||
{
|
||||
"icon": "/assets/img/home-1/icon/01.svg",
|
||||
"label": "Global Reach",
|
||||
"description": "Expanding Opportunities Worldwide"
|
||||
"name": "Marcus Johnson",
|
||||
"role": "Chief Academic Officer",
|
||||
"bio": "20+ years developing curriculum for asynchronous learning environments.",
|
||||
"avatar": "https://storage.googleapis.com/uxpilot-auth.appspot.com/avatars/avatar-6.jpg",
|
||||
"social": { "linkedin": "#" }
|
||||
},
|
||||
{
|
||||
"name": "Elena Rodriguez",
|
||||
"role": "VP of Student Success",
|
||||
"bio": "Passionate advocate for non-traditional student support systems.",
|
||||
"avatar": "https://storage.googleapis.com/uxpilot-auth.appspot.com/avatars/avatar-7.jpg",
|
||||
"social": { "linkedin": "#", "twitter": "#" }
|
||||
},
|
||||
{
|
||||
"name": "David Kim",
|
||||
"role": "Chief Technology Officer",
|
||||
"bio": "Former Google engineering lead building our seamless campus portal.",
|
||||
"avatar": "https://storage.googleapis.com/uxpilot-auth.appspot.com/avatars/avatar-8.jpg",
|
||||
"social": { "linkedin": "#" }
|
||||
}
|
||||
],
|
||||
]
|
||||
},
|
||||
"learningModel": {
|
||||
"heading": "Our Flexible Learning Model",
|
||||
"description": "Choose how you learn. We offer two distinct paths to ensure your education fits your life, not the other way around.",
|
||||
"async": {
|
||||
"title": "Asynchronous Learning",
|
||||
"description": "Learn completely at your own pace. Perfect for working professionals and those with unpredictable schedules.",
|
||||
"features": [
|
||||
"Fastest Visa form processing with skilled immigration agents",
|
||||
"Partnership with International Educational Institutions"
|
||||
],
|
||||
"ctaButton": {
|
||||
"label": "Get Started",
|
||||
"href": "/about"
|
||||
{ "icon": "fa-play", "title": "Pre-recorded Lectures", "desc": "Watch anytime, anywhere, and pause when you need to." },
|
||||
{ "icon": "fa-calendar-check", "title": "Flexible Deadlines", "desc": "Complete assignments within broad, manageable windows." },
|
||||
{ "icon": "fa-comments", "title": "Discussion Boards", "desc": "Engage with peers on your own timeline." }
|
||||
]
|
||||
},
|
||||
"sync": {
|
||||
"title": "Synchronous Learning",
|
||||
"description": "Experience the structure of a traditional classroom online. Ideal for those who thrive on real-time interaction.",
|
||||
"features": [
|
||||
{ "icon": "fa-video", "title": "Live Virtual Classes", "desc": "Attend scheduled lectures with your professors and peers." },
|
||||
{ "icon": "fa-hand-sparkles", "title": "Real-time Collaboration", "desc": "Work on group projects in live breakout rooms." },
|
||||
{ "icon": "fa-clipboard-question", "title": "Immediate Feedback", "desc": "Ask questions and get answers during class time." }
|
||||
]
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"backgroundImage": "/assets/img/home-2/feature/bg-shape.png",
|
||||
"subheading": "Your Travel Made Easy",
|
||||
"heading": "Smooth Visa Journey Guaranteed",
|
||||
"description": "We provide expert guidance for every visa application, ensuring smooth processing, personalized support, and reliable assistance",
|
||||
"image": "/uploads/about/686x906.jpg",
|
||||
"items": [
|
||||
{
|
||||
"icon": "/assets/img/home-2/icon/01.png",
|
||||
"title": "Expert Consultants",
|
||||
"description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors."
|
||||
},
|
||||
{
|
||||
"icon": "/assets/img/home-2/icon/01.png",
|
||||
"title": "Personalized Support",
|
||||
"description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors."
|
||||
},
|
||||
{
|
||||
"icon": "/assets/img/home-2/icon/01.png",
|
||||
"title": "Transparent Process",
|
||||
"description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors."
|
||||
}
|
||||
"accreditation": {
|
||||
"heading": "Recognized Quality. Real Results.",
|
||||
"description": "We hold the highest standards of academic excellence, ensuring your degree is respected by employers worldwide.",
|
||||
"badges": [
|
||||
{ "icon": "fa-award", "title": "Fully Accredited", "desc": "By the Higher Learning Commission (HLC)." },
|
||||
{ "icon": "fa-briefcase", "title": "Top Employer Network", "desc": "Partnerships with Fortune 500 companies." }
|
||||
],
|
||||
"ctaButton": {
|
||||
"label": "Get Started Today",
|
||||
"href": "/contact"
|
||||
"stats": [
|
||||
{ "value": "89%", "label": "Employed within 6 months" },
|
||||
{ "value": "$72k", "label": "Average Starting Salary" },
|
||||
{ "value": "15k+", "label": "Active Alumni Network" },
|
||||
{ "value": "#1", "label": "For Online ROI (2025)", "isPrimary": true }
|
||||
]
|
||||
},
|
||||
"successStories": {
|
||||
"heading": "Student Success Stories",
|
||||
"description": "Hear from our diverse community of learners who transformed their careers through our flexible programs.",
|
||||
"stories": [
|
||||
{
|
||||
"quote": "The asynchronous model allowed me to study coding late at night after my kids went to bed. I transitioned from retail to a Junior Developer role in just 14 months.",
|
||||
"name": "Maria S.",
|
||||
"program": "B.S. Computer Science '24",
|
||||
"avatar": "https://storage.googleapis.com/uxpilot-auth.appspot.com/avatars/avatar-1.jpg"
|
||||
},
|
||||
{
|
||||
"quote": "The pay-per-course option meant I didn't have to take out massive student loans. I earned my MBA debt-free while continuing to work full-time.",
|
||||
"name": "James T.",
|
||||
"program": "MBA '25",
|
||||
"avatar": "https://storage.googleapis.com/uxpilot-auth.appspot.com/avatars/avatar-4.jpg"
|
||||
},
|
||||
{
|
||||
"quote": "I loved the synchronous virtual classes. It felt like a real campus environment. The networking opportunities directly led to my current job in data analytics.",
|
||||
"name": "Aisha K.",
|
||||
"program": "Data Analytics Cert '23",
|
||||
"avatar": "https://storage.googleapis.com/uxpilot-auth.appspot.com/avatars/avatar-2.jpg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"news": {
|
||||
"subheading": "Visa Tips & Guides",
|
||||
"heading": "Latest Insights & Updates",
|
||||
"ctaButton": {
|
||||
"label": "view all articles",
|
||||
"href": "/blog"
|
||||
},
|
||||
"selectedBlogIds": [
|
||||
"69857d6c6d04fed459107944",
|
||||
"69857d6c6d04fed459107942",
|
||||
"69857d6c6d04fed459107940"
|
||||
],
|
||||
"items": []
|
||||
"cta": {
|
||||
"heading": "Ready to Transform Your Future?",
|
||||
"description": "Join a global community of learners and take the first step towards a flexible, affordable degree.",
|
||||
"primaryButton": { "label": "Apply Now", "href": "/admissions" },
|
||||
"secondaryButton": { "label": "Request Information", "href": "/contact" }
|
||||
}
|
||||
}
|
||||
@@ -1,79 +1,80 @@
|
||||
{
|
||||
"top": {
|
||||
"bgImage": "/assets/img/home-1/footer-bg.jpg",
|
||||
"phone": {
|
||||
"display": "+84 961 83 4040",
|
||||
"href": "tel:+84961834040"
|
||||
},
|
||||
"address": "734 Luy Ban Bich St, Tan Thanh Ward, Tan Phu Dist, HCMC",
|
||||
"brand": {
|
||||
"logo": {
|
||||
"src": "/assets/img/logo/white-logo.svg",
|
||||
"alt": "logo",
|
||||
"image": "/assets/img/logo/logo.jpg",
|
||||
"href": "/"
|
||||
},
|
||||
"menuLinks": [
|
||||
"description": "Welcome to the LAMS, a premier platform offering courses from top universities and institutions around the world.",
|
||||
"social": [
|
||||
{
|
||||
"label": "Home",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"label": "About Us",
|
||||
"href": "/about"
|
||||
},
|
||||
{
|
||||
"label": "Visa",
|
||||
"href": "/country-details"
|
||||
},
|
||||
{
|
||||
"label": "Pages",
|
||||
"href": "/news-details"
|
||||
},
|
||||
{
|
||||
"label": "Article",
|
||||
"href": "/news"
|
||||
},
|
||||
{
|
||||
"label": "Contact Us",
|
||||
"href": "/contact"
|
||||
}
|
||||
],
|
||||
"socialLinks": [
|
||||
{
|
||||
"icon": "fa-brands fa-twitter",
|
||||
"icon": "fa-twitter",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"icon": "fa-brands fa-instagram",
|
||||
"icon": "fa-linkedin-in",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"icon": "fa-brands fa-linkedin",
|
||||
"icon": "fa-facebook-f",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"icon": "fa-brands fa-youtube",
|
||||
"icon": "fa-instagram",
|
||||
"href": "#"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bottom": {
|
||||
"copyright": {
|
||||
"text": "Copyright©",
|
||||
"brand": "GRAMENTHEME",
|
||||
"rights": "All Rights Reserved."
|
||||
},
|
||||
"menuLinks": [
|
||||
"explore": {
|
||||
"heading": "Explore",
|
||||
"links": [
|
||||
{
|
||||
"label": "Terms & Conditions",
|
||||
"href": "/contact"
|
||||
"label": "Welcome / Overview",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"label": "Our Milestones",
|
||||
"href": "/history"
|
||||
},
|
||||
{
|
||||
"label": "Accreditations",
|
||||
"href": "/accreditations"
|
||||
},
|
||||
{
|
||||
"label": "Industry Partnerships",
|
||||
"href": "/partnership"
|
||||
},
|
||||
{
|
||||
"label": "University Blog",
|
||||
"href": "/blog"
|
||||
}
|
||||
]
|
||||
},
|
||||
"contact": {
|
||||
"heading": "Contact Us",
|
||||
"address": "207 Regent Street, London, England W1B3HH",
|
||||
"phone": "12345678-EDU-ONLINE",
|
||||
"email": "info@lams.ac"
|
||||
},
|
||||
"newsletter": {
|
||||
"heading": "Stay Updated",
|
||||
"description": "Subscribe to our newsletter for the latest academic news and program updates.",
|
||||
"placeholder": "Email Address",
|
||||
"buttonText": "Subscribe"
|
||||
},
|
||||
"bottom": {
|
||||
"copyright": "2026 London Academy of Management and Sciences. All rights reserved..",
|
||||
"links": [
|
||||
{
|
||||
"label": "Privacy Policy",
|
||||
"href": "/contact"
|
||||
"href": "/policies"
|
||||
},
|
||||
{
|
||||
"label": "Contact Us",
|
||||
"href": "/contact"
|
||||
"label": "Terms of Service",
|
||||
"href": "/policies"
|
||||
},
|
||||
{
|
||||
"label": "Accessibility Statement",
|
||||
"href": "/policies"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,334 +1,130 @@
|
||||
{
|
||||
"hero": {
|
||||
"title": "From Application to Visa – We’ve Got You Covered",
|
||||
"subtitle": "Global Education Simplified",
|
||||
"description": "We guide you through every step of the education visa process, from initial application to final approval, ensuring a smooth, hassle-free journey.",
|
||||
"primaryButton": {
|
||||
"label": "Apply now",
|
||||
"href": "/contact"
|
||||
},
|
||||
"secondaryButton": {
|
||||
"label": "Book Free Consultation",
|
||||
"href": "/contact"
|
||||
},
|
||||
"backgroundImage": "/assets/img/home-1/hero/bg.jpg",
|
||||
"videoUrl": "https://www.youtube.com/watch?v=Cn4G2lZ_g2I"
|
||||
},
|
||||
"whyChooseUs": {
|
||||
"heading": "Turning Study Abroad Dreams Into Reality",
|
||||
"subheading": "About Our Consultancy",
|
||||
"description": "We guide students with expert visa consulting, ensuring a smooth process from application to approval, turning study abroad aspirations into life-changing opportunities for a brighter future.",
|
||||
"items": [
|
||||
{
|
||||
"icon": "/assets/img/home-1/icon/01.svg",
|
||||
"title": "Global Reach",
|
||||
"description": "Expanding Opportunities Worldwide"
|
||||
},
|
||||
{
|
||||
"icon": "/assets/img/home-1/icon/01.svg",
|
||||
"title": "Expert Guidance",
|
||||
"description": "Professional Support Every Step"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
"Fastest Visa form processing with skilled immigration agents",
|
||||
"Partnership with International Educational Institutions"
|
||||
],
|
||||
"ctaButton": {
|
||||
"label": "Get Started",
|
||||
"href": "/about"
|
||||
"badge": "Top Ranked Online Education",
|
||||
"title": "Advance Your Career Without Boundaries.",
|
||||
"description": "Access world-class education from anywhere. Flexible schedules, accredited programs, and affordable tuition designed for the modern professional.",
|
||||
"searchPlaceholder": "e.g. Business Administration",
|
||||
"buttonLabel": "Find Program",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/d9383b2d27-55d93496971925dee83d.png",
|
||||
"imageAlt": "diverse group of modern adult students studying online with laptops, professional lighting, cinematic, high quality",
|
||||
"floatingBadge": {
|
||||
"icon": "fa-users",
|
||||
"value": "50,000+",
|
||||
"label": "Active Students"
|
||||
}
|
||||
},
|
||||
"visaSolutions": {
|
||||
"heading": "Comprehensive Visa Solutions",
|
||||
"subheading": "Our Expert Services",
|
||||
"items": [
|
||||
"quickLinks": [
|
||||
{
|
||||
"number": "01",
|
||||
"title": "Student Visa Guidance",
|
||||
"description": "Assistance with admission, documentation, and visa application.Assistance",
|
||||
"link": "/services/student-visa"
|
||||
"icon": "fa-trophy",
|
||||
"title": "Milestones",
|
||||
"description": "Explore our history of academic excellence and major achievements since our founding.",
|
||||
"linkText": "Explore History",
|
||||
"href": "/about/history"
|
||||
},
|
||||
{
|
||||
"number": "02",
|
||||
"title": "PTE Exam Preparation",
|
||||
"description": "We provide expert guidance and personalized support throughout the education visa process,",
|
||||
"link": "/services/pte-exam"
|
||||
"icon": "fa-certificate",
|
||||
"title": "Accreditations",
|
||||
"description": "Review our globally recognized credentials ensuring the highest quality of education.",
|
||||
"linkText": "View Credentials",
|
||||
"href": "/about/accreditation"
|
||||
},
|
||||
{
|
||||
"number": "03",
|
||||
"title": "University Selection Assistance",
|
||||
"description": "We provide expert guidance and personalized support throughout the education visa process,",
|
||||
"link": "/services/university-selection"
|
||||
"icon": "fa-handshake",
|
||||
"title": "Partnerships",
|
||||
"description": "Discover our network of industry leaders providing career pathways for graduates.",
|
||||
"linkText": "See Partners",
|
||||
"href": "/about/partnerships"
|
||||
},
|
||||
{
|
||||
"number": "04",
|
||||
"title": "IELTS Exam Preparation",
|
||||
"description": "We provide expert guidance and personalized support throughout the education visa process,",
|
||||
"link": "/services/ielts-exam"
|
||||
}
|
||||
]
|
||||
},
|
||||
"visaCountries": {
|
||||
"heading": "Visa & VISAWAY Services To UK",
|
||||
"subheading": "UK. United Kingdom",
|
||||
"description": "The Express Entry program is designed for skilled workers who wish to immigrate to Canada. It includes the Federal Skilled Worker Program, the Federal Skilled…",
|
||||
"countries": [
|
||||
{
|
||||
"name": "United Kingdom",
|
||||
"code": "UK",
|
||||
"flag": "/assets/img/home-1/feature/shape.png",
|
||||
"link": "/country-details/uk",
|
||||
"visaTypes": [
|
||||
"Visitor Visa",
|
||||
"Student Visa & Admission",
|
||||
"Work Visa – H1B",
|
||||
"Business Visa",
|
||||
"Work permit for Canada",
|
||||
"Student Visa for Canada"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "United States",
|
||||
"code": "US",
|
||||
"flag": "/assets/img/flags/us.png",
|
||||
"link": "/country-details/us",
|
||||
"visaTypes": [
|
||||
"Student Visa F-1",
|
||||
"Work Visa H1-B",
|
||||
"Tourist Visa B-2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Canada",
|
||||
"code": "CA",
|
||||
"flag": "/assets/img/flags/canada.png",
|
||||
"link": "/country-details/canada",
|
||||
"visaTypes": [
|
||||
"Study Permit",
|
||||
"Work Permit",
|
||||
"Express Entry"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Australia",
|
||||
"code": "AU",
|
||||
"flag": "/assets/img/flags/australia.png",
|
||||
"link": "/country-details/australia",
|
||||
"visaTypes": [
|
||||
"Student Visa 500",
|
||||
"Skilled Migration",
|
||||
"Working Holiday"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Germany",
|
||||
"code": "DE",
|
||||
"flag": "/assets/img/flags/germany.png",
|
||||
"link": "/country-details/germany",
|
||||
"visaTypes": [
|
||||
"Student Visa",
|
||||
"Job Seeker Visa",
|
||||
"EU Blue Card"
|
||||
]
|
||||
}
|
||||
],
|
||||
"ctaButton": {
|
||||
"label": "Get Started",
|
||||
"href": "/contact"
|
||||
}
|
||||
},
|
||||
"testimonials": {
|
||||
"heading": "Student Reviews & Testimonials",
|
||||
"subheading": "What Our Students Say",
|
||||
"videoUrl": "https://www.youtube.com/watch?v=Cn4G2lZ_g2I",
|
||||
"videoThumbnail": "/assets/img/home-1/testimonial/01.jpg",
|
||||
"items": [
|
||||
{
|
||||
"name": "Sohel Tanvir",
|
||||
"role": "Student",
|
||||
"country": "Canada",
|
||||
"rating": 5,
|
||||
"comment": "Professional and reliable service. They explained each step clearly, prepared my documents, and supported me during the interview. My visa approval came faster than expected.",
|
||||
"avatar": "/assets/img/home-1/testimonial/client.png"
|
||||
},
|
||||
{
|
||||
"name": "Ayesha Rahman",
|
||||
"role": "Student",
|
||||
"country": "UK. United Kingdom",
|
||||
"rating": 5,
|
||||
"comment": "The consultancy guided me from start to finish, making my study abroad journey smooth and stress-free. Thanks to their expert support, I secured my visa successfully.",
|
||||
"avatar": "/assets/img/home-1/testimonial/client-2.png"
|
||||
},
|
||||
{
|
||||
"name": "Michael Chen",
|
||||
"role": "Graduate Student",
|
||||
"country": "Australia",
|
||||
"rating": 5,
|
||||
"comment": "Outstanding service from beginning to end. The team was knowledgeable, responsive, and made the entire visa process seamless. Highly recommend to anyone planning to study abroad.",
|
||||
"avatar": "/assets/img/home-1/testimonial/client.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"videoGallery": {
|
||||
"heading": "VIDEO PLAY GALLERY",
|
||||
"videoUrl": "https://ex-coders.com/vdo/visa.mp4",
|
||||
"thumbnail": "/assets/img/home-1/feature/text.png"
|
||||
},
|
||||
"faq": {
|
||||
"heading": "Got Questions? We've Got Answers",
|
||||
"subheading": "Visa FAQs",
|
||||
"description": "We understand students often have many questions about studying abroad. Our experts provide clear.",
|
||||
"ctaButton": {
|
||||
"label": "contact us",
|
||||
"href": "/contact"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"question": "How long does the student visa process usually take?",
|
||||
"answer": "The student visa process typically takes 4-8 weeks depending on the country and time of year. We recommend starting the application process at least 3 months before your intended travel date to ensure sufficient time for document preparation and processing."
|
||||
},
|
||||
{
|
||||
"question": "Do you assist with scholarship applications as well?",
|
||||
"answer": "Yes, we guide students in identifying suitable scholarships, preparing strong applications, and increasing chances of securing financial aid for their studies abroad."
|
||||
},
|
||||
{
|
||||
"question": "Will you guide me in preparing for the visa interview?",
|
||||
"answer": "Absolutely! We provide comprehensive visa interview preparation, including mock interviews, document review, and tips on how to answer common questions confidently and effectively."
|
||||
},
|
||||
{
|
||||
"question": "Do you offer post-arrival support for students?",
|
||||
"answer": "Yes, we provide post-arrival support including airport pickup coordination, accommodation assistance, university orientation guidance, and ongoing support throughout your study period."
|
||||
},
|
||||
{
|
||||
"question": "What documents are required for a student visa application?",
|
||||
"answer": "Required documents typically include a valid passport, university acceptance letter, proof of financial support, academic transcripts, language proficiency test scores, and health insurance. We provide a complete checklist tailored to your destination country."
|
||||
}
|
||||
]
|
||||
},
|
||||
"achievements": {
|
||||
"heading": "Our Achievements in Numbers",
|
||||
"subheading": "Did You Know",
|
||||
"items": [
|
||||
{
|
||||
"value": "1000",
|
||||
"suffix": "k+",
|
||||
"label": "Students Guided",
|
||||
"description": "Successfully assisted over a thousand students worldwide."
|
||||
},
|
||||
{
|
||||
"value": "50",
|
||||
"suffix": "+",
|
||||
"label": "Countries Covered",
|
||||
"description": "Helping students apply to universities in more than 50 countries."
|
||||
},
|
||||
{
|
||||
"value": "95",
|
||||
"suffix": "%",
|
||||
"label": "Visa Success Rate",
|
||||
"description": "Inspired students to reach their goals globally"
|
||||
},
|
||||
{
|
||||
"value": "10",
|
||||
"suffix": "+",
|
||||
"label": "Years of Experience",
|
||||
"description": "Trusted experts in global education consulting."
|
||||
}
|
||||
]
|
||||
},
|
||||
"partners": {
|
||||
"visaConsultancy": {
|
||||
"heading": "Our Achievements & Awards",
|
||||
"items": [
|
||||
{
|
||||
"name": "Best Visa Consultancy",
|
||||
"icon": "/assets/img/home-1/feature/icon-1.png",
|
||||
"year": "2025"
|
||||
},
|
||||
{
|
||||
"name": "Visa Success Award",
|
||||
"icon": "/assets/img/home-1/feature/icon-2.png",
|
||||
"year": "2025"
|
||||
},
|
||||
{
|
||||
"name": "Innovation Award",
|
||||
"icon": "/assets/img/home-1/feature/icon-3.png",
|
||||
"year": "2025"
|
||||
},
|
||||
{
|
||||
"name": "Global Education Partner",
|
||||
"icon": "/assets/img/home-1/feature/icon-4.png",
|
||||
"year": "2025"
|
||||
}
|
||||
]
|
||||
},
|
||||
"brands": {
|
||||
"items": [
|
||||
{
|
||||
"logo": "/assets/img/home-1/brand/01.png"
|
||||
},
|
||||
{
|
||||
"logo": "/assets/img/home-1/brand/02.png"
|
||||
},
|
||||
{
|
||||
"logo": "/assets/img/home-1/brand/03.png"
|
||||
},
|
||||
{
|
||||
"logo": "/assets/img/home-1/brand/04.png"
|
||||
},
|
||||
{
|
||||
"logo": "/assets/img/home-1/brand/05.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"blogPreview": {
|
||||
"heading": "Latest Insights & Updates",
|
||||
"subheading": "Visa Tips & Guides",
|
||||
"ctaButton": {
|
||||
"label": "view all articles",
|
||||
"icon": "fa-newspaper",
|
||||
"title": "Latest Blog",
|
||||
"description": "Read insights, student success stories, and updates from our academic community.",
|
||||
"linkText": "Read Articles",
|
||||
"href": "/blog"
|
||||
}
|
||||
],
|
||||
"valueProp": {
|
||||
"badge": "Why Choose Us",
|
||||
"title": "Education Engineered for the Modern World",
|
||||
"description": "We believe that quality education should be accessible to everyone, regardless of location or schedule. Our platform delivers an immersive learning experience backed by industry-leading technology.",
|
||||
"features": [
|
||||
{
|
||||
"icon": "fa-laptop-code",
|
||||
"title": "100% Online Flexibility",
|
||||
"description": "Study on your own time with asynchronous classes designed to fit around your work and life commitments."
|
||||
},
|
||||
{
|
||||
"icon": "fa-piggy-bank",
|
||||
"title": "Affordable Tuition",
|
||||
"description": "Graduate with less debt. Our transparent pricing and financial aid options make your degree attainable."
|
||||
},
|
||||
{
|
||||
"icon": "fa-briefcase",
|
||||
"title": "Career-Focused Curriculum",
|
||||
"description": "Programs developed in partnership with industry leaders to ensure you learn the skills employers actually want."
|
||||
}
|
||||
],
|
||||
"stats": [
|
||||
{
|
||||
"value": "94%",
|
||||
"label": "Employment rate within 6 months of graduation",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/b1e2e6a5a4-7c815d7e47c7e7471c5e.png",
|
||||
"imageAlt": "student studying late at night looking focused"
|
||||
},
|
||||
{
|
||||
"value": "200+",
|
||||
"label": "Accredited degree and certificate programs",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/8e7cd5934f-dc6192e3a13ea24a5312.png",
|
||||
"imageAlt": "graduation cap and diploma abstract professional setup"
|
||||
}
|
||||
]
|
||||
},
|
||||
"programs": {
|
||||
"heading": "Featured Programs",
|
||||
"description": "Discover our most popular career-focused degrees and certificates designed for the modern job market.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Step-by-Step Guide to Applying for a Student Visa",
|
||||
"excerpt": "Learn the complete process of applying for a student visa, from gathering documents to attending your interview. Our comprehensive guide covers everything you need to know.",
|
||||
"category": "Student Visa",
|
||||
"date": "2025-08-20",
|
||||
"author": {
|
||||
"name": "Sohel",
|
||||
"avatar": "/assets/img/home-1/news/client.png"
|
||||
},
|
||||
"comments": 8,
|
||||
"link": "/blog/step-by-step-guide-student-visa",
|
||||
"thumbnail": "/assets/img/home-1/news/news-1.jpg"
|
||||
"category": "Tech & Software",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/f6c491e508-064cc521a89dee9776d1.png",
|
||||
"duration": "12-18 Months",
|
||||
"rating": "4.9",
|
||||
"title": "B.S. Computer Science",
|
||||
"description": "Master full-stack development, algorithms, and system design with hands-on projects.",
|
||||
"studentCount": "+1k",
|
||||
"href": "/programmes/bs-cs"
|
||||
},
|
||||
{
|
||||
"title": "Tips to Prepare Financial Documents for Visa Approval",
|
||||
"excerpt": "Financial documentation is crucial for visa approval. Discover expert tips on preparing bank statements, sponsorship letters, and proof of funds that meet embassy requirements.",
|
||||
"category": "IELTS / TOEFL",
|
||||
"date": "2025-08-20",
|
||||
"author": {
|
||||
"name": "Sohel",
|
||||
"avatar": "/assets/img/home-1/news/client.png"
|
||||
},
|
||||
"comments": 8,
|
||||
"link": "/blog/financial-documents-visa-approval",
|
||||
"thumbnail": "/assets/img/home-1/news/news-2.jpg"
|
||||
"category": "Data Analysis",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/e17be2a4e6-91247c5d9a4eed4d28a9.png",
|
||||
"duration": "8-12 Months",
|
||||
"rating": "4.8",
|
||||
"title": "Data Analytics Certificate",
|
||||
"description": "Learn SQL, Python, and Tableau to transform complex data into actionable business insights.",
|
||||
"studentCount": "+800",
|
||||
"href": "/programmes/cert-da"
|
||||
},
|
||||
{
|
||||
"title": "Post-Arrival Guide What Every Student Should Know",
|
||||
"excerpt": "Moving to a new country can be overwhelming. Our post-arrival guide helps international students navigate accommodation, banking, healthcare, and cultural adaptation successfully.",
|
||||
"category": "Study Abroad",
|
||||
"date": "2025-08-20",
|
||||
"author": {
|
||||
"name": "Sohel",
|
||||
"avatar": "/assets/img/home-1/news/client.png"
|
||||
},
|
||||
"comments": 8,
|
||||
"link": "/blog/post-arrival-guide-students",
|
||||
"thumbnail": "/assets/img/home-1/news/news-3.jpg"
|
||||
"category": "Business",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/c53c1f7b37-66e95dbe58513f7bce4b.png",
|
||||
"duration": "18-24 Months",
|
||||
"rating": "4.9",
|
||||
"title": "MBA in Leadership",
|
||||
"description": "Develop strategic management skills and leadership qualities for the modern corporate world.",
|
||||
"studentCount": "+2k",
|
||||
"href": "/programmes/mba-db"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requestInfo": {
|
||||
"heading": "Take the Next Step in Your Career.",
|
||||
"description": "Request more information about our programs, tuition, and admissions process. Our advisors are ready to help you plan your future.",
|
||||
"phone": "12345678-GLOBAL-U",
|
||||
"email": " info@lams.ac",
|
||||
"programs": [
|
||||
"Computer Science",
|
||||
"Business Administration",
|
||||
"Data Analytics",
|
||||
"Nursing"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
// Hero
|
||||
const HeroSchema = new Schema(
|
||||
{
|
||||
badge: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
studentCount: { type: String, default: "" },
|
||||
image: { type: String, default: "" },
|
||||
imageAlt: { type: String, default: "" },
|
||||
coreValues: { type: [String], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Leadership
|
||||
const SocialSchema = new Schema(
|
||||
{
|
||||
linkedin: { type: String, default: "" },
|
||||
twitter: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const LeadershipMemberSchema = new Schema(
|
||||
{
|
||||
name: { type: String, default: "" },
|
||||
role: { type: String, default: "" },
|
||||
bio: { type: String, default: "" },
|
||||
avatar: { type: String, default: "" },
|
||||
social: { type: SocialSchema, default: () => ({}) },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const LeadershipSchema = new Schema(
|
||||
{
|
||||
heading: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
members: { type: [LeadershipMemberSchema], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Learning Model
|
||||
const LearningFeatureSchema = new Schema(
|
||||
{
|
||||
icon: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
desc: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const LearningModeSchema = new Schema(
|
||||
{
|
||||
title: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
features: { type: [LearningFeatureSchema], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const LearningModelSchema = new Schema(
|
||||
{
|
||||
heading: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
async: { type: LearningModeSchema, default: () => ({}) },
|
||||
sync: { type: LearningModeSchema, default: () => ({}) },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Accreditation
|
||||
const AccreditationBadgeSchema = new Schema(
|
||||
{
|
||||
icon: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
desc: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const AccreditationStatSchema = new Schema(
|
||||
{
|
||||
value: { type: String, default: "" },
|
||||
label: { type: String, default: "" },
|
||||
isPrimary: { type: Boolean, default: false },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const AccreditationSchema = new Schema(
|
||||
{
|
||||
heading: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
badges: { type: [AccreditationBadgeSchema], default: [] },
|
||||
stats: { type: [AccreditationStatSchema], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Success Stories
|
||||
const StorySchema = new Schema(
|
||||
{
|
||||
quote: { type: String, default: "" },
|
||||
name: { type: String, default: "" },
|
||||
program: { type: String, default: "" },
|
||||
avatar: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const SuccessStoriesSchema = new Schema(
|
||||
{
|
||||
heading: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
stories: { type: [StorySchema], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// CTA
|
||||
const CtaButtonSchema = new Schema(
|
||||
{
|
||||
label: { type: String, default: "" },
|
||||
href: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const CtaSchema = new Schema(
|
||||
{
|
||||
heading: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
primaryButton: { type: CtaButtonSchema, default: () => ({}) },
|
||||
secondaryButton: { type: CtaButtonSchema, default: () => ({}) },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Root schema
|
||||
const AboutSchema = new Schema(
|
||||
{
|
||||
hero: { type: HeroSchema, default: () => ({}) },
|
||||
leadership: { type: LeadershipSchema, default: () => ({}) },
|
||||
learningModel: { type: LearningModelSchema, default: () => ({}) },
|
||||
accreditation: { type: AccreditationSchema, default: () => ({}) },
|
||||
successStories: { type: SuccessStoriesSchema, default: () => ({}) },
|
||||
cta: { type: CtaSchema, default: () => ({}) },
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
strict: false,
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = mongoose.model("About", AboutSchema);
|
||||
@@ -1,206 +0,0 @@
|
||||
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);
|
||||
@@ -1,83 +0,0 @@
|
||||
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);
|
||||
@@ -1,106 +0,0 @@
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
// Clear cache
|
||||
if (mongoose.models.Booking) {
|
||||
delete mongoose.models.Booking;
|
||||
}
|
||||
if (mongoose.connection.models.Booking) {
|
||||
delete mongoose.connection.models.Booking;
|
||||
}
|
||||
|
||||
const bookingSchema = new mongoose.Schema(
|
||||
{
|
||||
hero: {
|
||||
title: String,
|
||||
backgroundImage: String,
|
||||
},
|
||||
|
||||
searchBar: {
|
||||
locationLabel: String,
|
||||
holidaySeasonLabel: String,
|
||||
searchButtonText: String,
|
||||
},
|
||||
|
||||
filterPanel: {
|
||||
title: String,
|
||||
priceTitle: String,
|
||||
priceLabel: String,
|
||||
pricePlaceholder: String,
|
||||
priceMin: Number,
|
||||
priceMax: Number,
|
||||
activitiesTitle: String,
|
||||
ageTitle: String,
|
||||
ageSelectPlaceholder: String,
|
||||
ageMin: Number,
|
||||
ageMax: Number,
|
||||
ratingTitle: String,
|
||||
ratingOptions: [
|
||||
{
|
||||
value: String,
|
||||
label: String,
|
||||
},
|
||||
],
|
||||
resetButtonText: String,
|
||||
},
|
||||
|
||||
programs: [
|
||||
{
|
||||
value: String,
|
||||
label: String,
|
||||
},
|
||||
],
|
||||
|
||||
holidays: [
|
||||
{
|
||||
value: String,
|
||||
label: String,
|
||||
},
|
||||
],
|
||||
|
||||
locations: [
|
||||
{
|
||||
value: String,
|
||||
label: String,
|
||||
},
|
||||
],
|
||||
|
||||
camps: [
|
||||
{
|
||||
name: String,
|
||||
price: Number,
|
||||
priceText: String,
|
||||
season: [String],
|
||||
age: [Number],
|
||||
locations: [String],
|
||||
image: String,
|
||||
link: String,
|
||||
program: String,
|
||||
rating: Number,
|
||||
},
|
||||
],
|
||||
|
||||
// Configuration - Dùng Mixed type để chấp nhận bất kỳ structure nào
|
||||
configuration: mongoose.Schema.Types.Mixed,
|
||||
|
||||
formSteps: [
|
||||
{
|
||||
step: Number,
|
||||
title: String,
|
||||
sections: [
|
||||
{
|
||||
id: String,
|
||||
fields: [mongoose.Schema.Types.Mixed],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
validation: mongoose.Schema.Types.Mixed,
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
strict: false
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = mongoose.model("Booking", bookingSchema);
|
||||
@@ -1,200 +0,0 @@
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
// Clear cache
|
||||
if (mongoose.models.BookingSubmission) {
|
||||
delete mongoose.models.BookingSubmission;
|
||||
}
|
||||
if (mongoose.connection.models.BookingSubmission) {
|
||||
delete mongoose.connection.models.BookingSubmission;
|
||||
}
|
||||
|
||||
const bookingSubmissionSchema = new mongoose.Schema(
|
||||
{
|
||||
// Liên kết với activity và session
|
||||
activityId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Activity',
|
||||
required: true
|
||||
},
|
||||
sessionId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Thông tin người đăng ký
|
||||
parentFirstName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
parentLastName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
lowercase: true
|
||||
},
|
||||
phone: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
|
||||
// Thông tin địa chỉ
|
||||
address: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
city: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
country: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
postalCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
|
||||
// Thông tin người tham gia
|
||||
participantFirstName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
participantLastName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
participantBirthDate: {
|
||||
type: Date,
|
||||
required: true
|
||||
},
|
||||
participantGender: {
|
||||
type: String,
|
||||
enum: ['male', 'female', 'other'],
|
||||
required: true
|
||||
},
|
||||
numberOfParticipants: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1
|
||||
},
|
||||
|
||||
// Thông tin y tế và đặc biệt
|
||||
medicalConditions: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: ''
|
||||
},
|
||||
dietaryRestrictions: {
|
||||
type: String,
|
||||
enum: ['none', 'vegetarian', 'vegan', 'halal', 'kosher', 'gluten-free', 'other'],
|
||||
default: 'none'
|
||||
},
|
||||
specialRequests: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// Thông tin liên hệ khẩn cấp
|
||||
emergencyContact: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
emergencyPhone: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
|
||||
// Điều khoản và thông báo
|
||||
agreeTerms: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
agreeNewsletter: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// Trạng thái đăng ký
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'confirmed', 'cancelled', 'completed'],
|
||||
default: 'pending'
|
||||
},
|
||||
|
||||
// Ghi chú admin
|
||||
adminNotes: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// Thông tin thanh toán
|
||||
paymentStatus: {
|
||||
type: String,
|
||||
enum: ['pending', 'partial', 'paid', 'refunded'],
|
||||
default: 'pending'
|
||||
},
|
||||
totalAmount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
paidAmount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
}
|
||||
);
|
||||
|
||||
// Virtual để tính tuổi của participant
|
||||
bookingSubmissionSchema.virtual('participantAge').get(function() {
|
||||
if (this.participantBirthDate) {
|
||||
const today = new Date();
|
||||
const birthDate = new Date(this.participantBirthDate);
|
||||
let age = today.getFullYear() - birthDate.getFullYear();
|
||||
const monthDiff = today.getMonth() - birthDate.getMonth();
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Virtual để lấy thông tin activity
|
||||
bookingSubmissionSchema.virtual('activity', {
|
||||
ref: 'Activity',
|
||||
localField: 'activityId',
|
||||
foreignField: '_id',
|
||||
justOne: true
|
||||
});
|
||||
|
||||
// Index for better performance
|
||||
bookingSubmissionSchema.index({ activityId: 1, sessionId: 1 });
|
||||
bookingSubmissionSchema.index({ email: 1 });
|
||||
bookingSubmissionSchema.index({ status: 1 });
|
||||
bookingSubmissionSchema.index({ createdAt: -1 });
|
||||
|
||||
module.exports = mongoose.model("BookingSubmission", bookingSubmissionSchema);
|
||||
@@ -1,213 +1,102 @@
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
// Schema cho menu links
|
||||
const menuLinkSchema = new mongoose.Schema(
|
||||
const { Schema } = mongoose;
|
||||
|
||||
// Brand
|
||||
const LogoSchema = new Schema(
|
||||
{
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
order: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 0,
|
||||
},
|
||||
image: { type: String, default: "" },
|
||||
href: { type: String, default: "/" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Schema cho social links
|
||||
const socialLinkSchema = new mongoose.Schema(
|
||||
const SocialItemSchema = new Schema(
|
||||
{
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
icon: { type: String, default: "" },
|
||||
href: { type: String, default: "#" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Schema cho phone
|
||||
const phoneSchema = new mongoose.Schema(
|
||||
const BrandSchema = new Schema(
|
||||
{
|
||||
display: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "",
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "",
|
||||
},
|
||||
logo: { type: LogoSchema, default: () => ({}) },
|
||||
description: { type: String, default: "" },
|
||||
social: { type: [SocialItemSchema], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Schema cho logo
|
||||
const logoSchema = new mongoose.Schema(
|
||||
// Explore
|
||||
const ExploreLinkSchema = new Schema(
|
||||
{
|
||||
src: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "",
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "",
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "/",
|
||||
},
|
||||
label: { type: String, default: "" },
|
||||
href: { type: String, default: "#" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Schema cho copyright
|
||||
const copyrightSchema = new mongoose.Schema(
|
||||
const ExploreSchema = new Schema(
|
||||
{
|
||||
text: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "Copyright©",
|
||||
},
|
||||
brand: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "",
|
||||
},
|
||||
rights: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "All Rights Reserved.",
|
||||
},
|
||||
heading: { type: String, default: "" },
|
||||
links: { type: [ExploreLinkSchema], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Schema cho top section
|
||||
const topSchema = new mongoose.Schema(
|
||||
// Contact
|
||||
const ContactSchema = new Schema(
|
||||
{
|
||||
bgImage: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "",
|
||||
},
|
||||
phone: {
|
||||
type: phoneSchema,
|
||||
default: () => ({ display: "", href: "" }),
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "",
|
||||
},
|
||||
logo: {
|
||||
type: logoSchema,
|
||||
default: () => ({ src: "", alt: "", href: "/" }),
|
||||
},
|
||||
menuLinks: {
|
||||
type: [menuLinkSchema],
|
||||
default: [],
|
||||
},
|
||||
socialLinks: {
|
||||
type: [socialLinkSchema],
|
||||
default: [],
|
||||
},
|
||||
heading: { type: String, default: "" },
|
||||
address: { type: String, default: "" },
|
||||
phone: { type: String, default: "" },
|
||||
email: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Schema cho bottom section
|
||||
const bottomSchema = new mongoose.Schema(
|
||||
// Newsletter
|
||||
const NewsletterSchema = new Schema(
|
||||
{
|
||||
copyright: {
|
||||
type: copyrightSchema,
|
||||
default: () => ({ text: "Copyright©", brand: "", rights: "All Rights Reserved." }),
|
||||
},
|
||||
menuLinks: {
|
||||
type: [menuLinkSchema],
|
||||
default: [],
|
||||
},
|
||||
heading: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
placeholder: { type: String, default: "" },
|
||||
buttonText: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Main Footer Schema - khớp 100% với footer.json
|
||||
const footerSchema = new mongoose.Schema(
|
||||
// Bottom
|
||||
const BottomLinkSchema = new Schema(
|
||||
{
|
||||
top: {
|
||||
type: topSchema,
|
||||
default: () => ({
|
||||
bgImage: "",
|
||||
phone: { display: "", href: "" },
|
||||
address: "",
|
||||
logo: { src: "", alt: "", href: "/" },
|
||||
menuLinks: [],
|
||||
socialLinks: [],
|
||||
}),
|
||||
label: { type: String, default: "" },
|
||||
href: { type: String, default: "#" },
|
||||
},
|
||||
bottom: {
|
||||
type: bottomSchema,
|
||||
default: () => ({
|
||||
copyright: { text: "Copyright©", brand: "", rights: "All Rights Reserved." },
|
||||
menuLinks: [],
|
||||
}),
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const BottomSchema = new Schema(
|
||||
{
|
||||
copyright: { type: String, default: "" },
|
||||
links: { type: [BottomLinkSchema], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Root
|
||||
const FooterSchema = new Schema(
|
||||
{
|
||||
brand: { type: BrandSchema, default: () => ({}) },
|
||||
explore: { type: ExploreSchema, default: () => ({}) },
|
||||
contact: { type: ContactSchema, default: () => ({}) },
|
||||
newsletter: { type: NewsletterSchema, default: () => ({}) },
|
||||
bottom: { type: BottomSchema, default: () => ({}) },
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
strict: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Static method để lấy hoặc tạo footer duy nhất
|
||||
footerSchema.statics.getSingle = async function () {
|
||||
let footer = await this.findOne();
|
||||
if (!footer) {
|
||||
footer = await this.create({});
|
||||
}
|
||||
return footer;
|
||||
};
|
||||
|
||||
// Migration method để import từ JSON hiện tại
|
||||
footerSchema.statics.migrateFromJson = async function (jsonData) {
|
||||
try {
|
||||
// Xóa tất cả documents hiện có
|
||||
await this.deleteMany({});
|
||||
|
||||
// Tạo document mới
|
||||
const footer = await this.create(jsonData);
|
||||
console.log("Footer data migrated successfully");
|
||||
return footer;
|
||||
} catch (error) {
|
||||
console.error("Error migrating footer data:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = mongoose.model("Footer", footerSchema);
|
||||
module.exports = mongoose.model("Footer", FooterSchema);
|
||||
|
||||
@@ -1,84 +1,7 @@
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
const socialLinkSchema = new mongoose.Schema(
|
||||
{
|
||||
platform: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ["linkedin", "twitter", "instagram", "youtube", "facebook"],
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: String,
|
||||
order: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const languageSchema = new mongoose.Schema(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const menuItemSchema = new mongoose.Schema(
|
||||
{
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: String,
|
||||
order: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
children: [this],
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const headerSchema = new mongoose.Schema(
|
||||
{
|
||||
// Top bar
|
||||
top: {
|
||||
phone: String,
|
||||
email: String,
|
||||
location: String,
|
||||
socialLinks: [socialLinkSchema],
|
||||
languages: [languageSchema],
|
||||
},
|
||||
|
||||
// Offcanvas
|
||||
offcanvas: {
|
||||
description: String,
|
||||
contactInfo: {
|
||||
address: String,
|
||||
email: String,
|
||||
workingHours: String,
|
||||
phone: String,
|
||||
},
|
||||
},
|
||||
|
||||
// Menu
|
||||
menu: [menuItemSchema],
|
||||
|
||||
// Logo
|
||||
logo: {
|
||||
light: String,
|
||||
@@ -86,6 +9,18 @@ const headerSchema = new mongoose.Schema(
|
||||
alt: String,
|
||||
},
|
||||
|
||||
// Sign In Button
|
||||
signInButton: {
|
||||
label: {
|
||||
type: String,
|
||||
default: "Sign In",
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: "/signin",
|
||||
},
|
||||
},
|
||||
|
||||
// CTA Button
|
||||
ctaButton: {
|
||||
label: String,
|
||||
|
||||
@@ -3,49 +3,41 @@ const mongoose = require("mongoose");
|
||||
const { Schema } = mongoose;
|
||||
|
||||
// Reusable small schemas
|
||||
const LinkSchema = new Schema(
|
||||
const FloatingBadgeSchema = new Schema(
|
||||
{
|
||||
icon: { type: String, default: "" },
|
||||
value: { type: String, default: "" },
|
||||
label: { type: String, default: "" },
|
||||
href: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Hero slide (for multiple hero items in slider)
|
||||
const HeroSlideSchema = new Schema(
|
||||
{
|
||||
title: { type: String, default: "" },
|
||||
subtitle: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
primaryButton: { type: LinkSchema, default: () => ({}) },
|
||||
secondaryButton: { type: LinkSchema, default: () => ({}) },
|
||||
heroImage: { type: String, default: "" },
|
||||
videoUrl: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const HeroSchema = new Schema(
|
||||
{
|
||||
// Background for whole hero section
|
||||
backgroundImage: { type: String, default: "" },
|
||||
|
||||
// Multiple slides
|
||||
slides: { type: [HeroSlideSchema], default: [] },
|
||||
|
||||
// Legacy single-slide fields (backward compatible)
|
||||
badge: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
subtitle: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
primaryButton: { type: LinkSchema, default: () => ({}) },
|
||||
secondaryButton: { type: LinkSchema, default: () => ({}) },
|
||||
heroImage: { type: String, default: "" },
|
||||
videoUrl: { type: String, default: "" },
|
||||
searchPlaceholder: { type: String, default: "" },
|
||||
buttonLabel: { type: String, default: "" },
|
||||
image: { type: String, default: "" },
|
||||
imageAlt: { type: String, default: "" },
|
||||
floatingBadge: { type: FloatingBadgeSchema, default: () => ({}) },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const WhyChooseUsItemSchema = new Schema(
|
||||
const QuickLinkItemSchema = new Schema(
|
||||
{
|
||||
icon: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
linkText: { type: String, default: "" },
|
||||
href: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const ValuePropFeatureSchema = new Schema(
|
||||
{
|
||||
icon: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
@@ -54,218 +46,68 @@ const WhyChooseUsItemSchema = new Schema(
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const WhyChooseUsSchema = new Schema(
|
||||
{
|
||||
heading: { type: String, default: "" },
|
||||
subheading: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
highlightWord: { type: String, default: "" },
|
||||
mainImage: { type: String, default: "" },
|
||||
secondaryImage: { type: String, default: "" },
|
||||
items: { type: [WhyChooseUsItemSchema], default: [] },
|
||||
features: { type: [String], default: [] },
|
||||
ctaButton: { type: LinkSchema, default: () => ({}) },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const VisaSolutionItemSchema = new Schema(
|
||||
{
|
||||
number: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
link: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const VisaSolutionsSchema = new Schema(
|
||||
{
|
||||
heading: { type: String, default: "" },
|
||||
subheading: { type: String, default: "" },
|
||||
items: { type: [VisaSolutionItemSchema], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const VisaCountrySchema = new Schema(
|
||||
{
|
||||
name: { type: String, default: "" },
|
||||
code: { type: String, default: "" },
|
||||
flag: { type: String, default: "" },
|
||||
link: { type: String, default: "" },
|
||||
visaTypes: { type: [String], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const VisaCountriesSchema = new Schema(
|
||||
{
|
||||
heading: { type: String, default: "" },
|
||||
subheading: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
countries: { type: [VisaCountrySchema], default: [] },
|
||||
ctaButton: { type: LinkSchema, default: () => ({}) },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const TestimonialSchema = new Schema(
|
||||
{
|
||||
name: { type: String, default: "" },
|
||||
role: { type: String, default: "" },
|
||||
country: { type: String, default: "" },
|
||||
rating: { type: Number, default: 5 },
|
||||
comment: { type: String, default: "" },
|
||||
avatar: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const TestimonialsSchema = new Schema(
|
||||
{
|
||||
heading: { type: String, default: "" },
|
||||
subheading: { type: String, default: "" },
|
||||
videoUrl: { type: String, default: "" },
|
||||
videoThumbnail: { type: String, default: "" },
|
||||
items: { type: [TestimonialSchema], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const VideoGallerySchema = new Schema(
|
||||
{
|
||||
heading: { type: String, default: "" },
|
||||
videoUrl: { type: String, default: "" },
|
||||
thumbnail: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const FaqItemSchema = new Schema(
|
||||
{
|
||||
question: { type: String, default: "" },
|
||||
answer: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const FaqSchema = new Schema(
|
||||
{
|
||||
heading: { type: String, default: "" },
|
||||
subheading: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
ctaButton: { type: LinkSchema, default: () => ({}) },
|
||||
items: { type: [FaqItemSchema], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const AchievementItemSchema = new Schema(
|
||||
const ValuePropStatSchema = new Schema(
|
||||
{
|
||||
value: { type: String, default: "" },
|
||||
suffix: { type: String, default: "" },
|
||||
label: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
image: { type: String, default: "" },
|
||||
imageAlt: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const AchievementsSchema = new Schema(
|
||||
{
|
||||
heading: { type: String, default: "" },
|
||||
subheading: { type: String, default: "" },
|
||||
items: { type: [AchievementItemSchema], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const VisaConsultancyItemSchema = new Schema(
|
||||
{
|
||||
name: { type: String, default: "" },
|
||||
icon: { type: String, default: "" },
|
||||
year: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const VisaConsultancySchema = new Schema(
|
||||
{
|
||||
items: { type: [VisaConsultancyItemSchema], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const BrandItemSchema = new Schema(
|
||||
{
|
||||
logo: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const BrandsSchema = new Schema(
|
||||
{
|
||||
items: { type: [BrandItemSchema], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const PartnersSchema = new Schema(
|
||||
{
|
||||
visaConsultancy: { type: VisaConsultancySchema, default: () => ({}) },
|
||||
brands: { type: BrandsSchema, default: () => ({}) },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const BlogPreviewItemSchema = new Schema(
|
||||
const ValuePropSchema = new Schema(
|
||||
{
|
||||
badge: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
excerpt: { type: String, default: "" },
|
||||
category: { type: String, default: "" },
|
||||
date: { type: String, default: "" }, // keep as string for easy JSON compatibility (e.g. "2025-08-20")
|
||||
author: {
|
||||
name: { type: String, default: "" },
|
||||
avatar: { type: String, default: "" },
|
||||
},
|
||||
comments: { type: Number, default: 0 },
|
||||
link: { type: String, default: "" },
|
||||
thumbnail: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
features: { type: [ValuePropFeatureSchema], default: [] },
|
||||
stats: { type: [ValuePropStatSchema], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const BlogPreviewSchema = new Schema(
|
||||
const ProgramItemSchema = new Schema(
|
||||
{
|
||||
category: { type: String, default: "" },
|
||||
image: { type: String, default: "" },
|
||||
duration: { type: String, default: "" },
|
||||
rating: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
studentCount: { type: String, default: "" },
|
||||
href: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const ProgramsSchema = new Schema(
|
||||
{
|
||||
heading: { type: String, default: "" },
|
||||
subheading: { type: String, default: "" },
|
||||
ctaButton: { type: LinkSchema, default: () => ({}) },
|
||||
items: { type: [BlogPreviewItemSchema], default: [] },
|
||||
selectedBlogIds: [{ type: Schema.Types.ObjectId, ref: 'Blog' }],
|
||||
description: { type: String, default: "" },
|
||||
items: { type: [ProgramItemSchema], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const RequestInfoSchema = new Schema(
|
||||
{
|
||||
heading: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
phone: { type: String, default: "" },
|
||||
email: { type: String, default: "" },
|
||||
programs: { type: [String], default: [] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* Home page content model
|
||||
*
|
||||
* NOTE:
|
||||
* - This schema is based on `hailearning.edu.vn/app/home.json`.
|
||||
* - `strict: false` keeps backward compatibility with any existing CMS-only sections
|
||||
* (e.g. about/missionVision/programs/newsletter/latestPosts...) that the admin UI might still send.
|
||||
*/
|
||||
const HomeSchema = new Schema(
|
||||
{
|
||||
hero: { type: HeroSchema, default: () => ({}) },
|
||||
whyChooseUs: { type: WhyChooseUsSchema, default: () => ({}) },
|
||||
visaSolutions: { type: VisaSolutionsSchema, default: () => ({}) },
|
||||
visaCountries: { type: VisaCountriesSchema, default: () => ({}) },
|
||||
testimonials: { type: TestimonialsSchema, default: () => ({}) },
|
||||
videoGallery: { type: VideoGallerySchema, default: () => ({}) },
|
||||
faq: { type: FaqSchema, default: () => ({}) },
|
||||
achievements: { type: AchievementsSchema, default: () => ({}) },
|
||||
partners: { type: PartnersSchema, default: () => ({}) },
|
||||
blogPreview: { type: BlogPreviewSchema, default: () => ({}) },
|
||||
quickLinks: { type: [QuickLinkItemSchema], default: [] },
|
||||
valueProp: { type: ValuePropSchema, default: () => ({}) },
|
||||
programs: { type: ProgramsSchema, default: () => ({}) },
|
||||
requestInfo: { type: RequestInfoSchema, default: () => ({}) },
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
@@ -274,4 +116,3 @@ const HomeSchema = new Schema(
|
||||
);
|
||||
|
||||
module.exports = mongoose.model("Home", HomeSchema);
|
||||
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
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);
|
||||
@@ -1,118 +0,0 @@
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
// Define sub-schemas first
|
||||
const authorSchema = new mongoose.Schema(
|
||||
{
|
||||
name: String,
|
||||
type: String,
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const clientReviewSchema = new mongoose.Schema(
|
||||
{
|
||||
id: String,
|
||||
rating: Number,
|
||||
content: String,
|
||||
author: authorSchema,
|
||||
icon: String,
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const featureSchema = new mongoose.Schema(
|
||||
{
|
||||
title: String,
|
||||
description: String,
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const faqSchema = new mongoose.Schema(
|
||||
{
|
||||
id: String,
|
||||
question: String,
|
||||
answer: String,
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const serviceDetailsSchema = new mongoose.Schema(
|
||||
{
|
||||
title: String,
|
||||
description: String,
|
||||
mainImage: String,
|
||||
overviewTitle: String,
|
||||
overviewDescription: String,
|
||||
additionalDescription: String,
|
||||
keyFeaturesTitle: String,
|
||||
keyFeaturesImage: String,
|
||||
features: [featureSchema],
|
||||
faqTitle: String,
|
||||
faqImage: String,
|
||||
faq: [faqSchema],
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Main service page schema
|
||||
const serviceSchema = new mongoose.Schema(
|
||||
{
|
||||
pageTitle: String,
|
||||
|
||||
// Main services section
|
||||
services: {
|
||||
title: {
|
||||
subTitle: String,
|
||||
mainTitle: String,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
slug: String,
|
||||
name: String,
|
||||
description: String,
|
||||
image: String,
|
||||
layout: String,
|
||||
details: serviceDetailsSchema,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Destination countries section
|
||||
destinations: {
|
||||
backgroundImage: String,
|
||||
title: {
|
||||
subTitle: String,
|
||||
mainTitle: String,
|
||||
},
|
||||
},
|
||||
|
||||
// Visa types section
|
||||
visas: {
|
||||
items: [
|
||||
{
|
||||
id: String,
|
||||
number: String,
|
||||
name: String,
|
||||
description: String,
|
||||
buttonText: String,
|
||||
buttonLink: String,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Client reviews section
|
||||
reviews: {
|
||||
title: {
|
||||
subTitle: String,
|
||||
mainTitle: String,
|
||||
},
|
||||
thumb: String,
|
||||
items: [clientReviewSchema],
|
||||
},
|
||||
},
|
||||
{ timestamps: true },
|
||||
);
|
||||
|
||||
module.exports = mongoose.model("Service", serviceSchema);
|
||||
@@ -1,234 +0,0 @@
|
||||
// models/visa.js
|
||||
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
// ==================== SCHEMAS ====================
|
||||
|
||||
// VisaItem Schema
|
||||
const VisaItemSchema = new mongoose.Schema(
|
||||
{
|
||||
title: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// VisaTypeCategory Schema
|
||||
const VisaTypeCategorySchema = new mongoose.Schema(
|
||||
{
|
||||
category: { type: String, default: "" },
|
||||
items: [VisaItemSchema],
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// VisaProcessStep Schema
|
||||
const VisaProcessStepSchema = new mongoose.Schema(
|
||||
{
|
||||
number: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// VisaProcess Schema
|
||||
const VisaProcessSchema = new mongoose.Schema(
|
||||
{
|
||||
title: { type: String, default: "" },
|
||||
steps: [VisaProcessStepSchema],
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// VisaCategory Schema
|
||||
const VisaCategorySchema = new mongoose.Schema(
|
||||
{
|
||||
title: { type: String, default: "" },
|
||||
steps: {
|
||||
type: [[String]],
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// VisaService Schema
|
||||
const VisaServiceSchema = new mongoose.Schema(
|
||||
{
|
||||
title: { type: String, default: "" },
|
||||
steps: [VisaProcessStepSchema],
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// RelatedCountry Schema
|
||||
const RelatedCountrySchema = new mongoose.Schema(
|
||||
{
|
||||
id: { type: Number, default: 0 },
|
||||
name: { type: String, default: "" },
|
||||
icon: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Phone Schema
|
||||
const PhoneSchema = new mongoose.Schema(
|
||||
{
|
||||
label: { type: String, default: "" },
|
||||
value: { type: String, default: "" },
|
||||
link: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Email Schema
|
||||
const EmailSchema = new mongoose.Schema(
|
||||
{
|
||||
label: { type: String, default: "" },
|
||||
value: { type: String, default: "" },
|
||||
link: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Location Schema
|
||||
const LocationSchema = new mongoose.Schema(
|
||||
{
|
||||
label: { type: String, default: "" },
|
||||
address: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// ContactInfo Schema
|
||||
const ContactInfoSchema = new mongoose.Schema(
|
||||
{
|
||||
img: { type: String, default: "" },
|
||||
sectionTitle: { type: String, default: "" },
|
||||
helpText: { type: String, default: "" },
|
||||
|
||||
phone: {
|
||||
type: PhoneSchema,
|
||||
default: () => ({}),
|
||||
},
|
||||
email: {
|
||||
type: EmailSchema,
|
||||
default: () => ({}),
|
||||
},
|
||||
location: {
|
||||
type: LocationSchema,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// ActiveCountry Schema
|
||||
const ActiveCountrySchema = new mongoose.Schema(
|
||||
{
|
||||
id: { type: Number, default: 0 },
|
||||
name: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
mainImage: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
additionalInfo: { type: String, default: "" },
|
||||
tagline: { type: String, default: "" },
|
||||
visaTypes: [VisaTypeCategorySchema],
|
||||
visaProcess: {
|
||||
type: VisaProcessSchema,
|
||||
default: null,
|
||||
},
|
||||
gallery: {
|
||||
type: [String],
|
||||
default: [],
|
||||
},
|
||||
visaCategories: {
|
||||
type: VisaCategorySchema,
|
||||
default: null,
|
||||
},
|
||||
visaService: {
|
||||
type: VisaServiceSchema,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// DetailedView Schema
|
||||
const DetailedViewSchema = new mongoose.Schema(
|
||||
{
|
||||
activeCountry: {
|
||||
type: ActiveCountrySchema,
|
||||
default: null,
|
||||
},
|
||||
relatedCountries: {
|
||||
type: [RelatedCountrySchema],
|
||||
default: [],
|
||||
},
|
||||
contactInfo: {
|
||||
type: ContactInfoSchema,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// ==================== MAIN VISA COUNTRY SCHEMA ====================
|
||||
|
||||
// Main VisaCountry Schema (Individual country object)
|
||||
const VisaCountrySchema = new mongoose.Schema(
|
||||
{
|
||||
// Không dùng `index: true` ở đây vì đã tạo index riêng cho hero.summaryList.* bên dưới
|
||||
id: { type: Number, required: true },
|
||||
name: { type: String, required: true },
|
||||
slug: { type: String, required: true },
|
||||
icon: { type: String, default: "" },
|
||||
services: {
|
||||
type: [String],
|
||||
default: [],
|
||||
},
|
||||
detailedView: {
|
||||
type: DetailedViewSchema,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// ==================== HERO SCHEMA ====================
|
||||
|
||||
const HeroSchema = new mongoose.Schema(
|
||||
{
|
||||
title: { type: String, default: "Visa" },
|
||||
summaryList: {
|
||||
type: [VisaCountrySchema],
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// ==================== MAIN VISA SCHEMA ====================
|
||||
|
||||
const visaDataSchema = new mongoose.Schema(
|
||||
{
|
||||
hero: {
|
||||
type: HeroSchema,
|
||||
default: () => ({ title: "Visa", summaryList: [] }),
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
|
||||
// ==================== INDEXES ====================
|
||||
|
||||
visaDataSchema.index({ "hero.summaryList.slug": 1 });
|
||||
visaDataSchema.index({ "hero.summaryList.id": 1 });
|
||||
visaDataSchema.index({ "hero.summaryList.name": 1 });
|
||||
|
||||
// ==================== MODEL ====================
|
||||
|
||||
module.exports = mongoose.models.Visa || mongoose.model("Visa", visaDataSchema);
|
||||
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 427 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 427 KiB |
@@ -3,6 +3,7 @@ const router = express.Router();
|
||||
const { ensureAuthenticated } = require("../middleware/auth");
|
||||
const dashboardController = require("../controllers/dashboardController");
|
||||
const uploadController = require("../controllers/uploadController");
|
||||
|
||||
const homeController = require("../controllers/homeController");
|
||||
const headerController = require("../controllers/headerController");
|
||||
const footerController = require("../controllers/footerController");
|
||||
@@ -16,20 +17,18 @@ const formController = require("../controllers/formController");
|
||||
const contactController = require("../controllers/contactController");
|
||||
const studentSupportController = require("../controllers/studentSupportController");
|
||||
const requestInfoController = require("../controllers/requestInfoController");
|
||||
|
||||
const pageController = require("../controllers/pageController");
|
||||
const settingController = require("../controllers/settingController");
|
||||
const faqController = require("../controllers/faqController"); // Thêm import này
|
||||
const termsController = require("../controllers/termsController");
|
||||
const travelController = require("../controllers/travelController");
|
||||
const visaController = require("../controllers/visaController");
|
||||
const { upload, uploadVideo, convertToWebp } = require("../middleware/upload");
|
||||
const safetyController = require("../controllers/safetyController");
|
||||
const insuranceController = require("../controllers/insuranceController");
|
||||
const auditLogController = require("../controllers/auditLogController");
|
||||
|
||||
const activityController = require("../controllers/activityController");
|
||||
const bookingSubmissionController = require("../controllers/bookingSubmissionController");
|
||||
const serviceController = require("../controllers/serviceController");
|
||||
|
||||
const headerMenuController = require("../controllers/headerMenuController");
|
||||
const programmeController = require("../controllers/programmeController");
|
||||
|
||||
@@ -47,7 +46,7 @@ router.get("/dashboard", ensureAuthenticated, dashboardController.getDashboard);
|
||||
// Home
|
||||
router.get("/home", ensureAuthenticated, homeController.index);
|
||||
router.post("/home/update", ensureAuthenticated, homeController.update);
|
||||
router.get("/home/api/blogs", ensureAuthenticated, homeController.apiGetBlogs);
|
||||
// router.get("/home/api/blogs", ensureAuthenticated, homeController.apiGetBlogs);
|
||||
|
||||
// Middleware chuẩn hóa code
|
||||
router.param("code", (req, res, next, code) => {
|
||||
@@ -201,7 +200,6 @@ router.post(
|
||||
// Footer routes
|
||||
router.get("/footer", ensureAuthenticated, footerController.index);
|
||||
router.post("/footer/update", ensureAuthenticated, footerController.update);
|
||||
router.get("/footer/data", ensureAuthenticated, footerController.getFooterData);
|
||||
|
||||
// Contact routes
|
||||
router.get("/contact", ensureAuthenticated, contactController.index);
|
||||
@@ -248,51 +246,6 @@ router.post(
|
||||
requestInfoController.update,
|
||||
);
|
||||
|
||||
// Appointment management
|
||||
const appointmentController = require("../controllers/appointmentController");
|
||||
router.get(
|
||||
"/appointments",
|
||||
ensureAuthenticated,
|
||||
appointmentController.getAppointments,
|
||||
);
|
||||
router.get(
|
||||
"/appointments/:id",
|
||||
ensureAuthenticated,
|
||||
appointmentController.getAppointmentById,
|
||||
);
|
||||
router.put(
|
||||
"/appointments/:id",
|
||||
ensureAuthenticated,
|
||||
appointmentController.updateAppointmentStatus,
|
||||
);
|
||||
router.delete(
|
||||
"/appointments/:id",
|
||||
ensureAuthenticated,
|
||||
appointmentController.deleteAppointment,
|
||||
);
|
||||
|
||||
// Appointment CMS page management
|
||||
router.get("/appointment", ensureAuthenticated, appointmentController.index);
|
||||
router.post(
|
||||
"/appointment/update",
|
||||
ensureAuthenticated,
|
||||
appointmentController.update,
|
||||
);
|
||||
router.get(
|
||||
"/appointment/data",
|
||||
ensureAuthenticated,
|
||||
appointmentController.getAppointmentData,
|
||||
);
|
||||
|
||||
// Pricing CMS page management
|
||||
const pricingController = require("../controllers/pricingController");
|
||||
router.get("/pricing", ensureAuthenticated, pricingController.index);
|
||||
router.post("/pricing/update", ensureAuthenticated, pricingController.update);
|
||||
router.get(
|
||||
"/pricing/data",
|
||||
ensureAuthenticated,
|
||||
pricingController.getPricingData,
|
||||
);
|
||||
|
||||
// Activity CRUD routes
|
||||
router.get("/activity", ensureAuthenticated, activityController.index);
|
||||
@@ -363,18 +316,6 @@ router.get(
|
||||
ensureAuthenticated,
|
||||
activityController.exportAllBookingsData,
|
||||
);
|
||||
// Update booking submission
|
||||
router.put(
|
||||
"/bookings/:bookingId",
|
||||
ensureAuthenticated,
|
||||
bookingSubmissionController.updateBookingSubmission,
|
||||
);
|
||||
// Delete booking submission
|
||||
router.delete(
|
||||
"/bookings/:bookingId",
|
||||
ensureAuthenticated,
|
||||
bookingSubmissionController.deleteBookingSubmission,
|
||||
);
|
||||
|
||||
// Update filters
|
||||
|
||||
@@ -412,12 +353,12 @@ router.get("/terms/api", termsController.api);
|
||||
router.get("/terms/seed", ensureAuthenticated, termsController.seed);
|
||||
|
||||
// Travel routes
|
||||
router.get("/travel", ensureAuthenticated, travelController.index);
|
||||
router.post("/travel/update", ensureAuthenticated, travelController.update);
|
||||
router.post("/travel/preview", ensureAuthenticated, travelController.preview);
|
||||
router.get("/travel/data", ensureAuthenticated, travelController.getTravelData);
|
||||
router.get("/travel/api", travelController.api);
|
||||
router.get("/travel/seed", ensureAuthenticated, travelController.seed);
|
||||
// router.get("/travel", ensureAuthenticated, travelController.index);
|
||||
// router.post("/travel/update", ensureAuthenticated, travelController.update);
|
||||
// router.post("/travel/preview", ensureAuthenticated, travelController.preview);
|
||||
// router.get("/travel/data", ensureAuthenticated, travelController.getTravelData);
|
||||
// router.get("/travel/api", travelController.api);
|
||||
// router.get("/travel/seed", ensureAuthenticated, travelController.seed);
|
||||
|
||||
// Deprecated FAQ API routes removed
|
||||
|
||||
@@ -462,30 +403,6 @@ router.post(
|
||||
insuranceController.update,
|
||||
);
|
||||
|
||||
// Service routes
|
||||
router.get("/service", ensureAuthenticated, serviceController.index);
|
||||
router.post("/service/update", ensureAuthenticated, serviceController.update);
|
||||
router.post(
|
||||
"/service/generate-slug",
|
||||
ensureAuthenticated,
|
||||
serviceController.generateSlug,
|
||||
);
|
||||
router.get("/service/:slug/edit", ensureAuthenticated, serviceController.edit);
|
||||
router.post(
|
||||
"/service/:slug/edit",
|
||||
ensureAuthenticated,
|
||||
serviceController.updateService,
|
||||
);
|
||||
router.get(
|
||||
"/service/:slug/details",
|
||||
ensureAuthenticated,
|
||||
serviceController.details,
|
||||
);
|
||||
router.post(
|
||||
"/service/:slug/details/update",
|
||||
ensureAuthenticated,
|
||||
serviceController.updateDetails,
|
||||
);
|
||||
|
||||
// Test Image Paths route
|
||||
router.get("/test-images", ensureAuthenticated, (req, res) => {
|
||||
@@ -550,31 +467,6 @@ router.get("/test-images", ensureAuthenticated, (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Display visa management page
|
||||
router.get("/visa", ensureAuthenticated, visaController.index);
|
||||
|
||||
// Get country data for editing
|
||||
router.get("/visa/edit/:id", ensureAuthenticated, visaController.getCountry);
|
||||
|
||||
// Update hero title
|
||||
router.post("/visa/update", ensureAuthenticated, visaController.updateCountry);
|
||||
|
||||
// Add new country
|
||||
router.post("/visa/add", ensureAuthenticated, visaController.addCountry);
|
||||
|
||||
// Update single country
|
||||
router.put(
|
||||
"/visa/update/:id",
|
||||
ensureAuthenticated,
|
||||
visaController.updateCountry,
|
||||
);
|
||||
|
||||
// Delete country
|
||||
router.delete(
|
||||
"/visa/delete/:id",
|
||||
ensureAuthenticated,
|
||||
visaController.deleteCountry,
|
||||
);
|
||||
|
||||
// Programme Management
|
||||
router.get("/programme", ensureAuthenticated, programmeController.index);
|
||||
|
||||
@@ -9,25 +9,23 @@ const accreditationController = require("../controllers/accreditationController"
|
||||
const admissionsController = require("../controllers/admissionsController");
|
||||
const policiesController = require("../controllers/policiesController");
|
||||
const headerController = require("../controllers/headerController");
|
||||
|
||||
const socialLinkController = require("../controllers/socialLinkController");
|
||||
|
||||
const footerController = require("../controllers/footerController");
|
||||
const contactController = require("../controllers/contactController");
|
||||
const studentSupportController = require("../controllers/studentSupportController");
|
||||
const requestInfoController = require("../controllers/requestInfoController");
|
||||
|
||||
const faqController = require("../controllers/faqController");
|
||||
const visaController = require("../controllers/visaController");
|
||||
const headerMenuController = require("../controllers/headerMenuController");
|
||||
const safetyController = require("../controllers/safetyController");
|
||||
// Booking flow removed
|
||||
|
||||
const programmeController = require("../controllers/programmeController");
|
||||
const insuranceController = require("../controllers/insuranceController");
|
||||
const termsController = require("../controllers/termsController"); // <-- IMPORT ĐÃ CÓ
|
||||
const activityController = require("../controllers/activityController");
|
||||
const travelController = require("../controllers/travelController");
|
||||
const bookingSubmissionController = require("../controllers/bookingSubmissionController");
|
||||
|
||||
const serviceController = require("../controllers/serviceController");
|
||||
// Blog controllers
|
||||
const blogController = require("../controllers/blogController");
|
||||
const blogCategoryController = require("../controllers/blogCategoryController");
|
||||
@@ -60,9 +58,6 @@ router.get("/api/about-us", aboutUsController.getAbout);
|
||||
// Header API route
|
||||
router.get("/api/header", headerController.api);
|
||||
|
||||
// Menu Tree API route (for frontend)
|
||||
router.get("/api/menu-tree", headerController.getMenuTreeAPI);
|
||||
|
||||
// Header Menu New Module API
|
||||
router.get("/api/header-menu", headerMenuController.api);
|
||||
|
||||
@@ -86,71 +81,20 @@ router.get("/api/request-info", requestInfoController.api);
|
||||
// Contact form submission (public)
|
||||
router.post("/api/contact/submit", contactController.submitForm);
|
||||
|
||||
// Appointment API
|
||||
const appointmentController = require("../controllers/appointmentController");
|
||||
router.get("/api/appointment", appointmentController.api);
|
||||
router.post("/api/appointment/submit", appointmentController.submitAppointment);
|
||||
|
||||
// Pricing API
|
||||
const pricingController = require("../controllers/pricingController");
|
||||
router.get("/api/pricing", pricingController.api);
|
||||
|
||||
router.get("/api/faq", faqController.api);
|
||||
|
||||
// Safety API route
|
||||
router.get("/api/safety", safetyController.api);
|
||||
|
||||
// Activity API routes
|
||||
router.get("/api/activities", activityController.api);
|
||||
router.get("/api/activities/:id", activityController.apiDetail);
|
||||
|
||||
// Insurance APi route
|
||||
router.get("/api/insurance", insuranceController.api);
|
||||
|
||||
router.get("/api/terms", termsController.api);
|
||||
|
||||
// Travel public page and API
|
||||
router.get("/travel", async (req, res) => {
|
||||
try {
|
||||
const Travel = require("../models/travel");
|
||||
const travel = await Travel.findOne();
|
||||
|
||||
if (!travel) {
|
||||
return res.status(404).render("errors/404", {
|
||||
title: "Page Not Found",
|
||||
message: "Travel information not found",
|
||||
});
|
||||
}
|
||||
|
||||
res.render("page/travel", {
|
||||
title: travel.page.title,
|
||||
data: travel.toObject(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error loading travel page:", error);
|
||||
res.status(500).render("errors/500", {
|
||||
title: "Server Error",
|
||||
message: "Error loading travel page",
|
||||
});
|
||||
}
|
||||
});
|
||||
router.get("/api/travel", travelController.api);
|
||||
|
||||
// Booking submission APIs (public endpoints)
|
||||
router.post("/api/booking/submit", bookingSubmissionController.submitBooking);
|
||||
router.get("/api/activity/:activityId/sessions", bookingSubmissionController.getAvailableSessions);
|
||||
router.get(
|
||||
"/api/activity/:activityId/session/:sessionId/availability",
|
||||
bookingSubmissionController.getSessionAvailability,
|
||||
);
|
||||
|
||||
// Demo booking form
|
||||
router.get("/demo/booking-form", (req, res) => {
|
||||
res.sendFile(path.join(__dirname, "../views/demo/booking-form.html"));
|
||||
});
|
||||
|
||||
// Demo session booking API
|
||||
router.get("/demo/session-booking-api", (req, res) => {
|
||||
res.sendFile(path.join(__dirname, "../views/demo/session-booking-api.html"));
|
||||
});
|
||||
|
||||
// Blog API Routes
|
||||
router.get("/api/blog", blogController.api);
|
||||
router.get("/api/blog/featured", blogController.apiFeatured);
|
||||
@@ -178,21 +122,6 @@ router.get("/api/blog/:slug", blogController.apiShow);
|
||||
// // API route cho blog detail
|
||||
// router.get('/api/blog-detail', blogDetailController.api);
|
||||
|
||||
/* CMS - Hailearning
|
||||
*/
|
||||
// service
|
||||
router.get("/service", serviceController.index);
|
||||
router.post("/service", serviceController.update);
|
||||
router.get("/api/service", serviceController.api);
|
||||
|
||||
// Service details by slug
|
||||
router.get("/api/service/:slug", serviceController.getServiceBySlug);
|
||||
|
||||
// Service slugs list
|
||||
router.get("/api/service-slugs", serviceController.getServiceSlugs);
|
||||
|
||||
router.get("/api/visa", visaController.api);
|
||||
router.get("/api/visa/country", visaController.apiCountries);
|
||||
|
||||
// Programmes API
|
||||
router.get("/api/programmes", programmeController.api);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
require("dotenv").config();
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
const connectDB = require("../config/database");
|
||||
const Contact = require("../models/contact");
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
/**
|
||||
* Migration: contact
|
||||
* Migrate contact data from contact-data.json
|
||||
*/
|
||||
async function migrate() {
|
||||
try {
|
||||
await connectDB();
|
||||
|
||||
// Read contact-data.json file
|
||||
const contactJsonPath = path.join(__dirname, "../data/contact.json");
|
||||
const contactData = JSON.parse(await fs.readFile(contactJsonPath, "utf8"));
|
||||
|
||||
// Migrate data using the model's static method
|
||||
await Contact.migrateFromJson(contactData);
|
||||
|
||||
console.log("Contact migration completed successfully");
|
||||
|
||||
await mongoose.disconnect();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("Migration error:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Chạy migration nếu được gọi trực tiếp
|
||||
if (require.main === module) {
|
||||
migrate();
|
||||
}
|
||||
|
||||
module.exports = { migrate };
|
||||
@@ -1,186 +0,0 @@
|
||||
require("dotenv").config();
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
const connectDB = require("../config/database");
|
||||
const Service = require("../models/service");
|
||||
|
||||
/**
|
||||
* Transform service.json data to match Service schema
|
||||
*/
|
||||
function transformServiceData(sourceData) {
|
||||
return {
|
||||
pageTitle: sourceData.pageTitle || "",
|
||||
|
||||
// Breadcrumb navigation section
|
||||
breadcrumb: {
|
||||
title: sourceData?.breadcrumb?.title || "",
|
||||
backgroundImage: sourceData?.breadcrumb?.backgroundImage || "",
|
||||
shape: sourceData?.breadcrumb?.shape || "",
|
||||
items: Array.isArray(sourceData?.breadcrumb?.items)
|
||||
? sourceData.breadcrumb.items.map((item) => ({
|
||||
label: item.label || "",
|
||||
href: item.href || "",
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
|
||||
// Main services section
|
||||
services: {
|
||||
title: {
|
||||
subTitle: sourceData?.services?.title?.subTitle || "",
|
||||
mainTitle: sourceData?.services?.title?.mainTitle || "",
|
||||
},
|
||||
items: Array.isArray(sourceData?.services?.items)
|
||||
? sourceData.services.items.map((service) => ({
|
||||
slug: service.slug || "",
|
||||
name: service.name || "",
|
||||
description: service.description || "",
|
||||
image: service.image || "",
|
||||
layout: service.layout || "",
|
||||
details: {
|
||||
title: service.details?.title || "",
|
||||
description: service.details?.description || "",
|
||||
mainImage: service.details?.mainImage || "",
|
||||
overviewTitle: service.details?.overviewTitle || "",
|
||||
overviewDescription: service.details?.overviewDescription || "",
|
||||
additionalDescription:
|
||||
service.details?.additionalDescription || "",
|
||||
keyFeaturesTitle: service.details?.keyFeaturesTitle || "",
|
||||
keyFeaturesImage: service.details?.keyFeaturesImage || "",
|
||||
features: Array.isArray(service.details?.features)
|
||||
? service.details.features.map((feature) => ({
|
||||
icon: feature.icon || "",
|
||||
title: feature.title || "",
|
||||
description: feature.description || "",
|
||||
}))
|
||||
: [],
|
||||
faqTitle: service.details?.faqTitle || "",
|
||||
faqImage: service.details?.faqImage || "",
|
||||
faq: Array.isArray(service.details?.faq)
|
||||
? service.details.faq.map((faqItem) => ({
|
||||
id: faqItem.id || "",
|
||||
question: faqItem.question || "",
|
||||
answer: faqItem.answer || "",
|
||||
isExpanded: faqItem.isExpanded || false,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
|
||||
// Destination countries section
|
||||
destinations: {
|
||||
backgroundImage: sourceData?.destinations?.backgroundImage || "",
|
||||
title: {
|
||||
subTitle: sourceData?.destinations?.title?.subTitle || "",
|
||||
mainTitle: sourceData?.destinations?.title?.mainTitle || "",
|
||||
},
|
||||
items: Array.isArray(sourceData?.destinations?.items)
|
||||
? sourceData.destinations.items.map((country) => ({
|
||||
id: country.id || "",
|
||||
name: country.name || "",
|
||||
description: country.description || "",
|
||||
image: country.image || "",
|
||||
icon: country.icon || "",
|
||||
link: country.link || "",
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
|
||||
// Visa types section
|
||||
visas: {
|
||||
items: Array.isArray(sourceData?.visas?.items)
|
||||
? sourceData.visas.items.map((visa) => ({
|
||||
id: visa.id || "",
|
||||
number: visa.number || "",
|
||||
name: visa.name || "",
|
||||
description: visa.description || "",
|
||||
buttonText: visa.buttonText || "",
|
||||
buttonLink: visa.buttonLink || "",
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
|
||||
// Client reviews section
|
||||
reviews: {
|
||||
title: {
|
||||
subTitle: sourceData?.reviews?.title?.subTitle || "",
|
||||
mainTitle: sourceData?.reviews?.title?.mainTitle || "",
|
||||
},
|
||||
viewAllButton: {
|
||||
text: sourceData?.reviews?.viewAllButton?.text || "",
|
||||
icon: sourceData?.reviews?.viewAllButton?.icon || "",
|
||||
link: sourceData?.reviews?.viewAllButton?.link || "",
|
||||
},
|
||||
thumb: sourceData?.reviews?.thumb || "",
|
||||
items: Array.isArray(sourceData?.reviews?.items)
|
||||
? sourceData.reviews.items.map((review) => ({
|
||||
id: review.id || "",
|
||||
rating: review.rating || 5,
|
||||
content: review.content || "",
|
||||
author: {
|
||||
name: review.author?.name || "",
|
||||
type: review.author?.type || "",
|
||||
},
|
||||
icon: review.icon || "",
|
||||
}))
|
||||
: [],
|
||||
navigation: {
|
||||
prevButton: sourceData?.reviews?.navigation?.prevButton || "",
|
||||
nextButton: sourceData?.reviews?.navigation?.nextButton || "",
|
||||
prevIcon: sourceData?.reviews?.navigation?.prevIcon || "",
|
||||
nextIcon: sourceData?.reviews?.navigation?.nextIcon || "",
|
||||
},
|
||||
},
|
||||
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration function for service page data
|
||||
*/
|
||||
async function migrateServiceData() {
|
||||
try {
|
||||
await connectDB();
|
||||
console.log("🚀 Starting service page migration...");
|
||||
|
||||
// Clear existing service documents
|
||||
await Service.deleteMany({});
|
||||
console.log("🗑️ Cleared existing service documents");
|
||||
|
||||
// Read service.json file
|
||||
const serviceJsonPath = path.join(__dirname, "..", "data", "service.json");
|
||||
const rawJsonData = await fs.readFile(serviceJsonPath, "utf8");
|
||||
const sourceServiceData = JSON.parse(rawJsonData);
|
||||
|
||||
// Transform data to match schema
|
||||
const transformedServiceData = transformServiceData(sourceServiceData);
|
||||
|
||||
// Create new service document
|
||||
const newService = new Service(transformedServiceData);
|
||||
const savedService = await newService.save();
|
||||
|
||||
console.log("✅ Service page migration completed successfully!");
|
||||
console.log(`📄 Service document ID: ${savedService._id}`);
|
||||
|
||||
await mongoose.disconnect();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("❌ Service migration error:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run migration if called directly
|
||||
if (require.main === module) {
|
||||
migrateServiceData();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
migrate: migrateServiceData,
|
||||
transformServiceData,
|
||||
};
|
||||
@@ -1,182 +0,0 @@
|
||||
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 };
|
||||
@@ -1,336 +0,0 @@
|
||||
// scripts/migrateVisa.js
|
||||
|
||||
require("dotenv").config();
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
const mongoose = require("mongoose");
|
||||
const Visa = require("../models/visa");
|
||||
|
||||
// 1. Đọc file JSON
|
||||
async function loadVisaData() {
|
||||
const filePath = path.join(__dirname, "..", "data", "visa.json");
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
// 2. Hàm Transform: Đổ dữ liệu từ JSON vào đúng Schema
|
||||
function transformVisa(sourceData) {
|
||||
// JSON có structure hero.title và hero.summaryList
|
||||
return {
|
||||
hero: {
|
||||
title: sourceData.hero?.title || "Visa",
|
||||
summaryList: Array.isArray(sourceData.hero?.summaryList)
|
||||
? sourceData.hero.summaryList.map((country) =>
|
||||
transformCountry(country),
|
||||
)
|
||||
: [],
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function: Transform individual country
|
||||
function transformCountry(source) {
|
||||
return {
|
||||
id: source.id || 0,
|
||||
name: source.name || "",
|
||||
slug: source.slug || "",
|
||||
icon: source.icon || "",
|
||||
services: Array.isArray(source.services) ? source.services : [],
|
||||
detailedView: source.detailedView
|
||||
? transformDetailedView(source.detailedView)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function: Transform DetailedView
|
||||
function transformDetailedView(source) {
|
||||
return {
|
||||
activeCountry: source.activeCountry
|
||||
? transformActiveCountry(source.activeCountry)
|
||||
: null,
|
||||
relatedCountries: Array.isArray(source.relatedCountries)
|
||||
? source.relatedCountries.map((country) => ({
|
||||
id: country.id || 0,
|
||||
name: country.name || "",
|
||||
icon: country.icon || "",
|
||||
}))
|
||||
: [],
|
||||
contactInfo: source.contactInfo
|
||||
? transformContactInfo(source.contactInfo)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function: Transform ActiveCountry
|
||||
function transformActiveCountry(source) {
|
||||
return {
|
||||
id: source.id || 0,
|
||||
name: source.name || "",
|
||||
title: source.title || "",
|
||||
mainImage: source.mainImage || "",
|
||||
description: source.description || "",
|
||||
additionalInfo: source.additionalInfo || "",
|
||||
tagline: source.tagline || "",
|
||||
visaTypes: Array.isArray(source.visaTypes)
|
||||
? source.visaTypes.map((type) => ({
|
||||
category: type.category || "",
|
||||
items: Array.isArray(type.items)
|
||||
? type.items.map((item) => ({
|
||||
title: item.title || "",
|
||||
description: item.description || "",
|
||||
}))
|
||||
: [],
|
||||
}))
|
||||
: [],
|
||||
visaProcess: source.visaProcess
|
||||
? transformVisaProcess(source.visaProcess)
|
||||
: null,
|
||||
gallery: Array.isArray(source.gallery) ? source.gallery : [],
|
||||
visaCategories: source.visaCategories
|
||||
? transformVisaCategories(source.visaCategories)
|
||||
: null,
|
||||
visaService: source.visaService
|
||||
? transformVisaService(source.visaService)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function: Transform VisaProcess
|
||||
function transformVisaProcess(source) {
|
||||
return {
|
||||
title: source.title || "",
|
||||
steps: Array.isArray(source.steps)
|
||||
? source.steps.map((step) => ({
|
||||
number: step.number || "",
|
||||
title: step.title || "",
|
||||
description: step.description || "",
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function: Transform VisaCategories
|
||||
function transformVisaCategories(source) {
|
||||
return {
|
||||
title: source.title || "",
|
||||
steps: Array.isArray(source.steps) ? source.steps : [],
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function: Transform VisaService
|
||||
function transformVisaService(source) {
|
||||
return {
|
||||
title: source.title || "",
|
||||
steps: Array.isArray(source.steps)
|
||||
? source.steps.map((step) => ({
|
||||
number: step.number || "",
|
||||
title: step.title || "",
|
||||
description: step.description || "",
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function: Transform ContactInfo
|
||||
function transformContactInfo(source) {
|
||||
return {
|
||||
img: source.img || "",
|
||||
sectionTitle: source.sectionTitle || "Visa & Immigration",
|
||||
helpText: source.helpText || "Need Help?",
|
||||
phone: {
|
||||
label: source.phone?.label || "Call Us",
|
||||
value: source.phone?.value || "",
|
||||
link: source.phone?.link || "",
|
||||
},
|
||||
email: {
|
||||
label: source.email?.label || "Mail Us",
|
||||
value: source.email?.value || "",
|
||||
link: source.email?.link || "",
|
||||
},
|
||||
location: {
|
||||
label: source.location?.label || "Location",
|
||||
address: source.location?.address || "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Validate data before migration
|
||||
function validateVisaData(visaData) {
|
||||
const errors = [];
|
||||
|
||||
if (!visaData.hero) {
|
||||
errors.push("Missing hero section");
|
||||
}
|
||||
|
||||
if (!visaData.hero?.title) {
|
||||
console.warn("⚠️ Hero title is missing, using default 'Visa'");
|
||||
}
|
||||
|
||||
if (!Array.isArray(visaData.hero?.summaryList)) {
|
||||
errors.push("summaryList must be an array");
|
||||
} else if (visaData.hero.summaryList.length === 0) {
|
||||
errors.push("summaryList is empty");
|
||||
} else {
|
||||
// Validate each country
|
||||
visaData.hero.summaryList.forEach((country, idx) => {
|
||||
if (!country.name || !country.slug) {
|
||||
errors.push(`Country at index ${idx}: missing name or slug`);
|
||||
}
|
||||
|
||||
if (country.detailedView) {
|
||||
if (!country.detailedView.activeCountry) {
|
||||
console.warn(
|
||||
`⚠️ Country "${country.name}" (${idx}): missing activeCountry details`,
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(country.detailedView.relatedCountries)) {
|
||||
errors.push(
|
||||
`Country "${country.name}" (${idx}): relatedCountries must be array`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Helper function: Get data summary
|
||||
function getDataSummary(visaData) {
|
||||
const summary = {
|
||||
heroTitle: visaData.hero?.title || "N/A",
|
||||
totalCountries: visaData.hero?.summaryList?.length || 0,
|
||||
withDetails: 0,
|
||||
withoutDetails: 0,
|
||||
byCountry: [],
|
||||
};
|
||||
|
||||
if (visaData.hero?.summaryList) {
|
||||
visaData.hero.summaryList.forEach((country) => {
|
||||
const hasDetails = !!country.detailedView?.activeCountry;
|
||||
const relatedCount = country.detailedView?.relatedCountries?.length || 0;
|
||||
|
||||
if (hasDetails) {
|
||||
summary.withDetails++;
|
||||
} else {
|
||||
summary.withoutDetails++;
|
||||
}
|
||||
|
||||
summary.byCountry.push({
|
||||
name: country.name,
|
||||
slug: country.slug,
|
||||
hasDetails,
|
||||
relatedCountries: relatedCount,
|
||||
services: country.services?.length || 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
// 4. Chạy Migration
|
||||
async function migrate() {
|
||||
try {
|
||||
// Kết nối DB
|
||||
console.log("🔗 Connecting to MongoDB...");
|
||||
await mongoose.connect(process.env.MONGODB_URI);
|
||||
console.log("✅ Connected to MongoDB\n");
|
||||
|
||||
// A. Lấy dữ liệu thô
|
||||
console.log("📖 Loading visa data from JSON...");
|
||||
const rawData = await loadVisaData();
|
||||
console.log("✅ JSON data loaded\n");
|
||||
|
||||
// B. Chuẩn hóa dữ liệu theo Schema
|
||||
console.log("🔄 Transforming data structure...");
|
||||
const visaData = transformVisa(rawData);
|
||||
console.log("✅ Data transformation completed\n");
|
||||
|
||||
// C. Validate dữ liệu
|
||||
console.log("✔️ Validating data structure...");
|
||||
const errors = validateVisaData(visaData);
|
||||
if (errors.length > 0) {
|
||||
console.error("❌ Validation errors found:");
|
||||
errors.forEach((err, idx) => console.error(` ${idx + 1}. ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("✅ Data validation passed\n");
|
||||
|
||||
// D. Get summary
|
||||
const summary = getDataSummary(visaData);
|
||||
|
||||
console.log("📊 Migration Summary:");
|
||||
console.log(` Hero Title: "${summary.heroTitle}"`);
|
||||
console.log(` Total countries: ${summary.totalCountries}`);
|
||||
console.log(` With details: ${summary.withDetails}`);
|
||||
console.log(` Without details: ${summary.withoutDetails}`);
|
||||
console.log(`\n Country Details:`);
|
||||
|
||||
summary.byCountry.forEach((country) => {
|
||||
const detailBadge = country.hasDetails ? "✅" : "❌";
|
||||
const detailText = country.hasDetails
|
||||
? `(${country.relatedCountries} related)`
|
||||
: "(basic only)";
|
||||
console.log(
|
||||
` ${detailBadge} ${country.name.padEnd(20)} (${country.slug.padEnd(
|
||||
12,
|
||||
)}) - ${country.services} services ${detailText}`,
|
||||
);
|
||||
});
|
||||
console.log("");
|
||||
|
||||
// E. Lưu vào DB (Upsert: Có rồi thì update, chưa có thì tạo)
|
||||
const existingDoc = await Visa.findOne().sort({ updatedAt: -1 });
|
||||
|
||||
if (existingDoc) {
|
||||
console.log("📝 Updating existing Visa document...");
|
||||
console.log(` Document ID: ${existingDoc._id}`);
|
||||
|
||||
const updated = await Visa.findByIdAndUpdate(
|
||||
existingDoc._id,
|
||||
{ $set: visaData },
|
||||
{ new: true },
|
||||
);
|
||||
|
||||
console.log("✅ Visa document updated successfully");
|
||||
console.log(` Updated at: ${updated.updatedAt}`);
|
||||
} else {
|
||||
console.log("📝 Creating NEW Visa document...");
|
||||
|
||||
const newDoc = await Visa.create(visaData);
|
||||
|
||||
console.log("✅ Visa document created successfully");
|
||||
console.log(` Document ID: ${newDoc._id}`);
|
||||
console.log(` Created at: ${newDoc.createdAt}`);
|
||||
}
|
||||
|
||||
console.log("\n✨ Visa migration completed successfully!");
|
||||
} catch (error) {
|
||||
console.error("\n❌ Migration failed:");
|
||||
console.error(` Error: ${error.message}`);
|
||||
|
||||
if (error.name === "ValidationError") {
|
||||
console.error("\n Validation Errors:");
|
||||
Object.keys(error.errors).forEach((field) => {
|
||||
console.error(` - ${field}: ${error.errors[field].message}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (error.stack) {
|
||||
console.error("\n📋 Stack trace:");
|
||||
console.error(error.stack);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await mongoose.connection.close();
|
||||
console.log("\n🔌 MongoDB connection closed");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run migration
|
||||
console.log("🚀 Starting Visa Migration...\n");
|
||||
migrate();
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
@@ -1,68 +0,0 @@
|
||||
const mongoose = require("mongoose");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
// Import model
|
||||
const Footer = require("../models/footer");
|
||||
|
||||
/**
|
||||
* Migration script để import dữ liệu footer từ JSON
|
||||
*/
|
||||
async function up() {
|
||||
try {
|
||||
console.log("Starting footer migration...");
|
||||
|
||||
// Đọc dữ liệu từ file JSON
|
||||
const jsonPath = path.join(__dirname, "../data/footer.json");
|
||||
|
||||
if (!fs.existsSync(jsonPath)) {
|
||||
throw new Error("Footer JSON file not found");
|
||||
}
|
||||
|
||||
const footerData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
||||
|
||||
// Sử dụng static method từ model để migrate
|
||||
const result = await Footer.migrateFromJson(footerData);
|
||||
|
||||
console.log("Footer migration completed successfully");
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Footer migration failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback migration
|
||||
*/
|
||||
async function down() {
|
||||
try {
|
||||
console.log("Rolling back footer migration...");
|
||||
|
||||
// Xóa footer data
|
||||
await Footer.deleteMany({});
|
||||
|
||||
console.log("Footer rollback completed");
|
||||
} catch (error) {
|
||||
console.error("Footer rollback failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { up, down };
|
||||
|
||||
// Chạy migration nếu file được gọi trực tiếp
|
||||
if (require.main === module) {
|
||||
const connectDB = require("../config/database");
|
||||
|
||||
connectDB()
|
||||
.then(() => up())
|
||||
.then(() => {
|
||||
console.log("Migration completed successfully");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Migration failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
const mongoose = require("mongoose");
|
||||
const Footer = require("../models/footer");
|
||||
const footerData = require("../data/footer.json");
|
||||
|
||||
async function addFooterMenuOrder() {
|
||||
try {
|
||||
console.log("=== Adding order field to Footer Menu Links ===");
|
||||
|
||||
// Connect to database
|
||||
await mongoose.connect(process.env.MONGODB_URI || "mongodb://localhost:27017/hailearning");
|
||||
console.log("✓ Connected to MongoDB");
|
||||
|
||||
// Get existing footer or create from JSON
|
||||
let footer = await Footer.findOne();
|
||||
|
||||
if (!footer) {
|
||||
console.log("No existing footer found, creating from JSON data...");
|
||||
|
||||
// Add order to bottom menu links
|
||||
if (footerData.bottom && footerData.bottom.menuLinks) {
|
||||
footerData.bottom.menuLinks = footerData.bottom.menuLinks.map((link, index) => ({
|
||||
...link,
|
||||
order: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
// Add order to top menu links
|
||||
if (footerData.top && footerData.top.menuLinks) {
|
||||
footerData.top.menuLinks = footerData.top.menuLinks.map((link, index) => ({
|
||||
...link,
|
||||
order: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
footer = await Footer.create(footerData);
|
||||
console.log("✓ Footer created with order fields");
|
||||
} else {
|
||||
console.log("Found existing footer, adding order fields...");
|
||||
|
||||
// Add order to bottom menu links
|
||||
if (footer.bottom && footer.bottom.menuLinks) {
|
||||
footer.bottom.menuLinks = footer.bottom.menuLinks.map((link, index) => ({
|
||||
label: link.label,
|
||||
href: link.href,
|
||||
order: link.order || index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
// Add order to top menu links
|
||||
if (footer.top && footer.top.menuLinks) {
|
||||
footer.top.menuLinks = footer.top.menuLinks.map((link, index) => ({
|
||||
label: link.label,
|
||||
href: link.href,
|
||||
order: link.order || index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
await footer.save();
|
||||
console.log("✓ Footer updated with order fields");
|
||||
}
|
||||
|
||||
console.log("Bottom Menu Links with order:");
|
||||
footer.bottom.menuLinks.forEach((link, index) => {
|
||||
console.log(` ${index + 1}. ${link.label} (order: ${link.order}) -> ${link.href}`);
|
||||
});
|
||||
|
||||
console.log("=== Footer Menu Order Migration Completed ===");
|
||||
} catch (error) {
|
||||
console.error("✗ Migration failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await mongoose.disconnect();
|
||||
console.log("✓ Disconnected from MongoDB");
|
||||
}
|
||||
}
|
||||
|
||||
// Run migration if called directly
|
||||
if (require.main === module) {
|
||||
addFooterMenuOrder()
|
||||
.then(() => {
|
||||
console.log("Migration completed successfully");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Migration failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = addFooterMenuOrder;
|
||||
@@ -1,47 +0,0 @@
|
||||
require("dotenv").config();
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
const connectDB = require("../config/database");
|
||||
|
||||
/**
|
||||
* Migration: import_home_content
|
||||
* Created: 19:00:00 2026-02-05
|
||||
* Description:
|
||||
* Import nội dung trang Home từ file JSON (Next.js) vào MongoDB (model Home).
|
||||
* Nguồn dữ liệu: hailearning.edu.vn/app/home.json
|
||||
*/
|
||||
async function migrate() {
|
||||
try {
|
||||
// 1) Connect DB
|
||||
await connectDB();
|
||||
console.log("🚀 Starting migration: import_home_content...");
|
||||
|
||||
// 2) Load model
|
||||
const Home = require("../models/home");
|
||||
console.log("✅ Home model registered successfully");
|
||||
|
||||
// 3) Load JSON data
|
||||
const dataPath = path.join(__dirname, "..", "data", "home.json");
|
||||
const raw = await fs.readFile(dataPath, "utf8");
|
||||
const homeData = JSON.parse(raw);
|
||||
console.log("📖 Home data loaded from:", dataPath);
|
||||
|
||||
// 4) Clear existing
|
||||
console.log("🧹 Clearing existing Home data...");
|
||||
await Home.deleteMany({});
|
||||
console.log("✅ Existing Home documents cleared");
|
||||
|
||||
// 5) Insert new document
|
||||
const created = await Home.create(homeData);
|
||||
console.log("✅ Home document created with _id:", created._id.toString());
|
||||
|
||||
console.log("🎉 Migration import_home_content completed successfully.");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("❌ Migration failed:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Migration: Convert About News static items to dynamic Blog selection
|
||||
* Date: 2026-02-07
|
||||
*
|
||||
* This migration:
|
||||
* 1. Adds selectedBlogIds field to About news section
|
||||
* 2. Keeps existing items for backward compatibility
|
||||
* 3. Does NOT delete old data (safe migration)
|
||||
*/
|
||||
|
||||
const mongoose = require("mongoose");
|
||||
require("dotenv").config();
|
||||
|
||||
const MONGODB_URI = process.env.MONGODB_URI || "mongodb://localhost:27017/SIMS";
|
||||
|
||||
async function up() {
|
||||
try {
|
||||
await mongoose.connect(MONGODB_URI);
|
||||
console.log("✓ Connected to MongoDB");
|
||||
|
||||
const AboutUs = mongoose.model("AboutUs", new mongoose.Schema({}, { strict: false }));
|
||||
|
||||
const doc = await AboutUs.findOne();
|
||||
|
||||
if (!doc) {
|
||||
console.log("⚠ No About Us document found. Skipping migration.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already migrated
|
||||
if (doc.news && doc.news.selectedBlogIds !== undefined) {
|
||||
console.log("✓ Migration already applied. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add selectedBlogIds field (empty array by default)
|
||||
if (!doc.news) {
|
||||
doc.news = {};
|
||||
}
|
||||
|
||||
doc.news.selectedBlogIds = [];
|
||||
|
||||
// Keep existing items for backward compatibility
|
||||
// Admin can manually select blogs after migration
|
||||
|
||||
await doc.save();
|
||||
|
||||
console.log("✓ Migration completed successfully");
|
||||
console.log(" - Added selectedBlogIds field to About news section");
|
||||
console.log(" - Existing items preserved for backward compatibility");
|
||||
console.log(" - Admin can now select blogs from Blog Management");
|
||||
} catch (error) {
|
||||
console.error("✗ Migration failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function down() {
|
||||
try {
|
||||
await mongoose.connect(MONGODB_URI);
|
||||
console.log("✓ Connected to MongoDB");
|
||||
|
||||
const AboutUs = mongoose.model("AboutUs", new mongoose.Schema({}, { strict: false }));
|
||||
|
||||
const doc = await AboutUs.findOne();
|
||||
|
||||
if (!doc || !doc.news) {
|
||||
console.log("⚠ No About Us document found. Skipping rollback.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove selectedBlogIds field
|
||||
if (doc.news.selectedBlogIds !== undefined) {
|
||||
delete doc.news.selectedBlogIds;
|
||||
await doc.save();
|
||||
console.log("✓ Rollback completed - selectedBlogIds removed");
|
||||
} else {
|
||||
console.log("✓ Nothing to rollback");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("✗ Rollback failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run migration
|
||||
if (require.main === module) {
|
||||
up()
|
||||
.then(() => {
|
||||
console.log("\n✓ Migration script completed");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("\n✗ Migration script failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { up, down };
|
||||
@@ -1,29 +0,0 @@
|
||||
require("dotenv").config();
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
await mongoose.connect(process.env.MONGODB_URI);
|
||||
|
||||
console.log("Connected DB");
|
||||
|
||||
const collections = await mongoose.connection.db
|
||||
.listCollections({ name: "auditlogs" })
|
||||
.toArray();
|
||||
|
||||
if (collections.length > 0) {
|
||||
console.log("AuditLog collection already exists");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await mongoose.connection.createCollection("auditlogs");
|
||||
console.log("AuditLog collection created");
|
||||
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -1,86 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Tạo migration file mới với format giống Laravel
|
||||
* Format: YYYY_MM_DD_HHMMSS_migration_name.js
|
||||
*/
|
||||
function makeMigration(migrationName) {
|
||||
if (!migrationName) {
|
||||
console.error('Error: Migration name is required');
|
||||
console.log('\nUsage: node scripts/make-migration.js <migration-name>');
|
||||
console.log('Example: node scripts/make-migration.js create_users_table');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Tạo timestamp theo format Laravel: YYYY_MM_DD_HHMMSS
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
|
||||
const timestamp = `${year}_${month}_${day}_${hours}${minutes}${seconds}`;
|
||||
const fileName = `${timestamp}_${migrationName}.js`;
|
||||
const filePath = path.join(__dirname, fileName);
|
||||
|
||||
// Template migration mẫu
|
||||
const template = `require('dotenv').config();
|
||||
const connectDB = require('../config/database');
|
||||
|
||||
/**
|
||||
* Migration: ${migrationName}
|
||||
* Created: ${now.toLocaleString('vi-VN')}
|
||||
*/
|
||||
async function migrate() {
|
||||
try {
|
||||
// Kết nối database
|
||||
await connectDB();
|
||||
console.log('Starting migration: ${migrationName}...');
|
||||
|
||||
// TODO: Thêm code migration của bạn ở đây
|
||||
|
||||
console.log('Migration ${migrationName} 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 };
|
||||
`;
|
||||
|
||||
// Kiểm tra file đã tồn tại chưa
|
||||
if (fs.existsSync(filePath)) {
|
||||
console.error(`Error: Migration file already exists: ${fileName}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Tạo file migration
|
||||
try {
|
||||
fs.writeFileSync(filePath, template, 'utf8');
|
||||
console.log(`Migration created successfully: ${fileName}`);
|
||||
console.log(`Path: ${filePath}`);
|
||||
} catch (error) {
|
||||
console.error('Error creating migration file:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Lấy migration name từ command line arguments
|
||||
const migrationName = process.argv[2];
|
||||
|
||||
// Chạy hàm tạo migration
|
||||
makeMigration(migrationName);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
require('dotenv').config();
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const connectDB = require('../config/database');
|
||||
const About = require('../models/about');
|
||||
|
||||
async function validateAboutData(data) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('About data must be a valid object');
|
||||
}
|
||||
|
||||
// Tuỳ schema bạn chỉnh lại cho chuẩn hơn
|
||||
if (!data.title || !data.sections) {
|
||||
throw new Error('Missing required fields: title or sections');
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.sections)) {
|
||||
throw new Error('sections must be an array');
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateAboutData() {
|
||||
try {
|
||||
await connectDB();
|
||||
console.log('Đã kết nối MongoDB...');
|
||||
|
||||
// Xóa dữ liệu cũ
|
||||
await About.deleteMany({});
|
||||
console.log('Đã xóa dữ liệu About cũ');
|
||||
|
||||
// Đọc file JSON
|
||||
const aboutData = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(__dirname, '../data/about.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
|
||||
// Validate
|
||||
await validateAboutData(aboutData);
|
||||
|
||||
// Transform (optional)
|
||||
const finalData = {
|
||||
...aboutData,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
// Insert
|
||||
await About.create(finalData);
|
||||
|
||||
console.log('✓ Migrate About thành công!');
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Lỗi:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
migrateAboutData();
|
||||
@@ -16,10 +16,9 @@ function discoverMigrations() {
|
||||
// Danh sách các file quản lý migration cần loại trừ
|
||||
const excludeFiles = [
|
||||
"migrate-all.js",
|
||||
"migrate-status.js",
|
||||
"migrate-rollback.js",
|
||||
"migrate-fresh.js",
|
||||
"make-migration.js",
|
||||
"migrate-home.js",
|
||||
"migrate-about.js",
|
||||
|
||||
];
|
||||
|
||||
const migrations = files
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
require('dotenv').config();
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const connectDB = require('../config/database');
|
||||
const Footer = require('../models/footer');
|
||||
|
||||
async function validateFooterData(data) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Footer data must be a valid object');
|
||||
}
|
||||
|
||||
const required = ['brand', 'explore', 'contact', 'newsletter', 'bottom'];
|
||||
for (const field of required) {
|
||||
if (!data[field]) {
|
||||
throw new Error(`Missing required field: ${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateFooterData() {
|
||||
try {
|
||||
await connectDB();
|
||||
console.log('Đã kết nối MongoDB...');
|
||||
|
||||
// Xóa dữ liệu cũ
|
||||
await Footer.deleteMany({});
|
||||
console.log('Đã xóa dữ liệu Footer cũ');
|
||||
|
||||
// Đọc file JSON
|
||||
const footerData = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(__dirname, '../data/footer.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
|
||||
// Validate
|
||||
await validateFooterData(footerData);
|
||||
|
||||
// Transform
|
||||
const finalData = {
|
||||
...footerData,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Insert
|
||||
await Footer.create(finalData);
|
||||
|
||||
console.log('✓ Migrate Footer thành công!');
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Lỗi:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
migrateFooterData();
|
||||
@@ -1,189 +0,0 @@
|
||||
require('dotenv').config();
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { execSync } = require("child_process");
|
||||
const connectDB = require("../config/database");
|
||||
const migrationHelper = require("../utils/migrationHelper");
|
||||
|
||||
/**
|
||||
* Tự động phát hiện tất cả các file script trong thư mục scripts
|
||||
* Loại trừ các file quản lý migration và file không phải .js
|
||||
*/
|
||||
function discoverMigrations() {
|
||||
const scriptsDir = __dirname;
|
||||
const files = fs.readdirSync(scriptsDir);
|
||||
|
||||
// Danh sách các file quản lý migration cần loại trừ
|
||||
const excludeFiles = [
|
||||
'migrate-all.js',
|
||||
'migrate-status.js',
|
||||
'migrate-rollback.js',
|
||||
'migrate-fresh.js',
|
||||
'make-migration.js',
|
||||
'MIGRATION_README.md'
|
||||
];
|
||||
|
||||
const migrations = files
|
||||
.filter(file => {
|
||||
// Lấy tất cả file .js, trừ các file quản lý
|
||||
return file.endsWith('.js') && !excludeFiles.includes(file);
|
||||
})
|
||||
.map(file => {
|
||||
// Tạo tên migration từ tên file (bỏ .js)
|
||||
const name = file.replace('.js', '');
|
||||
return {
|
||||
name: name,
|
||||
script: file
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Sắp xếp theo tên để đảm bảo thứ tự nhất quán
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return migrations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chạy migration script (suppress output)
|
||||
* Sử dụng child_process để chạy script độc lập vì các script tự quản lý DB connection
|
||||
* Output từ script sẽ bị suppress để chỉ hiển thị status
|
||||
*/
|
||||
async function runMigrationScript(migration) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Chạy script bằng child_process với stdio: 'pipe' để suppress output
|
||||
// Nhưng vẫn capture stderr để có thể hiển thị lỗi nếu cần
|
||||
const result = execSync(`node scripts/${migration.script}`, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'], // stdin: ignore, stdout: pipe, stderr: pipe
|
||||
cwd: path.join(__dirname, ".."),
|
||||
encoding: 'utf8'
|
||||
});
|
||||
resolve();
|
||||
} catch (error) {
|
||||
// Attach stderr vào error để có thể hiển thị sau
|
||||
if (error.stderr) {
|
||||
error.stderr = error.stderr;
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hiển thị bảng kết quả migration đơn giản
|
||||
*/
|
||||
function displayResults(results) {
|
||||
console.log("\nRunning migrations...\n");
|
||||
|
||||
// Tìm độ dài tên migration dài nhất để format bảng
|
||||
const maxNameLength = Math.max(...results.map(r => r.name.length), 20);
|
||||
const statusWidth = 10;
|
||||
const totalWidth = maxNameLength + statusWidth + 7; // 7 = spaces and separators
|
||||
|
||||
// Header
|
||||
console.log("=".repeat(totalWidth));
|
||||
console.log(`${'Migration'.padEnd(maxNameLength)} | ${'Status'.padEnd(statusWidth)}`);
|
||||
console.log("=".repeat(totalWidth));
|
||||
|
||||
// Rows
|
||||
results.forEach(result => {
|
||||
let statusText = "";
|
||||
if (result.status === 'DONE') {
|
||||
statusText = "DONE".padEnd(statusWidth);
|
||||
} else if (result.status === 'FAIL') {
|
||||
statusText = "FAIL".padEnd(statusWidth);
|
||||
}
|
||||
|
||||
console.log(`${result.name.padEnd(maxNameLength)} | ${statusText}`);
|
||||
});
|
||||
|
||||
// Footer
|
||||
console.log("=".repeat(totalWidth));
|
||||
|
||||
// Summary
|
||||
const doneCount = results.filter(r => r.status === 'DONE').length;
|
||||
const failCount = results.filter(r => r.status === 'FAIL').length;
|
||||
|
||||
console.log("");
|
||||
if (doneCount > 0) {
|
||||
console.log(`${doneCount} migration(s) completed`);
|
||||
}
|
||||
if (failCount > 0) {
|
||||
console.log(`${failCount} migration(s) failed`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hàm chính để chạy lại tất cả migrations từ đầu (fresh)
|
||||
* Xóa tất cả tracking và chạy lại từ đầu
|
||||
*/
|
||||
async function runFreshMigrations() {
|
||||
const mongoose = require('mongoose');
|
||||
let ownConn = false;
|
||||
|
||||
try {
|
||||
const wasConnected = mongoose.connection.readyState === 1;
|
||||
await connectDB();
|
||||
if (!wasConnected) ownConn = true;
|
||||
|
||||
// Tự động phát hiện migrations
|
||||
const migrations = discoverMigrations();
|
||||
|
||||
// Xóa tất cả tracking migrations
|
||||
const Migration = require('../models/migration');
|
||||
await Migration.deleteMany({});
|
||||
|
||||
const batch = 1; // Batch mới bắt đầu từ 1
|
||||
const results = [];
|
||||
|
||||
// Chạy từng migration
|
||||
for (let i = 0; i < migrations.length; i++) {
|
||||
const migration = migrations[i];
|
||||
|
||||
try {
|
||||
// Chạy migration script (output bị suppress)
|
||||
await runMigrationScript(migration);
|
||||
|
||||
if (mongoose.connection.readyState !== 1) {
|
||||
await connectDB();
|
||||
}
|
||||
await migrationHelper.markAsRun(migration.name, batch);
|
||||
|
||||
results.push({ name: migration.name, status: 'DONE' });
|
||||
} catch (error) {
|
||||
results.push({ name: migration.name, status: 'FAIL', error: error.message });
|
||||
// Hiển thị bảng kết quả trước khi exit
|
||||
displayResults(results);
|
||||
console.error(`\n❌ Migration "${migration.name}" failed: ${error.message}`);
|
||||
if (error.stderr) {
|
||||
console.error(error.stderr.toString());
|
||||
}
|
||||
if (ownConn && mongoose.connection.readyState === 1) {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Hiển thị bảng kết quả
|
||||
displayResults(results);
|
||||
|
||||
if (ownConn && mongoose.connection.readyState === 1) {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("\n❌ Error:", error.message);
|
||||
if (ownConn && mongoose.connection.readyState === 1) {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Chạy hàm chính
|
||||
runFreshMigrations();
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
const mongoose = require('mongoose');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
const HeaderMenu = require('../models/headerMenu');
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/SIMS';
|
||||
|
||||
async function connectDB() {
|
||||
try {
|
||||
await mongoose.connect(MONGODB_URI);
|
||||
console.log('✅ MongoDB Connected for Migration');
|
||||
} catch (err) {
|
||||
console.error('❌ MongoDB Connection Error:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const processMenuItems = async (items, parentId = null) => {
|
||||
for (const item of items) {
|
||||
console.log(` > Importing: ${item.label}`);
|
||||
|
||||
const menuDoc = {
|
||||
title: item.label,
|
||||
slug: item.slug,
|
||||
url: item.href,
|
||||
parentId: parentId,
|
||||
order: item.order || 0,
|
||||
status: item.isActive === false ? "inactive" : "active",
|
||||
type: item.type === "external" ? "external" : "internal"
|
||||
};
|
||||
|
||||
const createdItem = await HeaderMenu.create(menuDoc);
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
await processMenuItems(item.children, createdItem._id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function migrate() {
|
||||
await connectDB();
|
||||
|
||||
try {
|
||||
console.log('--- Starting Header Menu Migration ---');
|
||||
|
||||
// 1. Clear existing menu items
|
||||
await HeaderMenu.deleteMany({});
|
||||
console.log('🗑️ Cleared existing HeaderMenu collection');
|
||||
|
||||
// 2. Read JSON data
|
||||
const dataPath = path.join(__dirname, '../data/header-menu.json');
|
||||
const fileData = fs.readFileSync(dataPath, 'utf8');
|
||||
const menuItems = JSON.parse(fileData);
|
||||
|
||||
// 3. Recursive import
|
||||
await processMenuItems(menuItems);
|
||||
|
||||
console.log('--- Migration Completed Successfully ---');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Migration Failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
@@ -1,73 +0,0 @@
|
||||
const mongoose = require("mongoose");
|
||||
const path = require("path");
|
||||
require("dotenv").config({ path: path.join(__dirname, "../.env") });
|
||||
|
||||
const Header = require("../models/header");
|
||||
const headerData = require("../data/header.json");
|
||||
|
||||
const migrateHeader = async () => {
|
||||
try {
|
||||
const mongoUri = process.env.MONGODB_URI;
|
||||
if (!mongoUri) {
|
||||
throw new Error("MONGODB_URI not found in environment variables");
|
||||
}
|
||||
await mongoose.connect(mongoUri);
|
||||
console.log("Connected to MongoDB");
|
||||
|
||||
// Delete existing header
|
||||
await Header.deleteMany({});
|
||||
console.log("Cleared existing headers");
|
||||
|
||||
// Transform and insert data
|
||||
const headerDocument = {
|
||||
top: {
|
||||
phone: headerData.top?.phone || "",
|
||||
email: headerData.top?.email || "",
|
||||
location: headerData.top?.location || "",
|
||||
socialLinks: (headerData.top?.socialLinks || []).map((link, idx) => ({
|
||||
...link,
|
||||
order: idx,
|
||||
})),
|
||||
languages: headerData.top?.languages || [],
|
||||
},
|
||||
offcanvas: headerData.offcanvas || {},
|
||||
menu: (headerData.menu || []).map((item, idx) => ({
|
||||
...item,
|
||||
order: idx,
|
||||
children:
|
||||
item.children?.map((child, childIdx) => ({
|
||||
...child,
|
||||
order: childIdx,
|
||||
children:
|
||||
child.children?.map((subchild, subIdx) => ({
|
||||
...subchild,
|
||||
order: subIdx,
|
||||
})) || [],
|
||||
})) || [],
|
||||
})),
|
||||
logo: {
|
||||
light: "/assets/img/logo/white-logo.svg",
|
||||
dark: "/assets/img/logo/black-logo.svg",
|
||||
alt: "Hai Learning",
|
||||
},
|
||||
ctaButton: {
|
||||
label: "Get Started",
|
||||
href: "/contact",
|
||||
style: "primary",
|
||||
},
|
||||
status: "active",
|
||||
order: 1,
|
||||
};
|
||||
|
||||
const result = await Header.create(headerDocument);
|
||||
console.log("Header migrated successfully:", result._id);
|
||||
|
||||
await mongoose.connection.close();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("Migration error:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
migrateHeader();
|
||||
@@ -0,0 +1,56 @@
|
||||
require('dotenv').config();
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const connectDB = require('../config/database');
|
||||
const Home = require('../models/home');
|
||||
|
||||
async function validateHomeData(data) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Home data must be a valid object');
|
||||
}
|
||||
|
||||
// Ví dụ validate cơ bản (tuỳ schema bạn chỉnh thêm)
|
||||
if (!data.hero || !data.quickLinks) {
|
||||
throw new Error('Missing required fields: hero or quickLinks');
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateHomeData() {
|
||||
try {
|
||||
await connectDB();
|
||||
console.log('Đã kết nối MongoDB...');
|
||||
|
||||
// Xóa dữ liệu cũ
|
||||
await Home.deleteMany({});
|
||||
console.log('Đã xóa dữ liệu Home cũ');
|
||||
|
||||
// Đọc file JSON
|
||||
const homeData = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(__dirname, '../data/home.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
|
||||
// Validate
|
||||
await validateHomeData(homeData);
|
||||
|
||||
// Transform nếu cần (optional)
|
||||
const finalData = {
|
||||
...homeData,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
// Insert
|
||||
await Home.create(finalData);
|
||||
|
||||
console.log('✓ Migrate Home thành công!');
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Lỗi:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
migrateHomeData();
|
||||
@@ -1,111 +0,0 @@
|
||||
require('dotenv').config();
|
||||
const connectDB = require('../config/database');
|
||||
const migrationHelper = require('../utils/migrationHelper');
|
||||
|
||||
/**
|
||||
* Rollback một migration cụ thể
|
||||
*/
|
||||
async function rollbackMigration(migrationName) {
|
||||
const mongoose = require('mongoose');
|
||||
let ownConn = false;
|
||||
|
||||
try {
|
||||
const wasConnected = mongoose.connection.readyState === 1;
|
||||
await connectDB();
|
||||
if (!wasConnected) ownConn = true;
|
||||
|
||||
const hasRun = await migrationHelper.hasRun(migrationName);
|
||||
if (!hasRun) {
|
||||
console.log(`⚠️ Migration "${migrationName}" chưa được chạy, không thể rollback.`);
|
||||
if (ownConn && mongoose.connection.readyState === 1) {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = await migrationHelper.rollback(migrationName);
|
||||
if (result) {
|
||||
console.log(`✅ Đã rollback migration: ${migrationName}`);
|
||||
} else {
|
||||
console.log(`❌ Không thể rollback migration: ${migrationName}`);
|
||||
}
|
||||
|
||||
if (ownConn && mongoose.connection.readyState === 1) {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Lỗi:', error.message);
|
||||
if (ownConn && mongoose.connection.readyState === 1) {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback batch cuối cùng
|
||||
*/
|
||||
async function rollbackLastBatch() {
|
||||
const mongoose = require('mongoose');
|
||||
let ownConn = false;
|
||||
|
||||
try {
|
||||
const wasConnected = mongoose.connection.readyState === 1;
|
||||
await connectDB();
|
||||
if (!wasConnected) ownConn = true;
|
||||
|
||||
const lastBatch = await migrationHelper.getLastBatch();
|
||||
if (lastBatch === 0) {
|
||||
console.log('⚠️ Không có batch nào để rollback.');
|
||||
if (ownConn && mongoose.connection.readyState === 1) {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const migrations = await migrationHelper.getMigrationsByBatch(lastBatch);
|
||||
if (migrations.length === 0) {
|
||||
console.log(`⚠️ Batch ${lastBatch} không có migration nào.`);
|
||||
if (ownConn && mongoose.connection.readyState === 1) {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`\n🔄 Đang rollback batch ${lastBatch}...`);
|
||||
console.log(`📋 Các migration sẽ được rollback:`);
|
||||
migrations.forEach(m => {
|
||||
console.log(` - ${m.name}`);
|
||||
});
|
||||
|
||||
const deletedCount = await migrationHelper.rollbackBatch(lastBatch);
|
||||
console.log(`\n✅ Đã rollback ${deletedCount} migration(s) trong batch ${lastBatch}`);
|
||||
|
||||
if (ownConn && mongoose.connection.readyState === 1) {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Lỗi:', error.message);
|
||||
if (ownConn && mongoose.connection.readyState === 1) {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Xử lý command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.log('Usage:');
|
||||
console.log(' node scripts/migrate-rollback.js <migration-name> - Rollback một migration cụ thể');
|
||||
console.log(' node scripts/migrate-rollback.js --batch - Rollback batch cuối cùng');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] === '--batch') {
|
||||
rollbackLastBatch();
|
||||
} else {
|
||||
rollbackMigration(args[0]);
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
require('dotenv').config();
|
||||
const connectDB = require('../config/database');
|
||||
const migrationHelper = require('../utils/migrationHelper');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
/**
|
||||
* Tự động phát hiện tất cả các file script trong thư mục scripts
|
||||
* Loại trừ các file quản lý migration và file không phải .js
|
||||
*/
|
||||
function discoverMigrations() {
|
||||
const scriptsDir = __dirname;
|
||||
const files = fs.readdirSync(scriptsDir);
|
||||
|
||||
// Danh sách các file quản lý migration cần loại trừ
|
||||
const excludeFiles = [
|
||||
'migrate-all.js',
|
||||
'migrate-status.js',
|
||||
'migrate-rollback.js',
|
||||
'migrate-fresh.js',
|
||||
'make-migration.js',
|
||||
'MIGRATION_README.md'
|
||||
];
|
||||
|
||||
const migrations = files
|
||||
.filter(file => {
|
||||
// Lấy tất cả file .js, trừ các file quản lý
|
||||
return file.endsWith('.js') && !excludeFiles.includes(file);
|
||||
})
|
||||
.map(file => file.replace('.js', ''))
|
||||
.sort();
|
||||
|
||||
return migrations;
|
||||
}
|
||||
|
||||
// Tự động phát hiện migrations
|
||||
const availableMigrations = discoverMigrations();
|
||||
|
||||
/**
|
||||
* Hiển thị trạng thái của tất cả migrations
|
||||
*/
|
||||
async function showStatus() {
|
||||
const mongoose = require('mongoose');
|
||||
let ownConn = false;
|
||||
|
||||
try {
|
||||
const wasConnected = mongoose.connection.readyState === 1;
|
||||
await connectDB();
|
||||
if (!wasConnected) ownConn = true;
|
||||
|
||||
console.log('\nMigration Status:\n');
|
||||
|
||||
const ranMigrations = await migrationHelper.getRanMigrations();
|
||||
const ranMap = new Map();
|
||||
ranMigrations.forEach(m => ranMap.set(m.name, m));
|
||||
|
||||
// Tính toán độ rộng cột
|
||||
const maxNameLength = Math.max(...availableMigrations.map(name => name.length), 20);
|
||||
const statusWidth = 10;
|
||||
const batchWidth = 6;
|
||||
const ranAtWidth = 20;
|
||||
const totalWidth = maxNameLength + statusWidth + batchWidth + ranAtWidth + 11; // 11 = spaces and separators
|
||||
|
||||
// Header
|
||||
console.log('='.repeat(totalWidth));
|
||||
console.log(
|
||||
`${'Migration Name'.padEnd(maxNameLength)} | ${'Status'.padEnd(statusWidth)} | ${'Batch'.padEnd(batchWidth)} | Ran At`
|
||||
);
|
||||
console.log('='.repeat(totalWidth));
|
||||
|
||||
let pendingCount = 0;
|
||||
let ranCount = 0;
|
||||
|
||||
for (const migrationName of availableMigrations) {
|
||||
const migration = ranMap.get(migrationName);
|
||||
if (migration) {
|
||||
const ranAt = new Date(migration.ranAt).toLocaleString('vi-VN');
|
||||
console.log(
|
||||
`${migrationName.padEnd(maxNameLength)} | ${'Ran'.padEnd(statusWidth)} | ${String(migration.batch).padEnd(batchWidth)} | ${ranAt}`
|
||||
);
|
||||
ranCount++;
|
||||
} else {
|
||||
console.log(
|
||||
`${migrationName.padEnd(maxNameLength)} | ${'Pending'.padEnd(statusWidth)} | ${'-'.padEnd(batchWidth)} | -`
|
||||
);
|
||||
pendingCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('='.repeat(totalWidth));
|
||||
console.log(`\nSummary:`);
|
||||
console.log(` Ran: ${ranCount} migration(s)`);
|
||||
console.log(` Pending: ${pendingCount} migration(s)`);
|
||||
|
||||
const lastBatch = await migrationHelper.getLastBatch();
|
||||
if (lastBatch > 0) {
|
||||
console.log(` Last batch: ${lastBatch}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
if (ownConn && mongoose.connection.readyState === 1) {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
if (ownConn && mongoose.connection.readyState === 1) {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Chạy nếu được gọi trực tiếp
|
||||
if (require.main === module) {
|
||||
showStatus();
|
||||
}
|
||||
|
||||
module.exports = { showStatus };
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
const mongoose = require("mongoose");
|
||||
const dotenv = require("dotenv");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const AboutUs = require("../models/aboutUs");
|
||||
|
||||
const migrate = async () => {
|
||||
try {
|
||||
console.log("🚀 Starting About Us migration...");
|
||||
|
||||
// 1. Connect to MongoDB
|
||||
await mongoose.connect(process.env.MONGODB_URI);
|
||||
console.log("✅ MongoDB Connected");
|
||||
|
||||
// 2. Read about.json from Backend (Source of Truth)
|
||||
const jsonPath = path.join(__dirname, "../data/about.json");
|
||||
if (!fs.existsSync(jsonPath)) {
|
||||
throw new Error(`Source about.json not found at: ${jsonPath}`);
|
||||
}
|
||||
|
||||
const rawData = fs.readFileSync(jsonPath, "utf8");
|
||||
const jsonData = JSON.parse(rawData);
|
||||
console.log("✅ Read about.json successfully");
|
||||
|
||||
// 3. Delete existing AboutUs documents (Singleton pattern)
|
||||
await AboutUs.deleteMany({});
|
||||
console.log("✅ Cleared existing AboutUs collection");
|
||||
|
||||
// 4. Create new AboutUs document with JSON data
|
||||
const newAboutUs = new AboutUs(jsonData);
|
||||
await newAboutUs.save();
|
||||
console.log("✅ Successfully migrated about.json data to MongoDB");
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Migration failed:", error.message);
|
||||
} finally {
|
||||
// 5. Close connection
|
||||
await mongoose.connection.close();
|
||||
console.log("👋 Database connection closed");
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
migrate();
|
||||
@@ -1,52 +0,0 @@
|
||||
const mongoose = require("mongoose");
|
||||
const dotenv = require("dotenv");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const AboutUs = require("../models/aboutUs");
|
||||
|
||||
const seedAbout = async () => {
|
||||
try {
|
||||
console.log("🚀 Starting About section seeding...");
|
||||
|
||||
// 1. Connect to MongoDB
|
||||
if (!process.env.MONGODB_URI) {
|
||||
throw new Error("MONGODB_URI is not defined in environment variables");
|
||||
}
|
||||
await mongoose.connect(process.env.MONGODB_URI);
|
||||
console.log("✅ MongoDB Connected");
|
||||
|
||||
// 2. Read about.json (Single Source of Truth)
|
||||
const jsonPath = path.join(__dirname, "../data/about.json");
|
||||
if (!fs.existsSync(jsonPath)) {
|
||||
throw new Error(`Source about.json not found at: ${jsonPath}`);
|
||||
}
|
||||
|
||||
const rawData = fs.readFileSync(jsonPath, "utf8");
|
||||
const jsonData = JSON.parse(rawData);
|
||||
console.log("✅ Read data/about.json successfully");
|
||||
|
||||
// 3. Upsert logic (Singleton pattern)
|
||||
// We look for any existing document and update it, or create a new one if none exists.
|
||||
await AboutUs.findOneAndUpdate(
|
||||
{},
|
||||
jsonData,
|
||||
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||
);
|
||||
|
||||
console.log("✅ Successfully seeded about.json data to MongoDB (Upserted)");
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Seeding failed:", error.message);
|
||||
} finally {
|
||||
// 4. Close connection
|
||||
await mongoose.connection.close();
|
||||
console.log("👋 Database connection closed");
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
seedAbout();
|
||||
@@ -0,0 +1,190 @@
|
||||
require('dotenv').config();
|
||||
const mongoose = require('mongoose');
|
||||
const slugify = require('slugify');
|
||||
|
||||
// Data from lams/app/components/layout/Header/header.json
|
||||
const headerJsonData = {
|
||||
"logo": {
|
||||
"image": "/uploads/header/logo.jp",
|
||||
"href": "/"
|
||||
},
|
||||
"navLinks": [
|
||||
{
|
||||
"label": "About Us",
|
||||
"href": "/about",
|
||||
"children": [
|
||||
{
|
||||
"label": "History & Milestones",
|
||||
"href": "/about/history"
|
||||
},
|
||||
{
|
||||
"label": "Accreditation",
|
||||
"href": "/about/accreditation"
|
||||
},
|
||||
{
|
||||
"label": "Partnerships",
|
||||
"href": "/about/partnerships"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Programs",
|
||||
"href": "/programmes"
|
||||
},
|
||||
{
|
||||
"label": "Student Support",
|
||||
"href": "/student-support"
|
||||
},
|
||||
{
|
||||
"label": "Admissions & Tuition",
|
||||
"href": "/admissions"
|
||||
},
|
||||
{
|
||||
"label": "Blog",
|
||||
"href": "/blog"
|
||||
},
|
||||
{
|
||||
"label": "Contact",
|
||||
"href": "/contact"
|
||||
}
|
||||
],
|
||||
"actions": {
|
||||
"signIn": {
|
||||
"label": "Sign In",
|
||||
"href": "/signin"
|
||||
},
|
||||
"cta": {
|
||||
"label": "Request Info",
|
||||
"href": "/request"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function seedHeader() {
|
||||
try {
|
||||
console.log('=== Seed Header Data from JSON ===');
|
||||
console.log('Connecting to MongoDB...');
|
||||
|
||||
await mongoose.connect(process.env.MONGODB_URI);
|
||||
console.log('✓ Connected to MongoDB');
|
||||
|
||||
const Header = require('../models/header');
|
||||
const HeaderMenu = require('../models/headerMenu');
|
||||
|
||||
// Step 1: Seed Header document
|
||||
console.log('\nStep 1: Seeding Header document...');
|
||||
|
||||
const existingHeader = await Header.findOne();
|
||||
|
||||
if (existingHeader) {
|
||||
console.log('⚠ Header document already exists. Updating...');
|
||||
existingHeader.logo = {
|
||||
light: headerJsonData.logo.image,
|
||||
dark: '',
|
||||
alt: 'LAMS Logo',
|
||||
};
|
||||
existingHeader.signInButton = {
|
||||
label: headerJsonData.actions.signIn.label,
|
||||
href: headerJsonData.actions.signIn.href,
|
||||
};
|
||||
existingHeader.ctaButton = {
|
||||
label: headerJsonData.actions.cta.label,
|
||||
href: headerJsonData.actions.cta.href,
|
||||
style: 'primary',
|
||||
};
|
||||
existingHeader.status = 'active';
|
||||
await existingHeader.save();
|
||||
console.log('✓ Header document updated');
|
||||
} else {
|
||||
const header = new Header({
|
||||
logo: {
|
||||
light: headerJsonData.logo.image,
|
||||
dark: '',
|
||||
alt: 'LAMS Logo',
|
||||
},
|
||||
signInButton: {
|
||||
label: headerJsonData.actions.signIn.label,
|
||||
href: headerJsonData.actions.signIn.href,
|
||||
},
|
||||
ctaButton: {
|
||||
label: headerJsonData.actions.cta.label,
|
||||
href: headerJsonData.actions.cta.href,
|
||||
style: 'primary',
|
||||
},
|
||||
status: 'active',
|
||||
order: 1,
|
||||
});
|
||||
await header.save();
|
||||
console.log('✓ Header document created');
|
||||
}
|
||||
|
||||
// Step 2: Seed HeaderMenu documents
|
||||
console.log('\nStep 2: Seeding HeaderMenu documents...');
|
||||
|
||||
const existingMenuCount = await HeaderMenu.countDocuments();
|
||||
|
||||
if (existingMenuCount > 0) {
|
||||
console.log(`⚠ Found ${existingMenuCount} existing menu items. Skipping menu seed.`);
|
||||
console.log(' To re-seed menu, delete existing menu items first.');
|
||||
} else {
|
||||
let order = 0;
|
||||
|
||||
for (const navLink of headerJsonData.navLinks) {
|
||||
// Create parent menu item
|
||||
const parentSlug = slugify(navLink.label, { lower: true, strict: true });
|
||||
const parentMenu = new HeaderMenu({
|
||||
title: navLink.label,
|
||||
slug: parentSlug,
|
||||
url: navLink.href,
|
||||
parentId: null,
|
||||
order: order++,
|
||||
status: 'active',
|
||||
type: 'internal',
|
||||
});
|
||||
await parentMenu.save();
|
||||
console.log(` ✓ Created parent menu: ${navLink.label}`);
|
||||
|
||||
// Create children menu items if they exist
|
||||
if (navLink.children && navLink.children.length > 0) {
|
||||
let childOrder = 0;
|
||||
for (const child of navLink.children) {
|
||||
const childSlug = slugify(child.label, { lower: true, strict: true });
|
||||
const childMenu = new HeaderMenu({
|
||||
title: child.label,
|
||||
slug: childSlug,
|
||||
url: child.href,
|
||||
parentId: parentMenu._id,
|
||||
order: childOrder++,
|
||||
status: 'active',
|
||||
type: 'internal',
|
||||
});
|
||||
await childMenu.save();
|
||||
console.log(` ✓ Created child menu: ${child.label}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✓ Created ${order} parent menu items with their children`);
|
||||
}
|
||||
|
||||
console.log('\n=== Seed Complete ===');
|
||||
await mongoose.disconnect();
|
||||
console.log('✓ Disconnected from MongoDB');
|
||||
|
||||
} catch (error) {
|
||||
console.error('✗ Seed failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run seed if this script is executed directly
|
||||
if (require.main === module) {
|
||||
seedHeader()
|
||||
.then(() => process.exit(0))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = seedHeader;
|
||||
@@ -1,106 +0,0 @@
|
||||
const mongoose = require("mongoose");
|
||||
const path = require("path");
|
||||
require("dotenv").config({ path: path.join(__dirname, "../.env") });
|
||||
|
||||
const Header = require("../models/header");
|
||||
|
||||
async function updateHeaderData() {
|
||||
try {
|
||||
// Connect to MongoDB
|
||||
await mongoose.connect(process.env.MONGODB_URI || "mongodb://localhost:27017/hailearning");
|
||||
console.log("Connected to MongoDB");
|
||||
|
||||
// Find the first header
|
||||
let header = await Header.findOne().sort({ order: 1 });
|
||||
|
||||
if (!header) {
|
||||
console.log("No header found, creating new one...");
|
||||
header = new Header({
|
||||
top: {
|
||||
phone: "+09 378 357 5222",
|
||||
email: "info@hailearning.edu.vn",
|
||||
location: "69 Street, 5th Avenue LA, United States",
|
||||
socialLinks: [
|
||||
{
|
||||
platform: "linkedin",
|
||||
url: "https://linkedin.com",
|
||||
icon: "fa-brands fa-linkedin",
|
||||
},
|
||||
{
|
||||
platform: "twitter",
|
||||
url: "https://twitter.com",
|
||||
icon: "fa-brands fa-twitter",
|
||||
},
|
||||
{
|
||||
platform: "instagram",
|
||||
url: "https://instagram.com",
|
||||
icon: "fa-brands fa-instagram",
|
||||
},
|
||||
{
|
||||
platform: "youtube",
|
||||
url: "https://youtube.com",
|
||||
icon: "fa-brands fa-youtube",
|
||||
},
|
||||
],
|
||||
languages: [
|
||||
{ name: "English", value: "1" },
|
||||
{ name: "Bangla", value: "2" },
|
||||
{ name: "Hindi", value: "3" },
|
||||
],
|
||||
},
|
||||
status: "active",
|
||||
order: 1,
|
||||
});
|
||||
} else {
|
||||
console.log("Header found, updating...");
|
||||
// Update existing header
|
||||
header.top = {
|
||||
phone: header.top?.phone || "+09 378 357 5222",
|
||||
email: header.top?.email || "info@hailearning.edu.vn",
|
||||
location: header.top?.location || "69 Street, 5th Avenue LA, United States",
|
||||
socialLinks:
|
||||
header.top?.socialLinks?.length > 0
|
||||
? header.top.socialLinks
|
||||
: [
|
||||
{
|
||||
platform: "linkedin",
|
||||
url: "https://linkedin.com",
|
||||
icon: "fa-brands fa-linkedin",
|
||||
},
|
||||
{
|
||||
platform: "twitter",
|
||||
url: "https://twitter.com",
|
||||
icon: "fa-brands fa-twitter",
|
||||
},
|
||||
{
|
||||
platform: "instagram",
|
||||
url: "https://instagram.com",
|
||||
icon: "fa-brands fa-instagram",
|
||||
},
|
||||
{
|
||||
platform: "youtube",
|
||||
url: "https://youtube.com",
|
||||
icon: "fa-brands fa-youtube",
|
||||
},
|
||||
],
|
||||
languages: header.top?.languages || [
|
||||
{ name: "English", value: "1" },
|
||||
{ name: "Bangla", value: "2" },
|
||||
{ name: "Hindi", value: "3" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
await header.save();
|
||||
console.log("Header updated successfully!");
|
||||
console.log("Header data:", JSON.stringify(header, null, 2));
|
||||
|
||||
await mongoose.connection.close();
|
||||
console.log("Database connection closed");
|
||||
} catch (error) {
|
||||
console.error("Error updating header:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
updateHeaderData();
|
||||
@@ -1,10 +0,0 @@
|
||||
// Keycloak config
|
||||
const keycloakConfig = {
|
||||
clientId: process.env.KEYCLOAK_CLIENT_ID,
|
||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||
redirectUri: process.env.KEYCLOAK_REDIRECT_URI,
|
||||
authServerUrl: process.env.KEYCLOAK_AUTH_SERVER_URL,
|
||||
isDev: process.env.KEYCLOAK_IS_DEV,
|
||||
};
|
||||
|
||||
module.exports = keycloakConfig;
|
||||
@@ -0,0 +1,812 @@
|
||||
<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 the About page</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form method="POST" id="aboutForm" action="/admin/about-us/update">
|
||||
<!-- Hidden JSON inputs -->
|
||||
<input type="hidden" name="hero" id="heroJson">
|
||||
<input type="hidden" name="leadership" id="leadershipJson">
|
||||
<input type="hidden" name="learningModel" id="learningModelJson">
|
||||
<input type="hidden" name="accreditation" id="accreditationJson">
|
||||
<input type="hidden" name="successStories" id="successStoriesJson">
|
||||
<input type="hidden" name="cta" id="ctaJson">
|
||||
|
||||
<!-- 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" id="aboutTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link active" data-bs-toggle="tab" href="#tab-hero-pane" role="tab"><i
|
||||
class="fas fa-image me-2"></i>Hero</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#tab-leadership-pane" role="tab"><i
|
||||
class="fas fa-users me-2"></i>Leadership</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#tab-learningModel-pane" role="tab"><i
|
||||
class="fas fa-chalkboard-teacher me-2"></i>Learning Model</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#tab-accreditation-pane" role="tab"><i
|
||||
class="fas fa-award me-2"></i>Accreditation</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#tab-successStories-pane" role="tab"><i
|
||||
class="fas fa-quote-left me-2"></i>Success Stories</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#tab-cta-pane" role="tab"><i
|
||||
class="fas fa-bullhorn me-2"></i>CTA</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
|
||||
<!-- ===== HERO TAB ===== -->
|
||||
<div class="tab-pane fade show active" id="tab-hero-pane" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero Section</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Badge</label>
|
||||
<input type="text" class="form-control" id="heroBadge"
|
||||
value="<%= data.hero?.badge || '' %>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Student Count</label>
|
||||
<input type="text" class="form-control" id="heroStudentCount"
|
||||
value="<%= data.hero?.studentCount || '' %>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Image Alt Text</label>
|
||||
<input type="text" class="form-control" id="heroImageAlt"
|
||||
value="<%= data.hero?.imageAlt || '' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" class="form-control" id="heroTitle"
|
||||
value="<%= data.hero?.title || '' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="heroDescription"
|
||||
rows="3"><%= data.hero?.description || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Image URL</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="heroImage"
|
||||
value="<%= data.hero?.image || '' %>">
|
||||
<button class="btn btn-outline-primary" type="button"
|
||||
onclick="openImageUploader('heroImage', 'about')"><i
|
||||
class="fas fa-upload me-1"></i>Upload</button>
|
||||
</div>
|
||||
<% if (data.hero?.image) { %>
|
||||
<img src="<%= data.hero.image %>"
|
||||
class="img-thumbnail uploaded-preview mt-2"
|
||||
style="max-height:180px;">
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Core Values</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
onclick="addCoreValue()"><i class="fas fa-plus me-1"></i>Add
|
||||
Value</button>
|
||||
</div>
|
||||
<div id="coreValuesContainer">
|
||||
<% (data.hero?.coreValues || []).forEach(val=> { %>
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="text" class="form-control core-value-input"
|
||||
value="<%= val %>">
|
||||
<button class="btn btn-outline-danger" type="button"
|
||||
onclick="this.parentElement.remove()"><i
|
||||
class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== LEADERSHIP TAB ===== -->
|
||||
<div class="tab-pane fade" id="tab-leadership-pane" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0"><i class="fas fa-users me-2"></i>Leadership</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
onclick="addLeadershipMember()"><i class="fas fa-plus me-1"></i>Add
|
||||
Member</button>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Heading</label>
|
||||
<input type="text" class="form-control" id="leadershipHeading"
|
||||
value="<%= data.leadership?.heading || '' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="leadershipDescription"
|
||||
rows="2"><%= data.leadership?.description || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div id="leadershipMembersContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== LEARNING MODEL TAB ===== -->
|
||||
<div class="tab-pane fade" id="tab-learningModel-pane" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-chalkboard-teacher me-2"></i>Learning Model
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Heading</label>
|
||||
<input type="text" class="form-control" id="lmHeading"
|
||||
value="<%= data.learningModel?.heading || '' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="lmDescription"
|
||||
rows="2"><%= data.learningModel?.description || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Async -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border">
|
||||
<div
|
||||
class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Asynchronous Learning</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
onclick="addLearningFeature('async')"><i
|
||||
class="fas fa-plus me-1"></i>Add Feature</button>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Title</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
id="asyncTitle"
|
||||
value="<%= data.learningModel?.async?.title || '' %>">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Description</label>
|
||||
<textarea class="form-control form-control-sm"
|
||||
id="asyncDescription"
|
||||
rows="2"><%= data.learningModel?.async?.description || '' %></textarea>
|
||||
</div>
|
||||
<div id="asyncFeaturesContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sync -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border">
|
||||
<div
|
||||
class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Synchronous Learning</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
onclick="addLearningFeature('sync')"><i
|
||||
class="fas fa-plus me-1"></i>Add Feature</button>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Title</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
id="syncTitle"
|
||||
value="<%= data.learningModel?.sync?.title || '' %>">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Description</label>
|
||||
<textarea class="form-control form-control-sm"
|
||||
id="syncDescription"
|
||||
rows="2"><%= data.learningModel?.sync?.description || '' %></textarea>
|
||||
</div>
|
||||
<div id="syncFeaturesContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== ACCREDITATION TAB ===== -->
|
||||
<div class="tab-pane fade" id="tab-accreditation-pane" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-award me-2"></i>Accreditation</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Heading</label>
|
||||
<input type="text" class="form-control" id="accHeading"
|
||||
value="<%= data.accreditation?.heading || '' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="accDescription"
|
||||
rows="2"><%= data.accreditation?.description || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Badges</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
onclick="addAccBadge()"><i class="fas fa-plus me-1"></i>Add
|
||||
Badge</button>
|
||||
</div>
|
||||
<div id="accBadgesContainer"></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Stats</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
onclick="addAccStat()"><i class="fas fa-plus me-1"></i>Add
|
||||
Stat</button>
|
||||
</div>
|
||||
<div id="accStatsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SUCCESS STORIES TAB ===== -->
|
||||
<div class="tab-pane fade" id="tab-successStories-pane" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0"><i class="fas fa-quote-left me-2"></i>Success Stories</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
onclick="addStory()"><i class="fas fa-plus me-1"></i>Add Story</button>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Heading</label>
|
||||
<input type="text" class="form-control" id="ssHeading"
|
||||
value="<%= data.successStories?.heading || '' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="ssDescription"
|
||||
rows="2"><%= data.successStories?.description || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div id="storiesContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== CTA TAB ===== -->
|
||||
<div class="tab-pane fade" id="tab-cta-pane" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-bullhorn me-2"></i>Call to Action</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Heading</label>
|
||||
<input type="text" class="form-control" id="ctaHeading"
|
||||
value="<%= data.cta?.heading || '' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="ctaDescription"
|
||||
rows="2"><%= data.cta?.description || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Primary Button Label</label>
|
||||
<input type="text" class="form-control" id="ctaPrimaryLabel"
|
||||
value="<%= data.cta?.primaryButton?.label || '' %>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Primary Button Href</label>
|
||||
<input type="text" class="form-control" id="ctaPrimaryHref"
|
||||
value="<%= data.cta?.primaryButton?.href || '' %>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Secondary Button Label</label>
|
||||
<input type="text" class="form-control" id="ctaSecondaryLabel"
|
||||
value="<%= data.cta?.secondaryButton?.label || '' %>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Secondary Button Href</label>
|
||||
<input type="text" class="form-control" id="ctaSecondaryHref"
|
||||
value="<%= data.cta?.secondaryButton?.href || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.tab-content -->
|
||||
</div><!-- /.card-body -->
|
||||
|
||||
<div class="card-footer bg-light d-flex justify-content-end py-3 gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary px-4" onclick="resetForm()"><i
|
||||
class="fas fa-undo me-2"></i>Reset</button>
|
||||
<button type="submit" class="btn btn-outline-primary px-4" id="submitBtn"><i
|
||||
class="fas fa-save me-2"></i>Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let originalFormData = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
originalFormData = <%- JSON.stringify(data) %>;
|
||||
populateAll(originalFormData);
|
||||
|
||||
document.getElementById('aboutForm').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
serializeAll();
|
||||
this.submit();
|
||||
});
|
||||
|
||||
document.body.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('.btn-upload-image');
|
||||
if (btn) openImageUploader(btn.dataset.targetInput, btn.dataset.imageType);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Populate all sections ────────────────────────────────────────────────
|
||||
function populateAll(data) {
|
||||
if (!data) return;
|
||||
|
||||
// Hero
|
||||
const h = data.hero || {};
|
||||
setVal('heroBadge', h.badge);
|
||||
setVal('heroTitle', h.title);
|
||||
setVal('heroDescription', h.description);
|
||||
setVal('heroStudentCount', h.studentCount);
|
||||
setVal('heroImage', h.image);
|
||||
setVal('heroImageAlt', h.imageAlt);
|
||||
updateImagePreview('heroImage', h.image);
|
||||
populateCoreValues(h.coreValues || []);
|
||||
|
||||
// Leadership
|
||||
const l = data.leadership || {};
|
||||
setVal('leadershipHeading', l.heading);
|
||||
setVal('leadershipDescription', l.description);
|
||||
populateLeadershipMembers(l.members || []);
|
||||
|
||||
// Learning Model
|
||||
const lm = data.learningModel || {};
|
||||
setVal('lmHeading', lm.heading);
|
||||
setVal('lmDescription', lm.description);
|
||||
setVal('asyncTitle', lm.async?.title);
|
||||
setVal('asyncDescription', lm.async?.description);
|
||||
setVal('syncTitle', lm.sync?.title);
|
||||
setVal('syncDescription', lm.sync?.description);
|
||||
populateLearningFeatures('async', lm.async?.features || []);
|
||||
populateLearningFeatures('sync', lm.sync?.features || []);
|
||||
|
||||
// Accreditation
|
||||
const acc = data.accreditation || {};
|
||||
setVal('accHeading', acc.heading);
|
||||
setVal('accDescription', acc.description);
|
||||
populateAccBadges(acc.badges || []);
|
||||
populateAccStats(acc.stats || []);
|
||||
|
||||
// Success Stories
|
||||
const ss = data.successStories || {};
|
||||
setVal('ssHeading', ss.heading);
|
||||
setVal('ssDescription', ss.description);
|
||||
populateStories(ss.stories || []);
|
||||
|
||||
// CTA
|
||||
const cta = data.cta || {};
|
||||
setVal('ctaHeading', cta.heading);
|
||||
setVal('ctaDescription', cta.description);
|
||||
setVal('ctaPrimaryLabel', cta.primaryButton?.label);
|
||||
setVal('ctaPrimaryHref', cta.primaryButton?.href);
|
||||
setVal('ctaSecondaryLabel', cta.secondaryButton?.label);
|
||||
setVal('ctaSecondaryHref', cta.secondaryButton?.href);
|
||||
}
|
||||
|
||||
function setVal(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = val || '';
|
||||
}
|
||||
|
||||
// ── Serialize all sections into hidden JSON inputs ───────────────────────
|
||||
function serializeAll() {
|
||||
// Hero
|
||||
document.getElementById('heroJson').value = JSON.stringify({
|
||||
badge: v('heroBadge'),
|
||||
title: v('heroTitle'),
|
||||
description: v('heroDescription'),
|
||||
studentCount: v('heroStudentCount'),
|
||||
image: v('heroImage'),
|
||||
imageAlt: v('heroImageAlt'),
|
||||
coreValues: Array.from(document.querySelectorAll('.core-value-input')).map(i => i.value.trim()).filter(Boolean)
|
||||
});
|
||||
|
||||
// Leadership
|
||||
document.getElementById('leadershipJson').value = JSON.stringify({
|
||||
heading: v('leadershipHeading'),
|
||||
description: v('leadershipDescription'),
|
||||
members: Array.from(document.querySelectorAll('.leadership-member')).map(row => ({
|
||||
name: row.querySelector('[data-field="name"]').value.trim(),
|
||||
role: row.querySelector('[data-field="role"]').value.trim(),
|
||||
bio: row.querySelector('[data-field="bio"]').value.trim(),
|
||||
avatar: row.querySelector('[data-field="avatar"]').value.trim(),
|
||||
social: {
|
||||
linkedin: row.querySelector('[data-field="linkedin"]').value.trim(),
|
||||
twitter: row.querySelector('[data-field="twitter"]').value.trim()
|
||||
}
|
||||
}))
|
||||
});
|
||||
|
||||
// Learning Model
|
||||
document.getElementById('learningModelJson').value = JSON.stringify({
|
||||
heading: v('lmHeading'),
|
||||
description: v('lmDescription'),
|
||||
async: {
|
||||
title: v('asyncTitle'),
|
||||
description: v('asyncDescription'),
|
||||
features: collectLearningFeatures('async')
|
||||
},
|
||||
sync: {
|
||||
title: v('syncTitle'),
|
||||
description: v('syncDescription'),
|
||||
features: collectLearningFeatures('sync')
|
||||
}
|
||||
});
|
||||
|
||||
// Accreditation
|
||||
document.getElementById('accreditationJson').value = JSON.stringify({
|
||||
heading: v('accHeading'),
|
||||
description: v('accDescription'),
|
||||
badges: Array.from(document.querySelectorAll('.acc-badge-item')).map(row => ({
|
||||
icon: row.querySelector('[data-field="icon"]').value.trim(),
|
||||
title: row.querySelector('[data-field="title"]').value.trim(),
|
||||
desc: row.querySelector('[data-field="desc"]').value.trim()
|
||||
})),
|
||||
stats: Array.from(document.querySelectorAll('.acc-stat-item')).map(row => ({
|
||||
value: row.querySelector('[data-field="value"]').value.trim(),
|
||||
label: row.querySelector('[data-field="label"]').value.trim(),
|
||||
isPrimary: row.querySelector('[data-field="isPrimary"]').checked
|
||||
}))
|
||||
});
|
||||
|
||||
// Success Stories
|
||||
document.getElementById('successStoriesJson').value = JSON.stringify({
|
||||
heading: v('ssHeading'),
|
||||
description: v('ssDescription'),
|
||||
stories: Array.from(document.querySelectorAll('.story-item')).map(row => ({
|
||||
quote: row.querySelector('[data-field="quote"]').value.trim(),
|
||||
name: row.querySelector('[data-field="name"]').value.trim(),
|
||||
program: row.querySelector('[data-field="program"]').value.trim(),
|
||||
avatar: row.querySelector('[data-field="avatar"]').value.trim()
|
||||
}))
|
||||
});
|
||||
|
||||
// CTA
|
||||
document.getElementById('ctaJson').value = JSON.stringify({
|
||||
heading: v('ctaHeading'),
|
||||
description: v('ctaDescription'),
|
||||
primaryButton: { label: v('ctaPrimaryLabel'), href: v('ctaPrimaryHref') },
|
||||
secondaryButton: { label: v('ctaSecondaryLabel'), href: v('ctaSecondaryHref') }
|
||||
});
|
||||
}
|
||||
|
||||
function v(id) {
|
||||
const el = document.getElementById(id);
|
||||
return el ? el.value.trim() : '';
|
||||
}
|
||||
|
||||
// ── Core Values ──────────────────────────────────────────────────────────
|
||||
function addCoreValue(val = '') {
|
||||
const c = document.getElementById('coreValuesContainer');
|
||||
c.insertAdjacentHTML('beforeend', `
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="text" class="form-control core-value-input" value="${esc(val)}" placeholder="e.g. Radical Affordability">
|
||||
<button class="btn btn-outline-danger" type="button" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
function populateCoreValues(values) {
|
||||
document.getElementById('coreValuesContainer').innerHTML = '';
|
||||
values.forEach(v => addCoreValue(v));
|
||||
}
|
||||
|
||||
// ── Leadership Members ───────────────────────────────────────────────────
|
||||
function addLeadershipMember(item = {}) {
|
||||
const idx = Date.now();
|
||||
const c = document.getElementById('leadershipMembersContainer');
|
||||
c.insertAdjacentHTML('beforeend', `
|
||||
<div class="card mb-3 leadership-member">
|
||||
<div class="card-body p-3">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="name" value="${esc(item.name)}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Role</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="role" value="${esc(item.role)}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Bio</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="bio" value="${esc(item.bio)}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Avatar URL</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control" data-field="avatar" id="memberAvatar_${idx}" value="${esc(item.avatar)}">
|
||||
<button class="btn btn-outline-primary btn-upload-image" type="button" data-target-input="memberAvatar_${idx}" data-image-type="about"><i class="fas fa-upload"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">LinkedIn URL</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="linkedin" value="${esc(item.social?.linkedin)}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Twitter URL</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="twitter" value="${esc(item.social?.twitter)}">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link text-danger btn-sm p-0 mt-2" onclick="this.closest('.leadership-member').remove()"><i class="fas fa-trash me-1"></i>Remove</button>
|
||||
</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
function populateLeadershipMembers(members) {
|
||||
document.getElementById('leadershipMembersContainer').innerHTML = '';
|
||||
members.forEach(m => addLeadershipMember(m));
|
||||
}
|
||||
|
||||
// ── Learning Features ────────────────────────────────────────────────────
|
||||
function addLearningFeature(mode, item = {}) {
|
||||
const c = document.getElementById(mode + 'FeaturesContainer');
|
||||
c.insertAdjacentHTML('beforeend', `
|
||||
<div class="card mb-2 lf-feature-item" data-mode="${mode}">
|
||||
<div class="card-body p-2">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3">
|
||||
<input type="text" class="form-control form-control-sm" data-field="icon" value="${esc(item.icon)}" placeholder="fa-play">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control form-control-sm" data-field="title" value="${esc(item.title)}" placeholder="Title">
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<input type="text" class="form-control form-control-sm" data-field="desc" value="${esc(item.desc)}" placeholder="Description">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link text-danger btn-sm p-0 mt-1" onclick="this.closest('.lf-feature-item').remove()"><i class="fas fa-trash me-1"></i>Remove</button>
|
||||
</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
function populateLearningFeatures(mode, features) {
|
||||
document.getElementById(mode + 'FeaturesContainer').innerHTML = '';
|
||||
features.forEach(f => addLearningFeature(mode, f));
|
||||
}
|
||||
|
||||
function collectLearningFeatures(mode) {
|
||||
return Array.from(document.querySelectorAll(`.lf-feature-item[data-mode="${mode}"]`)).map(row => ({
|
||||
icon: row.querySelector('[data-field="icon"]').value.trim(),
|
||||
title: row.querySelector('[data-field="title"]').value.trim(),
|
||||
desc: row.querySelector('[data-field="desc"]').value.trim()
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Accreditation Badges ─────────────────────────────────────────────────
|
||||
function addAccBadge(item = {}) {
|
||||
const c = document.getElementById('accBadgesContainer');
|
||||
c.insertAdjacentHTML('beforeend', `
|
||||
<div class="card mb-2 acc-badge-item">
|
||||
<div class="card-body p-2">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3">
|
||||
<input type="text" class="form-control form-control-sm" data-field="icon" value="${esc(item.icon)}" placeholder="fa-award">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control form-control-sm" data-field="title" value="${esc(item.title)}" placeholder="Title">
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<input type="text" class="form-control form-control-sm" data-field="desc" value="${esc(item.desc)}" placeholder="Description">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link text-danger btn-sm p-0 mt-1" onclick="this.closest('.acc-badge-item').remove()"><i class="fas fa-trash me-1"></i>Remove</button>
|
||||
</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
function populateAccBadges(badges) {
|
||||
document.getElementById('accBadgesContainer').innerHTML = '';
|
||||
badges.forEach(b => addAccBadge(b));
|
||||
}
|
||||
|
||||
// ── Accreditation Stats ──────────────────────────────────────────────────
|
||||
function addAccStat(item = {}) {
|
||||
const c = document.getElementById('accStatsContainer');
|
||||
c.insertAdjacentHTML('beforeend', `
|
||||
<div class="card mb-2 acc-stat-item">
|
||||
<div class="card-body p-2">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-md-3">
|
||||
<input type="text" class="form-control form-control-sm" data-field="value" value="${esc(item.value)}" placeholder="e.g. 89%">
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<input type="text" class="form-control form-control-sm" data-field="label" value="${esc(item.label)}" placeholder="Label">
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-center gap-2">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="checkbox" data-field="isPrimary" ${item.isPrimary ? 'checked' : ''}>
|
||||
<label class="form-check-label small">Primary</label>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link text-danger btn-sm p-0" onclick="this.closest('.acc-stat-item').remove()"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
function populateAccStats(stats) {
|
||||
document.getElementById('accStatsContainer').innerHTML = '';
|
||||
stats.forEach(s => addAccStat(s));
|
||||
}
|
||||
|
||||
// ── Success Stories ──────────────────────────────────────────────────────
|
||||
function addStory(item = {}) {
|
||||
const idx = Date.now();
|
||||
const c = document.getElementById('storiesContainer');
|
||||
c.insertAdjacentHTML('beforeend', `
|
||||
<div class="card mb-3 story-item">
|
||||
<div class="card-body p-3">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label small">Quote</label>
|
||||
<textarea class="form-control form-control-sm" data-field="quote" rows="2">${esc(item.quote)}</textarea>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="name" value="${esc(item.name)}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Program</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="program" value="${esc(item.program)}">
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small">Avatar URL</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control" data-field="avatar" id="storyAvatar_${idx}" value="${esc(item.avatar)}">
|
||||
<button class="btn btn-outline-primary btn-upload-image" type="button" data-target-input="storyAvatar_${idx}" data-image-type="about"><i class="fas fa-upload"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link text-danger btn-sm p-0 mt-2" onclick="this.closest('.story-item').remove()"><i class="fas fa-trash me-1"></i>Remove</button>
|
||||
</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
function populateStories(stories) {
|
||||
document.getElementById('storiesContainer').innerHTML = '';
|
||||
stories.forEach(s => addStory(s));
|
||||
}
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────
|
||||
function esc(val) {
|
||||
if (!val) return '';
|
||||
return String(val).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
if (confirm('Reset all changes to last saved state?')) {
|
||||
populateAll(originalFormData);
|
||||
}
|
||||
}
|
||||
|
||||
function updateImagePreview(inputId, imagePath) {
|
||||
if (!imagePath) return;
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) return;
|
||||
let preview = input.closest('.card')?.querySelector('.uploaded-preview');
|
||||
if (preview) {
|
||||
preview.src = imagePath;
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = imagePath;
|
||||
img.className = 'img-thumbnail uploaded-preview mt-2';
|
||||
img.style.maxHeight = '180px';
|
||||
input.closest('.input-group')?.insertAdjacentElement('afterend', img);
|
||||
}
|
||||
}
|
||||
|
||||
function openImageUploader(targetInput, imageType) {
|
||||
// Dùng persistent hidden file input để tránh browser block programmatic click
|
||||
let fileInput = document.getElementById('__globalFileInput');
|
||||
if (!fileInput) {
|
||||
fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.id = '__globalFileInput';
|
||||
fileInput.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;width:1px;height:1px;';
|
||||
document.body.appendChild(fileInput);
|
||||
}
|
||||
|
||||
// Reset để có thể chọn lại cùng file
|
||||
fileInput.value = '';
|
||||
|
||||
// Gán handler mới mỗi lần gọi
|
||||
fileInput.onchange = async function (e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const uploadBtn = document.querySelector(`[onclick*="${targetInput}"]`) ||
|
||||
document.querySelector(`[data-target-input="${targetInput}"]`);
|
||||
if (uploadBtn) uploadBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const response = await fetch(`/admin/upload/image?imageType=${imageType}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (!response.ok) throw new Error('Upload failed');
|
||||
const result = await response.json();
|
||||
if (!result.success) throw new Error(result.error || 'Upload failed');
|
||||
|
||||
const input = document.getElementById(targetInput);
|
||||
if (input) {
|
||||
input.value = result.path;
|
||||
const previewUrl = result.path.startsWith('http') ? result.path : window.location.origin + result.path;
|
||||
let preview = input.closest('.col-md-12, .col-md-4, .col-md-5')?.querySelector('.uploaded-preview');
|
||||
if (preview) {
|
||||
preview.src = previewUrl;
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = previewUrl;
|
||||
img.className = 'img-thumbnail uploaded-preview mt-2';
|
||||
img.style.maxHeight = '150px';
|
||||
input.closest('.input-group')?.insertAdjacentElement('afterend', img);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err);
|
||||
alert('Upload failed: ' + err.message);
|
||||
} finally {
|
||||
if (uploadBtn) uploadBtn.disabled = false;
|
||||
fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
</script>
|
||||
@@ -1,967 +0,0 @@
|
||||
<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 About Us page</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= frontendUrl %>/about-us/" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View About Us Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form method="POST" class="content-with-fixed-buttons" id="aboutUsForm"
|
||||
action="/admin/about-us/update">
|
||||
<!-- Hidden inputs for JSON data -->
|
||||
<input type="hidden" name="heroJson" id="heroJson">
|
||||
<input type="hidden" name="introJson" id="introJson">
|
||||
<input type="hidden" name="missionJson" id="missionJson">
|
||||
<input type="hidden" name="featuresJson" id="featuresJson">
|
||||
<input type="hidden" name="newsJson" id="newsJson">
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= locals.activeTab || 'hero' %>">
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<!-- Tab Menu -->
|
||||
<ul class="nav nav-tabs card-header-tabs" id="aboutUsTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link <%= (locals.activeTab === 'hero' || !locals.activeTab) ? 'active' : '' %>"
|
||||
id="hero-tab" data-bs-toggle="tab" href="#hero" role="tab"
|
||||
aria-selected="true"><i class="fas fa-image me-2"></i>Hero</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link <%= locals.activeTab === 'intro' ? 'active' : '' %>" id="intro-tab"
|
||||
data-bs-toggle="tab" href="#intro" role="tab"
|
||||
aria-selected="false"><i class="fas fa-info-circle me-2"></i>Intro</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link <%= locals.activeTab === 'mission' ? 'active' : '' %>" id="mission-tab"
|
||||
data-bs-toggle="tab" href="#mission" role="tab"
|
||||
aria-selected="false"><i class="fas fa-bullseye me-2"></i>Mission</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link <%= locals.activeTab === 'features' ? 'active' : '' %>" id="features-tab"
|
||||
data-bs-toggle="tab" href="#features" role="tab"
|
||||
aria-selected="false"><i class="fas fa-star me-2"></i>Features</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link <%= locals.activeTab === 'news' ? 'active' : '' %>" id="news-tab"
|
||||
data-bs-toggle="tab" href="#news" role="tab"
|
||||
aria-selected="false"><i class="fas fa-newspaper me-2"></i>News</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
<!-- Hero Tab -->
|
||||
<div class="tab-pane fade <%= (locals.activeTab === 'hero' || !locals.activeTab) ? 'show active' : '' %>" id="hero"
|
||||
role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero Section</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">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">Breadcrumb (comma separated)</label>
|
||||
<input type="text" class="form-control" id="heroBreadcrumb"
|
||||
value="<%= (data.hero?.breadcrumb || []).join(', ') %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Background Image</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="heroBackgroundImage"
|
||||
name="heroBackgroundImage" value="<%= data.hero?.backgroundImage || '' %>">
|
||||
<button class="btn btn-outline-primary btn-upload-image" type="button"
|
||||
data-target-input="heroBackgroundImage" data-image-type="about">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
<% if (data.hero?.backgroundImage) { %>
|
||||
<img src="<%= data.hero.backgroundImage %>"
|
||||
class="img-thumbnail uploaded-preview mt-2"
|
||||
style="max-height: 200px;">
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intro Tab -->
|
||||
<div class="tab-pane fade <%= locals.activeTab === 'intro' ? 'show active' : '' %>" id="intro"
|
||||
role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-info-circle me-2"></i>Introduction</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Subheading</label>
|
||||
<input type="text" class="form-control" id="introSubheading" name="introSubheading"
|
||||
value="<%= data.intro?.subheading || '' %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Heading</label>
|
||||
<input type="text" class="form-control" id="introHeading" name="introHeading"
|
||||
value="<%= data.intro?.heading || '' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="introDescription" name="introDescription"
|
||||
rows="4"><%= data.intro?.description || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Main Image</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="introImage" name="introImage"
|
||||
value="<%= data.intro?.image || '' %>">
|
||||
<button class="btn btn-outline-primary btn-upload-image" type="button"
|
||||
data-target-input="introImage" data-image-type="about">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
<% if (data.intro?.image) { %>
|
||||
<img src="<%= data.intro.image %>"
|
||||
class="img-thumbnail uploaded-preview mt-2"
|
||||
style="max-height: 200px;">
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mission Tab -->
|
||||
<div class="tab-pane fade <%= locals.activeTab === 'mission' ? 'show active' : '' %>" id="mission" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-bullseye me-2"></i>Mission Section</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Subheading</label>
|
||||
<input type="text" class="form-control" id="missionSubheading" value="<%= data.mission?.subheading || '' %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Heading</label>
|
||||
<input type="text" class="form-control" id="missionHeading" value="<%= data.mission?.heading || '' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="missionDescription" rows="3"><%= data.mission?.description || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">CTA Button Label</label>
|
||||
<input type="text" class="form-control" id="missionCtaLabel" value="<%= data.mission?.ctaButton?.label || '' %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">CTA Button Link</label>
|
||||
<input type="text" class="form-control" id="missionCtaHref" value="<%= data.mission?.ctaButton?.href || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="mt-4 mb-3">Images</h6>
|
||||
<div class="row g-3">
|
||||
<% ['main', 'secondary', 'bgShape', 'planeShape', 'topShape', 'globeShape'].forEach(imgKey => { %>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label"><%= imgKey.charAt(0).toUpperCase() + imgKey.slice(1) %></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="missionImg_<%= imgKey %>" value="<%= data.mission?.images?.[imgKey] || '' %>">
|
||||
<button class="btn btn-outline-primary btn-upload-image btn-sm" type="button" data-target-input="missionImg_<%= imgKey %>" data-image-type="about">
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<label class="form-label mb-0">Items (Icons & Labels)</label>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="addMissionItem()">
|
||||
<i class="fas fa-plus me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
<div id="missionItemsContainer"></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<label class="form-label mb-0">Features (List)</label>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="addMissionFeature()">
|
||||
<i class="fas fa-plus me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
<div id="missionFeaturesContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Tab -->
|
||||
<div class="tab-pane fade <%= locals.activeTab === 'features' ? 'show active' : '' %>" id="features" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-star me-2"></i>Features Section</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Subheading</label>
|
||||
<input type="text" class="form-control" id="featuresSubheading" value="<%= data.features?.subheading || '' %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Heading</label>
|
||||
<input type="text" class="form-control" id="featuresHeading" value="<%= data.features?.heading || '' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="featuresDescription" rows="3"><%= data.features?.description || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Background Image</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="featuresBgImage" value="<%= data.features?.backgroundImage || '' %>">
|
||||
<button class="btn btn-outline-primary btn-upload-image" type="button" data-target-input="featuresBgImage" data-image-type="about">
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Side Image</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="featuresImage" value="<%= data.features?.image || '' %>">
|
||||
<button class="btn btn-outline-primary btn-upload-image" type="button" data-target-input="featuresImage" data-image-type="about">
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">CTA Button Label</label>
|
||||
<input type="text" class="form-control" id="featuresCtaLabel" value="<%= data.features?.ctaButton?.label || '' %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">CTA Button Link</label>
|
||||
<input type="text" class="form-control" id="featuresCtaHref" value="<%= data.features?.ctaButton?.href || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Feature Items</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="addFeatureItem()">
|
||||
<i class="fas fa-plus me-1"></i>Add Item
|
||||
</button>
|
||||
</div>
|
||||
<div id="featureItemsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- News Tab -->
|
||||
<div class="tab-pane fade <%= locals.activeTab === 'news' ? 'show active' : '' %>" id="news" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0"><i class="fas fa-newspaper me-2"></i>News Section (Blog Preview)</h6>
|
||||
<span class="badge bg-info text-dark">System will automatically fetch the 3 latest posts if no specific blog is selected.</span>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Subheading</label>
|
||||
<input type="text" class="form-control" id="newsSubheading" value="<%= data.news?.subheading || '' %>" placeholder="e.g., Visa Tips & Guides">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Heading</label>
|
||||
<input type="text" class="form-control" id="newsHeading" value="<%= data.news?.heading || '' %>" placeholder="e.g., Latest Insights & Updates">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">CTA Button Label</label>
|
||||
<input type="text" class="form-control" id="newsCtaLabel" value="<%= data.news?.ctaButton?.label || '' %>" placeholder="e.g., View All Articles">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">CTA Button Link</label>
|
||||
<input type="text" class="form-control" id="newsCtaHref" value="<%= data.news?.ctaButton?.href || '' %>" placeholder="/blog">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-12 mt-4">
|
||||
<label class="form-label fw-bold"><i class="fas fa-check-square me-2"></i>Select Featured Blogs (Direct from Blog Module)</label>
|
||||
<p class="text-muted small mb-3">Select blog posts to display on About page. If none are selected, the system will use the 3 latest posts.</p>
|
||||
<div class="row g-3 blog-selector-container" style="max-height: 400px; overflow-y: auto; border: 1px solid #eee; padding: 15px; border-radius: 8px;">
|
||||
<% if (allBlogs && allBlogs.length > 0) { %>
|
||||
<% allBlogs.forEach(blog => {
|
||||
const isSelected = data.news?.selectedBlogIds && data.news.selectedBlogIds.some(id => id.toString() === blog._id.toString());
|
||||
%>
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 blog-select-card <%= isSelected ? 'border-primary bg-light' : '' %>" onclick="toggleAboutBlogSelection(this, '<%= blog._id %>')" style="cursor: pointer; transition: all 0.2s;">
|
||||
<div class="position-absolute top-0 end-0 m-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input about-blog-checkbox" type="checkbox" value="<%= blog._id %>" <%= isSelected ? 'checked' : '' %> onclick="event.stopPropagation(); handleAboutCheckboxChange(this)">
|
||||
</div>
|
||||
</div>
|
||||
<img src="<%= blog.featuredImage ? (blog.featuredImage.startsWith('http') ? blog.featuredImage : backendUrl + blog.featuredImage) : '/assets/img/placeholder.jpg' %>" class="card-img-top" style="height: 210px; object-fit: cover;">
|
||||
<div class="card-body p-2">
|
||||
<h6 class="card-title small fw-bold mb-1" title="<%= blog.title %>" style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2.6em; line-height: 1.3em;">
|
||||
<%= blog.title %>
|
||||
</h6>
|
||||
<p class="card-text tiny text-muted mb-0">
|
||||
<%= blog.publishedAt ? new Date(blog.publishedAt).toLocaleDateString('vi-VN') : '' %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
<% } else { %>
|
||||
<div class="col-12 text-center py-4">
|
||||
<p class="text-muted">No published blogs found. Please create some blogs first.</p>
|
||||
<a href="/admin/blog/create" class="btn btn-sm btn-outline-primary">Create Blog</a>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Bottom Buttons inside Card Footer -->
|
||||
<div class="card-footer bg-light d-flex justify-content-end py-3 gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary px-4" onclick="resetForm()">
|
||||
<i class="fas fa-undo me-2"></i>Reset
|
||||
</button>
|
||||
<button type="submit" class="btn btn-outline-primary px-4" id="submitBtn">
|
||||
<i class="fas fa-save me-2"></i>Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let originalFormData = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
originalFormData = <%- JSON.stringify(data) %>;
|
||||
updateAllJsonInputs(originalFormData);
|
||||
initializeFormHandlers();
|
||||
});
|
||||
|
||||
function initializeFormHandlers() {
|
||||
const form = document.getElementById('aboutUsForm');
|
||||
form.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const originalHtml = submitBtn.innerHTML;
|
||||
|
||||
try {
|
||||
// Collect all data into a single object
|
||||
updateJsonData();
|
||||
// No more "Saving..." text or disabling button to keep UI "instant"
|
||||
// submitBtn.disabled = true;
|
||||
// submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving...';
|
||||
|
||||
// We'll send the individual section JSONs or construct one big one
|
||||
const aboutData = {
|
||||
hero: JSON.parse(document.getElementById('heroJson').value),
|
||||
intro: JSON.parse(document.getElementById('introJson').value),
|
||||
mission: JSON.parse(document.getElementById('missionJson').value),
|
||||
features: JSON.parse(document.getElementById('featuresJson').value),
|
||||
news: JSON.parse(document.getElementById('newsJson').value)
|
||||
};
|
||||
|
||||
const response = await fetch('/api/about', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ aboutJson: JSON.stringify(aboutData) })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Update failed');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showToast('Success', 'About Us updated successfully', 'success');
|
||||
|
||||
// Update the local state with returned data from server
|
||||
// This ensures the UI is in sync with what was actually saved
|
||||
if (result.data) {
|
||||
originalFormData = result.data;
|
||||
updateAllJsonInputs(originalFormData);
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to update');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showToast('Error', error.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('click', function (e) {
|
||||
const uploadBtn = e.target.closest('.btn-upload-image');
|
||||
if (uploadBtn) {
|
||||
const targetInput = uploadBtn.dataset.targetInput;
|
||||
const imageType = uploadBtn.dataset.imageType;
|
||||
openImageUploader(targetInput, imageType);
|
||||
}
|
||||
});
|
||||
|
||||
// Tab change listener to keep track of active tab
|
||||
const tabs = document.querySelectorAll('a[data-bs-toggle="tab"]');
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('shown.bs.tab', function (e) {
|
||||
const targetId = e.target.getAttribute('href').replace('#', '');
|
||||
const activeTabInput = document.getElementById('activeTabInput');
|
||||
if (activeTabInput) {
|
||||
activeTabInput.value = targetId;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openImageUploader(targetInput, imageType) {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.style.display = 'none';
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
function getPreviewDims(name) {
|
||||
if (/hero/i.test(name)) return { h: '250px', w: '100%' };
|
||||
if (/intro/i.test(name) || /mission/i.test(name) || /features/i.test(name)) return { h: '150px', w: '100%' };
|
||||
return { h: '120px', w: '100%' };
|
||||
}
|
||||
|
||||
fileInput.onchange = async function (e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
// Disable upload button during upload
|
||||
const uploadBtn = document.querySelector(`[data-target-input="${targetInput}"]`);
|
||||
const originalBtnHtml = uploadBtn ? uploadBtn.innerHTML : 'Upload';
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = true;
|
||||
}
|
||||
|
||||
const response = await fetch(`/admin/upload/image?imageType=${imageType}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Upload failed');
|
||||
}
|
||||
|
||||
// Update input value
|
||||
const input = document.getElementById(targetInput) || document.querySelector(`[name="${targetInput}"]`);
|
||||
if (!input) {
|
||||
throw new Error('Target input not found');
|
||||
}
|
||||
|
||||
// Use absolute URL for preview when necessary
|
||||
const previewUrl = (result.path && (result.path.startsWith('http://') || result.path.startsWith('https://'))) ? result.path : (window.location.origin + result.path);
|
||||
|
||||
input.value = result.path;
|
||||
|
||||
// Try to find an existing image preview inside the closest card or input group (prefer .uploaded-preview)
|
||||
let card = input.closest('.card');
|
||||
let previewImg = card ? card.querySelector('.uploaded-preview') : null;
|
||||
|
||||
if (!previewImg) {
|
||||
// Look for a preview right after the input group
|
||||
const parent = input.parentElement || input.closest('.input-group') || input.closest('div');
|
||||
previewImg = parent ? parent.querySelector('.uploaded-preview') : null;
|
||||
}
|
||||
|
||||
const dims = getPreviewDims(targetInput);
|
||||
|
||||
if (previewImg) {
|
||||
previewImg.src = previewUrl;
|
||||
previewImg.style.height = dims.h;
|
||||
previewImg.style.width = dims.w;
|
||||
} else {
|
||||
// Create a preview image and attach it after the input group
|
||||
const img = document.createElement('img');
|
||||
img.src = previewUrl;
|
||||
img.className = 'img-thumbnail uploaded-preview mt-2';
|
||||
img.style.height = dims.h;
|
||||
img.style.width = dims.w;
|
||||
img.style.objectFit = 'cover';
|
||||
img.alt = 'Image preview';
|
||||
|
||||
const parent = input.parentElement || input.closest('.input-group') || input.closest('div');
|
||||
if (parent) parent.appendChild(img);
|
||||
}
|
||||
|
||||
// Removed toast for silent upload
|
||||
|
||||
// Restore button state
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
showToast('Error', 'Failed to upload image: ' + error.message, 'error');
|
||||
|
||||
// Restore button state
|
||||
const uploadBtn = document.querySelector(`[data-target-input="${targetInput}"]`);
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = false;
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(fileInput);
|
||||
}
|
||||
};
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
if (confirm('Are you sure you want to reset all changes?')) {
|
||||
updateAllJsonInputs(originalFormData);
|
||||
showToast('Reset', 'Form restored to last saved state', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const alertHtml = `
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`;
|
||||
document.querySelector('.container').insertAdjacentHTML('afterbegin', alertHtml);
|
||||
}
|
||||
|
||||
// Show toast message
|
||||
function showToast(title, message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast align-items-center text-white bg-${type === 'error' ? 'danger' : type} border-0`;
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.setAttribute('aria-live', 'assertive');
|
||||
toast.setAttribute('aria-atomic', 'true');
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<strong>${title}:</strong> ${message}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add toast to container
|
||||
let container = document.querySelector('.toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'toast-container position-fixed top-0 end-0 p-3';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
container.appendChild(toast);
|
||||
|
||||
// Show toast
|
||||
const bsToast = new bootstrap.Toast(toast, {
|
||||
animation: true,
|
||||
autohide: true,
|
||||
delay: 3000
|
||||
});
|
||||
bsToast.show();
|
||||
|
||||
// Remove toast after hide
|
||||
toast.addEventListener('hidden.bs.toast', () => {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function updateAllJsonInputs(data) {
|
||||
if (!data) return;
|
||||
|
||||
// 1. Hero
|
||||
const hero = data.hero || {};
|
||||
document.getElementById('heroJson').value = JSON.stringify(hero);
|
||||
document.getElementById('heroTitle').value = hero.title || '';
|
||||
document.getElementById('heroBreadcrumb').value = (hero.breadcrumb || []).join(', ');
|
||||
document.getElementById('heroBackgroundImage').value = hero.backgroundImage || '';
|
||||
updateImagePreview('heroBackgroundImage', hero.backgroundImage);
|
||||
|
||||
// 2. Intro
|
||||
const intro = data.intro || {};
|
||||
document.getElementById('introJson').value = JSON.stringify(intro);
|
||||
document.getElementById('introSubheading').value = intro.subheading || '';
|
||||
document.getElementById('introHeading').value = intro.heading || '';
|
||||
document.getElementById('introDescription').value = intro.description || '';
|
||||
document.getElementById('introImage').value = intro.image || '';
|
||||
updateImagePreview('introImage', intro.image);
|
||||
|
||||
// 3. Mission
|
||||
const mission = data.mission || {};
|
||||
document.getElementById('missionJson').value = JSON.stringify(mission);
|
||||
document.getElementById('missionSubheading').value = mission.subheading || '';
|
||||
document.getElementById('missionHeading').value = mission.heading || '';
|
||||
document.getElementById('missionDescription').value = mission.description || '';
|
||||
document.getElementById('missionCtaLabel').value = mission.ctaButton?.label || '';
|
||||
document.getElementById('missionCtaHref').value = mission.ctaButton?.href || '';
|
||||
|
||||
['main', 'secondary', 'bgShape', 'planeShape', 'topShape', 'globeShape'].forEach(k => {
|
||||
const el = document.getElementById('missionImg_' + k);
|
||||
const val = mission.images?.[k] || '';
|
||||
if (el) {
|
||||
el.value = val;
|
||||
updateImagePreview('missionImg_' + k, val);
|
||||
}
|
||||
});
|
||||
populateMissionItems(mission.items || []);
|
||||
populateMissionFeatures(mission.features || []);
|
||||
|
||||
// 4. Features
|
||||
const features = data.features || {};
|
||||
document.getElementById('featuresJson').value = JSON.stringify(features);
|
||||
document.getElementById('featuresSubheading').value = features.subheading || '';
|
||||
document.getElementById('featuresHeading').value = features.heading || '';
|
||||
document.getElementById('featuresDescription').value = features.description || '';
|
||||
document.getElementById('featuresBgImage').value = features.backgroundImage || '';
|
||||
document.getElementById('featuresImage').value = features.image || '';
|
||||
document.getElementById('featuresCtaLabel').value = features.ctaButton?.label || '';
|
||||
document.getElementById('featuresCtaHref').value = features.ctaButton?.href || '';
|
||||
updateImagePreview('featuresBgImage', features.backgroundImage);
|
||||
updateImagePreview('featuresImage', features.image);
|
||||
populateFeatureItems(features.items || []);
|
||||
|
||||
// 5. News
|
||||
const news = data.news || {};
|
||||
document.getElementById('newsJson').value = JSON.stringify(news);
|
||||
document.getElementById('newsSubheading').value = news.subheading || '';
|
||||
document.getElementById('newsHeading').value = news.heading || '';
|
||||
document.getElementById('newsCtaLabel').value = news.ctaButton?.label || '';
|
||||
document.getElementById('newsCtaHref').value = news.ctaButton?.href || '';
|
||||
|
||||
// Update blog selection checkboxes
|
||||
document.querySelectorAll('.about-blog-checkbox').forEach(cb => {
|
||||
const isSelected = news.selectedBlogIds && news.selectedBlogIds.some(id => id.toString() === cb.value);
|
||||
cb.checked = isSelected;
|
||||
const card = cb.closest('.blog-select-card');
|
||||
if (card) {
|
||||
handleAboutCheckboxUpdate(card, isSelected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateImagePreview(inputId, imagePath) {
|
||||
if (!imagePath) return;
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) return;
|
||||
|
||||
let card = input.closest('.card');
|
||||
let previewImg = card ? card.querySelector('.uploaded-preview') : null;
|
||||
|
||||
if (!previewImg) {
|
||||
const parent = input.closest('.input-group') || input.parentElement;
|
||||
previewImg = parent ? parent.querySelector('.uploaded-preview') : null;
|
||||
}
|
||||
|
||||
if (previewImg) {
|
||||
previewImg.src = imagePath;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper dynamic populations ---
|
||||
|
||||
function addMissionItem() {
|
||||
const container = document.getElementById('missionItemsContainer');
|
||||
const idx = container.children.length;
|
||||
const html = `
|
||||
<div class="card mb-2 mission-item">
|
||||
<div class="card-body p-2">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control" name="missionItemIcon_${idx}" placeholder="Icon path">
|
||||
<button class="btn btn-outline-primary btn-upload-image" type="button" data-target-input="missionItemIcon_${idx}" data-image-type="about">
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control form-control-sm" name="missionItemLabel_${idx}" placeholder="Label">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control form-control-sm" name="missionItemDesc_${idx}" placeholder="Description">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link text-danger btn-sm p-0 mt-1" onclick="this.closest('.mission-item').remove()">Remove</button>
|
||||
</div>
|
||||
</div>`;
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
function populateMissionItems(items) {
|
||||
const container = document.getElementById('missionItemsContainer');
|
||||
container.innerHTML = '';
|
||||
items.forEach((item, i) => {
|
||||
addMissionItem();
|
||||
const last = container.lastElementChild;
|
||||
last.querySelector(`[name="missionItemIcon_${i}"]`).value = item.icon || '';
|
||||
last.querySelector(`[name="missionItemLabel_${i}"]`).value = item.label || '';
|
||||
last.querySelector(`[name="missionItemDesc_${i}"]`).value = item.description || '';
|
||||
});
|
||||
}
|
||||
|
||||
function addMissionFeature() {
|
||||
const container = document.getElementById('missionFeaturesContainer');
|
||||
const html = `
|
||||
<div class="input-group input-group-sm mb-1">
|
||||
<input type="text" class="form-control mission-feature-input" placeholder="Feature text">
|
||||
<button class="btn btn-outline-danger" type="button" onclick="this.parentElement.remove()">x</button>
|
||||
</div>`;
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
function populateMissionFeatures(features) {
|
||||
const container = document.getElementById('missionFeaturesContainer');
|
||||
container.innerHTML = '';
|
||||
features.forEach(f => {
|
||||
addMissionFeature();
|
||||
container.lastElementChild.querySelector('input').value = f || '';
|
||||
});
|
||||
}
|
||||
|
||||
function addFeatureItem() {
|
||||
const container = document.getElementById('featureItemsContainer');
|
||||
const idx = container.children.length;
|
||||
const html = `
|
||||
<div class="card mb-2 feature-item-row">
|
||||
<div class="card-body p-2">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control" name="featureItemIcon_${idx}" placeholder="Icon path">
|
||||
<button class="btn btn-outline-primary btn-upload-image" type="button" data-target-input="featureItemIcon_${idx}" data-image-type="about">
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control form-control-sm" name="featureItemTitle_${idx}" placeholder="Title">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control form-control-sm" name="featureItemDesc_${idx}" placeholder="Description">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link text-danger btn-sm p-0 mt-1" onclick="this.closest('.feature-item-row').remove()">Remove</button>
|
||||
</div>
|
||||
</div>`;
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
function populateFeatureItems(items) {
|
||||
const container = document.getElementById('featureItemsContainer');
|
||||
container.innerHTML = '';
|
||||
items.forEach((item, i) => {
|
||||
addFeatureItem();
|
||||
const last = container.lastElementChild;
|
||||
last.querySelector(`[name="featureItemIcon_${i}"]`).value = item.icon || '';
|
||||
last.querySelector(`[name="featureItemTitle_${i}"]`).value = item.title || '';
|
||||
last.querySelector(`[name="featureItemDesc_${i}"]`).value = item.description || '';
|
||||
});
|
||||
}
|
||||
|
||||
// Blog selection functions for About News section
|
||||
function toggleAboutBlogSelection(card, blogId) {
|
||||
const checkbox = card.querySelector('.about-blog-checkbox');
|
||||
const isChecking = !checkbox.checked;
|
||||
|
||||
if (isChecking) {
|
||||
const checkedCount = document.querySelectorAll('.about-blog-checkbox:checked').length;
|
||||
if (checkedCount >= 3) {
|
||||
alert('You can only select up to 3 blogs.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
checkbox.checked = isChecking;
|
||||
handleAboutCheckboxUpdate(card, checkbox.checked);
|
||||
}
|
||||
|
||||
function handleAboutCheckboxChange(checkbox) {
|
||||
if (checkbox.checked) {
|
||||
const checkedCount = document.querySelectorAll('.about-blog-checkbox:checked').length;
|
||||
if (checkedCount > 3) {
|
||||
checkbox.checked = false;
|
||||
alert('You can only select up to 3 blogs.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const card = checkbox.closest('.blog-select-card');
|
||||
handleAboutCheckboxUpdate(card, checkbox.checked);
|
||||
}
|
||||
|
||||
function handleAboutCheckboxUpdate(card, isChecked) {
|
||||
if (isChecked) {
|
||||
card.classList.add('border-primary', 'bg-light');
|
||||
} else {
|
||||
card.classList.remove('border-primary', 'bg-light');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function addService() {
|
||||
const container = document.getElementById('servicesContainer');
|
||||
const index = container.children.length;
|
||||
const html = `
|
||||
<div class="card mb-3 service-item">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" class="form-control" name="serviceTitle_${index}">
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" name="serviceDescription_${index}" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm mt-3" onclick="removeService(this)">
|
||||
<i class="fas fa-trash me-2"></i>Remove Service
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
|
||||
function updateJsonData() {
|
||||
try {
|
||||
// Hero
|
||||
const heroData = {
|
||||
title: document.getElementById('heroTitle').value.trim(),
|
||||
breadcrumb: document.getElementById('heroBreadcrumb').value.split(',').map(s => s.trim()).filter(s => s !== ''),
|
||||
backgroundImage: document.getElementById('heroBackgroundImage').value.trim()
|
||||
};
|
||||
document.getElementById('heroJson').value = JSON.stringify(heroData);
|
||||
|
||||
// Intro
|
||||
const introData = {
|
||||
subheading: document.getElementById('introSubheading').value.trim(),
|
||||
heading: document.getElementById('introHeading').value.trim(),
|
||||
description: document.getElementById('introDescription').value.trim(),
|
||||
image: document.getElementById('introImage').value.trim()
|
||||
};
|
||||
document.getElementById('introJson').value = JSON.stringify(introData);
|
||||
|
||||
// Mission
|
||||
const missionData = {
|
||||
subheading: document.getElementById('missionSubheading').value.trim(),
|
||||
heading: document.getElementById('missionHeading').value.trim(),
|
||||
description: document.getElementById('missionDescription').value.trim(),
|
||||
images: {
|
||||
main: document.getElementById('missionImg_main').value.trim(),
|
||||
secondary: document.getElementById('missionImg_secondary').value.trim(),
|
||||
bgShape: document.getElementById('missionImg_bgShape').value.trim(),
|
||||
planeShape: document.getElementById('missionImg_planeShape').value.trim(),
|
||||
topShape: document.getElementById('missionImg_topShape').value.trim(),
|
||||
globeShape: document.getElementById('missionImg_globeShape').value.trim()
|
||||
},
|
||||
items: Array.from(document.querySelectorAll('.mission-item')).map(item => ({
|
||||
icon: item.querySelector('[name^="missionItemIcon_"]').value.trim(),
|
||||
label: item.querySelector('[name^="missionItemLabel_"]').value.trim(),
|
||||
description: item.querySelector('[name^="missionItemDesc_"]').value.trim()
|
||||
})).filter(i => i.label !== ''),
|
||||
features: Array.from(document.querySelectorAll('.mission-feature-input')).map(input => input.value.trim()).filter(v => v !== ''),
|
||||
ctaButton: {
|
||||
label: document.getElementById('missionCtaLabel').value.trim(),
|
||||
href: document.getElementById('missionCtaHref').value.trim()
|
||||
}
|
||||
};
|
||||
document.getElementById('missionJson').value = JSON.stringify(missionData);
|
||||
|
||||
// Features
|
||||
const featuresData = {
|
||||
backgroundImage: document.getElementById('featuresBgImage').value.trim(),
|
||||
subheading: document.getElementById('featuresSubheading').value.trim(),
|
||||
heading: document.getElementById('featuresHeading').value.trim(),
|
||||
description: document.getElementById('featuresDescription').value.trim(),
|
||||
image: document.getElementById('featuresImage').value.trim(),
|
||||
items: Array.from(document.querySelectorAll('.feature-item-row')).map(item => ({
|
||||
icon: item.querySelector('[name^="featureItemIcon_"]').value.trim(),
|
||||
title: item.querySelector('[name^="featureItemTitle_"]').value.trim(),
|
||||
description: item.querySelector('[name^="featureItemDesc_"]').value.trim()
|
||||
})).filter(i => i.title !== ''),
|
||||
ctaButton: {
|
||||
label: document.getElementById('featuresCtaLabel').value.trim(),
|
||||
href: document.getElementById('featuresCtaHref').value.trim()
|
||||
}
|
||||
};
|
||||
document.getElementById('featuresJson').value = JSON.stringify(featuresData);
|
||||
|
||||
// News
|
||||
const selectedIds = [];
|
||||
document.querySelectorAll('.about-blog-checkbox:checked').forEach(cb => {
|
||||
selectedIds.push(cb.value);
|
||||
});
|
||||
|
||||
const newsData = {
|
||||
subheading: document.getElementById('newsSubheading').value.trim(),
|
||||
heading: document.getElementById('newsHeading').value.trim(),
|
||||
ctaButton: {
|
||||
label: document.getElementById('newsCtaLabel').value.trim(),
|
||||
href: document.getElementById('newsCtaHref').value.trim()
|
||||
},
|
||||
selectedBlogIds: selectedIds,
|
||||
items: [] // Server will populate this from selectedBlogIds
|
||||
};
|
||||
document.getElementById('newsJson').value = JSON.stringify(newsData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating JSON data:', error);
|
||||
throw new Error('Failed to process form data');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.tiny {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.blog-select-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,791 +0,0 @@
|
||||
<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>
|
||||
@@ -65,7 +65,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About Us -->
|
||||
<!-- About -->
|
||||
<div class="col-md-4 border-end border-top">
|
||||
<div class="p-4">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
@@ -78,7 +78,7 @@
|
||||
<p class="text-muted mb-0 small">Manage about us</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/admin/about-us" class="btn btn-sm btn-primary w-100 mt-2">
|
||||
<a href="/admin/about" class="btn btn-sm btn-primary w-100 mt-2">
|
||||
<i class="fas fa-edit me-2"></i>Edit
|
||||
</a>
|
||||
</div>
|
||||
@@ -123,7 +123,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Request Info -->
|
||||
<div class="col-md-4 border-top">
|
||||
<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"
|
||||
@@ -141,64 +141,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appointment -->
|
||||
<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>
|
||||
|
||||
<!-- Pricing -->
|
||||
<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>
|
||||
<!-- Services -->
|
||||
<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-concierge-bell fa-lg" style="color: var(--primary-color);"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="mb-0">Services</h5>
|
||||
<p class="text-muted mb-0 small">Manage services</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/admin/service" class="btn btn-sm btn-primary w-100 mt-2">
|
||||
<i class="fas fa-edit me-2"></i>Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blog -->
|
||||
<div class="col-md-4 border-top">
|
||||
<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"
|
||||
@@ -216,27 +160,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visa -->
|
||||
<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-passport fa-lg" style="color: var(--primary-color);"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="mb-0">Visa</h5>
|
||||
<p class="text-muted mb-0 small">Manage visa countries</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/admin/visa" class="btn btn-sm btn-primary w-100 mt-2">
|
||||
<i class="fas fa-edit me-2"></i>Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Programme -->
|
||||
<div class="col-md-4 border-top">
|
||||
<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"
|
||||
@@ -276,6 +201,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!--Menu Header API-->
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
@@ -286,17 +212,19 @@
|
||||
<span>Menu Header API</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/header</code></td>
|
||||
<td><code>/api/header-menu</code></td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: var(--primary-color)">GET</span>
|
||||
</td>
|
||||
<td>API to get menu header data</td>
|
||||
<td>
|
||||
<a href="/api/header" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<a href="/api/header-menu" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!--Home API-->
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
@@ -318,6 +246,31 @@
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!--Footer API-->
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center me-2"
|
||||
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
|
||||
<i class="fas fa-window-minimize" style="color: var(--primary-color);"></i>
|
||||
</div>
|
||||
<span>Footer API</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/footer</code></td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: var(--primary-color)">GET</span>
|
||||
</td>
|
||||
<td>API to get footer data</td>
|
||||
<td>
|
||||
<a href="/api/footer" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!--About API-->
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
@@ -339,30 +292,8 @@
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center me-2"
|
||||
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
|
||||
<i class="fas fa-users" style="color: var(--primary-color);"></i>
|
||||
</div>
|
||||
<span>About Us API</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/about-us</code></td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: var(--primary-color)">GET</span>
|
||||
</td>
|
||||
<td>API to get about us data</td>
|
||||
<td>
|
||||
<a href="/api/about-us" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<!--Contact API-->
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
@@ -384,6 +315,8 @@
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!--Student Support API-->
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
@@ -405,6 +338,8 @@
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!--Request Inf API-->
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
@@ -426,6 +361,8 @@
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!--Blog API-->
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
@@ -447,6 +384,8 @@
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!--Programmes API-->
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
@@ -468,27 +407,6 @@
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center me-2"
|
||||
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
|
||||
<i class="fas fa-graduation-cap" style="color: var(--primary-color);"></i>
|
||||
</div>
|
||||
<span>Programme Detail API</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/programmes/:id</code></td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: var(--primary-color)">GET</span>
|
||||
</td>
|
||||
<td>Single programme by slug ID</td>
|
||||
<td>
|
||||
<a href="/api/programmes" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -515,7 +433,7 @@
|
||||
<div>
|
||||
<div class="text-muted small">Version</div>
|
||||
<div class="fw-bold" style="color: var(--primary-color)">
|
||||
CMS.HAILearning v1.0.0
|
||||
CMS.LAMS v1.0.0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
<!-- Header section -->
|
||||
<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);">Create New Department</h1>
|
||||
<p class="text-muted mb-0">Add a new department to the system</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/admin/department" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>Back to Department Management
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<form action="/admin/department/create" method="POST" id="createForm">
|
||||
<div class="mb-4">
|
||||
<label for="name" class="form-label fw-medium">Department Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required
|
||||
placeholder="Enter department name">
|
||||
<div class="form-text">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Enter the full name of the department (e.g. Business, Engineering)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-lightbulb me-2"></i>
|
||||
<strong>Tips:</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
<li>Department name should be clear and descriptive</li>
|
||||
<li>Use proper capitalization</li>
|
||||
<li>Avoid abbreviations unless commonly known</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-plus-circle me-1"></i>Create Department
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Modal -->
|
||||
<div id="customModal" class="custom-modal">
|
||||
<div class="custom-modal-content">
|
||||
<div class="custom-modal-header">
|
||||
<h5 class="custom-modal-title">Notification</h5>
|
||||
<button type="button" class="custom-modal-close">×</button>
|
||||
</div>
|
||||
<div class="custom-modal-body">
|
||||
<p id="modalMessage">Content of the notification will appear here.</p>
|
||||
</div>
|
||||
<div class="custom-modal-footer">
|
||||
<button type="button" class="btn btn-primary custom-modal-ok">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import custom modal CSS -->
|
||||
<link rel="stylesheet" href="/css/custom-modal.css">
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize custom modal
|
||||
CustomModal.init('customModal', {
|
||||
closeOnOutsideClick: true,
|
||||
animationDuration: 300
|
||||
});
|
||||
|
||||
const form = document.getElementById('createForm');
|
||||
const nameInput = document.getElementById('name');
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
// Validate department name
|
||||
if (!nameInput.value.trim()) {
|
||||
e.preventDefault();
|
||||
CustomModal.alert('Please enter a department name.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for special characters
|
||||
if (/[^a-zA-Z0-9\s-]/.test(nameInput.value.trim())) {
|
||||
e.preventDefault();
|
||||
CustomModal.alert('Department name can only contain letters, numbers, spaces and hyphens.');
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -1,100 +0,0 @@
|
||||
<!-- Header section -->
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark);">Edit Department</h1>
|
||||
<p class="text-muted mb-0">
|
||||
Editing department: <span class="badge bg-primary"><%= department.name %></span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/admin/department" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>Back to Department Management
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<form action="/admin/department/edit/<%= department._id %>" method="POST" id="editForm">
|
||||
<div class="mb-4">
|
||||
<label for="name" class="form-label fw-medium">Department Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required
|
||||
value="<%= department.name %>" placeholder="Enter department name">
|
||||
<div class="form-text">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Enter the full name of the department (e.g. Business, Engineering)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-lightbulb me-2"></i>
|
||||
<strong>Tips:</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
<li>Department name should be clear and descriptive</li>
|
||||
<li>Use proper capitalization</li>
|
||||
<li>Avoid abbreviations unless commonly known</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-1"></i>Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Modal -->
|
||||
<div id="customModal" class="custom-modal">
|
||||
<div class="custom-modal-content">
|
||||
<div class="custom-modal-header">
|
||||
<h5 class="custom-modal-title">Notification</h5>
|
||||
<button type="button" class="custom-modal-close">×</button>
|
||||
</div>
|
||||
<div class="custom-modal-body">
|
||||
<p id="modalMessage">Content of the notification will appear here.</p>
|
||||
</div>
|
||||
<div class="custom-modal-footer">
|
||||
<button type="button" class="btn btn-primary custom-modal-ok">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import custom modal CSS -->
|
||||
<link rel="stylesheet" href="/css/custom-modal.css">
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize custom modal
|
||||
CustomModal.init('customModal', {
|
||||
closeOnOutsideClick: true,
|
||||
animationDuration: 300
|
||||
});
|
||||
|
||||
const form = document.getElementById('editForm');
|
||||
const nameInput = document.getElementById('name');
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
// Validate department name
|
||||
if (!nameInput.value.trim()) {
|
||||
e.preventDefault();
|
||||
CustomModal.alert('Please enter a department name.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for special characters
|
||||
if (/[^a-zA-Z0-9\s-]/.test(nameInput.value.trim())) {
|
||||
e.preventDefault();
|
||||
CustomModal.alert('Department name can only contain letters, numbers, spaces and hyphens.');
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -1,126 +0,0 @@
|
||||
<!-- Header section -->
|
||||
<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);">Department Management</h1>
|
||||
<p class="text-muted mb-0">Manage all departments in the system</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/admin/department/create" class="btn btn-primary">
|
||||
<i class="fas fa-plus-circle me-1"></i>Create New Department
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<% if (departments && departments.length > 0) { %>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 50px">#</th>
|
||||
<th scope="col">Department Name</th>
|
||||
<th scope="col">Slug</th>
|
||||
<th scope="col" style="width: 200px">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% departments.forEach((department, index) => { %>
|
||||
<tr>
|
||||
<td><%= index + 1 %></td>
|
||||
<td>
|
||||
<span class="fw-medium"><%= department.name %></span>
|
||||
</td>
|
||||
<td>
|
||||
<code class="text-muted"><%= department.slug %></code>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="/admin/department/edit/<%= department._id %>" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fas fa-edit me-1"></i>Edit
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
data-custom-modal="open"
|
||||
data-id="<%= department._id %>"
|
||||
data-name="<%= department.name %>">
|
||||
<i class="fas fa-trash-alt me-1"></i>Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-folder-open text-muted mb-3" style="font-size: 3rem;"></i>
|
||||
<h5 class="text-muted mb-3">No Departments Found</h5>
|
||||
<a href="/admin/department/create" class="btn btn-primary">
|
||||
<i class="fas fa-plus-circle me-1"></i>Create First Department
|
||||
</a>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Modal -->
|
||||
<div id="customModal" class="custom-modal">
|
||||
<div class="custom-modal-content">
|
||||
<div class="custom-modal-header">
|
||||
<h5 class="custom-modal-title">Delete Confirmation</h5>
|
||||
<button type="button" class="custom-modal-close">×</button>
|
||||
</div>
|
||||
<div class="custom-modal-body">
|
||||
<p id="modalMessage">Are you sure you want to delete this department?</p>
|
||||
</div>
|
||||
<div class="custom-modal-footer">
|
||||
<button type="button" class="btn btn-secondary custom-modal-cancel">Cancel</button>
|
||||
<button type="button" class="btn btn-danger custom-modal-ok">Delete Permanently</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import custom modal CSS -->
|
||||
<link rel="stylesheet" href="/css/custom-modal.css">
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Khởi tạo modal tùy chỉnh
|
||||
CustomModal.init('customModal', {
|
||||
closeOnOutsideClick: true,
|
||||
animationDuration: 300
|
||||
});
|
||||
|
||||
// Lắng nghe click vào nút xóa
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.getAttribute('data-custom-modal') === 'open' ||
|
||||
e.target.parentElement.getAttribute('data-custom-modal') === 'open') {
|
||||
|
||||
// Lấy button hoặc icon parent nếu click vào icon
|
||||
const button = e.target.getAttribute('data-custom-modal') === 'open' ?
|
||||
e.target : e.target.parentElement;
|
||||
|
||||
const id = button.getAttribute('data-id');
|
||||
const name = button.getAttribute('data-name');
|
||||
|
||||
// Sử dụng CustomModal.confirm thay vì xử lý trực tiếp
|
||||
CustomModal.confirm(
|
||||
`Are you sure you want to delete department "${name}"? This action cannot be undone.`,
|
||||
function() {
|
||||
// Hành động khi xác nhận
|
||||
window.location.href = `/admin/department/delete/${id}`;
|
||||
},
|
||||
null,
|
||||
'Delete Confirmation'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -12,25 +12,13 @@
|
||||
<div class="col-12">
|
||||
<div class="content-with-fixed-buttons">
|
||||
<!-- Hidden inputs for JSON data -->
|
||||
<input type="hidden" name="topbarJson" id="topbarJson" />
|
||||
<input type="hidden" name="logo" id="logoInput" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="topbar" />
|
||||
<input type="hidden" name="menuUpdates" id="menuUpdates" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="logo" />
|
||||
|
||||
<!-- 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 <%= activeTab === 'topbar' ? 'active' : '' %>"
|
||||
data-bs-toggle="tab"
|
||||
href="#topbar"
|
||||
role="tab"
|
||||
>
|
||||
<i class="fas fa-bars me-2"></i>Topbar
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link <%= activeTab === 'logo' ? 'active' : '' %>"
|
||||
@@ -41,6 +29,16 @@
|
||||
<i class="fas fa-image me-2"></i>Logo
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link <%= activeTab === 'buttons' ? 'active' : '' %>"
|
||||
data-bs-toggle="tab"
|
||||
href="#buttons"
|
||||
role="tab"
|
||||
>
|
||||
<i class="fas fa-mouse-pointer me-2"></i>Buttons
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link <%= activeTab === 'menu' ? 'active' : '' %>"
|
||||
@@ -56,79 +54,6 @@
|
||||
|
||||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
<!-- Topbar Tab -->
|
||||
<div class="tab-pane fade <%= activeTab === 'topbar' ? 'show active' : '' %>" id="topbar" role="tabpanel">
|
||||
<div class="row g-4">
|
||||
<!-- Contact Information -->
|
||||
<div class="col-md-12">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-phone me-2"></i>Contact Information
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Phone Number</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="contactPhone"
|
||||
value="<%= data.topbar.contactInfo.phone %>"
|
||||
placeholder="+1 234 567 890"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
class="form-control"
|
||||
id="contactEmail"
|
||||
value="<%= data.topbar.contactInfo.email || '' %>"
|
||||
placeholder="info@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-medium">Location</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="contactLocation"
|
||||
value="<%= data.topbar.contactInfo.location || '' %>"
|
||||
placeholder="69 Street, 5th Avenue LA, United States"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Social Links -->
|
||||
<div class="col-md-12">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-share-alt me-2"></i>Social Media Links
|
||||
</h6>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
id="addSocialLink"
|
||||
>
|
||||
<i class="fas fa-plus me-1"></i>Add Social Link
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="socialLinksContainer" class="social-links-sortable">
|
||||
<!-- Social links will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logo Tab -->
|
||||
<div class="tab-pane fade <%= activeTab === 'logo' ? 'show active' : '' %>" id="logo" role="tabpanel">
|
||||
<div class="row g-4">
|
||||
@@ -179,6 +104,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons Tab -->
|
||||
<div class="tab-pane fade <%= activeTab === 'buttons' ? 'show active' : '' %>" id="buttons" role="tabpanel">
|
||||
<div class="row g-4">
|
||||
<!-- Sign In Button -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-sign-in-alt me-2"></i>Sign In Button</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">Label</label>
|
||||
<input type="text" class="form-control" id="signInLabel"
|
||||
value="<%= data.signInButton.label %>" placeholder="Sign In" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">URL</label>
|
||||
<input type="text" class="form-control" id="signInHref"
|
||||
value="<%= data.signInButton.href %>" placeholder="/signin" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- CTA Button -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-mouse-pointer me-2"></i>CTA Button</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">Label</label>
|
||||
<input type="text" class="form-control" id="ctaLabel"
|
||||
value="<%= data.ctaButton.label %>" placeholder="Request Info" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">URL</label>
|
||||
<input type="text" class="form-control" id="ctaHref"
|
||||
value="<%= data.ctaButton.href %>" placeholder="/request" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu Structure Tab -->
|
||||
<div class="tab-pane fade <%= activeTab === 'menu' ? 'show active' : '' %>" id="menu" role="tabpanel">
|
||||
<%- include('menu') %>
|
||||
@@ -202,12 +173,6 @@
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
let socialLinkIndex = 0;
|
||||
|
||||
// Initialize social links from data
|
||||
initializeSocialLinks();
|
||||
updateHiddenInputs();
|
||||
|
||||
// Safely remove any lingering modal backdrops on page load/navigation
|
||||
function cleanupModals() {
|
||||
// Basic reset
|
||||
@@ -291,7 +256,7 @@
|
||||
}
|
||||
|
||||
// Attach listeners to all inputs for change detection
|
||||
const headerInputs = document.querySelectorAll('#topbar input, #logo input');
|
||||
const headerInputs = document.querySelectorAll('#logo input, #buttons input');
|
||||
headerInputs.forEach(input => {
|
||||
input.addEventListener('input', markChanged);
|
||||
input.addEventListener('change', markChanged);
|
||||
@@ -338,7 +303,6 @@
|
||||
if (saveHeaderBtn) {
|
||||
saveHeaderBtn.addEventListener('click', async function (e) {
|
||||
console.log('=== TRACE: saveHeaderBtn Clicked (Unified) ===');
|
||||
updateHiddenInputs();
|
||||
|
||||
const submitBtn = this;
|
||||
const originalText = submitBtn.innerHTML;
|
||||
@@ -346,11 +310,17 @@
|
||||
submitBtn.disabled = true;
|
||||
|
||||
try {
|
||||
// 1. Collect and Save Topbar & Logo
|
||||
// 1. Collect and Save Header Data (Logo + Buttons)
|
||||
const headerData = {
|
||||
topbarJson: document.getElementById('topbarJson').value,
|
||||
logo: document.getElementById('logoInput').value,
|
||||
activeTab: document.getElementById('activeTabInput').value
|
||||
logo: document.getElementById('logoImage').value,
|
||||
signInButton: {
|
||||
label: document.getElementById('signInLabel').value,
|
||||
href: document.getElementById('signInHref').value,
|
||||
},
|
||||
ctaButton: {
|
||||
label: document.getElementById('ctaLabel').value,
|
||||
href: document.getElementById('ctaHref').value,
|
||||
},
|
||||
};
|
||||
|
||||
const headerResponse = await fetch('/admin/header/update', {
|
||||
@@ -382,7 +352,7 @@
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('=== TRACE: Unified Save ERROR ===', error);
|
||||
showNotification('Lỗi: ' + error.message, 'error');
|
||||
showNotification('Error: ' + error.message, 'error');
|
||||
} finally {
|
||||
submitBtn.innerHTML = originalText;
|
||||
submitBtn.disabled = false;
|
||||
@@ -390,416 +360,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
function updateHiddenInputs() {
|
||||
const topbarData = {
|
||||
contactInfo: {
|
||||
phone: document.getElementById('contactPhone').value || '',
|
||||
email: document.getElementById('contactEmail').value || '',
|
||||
location: document.getElementById('contactLocation').value || ''
|
||||
},
|
||||
socialLinks: []
|
||||
};
|
||||
|
||||
// Collect social links
|
||||
document.querySelectorAll('.social-platform').forEach((input) => {
|
||||
const platform = input.value.trim();
|
||||
const urlInput = document.querySelector(`.social-url[data-index="${input.dataset.index}"]`);
|
||||
const iconInput = document.querySelector(`.social-icon[data-index="${input.dataset.index}"]`);
|
||||
|
||||
const url = urlInput ? urlInput.value.trim() : '';
|
||||
const icon = iconInput ? iconInput.value.trim() : '';
|
||||
|
||||
if (platform && url) {
|
||||
topbarData.socialLinks.push({
|
||||
platform: platform,
|
||||
url: url,
|
||||
icon: icon
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('topbarJson').value = JSON.stringify(topbarData);
|
||||
document.getElementById('logoInput').value = document.getElementById('logoImage').value || '';
|
||||
|
||||
try {
|
||||
const menuUpdates = collectMenuUpdates();
|
||||
document.getElementById('menuUpdates').value = JSON.stringify(menuUpdates);
|
||||
} catch (e) {
|
||||
console.error('Error collecting menu updates:', e);
|
||||
document.getElementById('menuUpdates').value = '[]';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect menu updates from the menu tree
|
||||
* Returns an empty array if no menu tree exists
|
||||
*/
|
||||
function collectMenuUpdates() {
|
||||
// For now, return empty array as menu structure management is coming soon
|
||||
// This function can be expanded when menu tree functionality is implemented
|
||||
return [];
|
||||
}
|
||||
|
||||
function initializeSocialLinks() {
|
||||
const container = document.getElementById('socialLinksContainer');
|
||||
if (!container) return;
|
||||
|
||||
// Extract social links safely from EJS data
|
||||
let socialLinks = [];
|
||||
try {
|
||||
socialLinks = <%- JSON.stringify(data.topbar.socialLinks || []) %>;
|
||||
} catch (e) {
|
||||
console.error('Error parsing social links data:', e);
|
||||
}
|
||||
|
||||
if (socialLinks.length === 0) {
|
||||
// Add default platforms if no social links exist
|
||||
const platforms = ['linkedin', 'twitter', 'instagram', 'youtube'];
|
||||
platforms.forEach((platform, index) => {
|
||||
addSocialLinkRow(platform, '', `fa-brands fa-${platform}`, index);
|
||||
});
|
||||
socialLinkIndex = platforms.length;
|
||||
} else {
|
||||
// Load existing social links
|
||||
socialLinks.forEach((social, index) => {
|
||||
const platform = social.platform || '';
|
||||
const url = social.url || '';
|
||||
const icon = social.icon || '';
|
||||
addSocialLinkRow(platform, url, icon, index);
|
||||
});
|
||||
socialLinkIndex = socialLinks.length;
|
||||
}
|
||||
}
|
||||
|
||||
function addSocialLinkRow(platform, url, icon, index) {
|
||||
const container = document.getElementById('socialLinksContainer');
|
||||
const socialLink = document.createElement('div');
|
||||
socialLink.className = 'card mb-3 border social-link-item';
|
||||
socialLink.dataset.platform = platform;
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
const escapedPlatform = (platform || '').replace(/[&<>"']/g, char => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[char]));
|
||||
|
||||
const escapedUrl = (url || '').replace(/[&<>"']/g, char => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[char]));
|
||||
|
||||
const escapedIcon = (icon || '').replace(/[&<>"']/g, char => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[char]));
|
||||
|
||||
socialLink.innerHTML = `
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-1 d-flex justify-content-center align-items-center w-auto">
|
||||
<label class="form-label fw-medium"> </label>
|
||||
<div class="drag-handle" title="Drag to reorder" style="cursor: grab; font-size: 1.2rem; color: #999; user-select: none;">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label fw-medium">Platform</label>
|
||||
<input type="text" class="form-control social-platform" value="${escapedPlatform}" data-index="${index}" disabled />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium">URL</label>
|
||||
<input type="text" class="form-control social-url" value="${escapedUrl}" data-index="${index}" placeholder="https://..." />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-medium">Icon Class</label>
|
||||
<input type="text" class="form-control social-icon" value="${escapedIcon}" data-index="${index}" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label"> </label>
|
||||
<div class="btn-group w-100" role="group">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm edit-social-link mx-3 rounded" style="transform: none" data-index="${index}" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm remove-social-link rounded" data-index="${index}" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(socialLink);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DRAG & DROP STYLING
|
||||
============================================ */
|
||||
const dragDropStyles = document.createElement('style');
|
||||
dragDropStyles.textContent = `
|
||||
.social-link-item {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: grab !important;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
background-color: #f0f0f0;
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
/* SortableJS Classes */
|
||||
.social-ghost {
|
||||
opacity: 0.4;
|
||||
border: 2px dashed #0d6efd !important;
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
.social-chosen {
|
||||
background-color: #eef3ff !important;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.1) !important;
|
||||
}
|
||||
.social-drag {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Fix Modal Freeze & Z-Index issues */
|
||||
body.modal-open {
|
||||
overflow: hidden !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(dragDropStyles);
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (e.target.closest('.remove-social-link')) {
|
||||
e.preventDefault();
|
||||
const btn = e.target.closest('.remove-social-link');
|
||||
const index = btn.dataset.index;
|
||||
const platformInput = document.querySelector(`.social-platform[data-index="${index}"]`);
|
||||
const platform = platformInput.value;
|
||||
|
||||
if (confirm(`Delete ${platform} social link?`)) {
|
||||
deleteSocialLink(platform, btn.closest('.card'));
|
||||
}
|
||||
}
|
||||
|
||||
if (e.target.closest('.edit-social-link')) {
|
||||
e.preventDefault();
|
||||
const btn = e.target.closest('.edit-social-link');
|
||||
const index = btn.dataset.index;
|
||||
const platformInput = document.querySelector(`.social-platform[data-index="${index}"]`);
|
||||
const urlInput = document.querySelector(`.social-url[data-index="${index}"]`);
|
||||
const iconInput = document.querySelector(`.social-icon[data-index="${index}"]`);
|
||||
|
||||
const platform = platformInput.value;
|
||||
const url = urlInput.value;
|
||||
const icon = iconInput.value;
|
||||
|
||||
showEditSocialLinkModal(platform, url, icon);
|
||||
}
|
||||
|
||||
if (e.target.closest('#addSocialLink')) {
|
||||
e.preventDefault();
|
||||
showAddSocialLinkModal();
|
||||
}
|
||||
});
|
||||
|
||||
function deleteSocialLink(platform, cardElement) {
|
||||
if (confirm(`Delete ${platform} social link?`)) {
|
||||
cardElement.remove();
|
||||
updateHiddenInputs();
|
||||
markChanged();
|
||||
}
|
||||
}
|
||||
|
||||
function showAddSocialLinkModal() {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal fade';
|
||||
modal.id = 'addSocialLinkModal';
|
||||
modal.setAttribute('tabindex', '-1');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Add New Social Link</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">Platform <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="newSocialPlatform" placeholder="e.g., linkedin, twitter, instagram, youtube, facebook" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">URL <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="newSocialUrl" placeholder="https://..." />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">Icon Class</label>
|
||||
<input type="text" class="form-control" id="newSocialIcon" placeholder="e.g., fa-brands fa-linkedin" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="saveSocialLink">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
const bsModal = new bootstrap.Modal(modal);
|
||||
|
||||
document.getElementById('saveSocialLink').addEventListener('click', function() {
|
||||
const platform = document.getElementById('newSocialPlatform').value.trim();
|
||||
const url = document.getElementById('newSocialUrl').value.trim();
|
||||
const icon = document.getElementById('newSocialIcon').value.trim();
|
||||
|
||||
if (!platform) {
|
||||
alert('Vui lòng nhập tên nền tảng');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
alert('Vui lòng nhập URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if platform already exists
|
||||
const existingPlatforms = Array.from(document.querySelectorAll('.social-platform')).map(el => el.value);
|
||||
if (existingPlatforms.includes(platform)) {
|
||||
alert(`${platform} đã tồn tại`);
|
||||
return;
|
||||
}
|
||||
|
||||
addSocialLinkViaAPI(platform, url, icon || `fa-brands fa-${platform}`, bsModal, modal);
|
||||
});
|
||||
|
||||
bsModal.show();
|
||||
|
||||
// Focus on platform input
|
||||
setTimeout(() => {
|
||||
document.getElementById('newSocialPlatform').focus();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function showEditSocialLinkModal(platform, url, icon) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal fade';
|
||||
modal.id = 'editSocialLinkModal';
|
||||
modal.setAttribute('tabindex', '-1');
|
||||
const platformDisplay = platform ? platform.charAt(0).toUpperCase() + platform.slice(1) : 'Social';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit ${platformDisplay} Link</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">Platform <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="editSocialPlatform" value="${platform}" placeholder="e.g., linkedin, twitter, instagram" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">URL <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="editSocialUrl" value="${url}" placeholder="https://www.example.com" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">Icon Class</label>
|
||||
<input type="text" class="form-control" id="editSocialIcon" value="${icon}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="updateSocialLink">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
const bsModal = new bootstrap.Modal(modal);
|
||||
|
||||
document.getElementById('updateSocialLink').addEventListener('click', function() {
|
||||
const newPlatform = document.getElementById('editSocialPlatform').value.trim();
|
||||
const newUrl = document.getElementById('editSocialUrl').value.trim();
|
||||
const newIcon = document.getElementById('editSocialIcon').value.trim();
|
||||
|
||||
if (!newPlatform) {
|
||||
alert('Vui lòng nhập tên nền tảng');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newUrl) {
|
||||
alert('Vui lòng nhập URL');
|
||||
return;
|
||||
}
|
||||
|
||||
updateSocialLinkViaAPIModal(platform, newPlatform, newUrl, newIcon, bsModal, modal);
|
||||
});
|
||||
|
||||
bsModal.show();
|
||||
|
||||
// Focus on platform input
|
||||
setTimeout(() => {
|
||||
document.getElementById('editSocialPlatform').focus();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function addSocialLinkViaAPI(platform, url, icon, modal, modalElement) {
|
||||
addSocialLinkRow(platform, url, icon, Date.now());
|
||||
updateHiddenInputs();
|
||||
markChanged();
|
||||
modal.hide();
|
||||
modalElement.remove();
|
||||
}
|
||||
|
||||
function updateSocialLinkViaAPIModal(oldPlatform, newPlatform, url, icon, modal, modalElement) {
|
||||
newPlatform = newPlatform.toLowerCase().trim();
|
||||
|
||||
// Update the input fields in the DOM
|
||||
const platformInputs = document.querySelectorAll(`.social-platform`);
|
||||
const urlInputs = document.querySelectorAll(`.social-url`);
|
||||
const iconInputs = document.querySelectorAll(`.social-icon`);
|
||||
|
||||
let found = false;
|
||||
for (let i = 0; i < platformInputs.length; i++) {
|
||||
if (platformInputs[i].value.toLowerCase() === oldPlatform.toLowerCase()) {
|
||||
platformInputs[i].value = newPlatform;
|
||||
urlInputs[i].value = url;
|
||||
if (iconInputs[i]) iconInputs[i].value = icon;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
modal.hide();
|
||||
modalElement.remove();
|
||||
updateHiddenInputs();
|
||||
markChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show toast notification at top of page
|
||||
@@ -990,41 +550,6 @@
|
||||
previewContainer.appendChild(img);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Sortable for Social Links
|
||||
const socialLinksContainer = document.getElementById('socialLinksContainer');
|
||||
const SortableLib = window.Sortable || Sortable;
|
||||
|
||||
console.log('=== TRACE: Social Sortable Init ===', {
|
||||
containerExists: !!socialLinksContainer,
|
||||
sortableDefined: typeof SortableLib !== 'undefined'
|
||||
});
|
||||
|
||||
if (socialLinksContainer && typeof SortableLib !== 'undefined') {
|
||||
try {
|
||||
new SortableLib(socialLinksContainer, {
|
||||
animation: 150,
|
||||
handle: '.drag-handle',
|
||||
ghostClass: 'social-ghost',
|
||||
chosenClass: 'social-chosen',
|
||||
dragClass: 'social-drag',
|
||||
forceFallback: true, // Use transition-based dragging for better compatibility
|
||||
onStart: function() {
|
||||
console.log('=== TRACE: Social Drag Started ===');
|
||||
},
|
||||
onEnd: function() {
|
||||
console.log('=== TRACE: Social Drag Ended ===');
|
||||
updateHiddenInputs();
|
||||
markChanged();
|
||||
}
|
||||
});
|
||||
console.log('=== TRACE: Sortable initialized for socialLinksContainer ===');
|
||||
} catch (err) {
|
||||
console.error('=== TRACE: Sortable Init Error ===', err);
|
||||
}
|
||||
} else {
|
||||
console.warn('SortableJS not loaded or socialLinksContainer not found');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,231 +1,675 @@
|
||||
<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)">
|
||||
Homepage Management
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark);">
|
||||
<%= title %>
|
||||
</h1>
|
||||
<p class="text-muted mb-0">Edit content displayed on homepage</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= frontendUrl %>" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Homepage
|
||||
</a>
|
||||
<p class="text-muted mb-0">Edit content displayed on the Home page</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="/admin/home/update" method="POST" class="content-with-fixed-buttons">
|
||||
<!-- Hidden inputs for JSON data -->
|
||||
<input type="hidden" name="hero" id="heroJson" />
|
||||
<input type="hidden" name="whyChooseUs" id="whyChooseUsJson" />
|
||||
<input type="hidden" name="visaSolutions" id="visaSolutionsJson" />
|
||||
<input type="hidden" name="visaCountries" id="visaCountriesJson" />
|
||||
<input type="hidden" name="testimonials" id="testimonialsJson" />
|
||||
<input type="hidden" name="videoGallery" id="videoGalleryJson" />
|
||||
<input type="hidden" name="faq" id="faqJson" />
|
||||
<input type="hidden" name="achievements" id="achievementsJson" />
|
||||
<input type="hidden" name="partners" id="partnersJson" />
|
||||
<input type="hidden" name="blogPreview" id="blogPreviewJson" />
|
||||
<form method="POST" id="homeForm" action="/admin/home/update">
|
||||
<!-- Hidden JSON inputs -->
|
||||
<input type="hidden" name="hero" id="heroJson">
|
||||
<input type="hidden" name="quickLinks" id="quickLinksJson">
|
||||
<input type="hidden" name="valueProp" id="valuePropJson">
|
||||
<input type="hidden" name="programs" id="programsJson">
|
||||
<input type="hidden" name="requestInfo" id="requestInfoJson">
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<!-- 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>
|
||||
<ul class="nav nav-tabs card-header-tabs" id="homeTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link active" id="tab-hero" data-bs-toggle="tab" href="#tab-hero-pane" role="tab"><i
|
||||
class="fas fa-image me-2"></i>Hero</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#whychooseus" role="tab">
|
||||
<i class="fas fa-star me-2"></i>Why Choose Us
|
||||
</a>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link" id="tab-quickLinks" data-bs-toggle="tab" href="#tab-quickLinks-pane" role="tab"><i
|
||||
class="fas fa-link me-2"></i>Quick Links</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#visasolutions" role="tab">
|
||||
<i class="fas fa-concierge-bell me-2"></i>Visa Solutions
|
||||
</a>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link" id="tab-valueProp" data-bs-toggle="tab" href="#tab-valueProp-pane" role="tab"><i
|
||||
class="fas fa-star me-2"></i>Value Prop</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#visacountries" role="tab">
|
||||
<i class="fas fa-globe-americas me-2"></i>Visa Countries
|
||||
</a>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link" id="tab-programs" data-bs-toggle="tab" href="#tab-programs-pane" role="tab"><i
|
||||
class="fas fa-graduation-cap me-2"></i>Programs</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#testimonials" role="tab">
|
||||
<i class="fas fa-comments me-2"></i>Testimonials
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#videogallery" role="tab">
|
||||
<i class="fas fa-video me-2"></i>Video Gallery
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#faq" role="tab">
|
||||
<i class="fas fa-question-circle me-2"></i>FAQ
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#achievements" role="tab">
|
||||
<i class="fas fa-chart-pie me-2"></i>Achievements
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#partners" role="tab">
|
||||
<i class="fas fa-handshake me-2"></i>Partners
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#blogpreview" role="tab">
|
||||
<i class="fas fa-blog me-2"></i>Blog Preview
|
||||
</a>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link" id="tab-requestInfo" data-bs-toggle="tab" href="#tab-requestInfo-pane" role="tab"><i
|
||||
class="fas fa-envelope me-2"></i>Request Info</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
<%- include('sections/hero') %>
|
||||
<%- include('sections/whyChooseUs') %>
|
||||
<%- include('sections/visaSolutions') %>
|
||||
<%- include('sections/visaCountries') %>
|
||||
<%- include('sections/testimonials') %>
|
||||
<%- include('sections/videoGallery') %>
|
||||
<%- include('sections/faq') %>
|
||||
<%- include('sections/achievements') %>
|
||||
<%- include('sections/partners') %>
|
||||
<%- include('sections/blogPreview') %>
|
||||
|
||||
<!-- ===== HERO TAB ===== -->
|
||||
<div class="tab-pane fade show active" id="tab-hero-pane" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero Section</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Badge</label>
|
||||
<input type="text" class="form-control" id="heroBadge" value="<%= data.hero?.badge || '' %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Button Label</label>
|
||||
<input type="text" class="form-control" id="heroButtonLabel"
|
||||
value="<%= data.hero?.buttonLabel || '' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" class="form-control" id="heroTitle" value="<%= data.hero?.title || '' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="heroDescription"
|
||||
rows="3"><%= data.hero?.description || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Search Placeholder</label>
|
||||
<input type="text" class="form-control" id="heroSearchPlaceholder"
|
||||
value="<%= data.hero?.searchPlaceholder || '' %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Image Alt Text</label>
|
||||
<input type="text" class="form-control" id="heroImageAlt"
|
||||
value="<%= data.hero?.imageAlt || '' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Image URL</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="heroImage" value="<%= data.hero?.image || '' %>">
|
||||
<button class="btn btn-outline-primary btn-upload-image" type="button"
|
||||
data-target-input="heroImage" data-image-type="home"><i
|
||||
class="fas fa-upload me-1"></i>Upload</button>
|
||||
</div>
|
||||
<% if (data.hero?.image) { %>
|
||||
<img src="<%= data.hero.image %>" class="img-thumbnail uploaded-preview mt-2"
|
||||
style="max-height:180px;">
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="mt-4 mb-3">Floating Badge</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Icon (FA class)</label>
|
||||
<input type="text" class="form-control" id="floatingBadgeIcon"
|
||||
value="<%= data.hero?.floatingBadge?.icon || '' %>" placeholder="e.g. fa-users">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Value</label>
|
||||
<input type="text" class="form-control" id="floatingBadgeValue"
|
||||
value="<%= data.hero?.floatingBadge?.value || '' %>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Label</label>
|
||||
<input type="text" class="form-control" id="floatingBadgeLabel"
|
||||
value="<%= data.hero?.floatingBadge?.label || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Move buttons to fixed bottom -->
|
||||
<div class="fixed-bottom-buttons">
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
<!-- ===== QUICK LINKS TAB ===== -->
|
||||
<div class="tab-pane fade" id="tab-quickLinks-pane" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0"><i class="fas fa-link me-2"></i>Quick Links</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="addQuickLink()"><i
|
||||
class="fas fa-plus me-1"></i>Add Link</button>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div id="quickLinksContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== VALUE PROP TAB ===== -->
|
||||
<div class="tab-pane fade" id="tab-valueProp-pane" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-star me-2"></i>Value Proposition</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Badge</label>
|
||||
<input type="text" class="form-control" id="valuePropBadge"
|
||||
value="<%= data.valueProp?.badge || '' %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" class="form-control" id="valuePropTitle"
|
||||
value="<%= data.valueProp?.title || '' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="valuePropDescription"
|
||||
rows="3"><%= data.valueProp?.description || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Features</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="addValuePropFeature()"><i
|
||||
class="fas fa-plus me-1"></i>Add Feature</button>
|
||||
</div>
|
||||
<div id="valuePropFeaturesContainer"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Stats</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="addValuePropStat()"><i
|
||||
class="fas fa-plus me-1"></i>Add Stat</button>
|
||||
</div>
|
||||
<div id="valuePropStatsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== PROGRAMS TAB ===== -->
|
||||
<div class="tab-pane fade" id="tab-programs-pane" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-graduation-cap me-2"></i>Programs Section</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Heading</label>
|
||||
<input type="text" class="form-control" id="programsHeading"
|
||||
value="<%= data.programs?.heading || '' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="programsDescription"
|
||||
rows="2"><%= data.programs?.description || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Program Items</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="addProgramItem()"><i
|
||||
class="fas fa-plus me-1"></i>Add Program</button>
|
||||
</div>
|
||||
<div id="programItemsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== REQUEST INFO TAB ===== -->
|
||||
<div class="tab-pane fade" id="tab-requestInfo-pane" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-envelope me-2"></i>Request Info Section</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Heading</label>
|
||||
<input type="text" class="form-control" id="requestInfoHeading"
|
||||
value="<%= data.requestInfo?.heading || '' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="requestInfoDescription"
|
||||
rows="2"><%= data.requestInfo?.description || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Phone</label>
|
||||
<input type="text" class="form-control" id="requestInfoPhone"
|
||||
value="<%= data.requestInfo?.phone || '' %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="text" class="form-control" id="requestInfoEmail"
|
||||
value="<%= data.requestInfo?.email || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Programs List</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
onclick="addRequestInfoProgram()"><i class="fas fa-plus me-1"></i>Add Program</button>
|
||||
</div>
|
||||
<div id="requestInfoProgramsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.tab-content -->
|
||||
</div><!-- /.card-body -->
|
||||
|
||||
<div class="card-footer bg-light d-flex justify-content-end py-3 gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary px-4" onclick="resetForm()"><i
|
||||
class="fas fa-undo me-2"></i>Reset</button>
|
||||
<button type="submit" class="btn btn-outline-primary px-4" id="submitBtn"><i
|
||||
class="fas fa-save me-2"></i>Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image upload input -->
|
||||
<input type="file" id="directImageUpload" style="display: none" />
|
||||
<input type="hidden" id="currentImageType" name="imageType" />
|
||||
<input type="hidden" id="currentTargetInput" name="targetInput" />
|
||||
|
||||
<script>
|
||||
/**
|
||||
* BRIDGE SCRIPT: Cho phép các section lẻ tự đăng ký logic lấy dữ liệu.
|
||||
* Cách dùng trong file lẻ (vị dụ hero.ejs):
|
||||
* <script>
|
||||
* window.homeScrapers = window.homeScrapers || {};
|
||||
* window.homeScrapers.hero = () => ({ title: document.getElementById('heroTitle').value, ... });
|
||||
* <\/script>
|
||||
*/
|
||||
window.homeScrapers = window.homeScrapers || {};
|
||||
let originalFormData = null;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const form = document.querySelector("form");
|
||||
if (form) {
|
||||
form.addEventListener("submit", function (e) {
|
||||
console.log("Form submitting, collecting data from scrapers...");
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
originalFormData = <%- JSON.stringify(data) %>;
|
||||
populateAll(originalFormData);
|
||||
|
||||
// Tự động thu gom dữ liệu từ các section đã đăng ký
|
||||
Object.keys(window.homeScrapers).forEach(section => {
|
||||
const input = document.getElementById(section + 'Json');
|
||||
if (input) {
|
||||
try {
|
||||
const data = window.homeScrapers[section]();
|
||||
console.log(`- Collected data for [${section}]:`, data);
|
||||
input.value = JSON.stringify(data);
|
||||
} catch (err) {
|
||||
console.error(`Error scraping section [${section}]:`, err);
|
||||
document.getElementById('homeForm').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
serializeAll();
|
||||
this.submit();
|
||||
});
|
||||
|
||||
document.body.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('.btn-upload-image');
|
||||
if (btn) openImageUploader(btn.dataset.targetInput, btn.dataset.imageType);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Populate all sections from data object ──────────────────────────────
|
||||
function populateAll(data) {
|
||||
if (!data) return;
|
||||
|
||||
// Hero
|
||||
const h = data.hero || {};
|
||||
setVal('heroBadge', h.badge);
|
||||
setVal('heroTitle', h.title);
|
||||
setVal('heroDescription', h.description);
|
||||
setVal('heroSearchPlaceholder', h.searchPlaceholder);
|
||||
setVal('heroButtonLabel', h.buttonLabel);
|
||||
setVal('heroImage', h.image);
|
||||
setVal('heroImageAlt', h.imageAlt);
|
||||
setVal('floatingBadgeIcon', h.floatingBadge?.icon);
|
||||
setVal('floatingBadgeValue', h.floatingBadge?.value);
|
||||
setVal('floatingBadgeLabel', h.floatingBadge?.label);
|
||||
updateImagePreview('heroImage', h.image);
|
||||
|
||||
// Quick Links
|
||||
populateQuickLinks(data.quickLinks || []);
|
||||
|
||||
// Value Prop
|
||||
const vp = data.valueProp || {};
|
||||
setVal('valuePropBadge', vp.badge);
|
||||
setVal('valuePropTitle', vp.title);
|
||||
setVal('valuePropDescription', vp.description);
|
||||
populateValuePropFeatures(vp.features || []);
|
||||
populateValuePropStats(vp.stats || []);
|
||||
|
||||
// Programs
|
||||
const pr = data.programs || {};
|
||||
setVal('programsHeading', pr.heading);
|
||||
setVal('programsDescription', pr.description);
|
||||
populateProgramItems(pr.items || []);
|
||||
|
||||
// Request Info
|
||||
const ri = data.requestInfo || {};
|
||||
setVal('requestInfoHeading', ri.heading);
|
||||
setVal('requestInfoDescription', ri.description);
|
||||
setVal('requestInfoPhone', ri.phone);
|
||||
setVal('requestInfoEmail', ri.email);
|
||||
populateRequestInfoPrograms(ri.programs || []);
|
||||
}
|
||||
|
||||
function setVal(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.tagName === 'TEXTAREA' ? (el.value = val || '') : (el.value = val || '');
|
||||
}
|
||||
|
||||
// ── Serialize all sections into hidden JSON inputs ───────────────────────
|
||||
function serializeAll() {
|
||||
// Hero
|
||||
document.getElementById('heroJson').value = JSON.stringify({
|
||||
badge: v('heroBadge'),
|
||||
title: v('heroTitle'),
|
||||
description: v('heroDescription'),
|
||||
searchPlaceholder: v('heroSearchPlaceholder'),
|
||||
buttonLabel: v('heroButtonLabel'),
|
||||
image: v('heroImage'),
|
||||
imageAlt: v('heroImageAlt'),
|
||||
floatingBadge: {
|
||||
icon: v('floatingBadgeIcon'),
|
||||
value: v('floatingBadgeValue'),
|
||||
label: v('floatingBadgeLabel')
|
||||
}
|
||||
});
|
||||
|
||||
// Để form tự submit tự nhiên sau khi đã điền xong các hidden inputs
|
||||
// Quick Links
|
||||
document.getElementById('quickLinksJson').value = JSON.stringify(
|
||||
Array.from(document.querySelectorAll('.quicklink-item')).map(row => ({
|
||||
icon: row.querySelector('[data-field="icon"]').value.trim(),
|
||||
title: row.querySelector('[data-field="title"]').value.trim(),
|
||||
description: row.querySelector('[data-field="description"]').value.trim(),
|
||||
linkText: row.querySelector('[data-field="linkText"]').value.trim(),
|
||||
href: row.querySelector('[data-field="href"]').value.trim()
|
||||
}))
|
||||
);
|
||||
|
||||
// Value Prop
|
||||
document.getElementById('valuePropJson').value = JSON.stringify({
|
||||
badge: v('valuePropBadge'),
|
||||
title: v('valuePropTitle'),
|
||||
description: v('valuePropDescription'),
|
||||
features: Array.from(document.querySelectorAll('.vp-feature-item')).map(row => ({
|
||||
icon: row.querySelector('[data-field="icon"]').value.trim(),
|
||||
title: row.querySelector('[data-field="title"]').value.trim(),
|
||||
description: row.querySelector('[data-field="description"]').value.trim()
|
||||
})),
|
||||
stats: Array.from(document.querySelectorAll('.vp-stat-item')).map(row => ({
|
||||
value: row.querySelector('[data-field="value"]').value.trim(),
|
||||
label: row.querySelector('[data-field="label"]').value.trim(),
|
||||
image: row.querySelector('[data-field="image"]').value.trim(),
|
||||
imageAlt: row.querySelector('[data-field="imageAlt"]').value.trim()
|
||||
}))
|
||||
});
|
||||
|
||||
// Programs
|
||||
document.getElementById('programsJson').value = JSON.stringify({
|
||||
heading: v('programsHeading'),
|
||||
description: v('programsDescription'),
|
||||
items: Array.from(document.querySelectorAll('.program-item')).map(row => ({
|
||||
category: row.querySelector('[data-field="category"]').value.trim(),
|
||||
image: row.querySelector('[data-field="image"]').value.trim(),
|
||||
duration: row.querySelector('[data-field="duration"]').value.trim(),
|
||||
rating: row.querySelector('[data-field="rating"]').value.trim(),
|
||||
title: row.querySelector('[data-field="title"]').value.trim(),
|
||||
description: row.querySelector('[data-field="description"]').value.trim(),
|
||||
studentCount: row.querySelector('[data-field="studentCount"]').value.trim(),
|
||||
href: row.querySelector('[data-field="href"]').value.trim()
|
||||
}))
|
||||
});
|
||||
|
||||
// Request Info
|
||||
document.getElementById('requestInfoJson').value = JSON.stringify({
|
||||
heading: v('requestInfoHeading'),
|
||||
description: v('requestInfoDescription'),
|
||||
phone: v('requestInfoPhone'),
|
||||
email: v('requestInfoEmail'),
|
||||
programs: Array.from(document.querySelectorAll('.ri-program-input')).map(i => i.value.trim()).filter(Boolean)
|
||||
});
|
||||
}
|
||||
|
||||
// Khởi tạo các nút upload ảnh (dùng chung cho toàn bộ các section)
|
||||
initImageUploads();
|
||||
});
|
||||
|
||||
// --- UTILITIES (Dùng chung) ---
|
||||
|
||||
function initImageUploads() {
|
||||
document.addEventListener("click", function (e) {
|
||||
const btn = e.target.closest(".btn-upload-image");
|
||||
if (btn) {
|
||||
document.getElementById("currentImageType").value = btn.dataset.imageType;
|
||||
document.getElementById("currentTargetInput").value = btn.dataset.targetInput;
|
||||
document.getElementById("directImageUpload").click();
|
||||
function v(id) {
|
||||
const el = document.getElementById(id);
|
||||
return el ? el.value.trim() : '';
|
||||
}
|
||||
});
|
||||
|
||||
const fileInput = document.getElementById("directImageUpload");
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener("change", handleDirectImageUpload);
|
||||
// ── Quick Links ──────────────────────────────────────────────────────────
|
||||
function addQuickLink(item = {}) {
|
||||
const c = document.getElementById('quickLinksContainer');
|
||||
const html = `
|
||||
<div class="card mb-3 quicklink-item">
|
||||
<div class="card-body p-3">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Icon (FA class)</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="icon" value="${esc(item.icon)}" placeholder="fa-trophy">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Title</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="title" value="${esc(item.title)}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Description</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="description" value="${esc(item.description)}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Link Text</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="linkText" value="${esc(item.linkText)}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Href</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="href" value="${esc(item.href)}">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link text-danger btn-sm p-0 mt-2" onclick="this.closest('.quicklink-item').remove()"><i class="fas fa-trash me-1"></i>Remove</button>
|
||||
</div>
|
||||
</div>`;
|
||||
c.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
function populateQuickLinks(items) {
|
||||
document.getElementById('quickLinksContainer').innerHTML = '';
|
||||
items.forEach(item => addQuickLink(item));
|
||||
}
|
||||
|
||||
// ── Value Prop Features ──────────────────────────────────────────────────
|
||||
function addValuePropFeature(item = {}) {
|
||||
const c = document.getElementById('valuePropFeaturesContainer');
|
||||
const html = `
|
||||
<div class="card mb-2 vp-feature-item">
|
||||
<div class="card-body p-3">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Icon (FA class)</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="icon" value="${esc(item.icon)}" placeholder="fa-laptop-code">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Title</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="title" value="${esc(item.title)}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Description</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="description" value="${esc(item.description)}">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link text-danger btn-sm p-0 mt-2" onclick="this.closest('.vp-feature-item').remove()"><i class="fas fa-trash me-1"></i>Remove</button>
|
||||
</div>
|
||||
</div>`;
|
||||
c.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
function populateValuePropFeatures(items) {
|
||||
document.getElementById('valuePropFeaturesContainer').innerHTML = '';
|
||||
items.forEach(item => addValuePropFeature(item));
|
||||
}
|
||||
|
||||
// ── Value Prop Stats ─────────────────────────────────────────────────────
|
||||
function addValuePropStat(item = {}) {
|
||||
const idx = Date.now();
|
||||
const c = document.getElementById('valuePropStatsContainer');
|
||||
const html = `
|
||||
<div class="card mb-2 vp-stat-item">
|
||||
<div class="card-body p-3">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Value</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="value" value="${esc(item.value)}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Label</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="label" value="${esc(item.label)}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Image URL</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control" data-field="image" id="vpStatImg_${idx}" value="${esc(item.image)}">
|
||||
<button class="btn btn-outline-primary btn-upload-image" type="button" data-target-input="vpStatImg_${idx}" data-image-type="home"><i class="fas fa-upload"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Image Alt</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="imageAlt" value="${esc(item.imageAlt)}">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link text-danger btn-sm p-0 mt-2" onclick="this.closest('.vp-stat-item').remove()"><i class="fas fa-trash me-1"></i>Remove</button>
|
||||
</div>
|
||||
</div>`;
|
||||
c.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
function populateValuePropStats(items) {
|
||||
document.getElementById('valuePropStatsContainer').innerHTML = '';
|
||||
items.forEach(item => addValuePropStat(item));
|
||||
}
|
||||
|
||||
// ── Program Items ────────────────────────────────────────────────────────
|
||||
function addProgramItem(item = {}) {
|
||||
const idx = Date.now();
|
||||
const c = document.getElementById('programItemsContainer');
|
||||
const html = `
|
||||
<div class="card mb-3 program-item">
|
||||
<div class="card-body p-3">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Title</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="title" value="${esc(item.title)}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Category</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="category" value="${esc(item.category)}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Duration</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="duration" value="${esc(item.duration)}">
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Rating</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="rating" value="${esc(item.rating)}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Student Count</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="studentCount" value="${esc(item.studentCount)}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Href</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="href" value="${esc(item.href)}">
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label small">Description</label>
|
||||
<input type="text" class="form-control form-control-sm" data-field="description" value="${esc(item.description)}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Image URL</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control" data-field="image" id="progImg_${idx}" value="${esc(item.image)}">
|
||||
<button class="btn btn-outline-primary btn-upload-image" type="button" data-target-input="progImg_${idx}" data-image-type="home"><i class="fas fa-upload"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link text-danger btn-sm p-0 mt-2" onclick="this.closest('.program-item').remove()"><i class="fas fa-trash me-1"></i>Remove</button>
|
||||
</div>
|
||||
</div>`;
|
||||
c.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
function populateProgramItems(items) {
|
||||
document.getElementById('programItemsContainer').innerHTML = '';
|
||||
items.forEach(item => addProgramItem(item));
|
||||
}
|
||||
|
||||
// ── Request Info Programs ────────────────────────────────────────────────
|
||||
function addRequestInfoProgram(val = '') {
|
||||
const c = document.getElementById('requestInfoProgramsContainer');
|
||||
const html = `
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="text" class="form-control ri-program-input" value="${esc(val)}" placeholder="Program name">
|
||||
<button class="btn btn-outline-danger" type="button" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
|
||||
</div>`;
|
||||
c.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
function populateRequestInfoPrograms(programs) {
|
||||
document.getElementById('requestInfoProgramsContainer').innerHTML = '';
|
||||
programs.forEach(p => addRequestInfoProgram(p));
|
||||
}
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────
|
||||
function esc(val) {
|
||||
if (!val) return '';
|
||||
return String(val).replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
if (confirm('Reset all changes to last saved state?')) {
|
||||
populateAll(originalFormData);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDirectImageUpload(e) {
|
||||
if (!this.files || !this.files[0]) return;
|
||||
const file = this.files[0];
|
||||
const imageType = document.getElementById("currentImageType").value;
|
||||
const targetInput = document.getElementById("currentTargetInput").value;
|
||||
function updateImagePreview(inputId, imagePath) {
|
||||
if (!imagePath) return;
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) return;
|
||||
const card = input.closest('.card');
|
||||
const preview = card ? card.querySelector('.uploaded-preview') : null;
|
||||
if (preview) preview.src = imagePath;
|
||||
}
|
||||
|
||||
function openImageUploader(targetInput, imageType) {
|
||||
// Dùng persistent hidden file input để tránh browser block programmatic click
|
||||
let fileInput = document.getElementById('__globalFileInput');
|
||||
if (!fileInput) {
|
||||
fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.id = '__globalFileInput';
|
||||
fileInput.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;width:1px;height:1px;';
|
||||
document.body.appendChild(fileInput);
|
||||
}
|
||||
|
||||
fileInput.value = '';
|
||||
|
||||
fileInput.onchange = async function (e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const uploadBtn = document.querySelector(`[data-target-input="${targetInput}"]`) ||
|
||||
document.querySelector(`[onclick*="${targetInput}"]`);
|
||||
if (uploadBtn) uploadBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
const response = await fetch(`/admin/upload/image?imageType=${imageType}`, { method: "POST", body: formData });
|
||||
const result = await response.json();
|
||||
formData.append('image', file);
|
||||
|
||||
if (result.success && result.path) {
|
||||
const input = document.getElementById(targetInput);
|
||||
const response = await fetch(`/admin/upload/image?imageType=${imageType}`, { method: 'POST', body: formData });
|
||||
if (!response.ok) throw new Error('Upload failed');
|
||||
const result = await response.json();
|
||||
if (!result.success) throw new Error(result.error || 'Upload failed');
|
||||
|
||||
const input = document.getElementById(targetInput) || document.querySelector(`[data-target-input-id="${targetInput}"]`);
|
||||
if (input) {
|
||||
input.value = result.path;
|
||||
// Cập nhật preview nếu có img ngay sau input group
|
||||
const previewImg = input.closest('.input-group')?.nextElementSibling?.querySelector('img');
|
||||
if (previewImg) {
|
||||
previewImg.src = result.path;
|
||||
previewImg.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
showToast("Success", "Image uploaded successfully", "success");
|
||||
const previewUrl = result.path.startsWith('http') ? result.path : window.location.origin + result.path;
|
||||
let preview = input.closest('.card')?.querySelector('.uploaded-preview');
|
||||
if (preview) {
|
||||
preview.src = previewUrl;
|
||||
} else {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
const img = document.createElement('img');
|
||||
img.src = previewUrl;
|
||||
img.className = 'img-thumbnail uploaded-preview mt-2';
|
||||
img.style.maxHeight = '150px';
|
||||
input.closest('.input-group')?.insertAdjacentElement('afterend', img);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast("Error", "Upload failed: " + error.message, "error");
|
||||
}
|
||||
this.value = "";
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err);
|
||||
alert('Upload failed: ' + err.message);
|
||||
} finally {
|
||||
if (uploadBtn) uploadBtn.disabled = false;
|
||||
fileInput.value = '';
|
||||
}
|
||||
|
||||
function showToast(title, message, type = "info") {
|
||||
let container = document.querySelector(".toast-container") || (() => {
|
||||
const c = document.createElement("div");
|
||||
c.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(c);
|
||||
return c;
|
||||
})();
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type === "error" ? "danger" : type} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${title}:</strong> ${message}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", () => toast.remove());
|
||||
};
|
||||
fileInput.click();
|
||||
}
|
||||
</script>
|
||||
@@ -1,742 +0,0 @@
|
||||
<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,755 +0,0 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark);">Service Details: <%= service.name %></h1>
|
||||
<p class="text-muted mb-0">Edit detailed content for <%= service.name %> service</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/admin/service" class="btn btn-outline-primary">
|
||||
<i class="fas fa-arrow-left me-2"></i>Back to Services
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="/admin/service/<%= service.slug %>/details/update" method="POST" class="content-with-fixed-buttons" id="serviceDetailsForm">
|
||||
<!-- Hidden inputs for JSON data -->
|
||||
<input type="hidden" name="details" id="detailsJson">
|
||||
<input type="hidden" name="features" id="featuresJson">
|
||||
<input type="hidden" name="faq" id="faqJson">
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" data-bs-toggle="tab" href="#basic-info" role="tab">
|
||||
<i class="fas fa-info-circle me-2"></i>Basic Information
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#key-features" role="tab">
|
||||
<i class="fas fa-star me-2"></i>Key Features
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#faq-section" role="tab">
|
||||
<i class="fas fa-question-circle me-2"></i>FAQ Section
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
<!-- Basic Information Tab -->
|
||||
<div class="tab-pane fade show active" id="basic-info" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label fw-medium">Main Image</label>
|
||||
<div class="input-group mb-2">
|
||||
<input type="text" class="form-control" id="mainImage" name="mainImage"
|
||||
value="<%= service.details?.mainImage || '' %>">
|
||||
<button type="button" class="btn btn-outline-primary btn-upload-image"
|
||||
data-target-input="mainImage" data-image-type="service">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-text text-muted">Recommended size: 800x600px</small>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<div id="mainImagePreview">
|
||||
<% if (service.details?.mainImage) { %>
|
||||
<img src="<%= getFullImageUrl(service.details.mainImage) %>" class="img-thumbnail" style="max-height: 250px; width: auto; max-width: 100%; object-fit: contain;" alt="Main image preview">
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-medium">Title</label>
|
||||
<input type="text" class="form-control" id="detailsTitle" name="title"
|
||||
value="<%= service.details?.title || service.name %>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-medium">Description</label>
|
||||
<textarea class="form-control" id="detailsDescription" name="description" rows="3"><%= service.details?.description || service.description %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview Section -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card border">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0">Overview Section</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Overview Title</label>
|
||||
<input type="text" class="form-control" id="overviewTitle" name="overviewTitle"
|
||||
value="<%= service.details?.overviewTitle || 'Service Overview' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Overview Description</label>
|
||||
<textarea class="form-control" id="overviewDescription" name="overviewDescription" rows="4"><%= service.details?.overviewDescription || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Additional Description</label>
|
||||
<textarea class="form-control" id="additionalDescription" name="additionalDescription" rows="3"><%= service.details?.additionalDescription || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Features Tab -->
|
||||
<div class="tab-pane fade" id="key-features" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-body">
|
||||
<!-- Features Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Features Title</label>
|
||||
<input type="text" class="form-control" id="keyFeaturesTitle" name="keyFeaturesTitle"
|
||||
value="<%= service.details?.keyFeaturesTitle || 'Key Features' %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Features Image</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="keyFeaturesImage" name="keyFeaturesImage"
|
||||
value="<%= service.details?.keyFeaturesImage || '' %>">
|
||||
<button type="button" class="btn btn-outline-primary btn-upload-image"
|
||||
data-target-input="keyFeaturesImage" data-image-type="service">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Features Image Preview -->
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<div id="keyFeaturesImagePreview">
|
||||
<% if (service.details?.keyFeaturesImage) { %>
|
||||
<img src="<%= getFullImageUrl(service.details.keyFeaturesImage) %>" class="img-thumbnail" style="max-height: 180px; width: auto; max-width: 100%; object-fit: contain;" alt="Key Features image preview">
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features List -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="fw-medium mb-0">Features</h6>
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="addFeature()">
|
||||
<i class="fas fa-plus"></i> Add Feature
|
||||
</button>
|
||||
</div>
|
||||
<div id="featuresContainer">
|
||||
<% if (service.details?.features && service.details.features.length > 0) { %>
|
||||
<% service.details.features.forEach((feature, index) => { %>
|
||||
<div class="card mb-3 feature-item">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0 text-decoration-underline">Feature <%= index + 1 %></h6>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="removeFeature(this)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" class="form-control feature-title"
|
||||
value="<%= feature.title || '' %>" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control feature-description" rows="2" required><%= feature.description || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAQ Section Tab -->
|
||||
<div class="tab-pane fade" id="faq-section" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-body">
|
||||
<!-- FAQ Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">FAQ Title</label>
|
||||
<input type="text" class="form-control" id="faqTitle" name="faqTitle"
|
||||
value="<%= service.details?.faqTitle || 'Frequently Asked Questions' %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">FAQ Image</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="faqImage" name="faqImage"
|
||||
value="<%= service.details?.faqImage || '' %>">
|
||||
<button type="button" class="btn btn-outline-primary btn-upload-image"
|
||||
data-target-input="faqImage" data-image-type="service">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAQ Image Preview -->
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<div id="faqImagePreview">
|
||||
<% if (service.details?.faqImage) { %>
|
||||
<img src="<%= getFullImageUrl(service.details.faqImage) %>" class="img-thumbnail" style="max-height: 180px; width: auto; max-width: 100%; object-fit: contain;" alt="FAQ image preview">
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAQ List -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="fw-medium mb-0">FAQ Items</h6>
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="addFAQ()">
|
||||
<i class="fas fa-plus"></i> Add FAQ
|
||||
</button>
|
||||
</div>
|
||||
<div id="faqContainer">
|
||||
<% if (service.details?.faq && service.details.faq.length > 0) { %>
|
||||
<% service.details.faq.forEach((faq, index) => { %>
|
||||
<div class="card mb-3 faq-item">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0 text-decoration-underline">FAQ <%= index + 1 %></h6>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="removeFAQ(this)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">ID (Auto-generated)</label>
|
||||
<input type="text" class="form-control faq-id"
|
||||
value="<%= faq.id || 'faq-' + (index + 1) %>" readonly>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Expanded by Default</label>
|
||||
<select class="form-control faq-expanded">
|
||||
<option value="false" <%= !faq.isExpanded ? 'selected' : '' %>>No</option>
|
||||
<option value="true" <%= faq.isExpanded ? 'selected' : '' %>>Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Question</label>
|
||||
<input type="text" class="form-control faq-question"
|
||||
value="<%= faq.question || '' %>" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Answer</label>
|
||||
<textarea class="form-control faq-answer" rows="3" required><%= faq.answer || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Buttons -->
|
||||
<div class="bottom-buttons">
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="fas fa-undo me-2"></i>Reset
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
<i class="fas fa-save me-2"></i>Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Scripts -->
|
||||
<script>
|
||||
let originalFormData = null;
|
||||
let featureIndex = <%= service.details?.features?.length || 0 %>;
|
||||
let faqIndex = <%= service.details?.faq?.length || 0 %>;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize form data
|
||||
originalFormData = <%- JSON.stringify(service) %>;
|
||||
|
||||
// Set initial JSON values
|
||||
updateAllJsonInputs(originalFormData);
|
||||
|
||||
// Initialize form handlers
|
||||
initializeFormHandlers();
|
||||
});
|
||||
|
||||
function initializeFormHandlers() {
|
||||
// Form submission
|
||||
const form = document.getElementById('serviceDetailsForm');
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving...';
|
||||
|
||||
try {
|
||||
updateJsonData();
|
||||
this.submit();
|
||||
} catch (error) {
|
||||
console.error('Error updating data:', error);
|
||||
showError('Failed to process form data. Please try again.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize image upload buttons
|
||||
document.querySelectorAll('.btn-upload-image').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetInput = this.dataset.targetInput;
|
||||
const imageType = this.dataset.imageType;
|
||||
openImageUploader(targetInput, imageType);
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize image input change listeners for manual URL input
|
||||
const imageInputs = ['mainImage', 'keyFeaturesImage', 'faqImage'];
|
||||
imageInputs.forEach(inputId => {
|
||||
const input = document.getElementById(inputId);
|
||||
if (input) {
|
||||
input.addEventListener('input', function() {
|
||||
updateImagePreviewAfterUpload(inputId, this.value);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addFeature() {
|
||||
const container = document.getElementById('featuresContainer');
|
||||
const featureHtml = `
|
||||
<div class="card mb-3 feature-item">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0 text-decoration-underline">Feature ${featureIndex + 1}</h6>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="removeFeature(this)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" class="form-control feature-title" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control feature-description" rows="2" required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.insertAdjacentHTML('beforeend', featureHtml);
|
||||
featureIndex++;
|
||||
}
|
||||
|
||||
function removeFeature(button) {
|
||||
const featureItem = button.closest('.feature-item');
|
||||
if (featureItem) {
|
||||
featureItem.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function addFAQ() {
|
||||
const container = document.getElementById('faqContainer');
|
||||
const newFaqId = generateFAQId();
|
||||
const faqNumber = document.querySelectorAll('.faq-item').length + 1;
|
||||
const faqHtml = `
|
||||
<div class="card mb-3 faq-item">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0 text-decoration-underline">FAQ ${faqNumber}</h6>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="removeFAQ(this)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">ID (Auto-generated)</label>
|
||||
<input type="text" class="form-control faq-id"
|
||||
value="${newFaqId}" readonly>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Expanded by Default</label>
|
||||
<select class="form-control faq-expanded">
|
||||
<option value="false">No</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Question</label>
|
||||
<input type="text" class="form-control faq-question" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Answer</label>
|
||||
<textarea class="form-control faq-answer" rows="3" required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.insertAdjacentHTML('beforeend', faqHtml);
|
||||
faqIndex++;
|
||||
}
|
||||
|
||||
function updateFAQId(questionInput) {
|
||||
// Không cần update ID nữa vì đã tự động theo số thứ tự
|
||||
}
|
||||
|
||||
function removeFAQ(button) {
|
||||
const faqItem = button.closest('.faq-item');
|
||||
if (faqItem) {
|
||||
faqItem.remove();
|
||||
// Cập nhật lại số thứ tự và ID của tất cả FAQ
|
||||
updateFAQNumbers();
|
||||
}
|
||||
}
|
||||
|
||||
function generateFAQId() {
|
||||
// Đếm số lượng FAQ hiện tại và tạo ID tiếp theo
|
||||
const existingFAQs = document.querySelectorAll('.faq-item');
|
||||
const nextNumber = existingFAQs.length + 1;
|
||||
return `faq-${nextNumber}`;
|
||||
}
|
||||
|
||||
function updateFAQNumbers() {
|
||||
// Cập nhật lại tất cả FAQ ID và số thứ tự
|
||||
const faqItems = document.querySelectorAll('.faq-item');
|
||||
faqItems.forEach((item, index) => {
|
||||
const number = index + 1;
|
||||
const idInput = item.querySelector('.faq-id');
|
||||
const titleElement = item.querySelector('h6');
|
||||
|
||||
if (idInput) {
|
||||
idInput.value = `faq-${number}`;
|
||||
}
|
||||
if (titleElement) {
|
||||
titleElement.textContent = `FAQ ${number}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateAllJsonInputs(data) {
|
||||
// Collect basic details data
|
||||
const details = {
|
||||
title: data.details?.title || data.name,
|
||||
description: data.details?.description || data.description,
|
||||
mainImage: data.details?.mainImage || '',
|
||||
overviewTitle: data.details?.overviewTitle || 'Service Overview',
|
||||
overviewDescription: data.details?.overviewDescription || '',
|
||||
additionalDescription: data.details?.additionalDescription || '',
|
||||
keyFeaturesTitle: data.details?.keyFeaturesTitle || 'Key Features',
|
||||
keyFeaturesImage: data.details?.keyFeaturesImage || '',
|
||||
faqTitle: data.details?.faqTitle || 'Frequently Asked Questions',
|
||||
faqImage: data.details?.faqImage || ''
|
||||
};
|
||||
|
||||
document.getElementById('detailsJson').value = JSON.stringify(details);
|
||||
document.getElementById('featuresJson').value = JSON.stringify(data.details?.features || []);
|
||||
document.getElementById('faqJson').value = JSON.stringify(data.details?.faq || []);
|
||||
}
|
||||
|
||||
function updateJsonData() {
|
||||
// Collect basic details data
|
||||
const details = {
|
||||
title: document.getElementById('detailsTitle').value,
|
||||
description: document.getElementById('detailsDescription').value,
|
||||
mainImage: document.getElementById('mainImage').value,
|
||||
overviewTitle: document.getElementById('overviewTitle').value,
|
||||
overviewDescription: document.getElementById('overviewDescription').value,
|
||||
additionalDescription: document.getElementById('additionalDescription').value,
|
||||
keyFeaturesTitle: document.getElementById('keyFeaturesTitle').value,
|
||||
keyFeaturesImage: document.getElementById('keyFeaturesImage').value,
|
||||
faqTitle: document.getElementById('faqTitle').value,
|
||||
faqImage: document.getElementById('faqImage').value
|
||||
};
|
||||
|
||||
// Collect features data
|
||||
const features = [];
|
||||
document.querySelectorAll('.feature-item').forEach(item => {
|
||||
features.push({
|
||||
title: item.querySelector('.feature-title').value,
|
||||
description: item.querySelector('.feature-description').value
|
||||
});
|
||||
});
|
||||
|
||||
// Collect FAQ data
|
||||
const faq = [];
|
||||
document.querySelectorAll('.faq-item').forEach(item => {
|
||||
faq.push({
|
||||
id: item.querySelector('.faq-id').value,
|
||||
question: item.querySelector('.faq-question').value,
|
||||
answer: item.querySelector('.faq-answer').value,
|
||||
isExpanded: item.querySelector('.faq-expanded').value === 'true'
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('detailsJson').value = JSON.stringify(details);
|
||||
document.getElementById('featuresJson').value = JSON.stringify(features);
|
||||
document.getElementById('faqJson').value = JSON.stringify(faq);
|
||||
}
|
||||
|
||||
// Helper function để tạo full URL cho ảnh - tương tự như server-side helper
|
||||
function getFullImageUrlJS(imagePath) {
|
||||
if (!imagePath) return '';
|
||||
|
||||
// Nếu đã là full URL thì return luôn
|
||||
if (imagePath.startsWith('http')) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
// Lấy backend URL
|
||||
const backendUrl = '<%= (process.env.BACKEND_URL || "http://localhost:3001").replace(/\/$/, "") %>';
|
||||
|
||||
// Xử lý đường dẫn
|
||||
let imgSrc = imagePath;
|
||||
if (!imgSrc.startsWith('/')) {
|
||||
imgSrc = '/uploads/' + imgSrc;
|
||||
}
|
||||
|
||||
return backendUrl + imgSrc;
|
||||
}
|
||||
|
||||
// Function để cập nhật image preview sau khi upload
|
||||
function updateImagePreviewAfterUpload(targetInput, imagePath) {
|
||||
const fullImageUrl = getFullImageUrlJS(imagePath);
|
||||
|
||||
switch(targetInput) {
|
||||
case 'mainImage':
|
||||
const mainPreview = document.getElementById('mainImagePreview');
|
||||
if (mainPreview) {
|
||||
mainPreview.innerHTML = `<img src="${fullImageUrl}" class="img-thumbnail" style="max-height: 250px; width: auto; max-width: 100%; object-fit: contain;" alt="Main image preview">`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'keyFeaturesImage':
|
||||
const featuresPreview = document.getElementById('keyFeaturesImagePreview');
|
||||
if (featuresPreview) {
|
||||
featuresPreview.innerHTML = `<img src="${fullImageUrl}" class="img-thumbnail" style="max-height: 180px; width: auto; max-width: 100%; object-fit: contain;" alt="Key Features image preview">`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'faqImage':
|
||||
const faqPreview = document.getElementById('faqImagePreview');
|
||||
if (faqPreview) {
|
||||
faqPreview.innerHTML = `<img src="${fullImageUrl}" class="img-thumbnail" style="max-height: 180px; width: auto; max-width: 100%; object-fit: contain;" alt="FAQ image preview">`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function openImageUploader(targetInput, imageType) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.style.display = 'none';
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.onchange = async function (e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return reject(new Error('No file selected'));
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const uploadBtn = document.querySelector(`[data-target-input="${targetInput}"]`);
|
||||
const originalBtnHtml = uploadBtn ? uploadBtn.innerHTML : null;
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Uploading...';
|
||||
}
|
||||
|
||||
const response = await fetch(`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const input = document.getElementById(targetInput) || document.querySelector(`[name="${targetInput}"]`);
|
||||
if (!input) throw new Error('Target input not found');
|
||||
|
||||
input.value = result.path;
|
||||
|
||||
// Update image preview based on target input
|
||||
updateImagePreviewAfterUpload(targetInput, result.path);
|
||||
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.innerHTML = originalBtnHtml;
|
||||
}
|
||||
|
||||
resolve(result);
|
||||
} catch (err) {
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.innerHTML = originalBtnHtml;
|
||||
}
|
||||
console.error('Upload error:', err);
|
||||
showError('Upload failed: ' + (err.message || 'Unknown error'));
|
||||
reject(err);
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
};
|
||||
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
// Create and show success alert
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
|
||||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
// Create and show error alert
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
|
||||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
padding: 15px 0;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.content-with-fixed-buttons {
|
||||
/* Remove bottom padding since buttons are no longer fixed */
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.btn-group .btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.card-header h6 {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.text-decoration-underline {
|
||||
text-decoration: underline;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* Image Preview Styles */
|
||||
#mainImagePreview, #keyFeaturesImagePreview, #faqImagePreview {
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f8f9fa;
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#mainImagePreview:empty::before,
|
||||
#keyFeaturesImagePreview:empty::before,
|
||||
#faqImagePreview:empty::before {
|
||||
content: "No image selected";
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#mainImagePreview img,
|
||||
#keyFeaturesImagePreview img,
|
||||
#faqImagePreview img {
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,440 +0,0 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark);">Edit Service: <%= service.name %></h1>
|
||||
<p class="text-muted mb-0">Update service information and settings</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/admin/service" class="btn btn-outline-primary">
|
||||
<i class="fas fa-arrow-left me-2"></i>Back to Services
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="/admin/service/<%= service.slug %>/edit" method="POST" id="editServiceForm">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h5 class="mb-0" style="color: var(--primary-dark);">
|
||||
<i class="fas fa-edit me-2"></i>Service Information
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-medium">Service Name</label>
|
||||
<input type="text" class="form-control" id="serviceName" name="name"
|
||||
value="<%= service.name %>" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">
|
||||
Slug
|
||||
<small class="text-muted">(generated from name)</small>
|
||||
<span id="slugAutoIndicator" class="badge bg-info ms-1" style="font-size: 0.7em;">EXISTING</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="serviceSlug" name="slug"
|
||||
value="<%= service.slug %>" readonly>
|
||||
<button type="button" class="btn btn-primary" id="generateSlugBtn" title="Generate slug from name">
|
||||
<i class="fas fa-magic me-1"></i>Generate
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-text text-muted">URL-friendly version of the service name.</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Layout</label>
|
||||
<select class="form-control" id="serviceLayout" name="layout">
|
||||
<option value="left" <%= service.layout === 'left' ? 'selected' : '' %>>Left</option>
|
||||
<option value="right" <%= service.layout === 'right' ? 'selected' : '' %>>Right</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-medium">Image</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="serviceImage" name="image"
|
||||
value="<%= service.image || '' %>">
|
||||
<button type="button" class="btn btn-outline-primary btn-upload-image"
|
||||
data-target-input="serviceImage" data-image-type="service">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div id="serviceImagePreview">
|
||||
<% if (service.image) { %>
|
||||
<img src="<%= getFullImageUrl(service.image) %>" class="img-thumbnail"
|
||||
style="height: 80px; width: 100%; object-fit: cover;" alt="Preview">
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Description</label>
|
||||
<textarea class="form-control" id="serviceDescription" name="description"
|
||||
rows="3" required><%= service.description %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Buttons -->
|
||||
<div class="bottom-buttons">
|
||||
<a href="/admin/service" class="btn btn-secondary">
|
||||
<i class="fas fa-times me-2"></i>Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
<i class="fas fa-save me-2"></i>Update Service
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script>
|
||||
let servicesData = []; // Will be populated for duplicate checking
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Load existing services data for duplicate checking
|
||||
loadServicesData();
|
||||
|
||||
// Initialize form handlers
|
||||
initializeFormHandlers();
|
||||
});
|
||||
|
||||
async function loadServicesData() {
|
||||
try {
|
||||
const response = await fetch('/api/service');
|
||||
const data = await response.json();
|
||||
servicesData = data.services?.items || [];
|
||||
} catch (error) {
|
||||
console.error('Error loading services data:', error);
|
||||
servicesData = [];
|
||||
}
|
||||
}
|
||||
|
||||
function initializeFormHandlers() {
|
||||
// Form submission
|
||||
const form = document.getElementById('editServiceForm');
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Updating...';
|
||||
|
||||
try {
|
||||
// Check for duplicate slug before submitting
|
||||
const slug = document.getElementById('serviceSlug').value.trim();
|
||||
const currentSlug = '<%= service.slug %>';
|
||||
|
||||
if (slug !== currentSlug && isSlugDuplicate(slug, -1)) {
|
||||
showError('Service with this slug already exists. Please generate a new slug.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Update Service';
|
||||
return;
|
||||
}
|
||||
|
||||
this.submit();
|
||||
} catch (error) {
|
||||
console.error('Error updating service:', error);
|
||||
showError('Failed to update service. Please try again.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Update Service';
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize image upload buttons
|
||||
document.querySelectorAll('.btn-upload-image').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetInput = this.dataset.targetInput;
|
||||
const imageType = this.dataset.imageType;
|
||||
openImageUploader(targetInput, imageType);
|
||||
});
|
||||
});
|
||||
|
||||
// Image preview for service image
|
||||
const serviceImageInput = document.getElementById('serviceImage');
|
||||
if (serviceImageInput) {
|
||||
serviceImageInput.addEventListener('input', function() {
|
||||
updateImagePreview('serviceImagePreview', this.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Generate slug from service name
|
||||
const serviceNameInput = document.getElementById('serviceName');
|
||||
const serviceSlugInput = document.getElementById('serviceSlug');
|
||||
const slugAutoIndicator = document.getElementById('slugAutoIndicator');
|
||||
const generateSlugBtn = document.getElementById('generateSlugBtn');
|
||||
|
||||
if (serviceNameInput && serviceSlugInput && generateSlugBtn) {
|
||||
// Generate slug button
|
||||
generateSlugBtn.addEventListener('click', async function() {
|
||||
const serviceName = serviceNameInput.value.trim();
|
||||
if (serviceName) {
|
||||
// Show loading state
|
||||
const originalBtnHtml = this.innerHTML;
|
||||
this.disabled = true;
|
||||
this.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Generating...';
|
||||
|
||||
try {
|
||||
const slug = await generateSlugFromText(serviceName);
|
||||
const currentSlug = '<%= service.slug %>';
|
||||
|
||||
// Check for duplicate slug (excluding current service)
|
||||
if (slug !== currentSlug && isSlugDuplicate(slug, -1)) {
|
||||
const uniqueSlug = generateUniqueSlug(slug);
|
||||
serviceSlugInput.value = uniqueSlug;
|
||||
showWarning(`Slug "${slug}" already exists. Generated unique slug: "${uniqueSlug}"`);
|
||||
} else {
|
||||
serviceSlugInput.value = slug;
|
||||
showSuccess('Slug generated successfully!');
|
||||
}
|
||||
|
||||
if (slugAutoIndicator) {
|
||||
slugAutoIndicator.textContent = 'GENERATED';
|
||||
slugAutoIndicator.className = 'badge bg-success ms-1';
|
||||
slugAutoIndicator.style.fontSize = '0.7em';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating slug:', error);
|
||||
showError('Failed to generate slug. Please try again.');
|
||||
} finally {
|
||||
// Restore button state
|
||||
this.disabled = false;
|
||||
this.innerHTML = originalBtnHtml;
|
||||
}
|
||||
} else {
|
||||
showError('Please enter a service name first.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate slug using backend API
|
||||
async function generateSlugFromText(text) {
|
||||
try {
|
||||
const response = await fetch('/admin/service/generate-slug', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text: text })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
return result.slug;
|
||||
} else {
|
||||
throw new Error(result.message || 'Failed to generate slug');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating slug:', error);
|
||||
// Fallback to simple slug generation if API fails
|
||||
return text
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w\-]+/g, '')
|
||||
.replace(/\-\-+/g, '-')
|
||||
.replace(/^-+/, '')
|
||||
.replace(/-+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if slug already exists
|
||||
function isSlugDuplicate(slug, excludeIndex = -1) {
|
||||
return servicesData.some((service, index) => {
|
||||
return service && service.slug === slug && index !== excludeIndex;
|
||||
});
|
||||
}
|
||||
|
||||
// Generate unique slug by appending number
|
||||
function generateUniqueSlug(baseSlug) {
|
||||
let counter = 1;
|
||||
let uniqueSlug = baseSlug;
|
||||
|
||||
while (isSlugDuplicate(uniqueSlug, -1)) {
|
||||
uniqueSlug = baseSlug + '-' + counter;
|
||||
counter++;
|
||||
}
|
||||
|
||||
return uniqueSlug;
|
||||
}
|
||||
|
||||
function updateImagePreview(previewId, imagePath) {
|
||||
const preview = document.getElementById(previewId);
|
||||
if (imagePath) {
|
||||
const fullImageUrl = getFullImageUrlJS(imagePath);
|
||||
preview.innerHTML = `<img src="${fullImageUrl}" class="img-thumbnail" style="height: 80px; width: 100%; object-fit: cover;" alt="Preview">`;
|
||||
} else {
|
||||
preview.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function để tạo full URL cho ảnh
|
||||
function getFullImageUrlJS(imagePath) {
|
||||
if (!imagePath) return '';
|
||||
|
||||
if (imagePath.startsWith('http')) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
const backendUrl = '<%= (process.env.BACKEND_URL || "http://localhost:3001").replace(/\/$/, "") %>';
|
||||
|
||||
let imgSrc = imagePath;
|
||||
if (!imgSrc.startsWith('/')) {
|
||||
imgSrc = '/uploads/' + imgSrc;
|
||||
}
|
||||
|
||||
return backendUrl + imgSrc;
|
||||
}
|
||||
|
||||
async function openImageUploader(targetInput, imageType) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.style.display = 'none';
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.onchange = async function (e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return reject(new Error('No file selected'));
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const uploadBtn = document.querySelector(`[data-target-input="${targetInput}"]`);
|
||||
const originalBtnHtml = uploadBtn ? uploadBtn.innerHTML : null;
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Uploading...';
|
||||
}
|
||||
|
||||
const response = await fetch(`/admin/upload/image?imageType=service`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const input = document.getElementById(targetInput);
|
||||
if (!input) throw new Error('Target input not found');
|
||||
|
||||
input.value = result.path;
|
||||
|
||||
// Update preview
|
||||
if (targetInput === 'serviceImage') {
|
||||
updateImagePreview('serviceImagePreview', result.path);
|
||||
}
|
||||
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.innerHTML = originalBtnHtml;
|
||||
}
|
||||
|
||||
resolve(result);
|
||||
} catch (err) {
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.innerHTML = originalBtnHtml;
|
||||
}
|
||||
console.error('Upload error:', err);
|
||||
showError('Upload failed: ' + (err.message || 'Unknown error'));
|
||||
reject(err);
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
};
|
||||
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
|
||||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showWarning(message) {
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-warning alert-dismissible fade show position-fixed';
|
||||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
|
||||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
padding: 15px 0;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.card-header h5 {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
</style>
|
||||
@@ -1,353 +0,0 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-2" style="color: var(--primary-dark);">
|
||||
Travel Information Editor
|
||||
</h1>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-primary preview-btn">
|
||||
<i class="fas fa-eye me-2"></i>Preview
|
||||
</button>
|
||||
<button type="submit" form="travelForm" class="btn btn-primary" id="saveBtn">
|
||||
<i class="fas fa-save me-2"></i>Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="travelForm" action="/admin/travel/update" method="POST" class="needs-validation" novalidate>
|
||||
<input type="hidden" name="hero" id="heroJson">
|
||||
<input type="hidden" name="page" id="pageJson">
|
||||
<input type="hidden" name="content" id="contentJson">
|
||||
<input type="hidden" name="enableScrollspy" id="enableScrollspyInput">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="card-title mb-0">Hero Section</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-5">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Background Image</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="heroBackgroundImage"
|
||||
value="<%= data.hero?.backgroundImage || '' %>" readonly>
|
||||
<button type="button" class="btn btn-outline-primary btn-upload-image"
|
||||
data-target-input="heroBackgroundImage" data-image-type="travel">
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-text text-muted">Recommended size: 1920x1080px</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<div id="heroImagePreview" style="height: 200px; width: 100%;">
|
||||
<% if (data.hero?.backgroundImage) { %>
|
||||
<img src="<%= data.hero.backgroundImage %>" class="img-thumbnail"
|
||||
style="height: 200px; width: 100%; object-fit: cover;" alt="Background image preview">
|
||||
<% } else { %>
|
||||
<div class="border rounded d-flex align-items-center justify-content-center h-100 bg-light">
|
||||
<span class="text-muted">No image selected</span>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Hero Title</label>
|
||||
<textarea class="form-control" id="heroTitle" rows="2"><%= data.hero?.title || 'Travel Information' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="card-title mb-0">Page Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Page Title</label>
|
||||
<input type="text" class="form-control" id="pageTitle" value="<%= data.page?.title || 'Go and Grow Camp Travel Information' %>">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">As of / Year</label>
|
||||
<input type="text" class="form-control" id="pageYear" value="<%= data.page?.year || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="card-title mb-0">Content Editor</h5>
|
||||
<p class="text-muted mb-0 small">Write content using the blog editor</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="editorjs" class="border rounded p-3" style="min-height: 500px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="card-title mb-0">SEO Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Meta Title</label>
|
||||
<input type="text" class="form-control" id="metadataTitle" value="<%= data.page?.metadata?.title || '' %>">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Meta Description</label>
|
||||
<textarea class="form-control" id="metadataDescription" rows="3"><%= data.page?.metadata?.description || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="card-title mb-0">Page Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="enableScrollspy" <%= data.enableScrollspy ? 'checked' : '' %>>
|
||||
<label class="form-check-label" for="enableScrollspy">Enable Scrollspy Navigation</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="card-title mb-0">Content Tips</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<h6><i class="fas fa-lightbulb me-2"></i>Tips for Terms & Conditions:</h6>
|
||||
<ul class="mb-0 small">
|
||||
<li>Use <strong>Header 2</strong> for main sections</li>
|
||||
<li>Use <strong>Header 3</strong> for subsections</li>
|
||||
<li>Use <strong>Lists</strong> for terms items</li>
|
||||
<li>Use <strong>Conclusion</strong> tool for important notes</li>
|
||||
<li>Use <strong>Quote</strong> for legal references</li>
|
||||
</ul>
|
||||
<hr class="my-2">
|
||||
<h6><i class="fas fa-keyboard me-2"></i>Keyboard Shortcuts:</h6>
|
||||
<ul class="mb-0 small">
|
||||
<li><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>H</kbd>: Convert list item to header</li>
|
||||
<li><kbd>Tab</kbd> in list: Indent item</li>
|
||||
<li><kbd>Backspace</kbd> at start: Exit list</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="previewModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Travel Information Preview</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<iframe id="previewFrame" style="width: 100%; height: 600px; border: none;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="file" id="directImageUpload" style="display: none;" accept="image/*">
|
||||
<input type="hidden" id="currentImageType">
|
||||
<input type="hidden" id="currentTargetInput">
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/editorjs@2.28.2"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/header@2.7.0"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/paragraph@2.11.3"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/list@1.8.0"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/image@2.8.1"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/quote@2.5.0"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/marker@1.3.0"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/embed@2.5.3"></script>
|
||||
|
||||
<script type="module">
|
||||
import BlogEditor from '/js/editor.js';
|
||||
|
||||
// Logic xử lý lọc dữ liệu để tránh duplicate video và xóa paragraph rỗng
|
||||
class TravelContentManager {
|
||||
cleanEditorData(editorData) {
|
||||
const cleanedBlocks = [];
|
||||
const seenVideoIds = new Set();
|
||||
const youtubeRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=|embed\/|v\/|shorts\/)?([A-Za-z0-9_-]{11})/;
|
||||
|
||||
(editorData.blocks || []).forEach(block => {
|
||||
if (!block) return;
|
||||
|
||||
// 1. Xử lý Video Embed (Deduplication)
|
||||
if (block.type === 'embed') {
|
||||
const bd = block.data || {};
|
||||
const source = bd.source || bd.embed || '';
|
||||
const match = source.match(youtubeRegex);
|
||||
const vid = bd.videoId || (match ? match[1] : null);
|
||||
|
||||
if (vid) {
|
||||
if (seenVideoIds.has(vid)) return; // Bỏ qua nếu đã có video này
|
||||
seenVideoIds.add(vid);
|
||||
}
|
||||
cleanedBlocks.push(block);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Xử lý Paragraph (Xóa dòng trống hoặc dòng chỉ chứa link đã embed)
|
||||
if (block.type === 'paragraph') {
|
||||
const text = (block.data?.text || '').toString().trim();
|
||||
if (text === '') return; // Xóa paragraph rỗng
|
||||
|
||||
const match = text.match(youtubeRegex);
|
||||
if (match && match[1]) {
|
||||
// Nếu paragraph chỉ chứa link YouTube, và ta sẽ có/đã có block embed cho nó, thì bỏ qua paragraph
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
cleanedBlocks.push(block);
|
||||
});
|
||||
|
||||
return { ...editorData, blocks: cleanedBlocks };
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
let blogEditorInstance = null;
|
||||
const travelData = <%- JSON.stringify(data) %>;
|
||||
const initialContent = travelData?.content || { blocks: [] };
|
||||
|
||||
try {
|
||||
blogEditorInstance = new BlogEditor('editorjs', initialContent, 'travel');
|
||||
window.blogEditorInstance = blogEditorInstance;
|
||||
} catch (error) {
|
||||
console.error('Error initializing BlogEditor:', error);
|
||||
}
|
||||
|
||||
const form = document.getElementById('travelForm');
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving...';
|
||||
|
||||
try {
|
||||
// Lấy dữ liệu thô
|
||||
const rawData = await blogEditorInstance.save();
|
||||
|
||||
// Làm sạch dữ liệu trước khi đóng gói JSON
|
||||
const travelManager = new TravelContentManager();
|
||||
const cleanedData = travelManager.cleanEditorData(rawData);
|
||||
|
||||
const heroData = {
|
||||
title: document.getElementById('heroTitle').value.trim(),
|
||||
backgroundImage: document.getElementById('heroBackgroundImage').value.trim(),
|
||||
};
|
||||
|
||||
const pageData = {
|
||||
title: document.getElementById('pageTitle').value.trim(),
|
||||
year: document.getElementById('pageYear')?.value.trim(),
|
||||
metadata: {
|
||||
title: document.getElementById('metadataTitle').value.trim(),
|
||||
description: document.getElementById('metadataDescription').value.trim(),
|
||||
},
|
||||
};
|
||||
|
||||
document.getElementById('heroJson').value = JSON.stringify(heroData);
|
||||
document.getElementById('pageJson').value = JSON.stringify(pageData);
|
||||
document.getElementById('contentJson').value = JSON.stringify(cleanedData);
|
||||
document.getElementById('enableScrollspyInput').value = document.getElementById('enableScrollspy').checked;
|
||||
|
||||
form.submit();
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
|
||||
}
|
||||
});
|
||||
|
||||
// Preview
|
||||
const previewBtn = document.querySelector('.preview-btn');
|
||||
const previewModal = new bootstrap.Modal(document.getElementById('previewModal'));
|
||||
previewBtn.addEventListener('click', async function () {
|
||||
try {
|
||||
const editorData = await blogEditorInstance.save();
|
||||
const travelManager = new TravelContentManager();
|
||||
const cleanedData = travelManager.cleanEditorData(editorData);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('content', JSON.stringify(cleanedData));
|
||||
formData.append('heroTitle', document.getElementById('heroTitle').value);
|
||||
formData.append('heroBackgroundImage', document.getElementById('heroBackgroundImage').value);
|
||||
formData.append('pageTitle', document.getElementById('pageTitle').value);
|
||||
formData.append('pageYear', document.getElementById('pageYear')?.value || '');
|
||||
|
||||
const response = await fetch('/admin/travel/preview', { method: 'POST', body: formData });
|
||||
const html = await response.text();
|
||||
const previewFrame = document.getElementById('previewFrame');
|
||||
const blob = new Blob([html], { type: 'text/html' });
|
||||
previewFrame.src = URL.createObjectURL(blob);
|
||||
previewModal.show();
|
||||
} catch (error) {
|
||||
console.error('Preview error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Image Upload Helpers
|
||||
document.querySelectorAll('.btn-upload-image').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
document.getElementById('currentImageType').value = this.getAttribute('data-image-type');
|
||||
document.getElementById('currentTargetInput').value = this.getAttribute('data-target-input');
|
||||
document.getElementById('directImageUpload').click();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('directImageUpload').addEventListener('change', async function () {
|
||||
if (!this.files || !this.files[0]) return;
|
||||
const formData = new FormData();
|
||||
formData.append('image', this.files[0]);
|
||||
const imageType = document.getElementById('currentImageType').value;
|
||||
const targetInput = document.getElementById('currentTargetInput').value;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/admin/upload/image?imageType=${imageType}`, { method: 'POST', body: formData });
|
||||
const result = await resp.json();
|
||||
if (result.success) {
|
||||
document.getElementById(targetInput).value = result.path;
|
||||
if (targetInput === 'heroBackgroundImage') updateHeroImagePreview(result.path);
|
||||
showToast('Uploaded successfully', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Upload failed', 'danger');
|
||||
}
|
||||
});
|
||||
|
||||
function updateHeroImagePreview(imageUrl) {
|
||||
document.getElementById('heroImagePreview').innerHTML = `<img src="${imageUrl}" class="img-thumbnail" style="height: 200px; width: 100%; object-fit: cover;">`;
|
||||
}
|
||||
|
||||
function showToast(message, type) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast align-items-center text-bg-${type} border-0 position-fixed bottom-0 end-0 m-3`;
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body">${message}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
document.body.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { delay: 3000 }).show();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -105,7 +105,7 @@
|
||||
<div class="card-body p-4 p-lg-5">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-lg-8 mx-auto text-center">
|
||||
<h2 class="fw-bold mb-4 text-white">Start Using CMS.HAILearning Today</h2>
|
||||
<h2 class="fw-bold mb-4 text-white">Start Using CMS.LAMS Today</h2>
|
||||
<p class="lead mb-4 text-white-50">Experience simple and effective API management system</p>
|
||||
<a href="/admin/dashboard" class="btn btn-lg"
|
||||
style="background-color: white; color: var(--primary-color); font-weight: 600; border-radius: 10px; padding: 12px 30px;">
|
||||
|
||||
@@ -1049,16 +1049,9 @@
|
||||
<li><a class="dropdown-item <%= currentPath === '/admin/policies' ? 'active' : '' %>" href="/admin/policies">Policies</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= currentPath === '/admin/service' ? 'active' : '' %>"
|
||||
href="/admin/service">Services</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= currentPath === '/admin/blog' ? 'active' : '' %>" href="/admin/blog">Blog</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= currentPath === '/admin/visa' ? 'active' : '' %>" href="/admin/visa">Visa</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= currentPath === '/admin/contact' ? 'active' : '' %>" href="/admin/contact">Contact
|
||||
Us</a>
|
||||
@@ -1067,14 +1060,6 @@
|
||||
<a class="nav-link <%= currentPath === '/admin/student-support' ? 'active' : '' %>"
|
||||
href="/admin/student-support">Student Support</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" href="/admin/programme">Programmes</a>
|
||||
</li>
|
||||
|
||||