forked from UKSOURCE/cms.lams
Merge branch 'develop' into fea/dat-20042026-CMS-Partnerships-Accreditation-History-Admissions-Policies
This commit is contained in:
@@ -37,6 +37,12 @@ const AUDIT_ACTIONS = Object.freeze({
|
|||||||
// Contact
|
// Contact
|
||||||
UPDATE_CONTACT: "UPDATE_CONTACT",
|
UPDATE_CONTACT: "UPDATE_CONTACT",
|
||||||
|
|
||||||
|
// Student Support (LAMS static page)
|
||||||
|
UPDATE_STUDENT_SUPPORT: "UPDATE_STUDENT_SUPPORT",
|
||||||
|
|
||||||
|
// Request Info (LAMS static page)
|
||||||
|
UPDATE_REQUEST_INFO: "UPDATE_REQUEST_INFO",
|
||||||
|
|
||||||
// Pricing
|
// Pricing
|
||||||
UPDATE_PRICING: "UPDATE_PRICING",
|
UPDATE_PRICING: "UPDATE_PRICING",
|
||||||
|
|
||||||
|
|||||||
@@ -1,110 +1,89 @@
|
|||||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||||
|
const { ICON_OPTIONS, normalizeIconClass } = require("../utils/iconOptions");
|
||||||
const Contact = require("../models/contact");
|
const Contact = require("../models/contact");
|
||||||
const ContactSubmission = require("../models/contactSubmission");
|
const ContactSubmission = require("../models/contactSubmission");
|
||||||
const writeAuditLog = require("../audit/writeAuditLog");
|
const writeAuditLog = require("../audit/writeAuditLog");
|
||||||
const diffObject = require("../audit/diffObject");
|
const diffObject = require("../audit/diffObject");
|
||||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||||
|
|
||||||
// Get contact data from MongoDB
|
const SLUG = "contact";
|
||||||
const getContactData = async () => {
|
|
||||||
const contact = await Contact.findOne({ name: "default" });
|
|
||||||
if (!contact) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return contact.toObject();
|
|
||||||
};
|
|
||||||
|
|
||||||
// API to get contact data
|
function parseJsonField(raw) {
|
||||||
|
if (raw == null || raw === "") return null;
|
||||||
|
if (typeof raw === "object") return raw;
|
||||||
|
try { return JSON.parse(raw); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDocument() {
|
||||||
|
return Contact.findOne({ slug: SLUG }).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/contact — public payload for frontend LAMS.
|
||||||
|
*/
|
||||||
exports.api = async (req, res) => {
|
exports.api = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const contact = await getContactData();
|
const doc = await getDocument();
|
||||||
if (!contact) {
|
if (!doc) {
|
||||||
return res.status(404).json({ error: "Contact data not found" });
|
return res.status(404).json({ error: "Contact data not found" });
|
||||||
}
|
}
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||||
const processedData = addBaseUrlToImages(contact, baseUrl);
|
const body = {
|
||||||
res.json(processedData);
|
metadata: doc.metadata || {},
|
||||||
|
hero: doc.hero || {},
|
||||||
|
infoCards: doc.infoCards || {},
|
||||||
|
form: doc.form || {},
|
||||||
|
faq: doc.faq || {},
|
||||||
|
cta: doc.cta || {},
|
||||||
|
};
|
||||||
|
res.json(addBaseUrlToImages(body, baseUrl));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("API Error:", err);
|
console.error("contact.api:", err);
|
||||||
res.status(500).json({ error: "Error loading contact data" });
|
res.status(500).json({ error: "Error loading contact data" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// API để lấy toàn bộ contact data
|
// Legacy alias
|
||||||
exports.getContactData = async (req, res) => {
|
exports.getContactData = exports.api;
|
||||||
try {
|
|
||||||
const contactData = await getContactData();
|
|
||||||
if (!contactData) {
|
|
||||||
return res.status(404).json({ error: "Contact data not found" });
|
|
||||||
}
|
|
||||||
res.json(contactData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error getting contact data:", error);
|
|
||||||
res.status(500).json({ error: "Error loading contact data" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render admin view
|
/**
|
||||||
|
* GET /admin/contact — Admin view.
|
||||||
|
*/
|
||||||
exports.index = async (req, res) => {
|
exports.index = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const data = (await getContactData()) || {
|
const doc = await getDocument();
|
||||||
hero: {
|
const data = doc
|
||||||
title: "Contact Us",
|
? {
|
||||||
backgroundImage: "",
|
metadata: doc.metadata || {},
|
||||||
overlayColor: "rgba(0, 0, 0, 0)",
|
hero: doc.hero || {},
|
||||||
sectionClass: "",
|
infoCards: doc.infoCards || {},
|
||||||
titleClass: "",
|
form: doc.form || {},
|
||||||
enableScrollspy: false,
|
faq: doc.faq || {},
|
||||||
backgroundPosition: "center",
|
cta: doc.cta || {},
|
||||||
},
|
}
|
||||||
contactCards: [],
|
: {
|
||||||
map: {
|
metadata: {},
|
||||||
coordinates: { lat: 0, lng: 0 },
|
hero: { badge: "", titleMain: "", titleHighlight: "", description: "" },
|
||||||
zoom: 15,
|
infoCards: { contactInfo: { title: "", channels: [] }, supportHours: { title: "", hours: [], footer: {} } },
|
||||||
location: "",
|
form: { heading: "", description: "", fields: {}, submitLabel: "Send Message", successMessage: "", errorMessage: "" },
|
||||||
markerTitle: "",
|
faq: { title: "", subtitle: "", items: [] },
|
||||||
embedUrl: "",
|
cta: { title: "", description: "", primaryButton: {}, secondaryButton: {} },
|
||||||
tileLayer: {
|
|
||||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
||||||
attribution: "",
|
|
||||||
maxZoom: 18,
|
|
||||||
minZoom: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
sectionLabel: "",
|
|
||||||
heading: "",
|
|
||||||
description: "",
|
|
||||||
fields: [],
|
|
||||||
submitButton: {
|
|
||||||
text: "Send Message",
|
|
||||||
icon: "fa-solid fa-arrow-right",
|
|
||||||
buttonClass: "theme-btn style-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { startDate, endDate } = req.query;
|
const { startDate, endDate } = req.query;
|
||||||
const query = {};
|
const query = {};
|
||||||
|
|
||||||
if (startDate || endDate) {
|
if (startDate || endDate) {
|
||||||
query.createdAt = {};
|
query.createdAt = {};
|
||||||
if (startDate) {
|
if (startDate) query.createdAt.$gte = new Date(startDate);
|
||||||
query.createdAt.$gte = new Date(startDate);
|
|
||||||
}
|
|
||||||
if (endDate) {
|
if (endDate) {
|
||||||
// Set end date to end of day
|
|
||||||
const end = new Date(endDate);
|
const end = new Date(endDate);
|
||||||
end.setHours(23, 59, 59, 999);
|
end.setHours(23, 59, 59, 999);
|
||||||
query.createdAt.$lte = end;
|
query.createdAt.$lte = end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const submissions = await ContactSubmission.find(query).sort({ createdAt: -1 }).limit(50);
|
||||||
const submissions = await ContactSubmission.find(query)
|
const frontendUrl = process.env.FRONTEND_URL || "";
|
||||||
.sort({ createdAt: -1 })
|
|
||||||
.limit(50);
|
|
||||||
const frontendUrl = process.env.FRONTEND_URL;
|
|
||||||
|
|
||||||
res.render("admin/contact/index", {
|
res.render("admin/contact/index", {
|
||||||
title: "Contact Management",
|
title: "Contact Management",
|
||||||
@@ -114,124 +93,61 @@ exports.index = async (req, res) => {
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
frontendUrl,
|
frontendUrl,
|
||||||
|
iconOptions: ICON_OPTIONS,
|
||||||
currentPath: req.path,
|
currentPath: req.path,
|
||||||
user: req.session.user,
|
user: req.session.user,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in contact index:", error);
|
console.error("contact.index:", error);
|
||||||
req.flash("error_msg", "An error occurred while loading the page");
|
req.flash("error_msg", "An error occurred while loading the page");
|
||||||
res.redirect("/admin/dashboard");
|
res.redirect("/admin/dashboard");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cập nhật dữ liệu contact
|
/**
|
||||||
|
* POST /admin/contact/update — Save & audit.
|
||||||
|
*/
|
||||||
exports.update = async (req, res) => {
|
exports.update = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { hero, contactCards, map, form } = req.body;
|
const metadata = parseJsonField(req.body.metadata);
|
||||||
|
const hero = parseJsonField(req.body.hero);
|
||||||
|
const infoCards = parseJsonField(req.body.infoCards);
|
||||||
|
const form = parseJsonField(req.body.form);
|
||||||
|
const faq = parseJsonField(req.body.faq);
|
||||||
|
const cta = parseJsonField(req.body.cta);
|
||||||
|
|
||||||
// Parse JSON strings nếu cần
|
if (!metadata || !hero || !infoCards || !form || !faq || !cta) {
|
||||||
const parseJson = (data) => {
|
req.flash("error_msg", "Invalid form payload. Please try again.");
|
||||||
if (!data) return null;
|
return res.redirect("/admin/contact");
|
||||||
if (typeof data === "string") {
|
|
||||||
try {
|
|
||||||
return JSON.parse(data);
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const heroData = parseJson(hero);
|
// Normalize channel icons against whitelist
|
||||||
const contactCardsData = parseJson(contactCards);
|
if (infoCards?.contactInfo?.channels?.length) {
|
||||||
const mapData = parseJson(map);
|
infoCards.contactInfo.channels = infoCards.contactInfo.channels.map((ch) => ({
|
||||||
const formData = parseJson(form);
|
...ch,
|
||||||
|
icon: normalizeIconClass(ch.icon),
|
||||||
// Tìm hoặc tạo contact
|
|
||||||
let contact = await Contact.findOne({ name: "default" });
|
|
||||||
|
|
||||||
// ✅ Capture BEFORE state
|
|
||||||
const beforeData = contact
|
|
||||||
? JSON.parse(JSON.stringify(contact.toObject()))
|
|
||||||
: {};
|
|
||||||
|
|
||||||
if (!contact) {
|
|
||||||
// Tạo mới với default values
|
|
||||||
contact = new Contact({
|
|
||||||
name: "default",
|
|
||||||
hero: heroData || {
|
|
||||||
title: "Contact Us",
|
|
||||||
backgroundImage: "",
|
|
||||||
overlayColor: "rgba(0, 0, 0, 0)",
|
|
||||||
sectionClass: "",
|
|
||||||
titleClass: "",
|
|
||||||
enableScrollspy: false,
|
|
||||||
backgroundPosition: "center",
|
|
||||||
},
|
|
||||||
contactCards: (contactCardsData || []).map((card) => ({
|
|
||||||
...card,
|
|
||||||
iconType: card.iconType || "",
|
|
||||||
iconSource:
|
|
||||||
card.iconSource ||
|
|
||||||
(card.iconType && card.iconType.startsWith("/uploads/")
|
|
||||||
? "image"
|
|
||||||
: "fontawesome"),
|
|
||||||
})),
|
|
||||||
map: mapData || {
|
|
||||||
coordinates: { lat: 0, lng: 0 },
|
|
||||||
zoom: 15,
|
|
||||||
location: "",
|
|
||||||
markerTitle: "",
|
|
||||||
embedUrl: "",
|
|
||||||
tileLayer: {
|
|
||||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
||||||
attribution: "",
|
|
||||||
maxZoom: 18,
|
|
||||||
minZoom: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
form: formData || {
|
|
||||||
sectionLabel: "",
|
|
||||||
heading: "",
|
|
||||||
description: "",
|
|
||||||
fields: [],
|
|
||||||
submitButton: {
|
|
||||||
text: "Send Message",
|
|
||||||
icon: "fa-solid fa-arrow-right",
|
|
||||||
buttonClass: "theme-btn style-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Cập nhật dữ liệu
|
|
||||||
if (heroData) contact.hero = heroData;
|
|
||||||
if (contactCardsData && Array.isArray(contactCardsData)) {
|
|
||||||
// Đảm bảo mỗi card có iconType và iconSource
|
|
||||||
contact.contactCards = contactCardsData.map((card) => ({
|
|
||||||
...card,
|
|
||||||
iconType: card.iconType || "",
|
|
||||||
iconSource:
|
|
||||||
card.iconSource ||
|
|
||||||
(card.iconType && card.iconType.startsWith("/uploads/")
|
|
||||||
? "image"
|
|
||||||
: "fontawesome"),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (mapData) contact.map = mapData;
|
|
||||||
if (formData) contact.form = formData;
|
const payload = { metadata, hero, infoCards, form, faq, cta };
|
||||||
|
|
||||||
|
let doc = await Contact.findOne({ slug: SLUG });
|
||||||
|
const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {};
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
doc = new Contact({ slug: SLUG, ...payload });
|
||||||
|
} else {
|
||||||
|
doc.set(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
await contact.save();
|
await doc.save();
|
||||||
|
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||||
|
|
||||||
// ✅ Capture AFTER state
|
|
||||||
const afterData = JSON.parse(JSON.stringify(contact.toObject()));
|
|
||||||
|
|
||||||
// ✅ AUDIT LOGGING - Contact Updated
|
|
||||||
const changes = diffObject(beforeData, afterData);
|
const changes = diffObject(beforeData, afterData);
|
||||||
if (changes.length > 0) {
|
if (changes.length > 0) {
|
||||||
await writeAuditLog({
|
await writeAuditLog({
|
||||||
model: "Contact",
|
model: "Contact",
|
||||||
documentId: contact._id,
|
documentId: doc._id,
|
||||||
action: AUDIT_ACTIONS.UPDATE_CONTACT,
|
action: AUDIT_ACTIONS.UPDATE_CONTACT,
|
||||||
before: beforeData,
|
before: beforeData,
|
||||||
after: afterData,
|
after: afterData,
|
||||||
@@ -243,18 +159,20 @@ exports.update = async (req, res) => {
|
|||||||
req.flash("success_msg", "Contact updated successfully");
|
req.flash("success_msg", "Contact updated successfully");
|
||||||
res.redirect("/admin/contact");
|
res.redirect("/admin/contact");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error updating contact:", err);
|
console.error("contact.update:", err);
|
||||||
req.flash("error_msg", err.message || "Error updating contact");
|
req.flash("error_msg", err.message || "Error updating contact");
|
||||||
res.redirect("/admin/contact");
|
res.redirect("/admin/contact");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// API để submit contact form (từ frontend)
|
// ─────────────────────────────────────────────
|
||||||
|
// Form Submissions (unchanged)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
exports.submitForm = async (req, res) => {
|
exports.submitForm = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, email, phone, address, date, message } = req.body;
|
const { name, email, phone, address, date, message } = req.body;
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!name || !email) {
|
if (!name || !email) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -262,7 +180,6 @@ exports.submitForm = async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new submission
|
|
||||||
const submission = new ContactSubmission({
|
const submission = new ContactSubmission({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
email: email.trim().toLowerCase(),
|
email: email.trim().toLowerCase(),
|
||||||
@@ -288,13 +205,9 @@ exports.submitForm = async (req, res) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error submitting contact form:", err);
|
console.error("Error submitting contact form:", err);
|
||||||
|
|
||||||
// Handle validation errors
|
|
||||||
if (err.name === "ValidationError") {
|
if (err.name === "ValidationError") {
|
||||||
const errors = Object.values(err.errors).map((e) => e.message);
|
const errors = Object.values(err.errors).map((e) => e.message);
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, error: errors.join(", ") });
|
||||||
success: false,
|
|
||||||
error: errors.join(", "),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -304,7 +217,6 @@ exports.submitForm = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// API để lấy danh sách submissions (cho admin)
|
|
||||||
exports.getSubmissions = async (req, res) => {
|
exports.getSubmissions = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { status, page = 1, limit = 20 } = req.query;
|
const { status, page = 1, limit = 20 } = req.query;
|
||||||
@@ -336,14 +248,10 @@ exports.getSubmissions = async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error getting submissions:", err);
|
console.error("Error getting submissions:", err);
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, error: "Error loading submissions" });
|
||||||
success: false,
|
|
||||||
error: "Error loading submissions",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// API để cập nhật status của submission
|
|
||||||
exports.updateSubmissionStatus = async (req, res) => {
|
exports.updateSubmissionStatus = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -351,10 +259,7 @@ exports.updateSubmissionStatus = async (req, res) => {
|
|||||||
|
|
||||||
const validStatuses = ["pending", "read", "replied", "archived"];
|
const validStatuses = ["pending", "read", "replied", "archived"];
|
||||||
if (!validStatuses.includes(status)) {
|
if (!validStatuses.includes(status)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, error: "Invalid status" });
|
||||||
success: false,
|
|
||||||
error: "Invalid status",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData = { status };
|
const updateData = { status };
|
||||||
@@ -364,25 +269,16 @@ exports.updateSubmissionStatus = async (req, res) => {
|
|||||||
const submission = await ContactSubmission.findByIdAndUpdate(
|
const submission = await ContactSubmission.findByIdAndUpdate(
|
||||||
id,
|
id,
|
||||||
updateData,
|
updateData,
|
||||||
{ new: true },
|
{ new: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!submission) {
|
if (!submission) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({ success: false, error: "Submission not found" });
|
||||||
success: false,
|
|
||||||
error: "Submission not found",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({ success: true, data: submission });
|
||||||
success: true,
|
|
||||||
data: submission,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error updating submission:", err);
|
console.error("Error updating submission:", err);
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, error: "Error updating submission" });
|
||||||
success: false,
|
|
||||||
error: "Error updating submission",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||||
|
const { ICON_OPTIONS, normalizeIconClass } = require("../utils/iconOptions");
|
||||||
|
const Programme = require("../models/programme");
|
||||||
|
const writeAuditLog = require("../audit/writeAuditLog");
|
||||||
|
const diffObject = require("../audit/diffObject");
|
||||||
|
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize all icon fields in the payload against the ICON_OPTIONS whitelist.
|
||||||
|
*/
|
||||||
|
function sanitizeProgrammeIcons(payload) {
|
||||||
|
if (!payload) return payload;
|
||||||
|
if (Array.isArray(payload.coreCourses)) {
|
||||||
|
payload.coreCourses = payload.coreCourses.map((c) => ({
|
||||||
|
...c,
|
||||||
|
icon: normalizeIconClass(c.icon),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload.outcomes)) {
|
||||||
|
payload.outcomes = payload.outcomes.map((o) => ({
|
||||||
|
...o,
|
||||||
|
icon: normalizeIconClass(o.icon),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely parse a JSON field that may arrive as a string or already-parsed object.
|
||||||
|
*/
|
||||||
|
function parseJsonField(raw) {
|
||||||
|
if (raw == null || raw === "") return null;
|
||||||
|
if (typeof raw === "object") return raw;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ADMIN ROUTES ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/programme
|
||||||
|
* List all programmes.
|
||||||
|
*/
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await Programme.find().sort({ updatedAt: -1 }).lean();
|
||||||
|
res.render("admin/programme/index", {
|
||||||
|
title: "Programmes Management",
|
||||||
|
data,
|
||||||
|
frontendUrl: process.env.FRONTEND_URL || "",
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("programme.index:", err);
|
||||||
|
req.flash("error_msg", "Error loading programmes");
|
||||||
|
res.redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/programme/edit/:id
|
||||||
|
* Show edit form. Pass id = "new" to create.
|
||||||
|
*/
|
||||||
|
exports.edit = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
let programme;
|
||||||
|
|
||||||
|
if (id === "new") {
|
||||||
|
programme = {
|
||||||
|
_id: null,
|
||||||
|
id: "",
|
||||||
|
level: "",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
duration: "",
|
||||||
|
cost: "",
|
||||||
|
selected: false,
|
||||||
|
link: "",
|
||||||
|
shortName: "",
|
||||||
|
detailBadge: "",
|
||||||
|
detailTitle: "",
|
||||||
|
detailDescription: "",
|
||||||
|
heroImage: "",
|
||||||
|
overview: "",
|
||||||
|
credits: 120,
|
||||||
|
format: "100% Online",
|
||||||
|
nextStartDate: "",
|
||||||
|
monthlyCost: "",
|
||||||
|
perCourseCost: "",
|
||||||
|
coreCourses: [],
|
||||||
|
electives: [],
|
||||||
|
outcomes: [],
|
||||||
|
faqs: [],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
programme = await Programme.findById(id).lean();
|
||||||
|
if (!programme) {
|
||||||
|
req.flash("error_msg", "Programme not found");
|
||||||
|
return res.redirect("/admin/programme");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render("admin/programme/edit", {
|
||||||
|
title: id === "new" ? "Add New Programme" : `Edit: ${programme.title}`,
|
||||||
|
programme,
|
||||||
|
iconOptions: ICON_OPTIONS,
|
||||||
|
frontendUrl: process.env.FRONTEND_URL || "",
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("programme.edit:", err);
|
||||||
|
req.flash("error_msg", "Error loading programme");
|
||||||
|
res.redirect("/admin/programme");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/programme/update/:id
|
||||||
|
* Save a new or existing programme.
|
||||||
|
*/
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const body = req.body;
|
||||||
|
|
||||||
|
const coreCourses = parseJsonField(body.coreCourses) || [];
|
||||||
|
const outcomes = parseJsonField(body.outcomes) || [];
|
||||||
|
const faqs = parseJsonField(body.faqs) || [];
|
||||||
|
const electivesRaw = parseJsonField(body.electives) || [];
|
||||||
|
|
||||||
|
// Electives arrive as [{value: "..."}, ...] from the form
|
||||||
|
const electives = electivesRaw.map((e) =>
|
||||||
|
typeof e === "object" ? (e.value || "") : String(e)
|
||||||
|
);
|
||||||
|
|
||||||
|
let payload = {
|
||||||
|
id: (body.id || "").trim(),
|
||||||
|
level: (body.level || "").trim(),
|
||||||
|
title: (body.title || "").trim(),
|
||||||
|
description: (body.description || "").trim(),
|
||||||
|
duration: (body.duration || "").trim(),
|
||||||
|
cost: (body.cost || "").trim(),
|
||||||
|
selected: body.selected === "true" || body.selected === true,
|
||||||
|
link: (body.link || "").trim(),
|
||||||
|
shortName: (body.shortName || "").trim(),
|
||||||
|
detailBadge: (body.detailBadge || "").trim(),
|
||||||
|
detailTitle: (body.detailTitle || "").trim(),
|
||||||
|
detailDescription: (body.detailDescription || "").trim(),
|
||||||
|
overview: (body.overview || "").trim(),
|
||||||
|
credits: parseInt(body.credits, 10) || 0,
|
||||||
|
format: (body.format || "").trim(),
|
||||||
|
nextStartDate: (body.nextStartDate || "").trim(),
|
||||||
|
monthlyCost: (body.monthlyCost || "").trim(),
|
||||||
|
perCourseCost: (body.perCourseCost || "").trim(),
|
||||||
|
coreCourses,
|
||||||
|
electives,
|
||||||
|
outcomes,
|
||||||
|
faqs,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hero image: file upload takes priority over the text URL field
|
||||||
|
if (req.file) {
|
||||||
|
payload.heroImage = `/uploads/programmes/${req.file.filename}`;
|
||||||
|
} else if (body.heroImageUrl) {
|
||||||
|
payload.heroImage = body.heroImageUrl.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If link is empty, generate from id
|
||||||
|
if (!payload.link && payload.id) {
|
||||||
|
payload.link = `/programmes/${payload.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = sanitizeProgrammeIcons(payload);
|
||||||
|
|
||||||
|
if (id === "new") {
|
||||||
|
if (!payload.id) {
|
||||||
|
req.flash("error_msg", "Programme ID / Code is required");
|
||||||
|
return res.redirect("/admin/programme/edit/new");
|
||||||
|
}
|
||||||
|
const doc = await Programme.create(payload);
|
||||||
|
await writeAuditLog({
|
||||||
|
model: "Programme",
|
||||||
|
documentId: doc._id,
|
||||||
|
action: AUDIT_ACTIONS.CREATE,
|
||||||
|
before: {},
|
||||||
|
after: doc.toObject(),
|
||||||
|
changes: [],
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
req.flash("success_msg", `Programme "${payload.title}" created successfully`);
|
||||||
|
} else {
|
||||||
|
const existing = await Programme.findById(id);
|
||||||
|
if (!existing) {
|
||||||
|
req.flash("error_msg", "Programme not found");
|
||||||
|
return res.redirect("/admin/programme");
|
||||||
|
}
|
||||||
|
const beforeData = JSON.parse(JSON.stringify(existing.toObject()));
|
||||||
|
existing.set(payload);
|
||||||
|
await existing.save();
|
||||||
|
const afterData = JSON.parse(JSON.stringify(existing.toObject()));
|
||||||
|
|
||||||
|
const changes = diffObject(beforeData, afterData);
|
||||||
|
if (changes.length > 0) {
|
||||||
|
await writeAuditLog({
|
||||||
|
model: "Programme",
|
||||||
|
documentId: existing._id,
|
||||||
|
action: AUDIT_ACTIONS.UPDATE,
|
||||||
|
before: beforeData,
|
||||||
|
after: afterData,
|
||||||
|
changes,
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
req.flash("success_msg", `Programme "${existing.title}" updated successfully`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect("/admin/programme");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("programme.update:", err);
|
||||||
|
req.flash("error_msg", `Update error: ${err.message}`);
|
||||||
|
res.redirect(id === "new" ? "/admin/programme/edit/new" : `/admin/programme/edit/${id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/programme/delete/:id
|
||||||
|
* Delete a programme by MongoDB _id.
|
||||||
|
*/
|
||||||
|
exports.delete = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const doc = await Programme.findByIdAndDelete(req.params.id);
|
||||||
|
if (doc) {
|
||||||
|
await writeAuditLog({
|
||||||
|
model: "Programme",
|
||||||
|
documentId: req.params.id,
|
||||||
|
action: AUDIT_ACTIONS.DELETE,
|
||||||
|
before: doc.toObject(),
|
||||||
|
after: {},
|
||||||
|
changes: [],
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
req.flash("success_msg", "Programme deleted successfully");
|
||||||
|
res.redirect("/admin/programme");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("programme.delete:", err);
|
||||||
|
req.flash("error_msg", "Error deleting programme");
|
||||||
|
res.redirect("/admin/programme");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── PUBLIC API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/programmes
|
||||||
|
* Return all programmes as JSON.
|
||||||
|
*/
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await Programme.find().sort({ updatedAt: -1 }).lean();
|
||||||
|
const baseUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||||
|
res.json(addBaseUrlToImages(data, baseUrl));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("programme.api:", err);
|
||||||
|
res.status(500).json({ error: "Error loading programmes" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/programmes/:id
|
||||||
|
* Return a single programme matched by the slug `id` field.
|
||||||
|
*/
|
||||||
|
exports.apiDetail = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await Programme.findOne({ id: req.params.id }).lean();
|
||||||
|
if (!data) return res.status(404).json({ error: "Programme not found" });
|
||||||
|
const baseUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||||
|
res.json(addBaseUrlToImages(data, baseUrl));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("programme.apiDetail:", err);
|
||||||
|
res.status(500).json({ error: "Error loading programme" });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||||
|
const { normalizeIconClass } = require("../utils/iconOptions");
|
||||||
|
const RequestInfo = require("../models/requestInfo");
|
||||||
|
const writeAuditLog = require("../audit/writeAuditLog");
|
||||||
|
const diffObject = require("../audit/diffObject");
|
||||||
|
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||||
|
|
||||||
|
const SLUG = "request-info";
|
||||||
|
|
||||||
|
function emptyPageShape() {
|
||||||
|
return {
|
||||||
|
metadata: { title: "", description: "", keywords: "", ogImage: "" },
|
||||||
|
hero: {
|
||||||
|
badge: "",
|
||||||
|
titleMain: "",
|
||||||
|
titleHighlight: "",
|
||||||
|
description: "",
|
||||||
|
valueProps: [],
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
heading: "",
|
||||||
|
description: "",
|
||||||
|
fields: {
|
||||||
|
firstName: { name: "", label: "", placeholder: "", required: true },
|
||||||
|
lastName: { name: "", label: "", placeholder: "", required: true },
|
||||||
|
email: { name: "", label: "", placeholder: "", required: true },
|
||||||
|
phone: { name: "", label: "", placeholder: "", required: false },
|
||||||
|
program: { name: "", label: "", placeholder: "", required: true, options: [] },
|
||||||
|
timeline: { name: "", label: "", options: [] },
|
||||||
|
},
|
||||||
|
consentText: "",
|
||||||
|
submitLabel: "",
|
||||||
|
footerText: "",
|
||||||
|
successMessage: "",
|
||||||
|
errorMessage: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonField(raw) {
|
||||||
|
if (raw == null || raw === "") return null;
|
||||||
|
if (typeof raw === "object") return raw;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chuẩn hoá icon theo whitelist (valueProps). */
|
||||||
|
function sanitizeIcons(payload) {
|
||||||
|
if (!payload) return payload;
|
||||||
|
if (payload.hero?.valueProps?.length) {
|
||||||
|
payload.hero.valueProps = payload.hero.valueProps.map((vp) => ({
|
||||||
|
...vp,
|
||||||
|
icon: normalizeIconClass(vp.icon),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDocument() {
|
||||||
|
return RequestInfo.findOne({ slug: SLUG }).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/request-info — public payload cho frontend LAMS.
|
||||||
|
* Response: { metadata, hero, form } với ảnh đã gắn full URL.
|
||||||
|
*/
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const doc = await getDocument();
|
||||||
|
if (!doc) {
|
||||||
|
return res.status(404).json({ error: "Request Info content not found" });
|
||||||
|
}
|
||||||
|
const baseUrl =
|
||||||
|
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||||
|
const body = {
|
||||||
|
metadata: doc.metadata || {},
|
||||||
|
hero: doc.hero || {},
|
||||||
|
form: doc.form || {},
|
||||||
|
};
|
||||||
|
res.json(addBaseUrlToImages(body, baseUrl));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("requestInfo.api:", err);
|
||||||
|
res.status(500).json({ error: "Error loading Request Info data" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/request-info — Admin view.
|
||||||
|
*/
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const doc = await getDocument();
|
||||||
|
const data = doc
|
||||||
|
? {
|
||||||
|
metadata: doc.metadata || {},
|
||||||
|
hero: doc.hero || {},
|
||||||
|
form: doc.form || {},
|
||||||
|
}
|
||||||
|
: emptyPageShape();
|
||||||
|
|
||||||
|
const { ICON_OPTIONS } = require("../utils/iconOptions");
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "";
|
||||||
|
|
||||||
|
res.render("admin/request-info/index", {
|
||||||
|
title: "Request Info",
|
||||||
|
layout: "layouts/main",
|
||||||
|
data,
|
||||||
|
iconOptions: ICON_OPTIONS,
|
||||||
|
frontendUrl,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("requestInfo.index:", error);
|
||||||
|
req.flash("error_msg", "Could not load Request Info page");
|
||||||
|
res.redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/request-info/update — Lưu dữ liệu, tạo audit log.
|
||||||
|
*/
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const metadata = parseJsonField(req.body.metadata);
|
||||||
|
const hero = parseJsonField(req.body.hero);
|
||||||
|
const form = parseJsonField(req.body.form);
|
||||||
|
|
||||||
|
if (!metadata || !hero || !form) {
|
||||||
|
req.flash("error_msg", "Invalid form payload. Please try again.");
|
||||||
|
return res.redirect("/admin/request-info");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = sanitizeIcons({ metadata, hero, form });
|
||||||
|
|
||||||
|
let doc = await RequestInfo.findOne({ slug: SLUG });
|
||||||
|
const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {};
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
doc = new RequestInfo({ slug: SLUG, ...payload });
|
||||||
|
} else {
|
||||||
|
doc.set(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
await doc.save();
|
||||||
|
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||||
|
|
||||||
|
const changes = diffObject(beforeData, afterData);
|
||||||
|
if (changes.length > 0) {
|
||||||
|
await writeAuditLog({
|
||||||
|
model: "RequestInfo",
|
||||||
|
documentId: doc._id,
|
||||||
|
action: AUDIT_ACTIONS.UPDATE_REQUEST_INFO,
|
||||||
|
before: beforeData,
|
||||||
|
after: afterData,
|
||||||
|
changes,
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.flash("success_msg", "Request Info updated successfully");
|
||||||
|
res.redirect("/admin/request-info");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("requestInfo.update:", err);
|
||||||
|
req.flash("error_msg", err.message || "Error updating Request Info");
|
||||||
|
res.redirect("/admin/request-info");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||||
|
const { normalizeIconClass } = require("../utils/iconOptions");
|
||||||
|
const StudentSupport = require("../models/studentSupport");
|
||||||
|
const writeAuditLog = require("../audit/writeAuditLog");
|
||||||
|
const diffObject = require("../audit/diffObject");
|
||||||
|
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||||
|
|
||||||
|
const SLUG = "student-support";
|
||||||
|
|
||||||
|
function emptyPageShape() {
|
||||||
|
return {
|
||||||
|
metadata: { title: "", description: "" },
|
||||||
|
hero: {
|
||||||
|
badge: "",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
primaryButton: { label: "", href: "" },
|
||||||
|
secondaryButton: { label: "", href: "" },
|
||||||
|
image: "",
|
||||||
|
imageAlt: "",
|
||||||
|
},
|
||||||
|
directory: { title: "", subtitle: "", services: [] },
|
||||||
|
contactSection: {
|
||||||
|
heading: "",
|
||||||
|
description: "",
|
||||||
|
channels: [],
|
||||||
|
form: {
|
||||||
|
heading: "",
|
||||||
|
fields: {
|
||||||
|
firstName: { label: "", placeholder: "" },
|
||||||
|
lastName: { label: "", placeholder: "" },
|
||||||
|
studentId: { label: "", placeholder: "" },
|
||||||
|
department: { label: "" },
|
||||||
|
message: { label: "", placeholder: "" },
|
||||||
|
},
|
||||||
|
departments: [],
|
||||||
|
submitLabel: "",
|
||||||
|
successMessage: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonField(raw) {
|
||||||
|
if (raw == null || raw === "") return null;
|
||||||
|
if (typeof raw === "object") return raw;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chuẩn hoá mọi field icon theo whitelist (dropdown admin). */
|
||||||
|
function sanitizeIcons(payload) {
|
||||||
|
if (!payload) return payload;
|
||||||
|
if (payload.hero) {
|
||||||
|
/* hero không có icon list */
|
||||||
|
}
|
||||||
|
if (payload.directory?.services?.length) {
|
||||||
|
payload.directory.services = payload.directory.services.map((s) => ({
|
||||||
|
...s,
|
||||||
|
icon: normalizeIconClass(s.icon),
|
||||||
|
hoursIcon: normalizeIconClass(s.hoursIcon),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (payload.contactSection?.channels?.length) {
|
||||||
|
payload.contactSection.channels = payload.contactSection.channels.map((c) => ({
|
||||||
|
...c,
|
||||||
|
icon: normalizeIconClass(c.icon),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDocument() {
|
||||||
|
return StudentSupport.findOne({ slug: SLUG }).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/student-support — public payload giống file JSON frontend (không kèm _id/slug).
|
||||||
|
*/
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const doc = await getDocument();
|
||||||
|
if (!doc) {
|
||||||
|
return res.status(404).json({ error: "Student Support content not found" });
|
||||||
|
}
|
||||||
|
const baseUrl =
|
||||||
|
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||||
|
const body = {
|
||||||
|
metadata: doc.metadata || {},
|
||||||
|
hero: doc.hero || {},
|
||||||
|
directory: doc.directory || { services: [] },
|
||||||
|
contactSection: doc.contactSection || {},
|
||||||
|
};
|
||||||
|
res.json(addBaseUrlToImages(body, baseUrl));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("studentSupport.api:", err);
|
||||||
|
res.status(500).json({ error: "Error loading Student Support data" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const doc = await getDocument();
|
||||||
|
const data = doc
|
||||||
|
? {
|
||||||
|
metadata: doc.metadata || {},
|
||||||
|
hero: doc.hero || {},
|
||||||
|
directory: doc.directory || { services: [] },
|
||||||
|
contactSection: doc.contactSection || {},
|
||||||
|
}
|
||||||
|
: emptyPageShape();
|
||||||
|
|
||||||
|
const { ICON_OPTIONS } = require("../utils/iconOptions");
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "";
|
||||||
|
|
||||||
|
res.render("admin/student-support/index", {
|
||||||
|
title: "Student Support",
|
||||||
|
layout: "layouts/main",
|
||||||
|
data,
|
||||||
|
iconOptions: ICON_OPTIONS,
|
||||||
|
frontendUrl,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("studentSupport.index:", error);
|
||||||
|
req.flash("error_msg", "Could not load Student Support page");
|
||||||
|
res.redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const metadata = parseJsonField(req.body.metadata);
|
||||||
|
const hero = parseJsonField(req.body.hero);
|
||||||
|
const directory = parseJsonField(req.body.directory);
|
||||||
|
const contactSection = parseJsonField(req.body.contactSection);
|
||||||
|
|
||||||
|
if (!metadata || !hero || !directory || !contactSection) {
|
||||||
|
req.flash("error_msg", "Invalid form payload. Please try again.");
|
||||||
|
return res.redirect("/admin/student-support");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = sanitizeIcons({
|
||||||
|
metadata,
|
||||||
|
hero,
|
||||||
|
directory,
|
||||||
|
contactSection,
|
||||||
|
});
|
||||||
|
|
||||||
|
let doc = await StudentSupport.findOne({ slug: SLUG });
|
||||||
|
const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {};
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
doc = new StudentSupport({ slug: SLUG, ...payload });
|
||||||
|
} else {
|
||||||
|
doc.set(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
await doc.save();
|
||||||
|
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||||
|
|
||||||
|
const changes = diffObject(beforeData, afterData);
|
||||||
|
if (changes.length > 0) {
|
||||||
|
await writeAuditLog({
|
||||||
|
model: "StudentSupport",
|
||||||
|
documentId: doc._id,
|
||||||
|
action: AUDIT_ACTIONS.UPDATE_STUDENT_SUPPORT,
|
||||||
|
before: beforeData,
|
||||||
|
after: afterData,
|
||||||
|
changes,
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.flash("success_msg", "Student Support updated successfully");
|
||||||
|
res.redirect("/admin/student-support");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("studentSupport.update:", err);
|
||||||
|
req.flash("error_msg", err.message || "Error updating Student Support");
|
||||||
|
res.redirect("/admin/student-support");
|
||||||
|
}
|
||||||
|
};
|
||||||
+83
-96
@@ -1,119 +1,106 @@
|
|||||||
{
|
{
|
||||||
|
"metadata": {
|
||||||
|
"title": "Contact Us | LAMS",
|
||||||
|
"description": "Get in touch with LAMS. Our team is ready to assist you with admissions, financial aid, or program information.",
|
||||||
|
"keywords": "contact LAMS, admissions support, university contact, online education help",
|
||||||
|
"ogImage": "/images/og-contact.jpg"
|
||||||
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"title": "CONTACT US",
|
"badge": "We're Here to Help",
|
||||||
"backgroundImage": "/assets/img/inner-page/breadcrumb.jpg",
|
"titleMain": "Get in Touch with ",
|
||||||
"overlayColor": "rgba(0, 0, 0, 0)",
|
"titleHighlight": "LAMS",
|
||||||
"sectionClass": "breadcrumb-wrapper fix bg-cover",
|
"description": "Whether you have questions about our programs, admissions, or financial aid, our team is ready to assist you on your educational journey."
|
||||||
"titleClass": "breadcrumb-title",
|
|
||||||
"enableScrollspy": false,
|
|
||||||
"backgroundPosition": "center"
|
|
||||||
},
|
},
|
||||||
"contactCards": [
|
"infoCards": {
|
||||||
|
"contactInfo": {
|
||||||
|
"title": "Contact Information",
|
||||||
|
"channels": [
|
||||||
{
|
{
|
||||||
"type": "location",
|
"icon": "fa-solid fa-phone",
|
||||||
"title": "Location",
|
"title": "Admissions Phone",
|
||||||
"content": [
|
"detail": "123456789",
|
||||||
"43 Sardinella, 3nd Land Walk,",
|
"subDetail": "Mon-Fri, 8am-8pm EST"
|
||||||
"Orchard view, London, UK"
|
|
||||||
],
|
|
||||||
"iconType": "fa-solid fa-location-dot",
|
|
||||||
"iconSource": "fontawesome"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "email",
|
"icon": "fa-regular fa-envelope",
|
||||||
"title": "Email Address",
|
"title": "Email Support",
|
||||||
"content": [
|
"detail": "info@lams.ac",
|
||||||
"supportinfo@gmail.com",
|
"subDetail": "We aim to reply within 24 hours"
|
||||||
"arluxhotelinfo.com"
|
|
||||||
],
|
|
||||||
"iconType": "fa-solid fa-envelope",
|
|
||||||
"iconSource": "fontawesome"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "phone",
|
"icon": "fa-solid fa-map-location-dot",
|
||||||
"title": "Phone Number",
|
"title": "Administrative Office",
|
||||||
"content": [
|
"detail": "207 Regent Street, London, England W1B3HH"
|
||||||
"+880 123 427 00",
|
|
||||||
"+000 938 809 12"
|
|
||||||
],
|
|
||||||
"iconType": "fa-solid fa-phone",
|
|
||||||
"iconSource": "fontawesome"
|
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"map": {
|
|
||||||
"coordinates": {
|
|
||||||
"lat": -37.81450084255415,
|
|
||||||
"lng": 144.9618311901502
|
|
||||||
},
|
},
|
||||||
"zoom": 15,
|
"supportHours": {
|
||||||
"location": "Envato, Melbourne, Australia",
|
"title": "Student Support Hours",
|
||||||
"markerTitle": "Our Office",
|
"hours": [
|
||||||
"embedUrl": "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d6678.7619084840835!2d144.9618311901502!3d-37.81450084255415!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x6ad642b4758afc1d%3A0x3119cc820fdfc62e!2sEnvato!5e0!3m2!1sen!2sbd!4v1641984054261!5m2!1sen!2sbd",
|
{ "day": "Monday - Friday", "time": "8:00 AM - 8:00 PM EST" },
|
||||||
"tileLayer": {
|
{ "day": "Saturday", "time": "10:00 AM - 4:00 PM EST" },
|
||||||
"url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
{ "day": "Sunday", "time": "Closed" }
|
||||||
"attribution": "",
|
],
|
||||||
"maxZoom": 18,
|
"footer": {
|
||||||
"minZoom": 0
|
"text": "Current student looking for academic advising?",
|
||||||
|
"linkText": "Visit Student Portal",
|
||||||
|
"linkHref": "https://portal.lams.ac"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"sectionLabel": "",
|
"heading": "Send us a Message",
|
||||||
"heading": "Send Us Message",
|
"description": "Fill out the form below and our team will get back to you shortly.",
|
||||||
"description": "Have questions about visas or immigration? Send us a message today and our expert team will respond quickly.",
|
"fields": {
|
||||||
"fields": [
|
"firstName": { "name": "first_name", "label": "First Name", "placeholder": "John", "required": true },
|
||||||
{
|
"lastName": { "name": "last_name", "label": "Last Name", "placeholder": "Doe", "required": true },
|
||||||
"name": "name",
|
"email": { "name": "email", "label": "Email Address", "placeholder": "john.doe@example.com", "required": true },
|
||||||
"label": "Your Name",
|
"phone": { "name": "phone_number", "label": "Phone Number (Optional)", "placeholder": "(555) 123-4567", "required": false },
|
||||||
"type": "text",
|
"inquiryType": {
|
||||||
"placeholder": "Your name",
|
"name": "inquiry_type",
|
||||||
|
"label": "How can we help you?",
|
||||||
|
"placeholder": "Select an inquiry type...",
|
||||||
"required": true,
|
"required": true,
|
||||||
"colClass": "col-lg-4"
|
"options": [
|
||||||
|
{ "label": "Admissions & Enrollment", "value": "admissions" },
|
||||||
|
{ "label": "Financial Aid & Tuition", "value": "financial_aid" },
|
||||||
|
{ "label": "Program Information", "value": "program_info" },
|
||||||
|
{ "label": "Technical Support", "value": "tech_support" },
|
||||||
|
{ "label": "Other", "value": "other" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"message": { "name": "message_content", "label": "Message", "placeholder": "Please provide details about your inquiry...", "required": true }
|
||||||
|
},
|
||||||
|
"submitLabel": "Send Message",
|
||||||
|
"successMessage": "Your message has been sent successfully! Our team will contact you soon.",
|
||||||
|
"errorMessage": "Something went wrong. Please try again later or contact us directly."
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"title": "Frequently Asked Questions",
|
||||||
|
"subtitle": "Find quick answers to common questions about admissions, tuition, and online learning.",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"question": "Are LAMS degrees accredited?",
|
||||||
|
"answer": "Yes, LAMS is regionally accredited by the Higher Learning Commission. Our programs meet rigorous academic standards and our degrees are recognized by employers and other educational institutions worldwide."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "email",
|
"question": "How does the monthly subscription model work?",
|
||||||
"label": "Your Email",
|
"answer": "Our subscription model allows you to pay a flat monthly fee to access your coursework. You can move through the material at your own pace, potentially completing more courses per term compared to traditional models."
|
||||||
"type": "email",
|
|
||||||
"placeholder": "Your email",
|
|
||||||
"required": true,
|
|
||||||
"colClass": "col-lg-4"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phone",
|
"question": "Can I transfer credits from another institution?",
|
||||||
"label": "Your Phone",
|
"answer": "Absolutely. We accept transfer credits from regionally or nationally accredited institutions. You can transfer up to 90 credits for a bachelor's degree program."
|
||||||
"type": "tel",
|
|
||||||
"placeholder": "Phone Number",
|
|
||||||
"required": true,
|
|
||||||
"colClass": "col-lg-4"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "address",
|
"question": "Are there set class times I need to attend?",
|
||||||
"label": "Your Address",
|
"answer": "No, our programs are 100% online and asynchronous. While there are deadlines, you do not need to log in at specific times for live lectures."
|
||||||
"type": "text",
|
|
||||||
"placeholder": "Address Now",
|
|
||||||
"required": false,
|
|
||||||
"colClass": "col-lg-6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "date",
|
|
||||||
"label": "Your Date",
|
|
||||||
"type": "date",
|
|
||||||
"placeholder": "Date",
|
|
||||||
"required": false,
|
|
||||||
"colClass": "col-lg-6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "message",
|
|
||||||
"label": "Your Message",
|
|
||||||
"type": "textarea",
|
|
||||||
"placeholder": "Type your message",
|
|
||||||
"required": false,
|
|
||||||
"colClass": "col-lg-12"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"submitButton": {
|
|
||||||
"text": "SEND MESSAGE",
|
|
||||||
"icon": "fa-solid fa-arrow-right",
|
|
||||||
"buttonClass": "theme-btn style-2"
|
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"title": "Ready to take the next step?",
|
||||||
|
"description": "Join thousands of students who have advanced their careers through our affordable, flexible online programs.",
|
||||||
|
"primaryButton": { "label": "Request Information", "href": "/request" },
|
||||||
|
"secondaryButton": { "label": "Apply Now", "href": "/apply" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "bs-cs",
|
||||||
|
"level": "Bachelor's",
|
||||||
|
"title": "B.S. in Computer Science",
|
||||||
|
"description": "Master software engineering, algorithms, and system design in this comprehensive asynchronous program.",
|
||||||
|
"duration": "4 Years (Flexible)",
|
||||||
|
"cost": "$299 / month",
|
||||||
|
"selected": false,
|
||||||
|
"link": "/programmes/bs-cs",
|
||||||
|
"shortName": "B.S.",
|
||||||
|
"detailBadge": "Bachelor of Science",
|
||||||
|
"detailTitle": "Computer Science",
|
||||||
|
"detailDescription": "Master software engineering, algorithms, and system design in this comprehensive asynchronous program designed for the modern tech landscape.",
|
||||||
|
"heroImage": "/uploads/programmes/bs-cs-hero.png",
|
||||||
|
"overview": "The B.S. in Computer Science program prepares you for a successful career in software development, data engineering, and systems architecture. Through our flexible, subscription-based model, you can learn at your own pace while mastering the core principles of computer science.",
|
||||||
|
"credits": 120,
|
||||||
|
"format": "100% Online",
|
||||||
|
"coreCourses": [
|
||||||
|
{ "id": "CS101", "title": "CS101: Introduction to Programming", "description": "Fundamentals of Python and computational thinking.", "icon": "fa-solid fa-code" },
|
||||||
|
{ "id": "CS201", "title": "CS201: Data Structures & Algorithms", "description": "Analysis and implementation of core data structures.", "icon": "fa-solid fa-database" },
|
||||||
|
{ "id": "CS301", "title": "CS301: Computer Networks", "description": "Protocols, routing, and network architecture.", "icon": "fa-solid fa-network-wired" }
|
||||||
|
],
|
||||||
|
"electives": ["Artificial Intelligence", "Cybersecurity Fundamentals", "Cloud Computing", "Mobile App Development"],
|
||||||
|
"outcomes": [
|
||||||
|
{ "title": "Software Engineering", "description": "Design, develop, and test scalable software systems using modern methodologies.", "icon": "fa-solid fa-laptop-code" },
|
||||||
|
{ "title": "Problem Solving", "description": "Apply algorithmic thinking to solve complex computational problems efficiently.", "icon": "fa-solid fa-brain" }
|
||||||
|
],
|
||||||
|
"faqs": [
|
||||||
|
{ "question": "Is this program fully asynchronous?", "answer": "Yes, all coursework is designed to be completed entirely online and on your own schedule. There are no mandatory live login times." },
|
||||||
|
{ "question": "Can I transfer credits from another institution?", "answer": "Absolutely. We accept up to 90 transfer credits for bachelor's degree programs from accredited institutions." }
|
||||||
|
],
|
||||||
|
"nextStartDate": "September 1st",
|
||||||
|
"monthlyCost": "$299",
|
||||||
|
"perCourseCost": "$450"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mba-db",
|
||||||
|
"level": "Master's",
|
||||||
|
"title": "MBA in Digital Business",
|
||||||
|
"description": "Lead the digital transformation with advanced business strategies and technology management skills.",
|
||||||
|
"duration": "18 Months",
|
||||||
|
"cost": "$450 / month",
|
||||||
|
"selected": true,
|
||||||
|
"link": "/programmes/mba-db",
|
||||||
|
"shortName": "MBA",
|
||||||
|
"detailBadge": "Master of Business Administration",
|
||||||
|
"detailTitle": "Digital Business",
|
||||||
|
"detailDescription": "Lead the digital transformation with advanced business strategies and technology management skills in our flexible online program.",
|
||||||
|
"heroImage": "/uploads/programmes/mba-db-hero.png",
|
||||||
|
"overview": "The MBA in Digital Business prepares executives and entrepreneurs for the demands of the modern digital economy.",
|
||||||
|
"credits": 45,
|
||||||
|
"format": "100% Online",
|
||||||
|
"coreCourses": [
|
||||||
|
{ "id": "MBA501", "title": "MBA501: Digital Transformation", "description": "Leading organizational change in the digital era.", "icon": "fa-solid fa-chart-line" }
|
||||||
|
],
|
||||||
|
"electives": ["Digital Marketing", "Fintech Innovations"],
|
||||||
|
"outcomes": [
|
||||||
|
{ "title": "Leadership", "description": "Lead digital transformation initiatives.", "icon": "fa-solid fa-users" }
|
||||||
|
],
|
||||||
|
"faqs": [
|
||||||
|
{ "question": "Do I need a GMAT?", "answer": "No, we have a holistic review process that does not require standardized tests." }
|
||||||
|
],
|
||||||
|
"nextStartDate": "October 15th",
|
||||||
|
"monthlyCost": "$450",
|
||||||
|
"perCourseCost": "$1200"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"title": "Request Information | LAMS",
|
||||||
|
"description": "Start your journey with LAMS. Request more information about our affordable, flexible online degree programs.",
|
||||||
|
"keywords": "request university info, LAMS programs, admissions inquiry, online degree steps",
|
||||||
|
"ogImage": "/images/og-request.jpg"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"badge": "Your Future Starts Here",
|
||||||
|
"titleMain": "Take the Next Step in Your ",
|
||||||
|
"titleHighlight": "Career",
|
||||||
|
"description": "Join thousands of students achieving their goals through our flexible, affordable, and accredited online programs.",
|
||||||
|
"valueProps": [
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-piggy-bank",
|
||||||
|
"title": "Affordable Tuition",
|
||||||
|
"description": "Learn by monthly subscription or pay-per-course. We believe quality education should be accessible to everyone."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-clock",
|
||||||
|
"title": "Flexible Schedules",
|
||||||
|
"description": "100% online courses designed to fit your busy life. Study when and where it works best for you."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-certificate",
|
||||||
|
"title": "Accredited Programs",
|
||||||
|
"description": "Earn a recognized degree from an institution committed to academic excellence and industry relevance."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"heading": "Request Information",
|
||||||
|
"description": "Fill out the form below and an admissions advisor will contact you within 24 hours.",
|
||||||
|
"fields": {
|
||||||
|
"firstName": { "name": "first_name", "label": "First Name", "placeholder": "John", "required": true },
|
||||||
|
"lastName": { "name": "last_name", "label": "Last Name", "placeholder": "Doe", "required": true },
|
||||||
|
"email": { "name": "email_address", "label": "Email Address", "placeholder": "john.doe@example.com", "required": true },
|
||||||
|
"phone": { "name": "phone_number", "label": "Phone Number", "placeholder": "(555) 123-4567", "required": false },
|
||||||
|
"program": {
|
||||||
|
"name": "program_id",
|
||||||
|
"label": "Program of Interest",
|
||||||
|
"placeholder": "Select a program...",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{ "label": "B.S. Computer Science", "value": "bs-cs" },
|
||||||
|
{ "label": "B.S. Business Administration", "value": "bs-ba" },
|
||||||
|
{ "label": "B.A. Psychology", "value": "ba-psych" },
|
||||||
|
{ "label": "Master of Business Administration (MBA)", "value": "mba" },
|
||||||
|
{ "label": "M.S. Data Science", "value": "ms-ds" },
|
||||||
|
{ "label": "Digital Marketing Certificate", "value": "cert-dm" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"name": "start_timeline",
|
||||||
|
"label": "Start Timeline",
|
||||||
|
"options": [
|
||||||
|
{ "label": "Immediately", "value": "immediate" },
|
||||||
|
{ "label": "1-3 Months", "value": "1-3-months" },
|
||||||
|
{ "label": "3-6 Months", "value": "3-6-months" },
|
||||||
|
{ "label": "Undecided", "value": "undecided" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"consentText": "By submitting this form, I consent to receive emails, texts, and calls from LAMS regarding educational programs. I understand consent is not required to enroll.",
|
||||||
|
"submitLabel": "Get Information Now",
|
||||||
|
"footerText": "Your information is secure and encrypted.",
|
||||||
|
"successMessage": "Your request has been submitted successfully! An advisor will reach out to you within 24 hours.",
|
||||||
|
"errorMessage": "There was an error submitting your request. Please check your information and try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"title": "Student Support | LAMS",
|
||||||
|
"description": "Comprehensive student support at LAMS. Access academic advising, career services, and technical support to thrive in your online learning journey."
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"badge": "Student Success Center",
|
||||||
|
"title": "Comprehensive Student Support",
|
||||||
|
"description": "We are dedicated to your success. Access academic advising, tutoring, career services, and technical support designed to help you thrive in your online learning journey.",
|
||||||
|
"primaryButton": {
|
||||||
|
"label": "Explore Services",
|
||||||
|
"href": "#directory"
|
||||||
|
},
|
||||||
|
"secondaryButton": {
|
||||||
|
"label": "Get Help Now",
|
||||||
|
"href": "#contact-support"
|
||||||
|
},
|
||||||
|
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/5a10584b95-33a6a34a5a6353e69a2c.png",
|
||||||
|
"imageAlt": "Support advisor helping student online"
|
||||||
|
},
|
||||||
|
"directory": {
|
||||||
|
"title": "Support Services Directory",
|
||||||
|
"subtitle": "Everything you need to succeed academically, professionally, and personally while studying at LAMS.",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-compass",
|
||||||
|
"title": "Academic Advising",
|
||||||
|
"description": "Personalized guidance to help you select courses, stay on track for graduation, and navigate academic policies.",
|
||||||
|
"hours": "Mon-Fri: 8am - 8pm EST",
|
||||||
|
"hoursIcon": "fa-regular fa-clock",
|
||||||
|
"buttonLabel": "Schedule Appointment",
|
||||||
|
"buttonHref": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-language",
|
||||||
|
"title": "Language Enhancement",
|
||||||
|
"description": "Enhance your language skills through our Language system, supporting both general and specialised language learning tailored to your academic and professional needs.",
|
||||||
|
"hours": "24/7 Online Support",
|
||||||
|
"hoursIcon": "fa-regular fa-clock",
|
||||||
|
"buttonLabel": "Start Learning",
|
||||||
|
"buttonHref": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-briefcase",
|
||||||
|
"title": "Career Services",
|
||||||
|
"description": "Resume reviews, interview preparation, networking events, and access to our exclusive job board.",
|
||||||
|
"hours": "Mon-Fri: 9am - 6pm EST",
|
||||||
|
"hoursIcon": "fa-regular fa-clock",
|
||||||
|
"buttonLabel": "Access Career Hub",
|
||||||
|
"buttonHref": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-laptop-code",
|
||||||
|
"title": "Tech Support",
|
||||||
|
"description": "Assistance with learning management systems, student email, software access, and general technical troubleshooting.",
|
||||||
|
"hours": "24/7 Help Desk",
|
||||||
|
"hoursIcon": "fa-regular fa-clock",
|
||||||
|
"buttonLabel": "Submit IT Ticket",
|
||||||
|
"buttonHref": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-universal-access",
|
||||||
|
"title": "Accessibility Services",
|
||||||
|
"description": "Ensuring equal access to educational opportunities through accommodations, assistive tech, and advocacy.",
|
||||||
|
"hours": "Mon-Fri: 8am - 5pm EST",
|
||||||
|
"hoursIcon": "fa-regular fa-clock",
|
||||||
|
"buttonLabel": "Request Accommodations",
|
||||||
|
"buttonHref": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-users",
|
||||||
|
"title": "Community Resources",
|
||||||
|
"description": "Mental health resources, student clubs, diversity initiatives, and online community forums.",
|
||||||
|
"hours": "Virtual Campus",
|
||||||
|
"hoursIcon": "fa-solid fa-globe",
|
||||||
|
"buttonLabel": "Join the Community",
|
||||||
|
"buttonHref": "#"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contactSection": {
|
||||||
|
"heading": "Need Immediate Assistance?",
|
||||||
|
"description": "Our support team is ready to help you navigate any challenges. Reach out through your preferred channel.",
|
||||||
|
"channels": [
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-phone",
|
||||||
|
"title": "Call Us",
|
||||||
|
"detail": "123456789"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-envelope",
|
||||||
|
"title": "Email Support",
|
||||||
|
"detail": "info@lams.ac"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-comments",
|
||||||
|
"title": "Live Chat",
|
||||||
|
"detail": "Available 24/7 on Student Portal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"form": {
|
||||||
|
"heading": "Send a Message",
|
||||||
|
"fields": {
|
||||||
|
"firstName": {
|
||||||
|
"label": "First Name",
|
||||||
|
"placeholder": "John"
|
||||||
|
},
|
||||||
|
"lastName": {
|
||||||
|
"label": "Last Name",
|
||||||
|
"placeholder": "Doe"
|
||||||
|
},
|
||||||
|
"studentId": {
|
||||||
|
"label": "Student ID (Optional)",
|
||||||
|
"placeholder": "e.g. 12345678"
|
||||||
|
},
|
||||||
|
"department": {
|
||||||
|
"label": "Department"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"label": "Message",
|
||||||
|
"placeholder": "How can we help you?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"departments": [
|
||||||
|
"Academic Advising",
|
||||||
|
"Financial Aid",
|
||||||
|
"Tech Support",
|
||||||
|
"Other"
|
||||||
|
],
|
||||||
|
"submitLabel": "Submit Request",
|
||||||
|
"successMessage": "Your message has been sent successfully!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
-361
@@ -1,422 +1,189 @@
|
|||||||
const mongoose = require("mongoose");
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
// Schema cho hero section
|
// ─────────────────────────────────────────────
|
||||||
|
// Sub-schemas
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const metadataSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: { type: String, trim: true, default: "" },
|
||||||
|
description: { type: String, trim: true, default: "" },
|
||||||
|
keywords: { type: String, trim: true, default: "" },
|
||||||
|
ogImage: { type: String, trim: true, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
const heroSchema = new mongoose.Schema(
|
const heroSchema = new mongoose.Schema(
|
||||||
{
|
{
|
||||||
title: {
|
badge: { type: String, trim: true, default: "" },
|
||||||
type: String,
|
titleMain: { type: String, trim: true, default: "" },
|
||||||
required: true,
|
titleHighlight: { type: String, trim: true, default: "" },
|
||||||
trim: true,
|
description: { type: String, trim: true, default: "" },
|
||||||
},
|
|
||||||
backgroundImage: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
overlayColor: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "rgba(0, 0, 0, 0)",
|
|
||||||
},
|
|
||||||
sectionClass: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
titleClass: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
enableScrollspy: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
backgroundPosition: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "center",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schema cho contact card
|
const channelSchema = new mongoose.Schema(
|
||||||
const contactCardSchema = new mongoose.Schema(
|
|
||||||
{
|
{
|
||||||
type: {
|
icon: { type: String, trim: true, default: "" },
|
||||||
type: String,
|
title: { type: String, trim: true, default: "" },
|
||||||
required: true,
|
detail: { type: String, trim: true, default: "" },
|
||||||
trim: true,
|
subDetail: { type: String, trim: true, default: "" },
|
||||||
enum: [
|
|
||||||
"phone",
|
|
||||||
"email",
|
|
||||||
"location",
|
|
||||||
"hours",
|
|
||||||
"website",
|
|
||||||
"social",
|
|
||||||
"custom",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
trim: true,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
type: [String],
|
|
||||||
default: [],
|
|
||||||
},
|
|
||||||
iconType: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
iconSource: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
trim: true,
|
|
||||||
enum: ["fontawesome", "image"],
|
|
||||||
default: "fontawesome",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schema cho map coordinates
|
const supportHourSchema = new mongoose.Schema(
|
||||||
const coordinatesSchema = new mongoose.Schema(
|
|
||||||
{
|
{
|
||||||
lat: {
|
day: { type: String, trim: true, default: "" },
|
||||||
type: Number,
|
time: { type: String, trim: true, default: "" },
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
lng: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schema cho tile layer
|
const infoCardsSchema = new mongoose.Schema(
|
||||||
const tileLayerSchema = new mongoose.Schema(
|
|
||||||
{
|
{
|
||||||
url: {
|
contactInfo: {
|
||||||
type: String,
|
title: { type: String, trim: true, default: "" },
|
||||||
required: true,
|
channels: { type: [channelSchema], default: [] },
|
||||||
trim: true,
|
|
||||||
},
|
},
|
||||||
attribution: {
|
supportHours: {
|
||||||
type: String,
|
title: { type: String, trim: true, default: "" },
|
||||||
trim: true,
|
hours: { type: [supportHourSchema], default: [] },
|
||||||
default: "",
|
footer: {
|
||||||
|
text: { type: String, trim: true, default: "" },
|
||||||
|
linkText: { type: String, trim: true, default: "" },
|
||||||
|
linkHref: { type: String, trim: true, default: "" },
|
||||||
},
|
},
|
||||||
maxZoom: {
|
|
||||||
type: Number,
|
|
||||||
default: 18,
|
|
||||||
},
|
|
||||||
minZoom: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schema cho map
|
const formOptionSchema = new mongoose.Schema(
|
||||||
const mapSchema = new mongoose.Schema(
|
|
||||||
{
|
{
|
||||||
coordinates: {
|
label: { type: String, trim: true, default: "" },
|
||||||
type: coordinatesSchema,
|
value: { type: String, trim: true, default: "" },
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
zoom: {
|
|
||||||
type: Number,
|
|
||||||
default: 15,
|
|
||||||
},
|
|
||||||
location: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
trim: true,
|
|
||||||
},
|
|
||||||
markerTitle: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
embedUrl: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
tileLayer: {
|
|
||||||
type: tileLayerSchema,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schema cho form field
|
const simpleFieldSchema = new mongoose.Schema(
|
||||||
const formFieldSchema = new mongoose.Schema(
|
|
||||||
{
|
{
|
||||||
name: {
|
name: { type: String, trim: true, default: "" },
|
||||||
type: String,
|
label: { type: String, trim: true, default: "" },
|
||||||
required: true,
|
placeholder: { type: String, trim: true, default: "" },
|
||||||
trim: true,
|
required: { type: Boolean, default: false },
|
||||||
},
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
type: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
trim: true,
|
|
||||||
enum: ["text", "email", "tel", "textarea", "programme", "date"],
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
required: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
colClass: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "col-lg-12",
|
|
||||||
},
|
|
||||||
programmeName: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schema cho submit button
|
const inquiryFieldSchema = new mongoose.Schema(
|
||||||
const submitButtonSchema = new mongoose.Schema(
|
|
||||||
{
|
{
|
||||||
text: {
|
name: { type: String, trim: true, default: "" },
|
||||||
type: String,
|
label: { type: String, trim: true, default: "" },
|
||||||
required: true,
|
placeholder: { type: String, trim: true, default: "" },
|
||||||
trim: true,
|
required: { type: Boolean, default: true },
|
||||||
},
|
options: { type: [formOptionSchema], default: [] },
|
||||||
icon: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "fa-solid fa-arrow-right",
|
|
||||||
},
|
|
||||||
buttonClass: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "theme-btn style-2",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schema cho form
|
|
||||||
const formSchema = new mongoose.Schema(
|
const formSchema = new mongoose.Schema(
|
||||||
{
|
{
|
||||||
sectionLabel: {
|
heading: { type: String, trim: true, default: "" },
|
||||||
type: String,
|
description: { type: String, trim: true, default: "" },
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
heading: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
fields: {
|
fields: {
|
||||||
type: [formFieldSchema],
|
firstName: { type: simpleFieldSchema, default: () => ({}) },
|
||||||
default: [],
|
lastName: { type: simpleFieldSchema, default: () => ({}) },
|
||||||
},
|
email: { type: simpleFieldSchema, default: () => ({}) },
|
||||||
submitButton: {
|
phone: { type: simpleFieldSchema, default: () => ({}) },
|
||||||
type: submitButtonSchema,
|
inquiryType: { type: inquiryFieldSchema, default: () => ({}) },
|
||||||
required: true,
|
message: { type: simpleFieldSchema, default: () => ({}) },
|
||||||
},
|
},
|
||||||
|
submitLabel: { type: String, trim: true, default: "Send Message" },
|
||||||
|
successMessage: { type: String, trim: true, default: "" },
|
||||||
|
errorMessage: { type: String, trim: true, default: "" },
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Main Contact Schema
|
const faqItemSchema = new mongoose.Schema(
|
||||||
const contactSchema = new mongoose.Schema(
|
|
||||||
{
|
{
|
||||||
name: {
|
question: { type: String, trim: true, default: "" },
|
||||||
type: String,
|
answer: { type: String, trim: true, default: "" },
|
||||||
default: "default",
|
|
||||||
unique: true,
|
|
||||||
},
|
},
|
||||||
hero: {
|
{ _id: false }
|
||||||
type: heroSchema,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
contactCards: {
|
|
||||||
type: [contactCardSchema],
|
|
||||||
default: [],
|
|
||||||
},
|
|
||||||
map: {
|
|
||||||
type: mapSchema,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
type: formSchema,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
timestamps: true,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mapping iconType cũ sang Font Awesome icon mới
|
const faqSchema = new mongoose.Schema(
|
||||||
const iconTypeMapping = {
|
{
|
||||||
phone: "fas fa-phone",
|
title: { type: String, trim: true, default: "" },
|
||||||
email: "fas fa-envelope",
|
subtitle: { type: String, trim: true, default: "" },
|
||||||
location: "fas fa-map-marker-alt",
|
items: { type: [faqItemSchema], default: [] },
|
||||||
clock: "fas fa-clock",
|
},
|
||||||
hours: "fas fa-clock",
|
{ _id: false }
|
||||||
};
|
);
|
||||||
|
|
||||||
// Tạo migration script để import dữ liệu từ contact-data.json
|
const ctaButtonSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
label: { type: String, trim: true, default: "" },
|
||||||
|
href: { type: String, trim: true, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const ctaSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: { type: String, trim: true, default: "" },
|
||||||
|
description: { type: String, trim: true, default: "" },
|
||||||
|
primaryButton: { type: ctaButtonSchema, default: () => ({}) },
|
||||||
|
secondaryButton: { type: ctaButtonSchema, default: () => ({}) },
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Main Contact Schema (slug-based, same pattern
|
||||||
|
// as StudentSupport / RequestInfo)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
const contactSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
slug: { type: String, default: "contact", unique: true },
|
||||||
|
metadata: { type: metadataSchema, default: () => ({}) },
|
||||||
|
hero: { type: heroSchema, default: () => ({}) },
|
||||||
|
infoCards: { type: infoCardsSchema, default: () => ({}) },
|
||||||
|
form: { type: formSchema, default: () => ({}) },
|
||||||
|
faq: { type: faqSchema, default: () => ({}) },
|
||||||
|
cta: { type: ctaSchema, default: () => ({}) },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Migration helper (idempotent upsert)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
contactSchema.statics.migrateFromJson = async function (jsonData) {
|
contactSchema.statics.migrateFromJson = async function (jsonData) {
|
||||||
try {
|
const payload = {
|
||||||
// Kiểm tra xem đã có contact mặc định chưa
|
metadata: jsonData.metadata || {},
|
||||||
const existingContact = await this.findOne({ name: "default" });
|
hero: jsonData.hero || {},
|
||||||
|
infoCards: jsonData.infoCards || {},
|
||||||
// Xử lý và chuẩn hóa dữ liệu từ JSON
|
form: jsonData.form || {},
|
||||||
const processedData = {
|
faq: jsonData.faq || {},
|
||||||
hero: {
|
cta: jsonData.cta || {},
|
||||||
title: jsonData.hero?.title || "Contact Us",
|
|
||||||
backgroundImage: jsonData.hero?.backgroundImage || "",
|
|
||||||
overlayColor: jsonData.hero?.overlayColor || "rgba(0, 0, 0, 0)",
|
|
||||||
sectionClass: jsonData.hero?.sectionClass || "",
|
|
||||||
titleClass: jsonData.hero?.titleClass || "",
|
|
||||||
enableScrollspy: jsonData.hero?.enableScrollspy || false,
|
|
||||||
backgroundPosition: jsonData.hero?.backgroundPosition || "center",
|
|
||||||
},
|
|
||||||
contactCards: (jsonData.contactCards || []).map((card) => {
|
|
||||||
let iconType = card.iconType || "";
|
|
||||||
let iconSource = card.iconSource;
|
|
||||||
|
|
||||||
// Nếu không có iconSource, tự động detect từ iconType
|
|
||||||
if (!iconSource) {
|
|
||||||
// Nếu iconType là image path (bắt đầu bằng /uploads/ hoặc http)
|
|
||||||
if (
|
|
||||||
iconType.startsWith("/uploads/") ||
|
|
||||||
iconType.startsWith("http://") ||
|
|
||||||
iconType.startsWith("https://")
|
|
||||||
) {
|
|
||||||
iconSource = "image";
|
|
||||||
} else {
|
|
||||||
// Nếu iconType là string cũ (phone, email, location, clock)
|
|
||||||
iconSource = "fontawesome";
|
|
||||||
// Map iconType cũ sang Font Awesome icon mới
|
|
||||||
if (iconTypeMapping[iconType]) {
|
|
||||||
iconType = iconTypeMapping[iconType];
|
|
||||||
} else if (
|
|
||||||
iconType &&
|
|
||||||
!iconType.startsWith("fas ") &&
|
|
||||||
!iconType.startsWith("fab ")
|
|
||||||
) {
|
|
||||||
// Nếu iconType không phải là Font Awesome class hợp lệ, thử map
|
|
||||||
iconType = iconTypeMapping[iconType] || iconType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Nếu đã có iconSource nhưng iconType là string cũ, map sang Font Awesome
|
|
||||||
if (
|
|
||||||
iconSource === "fontawesome" &&
|
|
||||||
iconType &&
|
|
||||||
!iconType.startsWith("fas ") &&
|
|
||||||
!iconType.startsWith("fab ") &&
|
|
||||||
iconTypeMapping[iconType]
|
|
||||||
) {
|
|
||||||
iconType = iconTypeMapping[iconType];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: card.type || "custom",
|
|
||||||
title: card.title || "",
|
|
||||||
content: Array.isArray(card.content) ? card.content : [],
|
|
||||||
iconType: iconType,
|
|
||||||
iconSource: iconSource || "fontawesome",
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
map: {
|
|
||||||
coordinates: {
|
|
||||||
lat: jsonData.map?.coordinates?.lat || 0,
|
|
||||||
lng: jsonData.map?.coordinates?.lng || 0,
|
|
||||||
},
|
|
||||||
zoom: jsonData.map?.zoom || 15,
|
|
||||||
location: jsonData.map?.location || "",
|
|
||||||
markerTitle: jsonData.map?.markerTitle || "",
|
|
||||||
embedUrl: jsonData.map?.embedUrl || "",
|
|
||||||
tileLayer: {
|
|
||||||
url:
|
|
||||||
jsonData.map?.tileLayer?.url ||
|
|
||||||
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
||||||
attribution: jsonData.map?.tileLayer?.attribution || "",
|
|
||||||
maxZoom: jsonData.map?.tileLayer?.maxZoom || 18,
|
|
||||||
minZoom: jsonData.map?.tileLayer?.minZoom || 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
sectionLabel: jsonData.form?.sectionLabel || "",
|
|
||||||
heading: jsonData.form?.heading || "",
|
|
||||||
description: jsonData.form?.description || "",
|
|
||||||
fields: (jsonData.form?.fields || []).map((field) => ({
|
|
||||||
name: field.name || "",
|
|
||||||
label: field.label || "",
|
|
||||||
type: field.type || "text",
|
|
||||||
placeholder: field.placeholder || "",
|
|
||||||
required: field.required || false,
|
|
||||||
colClass: field.colClass || "col-lg-12",
|
|
||||||
programmeName: field.programmeName || "",
|
|
||||||
})),
|
|
||||||
submitButton: {
|
|
||||||
text: jsonData.form?.submitButton?.text || "Send Message",
|
|
||||||
icon: jsonData.form?.submitButton?.icon || "fa-solid fa-arrow-right",
|
|
||||||
buttonClass: jsonData.form?.submitButton?.buttonClass || "theme-btn style-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existingContact) {
|
const existing = await this.findOne({ slug: "contact" });
|
||||||
// Cập nhật contact hiện có với dữ liệu đã xử lý
|
if (existing) {
|
||||||
existingContact.hero = processedData.hero;
|
existing.set(payload);
|
||||||
existingContact.contactCards = processedData.contactCards;
|
await existing.save();
|
||||||
existingContact.map = processedData.map;
|
console.log("✅ Contact data updated via migration.");
|
||||||
existingContact.form = processedData.form;
|
return existing;
|
||||||
await existingContact.save();
|
|
||||||
console.log("Contact data updated successfully");
|
|
||||||
return existingContact;
|
|
||||||
} else {
|
} else {
|
||||||
// Tạo contact mới với dữ liệu đã xử lý
|
const created = await this.create({ slug: "contact", ...payload });
|
||||||
const newContact = await this.create({
|
console.log("✅ Contact data created via migration.");
|
||||||
name: "default",
|
return created;
|
||||||
...processedData,
|
|
||||||
});
|
|
||||||
console.log("Contact data imported successfully");
|
|
||||||
return newContact;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error migrating contact data:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
const courseSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
id: { type: String, default: "" },
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
icon: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const outcomeSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
icon: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const faqSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
question: { type: String, default: "" },
|
||||||
|
answer: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const programmeSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
id: { type: String, required: true, unique: true, trim: true },
|
||||||
|
level: { type: String, default: "" },
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
duration: { type: String, default: "" },
|
||||||
|
cost: { type: String, default: "" },
|
||||||
|
selected: { type: Boolean, default: false },
|
||||||
|
link: { type: String, default: "" },
|
||||||
|
shortName: { type: String, default: "" },
|
||||||
|
detailBadge: { type: String, default: "" },
|
||||||
|
detailTitle: { type: String, default: "" },
|
||||||
|
detailDescription: { type: String, default: "" },
|
||||||
|
heroImage: { type: String, default: "" },
|
||||||
|
overview: { type: String, default: "" },
|
||||||
|
credits: { type: Number, default: 0 },
|
||||||
|
format: { type: String, default: "100% Online" },
|
||||||
|
nextStartDate: { type: String, default: "" },
|
||||||
|
monthlyCost: { type: String, default: "" },
|
||||||
|
perCourseCost: { type: String, default: "" },
|
||||||
|
coreCourses: { type: [courseSchema], default: [] },
|
||||||
|
electives: { type: [String], default: [] },
|
||||||
|
outcomes: { type: [outcomeSchema], default: [] },
|
||||||
|
faqs: { type: [faqSchema], default: [] },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert from a JSON array. Used by migration scripts.
|
||||||
|
*/
|
||||||
|
programmeSchema.statics.migrateFromJson = async function (jsonArray) {
|
||||||
|
const results = [];
|
||||||
|
for (const item of jsonArray) {
|
||||||
|
const existing = await this.findOne({ id: item.id });
|
||||||
|
if (existing) {
|
||||||
|
existing.set(item);
|
||||||
|
await existing.save();
|
||||||
|
results.push({ action: "updated", id: item.id });
|
||||||
|
} else {
|
||||||
|
await this.create(item);
|
||||||
|
results.push({ action: "created", id: item.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model("Programme", programmeSchema);
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
// ── Sub-schemas ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const metadataSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
keywords: { type: String, default: "" },
|
||||||
|
ogImage: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const valuePropSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
icon: { type: String, default: "" },
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const heroSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
badge: { type: String, default: "" },
|
||||||
|
titleMain: { type: String, default: "" },
|
||||||
|
titleHighlight: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
valueProps: { type: [valuePropSchema], default: [] },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const formOptionSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
label: { type: String, default: "" },
|
||||||
|
value: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const formFieldSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: { type: String, default: "" },
|
||||||
|
label: { type: String, default: "" },
|
||||||
|
placeholder: { type: String, default: "" },
|
||||||
|
required: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const formSelectFieldSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: { type: String, default: "" },
|
||||||
|
label: { type: String, default: "" },
|
||||||
|
placeholder: { type: String, default: "" },
|
||||||
|
required: { type: Boolean, default: false },
|
||||||
|
options: { type: [formOptionSchema], default: [] },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const formTimelineFieldSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: { type: String, default: "" },
|
||||||
|
label: { type: String, default: "" },
|
||||||
|
options: { type: [formOptionSchema], default: [] },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const formFieldsSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
firstName: { type: formFieldSchema, default: () => ({}) },
|
||||||
|
lastName: { type: formFieldSchema, default: () => ({}) },
|
||||||
|
email: { type: formFieldSchema, default: () => ({}) },
|
||||||
|
phone: { type: formFieldSchema, default: () => ({}) },
|
||||||
|
program: { type: formSelectFieldSchema, default: () => ({}) },
|
||||||
|
timeline: { type: formTimelineFieldSchema, default: () => ({}) },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const formSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
heading: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
fields: { type: formFieldsSchema, default: () => ({}) },
|
||||||
|
consentText: { type: String, default: "" },
|
||||||
|
submitLabel: { type: String, default: "" },
|
||||||
|
footerText: { type: String, default: "" },
|
||||||
|
successMessage: { type: String, default: "" },
|
||||||
|
errorMessage: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Root schema ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const requestInfoSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
slug: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
default: "request-info",
|
||||||
|
},
|
||||||
|
metadata: { type: metadataSchema, default: () => ({}) },
|
||||||
|
hero: { type: heroSchema, default: () => ({}) },
|
||||||
|
form: { type: formSchema, default: () => ({}) },
|
||||||
|
},
|
||||||
|
{ timestamps: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed / migrate từ file JSON (cùng shape với API frontend).
|
||||||
|
* Idempotent — chạy lại không tạo trùng.
|
||||||
|
*/
|
||||||
|
requestInfoSchema.statics.migrateFromJson = async function (jsonData) {
|
||||||
|
const slug = "request-info";
|
||||||
|
const payload = {
|
||||||
|
slug,
|
||||||
|
metadata: jsonData.metadata || {},
|
||||||
|
hero: jsonData.hero || {},
|
||||||
|
form: jsonData.form || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = await this.findOne({ slug });
|
||||||
|
if (existing) {
|
||||||
|
existing.set(payload);
|
||||||
|
await existing.save();
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return this.create(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model("RequestInfo", requestInfoSchema);
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
const ctaButtonSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
label: { type: String, default: "" },
|
||||||
|
href: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const heroSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
badge: { type: String, default: "" },
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
primaryButton: { type: ctaButtonSchema, default: () => ({}) },
|
||||||
|
secondaryButton: { type: ctaButtonSchema, default: () => ({}) },
|
||||||
|
image: { type: String, default: "" },
|
||||||
|
imageAlt: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const directoryServiceSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
icon: { type: String, default: "" },
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
hours: { type: String, default: "" },
|
||||||
|
hoursIcon: { type: String, default: "" },
|
||||||
|
buttonLabel: { type: String, default: "" },
|
||||||
|
buttonHref: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const directorySchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
subtitle: { type: String, default: "" },
|
||||||
|
services: { type: [directoryServiceSchema], default: [] },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const channelSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
icon: { type: String, default: "" },
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
detail: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelPlaceholderSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
label: { type: String, default: "" },
|
||||||
|
placeholder: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const departmentFieldSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
label: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const supportFormFieldsSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
firstName: { type: labelPlaceholderSchema, default: () => ({}) },
|
||||||
|
lastName: { type: labelPlaceholderSchema, default: () => ({}) },
|
||||||
|
studentId: { type: labelPlaceholderSchema, default: () => ({}) },
|
||||||
|
department: { type: departmentFieldSchema, default: () => ({}) },
|
||||||
|
message: { type: labelPlaceholderSchema, default: () => ({}) },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const contactFormSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
heading: { type: String, default: "" },
|
||||||
|
fields: { type: supportFormFieldsSchema, default: () => ({}) },
|
||||||
|
departments: { type: [String], default: [] },
|
||||||
|
submitLabel: { type: String, default: "" },
|
||||||
|
successMessage: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const contactSectionSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
heading: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
channels: { type: [channelSchema], default: [] },
|
||||||
|
form: { type: contactFormSchema, default: () => ({}) },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadataSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const studentSupportSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
slug: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
default: "student-support",
|
||||||
|
},
|
||||||
|
metadata: { type: metadataSchema, default: () => ({}) },
|
||||||
|
hero: { type: heroSchema, default: () => ({}) },
|
||||||
|
directory: { type: directorySchema, default: () => ({}) },
|
||||||
|
contactSection: { type: contactSectionSchema, default: () => ({}) },
|
||||||
|
},
|
||||||
|
{ timestamps: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed / migrate từ file JSON (cùng shape với API frontend).
|
||||||
|
*/
|
||||||
|
studentSupportSchema.statics.migrateFromJson = async function (jsonData) {
|
||||||
|
const slug = "student-support";
|
||||||
|
const payload = {
|
||||||
|
slug,
|
||||||
|
metadata: jsonData.metadata || {},
|
||||||
|
hero: jsonData.hero || {},
|
||||||
|
directory: jsonData.directory || { services: [] },
|
||||||
|
contactSection: jsonData.contactSection || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = await this.findOne({ slug });
|
||||||
|
if (existing) {
|
||||||
|
existing.set(payload);
|
||||||
|
await existing.save();
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return this.create(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model("StudentSupport", studentSupportSchema);
|
||||||
@@ -14,6 +14,8 @@ const admissionsController = require("../controllers/admissionsController");
|
|||||||
const policiesController = require("../controllers/policiesController");
|
const policiesController = require("../controllers/policiesController");
|
||||||
const formController = require("../controllers/formController");
|
const formController = require("../controllers/formController");
|
||||||
const contactController = require("../controllers/contactController");
|
const contactController = require("../controllers/contactController");
|
||||||
|
const studentSupportController = require("../controllers/studentSupportController");
|
||||||
|
const requestInfoController = require("../controllers/requestInfoController");
|
||||||
const pageController = require("../controllers/pageController");
|
const pageController = require("../controllers/pageController");
|
||||||
const settingController = require("../controllers/settingController");
|
const settingController = require("../controllers/settingController");
|
||||||
const faqController = require("../controllers/faqController"); // Thêm import này
|
const faqController = require("../controllers/faqController"); // Thêm import này
|
||||||
@@ -29,6 +31,7 @@ const activityController = require("../controllers/activityController");
|
|||||||
const bookingSubmissionController = require("../controllers/bookingSubmissionController");
|
const bookingSubmissionController = require("../controllers/bookingSubmissionController");
|
||||||
const serviceController = require("../controllers/serviceController");
|
const serviceController = require("../controllers/serviceController");
|
||||||
const headerMenuController = require("../controllers/headerMenuController");
|
const headerMenuController = require("../controllers/headerMenuController");
|
||||||
|
const programmeController = require("../controllers/programmeController");
|
||||||
|
|
||||||
// Blog controllers
|
// Blog controllers
|
||||||
const blogController = require("../controllers/blogController");
|
const blogController = require("../controllers/blogController");
|
||||||
@@ -211,6 +214,30 @@ router.put(
|
|||||||
contactController.updateSubmissionStatus,
|
contactController.updateSubmissionStatus,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Student Support (LAMS static page)
|
||||||
|
router.get(
|
||||||
|
"/student-support",
|
||||||
|
ensureAuthenticated,
|
||||||
|
studentSupportController.index,
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/student-support/update",
|
||||||
|
ensureAuthenticated,
|
||||||
|
studentSupportController.update,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Request Info (LAMS static page)
|
||||||
|
router.get(
|
||||||
|
"/request-info",
|
||||||
|
ensureAuthenticated,
|
||||||
|
requestInfoController.index,
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/request-info/update",
|
||||||
|
ensureAuthenticated,
|
||||||
|
requestInfoController.update,
|
||||||
|
);
|
||||||
|
|
||||||
// Appointment management
|
// Appointment management
|
||||||
const appointmentController = require("../controllers/appointmentController");
|
const appointmentController = require("../controllers/appointmentController");
|
||||||
router.get(
|
router.get(
|
||||||
@@ -538,6 +565,13 @@ router.delete(
|
|||||||
ensureAuthenticated,
|
ensureAuthenticated,
|
||||||
visaController.deleteCountry,
|
visaController.deleteCountry,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Programme Management
|
||||||
|
router.get("/programme", ensureAuthenticated, programmeController.index);
|
||||||
|
router.get("/programme/edit/:id", ensureAuthenticated, programmeController.edit);
|
||||||
|
router.post("/programme/update/:id", ensureAuthenticated, upload.single('heroImage'), programmeController.update);
|
||||||
|
router.post("/programme/delete/:id", ensureAuthenticated, programmeController.delete);
|
||||||
|
|
||||||
// Blog routes
|
// Blog routes
|
||||||
// Blog Management Routes
|
// Blog Management Routes
|
||||||
router.get("/blog", ensureAuthenticated, blogController.index);
|
router.get("/blog", ensureAuthenticated, blogController.index);
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ const headerController = require("../controllers/headerController");
|
|||||||
const socialLinkController = require("../controllers/socialLinkController");
|
const socialLinkController = require("../controllers/socialLinkController");
|
||||||
const footerController = require("../controllers/footerController");
|
const footerController = require("../controllers/footerController");
|
||||||
const contactController = require("../controllers/contactController");
|
const contactController = require("../controllers/contactController");
|
||||||
|
const studentSupportController = require("../controllers/studentSupportController");
|
||||||
|
const requestInfoController = require("../controllers/requestInfoController");
|
||||||
const faqController = require("../controllers/faqController");
|
const faqController = require("../controllers/faqController");
|
||||||
const visaController = require("../controllers/visaController");
|
const visaController = require("../controllers/visaController");
|
||||||
const headerMenuController = require("../controllers/headerMenuController");
|
const headerMenuController = require("../controllers/headerMenuController");
|
||||||
const safetyController = require("../controllers/safetyController");
|
const safetyController = require("../controllers/safetyController");
|
||||||
// Booking flow removed
|
// Booking flow removed
|
||||||
|
|
||||||
|
const programmeController = require("../controllers/programmeController");
|
||||||
const insuranceController = require("../controllers/insuranceController");
|
const insuranceController = require("../controllers/insuranceController");
|
||||||
const termsController = require("../controllers/termsController"); // <-- IMPORT ĐÃ CÓ
|
const termsController = require("../controllers/termsController"); // <-- IMPORT ĐÃ CÓ
|
||||||
const activityController = require("../controllers/activityController");
|
const activityController = require("../controllers/activityController");
|
||||||
@@ -74,6 +77,12 @@ router.put("/api/admin/footer", footerController.updateFooter);
|
|||||||
// Contact API route
|
// Contact API route
|
||||||
router.get("/api/contact", contactController.api);
|
router.get("/api/contact", contactController.api);
|
||||||
|
|
||||||
|
// Student Support page (public API for frontend LAMS)
|
||||||
|
router.get("/api/student-support", studentSupportController.api);
|
||||||
|
|
||||||
|
// Request Info page (public API for frontend LAMS)
|
||||||
|
router.get("/api/request-info", requestInfoController.api);
|
||||||
|
|
||||||
// Contact form submission (public)
|
// Contact form submission (public)
|
||||||
router.post("/api/contact/submit", contactController.submitForm);
|
router.post("/api/contact/submit", contactController.submitForm);
|
||||||
|
|
||||||
@@ -185,6 +194,10 @@ router.get("/api/service-slugs", serviceController.getServiceSlugs);
|
|||||||
router.get("/api/visa", visaController.api);
|
router.get("/api/visa", visaController.api);
|
||||||
router.get("/api/visa/country", visaController.apiCountries);
|
router.get("/api/visa/country", visaController.apiCountries);
|
||||||
|
|
||||||
|
// Programmes API
|
||||||
|
router.get("/api/programmes", programmeController.api);
|
||||||
|
router.get("/api/programmes/:id", programmeController.apiDetail);
|
||||||
|
|
||||||
// Testimonials API
|
// Testimonials API
|
||||||
const testimonialController = require("../controllers/testimonialController");
|
const testimonialController = require("../controllers/testimonialController");
|
||||||
router.get("/api/testimonials", testimonialController.api);
|
router.get("/api/testimonials", testimonialController.api);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async function migrate() {
|
|||||||
console.log("✅ Home model registered successfully");
|
console.log("✅ Home model registered successfully");
|
||||||
|
|
||||||
// 3) Load JSON data
|
// 3) Load JSON data
|
||||||
const dataPath = path.join(__dirname, "..", "..", "hailearning.edu.vn", "app", "home.json");
|
const dataPath = path.join(__dirname, "..", "data", "home.json");
|
||||||
const raw = await fs.readFile(dataPath, "utf8");
|
const raw = await fs.readFile(dataPath, "utf8");
|
||||||
const homeData = JSON.parse(raw);
|
const homeData = JSON.parse(raw);
|
||||||
console.log("📖 Home data loaded from:", dataPath);
|
console.log("📖 Home data loaded from:", dataPath);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
const fs = require("fs").promises;
|
||||||
|
const path = require("path");
|
||||||
|
const connectDB = require("../config/database");
|
||||||
|
const StudentSupport = require("../models/studentSupport");
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: Student Support page content (slug student-support)
|
||||||
|
* Reads data/student-support.json and upserts MongoDB.
|
||||||
|
*/
|
||||||
|
async function migrate() {
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
const jsonPath = path.join(__dirname, "../data/student-support.json");
|
||||||
|
const raw = await fs.readFile(jsonPath, "utf8");
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
await StudentSupport.migrateFromJson(data);
|
||||||
|
console.log("Student Support migration completed successfully");
|
||||||
|
await mongoose.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Student Support migration error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { migrate };
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
const connectDB = require("../config/database");
|
||||||
|
const User = require("../models/User");
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: seed_users
|
||||||
|
* Description: Tạo sẵn tài khoản cứng cho login CMS
|
||||||
|
*/
|
||||||
|
async function migrate() {
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
|
||||||
|
const users = [
|
||||||
|
{
|
||||||
|
username: process.env.ADMIN_USERNAME || "admin",
|
||||||
|
email: "admin@cms.local",
|
||||||
|
name: "System Admin",
|
||||||
|
password: process.env.ADMIN_PASSWORD || "admin1234",
|
||||||
|
role: "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: "manager",
|
||||||
|
email: "manager@cms.local",
|
||||||
|
name: "Content Manager",
|
||||||
|
password: "manager1234",
|
||||||
|
role: "manager",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const item of users) {
|
||||||
|
const existing = await User.findOne({ username: item.username });
|
||||||
|
if (existing) {
|
||||||
|
console.log(`SKIP user exists: ${item.username}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await User.create(item);
|
||||||
|
console.log(`DONE created user: ${item.username}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("User seed migration completed.");
|
||||||
|
await mongoose.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("User seed migration failed:", error);
|
||||||
|
if (mongoose.connection.readyState === 1) {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { migrate };
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
const fs = require("fs").promises;
|
||||||
|
const path = require("path");
|
||||||
|
const connectDB = require("../config/database");
|
||||||
|
const RequestInfo = require("../models/requestInfo");
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: Request Info page content (slug: request-info)
|
||||||
|
* Reads data/request-info.json and upserts into MongoDB.
|
||||||
|
* Idempotent — safe to re-run.
|
||||||
|
*/
|
||||||
|
async function migrate() {
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
const jsonPath = path.join(__dirname, "../data/request-info.json");
|
||||||
|
const raw = await fs.readFile(jsonPath, "utf8");
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
await RequestInfo.migrateFromJson(data);
|
||||||
|
console.log("Request Info migration completed successfully");
|
||||||
|
await mongoose.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Request Info migration error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { migrate };
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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 page content (slug: contact)
|
||||||
|
* Reads data/contact.json and upserts into MongoDB.
|
||||||
|
* Idempotent — safe to re-run.
|
||||||
|
*/
|
||||||
|
async function migrate() {
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
const jsonPath = path.join(__dirname, "../data/contact.json");
|
||||||
|
const raw = await fs.readFile(jsonPath, "utf8");
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
await Contact.migrateFromJson(data);
|
||||||
|
console.log("Contact migration completed successfully");
|
||||||
|
await mongoose.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Contact migration error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { migrate };
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
const fs = require("fs").promises;
|
||||||
|
const path = require("path");
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
const connectDB = require("../config/database");
|
||||||
|
const Programme = require("../models/programme");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed / upsert all programmes from data/programmes.json into MongoDB.
|
||||||
|
* Run: node scripts/2026_04_20_150000_seed_programmes.js
|
||||||
|
*/
|
||||||
|
async function migrate() {
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
console.log("Connected to MongoDB");
|
||||||
|
|
||||||
|
const jsonPath = path.join(__dirname, "../data/programmes.json");
|
||||||
|
const raw = await fs.readFile(jsonPath, "utf8");
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error("data/programmes.json must be a JSON array");
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Programme.migrateFromJson(data);
|
||||||
|
console.log("Programme migration completed:");
|
||||||
|
results.forEach((r) => console.log(` [${r.action}] id=${r.id}`));
|
||||||
|
|
||||||
|
await mongoose.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Programme migration error:", error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { migrate };
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const connectDB = require('../config/database');
|
||||||
|
const Programme = require('../models/programme');
|
||||||
|
|
||||||
|
async function validateProgrammeData(dataArray) {
|
||||||
|
if (!Array.isArray(dataArray)) {
|
||||||
|
throw new Error('Data must be an array of programme objects');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataArray.length === 0) {
|
||||||
|
throw new Error('Programme array cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of dataArray) {
|
||||||
|
if (!item.id || !item.title || !item.level) {
|
||||||
|
throw new Error(`Programme is missing required fields (id, title, level). Error at id: ${item.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateProgrammeData() {
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
console.log('Đã kết nối đến MongoDB...');
|
||||||
|
|
||||||
|
await Programme.deleteMany({});
|
||||||
|
console.log('Đã xóa dữ liệu Programme cũ');
|
||||||
|
|
||||||
|
const programmesData = JSON.parse(
|
||||||
|
await fs.readFile(path.join(__dirname, '../data/programmes.json'), 'utf8')
|
||||||
|
);
|
||||||
|
|
||||||
|
await validateProgrammeData(programmesData);
|
||||||
|
|
||||||
|
const dataWithTimestamps = programmesData.map(item => ({
|
||||||
|
...item,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}));
|
||||||
|
|
||||||
|
await Programme.insertMany(dataWithTimestamps);
|
||||||
|
console.log(`✓ Migrate dữ liệu Programmes thành công (${dataWithTimestamps.length} items)!`);
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lỗi:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateProgrammeData();
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Danh sách icon Font Awesome cho admin (dropdown) — tránh để user gõ class tự do.
|
||||||
|
* Giá trị lưu DB/API phải khớp một trong các `value` bên dưới (hoặc được map về default).
|
||||||
|
*/
|
||||||
|
const ICON_OPTIONS = [
|
||||||
|
{ value: "fa-solid fa-compass", label: "Compass (solid)" },
|
||||||
|
{ value: "fa-solid fa-language", label: "Language (solid)" },
|
||||||
|
{ value: "fa-solid fa-briefcase", label: "Briefcase (solid)" },
|
||||||
|
{ value: "fa-solid fa-laptop-code", label: "Laptop code (solid)" },
|
||||||
|
{ value: "fa-solid fa-universal-access", label: "Universal access (solid)" },
|
||||||
|
{ value: "fa-solid fa-users", label: "Users (solid)" },
|
||||||
|
{ value: "fa-solid fa-phone", label: "Phone (solid)" },
|
||||||
|
{ value: "fa-solid fa-envelope", label: "Envelope (solid)" },
|
||||||
|
{ value: "fa-solid fa-comments", label: "Comments (solid)" },
|
||||||
|
{ value: "fa-solid fa-globe", label: "Globe (solid)" },
|
||||||
|
{ value: "fa-regular fa-clock", label: "Clock (regular)" },
|
||||||
|
{ value: "fa-solid fa-clock", label: "Clock (solid)" },
|
||||||
|
{ value: "fa-solid fa-lightbulb", label: "Lightbulb (solid)" },
|
||||||
|
{ value: "fa-solid fa-circle-info", label: "Info (solid)" },
|
||||||
|
{ value: "fa-solid fa-headset", label: "Headset (solid)" },
|
||||||
|
{ value: "fa-solid fa-book", label: "Book (solid)" },
|
||||||
|
{ value: "fa-solid fa-graduation-cap", label: "Graduation cap (solid)" },
|
||||||
|
{ value: "fa-solid fa-handshake", label: "Handshake (solid)" },
|
||||||
|
{ value: "fa-solid fa-piggy-bank", label: "Piggy bank (solid)" },
|
||||||
|
{ value: "fa-solid fa-certificate", label: "Certificate (solid)" },
|
||||||
|
{ value: "fa-regular fa-envelope", label: "Envelope (regular)" },
|
||||||
|
{ value: "fa-solid fa-map-location-dot", label: "Map location dot (solid)" },
|
||||||
|
{ value: "fa-solid fa-map-marker-alt", label: "Map marker (solid)" },
|
||||||
|
{ value: "fa-solid fa-location-dot", label: "Location dot (solid)" },
|
||||||
|
{ value: "fa-solid fa-fax", label: "Fax (solid)" },
|
||||||
|
{ value: "fa-solid fa-mobile-alt", label: "Mobile (solid)" },
|
||||||
|
{ value: "fa-solid fa-building", label: "Building (solid)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALLOWED = new Set(ICON_OPTIONS.map((o) => o.value));
|
||||||
|
|
||||||
|
function normalizeIconClass(value, fallback = "fa-solid fa-circle-info") {
|
||||||
|
if (!value || typeof value !== "string") return fallback;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (ALLOWED.has(trimmed)) return trimmed;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ICON_OPTIONS,
|
||||||
|
normalizeIconClass,
|
||||||
|
};
|
||||||
+445
-1495
File diff suppressed because it is too large
Load Diff
+141
-2
@@ -103,7 +103,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Student Support -->
|
||||||
|
<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-life-ring fa-lg" style="color: var(--primary-color);"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0">Student Support</h5>
|
||||||
|
<p class="text-muted mb-0 small">LAMS student support page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/student-support" class="btn btn-sm btn-primary w-100 mt-2">
|
||||||
|
<i class="fas fa-edit me-2"></i>Edit
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request Info -->
|
||||||
|
<div class="col-md-4 border-top">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<div class="rounded-circle d-flex align-items-center justify-content-center me-3"
|
||||||
|
style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
|
||||||
|
<i class="fas fa-file-alt fa-lg" style="color: var(--primary-color);"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0">Request Info</h5>
|
||||||
|
<p class="text-muted mb-0 small">LAMS request information page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/request-info" class="btn btn-sm btn-primary w-100 mt-2">
|
||||||
|
<i class="fas fa-edit me-2"></i>Edit
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Appointment -->
|
<!-- Appointment -->
|
||||||
<div class="col-md-4 border-end border-top">
|
<div class="col-md-4 border-end border-top">
|
||||||
@@ -198,6 +234,25 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Programme -->
|
||||||
|
<div class="col-md-4 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-graduation-cap fa-lg" style="color: var(--primary-color);"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0">Programmes</h5>
|
||||||
|
<p class="text-muted mb-0 small">Manage academic programmes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/programme" class="btn btn-sm btn-primary w-100 mt-2">
|
||||||
|
<i class="fas fa-edit me-2"></i>Manage
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +261,7 @@
|
|||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0">API Endpoints</h5>
|
<h5 class="mb-0">API Endpoints</h5>
|
||||||
<span class="badge bg-primary">6 APIs</span>
|
<span class="badge bg-primary">9 APIs</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@@ -329,6 +384,48 @@
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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-life-ring" style="color: var(--primary-color);"></i>
|
||||||
|
</div>
|
||||||
|
<span>Student Support API</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><code>/api/student-support</code></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" style="background-color: var(--primary-color)">GET</span>
|
||||||
|
</td>
|
||||||
|
<td>LAMS Student Support page JSON</td>
|
||||||
|
<td>
|
||||||
|
<a href="/api/student-support" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||||
|
<i class="fas fa-external-link-alt me-1"></i>View
|
||||||
|
</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-file-alt" style="color: var(--primary-color);"></i>
|
||||||
|
</div>
|
||||||
|
<span>Request Info API</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><code>/api/request-info</code></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" style="background-color: var(--primary-color)">GET</span>
|
||||||
|
</td>
|
||||||
|
<td>LAMS Request Information page JSON</td>
|
||||||
|
<td>
|
||||||
|
<a href="/api/request-info" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||||
|
<i class="fas fa-external-link-alt me-1"></i>View
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
@@ -343,13 +440,55 @@
|
|||||||
<td>
|
<td>
|
||||||
<span class="badge" style="background-color: var(--primary-color)">GET</span>
|
<span class="badge" style="background-color: var(--primary-color)">GET</span>
|
||||||
</td>
|
</td>
|
||||||
<td>API to get blog posts</td>
|
<td>List published blog posts (supports ?page, ?category, ?search)</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="/api/blog" class="btn btn-sm btn-outline-primary" target="_blank">
|
<a href="/api/blog" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||||
<i class="fas fa-external-link-alt me-1"></i>View
|
<i class="fas fa-external-link-alt me-1"></i>View
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<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>Programmes API</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><code>/api/programmes</code></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" style="background-color: var(--primary-color)">GET</span>
|
||||||
|
</td>
|
||||||
|
<td>List all academic programmes</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>
|
||||||
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,610 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-4 mb-4 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0" style="color: var(--primary-dark);"><%= title %></h1>
|
||||||
|
<p class="text-muted mb-0">Fields marked with <span class="text-danger">*</span> are required.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<% if (programme._id && frontendUrl) { %>
|
||||||
|
<a href="<%= frontendUrl %>/programmes/<%= programme.id %>" class="btn btn-outline-primary" target="_blank" rel="noopener">
|
||||||
|
<i class="fas fa-external-link-alt me-2"></i>Preview
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
<a href="/admin/programme" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>Back to list
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
action="/admin/programme/update/<%= programme._id ? programme._id : 'new' %>?imageType=programmes"
|
||||||
|
method="POST"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
id="programmeForm"
|
||||||
|
class="content-with-fixed-buttons"
|
||||||
|
>
|
||||||
|
<%# Hidden JSON inputs for dynamic arrays %>
|
||||||
|
<input type="hidden" name="coreCourses" id="coreCoursesJson">
|
||||||
|
<input type="hidden" name="outcomes" id="outcomesJson">
|
||||||
|
<input type="hidden" name="faqs" id="faqsJson">
|
||||||
|
<input type="hidden" name="electives" id="electivesJson">
|
||||||
|
|
||||||
|
<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="#tab-basic" role="tab">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>Basic Info
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#tab-detail" role="tab">
|
||||||
|
<i class="fas fa-file-alt me-2"></i>Detail Page
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#tab-curriculum" role="tab">
|
||||||
|
<i class="fas fa-book me-2"></i>Curriculum
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#tab-outcomes" role="tab">
|
||||||
|
<i class="fas fa-star me-2"></i>Outcomes
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#tab-faq" role="tab">
|
||||||
|
<i class="fas fa-question-circle me-2"></i>FAQs
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#tab-pricing" role="tab">
|
||||||
|
<i class="fas fa-dollar-sign me-2"></i>Pricing
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content">
|
||||||
|
|
||||||
|
<%# ─── TAB 1: Basic Info ─────────────────────────────────────────── %>
|
||||||
|
<div class="tab-pane fade show active" id="tab-basic" role="tabpanel">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium" for="fieldId">ID / Code <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="fieldId" name="id" value="<%= programme.id || '' %>" required placeholder="e.g. bs-cs, mba-db">
|
||||||
|
<div class="form-text">Dùng làm URL slug: /programmes/<strong>bs-cs</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium" for="fieldLevel">Level</label>
|
||||||
|
<input type="text" class="form-control" id="fieldLevel" name="level" value="<%= programme.level || '' %>" placeholder="Bachelor's Degree, Master's…">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium" for="fieldShortName">Short Name</label>
|
||||||
|
<input type="text" class="form-control" id="fieldShortName" name="shortName" value="<%= programme.shortName || '' %>" placeholder="MBA, B.S., Cert…">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="fieldTitle">Title <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="fieldTitle" name="title" value="<%= programme.title || '' %>" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="fieldDescription">Description (Listing card)</label>
|
||||||
|
<textarea class="form-control" id="fieldDescription" name="description" rows="3"><%= programme.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium" for="fieldDuration">Duration</label>
|
||||||
|
<input type="text" class="form-control" id="fieldDuration" name="duration" value="<%= programme.duration || '' %>" placeholder="4 Years (Flexible)">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium" for="fieldFormat">Format</label>
|
||||||
|
<input type="text" class="form-control" id="fieldFormat" name="format" value="<%= programme.format || '100% Online' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium" for="fieldLink">Public Link</label>
|
||||||
|
<input type="text" class="form-control" id="fieldLink" name="link" value="<%= programme.link || '' %>" placeholder="/programmes/bs-cs">
|
||||||
|
<div class="form-text">Tự sinh từ ID nếu để trống. Hiện tại: <code id="linkPreview">/programmes/<%= programme.id || '…' %></code></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="fieldSelected" name="selected" value="true" <%= programme.selected ? 'checked' : '' %>>
|
||||||
|
<label class="form-check-label" for="fieldSelected">
|
||||||
|
<i class="fas fa-star text-warning me-1"></i>Featured / Selected (hiển thị nổi bật trên listing)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ─── TAB 2: Detail Page ────────────────────────────────────────── %>
|
||||||
|
<div class="tab-pane fade" id="tab-detail" role="tabpanel">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium" for="fieldDetailBadge">Detail Badge</label>
|
||||||
|
<input type="text" class="form-control" id="fieldDetailBadge" name="detailBadge" value="<%= programme.detailBadge || '' %>" placeholder="Bachelor of Science">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium" for="fieldDetailTitle">Detail Title</label>
|
||||||
|
<input type="text" class="form-control" id="fieldDetailTitle" name="detailTitle" value="<%= programme.detailTitle || '' %>" placeholder="Computer Science">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="fieldDetailDescription">Detail Description</label>
|
||||||
|
<textarea class="form-control" id="fieldDetailDescription" name="detailDescription" rows="3"><%= programme.detailDescription || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="fieldOverview">Overview (main body text)</label>
|
||||||
|
<textarea class="form-control" id="fieldOverview" name="overview" rows="5"><%= programme.overview || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium" for="fieldCredits">Total Credits</label>
|
||||||
|
<input type="number" class="form-control" id="fieldCredits" name="credits" value="<%= programme.credits || 120 %>" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium" for="fieldNextStartDate">Next Start Date</label>
|
||||||
|
<input type="text" class="form-control" id="fieldNextStartDate" name="nextStartDate" value="<%= programme.nextStartDate || '' %>" placeholder="September 1st">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<% /* placeholder col */ %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Hero Image Upload %>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label fw-medium" for="heroImageUrl">Hero Image URL</label>
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<input type="text" class="form-control" id="heroImageUrl" name="heroImageUrl" value="<%= programme.heroImage || '' %>">
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-upload-image" data-target-input="heroImageUrl" data-image-type="programmes">
|
||||||
|
<i class="fas fa-upload me-1"></i>Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Hoặc upload file: </small>
|
||||||
|
<input type="file" name="heroImage" class="form-control form-control-sm mt-1" accept="image/*" id="heroImageFile">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 d-flex align-items-end">
|
||||||
|
<div id="heroImagePreview" class="border rounded overflow-hidden bg-light w-100" style="height:100px;">
|
||||||
|
<% const hi = programme.heroImage; if (hi) { %>
|
||||||
|
<% let src = hi; if (!src.startsWith('http')) { src = src.startsWith('/') ? src : '/' + src; } %>
|
||||||
|
<img src="<%= src %>" alt="" class="w-100 h-100" style="object-fit:cover;" id="heroPreviewImg" onerror="this.style.display='none'">
|
||||||
|
<% } else { %>
|
||||||
|
<div class="h-100 d-flex align-items-center justify-content-center text-muted small">
|
||||||
|
<div class="text-center"><i class="fas fa-image fa-2x mb-1 d-block opacity-25"></i>No image</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ─── TAB 3: Curriculum ─────────────────────────────────────────── %>
|
||||||
|
<div class="tab-pane fade" id="tab-curriculum" role="tabpanel">
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">Core Courses</h6>
|
||||||
|
<small class="text-muted">Môn học bắt buộc trong chương trình</small>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" id="addCourseBtn">
|
||||||
|
<i class="fas fa-plus me-1"></i>Add Course
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="coursesContainer" class="vstack gap-3">
|
||||||
|
<% (programme.coreCourses || []).forEach(function(course, idx) { %>
|
||||||
|
<div class="card course-row border">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small">Course ID</label>
|
||||||
|
<input type="text" class="form-control course-id" value="<%= course.id || '' %>" placeholder="CS101">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Title</label>
|
||||||
|
<input type="text" class="form-control course-title" value="<%= course.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Icon</label>
|
||||||
|
<select class="form-select course-icon-select">
|
||||||
|
<option value="">— No icon —</option>
|
||||||
|
<% iconOptions.forEach(function(opt) { %>
|
||||||
|
<option value="<%= opt.value %>" <%= (course.icon === opt.value) ? 'selected' : '' %>><%= opt.label %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-flex align-items-end justify-content-end">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm remove-course">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label small">Description</label>
|
||||||
|
<input type="text" class="form-control course-description" value="<%= course.description || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">Electives</h6>
|
||||||
|
<small class="text-muted">Môn học tự chọn</small>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" id="addElectiveBtn">
|
||||||
|
<i class="fas fa-plus me-1"></i>Add Elective
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="electivesContainer" class="vstack gap-2">
|
||||||
|
<% (programme.electives || []).forEach(function(elective) { %>
|
||||||
|
<div class="input-group elective-row">
|
||||||
|
<input type="text" class="form-control elective-input" value="<%= elective %>">
|
||||||
|
<button type="button" class="btn btn-outline-danger remove-elective"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ─── TAB 4: Outcomes ───────────────────────────────────────────── %>
|
||||||
|
<div class="tab-pane fade" id="tab-outcomes" role="tabpanel">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">Learning Outcomes</h6>
|
||||||
|
<small class="text-muted">Những gì sinh viên đạt được sau khoá học</small>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" id="addOutcomeBtn">
|
||||||
|
<i class="fas fa-plus me-1"></i>Add Outcome
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="outcomesContainer" class="vstack gap-3">
|
||||||
|
<% (programme.outcomes || []).forEach(function(outcome) { %>
|
||||||
|
<div class="card outcome-row border">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Title</label>
|
||||||
|
<input type="text" class="form-control outcome-title" value="<%= outcome.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label small">Icon</label>
|
||||||
|
<select class="form-select outcome-icon-select">
|
||||||
|
<option value="">— No icon —</option>
|
||||||
|
<% iconOptions.forEach(function(opt) { %>
|
||||||
|
<option value="<%= opt.value %>" <%= (outcome.icon === opt.value) ? 'selected' : '' %>><%= opt.label %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 d-flex align-items-end justify-content-end">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm remove-outcome"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label small">Description</label>
|
||||||
|
<textarea class="form-control outcome-description" rows="2"><%= outcome.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ─── TAB 5: FAQs ───────────────────────────────────────────────── %>
|
||||||
|
<div class="tab-pane fade" id="tab-faq" role="tabpanel">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">Frequently Asked Questions</h6>
|
||||||
|
<small class="text-muted">Câu hỏi thường gặp trên trang chi tiết chương trình</small>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" id="addFaqBtn">
|
||||||
|
<i class="fas fa-plus me-1"></i>Add FAQ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="faqsContainer" class="vstack gap-3">
|
||||||
|
<% (programme.faqs || []).forEach(function(faq) { %>
|
||||||
|
<div class="card faq-row border">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-11">
|
||||||
|
<label class="form-label small">Question</label>
|
||||||
|
<input type="text" class="form-control faq-question" value="<%= faq.question || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 d-flex align-items-end justify-content-end">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm remove-faq"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label small">Answer</label>
|
||||||
|
<textarea class="form-control faq-answer" rows="2"><%= faq.answer || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ─── TAB 6: Pricing ────────────────────────────────────────────── %>
|
||||||
|
<div class="tab-pane fade" id="tab-pricing" role="tabpanel">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium" for="fieldCost">Cost (hiển thị listing)</label>
|
||||||
|
<input type="text" class="form-control" id="fieldCost" name="cost" value="<%= programme.cost || '' %>" placeholder="$299 / month">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium" for="fieldMonthlyCost">Monthly Cost (sidebar)</label>
|
||||||
|
<input type="text" class="form-control" id="fieldMonthlyCost" name="monthlyCost" value="<%= programme.monthlyCost || '' %>" placeholder="$299">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium" for="fieldPerCourseCost">Per Course Cost (sidebar)</label>
|
||||||
|
<input type="text" class="form-control" id="fieldPerCourseCost" name="perCourseCost" value="<%= programme.perCourseCost || '' %>" placeholder="$450">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><%# end .tab-content %>
|
||||||
|
</div><%# end .card-body %>
|
||||||
|
</div><%# end .card %>
|
||||||
|
|
||||||
|
<div class="fixed-bottom-buttons">
|
||||||
|
<a href="/admin/programme" 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>Save Programme
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.PROGRAMME_ICON_OPTIONS = <%- JSON.stringify(iconOptions || []) %>;
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s || '')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconOptionsHtml(selected) {
|
||||||
|
var opts = window.PROGRAMME_ICON_OPTIONS;
|
||||||
|
var html = '<option value="">— No icon —</option>';
|
||||||
|
for (var i = 0; i < opts.length; i++) {
|
||||||
|
var o = opts[i];
|
||||||
|
var sel = (o.value === selected) ? ' selected' : '';
|
||||||
|
html += '<option value="' + escHtml(o.value) + '"' + sel + '>' + escHtml(o.label) + '</option>';
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCourseRow(c) {
|
||||||
|
c = c || {};
|
||||||
|
return (
|
||||||
|
'<div class="card course-row border">' +
|
||||||
|
'<div class="card-body">' +
|
||||||
|
'<div class="row g-2">' +
|
||||||
|
'<div class="col-md-2"><label class="form-label small">Course ID</label>' +
|
||||||
|
'<input type="text" class="form-control course-id" value="' + escHtml(c.id) + '" placeholder="CS101"></div>' +
|
||||||
|
'<div class="col-md-4"><label class="form-label small">Title</label>' +
|
||||||
|
'<input type="text" class="form-control course-title" value="' + escHtml(c.title) + '"></div>' +
|
||||||
|
'<div class="col-md-4"><label class="form-label small">Icon</label>' +
|
||||||
|
'<select class="form-select course-icon-select"><' + 'option value="">— No icon —</' + 'option>' + iconOptionsHtml(c.icon) + '</select></div>' +
|
||||||
|
'<div class="col-md-2 d-flex align-items-end justify-content-end">' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger btn-sm remove-course"><i class="fas fa-trash"></i></button></div>' +
|
||||||
|
'<div class="col-md-12"><label class="form-label small">Description</label>' +
|
||||||
|
'<input type="text" class="form-control course-description" value="' + escHtml(c.description) + '"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOutcomeRow(o) {
|
||||||
|
o = o || {};
|
||||||
|
return (
|
||||||
|
'<div class="card outcome-row border">' +
|
||||||
|
'<div class="card-body">' +
|
||||||
|
'<div class="row g-2">' +
|
||||||
|
'<div class="col-md-6"><label class="form-label small">Title</label>' +
|
||||||
|
'<input type="text" class="form-control outcome-title" value="' + escHtml(o.title) + '"></div>' +
|
||||||
|
'<div class="col-md-5"><label class="form-label small">Icon</label>' +
|
||||||
|
'<select class="form-select outcome-icon-select"><option value="">— No icon —</option>' + iconOptionsHtml(o.icon) + '</select></div>' +
|
||||||
|
'<div class="col-md-1 d-flex align-items-end justify-content-end">' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger btn-sm remove-outcome"><i class="fas fa-trash"></i></button></div>' +
|
||||||
|
'<div class="col-md-12"><label class="form-label small">Description</label>' +
|
||||||
|
'<textarea class="form-control outcome-description" rows="2">' + escHtml(o.description) + '</textarea></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFaqRow(f) {
|
||||||
|
f = f || {};
|
||||||
|
return (
|
||||||
|
'<div class="card faq-row border">' +
|
||||||
|
'<div class="card-body">' +
|
||||||
|
'<div class="row g-2">' +
|
||||||
|
'<div class="col-md-11"><label class="form-label small">Question</label>' +
|
||||||
|
'<input type="text" class="form-control faq-question" value="' + escHtml(f.question) + '"></div>' +
|
||||||
|
'<div class="col-md-1 d-flex align-items-end justify-content-end">' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger btn-sm remove-faq"><i class="fas fa-trash"></i></button></div>' +
|
||||||
|
'<div class="col-md-12"><label class="form-label small">Answer</label>' +
|
||||||
|
'<textarea class="form-control faq-answer" rows="2">' + escHtml(f.answer) + '</textarea></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeroPreview(url) {
|
||||||
|
var el = document.getElementById('heroImagePreview');
|
||||||
|
if (!el) return;
|
||||||
|
if (!url) {
|
||||||
|
el.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-muted small"><div class="text-center"><i class="fas fa-image fa-2x mb-1 d-block opacity-25"></i>No image</div></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var src = url;
|
||||||
|
if (!src.startsWith('http://') && !src.startsWith('https://')) {
|
||||||
|
src = src.startsWith('/') ? src : '/' + src;
|
||||||
|
}
|
||||||
|
el.innerHTML = '<img src="' + escHtml(src) + '" alt="" class="w-100 h-100" style="object-fit:cover;">';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImageUploader(targetInputId, imageType) {
|
||||||
|
var fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.accept = 'image/*';
|
||||||
|
fileInput.style.display = 'none';
|
||||||
|
document.body.appendChild(fileInput);
|
||||||
|
fileInput.onchange = async function (e) {
|
||||||
|
var file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
var uploadBtn = document.querySelector('[data-target-input="' + targetInputId + '"]');
|
||||||
|
var original = uploadBtn ? uploadBtn.innerHTML : '';
|
||||||
|
if (uploadBtn) { uploadBtn.disabled = true; uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>...'; }
|
||||||
|
var res = await fetch('/admin/upload/image?imageType=' + encodeURIComponent(imageType || 'general'), {
|
||||||
|
method: 'POST', body: formData,
|
||||||
|
});
|
||||||
|
var result = await res.json();
|
||||||
|
if (!result.success) throw new Error(result.error || 'Upload failed');
|
||||||
|
var input = document.getElementById(targetInputId);
|
||||||
|
if (input) { input.value = result.path || ''; updateHeroPreview(input.value); }
|
||||||
|
if (uploadBtn) { uploadBtn.disabled = false; uploadBtn.innerHTML = original; }
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Upload failed: ' + (err.message || err));
|
||||||
|
var b = document.querySelector('[data-target-input="' + targetInputId + '"]');
|
||||||
|
if (b) { b.disabled = false; b.innerHTML = '<i class="fas fa-upload me-1"></i>Upload'; }
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(fileInput);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHiddenJson() {
|
||||||
|
// Core Courses
|
||||||
|
var courses = [];
|
||||||
|
document.querySelectorAll('#coursesContainer .course-row').forEach(function(row) {
|
||||||
|
courses.push({
|
||||||
|
id: row.querySelector('.course-id').value.trim(),
|
||||||
|
title: row.querySelector('.course-title').value.trim(),
|
||||||
|
description: row.querySelector('.course-description').value.trim(),
|
||||||
|
icon: row.querySelector('.course-icon-select').value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('coreCoursesJson').value = JSON.stringify(courses);
|
||||||
|
|
||||||
|
// Outcomes
|
||||||
|
var outcomes = [];
|
||||||
|
document.querySelectorAll('#outcomesContainer .outcome-row').forEach(function(row) {
|
||||||
|
outcomes.push({
|
||||||
|
title: row.querySelector('.outcome-title').value.trim(),
|
||||||
|
description: row.querySelector('.outcome-description').value.trim(),
|
||||||
|
icon: row.querySelector('.outcome-icon-select').value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('outcomesJson').value = JSON.stringify(outcomes);
|
||||||
|
|
||||||
|
// FAQs
|
||||||
|
var faqs = [];
|
||||||
|
document.querySelectorAll('#faqsContainer .faq-row').forEach(function(row) {
|
||||||
|
faqs.push({
|
||||||
|
question: row.querySelector('.faq-question').value.trim(),
|
||||||
|
answer: row.querySelector('.faq-answer').value.trim(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('faqsJson').value = JSON.stringify(faqs);
|
||||||
|
|
||||||
|
// Electives — gửi dạng [{value: "..."}, ...] giống student-support
|
||||||
|
var electives = [];
|
||||||
|
document.querySelectorAll('#electivesContainer .elective-input').forEach(function(inp) {
|
||||||
|
var v = inp.value.trim();
|
||||||
|
if (v) electives.push({ value: v });
|
||||||
|
});
|
||||||
|
document.getElementById('electivesJson').value = JSON.stringify(electives);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var form = document.getElementById('programmeForm');
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var btn = document.getElementById('submitBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving…';
|
||||||
|
try {
|
||||||
|
syncHiddenJson();
|
||||||
|
form.submit();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Could not prepare form data. Please try again.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-save me-2"></i>Save Programme';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload image buttons
|
||||||
|
document.querySelectorAll('.btn-upload-image').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
openImageUploader(this.dataset.targetInput, this.dataset.imageType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hero image preview sync on text input
|
||||||
|
var heroInput = document.getElementById('heroImageUrl');
|
||||||
|
if (heroInput) {
|
||||||
|
heroInput.addEventListener('input', function () { updateHeroPreview(this.value); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-sync link preview khi admin gõ ID
|
||||||
|
var fieldId = document.getElementById('fieldId');
|
||||||
|
var linkPreview = document.getElementById('linkPreview');
|
||||||
|
var fieldLink = document.getElementById('fieldLink');
|
||||||
|
if (fieldId && linkPreview) {
|
||||||
|
fieldId.addEventListener('input', function () {
|
||||||
|
var slug = this.value.trim().toLowerCase().replace(/\s+/g, '-');
|
||||||
|
linkPreview.textContent = '/programmes/' + (slug || '…');
|
||||||
|
// Nếu field link đang rỗng, tự động điền slug
|
||||||
|
if (fieldLink && !fieldLink.value) {
|
||||||
|
fieldLink.placeholder = '/programmes/' + (slug || 'your-id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add buttons
|
||||||
|
document.getElementById('addCourseBtn').addEventListener('click', function () {
|
||||||
|
document.getElementById('coursesContainer').insertAdjacentHTML('beforeend', buildCourseRow({}));
|
||||||
|
});
|
||||||
|
document.getElementById('addOutcomeBtn').addEventListener('click', function () {
|
||||||
|
document.getElementById('outcomesContainer').insertAdjacentHTML('beforeend', buildOutcomeRow({}));
|
||||||
|
});
|
||||||
|
document.getElementById('addFaqBtn').addEventListener('click', function () {
|
||||||
|
document.getElementById('faqsContainer').insertAdjacentHTML('beforeend', buildFaqRow({}));
|
||||||
|
});
|
||||||
|
document.getElementById('addElectiveBtn').addEventListener('click', function () {
|
||||||
|
document.getElementById('electivesContainer').insertAdjacentHTML('beforeend',
|
||||||
|
'<div class="input-group elective-row">' +
|
||||||
|
'<input type="text" class="form-control elective-input" value="">' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger remove-elective"><i class="fas fa-times"></i></button>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove buttons (event delegation)
|
||||||
|
document.getElementById('coursesContainer').addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('.remove-course')) e.target.closest('.course-row').remove();
|
||||||
|
});
|
||||||
|
document.getElementById('outcomesContainer').addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('.remove-outcome')) e.target.closest('.outcome-row').remove();
|
||||||
|
});
|
||||||
|
document.getElementById('faqsContainer').addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('.remove-faq')) e.target.closest('.faq-row').remove();
|
||||||
|
});
|
||||||
|
document.getElementById('electivesContainer').addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('.remove-elective')) e.target.closest('.elective-row').remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-4 mb-4 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0" style="color: var(--primary-dark);">Programmes Management</h1>
|
||||||
|
<p class="text-muted mb-0">Manage all academic programmes displayed on the public website.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<% if (frontendUrl) { %>
|
||||||
|
<a href="<%= frontendUrl %>/programmes" class="btn btn-outline-primary" target="_blank" rel="noopener">
|
||||||
|
<i class="fas fa-external-link-alt me-2"></i>View Programmes Page
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
<a href="/admin/programme/edit/new" class="btn btn-primary shadow-sm">
|
||||||
|
<i class="fas fa-plus me-2"></i>Add New Programme
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm border-0 mb-4">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-4" style="width:110px;">ID / Code</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Level</th>
|
||||||
|
<th>Format</th>
|
||||||
|
<th>Cost</th>
|
||||||
|
<th class="text-center" style="width:80px;">Courses</th>
|
||||||
|
<th class="text-center" style="width:80px;">Featured</th>
|
||||||
|
<th class="text-end pe-4" style="width:120px;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% if (data && data.length > 0) { %>
|
||||||
|
<% data.forEach(function(item) { %>
|
||||||
|
<tr>
|
||||||
|
<td class="ps-4">
|
||||||
|
<code class="text-primary"><%= item.id %></code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="fw-medium"><%= item.title %></span>
|
||||||
|
<% if (item.description) { %>
|
||||||
|
<div class="text-muted small text-truncate" style="max-width:280px;"><%= item.description %></div>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-light text-dark border"><%= item.level || '—' %></span>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small"><%= item.format || '—' %></td>
|
||||||
|
<td class="text-muted small"><%= item.monthlyCost || item.cost || '—' %></td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge bg-secondary rounded-pill"><%= (item.coreCourses || []).length %></span>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<% if (item.selected) { %>
|
||||||
|
<i class="fas fa-star text-warning" title="Featured"></i>
|
||||||
|
<% } else { %>
|
||||||
|
<i class="far fa-star text-muted" title="Not featured"></i>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td class="text-end pe-4">
|
||||||
|
<a href="/admin/programme/edit/<%= item._id %>" class="btn btn-sm btn-outline-primary me-1" title="Edit">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</a>
|
||||||
|
<form action="/admin/programme/delete/<%= item._id %>" method="POST" class="d-inline"
|
||||||
|
onsubmit="return confirm('Delete programme \'<%= item.title %>\'? This cannot be undone.');">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
<% } else { %>
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="text-center text-muted py-5">
|
||||||
|
<i class="fas fa-graduation-cap fa-2x mb-3 d-block opacity-25"></i>
|
||||||
|
No programmes found.
|
||||||
|
<a href="/admin/programme/edit/new" class="d-block mt-2">Add your first programme →</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% } %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-4 mb-4 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0" style="color: var(--primary-dark);"><%= title %></h1>
|
||||||
|
<p class="text-muted mb-0">Edit the public Request Information page: hero, value props, and form configuration.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<% if (frontendUrl) { %>
|
||||||
|
<a href="<%= frontendUrl %>/request" class="btn btn-outline-primary" target="_blank" rel="noopener">
|
||||||
|
<i class="fas fa-external-link-alt me-2"></i>View Request Info Page
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" class="content-with-fixed-buttons" id="requestInfoForm" action="/admin/request-info/update">
|
||||||
|
<input type="hidden" name="metadata" id="metadataJson">
|
||||||
|
<input type="hidden" name="hero" id="heroJson">
|
||||||
|
<input type="hidden" name="form" id="formJson">
|
||||||
|
|
||||||
|
<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="#tab-meta" role="tab">
|
||||||
|
<i class="fas fa-tags me-2"></i>Metadata
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#tab-hero" role="tab">
|
||||||
|
<i class="fas fa-star me-2"></i>Hero
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#tab-form" role="tab">
|
||||||
|
<i class="fas fa-wpforms me-2"></i>Form Config
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content">
|
||||||
|
|
||||||
|
<%# ── TAB: Metadata ──────────────────────────────────────────── %>
|
||||||
|
<div class="tab-pane fade show active" id="tab-meta" role="tabpanel">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="metaTitle">Title (SEO)</label>
|
||||||
|
<input type="text" class="form-control" id="metaTitle" value="<%= data.metadata?.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="metaDescription">Description (SEO)</label>
|
||||||
|
<textarea class="form-control" id="metaDescription" rows="3"><%= data.metadata?.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="metaKeywords">Keywords (SEO)</label>
|
||||||
|
<input type="text" class="form-control" id="metaKeywords" value="<%= data.metadata?.keywords || '' %>" placeholder="keyword1, keyword2, ...">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="metaOgImage">OG Image path</label>
|
||||||
|
<input type="text" class="form-control" id="metaOgImage" value="<%= data.metadata?.ogImage || '' %>" placeholder="/images/og-request.jpg">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ── TAB: Hero ──────────────────────────────────────────────── %>
|
||||||
|
<div class="tab-pane fade" id="tab-hero" role="tabpanel">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium" for="heroBadge">Badge</label>
|
||||||
|
<input type="text" class="form-control" id="heroBadge" value="<%= data.hero?.badge || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium" for="heroTitleHighlight">Title — Highlight word</label>
|
||||||
|
<input type="text" class="form-control" id="heroTitleHighlight" value="<%= data.hero?.titleHighlight || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="heroTitleMain">Title — Main text (before highlight)</label>
|
||||||
|
<input type="text" class="form-control" id="heroTitleMain" value="<%= data.hero?.titleMain || '' %>" placeholder="Take the Next Step in Your ">
|
||||||
|
<small class="text-muted">The highlight word will appear after this text, rendered in accent colour on the frontend.</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="heroDescription">Description</label>
|
||||||
|
<textarea class="form-control" id="heroDescription" rows="3"><%= data.hero?.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Value Props %>
|
||||||
|
<hr class="my-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="mb-0 fw-semibold">Value Props</h6>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" id="addValuePropBtn">
|
||||||
|
<i class="fas fa-plus me-1"></i>Add value prop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="valuePropsContainer" class="vstack gap-3">
|
||||||
|
<% const vps = data.hero?.valueProps || []; %>
|
||||||
|
<% vps.forEach(function(vp, index) { %>
|
||||||
|
<div class="card vp-row border">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Icon</label>
|
||||||
|
<select class="form-select vp-icon-select">
|
||||||
|
<% iconOptions.forEach(function(opt) { %>
|
||||||
|
<option value="<%= opt.value %>" <%= (vp.icon === opt.value) ? 'selected' : '' %>><%= opt.label %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7">
|
||||||
|
<label class="form-label small">Title</label>
|
||||||
|
<input type="text" class="form-control vp-title" value="<%= vp.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 d-flex align-items-end justify-content-end">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm remove-vp"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label small">Description</label>
|
||||||
|
<textarea class="form-control vp-description" rows="2"><%= vp.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ── TAB: Form Config ───────────────────────────────────────── %>
|
||||||
|
<div class="tab-pane fade" id="tab-form" role="tabpanel">
|
||||||
|
<% const fm = data.form || {}; const fld = fm.fields || {}; %>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="formHeading">Form Heading</label>
|
||||||
|
<input type="text" class="form-control" id="formHeading" value="<%= fm.heading || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="formDescription">Form Description</label>
|
||||||
|
<textarea class="form-control" id="formDescription" rows="2"><%= fm.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted small mb-3">Fields</h6>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">First Name — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_firstName_label" value="<%= fld.firstName?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">First Name — placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_firstName_ph" value="<%= fld.firstName?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Last Name — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_lastName_label" value="<%= fld.lastName?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Last Name — placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_lastName_ph" value="<%= fld.lastName?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Email — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_email_label" value="<%= fld.email?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Email — placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_email_ph" value="<%= fld.email?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Phone — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_phone_label" value="<%= fld.phone?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Phone — placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_phone_ph" value="<%= fld.phone?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted small mb-3">Program of Interest (dropdown options)</h6>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Field label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_program_label" value="<%= fld.program?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_program_ph" value="<%= fld.program?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="small fw-medium">Program options</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" id="addProgramBtn">
|
||||||
|
<i class="fas fa-plus me-1"></i>Add option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="programOptionsContainer" class="vstack gap-2 mb-4">
|
||||||
|
<% (fld.program?.options || []).forEach(function(opt) { %>
|
||||||
|
<div class="input-group program-opt-row">
|
||||||
|
<input type="text" class="form-control prog-label" placeholder="Label" value="<%= opt.label || '' %>">
|
||||||
|
<input type="text" class="form-control prog-value" placeholder="Value (e.g. bs-cs)" value="<%= opt.value || '' %>">
|
||||||
|
<button type="button" class="btn btn-outline-danger remove-prog-opt"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted small mb-3">Start Timeline (dropdown options)</h6>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label small">Field label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_timeline_label" value="<%= fld.timeline?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="small fw-medium">Timeline options</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" id="addTimelineBtn">
|
||||||
|
<i class="fas fa-plus me-1"></i>Add option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="timelineOptionsContainer" class="vstack gap-2 mb-4">
|
||||||
|
<% (fld.timeline?.options || []).forEach(function(opt) { %>
|
||||||
|
<div class="input-group timeline-opt-row">
|
||||||
|
<input type="text" class="form-control tl-label" placeholder="Label" value="<%= opt.label || '' %>">
|
||||||
|
<input type="text" class="form-control tl-value" placeholder="Value (e.g. immediate)" value="<%= opt.value || '' %>">
|
||||||
|
<button type="button" class="btn btn-outline-danger remove-tl-opt"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted small mb-3">Form texts</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label small">Consent text</label>
|
||||||
|
<textarea class="form-control" id="formConsentText" rows="3"><%= fm.consentText || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Submit label</label>
|
||||||
|
<input type="text" class="form-control" id="formSubmitLabel" value="<%= fm.submitLabel || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Footer text (security note)</label>
|
||||||
|
<input type="text" class="form-control" id="formFooterText" value="<%= fm.footerText || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Success message</label>
|
||||||
|
<input type="text" class="form-control" id="formSuccessMsg" value="<%= fm.successMessage || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Error message</label>
|
||||||
|
<input type="text" class="form-control" id="formErrorMsg" value="<%= fm.errorMessage || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fixed-bottom-buttons">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="resetRequestInfoForm()">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function resetRequestInfoForm() {
|
||||||
|
if (confirm('Are you sure you want to reset all changes? Unsaved edits will be lost.')) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.REQUEST_INFO_ICON_OPTIONS = <%- JSON.stringify(iconOptions) %>;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconOptionsHtml(selected) {
|
||||||
|
var opts = window.REQUEST_INFO_ICON_OPTIONS || [];
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < opts.length; i++) {
|
||||||
|
var o = opts[i];
|
||||||
|
var sel = (o.value === selected) ? ' selected' : '';
|
||||||
|
html += '<option value="' + escapeHtml(o.value) + '"' + sel + '>' + escapeHtml(o.label) + '</option>';
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildValuePropRow(vp) {
|
||||||
|
vp = vp || {};
|
||||||
|
var iconSel = '<select class="form-select vp-icon-select">' + iconOptionsHtml(vp.icon) + '</select>';
|
||||||
|
return (
|
||||||
|
'<div class="card vp-row border"><div class="card-body">' +
|
||||||
|
'<div class="row g-2 align-items-end">' +
|
||||||
|
'<div class="col-md-4"><label class="form-label small">Icon</label>' + iconSel + '</div>' +
|
||||||
|
'<div class="col-md-7"><label class="form-label small">Title</label>' +
|
||||||
|
'<input type="text" class="form-control vp-title" value="' + escapeHtml(vp.title) + '"></div>' +
|
||||||
|
'<div class="col-md-1 d-flex align-items-end justify-content-end">' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger btn-sm remove-vp"><i class="fas fa-trash"></i></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="col-md-12"><label class="form-label small">Description</label>' +
|
||||||
|
'<textarea class="form-control vp-description" rows="2">' + escapeHtml(vp.description) + '</textarea></div>' +
|
||||||
|
'</div></div></div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProgramOptRow(opt) {
|
||||||
|
opt = opt || {};
|
||||||
|
return (
|
||||||
|
'<div class="input-group program-opt-row">' +
|
||||||
|
'<input type="text" class="form-control prog-label" placeholder="Label" value="' + escapeHtml(opt.label) + '">' +
|
||||||
|
'<input type="text" class="form-control prog-value" placeholder="Value (e.g. bs-cs)" value="' + escapeHtml(opt.value) + '">' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger remove-prog-opt"><i class="fas fa-times"></i></button>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTimelineOptRow(opt) {
|
||||||
|
opt = opt || {};
|
||||||
|
return (
|
||||||
|
'<div class="input-group timeline-opt-row">' +
|
||||||
|
'<input type="text" class="form-control tl-label" placeholder="Label" value="' + escapeHtml(opt.label) + '">' +
|
||||||
|
'<input type="text" class="form-control tl-value" placeholder="Value (e.g. immediate)" value="' + escapeHtml(opt.value) + '">' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger remove-tl-opt"><i class="fas fa-times"></i></button>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHiddenJson() {
|
||||||
|
// Metadata
|
||||||
|
document.getElementById('metadataJson').value = JSON.stringify({
|
||||||
|
title: document.getElementById('metaTitle').value.trim(),
|
||||||
|
description: document.getElementById('metaDescription').value.trim(),
|
||||||
|
keywords: document.getElementById('metaKeywords').value.trim(),
|
||||||
|
ogImage: document.getElementById('metaOgImage').value.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ValueProps
|
||||||
|
var valueProps = [];
|
||||||
|
document.querySelectorAll('#valuePropsContainer .vp-row').forEach(function(row) {
|
||||||
|
valueProps.push({
|
||||||
|
icon: row.querySelector('.vp-icon-select').value,
|
||||||
|
title: row.querySelector('.vp-title').value.trim(),
|
||||||
|
description: row.querySelector('.vp-description').value.trim(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hero
|
||||||
|
document.getElementById('heroJson').value = JSON.stringify({
|
||||||
|
badge: document.getElementById('heroBadge').value.trim(),
|
||||||
|
titleMain: document.getElementById('heroTitleMain').value.trim(),
|
||||||
|
titleHighlight: document.getElementById('heroTitleHighlight').value.trim(),
|
||||||
|
description: document.getElementById('heroDescription').value.trim(),
|
||||||
|
valueProps: valueProps,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Program options
|
||||||
|
var programOptions = [];
|
||||||
|
document.querySelectorAll('#programOptionsContainer .program-opt-row').forEach(function(row) {
|
||||||
|
var label = row.querySelector('.prog-label').value.trim();
|
||||||
|
var value = row.querySelector('.prog-value').value.trim();
|
||||||
|
if (label || value) programOptions.push({ label: label, value: value });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeline options
|
||||||
|
var timelineOptions = [];
|
||||||
|
document.querySelectorAll('#timelineOptionsContainer .timeline-opt-row').forEach(function(row) {
|
||||||
|
var label = row.querySelector('.tl-label').value.trim();
|
||||||
|
var value = row.querySelector('.tl-value').value.trim();
|
||||||
|
if (label || value) timelineOptions.push({ label: label, value: value });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form
|
||||||
|
document.getElementById('formJson').value = JSON.stringify({
|
||||||
|
heading: document.getElementById('formHeading').value.trim(),
|
||||||
|
description: document.getElementById('formDescription').value.trim(),
|
||||||
|
fields: {
|
||||||
|
firstName: {
|
||||||
|
name: 'first_name',
|
||||||
|
label: document.getElementById('fld_firstName_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_firstName_ph').value.trim(),
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
lastName: {
|
||||||
|
name: 'last_name',
|
||||||
|
label: document.getElementById('fld_lastName_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_lastName_ph').value.trim(),
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
name: 'email_address',
|
||||||
|
label: document.getElementById('fld_email_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_email_ph').value.trim(),
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
name: 'phone_number',
|
||||||
|
label: document.getElementById('fld_phone_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_phone_ph').value.trim(),
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
program: {
|
||||||
|
name: 'program_id',
|
||||||
|
label: document.getElementById('fld_program_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_program_ph').value.trim(),
|
||||||
|
required: true,
|
||||||
|
options: programOptions,
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
name: 'start_timeline',
|
||||||
|
label: document.getElementById('fld_timeline_label').value.trim(),
|
||||||
|
options: timelineOptions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
consentText: document.getElementById('formConsentText').value.trim(),
|
||||||
|
submitLabel: document.getElementById('formSubmitLabel').value.trim(),
|
||||||
|
footerText: document.getElementById('formFooterText').value.trim(),
|
||||||
|
successMessage: document.getElementById('formSuccessMsg').value.trim(),
|
||||||
|
errorMessage: document.getElementById('formErrorMsg').value.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var form = document.getElementById('requestInfoForm');
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var btn = document.getElementById('submitBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving...';
|
||||||
|
try {
|
||||||
|
syncHiddenJson();
|
||||||
|
form.submit();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Could not prepare form data. Please try again.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Value props
|
||||||
|
document.getElementById('addValuePropBtn').addEventListener('click', function () {
|
||||||
|
document.getElementById('valuePropsContainer').insertAdjacentHTML('beforeend',
|
||||||
|
buildValuePropRow({ icon: 'fa-solid fa-circle-info' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
document.getElementById('valuePropsContainer').addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.remove-vp')) {
|
||||||
|
e.target.closest('.vp-row').remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Program options
|
||||||
|
document.getElementById('addProgramBtn').addEventListener('click', function () {
|
||||||
|
document.getElementById('programOptionsContainer').insertAdjacentHTML('beforeend', buildProgramOptRow());
|
||||||
|
});
|
||||||
|
document.getElementById('programOptionsContainer').addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.remove-prog-opt')) {
|
||||||
|
e.target.closest('.program-opt-row').remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeline options
|
||||||
|
document.getElementById('addTimelineBtn').addEventListener('click', function () {
|
||||||
|
document.getElementById('timelineOptionsContainer').insertAdjacentHTML('beforeend', buildTimelineOptRow());
|
||||||
|
});
|
||||||
|
document.getElementById('timelineOptionsContainer').addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.remove-tl-opt')) {
|
||||||
|
e.target.closest('.timeline-opt-row').remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-4 mb-4 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0" style="color: var(--primary-dark);"><%= title %></h1>
|
||||||
|
<p class="text-muted mb-0">Edit the public Student Support page: hero, services directory, contact channels, and form labels.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<% if (frontendUrl) { %>
|
||||||
|
<a href="<%= frontendUrl %>/student-support" class="btn btn-outline-primary" target="_blank" rel="noopener">
|
||||||
|
<i class="fas fa-external-link-alt me-2"></i>View Student Support Page
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" class="content-with-fixed-buttons" id="studentSupportForm" action="/admin/student-support/update">
|
||||||
|
<input type="hidden" name="metadata" id="metadataJson">
|
||||||
|
<input type="hidden" name="hero" id="heroJson">
|
||||||
|
<input type="hidden" name="directory" id="directoryJson">
|
||||||
|
<input type="hidden" name="contactSection" id="contactSectionJson">
|
||||||
|
|
||||||
|
<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="#tab-meta" role="tab">
|
||||||
|
<i class="fas fa-tags me-2"></i>Metadata
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#tab-hero" 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="#tab-directory" role="tab">
|
||||||
|
<i class="fas fa-list me-2"></i>Directory
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#tab-contact" role="tab">
|
||||||
|
<i class="fas fa-headset me-2"></i>Contact & form
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="tab-meta" role="tabpanel">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="metaTitle">Title (SEO)</label>
|
||||||
|
<input type="text" class="form-control" id="metaTitle" value="<%= data.metadata?.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="metaDescription">Description (SEO)</label>
|
||||||
|
<textarea class="form-control" id="metaDescription" rows="3"><%= data.metadata?.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="tab-hero" role="tabpanel">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium" for="heroBadge">Badge</label>
|
||||||
|
<input type="text" class="form-control" id="heroBadge" value="<%= data.hero?.badge || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="heroTitle">Title</label>
|
||||||
|
<input type="text" class="form-control" id="heroTitle" value="<%= data.hero?.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="heroDescription">Description</label>
|
||||||
|
<textarea class="form-control" id="heroDescription" rows="4"><%= data.hero?.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Primary button</label>
|
||||||
|
<input type="text" class="form-control mb-2" id="heroPrimaryLabel" placeholder="Label" value="<%= data.hero?.primaryButton?.label || '' %>">
|
||||||
|
<input type="text" class="form-control" id="heroPrimaryHref" placeholder="Href" value="<%= data.hero?.primaryButton?.href || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Secondary button</label>
|
||||||
|
<input type="text" class="form-control mb-2" id="heroSecondaryLabel" placeholder="Label" value="<%= data.hero?.secondaryButton?.label || '' %>">
|
||||||
|
<input type="text" class="form-control" id="heroSecondaryHref" placeholder="Href" value="<%= data.hero?.secondaryButton?.href || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium" for="heroImage">Hero image</label>
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<input type="text" class="form-control" id="heroImage" value="<%= data.hero?.image || '' %>">
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-upload-image" data-target-input="heroImage" data-image-type="student-support">
|
||||||
|
<i class="fas fa-upload me-1"></i>Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Full URL or relative path such as <code>/uploads/...</code> after upload.</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium" for="heroImageAlt">Image alt</label>
|
||||||
|
<input type="text" class="form-control" id="heroImageAlt" value="<%= data.hero?.imageAlt || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label text-muted small">Preview</label>
|
||||||
|
<div id="heroImagePreview" class="border rounded overflow-hidden bg-light" style="height: 380px; width: 100%;">
|
||||||
|
<% const hi = data.hero?.image; if (hi) { %>
|
||||||
|
<% let src = hi; if (!src.startsWith('http')) { src = src.startsWith('/') ? src : '/' + src; } %>
|
||||||
|
<img src="<%= src %>" alt="" class="w-100 h-100" style="object-fit: cover; object-position: center;" id="heroPreviewImg"
|
||||||
|
onerror="this.style.display='none'">
|
||||||
|
<% } else { %>
|
||||||
|
<div class="h-100 d-flex align-items-center justify-content-center text-muted">
|
||||||
|
<div><i class="fas fa-image fa-2x mb-2"></i><br>No image</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="tab-directory" role="tabpanel">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="dirTitle">Section title</label>
|
||||||
|
<input type="text" class="form-control" id="dirTitle" value="<%= data.directory?.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="dirSubtitle">Subtitle</label>
|
||||||
|
<textarea class="form-control" id="dirSubtitle" rows="2"><%= data.directory?.subtitle || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="mb-0">Services</h6>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" id="addServiceBtn"><i class="fas fa-plus me-1"></i>Add service</button>
|
||||||
|
</div>
|
||||||
|
<div id="servicesContainer" class="vstack gap-3">
|
||||||
|
<% const services = data.directory?.services || []; %>
|
||||||
|
<% services.forEach(function(svc, index) { %>
|
||||||
|
<div class="card service-row border">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Icon</label>
|
||||||
|
<select class="form-select svc-icon-select">
|
||||||
|
<% iconOptions.forEach(function(opt) { %>
|
||||||
|
<option value="<%= opt.value %>" <%= (svc.icon === opt.value) ? 'selected' : '' %>><%= opt.label %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Hours icon</label>
|
||||||
|
<select class="form-select svc-hours-icon-select">
|
||||||
|
<% iconOptions.forEach(function(opt) { %>
|
||||||
|
<option value="<%= opt.value %>" <%= (svc.hoursIcon === opt.value) ? 'selected' : '' %>><%= opt.label %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 d-flex align-items-end justify-content-end">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm remove-service"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label small">Title</label>
|
||||||
|
<input type="text" class="form-control svc-title" value="<%= svc.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label small">Description</label>
|
||||||
|
<textarea class="form-control svc-description" rows="2"><%= svc.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Hours text</label>
|
||||||
|
<input type="text" class="form-control svc-hours" value="<%= svc.hours || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">Button label</label>
|
||||||
|
<input type="text" class="form-control svc-btn-label" value="<%= svc.buttonLabel || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">Button href</label>
|
||||||
|
<input type="text" class="form-control svc-btn-href" value="<%= svc.buttonHref || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="tab-contact" role="tabpanel">
|
||||||
|
<% const cs = data.contactSection || {}; const fm = cs.form || {}; const fld = fm.fields || {}; %>
|
||||||
|
<h6 class="text-uppercase text-muted small">Contact channels</h6>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label" for="csHeading">Heading</label>
|
||||||
|
<input type="text" class="form-control" id="csHeading" value="<%= cs.heading || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label" for="csDescription">Description</label>
|
||||||
|
<textarea class="form-control" id="csDescription" rows="2"><%= cs.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="fw-medium">Channels</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" id="addChannelBtn"><i class="fas fa-plus me-1"></i>Add channel</button>
|
||||||
|
</div>
|
||||||
|
<div id="channelsContainer" class="vstack gap-2 mb-4">
|
||||||
|
<% (cs.channels || []).forEach(function(ch) { %>
|
||||||
|
<div class="card channel-row border">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">Icon</label>
|
||||||
|
<select class="form-select ch-icon-select">
|
||||||
|
<% iconOptions.forEach(function(opt) { %>
|
||||||
|
<option value="<%= opt.value %>" <%= (ch.icon === opt.value) ? 'selected' : '' %>><%= opt.label %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">Title</label>
|
||||||
|
<input type="text" class="form-control ch-title" value="<%= ch.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label small">Detail</label>
|
||||||
|
<input type="text" class="form-control ch-detail" value="<%= ch.detail || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 text-end">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm remove-channel"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted small">Form (display labels)</h6>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label" for="formHeading">Form heading</label>
|
||||||
|
<input type="text" class="form-control" id="formHeading" value="<%= fm.heading || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">First name — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_firstName_label" value="<%= fld.firstName?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">First name — placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_firstName_ph" value="<%= fld.firstName?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Last name — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_lastName_label" value="<%= fld.lastName?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Last name — placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_lastName_ph" value="<%= fld.lastName?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Student ID — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_studentId_label" value="<%= fld.studentId?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Student ID — placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_studentId_ph" value="<%= fld.studentId?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Department — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_department_label" value="<%= fld.department?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Message — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_message_label" value="<%= fld.message?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Message — placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_message_ph" value="<%= fld.message?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Submit label</label>
|
||||||
|
<input type="text" class="form-control" id="formSubmitLabel" value="<%= fm.submitLabel || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label small">Success message</label>
|
||||||
|
<input type="text" class="form-control" id="formSuccessMsg" value="<%= fm.successMessage || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="fw-medium">Departments (dropdown)</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" id="addDeptBtn"><i class="fas fa-plus me-1"></i>Add row</button>
|
||||||
|
</div>
|
||||||
|
<div id="departmentsContainer" class="vstack gap-2">
|
||||||
|
<% (fm.departments || []).forEach(function(dep) { %>
|
||||||
|
<div class="input-group dept-row">
|
||||||
|
<input type="text" class="form-control dept-input" value="<%= dep %>">
|
||||||
|
<button type="button" class="btn btn-outline-danger remove-dept"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fixed-bottom-buttons">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="resetStudentSupportForm()">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function resetStudentSupportForm() {
|
||||||
|
if (confirm('Are you sure you want to reset all changes? Unsaved edits will be lost.')) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.STUDENT_SUPPORT_ICON_OPTIONS = <%- JSON.stringify(iconOptions) %>;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconOptionsHtml(selected) {
|
||||||
|
var opts = window.STUDENT_SUPPORT_ICON_OPTIONS || [];
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < opts.length; i++) {
|
||||||
|
var o = opts[i];
|
||||||
|
var sel = (o.value === selected) ? ' selected' : '';
|
||||||
|
html += '<option value="' + escapeHtml(o.value) + '"' + sel + '>' + escapeHtml(o.label) + '</option>';
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildServiceRow(svc) {
|
||||||
|
svc = svc || {};
|
||||||
|
var iconSel = '<select class="form-select svc-icon-select">' + iconOptionsHtml(svc.icon) + '</select>';
|
||||||
|
var hoursIconSel = '<select class="form-select svc-hours-icon-select">' + iconOptionsHtml(svc.hoursIcon) + '</select>';
|
||||||
|
return (
|
||||||
|
'<div class="card service-row border">' +
|
||||||
|
'<div class="card-body">' +
|
||||||
|
'<div class="row g-2">' +
|
||||||
|
'<div class="col-md-4"><label class="form-label small">Icon</label>' + iconSel + '</div>' +
|
||||||
|
'<div class="col-md-4"><label class="form-label small">Hours icon</label>' + hoursIconSel + '</div>' +
|
||||||
|
'<div class="col-md-4 d-flex align-items-end justify-content-end">' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger btn-sm remove-service"><i class="fas fa-trash"></i></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="col-md-12"><label class="form-label small">Title</label>' +
|
||||||
|
'<input type="text" class="form-control svc-title" value="' + escapeHtml(svc.title) + '"></div>' +
|
||||||
|
'<div class="col-md-12"><label class="form-label small">Description</label>' +
|
||||||
|
'<textarea class="form-control svc-description" rows="2">' + escapeHtml(svc.description) + '</textarea></div>' +
|
||||||
|
'<div class="col-md-6"><label class="form-label small">Hours text</label>' +
|
||||||
|
'<input type="text" class="form-control svc-hours" value="' + escapeHtml(svc.hours) + '"></div>' +
|
||||||
|
'<div class="col-md-3"><label class="form-label small">Button label</label>' +
|
||||||
|
'<input type="text" class="form-control svc-btn-label" value="' + escapeHtml(svc.buttonLabel) + '"></div>' +
|
||||||
|
'<div class="col-md-3"><label class="form-label small">Button href</label>' +
|
||||||
|
'<input type="text" class="form-control svc-btn-href" value="' + escapeHtml(svc.buttonHref) + '"></div>' +
|
||||||
|
'</div></div></div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChannelRow(ch) {
|
||||||
|
ch = ch || {};
|
||||||
|
var iconSel = '<select class="form-select ch-icon-select">' + iconOptionsHtml(ch.icon) + '</select>';
|
||||||
|
return (
|
||||||
|
'<div class="card channel-row border"><div class="card-body py-2">' +
|
||||||
|
'<div class="row g-2 align-items-end">' +
|
||||||
|
'<div class="col-md-3"><label class="form-label small">Icon</label>' + iconSel + '</div>' +
|
||||||
|
'<div class="col-md-3"><label class="form-label small">Title</label>' +
|
||||||
|
'<input type="text" class="form-control ch-title" value="' + escapeHtml(ch.title) + '"></div>' +
|
||||||
|
'<div class="col-md-5"><label class="form-label small">Detail</label>' +
|
||||||
|
'<input type="text" class="form-control ch-detail" value="' + escapeHtml(ch.detail) + '"></div>' +
|
||||||
|
'<div class="col-md-1 text-end">' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger btn-sm remove-channel"><i class="fas fa-trash"></i></button>' +
|
||||||
|
'</div></div></div></div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeroPreview(url) {
|
||||||
|
var el = document.getElementById('heroImagePreview');
|
||||||
|
if (!el) return;
|
||||||
|
if (!url) {
|
||||||
|
el.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-muted"><div><i class="fas fa-image fa-2x mb-2"></i><br>No image</div></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.classList.remove('d-flex', 'align-items-center', 'justify-content-center');
|
||||||
|
el.style.height = '380px';
|
||||||
|
el.style.width = '100%';
|
||||||
|
var src = url;
|
||||||
|
if (!src.startsWith('http://') && !src.startsWith('https://')) {
|
||||||
|
src = src.startsWith('/') ? src : '/' + src;
|
||||||
|
}
|
||||||
|
el.innerHTML = '<img src="' + escapeHtml(src) + '" alt="" class="w-100 h-100" style="object-fit: cover; object-position: center;" id="heroPreviewImg">';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHiddenJson() {
|
||||||
|
document.getElementById('metadataJson').value = JSON.stringify({
|
||||||
|
title: document.getElementById('metaTitle').value.trim(),
|
||||||
|
description: document.getElementById('metaDescription').value.trim(),
|
||||||
|
});
|
||||||
|
document.getElementById('heroJson').value = JSON.stringify({
|
||||||
|
badge: document.getElementById('heroBadge').value.trim(),
|
||||||
|
title: document.getElementById('heroTitle').value.trim(),
|
||||||
|
description: document.getElementById('heroDescription').value.trim(),
|
||||||
|
primaryButton: {
|
||||||
|
label: document.getElementById('heroPrimaryLabel').value.trim(),
|
||||||
|
href: document.getElementById('heroPrimaryHref').value.trim(),
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
label: document.getElementById('heroSecondaryLabel').value.trim(),
|
||||||
|
href: document.getElementById('heroSecondaryHref').value.trim(),
|
||||||
|
},
|
||||||
|
image: document.getElementById('heroImage').value.trim(),
|
||||||
|
imageAlt: document.getElementById('heroImageAlt').value.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
var services = [];
|
||||||
|
document.querySelectorAll('#servicesContainer .service-row').forEach(function(row) {
|
||||||
|
services.push({
|
||||||
|
icon: row.querySelector('.svc-icon-select').value,
|
||||||
|
title: row.querySelector('.svc-title').value.trim(),
|
||||||
|
description: row.querySelector('.svc-description').value.trim(),
|
||||||
|
hours: row.querySelector('.svc-hours').value.trim(),
|
||||||
|
hoursIcon: row.querySelector('.svc-hours-icon-select').value,
|
||||||
|
buttonLabel: row.querySelector('.svc-btn-label').value.trim(),
|
||||||
|
buttonHref: row.querySelector('.svc-btn-href').value.trim(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('directoryJson').value = JSON.stringify({
|
||||||
|
title: document.getElementById('dirTitle').value.trim(),
|
||||||
|
subtitle: document.getElementById('dirSubtitle').value.trim(),
|
||||||
|
services: services,
|
||||||
|
});
|
||||||
|
|
||||||
|
var channels = [];
|
||||||
|
document.querySelectorAll('#channelsContainer .channel-row').forEach(function(row) {
|
||||||
|
channels.push({
|
||||||
|
icon: row.querySelector('.ch-icon-select').value,
|
||||||
|
title: row.querySelector('.ch-title').value.trim(),
|
||||||
|
detail: row.querySelector('.ch-detail').value.trim(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
var departments = [];
|
||||||
|
document.querySelectorAll('#departmentsContainer .dept-input').forEach(function(inp) {
|
||||||
|
var v = inp.value.trim();
|
||||||
|
if (v) departments.push(v);
|
||||||
|
});
|
||||||
|
document.getElementById('contactSectionJson').value = JSON.stringify({
|
||||||
|
heading: document.getElementById('csHeading').value.trim(),
|
||||||
|
description: document.getElementById('csDescription').value.trim(),
|
||||||
|
channels: channels,
|
||||||
|
form: {
|
||||||
|
heading: document.getElementById('formHeading').value.trim(),
|
||||||
|
fields: {
|
||||||
|
firstName: {
|
||||||
|
label: document.getElementById('fld_firstName_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_firstName_ph').value.trim(),
|
||||||
|
},
|
||||||
|
lastName: {
|
||||||
|
label: document.getElementById('fld_lastName_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_lastName_ph').value.trim(),
|
||||||
|
},
|
||||||
|
studentId: {
|
||||||
|
label: document.getElementById('fld_studentId_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_studentId_ph').value.trim(),
|
||||||
|
},
|
||||||
|
department: {
|
||||||
|
label: document.getElementById('fld_department_label').value.trim(),
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
label: document.getElementById('fld_message_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_message_ph').value.trim(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
departments: departments,
|
||||||
|
submitLabel: document.getElementById('formSubmitLabel').value.trim(),
|
||||||
|
successMessage: document.getElementById('formSuccessMsg').value.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImageUploader(targetInputId, imageType) {
|
||||||
|
var fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.accept = 'image/*';
|
||||||
|
fileInput.style.display = 'none';
|
||||||
|
document.body.appendChild(fileInput);
|
||||||
|
fileInput.onchange = async function (e) {
|
||||||
|
var file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
var uploadBtn = document.querySelector('[data-target-input="' + targetInputId + '"]');
|
||||||
|
var original = uploadBtn ? uploadBtn.innerHTML : '';
|
||||||
|
if (uploadBtn) {
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>...';
|
||||||
|
}
|
||||||
|
var res = await fetch('/admin/upload/image?imageType=' + encodeURIComponent(imageType || 'general'), {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
var result = await res.json();
|
||||||
|
if (!result.success) throw new Error(result.error || 'Upload failed');
|
||||||
|
var input = document.getElementById(targetInputId);
|
||||||
|
if (input) input.value = result.path || '';
|
||||||
|
if (targetInputId === 'heroImage') updateHeroPreview(input.value);
|
||||||
|
if (uploadBtn) {
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
uploadBtn.innerHTML = original;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Upload failed: ' + (err.message || err));
|
||||||
|
var b = document.querySelector('[data-target-input="' + targetInputId + '"]');
|
||||||
|
if (b) { b.disabled = false; b.innerHTML = '<i class="fas fa-upload me-1"></i>Upload'; }
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(fileInput);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var form = document.getElementById('studentSupportForm');
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var btn = document.getElementById('submitBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving...';
|
||||||
|
try {
|
||||||
|
syncHiddenJson();
|
||||||
|
form.submit();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Could not prepare form data. Please try again.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn-upload-image').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
openImageUploader(this.dataset.targetInput, this.dataset.imageType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var heroImgInput = document.getElementById('heroImage');
|
||||||
|
if (heroImgInput) {
|
||||||
|
heroImgInput.addEventListener('input', function () { updateHeroPreview(this.value); });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addServiceBtn').addEventListener('click', function () {
|
||||||
|
var wrap = document.getElementById('servicesContainer');
|
||||||
|
wrap.insertAdjacentHTML('beforeend', buildServiceRow({ icon: 'fa-solid fa-circle-info', hoursIcon: 'fa-regular fa-clock' }));
|
||||||
|
});
|
||||||
|
document.getElementById('addChannelBtn').addEventListener('click', function () {
|
||||||
|
document.getElementById('channelsContainer').insertAdjacentHTML('beforeend', buildChannelRow({ icon: 'fa-solid fa-phone' }));
|
||||||
|
});
|
||||||
|
document.getElementById('addDeptBtn').addEventListener('click', function () {
|
||||||
|
document.getElementById('departmentsContainer').insertAdjacentHTML(
|
||||||
|
'beforeend',
|
||||||
|
'<div class="input-group dept-row">' +
|
||||||
|
'<input type="text" class="form-control dept-input" value="">' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger remove-dept"><i class="fas fa-times"></i></button>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('servicesContainer').addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.remove-service')) {
|
||||||
|
e.target.closest('.service-row').remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('channelsContainer').addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.remove-channel')) {
|
||||||
|
e.target.closest('.channel-row').remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('departmentsContainer').addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.remove-dept')) {
|
||||||
|
e.target.closest('.dept-row').remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -96,6 +96,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/admin/upload">Upload</a>
|
<a class="nav-link" href="/admin/upload">Upload</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/admin/programme">Programmes</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -919,6 +919,10 @@
|
|||||||
<a class="nav-link <%= currentPath === '/admin/contact' ? 'active' : '' %>" href="/admin/contact">Contact
|
<a class="nav-link <%= currentPath === '/admin/contact' ? 'active' : '' %>" href="/admin/contact">Contact
|
||||||
Us</a>
|
Us</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link <%= currentPath === '/admin/student-support' ? 'active' : '' %>"
|
||||||
|
href="/admin/student-support">Student Support</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link <%= currentPath === '/admin/appointment' ? 'active' : '' %>"
|
<a class="nav-link <%= currentPath === '/admin/appointment' ? 'active' : '' %>"
|
||||||
href="/admin/appointment">Appointment</a>
|
href="/admin/appointment">Appointment</a>
|
||||||
@@ -927,6 +931,9 @@
|
|||||||
<a class="nav-link <%= currentPath === '/admin/pricing' ? 'active' : '' %>"
|
<a class="nav-link <%= currentPath === '/admin/pricing' ? 'active' : '' %>"
|
||||||
href="/admin/pricing">Pricing</a>
|
href="/admin/pricing">Pricing</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/admin/programme">Programmes</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link <%= currentPath === '/admin/audit-logs' ? 'active' : '' %>"
|
<a class="nav-link <%= currentPath === '/admin/audit-logs' ? 'active' : '' %>"
|
||||||
href="/admin/audit-logs">Audit Log
|
href="/admin/audit-logs">Audit Log
|
||||||
|
|||||||
Reference in New Issue
Block a user