forked from UKSOURCE/cms.lams
Adding backend of Student Support, Contact, Request
This commit is contained in:
@@ -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",
|
||||||
|
|
||||||
|
|||||||
+100
-204
@@ -1,110 +1,89 @@
|
|||||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||||
|
const { ICON_OPTIONS, normalizeIconClass } = require("../utils/iconOptions");
|
||||||
const Contact = require("../models/contact");
|
const Contact = require("../models/contact");
|
||||||
const ContactSubmission = require("../models/contactSubmission");
|
const ContactSubmission = require("../models/contactSubmission");
|
||||||
const writeAuditLog = require("../audit/writeAuditLog");
|
const writeAuditLog = require("../audit/writeAuditLog");
|
||||||
const diffObject = require("../audit/diffObject");
|
const diffObject = require("../audit/diffObject");
|
||||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||||
|
|
||||||
// Get contact data from MongoDB
|
const SLUG = "contact";
|
||||||
const getContactData = async () => {
|
|
||||||
const contact = await Contact.findOne({ name: "default" });
|
|
||||||
if (!contact) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return contact.toObject();
|
|
||||||
};
|
|
||||||
|
|
||||||
// API to get contact data
|
function parseJsonField(raw) {
|
||||||
|
if (raw == null || raw === "") return null;
|
||||||
|
if (typeof raw === "object") return raw;
|
||||||
|
try { return JSON.parse(raw); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDocument() {
|
||||||
|
return Contact.findOne({ slug: SLUG }).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/contact — public payload for frontend LAMS.
|
||||||
|
*/
|
||||||
exports.api = async (req, res) => {
|
exports.api = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const contact = await getContactData();
|
const doc = await getDocument();
|
||||||
if (!contact) {
|
if (!doc) {
|
||||||
return res.status(404).json({ error: "Contact data not found" });
|
return res.status(404).json({ error: "Contact data not found" });
|
||||||
}
|
}
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||||
const processedData = addBaseUrlToImages(contact, baseUrl);
|
const body = {
|
||||||
res.json(processedData);
|
metadata: doc.metadata || {},
|
||||||
|
hero: doc.hero || {},
|
||||||
|
infoCards: doc.infoCards || {},
|
||||||
|
form: doc.form || {},
|
||||||
|
faq: doc.faq || {},
|
||||||
|
cta: doc.cta || {},
|
||||||
|
};
|
||||||
|
res.json(addBaseUrlToImages(body, baseUrl));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("API Error:", err);
|
console.error("contact.api:", err);
|
||||||
res.status(500).json({ error: "Error loading contact data" });
|
res.status(500).json({ error: "Error loading contact data" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// API để lấy toàn bộ contact data
|
// Legacy alias
|
||||||
exports.getContactData = async (req, res) => {
|
exports.getContactData = exports.api;
|
||||||
try {
|
|
||||||
const contactData = await getContactData();
|
|
||||||
if (!contactData) {
|
|
||||||
return res.status(404).json({ error: "Contact data not found" });
|
|
||||||
}
|
|
||||||
res.json(contactData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error getting contact data:", error);
|
|
||||||
res.status(500).json({ error: "Error loading contact data" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render admin view
|
/**
|
||||||
|
* GET /admin/contact — Admin view.
|
||||||
|
*/
|
||||||
exports.index = async (req, res) => {
|
exports.index = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const data = (await getContactData()) || {
|
const doc = await getDocument();
|
||||||
hero: {
|
const data = doc
|
||||||
title: "Contact Us",
|
? {
|
||||||
backgroundImage: "",
|
metadata: doc.metadata || {},
|
||||||
overlayColor: "rgba(0, 0, 0, 0)",
|
hero: doc.hero || {},
|
||||||
sectionClass: "",
|
infoCards: doc.infoCards || {},
|
||||||
titleClass: "",
|
form: doc.form || {},
|
||||||
enableScrollspy: false,
|
faq: doc.faq || {},
|
||||||
backgroundPosition: "center",
|
cta: doc.cta || {},
|
||||||
},
|
}
|
||||||
contactCards: [],
|
: {
|
||||||
map: {
|
metadata: {},
|
||||||
coordinates: { lat: 0, lng: 0 },
|
hero: { badge: "", titleMain: "", titleHighlight: "", description: "" },
|
||||||
zoom: 15,
|
infoCards: { contactInfo: { title: "", channels: [] }, supportHours: { title: "", hours: [], footer: {} } },
|
||||||
location: "",
|
form: { heading: "", description: "", fields: {}, submitLabel: "Send Message", successMessage: "", errorMessage: "" },
|
||||||
markerTitle: "",
|
faq: { title: "", subtitle: "", items: [] },
|
||||||
embedUrl: "",
|
cta: { title: "", description: "", primaryButton: {}, secondaryButton: {} },
|
||||||
tileLayer: {
|
};
|
||||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
||||||
attribution: "",
|
|
||||||
maxZoom: 18,
|
|
||||||
minZoom: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
sectionLabel: "",
|
|
||||||
heading: "",
|
|
||||||
description: "",
|
|
||||||
fields: [],
|
|
||||||
submitButton: {
|
|
||||||
text: "Send Message",
|
|
||||||
icon: "fa-solid fa-arrow-right",
|
|
||||||
buttonClass: "theme-btn style-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const { startDate, endDate } = req.query;
|
const { startDate, endDate } = req.query;
|
||||||
const query = {};
|
const query = {};
|
||||||
|
|
||||||
if (startDate || endDate) {
|
if (startDate || endDate) {
|
||||||
query.createdAt = {};
|
query.createdAt = {};
|
||||||
if (startDate) {
|
if (startDate) query.createdAt.$gte = new Date(startDate);
|
||||||
query.createdAt.$gte = new Date(startDate);
|
|
||||||
}
|
|
||||||
if (endDate) {
|
if (endDate) {
|
||||||
// Set end date to end of day
|
|
||||||
const end = new Date(endDate);
|
const end = new Date(endDate);
|
||||||
end.setHours(23, 59, 59, 999);
|
end.setHours(23, 59, 59, 999);
|
||||||
query.createdAt.$lte = end;
|
query.createdAt.$lte = end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const submissions = await ContactSubmission.find(query).sort({ createdAt: -1 }).limit(50);
|
||||||
const submissions = await ContactSubmission.find(query)
|
const frontendUrl = process.env.FRONTEND_URL || "";
|
||||||
.sort({ createdAt: -1 })
|
|
||||||
.limit(50);
|
|
||||||
const frontendUrl = process.env.FRONTEND_URL;
|
|
||||||
|
|
||||||
res.render("admin/contact/index", {
|
res.render("admin/contact/index", {
|
||||||
title: "Contact Management",
|
title: "Contact Management",
|
||||||
@@ -114,124 +93,61 @@ exports.index = async (req, res) => {
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
frontendUrl,
|
frontendUrl,
|
||||||
|
iconOptions: ICON_OPTIONS,
|
||||||
currentPath: req.path,
|
currentPath: req.path,
|
||||||
user: req.session.user,
|
user: req.session.user,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in contact index:", error);
|
console.error("contact.index:", error);
|
||||||
req.flash("error_msg", "An error occurred while loading the page");
|
req.flash("error_msg", "An error occurred while loading the page");
|
||||||
res.redirect("/admin/dashboard");
|
res.redirect("/admin/dashboard");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cập nhật dữ liệu contact
|
/**
|
||||||
|
* POST /admin/contact/update — Save & audit.
|
||||||
|
*/
|
||||||
exports.update = async (req, res) => {
|
exports.update = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { hero, contactCards, map, form } = req.body;
|
const metadata = parseJsonField(req.body.metadata);
|
||||||
|
const hero = parseJsonField(req.body.hero);
|
||||||
|
const infoCards = parseJsonField(req.body.infoCards);
|
||||||
|
const form = parseJsonField(req.body.form);
|
||||||
|
const faq = parseJsonField(req.body.faq);
|
||||||
|
const cta = parseJsonField(req.body.cta);
|
||||||
|
|
||||||
// Parse JSON strings nếu cần
|
if (!metadata || !hero || !infoCards || !form || !faq || !cta) {
|
||||||
const parseJson = (data) => {
|
req.flash("error_msg", "Invalid form payload. Please try again.");
|
||||||
if (!data) return null;
|
return res.redirect("/admin/contact");
|
||||||
if (typeof data === "string") {
|
|
||||||
try {
|
|
||||||
return JSON.parse(data);
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const heroData = parseJson(hero);
|
|
||||||
const contactCardsData = parseJson(contactCards);
|
|
||||||
const mapData = parseJson(map);
|
|
||||||
const formData = parseJson(form);
|
|
||||||
|
|
||||||
// Tìm hoặc tạo contact
|
|
||||||
let contact = await Contact.findOne({ name: "default" });
|
|
||||||
|
|
||||||
// ✅ Capture BEFORE state
|
|
||||||
const beforeData = contact
|
|
||||||
? JSON.parse(JSON.stringify(contact.toObject()))
|
|
||||||
: {};
|
|
||||||
|
|
||||||
if (!contact) {
|
|
||||||
// Tạo mới với default values
|
|
||||||
contact = new Contact({
|
|
||||||
name: "default",
|
|
||||||
hero: heroData || {
|
|
||||||
title: "Contact Us",
|
|
||||||
backgroundImage: "",
|
|
||||||
overlayColor: "rgba(0, 0, 0, 0)",
|
|
||||||
sectionClass: "",
|
|
||||||
titleClass: "",
|
|
||||||
enableScrollspy: false,
|
|
||||||
backgroundPosition: "center",
|
|
||||||
},
|
|
||||||
contactCards: (contactCardsData || []).map((card) => ({
|
|
||||||
...card,
|
|
||||||
iconType: card.iconType || "",
|
|
||||||
iconSource:
|
|
||||||
card.iconSource ||
|
|
||||||
(card.iconType && card.iconType.startsWith("/uploads/")
|
|
||||||
? "image"
|
|
||||||
: "fontawesome"),
|
|
||||||
})),
|
|
||||||
map: mapData || {
|
|
||||||
coordinates: { lat: 0, lng: 0 },
|
|
||||||
zoom: 15,
|
|
||||||
location: "",
|
|
||||||
markerTitle: "",
|
|
||||||
embedUrl: "",
|
|
||||||
tileLayer: {
|
|
||||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
||||||
attribution: "",
|
|
||||||
maxZoom: 18,
|
|
||||||
minZoom: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
form: formData || {
|
|
||||||
sectionLabel: "",
|
|
||||||
heading: "",
|
|
||||||
description: "",
|
|
||||||
fields: [],
|
|
||||||
submitButton: {
|
|
||||||
text: "Send Message",
|
|
||||||
icon: "fa-solid fa-arrow-right",
|
|
||||||
buttonClass: "theme-btn style-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Cập nhật dữ liệu
|
|
||||||
if (heroData) contact.hero = heroData;
|
|
||||||
if (contactCardsData && Array.isArray(contactCardsData)) {
|
|
||||||
// Đảm bảo mỗi card có iconType và iconSource
|
|
||||||
contact.contactCards = contactCardsData.map((card) => ({
|
|
||||||
...card,
|
|
||||||
iconType: card.iconType || "",
|
|
||||||
iconSource:
|
|
||||||
card.iconSource ||
|
|
||||||
(card.iconType && card.iconType.startsWith("/uploads/")
|
|
||||||
? "image"
|
|
||||||
: "fontawesome"),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (mapData) contact.map = mapData;
|
|
||||||
if (formData) contact.form = formData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await contact.save();
|
// Normalize channel icons against whitelist
|
||||||
|
if (infoCards?.contactInfo?.channels?.length) {
|
||||||
|
infoCards.contactInfo.channels = infoCards.contactInfo.channels.map((ch) => ({
|
||||||
|
...ch,
|
||||||
|
icon: normalizeIconClass(ch.icon),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ Capture AFTER state
|
const payload = { metadata, hero, infoCards, form, faq, cta };
|
||||||
const afterData = JSON.parse(JSON.stringify(contact.toObject()));
|
|
||||||
|
let doc = await Contact.findOne({ slug: SLUG });
|
||||||
|
const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {};
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
doc = new Contact({ slug: SLUG, ...payload });
|
||||||
|
} else {
|
||||||
|
doc.set(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
await doc.save();
|
||||||
|
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||||
|
|
||||||
// ✅ AUDIT LOGGING - Contact Updated
|
|
||||||
const changes = diffObject(beforeData, afterData);
|
const changes = diffObject(beforeData, afterData);
|
||||||
if (changes.length > 0) {
|
if (changes.length > 0) {
|
||||||
await writeAuditLog({
|
await writeAuditLog({
|
||||||
model: "Contact",
|
model: "Contact",
|
||||||
documentId: contact._id,
|
documentId: doc._id,
|
||||||
action: AUDIT_ACTIONS.UPDATE_CONTACT,
|
action: AUDIT_ACTIONS.UPDATE_CONTACT,
|
||||||
before: beforeData,
|
before: beforeData,
|
||||||
after: afterData,
|
after: afterData,
|
||||||
@@ -243,18 +159,20 @@ exports.update = async (req, res) => {
|
|||||||
req.flash("success_msg", "Contact updated successfully");
|
req.flash("success_msg", "Contact updated successfully");
|
||||||
res.redirect("/admin/contact");
|
res.redirect("/admin/contact");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error updating contact:", err);
|
console.error("contact.update:", err);
|
||||||
req.flash("error_msg", err.message || "Error updating contact");
|
req.flash("error_msg", err.message || "Error updating contact");
|
||||||
res.redirect("/admin/contact");
|
res.redirect("/admin/contact");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// API để submit contact form (từ frontend)
|
// ─────────────────────────────────────────────
|
||||||
|
// Form Submissions (unchanged)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
exports.submitForm = async (req, res) => {
|
exports.submitForm = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, email, phone, address, date, message } = req.body;
|
const { name, email, phone, address, date, message } = req.body;
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!name || !email) {
|
if (!name || !email) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -262,7 +180,6 @@ exports.submitForm = async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new submission
|
|
||||||
const submission = new ContactSubmission({
|
const submission = new ContactSubmission({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
email: email.trim().toLowerCase(),
|
email: email.trim().toLowerCase(),
|
||||||
@@ -288,13 +205,9 @@ exports.submitForm = async (req, res) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error submitting contact form:", err);
|
console.error("Error submitting contact form:", err);
|
||||||
|
|
||||||
// Handle validation errors
|
|
||||||
if (err.name === "ValidationError") {
|
if (err.name === "ValidationError") {
|
||||||
const errors = Object.values(err.errors).map((e) => e.message);
|
const errors = Object.values(err.errors).map((e) => e.message);
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, error: errors.join(", ") });
|
||||||
success: false,
|
|
||||||
error: errors.join(", "),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -304,7 +217,6 @@ exports.submitForm = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// API để lấy danh sách submissions (cho admin)
|
|
||||||
exports.getSubmissions = async (req, res) => {
|
exports.getSubmissions = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { status, page = 1, limit = 20 } = req.query;
|
const { status, page = 1, limit = 20 } = req.query;
|
||||||
@@ -336,14 +248,10 @@ exports.getSubmissions = async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error getting submissions:", err);
|
console.error("Error getting submissions:", err);
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, error: "Error loading submissions" });
|
||||||
success: false,
|
|
||||||
error: "Error loading submissions",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// API để cập nhật status của submission
|
|
||||||
exports.updateSubmissionStatus = async (req, res) => {
|
exports.updateSubmissionStatus = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -351,10 +259,7 @@ exports.updateSubmissionStatus = async (req, res) => {
|
|||||||
|
|
||||||
const validStatuses = ["pending", "read", "replied", "archived"];
|
const validStatuses = ["pending", "read", "replied", "archived"];
|
||||||
if (!validStatuses.includes(status)) {
|
if (!validStatuses.includes(status)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, error: "Invalid status" });
|
||||||
success: false,
|
|
||||||
error: "Invalid status",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData = { status };
|
const updateData = { status };
|
||||||
@@ -364,25 +269,16 @@ exports.updateSubmissionStatus = async (req, res) => {
|
|||||||
const submission = await ContactSubmission.findByIdAndUpdate(
|
const submission = await ContactSubmission.findByIdAndUpdate(
|
||||||
id,
|
id,
|
||||||
updateData,
|
updateData,
|
||||||
{ new: true },
|
{ new: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!submission) {
|
if (!submission) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({ success: false, error: "Submission not found" });
|
||||||
success: false,
|
|
||||||
error: "Submission not found",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({ success: true, data: submission });
|
||||||
success: true,
|
|
||||||
data: submission,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error updating submission:", err);
|
console.error("Error updating submission:", err);
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, error: "Error updating submission" });
|
||||||
success: false,
|
|
||||||
error: "Error updating submission",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||||
|
const { normalizeIconClass } = require("../utils/iconOptions");
|
||||||
|
const RequestInfo = require("../models/requestInfo");
|
||||||
|
const writeAuditLog = require("../audit/writeAuditLog");
|
||||||
|
const diffObject = require("../audit/diffObject");
|
||||||
|
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||||
|
|
||||||
|
const SLUG = "request-info";
|
||||||
|
|
||||||
|
function emptyPageShape() {
|
||||||
|
return {
|
||||||
|
metadata: { title: "", description: "", keywords: "", ogImage: "" },
|
||||||
|
hero: {
|
||||||
|
badge: "",
|
||||||
|
titleMain: "",
|
||||||
|
titleHighlight: "",
|
||||||
|
description: "",
|
||||||
|
valueProps: [],
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
heading: "",
|
||||||
|
description: "",
|
||||||
|
fields: {
|
||||||
|
firstName: { name: "", label: "", placeholder: "", required: true },
|
||||||
|
lastName: { name: "", label: "", placeholder: "", required: true },
|
||||||
|
email: { name: "", label: "", placeholder: "", required: true },
|
||||||
|
phone: { name: "", label: "", placeholder: "", required: false },
|
||||||
|
program: { name: "", label: "", placeholder: "", required: true, options: [] },
|
||||||
|
timeline: { name: "", label: "", options: [] },
|
||||||
|
},
|
||||||
|
consentText: "",
|
||||||
|
submitLabel: "",
|
||||||
|
footerText: "",
|
||||||
|
successMessage: "",
|
||||||
|
errorMessage: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonField(raw) {
|
||||||
|
if (raw == null || raw === "") return null;
|
||||||
|
if (typeof raw === "object") return raw;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chuẩn hoá icon theo whitelist (valueProps). */
|
||||||
|
function sanitizeIcons(payload) {
|
||||||
|
if (!payload) return payload;
|
||||||
|
if (payload.hero?.valueProps?.length) {
|
||||||
|
payload.hero.valueProps = payload.hero.valueProps.map((vp) => ({
|
||||||
|
...vp,
|
||||||
|
icon: normalizeIconClass(vp.icon),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDocument() {
|
||||||
|
return RequestInfo.findOne({ slug: SLUG }).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/request-info — public payload cho frontend LAMS.
|
||||||
|
* Response: { metadata, hero, form } với ảnh đã gắn full URL.
|
||||||
|
*/
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const doc = await getDocument();
|
||||||
|
if (!doc) {
|
||||||
|
return res.status(404).json({ error: "Request Info content not found" });
|
||||||
|
}
|
||||||
|
const baseUrl =
|
||||||
|
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||||
|
const body = {
|
||||||
|
metadata: doc.metadata || {},
|
||||||
|
hero: doc.hero || {},
|
||||||
|
form: doc.form || {},
|
||||||
|
};
|
||||||
|
res.json(addBaseUrlToImages(body, baseUrl));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("requestInfo.api:", err);
|
||||||
|
res.status(500).json({ error: "Error loading Request Info data" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/request-info — Admin view.
|
||||||
|
*/
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const doc = await getDocument();
|
||||||
|
const data = doc
|
||||||
|
? {
|
||||||
|
metadata: doc.metadata || {},
|
||||||
|
hero: doc.hero || {},
|
||||||
|
form: doc.form || {},
|
||||||
|
}
|
||||||
|
: emptyPageShape();
|
||||||
|
|
||||||
|
const { ICON_OPTIONS } = require("../utils/iconOptions");
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "";
|
||||||
|
|
||||||
|
res.render("admin/request-info/index", {
|
||||||
|
title: "Request Info",
|
||||||
|
layout: "layouts/main",
|
||||||
|
data,
|
||||||
|
iconOptions: ICON_OPTIONS,
|
||||||
|
frontendUrl,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("requestInfo.index:", error);
|
||||||
|
req.flash("error_msg", "Could not load Request Info page");
|
||||||
|
res.redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/request-info/update — Lưu dữ liệu, tạo audit log.
|
||||||
|
*/
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const metadata = parseJsonField(req.body.metadata);
|
||||||
|
const hero = parseJsonField(req.body.hero);
|
||||||
|
const form = parseJsonField(req.body.form);
|
||||||
|
|
||||||
|
if (!metadata || !hero || !form) {
|
||||||
|
req.flash("error_msg", "Invalid form payload. Please try again.");
|
||||||
|
return res.redirect("/admin/request-info");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = sanitizeIcons({ metadata, hero, form });
|
||||||
|
|
||||||
|
let doc = await RequestInfo.findOne({ slug: SLUG });
|
||||||
|
const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {};
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
doc = new RequestInfo({ slug: SLUG, ...payload });
|
||||||
|
} else {
|
||||||
|
doc.set(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
await doc.save();
|
||||||
|
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||||
|
|
||||||
|
const changes = diffObject(beforeData, afterData);
|
||||||
|
if (changes.length > 0) {
|
||||||
|
await writeAuditLog({
|
||||||
|
model: "RequestInfo",
|
||||||
|
documentId: doc._id,
|
||||||
|
action: AUDIT_ACTIONS.UPDATE_REQUEST_INFO,
|
||||||
|
before: beforeData,
|
||||||
|
after: afterData,
|
||||||
|
changes,
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.flash("success_msg", "Request Info updated successfully");
|
||||||
|
res.redirect("/admin/request-info");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("requestInfo.update:", err);
|
||||||
|
req.flash("error_msg", err.message || "Error updating Request Info");
|
||||||
|
res.redirect("/admin/request-info");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||||
|
const { normalizeIconClass } = require("../utils/iconOptions");
|
||||||
|
const StudentSupport = require("../models/studentSupport");
|
||||||
|
const writeAuditLog = require("../audit/writeAuditLog");
|
||||||
|
const diffObject = require("../audit/diffObject");
|
||||||
|
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||||
|
|
||||||
|
const SLUG = "student-support";
|
||||||
|
|
||||||
|
function emptyPageShape() {
|
||||||
|
return {
|
||||||
|
metadata: { title: "", description: "" },
|
||||||
|
hero: {
|
||||||
|
badge: "",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
primaryButton: { label: "", href: "" },
|
||||||
|
secondaryButton: { label: "", href: "" },
|
||||||
|
image: "",
|
||||||
|
imageAlt: "",
|
||||||
|
},
|
||||||
|
directory: { title: "", subtitle: "", services: [] },
|
||||||
|
contactSection: {
|
||||||
|
heading: "",
|
||||||
|
description: "",
|
||||||
|
channels: [],
|
||||||
|
form: {
|
||||||
|
heading: "",
|
||||||
|
fields: {
|
||||||
|
firstName: { label: "", placeholder: "" },
|
||||||
|
lastName: { label: "", placeholder: "" },
|
||||||
|
studentId: { label: "", placeholder: "" },
|
||||||
|
department: { label: "" },
|
||||||
|
message: { label: "", placeholder: "" },
|
||||||
|
},
|
||||||
|
departments: [],
|
||||||
|
submitLabel: "",
|
||||||
|
successMessage: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonField(raw) {
|
||||||
|
if (raw == null || raw === "") return null;
|
||||||
|
if (typeof raw === "object") return raw;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chuẩn hoá mọi field icon theo whitelist (dropdown admin). */
|
||||||
|
function sanitizeIcons(payload) {
|
||||||
|
if (!payload) return payload;
|
||||||
|
if (payload.hero) {
|
||||||
|
/* hero không có icon list */
|
||||||
|
}
|
||||||
|
if (payload.directory?.services?.length) {
|
||||||
|
payload.directory.services = payload.directory.services.map((s) => ({
|
||||||
|
...s,
|
||||||
|
icon: normalizeIconClass(s.icon),
|
||||||
|
hoursIcon: normalizeIconClass(s.hoursIcon),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (payload.contactSection?.channels?.length) {
|
||||||
|
payload.contactSection.channels = payload.contactSection.channels.map((c) => ({
|
||||||
|
...c,
|
||||||
|
icon: normalizeIconClass(c.icon),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDocument() {
|
||||||
|
return StudentSupport.findOne({ slug: SLUG }).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/student-support — public payload giống file JSON frontend (không kèm _id/slug).
|
||||||
|
*/
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const doc = await getDocument();
|
||||||
|
if (!doc) {
|
||||||
|
return res.status(404).json({ error: "Student Support content not found" });
|
||||||
|
}
|
||||||
|
const baseUrl =
|
||||||
|
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||||
|
const body = {
|
||||||
|
metadata: doc.metadata || {},
|
||||||
|
hero: doc.hero || {},
|
||||||
|
directory: doc.directory || { services: [] },
|
||||||
|
contactSection: doc.contactSection || {},
|
||||||
|
};
|
||||||
|
res.json(addBaseUrlToImages(body, baseUrl));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("studentSupport.api:", err);
|
||||||
|
res.status(500).json({ error: "Error loading Student Support data" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const doc = await getDocument();
|
||||||
|
const data = doc
|
||||||
|
? {
|
||||||
|
metadata: doc.metadata || {},
|
||||||
|
hero: doc.hero || {},
|
||||||
|
directory: doc.directory || { services: [] },
|
||||||
|
contactSection: doc.contactSection || {},
|
||||||
|
}
|
||||||
|
: emptyPageShape();
|
||||||
|
|
||||||
|
const { ICON_OPTIONS } = require("../utils/iconOptions");
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "";
|
||||||
|
|
||||||
|
res.render("admin/student-support/index", {
|
||||||
|
title: "Student Support",
|
||||||
|
layout: "layouts/main",
|
||||||
|
data,
|
||||||
|
iconOptions: ICON_OPTIONS,
|
||||||
|
frontendUrl,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("studentSupport.index:", error);
|
||||||
|
req.flash("error_msg", "Could not load Student Support page");
|
||||||
|
res.redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const metadata = parseJsonField(req.body.metadata);
|
||||||
|
const hero = parseJsonField(req.body.hero);
|
||||||
|
const directory = parseJsonField(req.body.directory);
|
||||||
|
const contactSection = parseJsonField(req.body.contactSection);
|
||||||
|
|
||||||
|
if (!metadata || !hero || !directory || !contactSection) {
|
||||||
|
req.flash("error_msg", "Invalid form payload. Please try again.");
|
||||||
|
return res.redirect("/admin/student-support");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = sanitizeIcons({
|
||||||
|
metadata,
|
||||||
|
hero,
|
||||||
|
directory,
|
||||||
|
contactSection,
|
||||||
|
});
|
||||||
|
|
||||||
|
let doc = await StudentSupport.findOne({ slug: SLUG });
|
||||||
|
const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {};
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
doc = new StudentSupport({ slug: SLUG, ...payload });
|
||||||
|
} else {
|
||||||
|
doc.set(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
await doc.save();
|
||||||
|
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||||
|
|
||||||
|
const changes = diffObject(beforeData, afterData);
|
||||||
|
if (changes.length > 0) {
|
||||||
|
await writeAuditLog({
|
||||||
|
model: "StudentSupport",
|
||||||
|
documentId: doc._id,
|
||||||
|
action: AUDIT_ACTIONS.UPDATE_STUDENT_SUPPORT,
|
||||||
|
before: beforeData,
|
||||||
|
after: afterData,
|
||||||
|
changes,
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.flash("success_msg", "Student Support updated successfully");
|
||||||
|
res.redirect("/admin/student-support");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("studentSupport.update:", err);
|
||||||
|
req.flash("error_msg", err.message || "Error updating Student Support");
|
||||||
|
res.redirect("/admin/student-support");
|
||||||
|
}
|
||||||
|
};
|
||||||
+102
-115
@@ -1,119 +1,106 @@
|
|||||||
{
|
{
|
||||||
"hero": {
|
"metadata": {
|
||||||
"title": "CONTACT US",
|
"title": "Contact Us | LAMS",
|
||||||
"backgroundImage": "/assets/img/inner-page/breadcrumb.jpg",
|
"description": "Get in touch with LAMS. Our team is ready to assist you with admissions, financial aid, or program information.",
|
||||||
"overlayColor": "rgba(0, 0, 0, 0)",
|
"keywords": "contact LAMS, admissions support, university contact, online education help",
|
||||||
"sectionClass": "breadcrumb-wrapper fix bg-cover",
|
"ogImage": "/images/og-contact.jpg"
|
||||||
"titleClass": "breadcrumb-title",
|
},
|
||||||
"enableScrollspy": false,
|
"hero": {
|
||||||
"backgroundPosition": "center"
|
"badge": "We're Here to Help",
|
||||||
|
"titleMain": "Get in Touch with ",
|
||||||
|
"titleHighlight": "LAMS",
|
||||||
|
"description": "Whether you have questions about our programs, admissions, or financial aid, our team is ready to assist you on your educational journey."
|
||||||
|
},
|
||||||
|
"infoCards": {
|
||||||
|
"contactInfo": {
|
||||||
|
"title": "Contact Information",
|
||||||
|
"channels": [
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-phone",
|
||||||
|
"title": "Admissions Phone",
|
||||||
|
"detail": "123456789",
|
||||||
|
"subDetail": "Mon-Fri, 8am-8pm EST"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-regular fa-envelope",
|
||||||
|
"title": "Email Support",
|
||||||
|
"detail": "info@lams.ac",
|
||||||
|
"subDetail": "We aim to reply within 24 hours"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-map-location-dot",
|
||||||
|
"title": "Administrative Office",
|
||||||
|
"detail": "207 Regent Street, London, England W1B3HH"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"contactCards": [
|
"supportHours": {
|
||||||
{
|
"title": "Student Support Hours",
|
||||||
"type": "location",
|
"hours": [
|
||||||
"title": "Location",
|
{ "day": "Monday - Friday", "time": "8:00 AM - 8:00 PM EST" },
|
||||||
"content": [
|
{ "day": "Saturday", "time": "10:00 AM - 4:00 PM EST" },
|
||||||
"43 Sardinella, 3nd Land Walk,",
|
{ "day": "Sunday", "time": "Closed" }
|
||||||
"Orchard view, London, UK"
|
],
|
||||||
],
|
"footer": {
|
||||||
"iconType": "fa-solid fa-location-dot",
|
"text": "Current student looking for academic advising?",
|
||||||
"iconSource": "fontawesome"
|
"linkText": "Visit Student Portal",
|
||||||
},
|
"linkHref": "https://portal.lams.ac"
|
||||||
{
|
}
|
||||||
"type": "email",
|
|
||||||
"title": "Email Address",
|
|
||||||
"content": [
|
|
||||||
"supportinfo@gmail.com",
|
|
||||||
"arluxhotelinfo.com"
|
|
||||||
],
|
|
||||||
"iconType": "fa-solid fa-envelope",
|
|
||||||
"iconSource": "fontawesome"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "phone",
|
|
||||||
"title": "Phone Number",
|
|
||||||
"content": [
|
|
||||||
"+880 123 427 00",
|
|
||||||
"+000 938 809 12"
|
|
||||||
],
|
|
||||||
"iconType": "fa-solid fa-phone",
|
|
||||||
"iconSource": "fontawesome"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"map": {
|
|
||||||
"coordinates": {
|
|
||||||
"lat": -37.81450084255415,
|
|
||||||
"lng": 144.9618311901502
|
|
||||||
},
|
|
||||||
"zoom": 15,
|
|
||||||
"location": "Envato, Melbourne, Australia",
|
|
||||||
"markerTitle": "Our Office",
|
|
||||||
"embedUrl": "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d6678.7619084840835!2d144.9618311901502!3d-37.81450084255415!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x6ad642b4758afc1d%3A0x3119cc820fdfc62e!2sEnvato!5e0!3m2!1sen!2sbd!4v1641984054261!5m2!1sen!2sbd",
|
|
||||||
"tileLayer": {
|
|
||||||
"url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
||||||
"attribution": "",
|
|
||||||
"maxZoom": 18,
|
|
||||||
"minZoom": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"form": {
|
|
||||||
"sectionLabel": "",
|
|
||||||
"heading": "Send Us Message",
|
|
||||||
"description": "Have questions about visas or immigration? Send us a message today and our expert team will respond quickly.",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "name",
|
|
||||||
"label": "Your Name",
|
|
||||||
"type": "text",
|
|
||||||
"placeholder": "Your name",
|
|
||||||
"required": true,
|
|
||||||
"colClass": "col-lg-4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "email",
|
|
||||||
"label": "Your Email",
|
|
||||||
"type": "email",
|
|
||||||
"placeholder": "Your email",
|
|
||||||
"required": true,
|
|
||||||
"colClass": "col-lg-4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "phone",
|
|
||||||
"label": "Your Phone",
|
|
||||||
"type": "tel",
|
|
||||||
"placeholder": "Phone Number",
|
|
||||||
"required": true,
|
|
||||||
"colClass": "col-lg-4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "address",
|
|
||||||
"label": "Your Address",
|
|
||||||
"type": "text",
|
|
||||||
"placeholder": "Address Now",
|
|
||||||
"required": false,
|
|
||||||
"colClass": "col-lg-6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "date",
|
|
||||||
"label": "Your Date",
|
|
||||||
"type": "date",
|
|
||||||
"placeholder": "Date",
|
|
||||||
"required": false,
|
|
||||||
"colClass": "col-lg-6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "message",
|
|
||||||
"label": "Your Message",
|
|
||||||
"type": "textarea",
|
|
||||||
"placeholder": "Type your message",
|
|
||||||
"required": false,
|
|
||||||
"colClass": "col-lg-12"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"submitButton": {
|
|
||||||
"text": "SEND MESSAGE",
|
|
||||||
"icon": "fa-solid fa-arrow-right",
|
|
||||||
"buttonClass": "theme-btn style-2"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"heading": "Send us a Message",
|
||||||
|
"description": "Fill out the form below and our team will get back to you shortly.",
|
||||||
|
"fields": {
|
||||||
|
"firstName": { "name": "first_name", "label": "First Name", "placeholder": "John", "required": true },
|
||||||
|
"lastName": { "name": "last_name", "label": "Last Name", "placeholder": "Doe", "required": true },
|
||||||
|
"email": { "name": "email", "label": "Email Address", "placeholder": "john.doe@example.com", "required": true },
|
||||||
|
"phone": { "name": "phone_number", "label": "Phone Number (Optional)", "placeholder": "(555) 123-4567", "required": false },
|
||||||
|
"inquiryType": {
|
||||||
|
"name": "inquiry_type",
|
||||||
|
"label": "How can we help you?",
|
||||||
|
"placeholder": "Select an inquiry type...",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{ "label": "Admissions & Enrollment", "value": "admissions" },
|
||||||
|
{ "label": "Financial Aid & Tuition", "value": "financial_aid" },
|
||||||
|
{ "label": "Program Information", "value": "program_info" },
|
||||||
|
{ "label": "Technical Support", "value": "tech_support" },
|
||||||
|
{ "label": "Other", "value": "other" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"message": { "name": "message_content", "label": "Message", "placeholder": "Please provide details about your inquiry...", "required": true }
|
||||||
|
},
|
||||||
|
"submitLabel": "Send Message",
|
||||||
|
"successMessage": "Your message has been sent successfully! Our team will contact you soon.",
|
||||||
|
"errorMessage": "Something went wrong. Please try again later or contact us directly."
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"title": "Frequently Asked Questions",
|
||||||
|
"subtitle": "Find quick answers to common questions about admissions, tuition, and online learning.",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"question": "Are LAMS degrees accredited?",
|
||||||
|
"answer": "Yes, LAMS is regionally accredited by the Higher Learning Commission. Our programs meet rigorous academic standards and our degrees are recognized by employers and other educational institutions worldwide."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "How does the monthly subscription model work?",
|
||||||
|
"answer": "Our subscription model allows you to pay a flat monthly fee to access your coursework. You can move through the material at your own pace, potentially completing more courses per term compared to traditional models."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Can I transfer credits from another institution?",
|
||||||
|
"answer": "Absolutely. We accept transfer credits from regionally or nationally accredited institutions. You can transfer up to 90 credits for a bachelor's degree program."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Are there set class times I need to attend?",
|
||||||
|
"answer": "No, our programs are 100% online and asynchronous. While there are deadlines, you do not need to log in at specific times for live lectures."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"title": "Ready to take the next step?",
|
||||||
|
"description": "Join thousands of students who have advanced their careers through our affordable, flexible online programs.",
|
||||||
|
"primaryButton": { "label": "Request Information", "href": "/request" },
|
||||||
|
"secondaryButton": { "label": "Apply Now", "href": "/apply" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"title": "Request Information | LAMS",
|
||||||
|
"description": "Start your journey with LAMS. Request more information about our affordable, flexible online degree programs.",
|
||||||
|
"keywords": "request university info, LAMS programs, admissions inquiry, online degree steps",
|
||||||
|
"ogImage": "/images/og-request.jpg"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"badge": "Your Future Starts Here",
|
||||||
|
"titleMain": "Take the Next Step in Your ",
|
||||||
|
"titleHighlight": "Career",
|
||||||
|
"description": "Join thousands of students achieving their goals through our flexible, affordable, and accredited online programs.",
|
||||||
|
"valueProps": [
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-piggy-bank",
|
||||||
|
"title": "Affordable Tuition",
|
||||||
|
"description": "Learn by monthly subscription or pay-per-course. We believe quality education should be accessible to everyone."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-clock",
|
||||||
|
"title": "Flexible Schedules",
|
||||||
|
"description": "100% online courses designed to fit your busy life. Study when and where it works best for you."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-certificate",
|
||||||
|
"title": "Accredited Programs",
|
||||||
|
"description": "Earn a recognized degree from an institution committed to academic excellence and industry relevance."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"heading": "Request Information",
|
||||||
|
"description": "Fill out the form below and an admissions advisor will contact you within 24 hours.",
|
||||||
|
"fields": {
|
||||||
|
"firstName": { "name": "first_name", "label": "First Name", "placeholder": "John", "required": true },
|
||||||
|
"lastName": { "name": "last_name", "label": "Last Name", "placeholder": "Doe", "required": true },
|
||||||
|
"email": { "name": "email_address", "label": "Email Address", "placeholder": "john.doe@example.com", "required": true },
|
||||||
|
"phone": { "name": "phone_number", "label": "Phone Number", "placeholder": "(555) 123-4567", "required": false },
|
||||||
|
"program": {
|
||||||
|
"name": "program_id",
|
||||||
|
"label": "Program of Interest",
|
||||||
|
"placeholder": "Select a program...",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{ "label": "B.S. Computer Science", "value": "bs-cs" },
|
||||||
|
{ "label": "B.S. Business Administration", "value": "bs-ba" },
|
||||||
|
{ "label": "B.A. Psychology", "value": "ba-psych" },
|
||||||
|
{ "label": "Master of Business Administration (MBA)", "value": "mba" },
|
||||||
|
{ "label": "M.S. Data Science", "value": "ms-ds" },
|
||||||
|
{ "label": "Digital Marketing Certificate", "value": "cert-dm" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"name": "start_timeline",
|
||||||
|
"label": "Start Timeline",
|
||||||
|
"options": [
|
||||||
|
{ "label": "Immediately", "value": "immediate" },
|
||||||
|
{ "label": "1-3 Months", "value": "1-3-months" },
|
||||||
|
{ "label": "3-6 Months", "value": "3-6-months" },
|
||||||
|
{ "label": "Undecided", "value": "undecided" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"consentText": "By submitting this form, I consent to receive emails, texts, and calls from LAMS regarding educational programs. I understand consent is not required to enroll.",
|
||||||
|
"submitLabel": "Get Information Now",
|
||||||
|
"footerText": "Your information is secure and encrypted.",
|
||||||
|
"successMessage": "Your request has been submitted successfully! An advisor will reach out to you within 24 hours.",
|
||||||
|
"errorMessage": "There was an error submitting your request. Please check your information and try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"title": "Student Support | LAMS",
|
||||||
|
"description": "Comprehensive student support at LAMS. Access academic advising, career services, and technical support to thrive in your online learning journey."
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"badge": "Student Success Center",
|
||||||
|
"title": "Comprehensive Student Support",
|
||||||
|
"description": "We are dedicated to your success. Access academic advising, tutoring, career services, and technical support designed to help you thrive in your online learning journey.",
|
||||||
|
"primaryButton": {
|
||||||
|
"label": "Explore Services",
|
||||||
|
"href": "#directory"
|
||||||
|
},
|
||||||
|
"secondaryButton": {
|
||||||
|
"label": "Get Help Now",
|
||||||
|
"href": "#contact-support"
|
||||||
|
},
|
||||||
|
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/5a10584b95-33a6a34a5a6353e69a2c.png",
|
||||||
|
"imageAlt": "Support advisor helping student online"
|
||||||
|
},
|
||||||
|
"directory": {
|
||||||
|
"title": "Support Services Directory",
|
||||||
|
"subtitle": "Everything you need to succeed academically, professionally, and personally while studying at LAMS.",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-compass",
|
||||||
|
"title": "Academic Advising",
|
||||||
|
"description": "Personalized guidance to help you select courses, stay on track for graduation, and navigate academic policies.",
|
||||||
|
"hours": "Mon-Fri: 8am - 8pm EST",
|
||||||
|
"hoursIcon": "fa-regular fa-clock",
|
||||||
|
"buttonLabel": "Schedule Appointment",
|
||||||
|
"buttonHref": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-language",
|
||||||
|
"title": "Language Enhancement",
|
||||||
|
"description": "Enhance your language skills through our Language system, supporting both general and specialised language learning tailored to your academic and professional needs.",
|
||||||
|
"hours": "24/7 Online Support",
|
||||||
|
"hoursIcon": "fa-regular fa-clock",
|
||||||
|
"buttonLabel": "Start Learning",
|
||||||
|
"buttonHref": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-briefcase",
|
||||||
|
"title": "Career Services",
|
||||||
|
"description": "Resume reviews, interview preparation, networking events, and access to our exclusive job board.",
|
||||||
|
"hours": "Mon-Fri: 9am - 6pm EST",
|
||||||
|
"hoursIcon": "fa-regular fa-clock",
|
||||||
|
"buttonLabel": "Access Career Hub",
|
||||||
|
"buttonHref": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-laptop-code",
|
||||||
|
"title": "Tech Support",
|
||||||
|
"description": "Assistance with learning management systems, student email, software access, and general technical troubleshooting.",
|
||||||
|
"hours": "24/7 Help Desk",
|
||||||
|
"hoursIcon": "fa-regular fa-clock",
|
||||||
|
"buttonLabel": "Submit IT Ticket",
|
||||||
|
"buttonHref": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-universal-access",
|
||||||
|
"title": "Accessibility Services",
|
||||||
|
"description": "Ensuring equal access to educational opportunities through accommodations, assistive tech, and advocacy.",
|
||||||
|
"hours": "Mon-Fri: 8am - 5pm EST",
|
||||||
|
"hoursIcon": "fa-regular fa-clock",
|
||||||
|
"buttonLabel": "Request Accommodations",
|
||||||
|
"buttonHref": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-users",
|
||||||
|
"title": "Community Resources",
|
||||||
|
"description": "Mental health resources, student clubs, diversity initiatives, and online community forums.",
|
||||||
|
"hours": "Virtual Campus",
|
||||||
|
"hoursIcon": "fa-solid fa-globe",
|
||||||
|
"buttonLabel": "Join the Community",
|
||||||
|
"buttonHref": "#"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contactSection": {
|
||||||
|
"heading": "Need Immediate Assistance?",
|
||||||
|
"description": "Our support team is ready to help you navigate any challenges. Reach out through your preferred channel.",
|
||||||
|
"channels": [
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-phone",
|
||||||
|
"title": "Call Us",
|
||||||
|
"detail": "123456789"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-envelope",
|
||||||
|
"title": "Email Support",
|
||||||
|
"detail": "info@lams.ac"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa-solid fa-comments",
|
||||||
|
"title": "Live Chat",
|
||||||
|
"detail": "Available 24/7 on Student Portal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"form": {
|
||||||
|
"heading": "Send a Message",
|
||||||
|
"fields": {
|
||||||
|
"firstName": {
|
||||||
|
"label": "First Name",
|
||||||
|
"placeholder": "John"
|
||||||
|
},
|
||||||
|
"lastName": {
|
||||||
|
"label": "Last Name",
|
||||||
|
"placeholder": "Doe"
|
||||||
|
},
|
||||||
|
"studentId": {
|
||||||
|
"label": "Student ID (Optional)",
|
||||||
|
"placeholder": "e.g. 12345678"
|
||||||
|
},
|
||||||
|
"department": {
|
||||||
|
"label": "Department"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"label": "Message",
|
||||||
|
"placeholder": "How can we help you?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"departments": [
|
||||||
|
"Academic Advising",
|
||||||
|
"Financial Aid",
|
||||||
|
"Tech Support",
|
||||||
|
"Other"
|
||||||
|
],
|
||||||
|
"submitLabel": "Submit Request",
|
||||||
|
"successMessage": "Your message has been sent successfully!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
-364
@@ -1,422 +1,189 @@
|
|||||||
const mongoose = require("mongoose");
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
// Schema cho hero section
|
// ─────────────────────────────────────────────
|
||||||
|
// Sub-schemas
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const metadataSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: { type: String, trim: true, default: "" },
|
||||||
|
description: { type: String, trim: true, default: "" },
|
||||||
|
keywords: { type: String, trim: true, default: "" },
|
||||||
|
ogImage: { type: String, trim: true, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
const heroSchema = new mongoose.Schema(
|
const heroSchema = new mongoose.Schema(
|
||||||
{
|
{
|
||||||
title: {
|
badge: { type: String, trim: true, default: "" },
|
||||||
type: String,
|
titleMain: { type: String, trim: true, default: "" },
|
||||||
required: true,
|
titleHighlight: { type: String, trim: true, default: "" },
|
||||||
trim: true,
|
description: { type: String, trim: true, default: "" },
|
||||||
},
|
|
||||||
backgroundImage: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
overlayColor: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "rgba(0, 0, 0, 0)",
|
|
||||||
},
|
|
||||||
sectionClass: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
titleClass: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
enableScrollspy: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
backgroundPosition: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "center",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schema cho contact card
|
const channelSchema = new mongoose.Schema(
|
||||||
const contactCardSchema = new mongoose.Schema(
|
|
||||||
{
|
{
|
||||||
type: {
|
icon: { type: String, trim: true, default: "" },
|
||||||
type: String,
|
title: { type: String, trim: true, default: "" },
|
||||||
required: true,
|
detail: { type: String, trim: true, default: "" },
|
||||||
trim: true,
|
subDetail: { type: String, trim: true, default: "" },
|
||||||
enum: [
|
|
||||||
"phone",
|
|
||||||
"email",
|
|
||||||
"location",
|
|
||||||
"hours",
|
|
||||||
"website",
|
|
||||||
"social",
|
|
||||||
"custom",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
trim: true,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
type: [String],
|
|
||||||
default: [],
|
|
||||||
},
|
|
||||||
iconType: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
iconSource: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
trim: true,
|
|
||||||
enum: ["fontawesome", "image"],
|
|
||||||
default: "fontawesome",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schema cho map coordinates
|
const supportHourSchema = new mongoose.Schema(
|
||||||
const coordinatesSchema = new mongoose.Schema(
|
|
||||||
{
|
{
|
||||||
lat: {
|
day: { type: String, trim: true, default: "" },
|
||||||
type: Number,
|
time: { type: String, trim: true, default: "" },
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
lng: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schema cho tile layer
|
const infoCardsSchema = new mongoose.Schema(
|
||||||
const tileLayerSchema = new mongoose.Schema(
|
|
||||||
{
|
{
|
||||||
url: {
|
contactInfo: {
|
||||||
type: String,
|
title: { type: String, trim: true, default: "" },
|
||||||
required: true,
|
channels: { type: [channelSchema], default: [] },
|
||||||
trim: true,
|
|
||||||
},
|
},
|
||||||
attribution: {
|
supportHours: {
|
||||||
type: String,
|
title: { type: String, trim: true, default: "" },
|
||||||
trim: true,
|
hours: { type: [supportHourSchema], default: [] },
|
||||||
default: "",
|
footer: {
|
||||||
},
|
text: { type: String, trim: true, default: "" },
|
||||||
maxZoom: {
|
linkText: { type: String, trim: true, default: "" },
|
||||||
type: Number,
|
linkHref: { type: String, trim: true, default: "" },
|
||||||
default: 18,
|
},
|
||||||
},
|
|
||||||
minZoom: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schema cho map
|
const formOptionSchema = new mongoose.Schema(
|
||||||
const mapSchema = new mongoose.Schema(
|
|
||||||
{
|
{
|
||||||
coordinates: {
|
label: { type: String, trim: true, default: "" },
|
||||||
type: coordinatesSchema,
|
value: { type: String, trim: true, default: "" },
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
zoom: {
|
|
||||||
type: Number,
|
|
||||||
default: 15,
|
|
||||||
},
|
|
||||||
location: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
trim: true,
|
|
||||||
},
|
|
||||||
markerTitle: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
embedUrl: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
tileLayer: {
|
|
||||||
type: tileLayerSchema,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schema cho form field
|
const simpleFieldSchema = new mongoose.Schema(
|
||||||
const formFieldSchema = new mongoose.Schema(
|
|
||||||
{
|
{
|
||||||
name: {
|
name: { type: String, trim: true, default: "" },
|
||||||
type: String,
|
label: { type: String, trim: true, default: "" },
|
||||||
required: true,
|
placeholder: { type: String, trim: true, default: "" },
|
||||||
trim: true,
|
required: { type: Boolean, default: false },
|
||||||
},
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
type: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
trim: true,
|
|
||||||
enum: ["text", "email", "tel", "textarea", "programme", "date"],
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
required: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
colClass: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "col-lg-12",
|
|
||||||
},
|
|
||||||
programmeName: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schema cho submit button
|
const inquiryFieldSchema = new mongoose.Schema(
|
||||||
const submitButtonSchema = new mongoose.Schema(
|
|
||||||
{
|
{
|
||||||
text: {
|
name: { type: String, trim: true, default: "" },
|
||||||
type: String,
|
label: { type: String, trim: true, default: "" },
|
||||||
required: true,
|
placeholder: { type: String, trim: true, default: "" },
|
||||||
trim: true,
|
required: { type: Boolean, default: true },
|
||||||
},
|
options: { type: [formOptionSchema], default: [] },
|
||||||
icon: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "fa-solid fa-arrow-right",
|
|
||||||
},
|
|
||||||
buttonClass: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "theme-btn style-2",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schema cho form
|
|
||||||
const formSchema = new mongoose.Schema(
|
const formSchema = new mongoose.Schema(
|
||||||
{
|
{
|
||||||
sectionLabel: {
|
heading: { type: String, trim: true, default: "" },
|
||||||
type: String,
|
description: { type: String, trim: true, default: "" },
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
heading: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
fields: {
|
fields: {
|
||||||
type: [formFieldSchema],
|
firstName: { type: simpleFieldSchema, default: () => ({}) },
|
||||||
default: [],
|
lastName: { type: simpleFieldSchema, default: () => ({}) },
|
||||||
},
|
email: { type: simpleFieldSchema, default: () => ({}) },
|
||||||
submitButton: {
|
phone: { type: simpleFieldSchema, default: () => ({}) },
|
||||||
type: submitButtonSchema,
|
inquiryType: { type: inquiryFieldSchema, default: () => ({}) },
|
||||||
required: true,
|
message: { type: simpleFieldSchema, default: () => ({}) },
|
||||||
},
|
},
|
||||||
|
submitLabel: { type: String, trim: true, default: "Send Message" },
|
||||||
|
successMessage: { type: String, trim: true, default: "" },
|
||||||
|
errorMessage: { type: String, trim: true, default: "" },
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Main Contact Schema
|
const faqItemSchema = new mongoose.Schema(
|
||||||
const contactSchema = new mongoose.Schema(
|
|
||||||
{
|
{
|
||||||
name: {
|
question: { type: String, trim: true, default: "" },
|
||||||
type: String,
|
answer: { type: String, trim: true, default: "" },
|
||||||
default: "default",
|
|
||||||
unique: true,
|
|
||||||
},
|
|
||||||
hero: {
|
|
||||||
type: heroSchema,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
contactCards: {
|
|
||||||
type: [contactCardSchema],
|
|
||||||
default: [],
|
|
||||||
},
|
|
||||||
map: {
|
|
||||||
type: mapSchema,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
type: formSchema,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{ _id: false }
|
||||||
timestamps: true,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mapping iconType cũ sang Font Awesome icon mới
|
const faqSchema = new mongoose.Schema(
|
||||||
const iconTypeMapping = {
|
{
|
||||||
phone: "fas fa-phone",
|
title: { type: String, trim: true, default: "" },
|
||||||
email: "fas fa-envelope",
|
subtitle: { type: String, trim: true, default: "" },
|
||||||
location: "fas fa-map-marker-alt",
|
items: { type: [faqItemSchema], default: [] },
|
||||||
clock: "fas fa-clock",
|
},
|
||||||
hours: "fas fa-clock",
|
{ _id: false }
|
||||||
};
|
);
|
||||||
|
|
||||||
// Tạo migration script để import dữ liệu từ contact-data.json
|
const ctaButtonSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
label: { type: String, trim: true, default: "" },
|
||||||
|
href: { type: String, trim: true, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const ctaSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: { type: String, trim: true, default: "" },
|
||||||
|
description: { type: String, trim: true, default: "" },
|
||||||
|
primaryButton: { type: ctaButtonSchema, default: () => ({}) },
|
||||||
|
secondaryButton: { type: ctaButtonSchema, default: () => ({}) },
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Main Contact Schema (slug-based, same pattern
|
||||||
|
// as StudentSupport / RequestInfo)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
const contactSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
slug: { type: String, default: "contact", unique: true },
|
||||||
|
metadata: { type: metadataSchema, default: () => ({}) },
|
||||||
|
hero: { type: heroSchema, default: () => ({}) },
|
||||||
|
infoCards: { type: infoCardsSchema, default: () => ({}) },
|
||||||
|
form: { type: formSchema, default: () => ({}) },
|
||||||
|
faq: { type: faqSchema, default: () => ({}) },
|
||||||
|
cta: { type: ctaSchema, default: () => ({}) },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Migration helper (idempotent upsert)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
contactSchema.statics.migrateFromJson = async function (jsonData) {
|
contactSchema.statics.migrateFromJson = async function (jsonData) {
|
||||||
try {
|
const payload = {
|
||||||
// Kiểm tra xem đã có contact mặc định chưa
|
metadata: jsonData.metadata || {},
|
||||||
const existingContact = await this.findOne({ name: "default" });
|
hero: jsonData.hero || {},
|
||||||
|
infoCards: jsonData.infoCards || {},
|
||||||
|
form: jsonData.form || {},
|
||||||
|
faq: jsonData.faq || {},
|
||||||
|
cta: jsonData.cta || {},
|
||||||
|
};
|
||||||
|
|
||||||
// Xử lý và chuẩn hóa dữ liệu từ JSON
|
const existing = await this.findOne({ slug: "contact" });
|
||||||
const processedData = {
|
if (existing) {
|
||||||
hero: {
|
existing.set(payload);
|
||||||
title: jsonData.hero?.title || "Contact Us",
|
await existing.save();
|
||||||
backgroundImage: jsonData.hero?.backgroundImage || "",
|
console.log("✅ Contact data updated via migration.");
|
||||||
overlayColor: jsonData.hero?.overlayColor || "rgba(0, 0, 0, 0)",
|
return existing;
|
||||||
sectionClass: jsonData.hero?.sectionClass || "",
|
} else {
|
||||||
titleClass: jsonData.hero?.titleClass || "",
|
const created = await this.create({ slug: "contact", ...payload });
|
||||||
enableScrollspy: jsonData.hero?.enableScrollspy || false,
|
console.log("✅ Contact data created via migration.");
|
||||||
backgroundPosition: jsonData.hero?.backgroundPosition || "center",
|
return created;
|
||||||
},
|
|
||||||
contactCards: (jsonData.contactCards || []).map((card) => {
|
|
||||||
let iconType = card.iconType || "";
|
|
||||||
let iconSource = card.iconSource;
|
|
||||||
|
|
||||||
// Nếu không có iconSource, tự động detect từ iconType
|
|
||||||
if (!iconSource) {
|
|
||||||
// Nếu iconType là image path (bắt đầu bằng /uploads/ hoặc http)
|
|
||||||
if (
|
|
||||||
iconType.startsWith("/uploads/") ||
|
|
||||||
iconType.startsWith("http://") ||
|
|
||||||
iconType.startsWith("https://")
|
|
||||||
) {
|
|
||||||
iconSource = "image";
|
|
||||||
} else {
|
|
||||||
// Nếu iconType là string cũ (phone, email, location, clock)
|
|
||||||
iconSource = "fontawesome";
|
|
||||||
// Map iconType cũ sang Font Awesome icon mới
|
|
||||||
if (iconTypeMapping[iconType]) {
|
|
||||||
iconType = iconTypeMapping[iconType];
|
|
||||||
} else if (
|
|
||||||
iconType &&
|
|
||||||
!iconType.startsWith("fas ") &&
|
|
||||||
!iconType.startsWith("fab ")
|
|
||||||
) {
|
|
||||||
// Nếu iconType không phải là Font Awesome class hợp lệ, thử map
|
|
||||||
iconType = iconTypeMapping[iconType] || iconType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Nếu đã có iconSource nhưng iconType là string cũ, map sang Font Awesome
|
|
||||||
if (
|
|
||||||
iconSource === "fontawesome" &&
|
|
||||||
iconType &&
|
|
||||||
!iconType.startsWith("fas ") &&
|
|
||||||
!iconType.startsWith("fab ") &&
|
|
||||||
iconTypeMapping[iconType]
|
|
||||||
) {
|
|
||||||
iconType = iconTypeMapping[iconType];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: card.type || "custom",
|
|
||||||
title: card.title || "",
|
|
||||||
content: Array.isArray(card.content) ? card.content : [],
|
|
||||||
iconType: iconType,
|
|
||||||
iconSource: iconSource || "fontawesome",
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
map: {
|
|
||||||
coordinates: {
|
|
||||||
lat: jsonData.map?.coordinates?.lat || 0,
|
|
||||||
lng: jsonData.map?.coordinates?.lng || 0,
|
|
||||||
},
|
|
||||||
zoom: jsonData.map?.zoom || 15,
|
|
||||||
location: jsonData.map?.location || "",
|
|
||||||
markerTitle: jsonData.map?.markerTitle || "",
|
|
||||||
embedUrl: jsonData.map?.embedUrl || "",
|
|
||||||
tileLayer: {
|
|
||||||
url:
|
|
||||||
jsonData.map?.tileLayer?.url ||
|
|
||||||
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
||||||
attribution: jsonData.map?.tileLayer?.attribution || "",
|
|
||||||
maxZoom: jsonData.map?.tileLayer?.maxZoom || 18,
|
|
||||||
minZoom: jsonData.map?.tileLayer?.minZoom || 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
sectionLabel: jsonData.form?.sectionLabel || "",
|
|
||||||
heading: jsonData.form?.heading || "",
|
|
||||||
description: jsonData.form?.description || "",
|
|
||||||
fields: (jsonData.form?.fields || []).map((field) => ({
|
|
||||||
name: field.name || "",
|
|
||||||
label: field.label || "",
|
|
||||||
type: field.type || "text",
|
|
||||||
placeholder: field.placeholder || "",
|
|
||||||
required: field.required || false,
|
|
||||||
colClass: field.colClass || "col-lg-12",
|
|
||||||
programmeName: field.programmeName || "",
|
|
||||||
})),
|
|
||||||
submitButton: {
|
|
||||||
text: jsonData.form?.submitButton?.text || "Send Message",
|
|
||||||
icon: jsonData.form?.submitButton?.icon || "fa-solid fa-arrow-right",
|
|
||||||
buttonClass: jsonData.form?.submitButton?.buttonClass || "theme-btn style-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existingContact) {
|
|
||||||
// Cập nhật contact hiện có với dữ liệu đã xử lý
|
|
||||||
existingContact.hero = processedData.hero;
|
|
||||||
existingContact.contactCards = processedData.contactCards;
|
|
||||||
existingContact.map = processedData.map;
|
|
||||||
existingContact.form = processedData.form;
|
|
||||||
await existingContact.save();
|
|
||||||
console.log("Contact data updated successfully");
|
|
||||||
return existingContact;
|
|
||||||
} else {
|
|
||||||
// Tạo contact mới với dữ liệu đã xử lý
|
|
||||||
const newContact = await this.create({
|
|
||||||
name: "default",
|
|
||||||
...processedData,
|
|
||||||
});
|
|
||||||
console.log("Contact data imported successfully");
|
|
||||||
return newContact;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error migrating contact data:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
// ── Sub-schemas ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const metadataSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
keywords: { type: String, default: "" },
|
||||||
|
ogImage: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const valuePropSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
icon: { type: String, default: "" },
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const heroSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
badge: { type: String, default: "" },
|
||||||
|
titleMain: { type: String, default: "" },
|
||||||
|
titleHighlight: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
valueProps: { type: [valuePropSchema], default: [] },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const formOptionSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
label: { type: String, default: "" },
|
||||||
|
value: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const formFieldSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: { type: String, default: "" },
|
||||||
|
label: { type: String, default: "" },
|
||||||
|
placeholder: { type: String, default: "" },
|
||||||
|
required: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const formSelectFieldSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: { type: String, default: "" },
|
||||||
|
label: { type: String, default: "" },
|
||||||
|
placeholder: { type: String, default: "" },
|
||||||
|
required: { type: Boolean, default: false },
|
||||||
|
options: { type: [formOptionSchema], default: [] },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const formTimelineFieldSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: { type: String, default: "" },
|
||||||
|
label: { type: String, default: "" },
|
||||||
|
options: { type: [formOptionSchema], default: [] },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const formFieldsSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
firstName: { type: formFieldSchema, default: () => ({}) },
|
||||||
|
lastName: { type: formFieldSchema, default: () => ({}) },
|
||||||
|
email: { type: formFieldSchema, default: () => ({}) },
|
||||||
|
phone: { type: formFieldSchema, default: () => ({}) },
|
||||||
|
program: { type: formSelectFieldSchema, default: () => ({}) },
|
||||||
|
timeline: { type: formTimelineFieldSchema, default: () => ({}) },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const formSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
heading: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
fields: { type: formFieldsSchema, default: () => ({}) },
|
||||||
|
consentText: { type: String, default: "" },
|
||||||
|
submitLabel: { type: String, default: "" },
|
||||||
|
footerText: { type: String, default: "" },
|
||||||
|
successMessage: { type: String, default: "" },
|
||||||
|
errorMessage: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Root schema ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const requestInfoSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
slug: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
default: "request-info",
|
||||||
|
},
|
||||||
|
metadata: { type: metadataSchema, default: () => ({}) },
|
||||||
|
hero: { type: heroSchema, default: () => ({}) },
|
||||||
|
form: { type: formSchema, default: () => ({}) },
|
||||||
|
},
|
||||||
|
{ timestamps: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed / migrate từ file JSON (cùng shape với API frontend).
|
||||||
|
* Idempotent — chạy lại không tạo trùng.
|
||||||
|
*/
|
||||||
|
requestInfoSchema.statics.migrateFromJson = async function (jsonData) {
|
||||||
|
const slug = "request-info";
|
||||||
|
const payload = {
|
||||||
|
slug,
|
||||||
|
metadata: jsonData.metadata || {},
|
||||||
|
hero: jsonData.hero || {},
|
||||||
|
form: jsonData.form || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = await this.findOne({ slug });
|
||||||
|
if (existing) {
|
||||||
|
existing.set(payload);
|
||||||
|
await existing.save();
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return this.create(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model("RequestInfo", requestInfoSchema);
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
const ctaButtonSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
label: { type: String, default: "" },
|
||||||
|
href: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const heroSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
badge: { type: String, default: "" },
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
primaryButton: { type: ctaButtonSchema, default: () => ({}) },
|
||||||
|
secondaryButton: { type: ctaButtonSchema, default: () => ({}) },
|
||||||
|
image: { type: String, default: "" },
|
||||||
|
imageAlt: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const directoryServiceSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
icon: { type: String, default: "" },
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
hours: { type: String, default: "" },
|
||||||
|
hoursIcon: { type: String, default: "" },
|
||||||
|
buttonLabel: { type: String, default: "" },
|
||||||
|
buttonHref: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const directorySchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
subtitle: { type: String, default: "" },
|
||||||
|
services: { type: [directoryServiceSchema], default: [] },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const channelSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
icon: { type: String, default: "" },
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
detail: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelPlaceholderSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
label: { type: String, default: "" },
|
||||||
|
placeholder: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const departmentFieldSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
label: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const supportFormFieldsSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
firstName: { type: labelPlaceholderSchema, default: () => ({}) },
|
||||||
|
lastName: { type: labelPlaceholderSchema, default: () => ({}) },
|
||||||
|
studentId: { type: labelPlaceholderSchema, default: () => ({}) },
|
||||||
|
department: { type: departmentFieldSchema, default: () => ({}) },
|
||||||
|
message: { type: labelPlaceholderSchema, default: () => ({}) },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const contactFormSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
heading: { type: String, default: "" },
|
||||||
|
fields: { type: supportFormFieldsSchema, default: () => ({}) },
|
||||||
|
departments: { type: [String], default: [] },
|
||||||
|
submitLabel: { type: String, default: "" },
|
||||||
|
successMessage: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const contactSectionSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
heading: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
channels: { type: [channelSchema], default: [] },
|
||||||
|
form: { type: contactFormSchema, default: () => ({}) },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadataSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const studentSupportSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
slug: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
default: "student-support",
|
||||||
|
},
|
||||||
|
metadata: { type: metadataSchema, default: () => ({}) },
|
||||||
|
hero: { type: heroSchema, default: () => ({}) },
|
||||||
|
directory: { type: directorySchema, default: () => ({}) },
|
||||||
|
contactSection: { type: contactSectionSchema, default: () => ({}) },
|
||||||
|
},
|
||||||
|
{ timestamps: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed / migrate từ file JSON (cùng shape với API frontend).
|
||||||
|
*/
|
||||||
|
studentSupportSchema.statics.migrateFromJson = async function (jsonData) {
|
||||||
|
const slug = "student-support";
|
||||||
|
const payload = {
|
||||||
|
slug,
|
||||||
|
metadata: jsonData.metadata || {},
|
||||||
|
hero: jsonData.hero || {},
|
||||||
|
directory: jsonData.directory || { services: [] },
|
||||||
|
contactSection: jsonData.contactSection || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = await this.findOne({ slug });
|
||||||
|
if (existing) {
|
||||||
|
existing.set(payload);
|
||||||
|
await existing.save();
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return this.create(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model("StudentSupport", studentSupportSchema);
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async function migrate() {
|
|||||||
console.log("✅ Home model registered successfully");
|
console.log("✅ Home model registered successfully");
|
||||||
|
|
||||||
// 3) Load JSON data
|
// 3) Load JSON data
|
||||||
const dataPath = path.join(__dirname, "..", "..", "hailearning.edu.vn", "app", "home.json");
|
const dataPath = path.join(__dirname, "..", "data", "home.json");
|
||||||
const raw = await fs.readFile(dataPath, "utf8");
|
const raw = await fs.readFile(dataPath, "utf8");
|
||||||
const homeData = JSON.parse(raw);
|
const homeData = JSON.parse(raw);
|
||||||
console.log("📖 Home data loaded from:", dataPath);
|
console.log("📖 Home data loaded from:", dataPath);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
const fs = require("fs").promises;
|
||||||
|
const path = require("path");
|
||||||
|
const connectDB = require("../config/database");
|
||||||
|
const StudentSupport = require("../models/studentSupport");
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: Student Support page content (slug student-support)
|
||||||
|
* Reads data/student-support.json and upserts MongoDB.
|
||||||
|
*/
|
||||||
|
async function migrate() {
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
const jsonPath = path.join(__dirname, "../data/student-support.json");
|
||||||
|
const raw = await fs.readFile(jsonPath, "utf8");
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
await StudentSupport.migrateFromJson(data);
|
||||||
|
console.log("Student Support migration completed successfully");
|
||||||
|
await mongoose.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Student Support migration error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { migrate };
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
const connectDB = require("../config/database");
|
||||||
|
const User = require("../models/User");
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: seed_users
|
||||||
|
* Description: Tạo sẵn tài khoản cứng cho login CMS
|
||||||
|
*/
|
||||||
|
async function migrate() {
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
|
||||||
|
const users = [
|
||||||
|
{
|
||||||
|
username: process.env.ADMIN_USERNAME || "admin",
|
||||||
|
email: "admin@cms.local",
|
||||||
|
name: "System Admin",
|
||||||
|
password: process.env.ADMIN_PASSWORD || "admin1234",
|
||||||
|
role: "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: "manager",
|
||||||
|
email: "manager@cms.local",
|
||||||
|
name: "Content Manager",
|
||||||
|
password: "manager1234",
|
||||||
|
role: "manager",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const item of users) {
|
||||||
|
const existing = await User.findOne({ username: item.username });
|
||||||
|
if (existing) {
|
||||||
|
console.log(`SKIP user exists: ${item.username}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await User.create(item);
|
||||||
|
console.log(`DONE created user: ${item.username}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("User seed migration completed.");
|
||||||
|
await mongoose.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("User seed migration failed:", error);
|
||||||
|
if (mongoose.connection.readyState === 1) {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { migrate };
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
const fs = require("fs").promises;
|
||||||
|
const path = require("path");
|
||||||
|
const connectDB = require("../config/database");
|
||||||
|
const RequestInfo = require("../models/requestInfo");
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: Request Info page content (slug: request-info)
|
||||||
|
* Reads data/request-info.json and upserts into MongoDB.
|
||||||
|
* Idempotent — safe to re-run.
|
||||||
|
*/
|
||||||
|
async function migrate() {
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
const jsonPath = path.join(__dirname, "../data/request-info.json");
|
||||||
|
const raw = await fs.readFile(jsonPath, "utf8");
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
await RequestInfo.migrateFromJson(data);
|
||||||
|
console.log("Request Info migration completed successfully");
|
||||||
|
await mongoose.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Request Info migration error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { migrate };
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
const fs = require("fs").promises;
|
||||||
|
const path = require("path");
|
||||||
|
const connectDB = require("../config/database");
|
||||||
|
const Contact = require("../models/contact");
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: Contact page content (slug: contact)
|
||||||
|
* Reads data/contact.json and upserts into MongoDB.
|
||||||
|
* Idempotent — safe to re-run.
|
||||||
|
*/
|
||||||
|
async function migrate() {
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
const jsonPath = path.join(__dirname, "../data/contact.json");
|
||||||
|
const raw = await fs.readFile(jsonPath, "utf8");
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
await Contact.migrateFromJson(data);
|
||||||
|
console.log("Contact migration completed successfully");
|
||||||
|
await mongoose.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Contact migration error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { migrate };
|
||||||
@@ -0,0 +1,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,
|
||||||
|
};
|
||||||
+589
-1639
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||||
|
|||||||
@@ -0,0 +1,481 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-4 mb-4 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0" style="color: var(--primary-dark);"><%= title %></h1>
|
||||||
|
<p class="text-muted mb-0">Edit the public Request Information page: hero, value props, and form configuration.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<% if (frontendUrl) { %>
|
||||||
|
<a href="<%= frontendUrl %>/request" class="btn btn-outline-primary" target="_blank" rel="noopener">
|
||||||
|
<i class="fas fa-external-link-alt me-2"></i>View Request Info Page
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" class="content-with-fixed-buttons" id="requestInfoForm" action="/admin/request-info/update">
|
||||||
|
<input type="hidden" name="metadata" id="metadataJson">
|
||||||
|
<input type="hidden" name="hero" id="heroJson">
|
||||||
|
<input type="hidden" name="form" id="formJson">
|
||||||
|
|
||||||
|
<div class="card shadow-sm border-0 mb-4">
|
||||||
|
<div class="card-header bg-white border-bottom">
|
||||||
|
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" data-bs-toggle="tab" href="#tab-meta" role="tab">
|
||||||
|
<i class="fas fa-tags me-2"></i>Metadata
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#tab-hero" role="tab">
|
||||||
|
<i class="fas fa-star me-2"></i>Hero
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#tab-form" role="tab">
|
||||||
|
<i class="fas fa-wpforms me-2"></i>Form Config
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content">
|
||||||
|
|
||||||
|
<%# ── TAB: Metadata ──────────────────────────────────────────── %>
|
||||||
|
<div class="tab-pane fade show active" id="tab-meta" role="tabpanel">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="metaTitle">Title (SEO)</label>
|
||||||
|
<input type="text" class="form-control" id="metaTitle" value="<%= data.metadata?.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="metaDescription">Description (SEO)</label>
|
||||||
|
<textarea class="form-control" id="metaDescription" rows="3"><%= data.metadata?.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="metaKeywords">Keywords (SEO)</label>
|
||||||
|
<input type="text" class="form-control" id="metaKeywords" value="<%= data.metadata?.keywords || '' %>" placeholder="keyword1, keyword2, ...">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="metaOgImage">OG Image path</label>
|
||||||
|
<input type="text" class="form-control" id="metaOgImage" value="<%= data.metadata?.ogImage || '' %>" placeholder="/images/og-request.jpg">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ── TAB: Hero ──────────────────────────────────────────────── %>
|
||||||
|
<div class="tab-pane fade" id="tab-hero" role="tabpanel">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium" for="heroBadge">Badge</label>
|
||||||
|
<input type="text" class="form-control" id="heroBadge" value="<%= data.hero?.badge || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium" for="heroTitleHighlight">Title — Highlight word</label>
|
||||||
|
<input type="text" class="form-control" id="heroTitleHighlight" value="<%= data.hero?.titleHighlight || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="heroTitleMain">Title — Main text (before highlight)</label>
|
||||||
|
<input type="text" class="form-control" id="heroTitleMain" value="<%= data.hero?.titleMain || '' %>" placeholder="Take the Next Step in Your ">
|
||||||
|
<small class="text-muted">The highlight word will appear after this text, rendered in accent colour on the frontend.</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="heroDescription">Description</label>
|
||||||
|
<textarea class="form-control" id="heroDescription" rows="3"><%= data.hero?.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Value Props %>
|
||||||
|
<hr class="my-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="mb-0 fw-semibold">Value Props</h6>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" id="addValuePropBtn">
|
||||||
|
<i class="fas fa-plus me-1"></i>Add value prop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="valuePropsContainer" class="vstack gap-3">
|
||||||
|
<% const vps = data.hero?.valueProps || []; %>
|
||||||
|
<% vps.forEach(function(vp, index) { %>
|
||||||
|
<div class="card vp-row border">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Icon</label>
|
||||||
|
<select class="form-select vp-icon-select">
|
||||||
|
<% iconOptions.forEach(function(opt) { %>
|
||||||
|
<option value="<%= opt.value %>" <%= (vp.icon === opt.value) ? 'selected' : '' %>><%= opt.label %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7">
|
||||||
|
<label class="form-label small">Title</label>
|
||||||
|
<input type="text" class="form-control vp-title" value="<%= vp.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 d-flex align-items-end justify-content-end">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm remove-vp"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label small">Description</label>
|
||||||
|
<textarea class="form-control vp-description" rows="2"><%= vp.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ── TAB: Form Config ───────────────────────────────────────── %>
|
||||||
|
<div class="tab-pane fade" id="tab-form" role="tabpanel">
|
||||||
|
<% const fm = data.form || {}; const fld = fm.fields || {}; %>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="formHeading">Form Heading</label>
|
||||||
|
<input type="text" class="form-control" id="formHeading" value="<%= fm.heading || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="formDescription">Form Description</label>
|
||||||
|
<textarea class="form-control" id="formDescription" rows="2"><%= fm.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted small mb-3">Fields</h6>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">First Name — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_firstName_label" value="<%= fld.firstName?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">First Name — placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_firstName_ph" value="<%= fld.firstName?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Last Name — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_lastName_label" value="<%= fld.lastName?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Last Name — placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_lastName_ph" value="<%= fld.lastName?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Email — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_email_label" value="<%= fld.email?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Email — placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_email_ph" value="<%= fld.email?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Phone — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_phone_label" value="<%= fld.phone?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Phone — placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_phone_ph" value="<%= fld.phone?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted small mb-3">Program of Interest (dropdown options)</h6>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Field label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_program_label" value="<%= fld.program?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_program_ph" value="<%= fld.program?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="small fw-medium">Program options</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" id="addProgramBtn">
|
||||||
|
<i class="fas fa-plus me-1"></i>Add option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="programOptionsContainer" class="vstack gap-2 mb-4">
|
||||||
|
<% (fld.program?.options || []).forEach(function(opt) { %>
|
||||||
|
<div class="input-group program-opt-row">
|
||||||
|
<input type="text" class="form-control prog-label" placeholder="Label" value="<%= opt.label || '' %>">
|
||||||
|
<input type="text" class="form-control prog-value" placeholder="Value (e.g. bs-cs)" value="<%= opt.value || '' %>">
|
||||||
|
<button type="button" class="btn btn-outline-danger remove-prog-opt"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted small mb-3">Start Timeline (dropdown options)</h6>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label small">Field label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_timeline_label" value="<%= fld.timeline?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="small fw-medium">Timeline options</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" id="addTimelineBtn">
|
||||||
|
<i class="fas fa-plus me-1"></i>Add option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="timelineOptionsContainer" class="vstack gap-2 mb-4">
|
||||||
|
<% (fld.timeline?.options || []).forEach(function(opt) { %>
|
||||||
|
<div class="input-group timeline-opt-row">
|
||||||
|
<input type="text" class="form-control tl-label" placeholder="Label" value="<%= opt.label || '' %>">
|
||||||
|
<input type="text" class="form-control tl-value" placeholder="Value (e.g. immediate)" value="<%= opt.value || '' %>">
|
||||||
|
<button type="button" class="btn btn-outline-danger remove-tl-opt"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted small mb-3">Form texts</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label small">Consent text</label>
|
||||||
|
<textarea class="form-control" id="formConsentText" rows="3"><%= fm.consentText || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Submit label</label>
|
||||||
|
<input type="text" class="form-control" id="formSubmitLabel" value="<%= fm.submitLabel || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Footer text (security note)</label>
|
||||||
|
<input type="text" class="form-control" id="formFooterText" value="<%= fm.footerText || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Success message</label>
|
||||||
|
<input type="text" class="form-control" id="formSuccessMsg" value="<%= fm.successMessage || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Error message</label>
|
||||||
|
<input type="text" class="form-control" id="formErrorMsg" value="<%= fm.errorMessage || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fixed-bottom-buttons">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="resetRequestInfoForm()">
|
||||||
|
<i class="fas fa-undo me-2"></i>Reset
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||||
|
<i class="fas fa-save me-2"></i>Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function resetRequestInfoForm() {
|
||||||
|
if (confirm('Are you sure you want to reset all changes? Unsaved edits will be lost.')) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.REQUEST_INFO_ICON_OPTIONS = <%- JSON.stringify(iconOptions) %>;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconOptionsHtml(selected) {
|
||||||
|
var opts = window.REQUEST_INFO_ICON_OPTIONS || [];
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < opts.length; i++) {
|
||||||
|
var o = opts[i];
|
||||||
|
var sel = (o.value === selected) ? ' selected' : '';
|
||||||
|
html += '<option value="' + escapeHtml(o.value) + '"' + sel + '>' + escapeHtml(o.label) + '</option>';
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildValuePropRow(vp) {
|
||||||
|
vp = vp || {};
|
||||||
|
var iconSel = '<select class="form-select vp-icon-select">' + iconOptionsHtml(vp.icon) + '</select>';
|
||||||
|
return (
|
||||||
|
'<div class="card vp-row border"><div class="card-body">' +
|
||||||
|
'<div class="row g-2 align-items-end">' +
|
||||||
|
'<div class="col-md-4"><label class="form-label small">Icon</label>' + iconSel + '</div>' +
|
||||||
|
'<div class="col-md-7"><label class="form-label small">Title</label>' +
|
||||||
|
'<input type="text" class="form-control vp-title" value="' + escapeHtml(vp.title) + '"></div>' +
|
||||||
|
'<div class="col-md-1 d-flex align-items-end justify-content-end">' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger btn-sm remove-vp"><i class="fas fa-trash"></i></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="col-md-12"><label class="form-label small">Description</label>' +
|
||||||
|
'<textarea class="form-control vp-description" rows="2">' + escapeHtml(vp.description) + '</textarea></div>' +
|
||||||
|
'</div></div></div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProgramOptRow(opt) {
|
||||||
|
opt = opt || {};
|
||||||
|
return (
|
||||||
|
'<div class="input-group program-opt-row">' +
|
||||||
|
'<input type="text" class="form-control prog-label" placeholder="Label" value="' + escapeHtml(opt.label) + '">' +
|
||||||
|
'<input type="text" class="form-control prog-value" placeholder="Value (e.g. bs-cs)" value="' + escapeHtml(opt.value) + '">' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger remove-prog-opt"><i class="fas fa-times"></i></button>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTimelineOptRow(opt) {
|
||||||
|
opt = opt || {};
|
||||||
|
return (
|
||||||
|
'<div class="input-group timeline-opt-row">' +
|
||||||
|
'<input type="text" class="form-control tl-label" placeholder="Label" value="' + escapeHtml(opt.label) + '">' +
|
||||||
|
'<input type="text" class="form-control tl-value" placeholder="Value (e.g. immediate)" value="' + escapeHtml(opt.value) + '">' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger remove-tl-opt"><i class="fas fa-times"></i></button>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHiddenJson() {
|
||||||
|
// Metadata
|
||||||
|
document.getElementById('metadataJson').value = JSON.stringify({
|
||||||
|
title: document.getElementById('metaTitle').value.trim(),
|
||||||
|
description: document.getElementById('metaDescription').value.trim(),
|
||||||
|
keywords: document.getElementById('metaKeywords').value.trim(),
|
||||||
|
ogImage: document.getElementById('metaOgImage').value.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ValueProps
|
||||||
|
var valueProps = [];
|
||||||
|
document.querySelectorAll('#valuePropsContainer .vp-row').forEach(function(row) {
|
||||||
|
valueProps.push({
|
||||||
|
icon: row.querySelector('.vp-icon-select').value,
|
||||||
|
title: row.querySelector('.vp-title').value.trim(),
|
||||||
|
description: row.querySelector('.vp-description').value.trim(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hero
|
||||||
|
document.getElementById('heroJson').value = JSON.stringify({
|
||||||
|
badge: document.getElementById('heroBadge').value.trim(),
|
||||||
|
titleMain: document.getElementById('heroTitleMain').value.trim(),
|
||||||
|
titleHighlight: document.getElementById('heroTitleHighlight').value.trim(),
|
||||||
|
description: document.getElementById('heroDescription').value.trim(),
|
||||||
|
valueProps: valueProps,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Program options
|
||||||
|
var programOptions = [];
|
||||||
|
document.querySelectorAll('#programOptionsContainer .program-opt-row').forEach(function(row) {
|
||||||
|
var label = row.querySelector('.prog-label').value.trim();
|
||||||
|
var value = row.querySelector('.prog-value').value.trim();
|
||||||
|
if (label || value) programOptions.push({ label: label, value: value });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeline options
|
||||||
|
var timelineOptions = [];
|
||||||
|
document.querySelectorAll('#timelineOptionsContainer .timeline-opt-row').forEach(function(row) {
|
||||||
|
var label = row.querySelector('.tl-label').value.trim();
|
||||||
|
var value = row.querySelector('.tl-value').value.trim();
|
||||||
|
if (label || value) timelineOptions.push({ label: label, value: value });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form
|
||||||
|
document.getElementById('formJson').value = JSON.stringify({
|
||||||
|
heading: document.getElementById('formHeading').value.trim(),
|
||||||
|
description: document.getElementById('formDescription').value.trim(),
|
||||||
|
fields: {
|
||||||
|
firstName: {
|
||||||
|
name: 'first_name',
|
||||||
|
label: document.getElementById('fld_firstName_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_firstName_ph').value.trim(),
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
lastName: {
|
||||||
|
name: 'last_name',
|
||||||
|
label: document.getElementById('fld_lastName_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_lastName_ph').value.trim(),
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
name: 'email_address',
|
||||||
|
label: document.getElementById('fld_email_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_email_ph').value.trim(),
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
name: 'phone_number',
|
||||||
|
label: document.getElementById('fld_phone_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_phone_ph').value.trim(),
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
program: {
|
||||||
|
name: 'program_id',
|
||||||
|
label: document.getElementById('fld_program_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_program_ph').value.trim(),
|
||||||
|
required: true,
|
||||||
|
options: programOptions,
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
name: 'start_timeline',
|
||||||
|
label: document.getElementById('fld_timeline_label').value.trim(),
|
||||||
|
options: timelineOptions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
consentText: document.getElementById('formConsentText').value.trim(),
|
||||||
|
submitLabel: document.getElementById('formSubmitLabel').value.trim(),
|
||||||
|
footerText: document.getElementById('formFooterText').value.trim(),
|
||||||
|
successMessage: document.getElementById('formSuccessMsg').value.trim(),
|
||||||
|
errorMessage: document.getElementById('formErrorMsg').value.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var form = document.getElementById('requestInfoForm');
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var btn = document.getElementById('submitBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving...';
|
||||||
|
try {
|
||||||
|
syncHiddenJson();
|
||||||
|
form.submit();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Could not prepare form data. Please try again.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Value props
|
||||||
|
document.getElementById('addValuePropBtn').addEventListener('click', function () {
|
||||||
|
document.getElementById('valuePropsContainer').insertAdjacentHTML('beforeend',
|
||||||
|
buildValuePropRow({ icon: 'fa-solid fa-circle-info' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
document.getElementById('valuePropsContainer').addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.remove-vp')) {
|
||||||
|
e.target.closest('.vp-row').remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Program options
|
||||||
|
document.getElementById('addProgramBtn').addEventListener('click', function () {
|
||||||
|
document.getElementById('programOptionsContainer').insertAdjacentHTML('beforeend', buildProgramOptRow());
|
||||||
|
});
|
||||||
|
document.getElementById('programOptionsContainer').addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.remove-prog-opt')) {
|
||||||
|
e.target.closest('.program-opt-row').remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeline options
|
||||||
|
document.getElementById('addTimelineBtn').addEventListener('click', function () {
|
||||||
|
document.getElementById('timelineOptionsContainer').insertAdjacentHTML('beforeend', buildTimelineOptRow());
|
||||||
|
});
|
||||||
|
document.getElementById('timelineOptionsContainer').addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.remove-tl-opt')) {
|
||||||
|
e.target.closest('.timeline-opt-row').remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-4 mb-4 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0" style="color: var(--primary-dark);"><%= title %></h1>
|
||||||
|
<p class="text-muted mb-0">Edit the public Student Support page: hero, services directory, contact channels, and form labels.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<% if (frontendUrl) { %>
|
||||||
|
<a href="<%= frontendUrl %>/student-support" class="btn btn-outline-primary" target="_blank" rel="noopener">
|
||||||
|
<i class="fas fa-external-link-alt me-2"></i>View Student Support Page
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" class="content-with-fixed-buttons" id="studentSupportForm" action="/admin/student-support/update">
|
||||||
|
<input type="hidden" name="metadata" id="metadataJson">
|
||||||
|
<input type="hidden" name="hero" id="heroJson">
|
||||||
|
<input type="hidden" name="directory" id="directoryJson">
|
||||||
|
<input type="hidden" name="contactSection" id="contactSectionJson">
|
||||||
|
|
||||||
|
<div class="card shadow-sm border-0 mb-4">
|
||||||
|
<div class="card-header bg-white border-bottom">
|
||||||
|
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" data-bs-toggle="tab" href="#tab-meta" role="tab">
|
||||||
|
<i class="fas fa-tags me-2"></i>Metadata
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#tab-hero" role="tab">
|
||||||
|
<i class="fas fa-image me-2"></i>Hero
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#tab-directory" role="tab">
|
||||||
|
<i class="fas fa-list me-2"></i>Directory
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#tab-contact" role="tab">
|
||||||
|
<i class="fas fa-headset me-2"></i>Contact & form
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="tab-meta" role="tabpanel">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="metaTitle">Title (SEO)</label>
|
||||||
|
<input type="text" class="form-control" id="metaTitle" value="<%= data.metadata?.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="metaDescription">Description (SEO)</label>
|
||||||
|
<textarea class="form-control" id="metaDescription" rows="3"><%= data.metadata?.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="tab-hero" role="tabpanel">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium" for="heroBadge">Badge</label>
|
||||||
|
<input type="text" class="form-control" id="heroBadge" value="<%= data.hero?.badge || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="heroTitle">Title</label>
|
||||||
|
<input type="text" class="form-control" id="heroTitle" value="<%= data.hero?.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="heroDescription">Description</label>
|
||||||
|
<textarea class="form-control" id="heroDescription" rows="4"><%= data.hero?.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Primary button</label>
|
||||||
|
<input type="text" class="form-control mb-2" id="heroPrimaryLabel" placeholder="Label" value="<%= data.hero?.primaryButton?.label || '' %>">
|
||||||
|
<input type="text" class="form-control" id="heroPrimaryHref" placeholder="Href" value="<%= data.hero?.primaryButton?.href || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Secondary button</label>
|
||||||
|
<input type="text" class="form-control mb-2" id="heroSecondaryLabel" placeholder="Label" value="<%= data.hero?.secondaryButton?.label || '' %>">
|
||||||
|
<input type="text" class="form-control" id="heroSecondaryHref" placeholder="Href" value="<%= data.hero?.secondaryButton?.href || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium" for="heroImage">Hero image</label>
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<input type="text" class="form-control" id="heroImage" value="<%= data.hero?.image || '' %>">
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-upload-image" data-target-input="heroImage" data-image-type="student-support">
|
||||||
|
<i class="fas fa-upload me-1"></i>Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Full URL or relative path such as <code>/uploads/...</code> after upload.</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium" for="heroImageAlt">Image alt</label>
|
||||||
|
<input type="text" class="form-control" id="heroImageAlt" value="<%= data.hero?.imageAlt || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label text-muted small">Preview</label>
|
||||||
|
<div id="heroImagePreview" class="border rounded overflow-hidden bg-light" style="height: 380px; width: 100%;">
|
||||||
|
<% const hi = data.hero?.image; if (hi) { %>
|
||||||
|
<% let src = hi; if (!src.startsWith('http')) { src = src.startsWith('/') ? src : '/' + src; } %>
|
||||||
|
<img src="<%= src %>" alt="" class="w-100 h-100" style="object-fit: cover; object-position: center;" id="heroPreviewImg"
|
||||||
|
onerror="this.style.display='none'">
|
||||||
|
<% } else { %>
|
||||||
|
<div class="h-100 d-flex align-items-center justify-content-center text-muted">
|
||||||
|
<div><i class="fas fa-image fa-2x mb-2"></i><br>No image</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="tab-directory" role="tabpanel">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="dirTitle">Section title</label>
|
||||||
|
<input type="text" class="form-control" id="dirTitle" value="<%= data.directory?.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium" for="dirSubtitle">Subtitle</label>
|
||||||
|
<textarea class="form-control" id="dirSubtitle" rows="2"><%= data.directory?.subtitle || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="mb-0">Services</h6>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" id="addServiceBtn"><i class="fas fa-plus me-1"></i>Add service</button>
|
||||||
|
</div>
|
||||||
|
<div id="servicesContainer" class="vstack gap-3">
|
||||||
|
<% const services = data.directory?.services || []; %>
|
||||||
|
<% services.forEach(function(svc, index) { %>
|
||||||
|
<div class="card service-row border">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Icon</label>
|
||||||
|
<select class="form-select svc-icon-select">
|
||||||
|
<% iconOptions.forEach(function(opt) { %>
|
||||||
|
<option value="<%= opt.value %>" <%= (svc.icon === opt.value) ? 'selected' : '' %>><%= opt.label %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Hours icon</label>
|
||||||
|
<select class="form-select svc-hours-icon-select">
|
||||||
|
<% iconOptions.forEach(function(opt) { %>
|
||||||
|
<option value="<%= opt.value %>" <%= (svc.hoursIcon === opt.value) ? 'selected' : '' %>><%= opt.label %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 d-flex align-items-end justify-content-end">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm remove-service"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label small">Title</label>
|
||||||
|
<input type="text" class="form-control svc-title" value="<%= svc.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label small">Description</label>
|
||||||
|
<textarea class="form-control svc-description" rows="2"><%= svc.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Hours text</label>
|
||||||
|
<input type="text" class="form-control svc-hours" value="<%= svc.hours || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">Button label</label>
|
||||||
|
<input type="text" class="form-control svc-btn-label" value="<%= svc.buttonLabel || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">Button href</label>
|
||||||
|
<input type="text" class="form-control svc-btn-href" value="<%= svc.buttonHref || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="tab-contact" role="tabpanel">
|
||||||
|
<% const cs = data.contactSection || {}; const fm = cs.form || {}; const fld = fm.fields || {}; %>
|
||||||
|
<h6 class="text-uppercase text-muted small">Contact channels</h6>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label" for="csHeading">Heading</label>
|
||||||
|
<input type="text" class="form-control" id="csHeading" value="<%= cs.heading || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label" for="csDescription">Description</label>
|
||||||
|
<textarea class="form-control" id="csDescription" rows="2"><%= cs.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="fw-medium">Channels</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" id="addChannelBtn"><i class="fas fa-plus me-1"></i>Add channel</button>
|
||||||
|
</div>
|
||||||
|
<div id="channelsContainer" class="vstack gap-2 mb-4">
|
||||||
|
<% (cs.channels || []).forEach(function(ch) { %>
|
||||||
|
<div class="card channel-row border">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">Icon</label>
|
||||||
|
<select class="form-select ch-icon-select">
|
||||||
|
<% iconOptions.forEach(function(opt) { %>
|
||||||
|
<option value="<%= opt.value %>" <%= (ch.icon === opt.value) ? 'selected' : '' %>><%= opt.label %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">Title</label>
|
||||||
|
<input type="text" class="form-control ch-title" value="<%= ch.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label small">Detail</label>
|
||||||
|
<input type="text" class="form-control ch-detail" value="<%= ch.detail || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 text-end">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm remove-channel"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted small">Form (display labels)</h6>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label" for="formHeading">Form heading</label>
|
||||||
|
<input type="text" class="form-control" id="formHeading" value="<%= fm.heading || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">First name — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_firstName_label" value="<%= fld.firstName?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">First name — placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_firstName_ph" value="<%= fld.firstName?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Last name — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_lastName_label" value="<%= fld.lastName?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Last name — placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_lastName_ph" value="<%= fld.lastName?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Student ID — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_studentId_label" value="<%= fld.studentId?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Student ID — placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_studentId_ph" value="<%= fld.studentId?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Department — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_department_label" value="<%= fld.department?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Message — label</label>
|
||||||
|
<input type="text" class="form-control" id="fld_message_label" value="<%= fld.message?.label || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Message — placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="fld_message_ph" value="<%= fld.message?.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Submit label</label>
|
||||||
|
<input type="text" class="form-control" id="formSubmitLabel" value="<%= fm.submitLabel || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label small">Success message</label>
|
||||||
|
<input type="text" class="form-control" id="formSuccessMsg" value="<%= fm.successMessage || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="fw-medium">Departments (dropdown)</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" id="addDeptBtn"><i class="fas fa-plus me-1"></i>Add row</button>
|
||||||
|
</div>
|
||||||
|
<div id="departmentsContainer" class="vstack gap-2">
|
||||||
|
<% (fm.departments || []).forEach(function(dep) { %>
|
||||||
|
<div class="input-group dept-row">
|
||||||
|
<input type="text" class="form-control dept-input" value="<%= dep %>">
|
||||||
|
<button type="button" class="btn btn-outline-danger remove-dept"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fixed-bottom-buttons">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="resetStudentSupportForm()">
|
||||||
|
<i class="fas fa-undo me-2"></i>Reset
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||||
|
<i class="fas fa-save me-2"></i>Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function resetStudentSupportForm() {
|
||||||
|
if (confirm('Are you sure you want to reset all changes? Unsaved edits will be lost.')) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.STUDENT_SUPPORT_ICON_OPTIONS = <%- JSON.stringify(iconOptions) %>;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconOptionsHtml(selected) {
|
||||||
|
var opts = window.STUDENT_SUPPORT_ICON_OPTIONS || [];
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < opts.length; i++) {
|
||||||
|
var o = opts[i];
|
||||||
|
var sel = (o.value === selected) ? ' selected' : '';
|
||||||
|
html += '<option value="' + escapeHtml(o.value) + '"' + sel + '>' + escapeHtml(o.label) + '</option>';
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildServiceRow(svc) {
|
||||||
|
svc = svc || {};
|
||||||
|
var iconSel = '<select class="form-select svc-icon-select">' + iconOptionsHtml(svc.icon) + '</select>';
|
||||||
|
var hoursIconSel = '<select class="form-select svc-hours-icon-select">' + iconOptionsHtml(svc.hoursIcon) + '</select>';
|
||||||
|
return (
|
||||||
|
'<div class="card service-row border">' +
|
||||||
|
'<div class="card-body">' +
|
||||||
|
'<div class="row g-2">' +
|
||||||
|
'<div class="col-md-4"><label class="form-label small">Icon</label>' + iconSel + '</div>' +
|
||||||
|
'<div class="col-md-4"><label class="form-label small">Hours icon</label>' + hoursIconSel + '</div>' +
|
||||||
|
'<div class="col-md-4 d-flex align-items-end justify-content-end">' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger btn-sm remove-service"><i class="fas fa-trash"></i></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="col-md-12"><label class="form-label small">Title</label>' +
|
||||||
|
'<input type="text" class="form-control svc-title" value="' + escapeHtml(svc.title) + '"></div>' +
|
||||||
|
'<div class="col-md-12"><label class="form-label small">Description</label>' +
|
||||||
|
'<textarea class="form-control svc-description" rows="2">' + escapeHtml(svc.description) + '</textarea></div>' +
|
||||||
|
'<div class="col-md-6"><label class="form-label small">Hours text</label>' +
|
||||||
|
'<input type="text" class="form-control svc-hours" value="' + escapeHtml(svc.hours) + '"></div>' +
|
||||||
|
'<div class="col-md-3"><label class="form-label small">Button label</label>' +
|
||||||
|
'<input type="text" class="form-control svc-btn-label" value="' + escapeHtml(svc.buttonLabel) + '"></div>' +
|
||||||
|
'<div class="col-md-3"><label class="form-label small">Button href</label>' +
|
||||||
|
'<input type="text" class="form-control svc-btn-href" value="' + escapeHtml(svc.buttonHref) + '"></div>' +
|
||||||
|
'</div></div></div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChannelRow(ch) {
|
||||||
|
ch = ch || {};
|
||||||
|
var iconSel = '<select class="form-select ch-icon-select">' + iconOptionsHtml(ch.icon) + '</select>';
|
||||||
|
return (
|
||||||
|
'<div class="card channel-row border"><div class="card-body py-2">' +
|
||||||
|
'<div class="row g-2 align-items-end">' +
|
||||||
|
'<div class="col-md-3"><label class="form-label small">Icon</label>' + iconSel + '</div>' +
|
||||||
|
'<div class="col-md-3"><label class="form-label small">Title</label>' +
|
||||||
|
'<input type="text" class="form-control ch-title" value="' + escapeHtml(ch.title) + '"></div>' +
|
||||||
|
'<div class="col-md-5"><label class="form-label small">Detail</label>' +
|
||||||
|
'<input type="text" class="form-control ch-detail" value="' + escapeHtml(ch.detail) + '"></div>' +
|
||||||
|
'<div class="col-md-1 text-end">' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger btn-sm remove-channel"><i class="fas fa-trash"></i></button>' +
|
||||||
|
'</div></div></div></div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeroPreview(url) {
|
||||||
|
var el = document.getElementById('heroImagePreview');
|
||||||
|
if (!el) return;
|
||||||
|
if (!url) {
|
||||||
|
el.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-muted"><div><i class="fas fa-image fa-2x mb-2"></i><br>No image</div></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.classList.remove('d-flex', 'align-items-center', 'justify-content-center');
|
||||||
|
el.style.height = '380px';
|
||||||
|
el.style.width = '100%';
|
||||||
|
var src = url;
|
||||||
|
if (!src.startsWith('http://') && !src.startsWith('https://')) {
|
||||||
|
src = src.startsWith('/') ? src : '/' + src;
|
||||||
|
}
|
||||||
|
el.innerHTML = '<img src="' + escapeHtml(src) + '" alt="" class="w-100 h-100" style="object-fit: cover; object-position: center;" id="heroPreviewImg">';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHiddenJson() {
|
||||||
|
document.getElementById('metadataJson').value = JSON.stringify({
|
||||||
|
title: document.getElementById('metaTitle').value.trim(),
|
||||||
|
description: document.getElementById('metaDescription').value.trim(),
|
||||||
|
});
|
||||||
|
document.getElementById('heroJson').value = JSON.stringify({
|
||||||
|
badge: document.getElementById('heroBadge').value.trim(),
|
||||||
|
title: document.getElementById('heroTitle').value.trim(),
|
||||||
|
description: document.getElementById('heroDescription').value.trim(),
|
||||||
|
primaryButton: {
|
||||||
|
label: document.getElementById('heroPrimaryLabel').value.trim(),
|
||||||
|
href: document.getElementById('heroPrimaryHref').value.trim(),
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
label: document.getElementById('heroSecondaryLabel').value.trim(),
|
||||||
|
href: document.getElementById('heroSecondaryHref').value.trim(),
|
||||||
|
},
|
||||||
|
image: document.getElementById('heroImage').value.trim(),
|
||||||
|
imageAlt: document.getElementById('heroImageAlt').value.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
var services = [];
|
||||||
|
document.querySelectorAll('#servicesContainer .service-row').forEach(function(row) {
|
||||||
|
services.push({
|
||||||
|
icon: row.querySelector('.svc-icon-select').value,
|
||||||
|
title: row.querySelector('.svc-title').value.trim(),
|
||||||
|
description: row.querySelector('.svc-description').value.trim(),
|
||||||
|
hours: row.querySelector('.svc-hours').value.trim(),
|
||||||
|
hoursIcon: row.querySelector('.svc-hours-icon-select').value,
|
||||||
|
buttonLabel: row.querySelector('.svc-btn-label').value.trim(),
|
||||||
|
buttonHref: row.querySelector('.svc-btn-href').value.trim(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('directoryJson').value = JSON.stringify({
|
||||||
|
title: document.getElementById('dirTitle').value.trim(),
|
||||||
|
subtitle: document.getElementById('dirSubtitle').value.trim(),
|
||||||
|
services: services,
|
||||||
|
});
|
||||||
|
|
||||||
|
var channels = [];
|
||||||
|
document.querySelectorAll('#channelsContainer .channel-row').forEach(function(row) {
|
||||||
|
channels.push({
|
||||||
|
icon: row.querySelector('.ch-icon-select').value,
|
||||||
|
title: row.querySelector('.ch-title').value.trim(),
|
||||||
|
detail: row.querySelector('.ch-detail').value.trim(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
var departments = [];
|
||||||
|
document.querySelectorAll('#departmentsContainer .dept-input').forEach(function(inp) {
|
||||||
|
var v = inp.value.trim();
|
||||||
|
if (v) departments.push(v);
|
||||||
|
});
|
||||||
|
document.getElementById('contactSectionJson').value = JSON.stringify({
|
||||||
|
heading: document.getElementById('csHeading').value.trim(),
|
||||||
|
description: document.getElementById('csDescription').value.trim(),
|
||||||
|
channels: channels,
|
||||||
|
form: {
|
||||||
|
heading: document.getElementById('formHeading').value.trim(),
|
||||||
|
fields: {
|
||||||
|
firstName: {
|
||||||
|
label: document.getElementById('fld_firstName_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_firstName_ph').value.trim(),
|
||||||
|
},
|
||||||
|
lastName: {
|
||||||
|
label: document.getElementById('fld_lastName_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_lastName_ph').value.trim(),
|
||||||
|
},
|
||||||
|
studentId: {
|
||||||
|
label: document.getElementById('fld_studentId_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_studentId_ph').value.trim(),
|
||||||
|
},
|
||||||
|
department: {
|
||||||
|
label: document.getElementById('fld_department_label').value.trim(),
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
label: document.getElementById('fld_message_label').value.trim(),
|
||||||
|
placeholder: document.getElementById('fld_message_ph').value.trim(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
departments: departments,
|
||||||
|
submitLabel: document.getElementById('formSubmitLabel').value.trim(),
|
||||||
|
successMessage: document.getElementById('formSuccessMsg').value.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImageUploader(targetInputId, imageType) {
|
||||||
|
var fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.accept = 'image/*';
|
||||||
|
fileInput.style.display = 'none';
|
||||||
|
document.body.appendChild(fileInput);
|
||||||
|
fileInput.onchange = async function (e) {
|
||||||
|
var file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
var uploadBtn = document.querySelector('[data-target-input="' + targetInputId + '"]');
|
||||||
|
var original = uploadBtn ? uploadBtn.innerHTML : '';
|
||||||
|
if (uploadBtn) {
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>...';
|
||||||
|
}
|
||||||
|
var res = await fetch('/admin/upload/image?imageType=' + encodeURIComponent(imageType || 'general'), {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
var result = await res.json();
|
||||||
|
if (!result.success) throw new Error(result.error || 'Upload failed');
|
||||||
|
var input = document.getElementById(targetInputId);
|
||||||
|
if (input) input.value = result.path || '';
|
||||||
|
if (targetInputId === 'heroImage') updateHeroPreview(input.value);
|
||||||
|
if (uploadBtn) {
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
uploadBtn.innerHTML = original;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Upload failed: ' + (err.message || err));
|
||||||
|
var b = document.querySelector('[data-target-input="' + targetInputId + '"]');
|
||||||
|
if (b) { b.disabled = false; b.innerHTML = '<i class="fas fa-upload me-1"></i>Upload'; }
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(fileInput);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var form = document.getElementById('studentSupportForm');
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var btn = document.getElementById('submitBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving...';
|
||||||
|
try {
|
||||||
|
syncHiddenJson();
|
||||||
|
form.submit();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Could not prepare form data. Please try again.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn-upload-image').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
openImageUploader(this.dataset.targetInput, this.dataset.imageType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var heroImgInput = document.getElementById('heroImage');
|
||||||
|
if (heroImgInput) {
|
||||||
|
heroImgInput.addEventListener('input', function () { updateHeroPreview(this.value); });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addServiceBtn').addEventListener('click', function () {
|
||||||
|
var wrap = document.getElementById('servicesContainer');
|
||||||
|
wrap.insertAdjacentHTML('beforeend', buildServiceRow({ icon: 'fa-solid fa-circle-info', hoursIcon: 'fa-regular fa-clock' }));
|
||||||
|
});
|
||||||
|
document.getElementById('addChannelBtn').addEventListener('click', function () {
|
||||||
|
document.getElementById('channelsContainer').insertAdjacentHTML('beforeend', buildChannelRow({ icon: 'fa-solid fa-phone' }));
|
||||||
|
});
|
||||||
|
document.getElementById('addDeptBtn').addEventListener('click', function () {
|
||||||
|
document.getElementById('departmentsContainer').insertAdjacentHTML(
|
||||||
|
'beforeend',
|
||||||
|
'<div class="input-group dept-row">' +
|
||||||
|
'<input type="text" class="form-control dept-input" value="">' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger remove-dept"><i class="fas fa-times"></i></button>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('servicesContainer').addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.remove-service')) {
|
||||||
|
e.target.closest('.service-row').remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('channelsContainer').addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.remove-channel')) {
|
||||||
|
e.target.closest('.channel-row').remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('departmentsContainer').addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.remove-dept')) {
|
||||||
|
e.target.closest('.dept-row').remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user