Merge branch 'develop' into fea/dat-20042026-CMS-Partnerships-Accreditation-History-Admissions-Policies

This commit is contained in:
Tống Thành Đạt
2026-04-21 12:33:11 +07:00
31 changed files with 4477 additions and 2325 deletions
+6
View File
@@ -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",
+100 -204
View File
@@ -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);
const contactCardsData = parseJson(contactCards);
const mapData = parseJson(map);
const formData = parseJson(form);
// 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;
} }
await contact.save(); // Normalize channel icons against whitelist
if (infoCards?.contactInfo?.channels?.length) {
infoCards.contactInfo.channels = infoCards.contactInfo.channels.map((ch) => ({
...ch,
icon: normalizeIconClass(ch.icon),
}));
}
// ✅ Capture AFTER state const payload = { metadata, hero, infoCards, form, faq, cta };
const afterData = JSON.parse(JSON.stringify(contact.toObject()));
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 doc.save();
const afterData = JSON.parse(JSON.stringify(doc.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",
});
} }
}; };
+289
View File
@@ -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" });
}
};
+171
View File
@@ -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");
}
};
+186
View File
@@ -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");
}
};
+102 -115
View File
@@ -1,119 +1,106 @@
{ {
"hero": { "metadata": {
"title": "CONTACT US", "title": "Contact Us | LAMS",
"backgroundImage": "/assets/img/inner-page/breadcrumb.jpg", "description": "Get in touch with LAMS. Our team is ready to assist you with admissions, financial aid, or program information.",
"overlayColor": "rgba(0, 0, 0, 0)", "keywords": "contact LAMS, admissions support, university contact, online education help",
"sectionClass": "breadcrumb-wrapper fix bg-cover", "ogImage": "/images/og-contact.jpg"
"titleClass": "breadcrumb-title", },
"enableScrollspy": false, "hero": {
"backgroundPosition": "center" "badge": "We're Here to Help",
"titleMain": "Get in Touch with ",
"titleHighlight": "LAMS",
"description": "Whether you have questions about our programs, admissions, or financial aid, our team is ready to assist you on your educational journey."
},
"infoCards": {
"contactInfo": {
"title": "Contact Information",
"channels": [
{
"icon": "fa-solid fa-phone",
"title": "Admissions Phone",
"detail": "123456789",
"subDetail": "Mon-Fri, 8am-8pm EST"
},
{
"icon": "fa-regular fa-envelope",
"title": "Email Support",
"detail": "info@lams.ac",
"subDetail": "We aim to reply within 24 hours"
},
{
"icon": "fa-solid fa-map-location-dot",
"title": "Administrative Office",
"detail": "207 Regent Street, London, England W1B3HH"
}
]
}, },
"contactCards": [ "supportHours": {
{ "title": "Student Support Hours",
"type": "location", "hours": [
"title": "Location", { "day": "Monday - Friday", "time": "8:00 AM - 8:00 PM EST" },
"content": [ { "day": "Saturday", "time": "10:00 AM - 4:00 PM EST" },
"43 Sardinella, 3nd Land Walk,", { "day": "Sunday", "time": "Closed" }
"Orchard view, London, UK" ],
], "footer": {
"iconType": "fa-solid fa-location-dot", "text": "Current student looking for academic advising?",
"iconSource": "fontawesome" "linkText": "Visit Student Portal",
}, "linkHref": "https://portal.lams.ac"
{ }
"type": "email",
"title": "Email Address",
"content": [
"supportinfo@gmail.com",
"arluxhotelinfo.com"
],
"iconType": "fa-solid fa-envelope",
"iconSource": "fontawesome"
},
{
"type": "phone",
"title": "Phone Number",
"content": [
"+880 123 427 00",
"+000 938 809 12"
],
"iconType": "fa-solid fa-phone",
"iconSource": "fontawesome"
}
],
"map": {
"coordinates": {
"lat": -37.81450084255415,
"lng": 144.9618311901502
},
"zoom": 15,
"location": "Envato, Melbourne, Australia",
"markerTitle": "Our Office",
"embedUrl": "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d6678.7619084840835!2d144.9618311901502!3d-37.81450084255415!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x6ad642b4758afc1d%3A0x3119cc820fdfc62e!2sEnvato!5e0!3m2!1sen!2sbd!4v1641984054261!5m2!1sen!2sbd",
"tileLayer": {
"url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
"attribution": "",
"maxZoom": 18,
"minZoom": 0
}
},
"form": {
"sectionLabel": "",
"heading": "Send Us Message",
"description": "Have questions about visas or immigration? Send us a message today and our expert team will respond quickly.",
"fields": [
{
"name": "name",
"label": "Your Name",
"type": "text",
"placeholder": "Your name",
"required": true,
"colClass": "col-lg-4"
},
{
"name": "email",
"label": "Your Email",
"type": "email",
"placeholder": "Your email",
"required": true,
"colClass": "col-lg-4"
},
{
"name": "phone",
"label": "Your Phone",
"type": "tel",
"placeholder": "Phone Number",
"required": true,
"colClass": "col-lg-4"
},
{
"name": "address",
"label": "Your Address",
"type": "text",
"placeholder": "Address Now",
"required": false,
"colClass": "col-lg-6"
},
{
"name": "date",
"label": "Your Date",
"type": "date",
"placeholder": "Date",
"required": false,
"colClass": "col-lg-6"
},
{
"name": "message",
"label": "Your Message",
"type": "textarea",
"placeholder": "Type your message",
"required": false,
"colClass": "col-lg-12"
}
],
"submitButton": {
"text": "SEND MESSAGE",
"icon": "fa-solid fa-arrow-right",
"buttonClass": "theme-btn style-2"
}
} }
},
"form": {
"heading": "Send us a Message",
"description": "Fill out the form below and our team will get back to you shortly.",
"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", "label": "Email Address", "placeholder": "john.doe@example.com", "required": true },
"phone": { "name": "phone_number", "label": "Phone Number (Optional)", "placeholder": "(555) 123-4567", "required": false },
"inquiryType": {
"name": "inquiry_type",
"label": "How can we help you?",
"placeholder": "Select an inquiry type...",
"required": true,
"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."
},
{
"question": "How does the monthly subscription model work?",
"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."
},
{
"question": "Can I transfer credits from another institution?",
"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."
},
{
"question": "Are there set class times I need to attend?",
"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."
}
]
},
"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" }
}
} }
+68
View File
@@ -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"
}
]
+70
View File
@@ -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."
}
}
+134
View File
@@ -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!"
}
}
}
+131 -364
View File
@@ -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: "" },
maxZoom: { linkText: { type: String, trim: true, default: "" },
type: Number, linkHref: { type: String, trim: true, default: "" },
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: {
type: heroSchema,
required: true,
},
contactCards: {
type: [contactCardSchema],
default: [],
},
map: {
type: mapSchema,
required: true,
},
form: {
type: formSchema,
required: true,
},
}, },
{ { _id: false }
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 || {},
form: jsonData.form || {},
faq: jsonData.faq || {},
cta: jsonData.cta || {},
};
// Xử lý và chuẩn hóa dữ liệu từ JSON const existing = await this.findOne({ slug: "contact" });
const processedData = { if (existing) {
hero: { existing.set(payload);
title: jsonData.hero?.title || "Contact Us", await existing.save();
backgroundImage: jsonData.hero?.backgroundImage || "", console.log("✅ Contact data updated via migration.");
overlayColor: jsonData.hero?.overlayColor || "rgba(0, 0, 0, 0)", return existing;
sectionClass: jsonData.hero?.sectionClass || "", } else {
titleClass: jsonData.hero?.titleClass || "", const created = await this.create({ slug: "contact", ...payload });
enableScrollspy: jsonData.hero?.enableScrollspy || false, console.log("✅ Contact data created via migration.");
backgroundPosition: jsonData.hero?.backgroundPosition || "center", return created;
},
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) {
// Cập nhật contact hiện có với dữ liệu đã xử lý
existingContact.hero = processedData.hero;
existingContact.contactCards = processedData.contactCards;
existingContact.map = processedData.map;
existingContact.form = processedData.form;
await existingContact.save();
console.log("Contact data updated successfully");
return existingContact;
} else {
// Tạo contact mới với dữ liệu đã xử lý
const newContact = await this.create({
name: "default",
...processedData,
});
console.log("Contact data imported successfully");
return newContact;
}
} catch (error) {
console.error("Error migrating contact data:", error);
throw error;
} }
}; };
+78
View File
@@ -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);
+138
View File
@@ -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);
+148
View File
@@ -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);
+34
View File
@@ -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);
+13
View File
@@ -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);
+1 -1
View File
@@ -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 };
+58
View File
@@ -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 };
+33
View File
@@ -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 };
+33
View File
@@ -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 };
+53
View File
@@ -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();
+47
View File
@@ -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,
};
File diff suppressed because it is too large Load Diff
+141 -2
View File
@@ -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>
+610
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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>
+90
View File
@@ -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 &rarr;</a>
</td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
+481
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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>
+590
View File
@@ -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 &amp; 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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>
+3
View File
@@ -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>
+7
View File
@@ -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