Merge pull request 'Adding backend of Student Support, Contact, Request' (#1) from feat/duong-20042026-CMSIntergration into develop

Reviewed-on: UKSOURCE/cms.lams#1
This commit is contained in:
2026-04-20 11:49:09 +00:00
23 changed files with 3167 additions and 2324 deletions
+6
View File
@@ -32,6 +32,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",
+96 -200
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); // Normalize channel icons against whitelist
const contactCardsData = parseJson(contactCards); if (infoCards?.contactInfo?.channels?.length) {
const mapData = parseJson(map); infoCards.contactInfo.channels = infoCards.contactInfo.channels.map((ch) => ({
const formData = parseJson(form); ...ch,
icon: normalizeIconClass(ch.icon),
// Tìm hoặc tạo contact
let contact = await Contact.findOne({ name: "default" });
// ✅ Capture BEFORE state
const beforeData = contact
? JSON.parse(JSON.stringify(contact.toObject()))
: {};
if (!contact) {
// Tạo mới với default values
contact = new Contact({
name: "default",
hero: heroData || {
title: "Contact Us",
backgroundImage: "",
overlayColor: "rgba(0, 0, 0, 0)",
sectionClass: "",
titleClass: "",
enableScrollspy: false,
backgroundPosition: "center",
},
contactCards: (contactCardsData || []).map((card) => ({
...card,
iconType: card.iconType || "",
iconSource:
card.iconSource ||
(card.iconType && card.iconType.startsWith("/uploads/")
? "image"
: "fontawesome"),
})),
map: mapData || {
coordinates: { lat: 0, lng: 0 },
zoom: 15,
location: "",
markerTitle: "",
embedUrl: "",
tileLayer: {
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: "",
maxZoom: 18,
minZoom: 0,
},
},
form: formData || {
sectionLabel: "",
heading: "",
description: "",
fields: [],
submitButton: {
text: "Send Message",
icon: "fa-solid fa-arrow-right",
buttonClass: "theme-btn style-2",
},
},
});
} else {
// Cập nhật dữ liệu
if (heroData) contact.hero = heroData;
if (contactCardsData && Array.isArray(contactCardsData)) {
// Đảm bảo mỗi card có iconType và iconSource
contact.contactCards = contactCardsData.map((card) => ({
...card,
iconType: card.iconType || "",
iconSource:
card.iconSource ||
(card.iconType && card.iconType.startsWith("/uploads/")
? "image"
: "fontawesome"),
})); }));
} }
if (mapData) contact.map = mapData;
if (formData) contact.form = formData; const payload = { metadata, hero, infoCards, form, faq, cta };
let doc = await Contact.findOne({ slug: SLUG });
const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {};
if (!doc) {
doc = new Contact({ slug: SLUG, ...payload });
} else {
doc.set(payload);
} }
await contact.save(); await doc.save();
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(contact.toObject()));
// ✅ AUDIT LOGGING - Contact Updated
const changes = diffObject(beforeData, afterData); const changes = diffObject(beforeData, afterData);
if (changes.length > 0) { if (changes.length > 0) {
await writeAuditLog({ await writeAuditLog({
model: "Contact", model: "Contact",
documentId: contact._id, documentId: doc._id,
action: AUDIT_ACTIONS.UPDATE_CONTACT, action: AUDIT_ACTIONS.UPDATE_CONTACT,
before: beforeData, before: beforeData,
after: afterData, after: afterData,
@@ -243,18 +159,20 @@ exports.update = async (req, res) => {
req.flash("success_msg", "Contact updated successfully"); req.flash("success_msg", "Contact updated successfully");
res.redirect("/admin/contact"); res.redirect("/admin/contact");
} catch (err) { } catch (err) {
console.error("Error updating contact:", err); console.error("contact.update:", err);
req.flash("error_msg", err.message || "Error updating contact"); req.flash("error_msg", err.message || "Error updating contact");
res.redirect("/admin/contact"); res.redirect("/admin/contact");
} }
}; };
// API để submit contact form (từ frontend) // ─────────────────────────────────────────────
// Form Submissions (unchanged)
// ─────────────────────────────────────────────
exports.submitForm = async (req, res) => { exports.submitForm = async (req, res) => {
try { try {
const { name, email, phone, address, date, message } = req.body; const { name, email, phone, address, date, message } = req.body;
// Validation
if (!name || !email) { if (!name || !email) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@@ -262,7 +180,6 @@ exports.submitForm = async (req, res) => {
}); });
} }
// Create new submission
const submission = new ContactSubmission({ const submission = new ContactSubmission({
name: name.trim(), name: name.trim(),
email: email.trim().toLowerCase(), email: email.trim().toLowerCase(),
@@ -288,13 +205,9 @@ exports.submitForm = async (req, res) => {
} catch (err) { } catch (err) {
console.error("Error submitting contact form:", err); console.error("Error submitting contact form:", err);
// Handle validation errors
if (err.name === "ValidationError") { if (err.name === "ValidationError") {
const errors = Object.values(err.errors).map((e) => e.message); const errors = Object.values(err.errors).map((e) => e.message);
return res.status(400).json({ return res.status(400).json({ success: false, error: errors.join(", ") });
success: false,
error: errors.join(", "),
});
} }
res.status(500).json({ res.status(500).json({
@@ -304,7 +217,6 @@ exports.submitForm = async (req, res) => {
} }
}; };
// API để lấy danh sách submissions (cho admin)
exports.getSubmissions = async (req, res) => { exports.getSubmissions = async (req, res) => {
try { try {
const { status, page = 1, limit = 20 } = req.query; const { status, page = 1, limit = 20 } = req.query;
@@ -336,14 +248,10 @@ exports.getSubmissions = async (req, res) => {
}); });
} catch (err) { } catch (err) {
console.error("Error getting submissions:", err); console.error("Error getting submissions:", err);
res.status(500).json({ res.status(500).json({ success: false, error: "Error loading submissions" });
success: false,
error: "Error loading submissions",
});
} }
}; };
// API để cập nhật status của submission
exports.updateSubmissionStatus = async (req, res) => { exports.updateSubmissionStatus = async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
@@ -351,10 +259,7 @@ exports.updateSubmissionStatus = async (req, res) => {
const validStatuses = ["pending", "read", "replied", "archived"]; const validStatuses = ["pending", "read", "replied", "archived"];
if (!validStatuses.includes(status)) { if (!validStatuses.includes(status)) {
return res.status(400).json({ return res.status(400).json({ success: false, error: "Invalid status" });
success: false,
error: "Invalid status",
});
} }
const updateData = { status }; const updateData = { status };
@@ -364,25 +269,16 @@ exports.updateSubmissionStatus = async (req, res) => {
const submission = await ContactSubmission.findByIdAndUpdate( const submission = await ContactSubmission.findByIdAndUpdate(
id, id,
updateData, updateData,
{ new: true }, { new: true }
); );
if (!submission) { if (!submission) {
return res.status(404).json({ return res.status(404).json({ success: false, error: "Submission not found" });
success: false,
error: "Submission not found",
});
} }
res.json({ res.json({ success: true, data: submission });
success: true,
data: submission,
});
} catch (err) { } catch (err) {
console.error("Error updating submission:", err); console.error("Error updating submission:", err);
res.status(500).json({ res.status(500).json({ success: false, error: "Error updating submission" });
success: false,
error: "Error updating submission",
});
} }
}; };
+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");
}
};
+83 -96
View File
@@ -1,119 +1,106 @@
{ {
"metadata": {
"title": "Contact Us | LAMS",
"description": "Get in touch with LAMS. Our team is ready to assist you with admissions, financial aid, or program information.",
"keywords": "contact LAMS, admissions support, university contact, online education help",
"ogImage": "/images/og-contact.jpg"
},
"hero": { "hero": {
"title": "CONTACT US", "badge": "We're Here to Help",
"backgroundImage": "/assets/img/inner-page/breadcrumb.jpg", "titleMain": "Get in Touch with ",
"overlayColor": "rgba(0, 0, 0, 0)", "titleHighlight": "LAMS",
"sectionClass": "breadcrumb-wrapper fix bg-cover", "description": "Whether you have questions about our programs, admissions, or financial aid, our team is ready to assist you on your educational journey."
"titleClass": "breadcrumb-title",
"enableScrollspy": false,
"backgroundPosition": "center"
}, },
"contactCards": [ "infoCards": {
"contactInfo": {
"title": "Contact Information",
"channels": [
{ {
"type": "location", "icon": "fa-solid fa-phone",
"title": "Location", "title": "Admissions Phone",
"content": [ "detail": "123456789",
"43 Sardinella, 3nd Land Walk,", "subDetail": "Mon-Fri, 8am-8pm EST"
"Orchard view, London, UK"
],
"iconType": "fa-solid fa-location-dot",
"iconSource": "fontawesome"
}, },
{ {
"type": "email", "icon": "fa-regular fa-envelope",
"title": "Email Address", "title": "Email Support",
"content": [ "detail": "info@lams.ac",
"supportinfo@gmail.com", "subDetail": "We aim to reply within 24 hours"
"arluxhotelinfo.com"
],
"iconType": "fa-solid fa-envelope",
"iconSource": "fontawesome"
}, },
{ {
"type": "phone", "icon": "fa-solid fa-map-location-dot",
"title": "Phone Number", "title": "Administrative Office",
"content": [ "detail": "207 Regent Street, London, England W1B3HH"
"+880 123 427 00",
"+000 938 809 12"
],
"iconType": "fa-solid fa-phone",
"iconSource": "fontawesome"
} }
], ]
"map": {
"coordinates": {
"lat": -37.81450084255415,
"lng": 144.9618311901502
}, },
"zoom": 15, "supportHours": {
"location": "Envato, Melbourne, Australia", "title": "Student Support Hours",
"markerTitle": "Our Office", "hours": [
"embedUrl": "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d6678.7619084840835!2d144.9618311901502!3d-37.81450084255415!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x6ad642b4758afc1d%3A0x3119cc820fdfc62e!2sEnvato!5e0!3m2!1sen!2sbd!4v1641984054261!5m2!1sen!2sbd", { "day": "Monday - Friday", "time": "8:00 AM - 8:00 PM EST" },
"tileLayer": { { "day": "Saturday", "time": "10:00 AM - 4:00 PM EST" },
"url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { "day": "Sunday", "time": "Closed" }
"attribution": "", ],
"maxZoom": 18, "footer": {
"minZoom": 0 "text": "Current student looking for academic advising?",
"linkText": "Visit Student Portal",
"linkHref": "https://portal.lams.ac"
}
} }
}, },
"form": { "form": {
"sectionLabel": "", "heading": "Send us a Message",
"heading": "Send Us Message", "description": "Fill out the form below and our team will get back to you shortly.",
"description": "Have questions about visas or immigration? Send us a message today and our expert team will respond quickly.", "fields": {
"fields": [ "firstName": { "name": "first_name", "label": "First Name", "placeholder": "John", "required": true },
{ "lastName": { "name": "last_name", "label": "Last Name", "placeholder": "Doe", "required": true },
"name": "name", "email": { "name": "email", "label": "Email Address", "placeholder": "john.doe@example.com", "required": true },
"label": "Your Name", "phone": { "name": "phone_number", "label": "Phone Number (Optional)", "placeholder": "(555) 123-4567", "required": false },
"type": "text", "inquiryType": {
"placeholder": "Your name", "name": "inquiry_type",
"label": "How can we help you?",
"placeholder": "Select an inquiry type...",
"required": true, "required": true,
"colClass": "col-lg-4" "options": [
{ "label": "Admissions & Enrollment", "value": "admissions" },
{ "label": "Financial Aid & Tuition", "value": "financial_aid" },
{ "label": "Program Information", "value": "program_info" },
{ "label": "Technical Support", "value": "tech_support" },
{ "label": "Other", "value": "other" }
]
},
"message": { "name": "message_content", "label": "Message", "placeholder": "Please provide details about your inquiry...", "required": true }
},
"submitLabel": "Send Message",
"successMessage": "Your message has been sent successfully! Our team will contact you soon.",
"errorMessage": "Something went wrong. Please try again later or contact us directly."
},
"faq": {
"title": "Frequently Asked Questions",
"subtitle": "Find quick answers to common questions about admissions, tuition, and online learning.",
"items": [
{
"question": "Are LAMS degrees accredited?",
"answer": "Yes, LAMS is regionally accredited by the Higher Learning Commission. Our programs meet rigorous academic standards and our degrees are recognized by employers and other educational institutions worldwide."
}, },
{ {
"name": "email", "question": "How does the monthly subscription model work?",
"label": "Your Email", "answer": "Our subscription model allows you to pay a flat monthly fee to access your coursework. You can move through the material at your own pace, potentially completing more courses per term compared to traditional models."
"type": "email",
"placeholder": "Your email",
"required": true,
"colClass": "col-lg-4"
}, },
{ {
"name": "phone", "question": "Can I transfer credits from another institution?",
"label": "Your Phone", "answer": "Absolutely. We accept transfer credits from regionally or nationally accredited institutions. You can transfer up to 90 credits for a bachelor's degree program."
"type": "tel",
"placeholder": "Phone Number",
"required": true,
"colClass": "col-lg-4"
}, },
{ {
"name": "address", "question": "Are there set class times I need to attend?",
"label": "Your Address", "answer": "No, our programs are 100% online and asynchronous. While there are deadlines, you do not need to log in at specific times for live lectures."
"type": "text",
"placeholder": "Address Now",
"required": false,
"colClass": "col-lg-6"
},
{
"name": "date",
"label": "Your Date",
"type": "date",
"placeholder": "Date",
"required": false,
"colClass": "col-lg-6"
},
{
"name": "message",
"label": "Your Message",
"type": "textarea",
"placeholder": "Type your message",
"required": false,
"colClass": "col-lg-12"
}
],
"submitButton": {
"text": "SEND MESSAGE",
"icon": "fa-solid fa-arrow-right",
"buttonClass": "theme-btn style-2"
} }
]
},
"cta": {
"title": "Ready to take the next step?",
"description": "Join thousands of students who have advanced their careers through our affordable, flexible online programs.",
"primaryButton": { "label": "Request Information", "href": "/request" },
"secondaryButton": { "label": "Apply Now", "href": "/apply" }
} }
} }
+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!"
}
}
}
+128 -361
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: "" },
linkText: { type: String, trim: true, default: "" },
linkHref: { type: String, trim: true, default: "" },
}, },
maxZoom: {
type: Number,
default: 18,
},
minZoom: {
type: Number,
default: 0,
}, },
}, },
{ _id: false } { _id: false }
); );
// Schema cho map const formOptionSchema = new mongoose.Schema(
const mapSchema = new mongoose.Schema(
{ {
coordinates: { label: { type: String, trim: true, default: "" },
type: coordinatesSchema, value: { type: String, trim: true, default: "" },
required: true,
},
zoom: {
type: Number,
default: 15,
},
location: {
type: String,
required: true,
trim: true,
},
markerTitle: {
type: String,
trim: true,
default: "",
},
embedUrl: {
type: String,
trim: true,
default: "",
},
tileLayer: {
type: tileLayerSchema,
required: true,
},
}, },
{ _id: false } { _id: false }
); );
// Schema cho form field const simpleFieldSchema = new mongoose.Schema(
const formFieldSchema = new mongoose.Schema(
{ {
name: { name: { type: String, trim: true, default: "" },
type: String, label: { type: String, trim: true, default: "" },
required: true, placeholder: { type: String, trim: true, default: "" },
trim: true, required: { type: Boolean, default: false },
},
label: {
type: String,
trim: true,
default: "",
},
type: {
type: String,
required: true,
trim: true,
enum: ["text", "email", "tel", "textarea", "programme", "date"],
},
placeholder: {
type: String,
trim: true,
default: "",
},
required: {
type: Boolean,
default: false,
},
colClass: {
type: String,
trim: true,
default: "col-lg-12",
},
programmeName: {
type: String,
trim: true,
default: "",
},
}, },
{ _id: false } { _id: false }
); );
// Schema cho submit button const inquiryFieldSchema = new mongoose.Schema(
const submitButtonSchema = new mongoose.Schema(
{ {
text: { name: { type: String, trim: true, default: "" },
type: String, label: { type: String, trim: true, default: "" },
required: true, placeholder: { type: String, trim: true, default: "" },
trim: true, required: { type: Boolean, default: true },
}, options: { type: [formOptionSchema], default: [] },
icon: {
type: String,
trim: true,
default: "fa-solid fa-arrow-right",
},
buttonClass: {
type: String,
trim: true,
default: "theme-btn style-2",
},
}, },
{ _id: false } { _id: false }
); );
// Schema cho form
const formSchema = new mongoose.Schema( const formSchema = new mongoose.Schema(
{ {
sectionLabel: { heading: { type: String, trim: true, default: "" },
type: String, description: { type: String, trim: true, default: "" },
trim: true,
default: "",
},
heading: {
type: String,
trim: true,
default: "",
},
description: {
type: String,
trim: true,
default: "",
},
fields: { fields: {
type: [formFieldSchema], firstName: { type: simpleFieldSchema, default: () => ({}) },
default: [], lastName: { type: simpleFieldSchema, default: () => ({}) },
}, email: { type: simpleFieldSchema, default: () => ({}) },
submitButton: { phone: { type: simpleFieldSchema, default: () => ({}) },
type: submitButtonSchema, inquiryType: { type: inquiryFieldSchema, default: () => ({}) },
required: true, message: { type: simpleFieldSchema, default: () => ({}) },
}, },
submitLabel: { type: String, trim: true, default: "Send Message" },
successMessage: { type: String, trim: true, default: "" },
errorMessage: { type: String, trim: true, default: "" },
}, },
{ _id: false } { _id: false }
); );
// Main Contact Schema const faqItemSchema = new mongoose.Schema(
const contactSchema = new mongoose.Schema(
{ {
name: { question: { type: String, trim: true, default: "" },
type: String, answer: { type: String, trim: true, default: "" },
default: "default",
unique: true,
}, },
hero: { { _id: false }
type: heroSchema,
required: true,
},
contactCards: {
type: [contactCardSchema],
default: [],
},
map: {
type: mapSchema,
required: true,
},
form: {
type: formSchema,
required: true,
},
},
{
timestamps: true,
}
); );
// Mapping iconType cũ sang Font Awesome icon mới const faqSchema = new mongoose.Schema(
const iconTypeMapping = { {
phone: "fas fa-phone", title: { type: String, trim: true, default: "" },
email: "fas fa-envelope", subtitle: { type: String, trim: true, default: "" },
location: "fas fa-map-marker-alt", items: { type: [faqItemSchema], default: [] },
clock: "fas fa-clock", },
hours: "fas fa-clock", { _id: false }
}; );
// Tạo migration script để import dữ liệu từ contact-data.json const ctaButtonSchema = new mongoose.Schema(
{
label: { type: String, trim: true, default: "" },
href: { type: String, trim: true, default: "" },
},
{ _id: false }
);
const ctaSchema = new mongoose.Schema(
{
title: { type: String, trim: true, default: "" },
description: { type: String, trim: true, default: "" },
primaryButton: { type: ctaButtonSchema, default: () => ({}) },
secondaryButton: { type: ctaButtonSchema, default: () => ({}) },
},
{ _id: false }
);
// ─────────────────────────────────────────────
// Main Contact Schema (slug-based, same pattern
// as StudentSupport / RequestInfo)
// ─────────────────────────────────────────────
const contactSchema = new mongoose.Schema(
{
slug: { type: String, default: "contact", unique: true },
metadata: { type: metadataSchema, default: () => ({}) },
hero: { type: heroSchema, default: () => ({}) },
infoCards: { type: infoCardsSchema, default: () => ({}) },
form: { type: formSchema, default: () => ({}) },
faq: { type: faqSchema, default: () => ({}) },
cta: { type: ctaSchema, default: () => ({}) },
},
{ timestamps: true }
);
// ─────────────────────────────────────────────
// Migration helper (idempotent upsert)
// ─────────────────────────────────────────────
contactSchema.statics.migrateFromJson = async function (jsonData) { contactSchema.statics.migrateFromJson = async function (jsonData) {
try { const payload = {
// Kiểm tra xem đã có contact mặc định chưa metadata: jsonData.metadata || {},
const existingContact = await this.findOne({ name: "default" }); hero: jsonData.hero || {},
infoCards: jsonData.infoCards || {},
// Xử lý và chuẩn hóa dữ liệu từ JSON form: jsonData.form || {},
const processedData = { faq: jsonData.faq || {},
hero: { cta: jsonData.cta || {},
title: jsonData.hero?.title || "Contact Us",
backgroundImage: jsonData.hero?.backgroundImage || "",
overlayColor: jsonData.hero?.overlayColor || "rgba(0, 0, 0, 0)",
sectionClass: jsonData.hero?.sectionClass || "",
titleClass: jsonData.hero?.titleClass || "",
enableScrollspy: jsonData.hero?.enableScrollspy || false,
backgroundPosition: jsonData.hero?.backgroundPosition || "center",
},
contactCards: (jsonData.contactCards || []).map((card) => {
let iconType = card.iconType || "";
let iconSource = card.iconSource;
// Nếu không có iconSource, tự động detect từ iconType
if (!iconSource) {
// Nếu iconType là image path (bắt đầu bằng /uploads/ hoặc http)
if (
iconType.startsWith("/uploads/") ||
iconType.startsWith("http://") ||
iconType.startsWith("https://")
) {
iconSource = "image";
} else {
// Nếu iconType là string cũ (phone, email, location, clock)
iconSource = "fontawesome";
// Map iconType cũ sang Font Awesome icon mới
if (iconTypeMapping[iconType]) {
iconType = iconTypeMapping[iconType];
} else if (
iconType &&
!iconType.startsWith("fas ") &&
!iconType.startsWith("fab ")
) {
// Nếu iconType không phải là Font Awesome class hợp lệ, thử map
iconType = iconTypeMapping[iconType] || iconType;
}
}
} else {
// Nếu đã có iconSource nhưng iconType là string cũ, map sang Font Awesome
if (
iconSource === "fontawesome" &&
iconType &&
!iconType.startsWith("fas ") &&
!iconType.startsWith("fab ") &&
iconTypeMapping[iconType]
) {
iconType = iconTypeMapping[iconType];
}
}
return {
type: card.type || "custom",
title: card.title || "",
content: Array.isArray(card.content) ? card.content : [],
iconType: iconType,
iconSource: iconSource || "fontawesome",
};
}),
map: {
coordinates: {
lat: jsonData.map?.coordinates?.lat || 0,
lng: jsonData.map?.coordinates?.lng || 0,
},
zoom: jsonData.map?.zoom || 15,
location: jsonData.map?.location || "",
markerTitle: jsonData.map?.markerTitle || "",
embedUrl: jsonData.map?.embedUrl || "",
tileLayer: {
url:
jsonData.map?.tileLayer?.url ||
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: jsonData.map?.tileLayer?.attribution || "",
maxZoom: jsonData.map?.tileLayer?.maxZoom || 18,
minZoom: jsonData.map?.tileLayer?.minZoom || 0,
},
},
form: {
sectionLabel: jsonData.form?.sectionLabel || "",
heading: jsonData.form?.heading || "",
description: jsonData.form?.description || "",
fields: (jsonData.form?.fields || []).map((field) => ({
name: field.name || "",
label: field.label || "",
type: field.type || "text",
placeholder: field.placeholder || "",
required: field.required || false,
colClass: field.colClass || "col-lg-12",
programmeName: field.programmeName || "",
})),
submitButton: {
text: jsonData.form?.submitButton?.text || "Send Message",
icon: jsonData.form?.submitButton?.icon || "fa-solid fa-arrow-right",
buttonClass: jsonData.form?.submitButton?.buttonClass || "theme-btn style-2",
},
},
}; };
if (existingContact) { const existing = await this.findOne({ slug: "contact" });
// Cập nhật contact hiện có với dữ liệu đã xử lý if (existing) {
existingContact.hero = processedData.hero; existing.set(payload);
existingContact.contactCards = processedData.contactCards; await existing.save();
existingContact.map = processedData.map; console.log("✅ Contact data updated via migration.");
existingContact.form = processedData.form; return existing;
await existingContact.save();
console.log("Contact data updated successfully");
return existingContact;
} else { } else {
// Tạo contact mới với dữ liệu đã xử lý const created = await this.create({ slug: "contact", ...payload });
const newContact = await this.create({ console.log("✅ Contact data created via migration.");
name: "default", return created;
...processedData,
});
console.log("Contact data imported successfully");
return newContact;
}
} catch (error) {
console.error("Error migrating contact data:", error);
throw error;
} }
}; };
+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);
+26
View File
@@ -9,6 +9,8 @@ const footerController = require("../controllers/footerController");
const aboutUsController = require("../controllers/aboutUsController"); const aboutUsController = require("../controllers/aboutUsController");
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
@@ -170,6 +172,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(
+8
View File
@@ -7,6 +7,8 @@ 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");
@@ -64,6 +66,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);
+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 };
+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
+79 -1
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">
@@ -206,7 +242,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">7 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 +365,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">
+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>
+4
View File
@@ -738,6 +738,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>