feat(cms): add management for partnerships, history, accreditation, admissions, and policies pages

Implement a singleton page content system to manage static informational pages. This includes:
- New controllers, models, and data files for Partnerships, History, Accreditation, Admissions, and Policies.
- Admin routes and views for updating page content.
- Public API endpoints for fetching page data.
- Migration scripts for initializing page data.
- Updated admin navigation layout to include a dropdown for "About" sections.
- Audit action constants for tracking updates to these pages.
This commit is contained in:
Tống Thành Đạt
2026-04-20 14:08:38 +07:00
parent fab72e86a6
commit 8b6bf5fe6e
48 changed files with 2357 additions and 4 deletions
+5
View File
@@ -22,6 +22,11 @@ const AUDIT_ACTIONS = Object.freeze({
// About Us // About Us
UPDATE_ABOUT_US: "UPDATE_ABOUT_US", UPDATE_ABOUT_US: "UPDATE_ABOUT_US",
UPDATE_PARTNERSHIPS: "UPDATE_PARTNERSHIPS",
UPDATE_HISTORY: "UPDATE_HISTORY",
UPDATE_ACCREDITATION: "UPDATE_ACCREDITATION",
UPDATE_ADMISSIONS: "UPDATE_ADMISSIONS",
UPDATE_POLICIES: "UPDATE_POLICIES",
// Header // Header
UPDATE_HEADER: "UPDATE_HEADER", UPDATE_HEADER: "UPDATE_HEADER",
+113
View File
@@ -0,0 +1,113 @@
const { addBaseUrlToImages } = require("../utils/imageHelper");
const jsonHelper = require("../utils/jsonHelper");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
function createPageContentController({
model,
modelName,
auditAction,
editorConfig,
}) {
return {
async index(req, res) {
try {
const doc = await model.getSingle();
const data = doc.toObject();
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl =
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
res.render("admin/pageContent/index", {
layout: "layouts/main",
title: editorConfig.title,
subtitle: editorConfig.subtitle,
data,
editorConfig,
activeTab: req.query.tab || editorConfig.tabs[0].key,
frontendUrl,
backendUrl,
previewUrl: `${frontendUrl}${editorConfig.previewPath}`,
currentPath: req.path,
user: req.session.user,
});
} catch (error) {
console.error(`${editorConfig.key} index error:`, error);
req.flash("error_msg", `Error loading ${editorConfig.title}`);
return req.session.save(() => res.redirect("/admin/dashboard"));
}
},
async update(req, res) {
try {
const payload =
typeof req.body.pageJson === "string"
? JSON.parse(req.body.pageJson)
: req.body.pageJson || {};
const activeTab = req.body.activeTab || editorConfig.tabs[0].key;
const doc = await model.getSingle();
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
doc.set(payload);
Object.keys(payload).forEach((key) => doc.markModified(key));
await doc.save();
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: modelName,
documentId: doc._id,
action: auditAction,
before: beforeData,
after: afterData,
changes,
req,
});
}
const finalData = await model
.findOne()
.select("-_id -__v -createdAt -updatedAt")
.lean();
jsonHelper.writeJsonFile(editorConfig.dataFile, finalData);
req.flash("success_msg", `${editorConfig.title} updated successfully`);
return req.session.save(() =>
res.redirect(`${editorConfig.routeBase}?tab=${activeTab}`),
);
} catch (error) {
console.error(`${editorConfig.key} update error:`, error);
req.flash(
"error_msg",
`Error updating ${editorConfig.title}: ${error.message}`,
);
return req.session.save(() =>
res.redirect(
`${editorConfig.routeBase}?tab=${req.body.activeTab || ""}`,
),
);
}
},
async api(req, res) {
try {
const doc = await model.getSingle();
const rawData = doc.toObject();
const backendUrl =
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
const processed = addBaseUrlToImages(rawData, backendUrl);
return res.json(processed);
} catch (error) {
console.error(`${editorConfig.key} api error:`, error);
return res
.status(500)
.json({ error: `Error loading ${editorConfig.key} data` });
}
},
};
}
module.exports = createPageContentController;
+11
View File
@@ -0,0 +1,11 @@
const AccreditationPage = require("../models/accreditationPage");
const AUDIT_ACTIONS = require("../constants/auditAction");
const pageContentConfig = require("../utils/pageContentConfig");
const createPageContentController = require("./_createPageContentController");
module.exports = createPageContentController({
model: AccreditationPage,
modelName: "AccreditationPage",
auditAction: AUDIT_ACTIONS.UPDATE_ACCREDITATION,
editorConfig: pageContentConfig.accreditation,
});
+11
View File
@@ -0,0 +1,11 @@
const AdmissionsPage = require("../models/admissionsPage");
const AUDIT_ACTIONS = require("../constants/auditAction");
const pageContentConfig = require("../utils/pageContentConfig");
const createPageContentController = require("./_createPageContentController");
module.exports = createPageContentController({
model: AdmissionsPage,
modelName: "AdmissionsPage",
auditAction: AUDIT_ACTIONS.UPDATE_ADMISSIONS,
editorConfig: pageContentConfig.admissions,
});
+11
View File
@@ -0,0 +1,11 @@
const HistoryPage = require("../models/historyPage");
const AUDIT_ACTIONS = require("../constants/auditAction");
const pageContentConfig = require("../utils/pageContentConfig");
const createPageContentController = require("./_createPageContentController");
module.exports = createPageContentController({
model: HistoryPage,
modelName: "HistoryPage",
auditAction: AUDIT_ACTIONS.UPDATE_HISTORY,
editorConfig: pageContentConfig.history,
});
+11
View File
@@ -0,0 +1,11 @@
const PartnershipsPage = require("../models/partnerships");
const AUDIT_ACTIONS = require("../constants/auditAction");
const pageContentConfig = require("../utils/pageContentConfig");
const createPageContentController = require("./_createPageContentController");
module.exports = createPageContentController({
model: PartnershipsPage,
modelName: "PartnershipsPage",
auditAction: AUDIT_ACTIONS.UPDATE_PARTNERSHIPS,
editorConfig: pageContentConfig.partnerships,
});
+11
View File
@@ -0,0 +1,11 @@
const PoliciesPage = require("../models/policiesPage");
const AUDIT_ACTIONS = require("../constants/auditAction");
const pageContentConfig = require("../utils/pageContentConfig");
const createPageContentController = require("./_createPageContentController");
module.exports = createPageContentController({
model: PoliciesPage,
modelName: "PoliciesPage",
auditAction: AUDIT_ACTIONS.UPDATE_POLICIES,
editorConfig: pageContentConfig.policies,
});
+88
View File
@@ -0,0 +1,88 @@
{
"trustBanner": {
"icon": "fa-shield-check",
"text": "All our programs are rigorously evaluated and internationally recognized.",
"links": [
{
"label": "Verify Status",
"href": "#accreditations-grid",
"icon": "fa-magnifying-glass"
},
{
"label": "View Legal Disclaimers",
"href": "#accreditations-grid",
"icon": ""
}
]
},
"hero": {
"badge": "Excellence Assured",
"title": "ACCREDITATION AND RECOGNITION",
"description": "At the London Academy of Management and Sciences (LAMS), we are committed to maintaining the highest academic standards and delivering quality education that aligns with international benchmarks. Our accreditations reflect our dedication to excellence, credibility, and continuous improvement in higher education and professional learning."
},
"grid": {
"tabs": ["All", "Registry", "Certification", "Accreditation", "Quality Assurance"],
"items": [
{
"id": "ukrlp",
"icon": "fa-graduation-cap",
"image": "/uploads/accreditation/ukrlp.png",
"status": "Active",
"category": "Registry",
"title": "UKRLP",
"description": "The UK Register of Learning Providers is a national register in the United Kingdom for verified organizations delivering education and training. Inclusion confirms a validation process and official listing as a recognized learning provider, supporting transparency and enabling stakeholders to verify provider details.",
"scopeLabel": "Scope",
"scope": "All Programs",
"validUntilLabel": "Valid Until",
"validUntil": "Ongoing",
"buttonLabel": "View Certificate",
"certificateHref": "#"
},
{
"id": "ico",
"icon": "fa-check-double",
"image": "/uploads/accreditation/ico.png",
"status": "Active",
"category": "Certification",
"title": "ICO",
"description": "The International Certification Organization is an international certification body that assesses educational institutions against quality management, operational, and governance standards. ICO certification indicates alignment with internationally accepted frameworks and compliance with structured quality and administrative practices.",
"scopeLabel": "Scope",
"scope": "Quality Management",
"validUntilLabel": "Valid Until",
"validUntil": "Ongoing",
"buttonLabel": "View Certificate",
"certificateHref": "#"
},
{
"id": "head",
"icon": "fa-building-columns",
"image": "/uploads/accreditation/head.png",
"status": "Active",
"category": "Accreditation",
"title": "HEAD",
"description": "The Higher Education Accreditation Division is an independent accreditation body focused on evaluating higher education institutions. HEAD assesses academic quality, institutional governance, curriculum design, and internal quality assurance mechanisms, reflecting adherence to established standards for higher education delivery and institutional effectiveness.",
"scopeLabel": "Scope",
"scope": "Higher Education",
"validUntilLabel": "Valid Until",
"validUntil": "Ongoing",
"buttonLabel": "View Certificate",
"certificateHref": "#"
},
{
"id": "qahe",
"icon": "fa-award",
"image": "/uploads/accreditation/QAHE.png",
"status": "Active",
"category": "Quality Assurance",
"title": "QAHE",
"description": "Quality Assurance in Higher Education is an international quality assurance agency that evaluates institutions based on academic standards, teaching and learning processes, assessment practices, and institutional management systems. QAHE accreditation signifies that an institution meets defined benchmarks for quality assurance and continuous improvement within the higher education sector.",
"scopeLabel": "Scope",
"scope": "Quality Assurance",
"validUntilLabel": "Valid Until",
"validUntil": "Ongoing",
"buttonLabel": "View Certificate",
"certificateHref": "#"
}
]
}
}
+139
View File
@@ -0,0 +1,139 @@
{
"hero": {
"badge": "Your Path Starts Here",
"title": "Admissions & Transparent Tuition",
"description": "We believe high-quality education should be accessible to everyone. Explore our straightforward admissions process and flexible payment models designed to fit your life.",
"primaryCta": {
"label": "Start Application",
"href": "#apply"
},
"secondaryCta": {
"label": "View Tuition",
"href": "#tuition-breakdown"
},
"image": "/uploads/admissions/hero-students.png",
"imageAlt": "Diverse adult students studying online"
},
"process": {
"id": "admissions-process",
"title": "Admissions Process",
"description": "Our streamlined process gets you from application to enrolled in days, not months. No application fees, no standardized tests.",
"steps": [
{
"number": "1",
"title": "Submit Application",
"description": "Fill out our online form in under 15 minutes. Basic personal and educational history required.",
"active": true
},
{
"number": "2",
"title": "Send Transcripts",
"description": "Request official transcripts from previous institutions for credit evaluation.",
"active": false
},
{
"number": "3",
"title": "Choose Payment Plan",
"description": "Select between our monthly subscription or pay-per-course model.",
"active": false
}
]
},
"eligibility": {
"id": "eligibility",
"title": "Eligibility & Transfer Credits",
"cards": [
{
"title": "Basic Eligibility",
"icon": "fa-check-circle",
"items": [
"High school diploma or equivalent",
"Minimum 2.0 GPA for transfer students",
"English proficiency if applicable"
]
},
{
"title": "Transfer Policy",
"icon": "fa-exchange-alt",
"items": [
"Up to 90 credits accepted for Bachelor's",
"Free unofficial evaluation within 48 hours",
"Credit for prior learning and certifications"
]
}
]
},
"tuition": {
"id": "tuition-breakdown",
"title": "Tuition Breakdown",
"chartTitle": "Savings vs. Traditional University",
"chartDescription": "Estimated total cost for a 4-year degree",
"series": [
{
"label": "Traditional University",
"color": "#0F172A",
"values": [25000, 50000, 75000, 100000]
},
{
"label": "LAMS",
"color": "#c49b27",
"values": [3588, 7176, 10764, 14352]
}
]
},
"keyDates": {
"id": "key-dates",
"title": "Key Dates & Deadlines",
"columns": ["Term", "Application Deadline", "Classes Start"],
"rows": [
{
"term": "Fall Term 1",
"applicationDeadline": "August 15, 2026",
"classesStart": "September 1, 2026"
},
{
"term": "Fall Term 2",
"applicationDeadline": "October 15, 2026",
"classesStart": "November 1, 2026"
},
{
"term": "Spring Term 1",
"applicationDeadline": "December 15, 2026",
"classesStart": "January 5, 2027"
}
]
},
"calculator": {
"title": "Affordability Calculator",
"description": "Estimate your monthly investment.",
"modelOptions": ["Subscription", "Per Course"],
"paceLabel": "Target Pace",
"minPaceLabel": "Relaxed",
"maxPaceLabel": "Accelerated",
"resultLabel": "Estimated Monthly Payment",
"monthlyAmount": "$299",
"monthlySuffix": "/mo",
"noteIcon": "fa-bolt",
"note": "Flat rate, unlimited courses",
"cta": {
"label": "Apply Now",
"href": "#apply"
}
},
"scholarships": {
"title": "Scholarships & Aid",
"icon": "fa-award",
"items": [
{
"title": "Working Adult Grant",
"amount": "Up to $1,500",
"description": "For students employed full-time while studying."
},
{
"title": "Military Discount",
"amount": "15% Off",
"description": "Active duty, veterans, and spouses."
}
]
}
}
+97
View File
@@ -0,0 +1,97 @@
{
"highlight": {
"icon": "fa-trophy",
"text": "2025 Milestone Reached: A Rapidly Growing Global Community!",
"linkLabel": "Read Full Story",
"href": "#timeline-content"
},
"hero": {
"badge": "Our Journey",
"title": "Building the Future of Education.",
"description": "From our humble beginnings to becoming a global leader in online education, explore the key moments that define our legacy."
},
"filters": {
"yearLabel": "Decade / Year",
"categoryLabel": "Category",
"buttonLabel": "Apply Filters",
"yearOptions": ["All Years", "2020 - Present", "2010 - 2019", "2005 - 2009"],
"categoryOptions": [
"All Categories",
"Academic Programs",
"Global Expansion",
"Technology & Innovation",
"Student Experience",
"Awards & Recognition"
]
},
"timeline": {
"loadMoreLabel": "Load Earlier Milestones",
"items": [
{
"id": "student-experience-innovation",
"year": "2026",
"yearRange": "2020 - Present",
"category": "Student Experience",
"categoryLabel": "Student Experience",
"title": "Innovation in Student Experience",
"description": "Enhanced student support through integrated digital services, academic advising, and career development platforms.",
"image": "/uploads/history/2026.png",
"imageAlt": "",
"stats": [],
"featured": true
},
{
"id": "international-partnerships-expansion",
"year": "2025",
"yearRange": "2020 - Present",
"category": "Global Expansion",
"categoryLabel": "Global",
"title": "Expansion of International Partnerships",
"description": "Established collaborations with academic institutions and industry partners across regions, enabling dual qualifications and cross-border learning opportunities.",
"image": "/uploads/history/2025.png",
"imageAlt": "",
"stats": [],
"featured": false
},
{
"id": "academic-programmes-expansion",
"year": "2024",
"yearRange": "2020 - Present",
"category": "Academic Programs",
"categoryLabel": "Academic",
"title": "Expansion of Academic Programmes",
"description": "Launched a portfolio of undergraduate and postgraduate programmes designed to meet global market demands and emerging industry needs.",
"image": "/uploads/history/2024.png",
"imageAlt": "",
"stats": [],
"featured": false
},
{
"id": "ai-enhanced-learning-platform",
"year": "2024",
"yearRange": "2020 - Present",
"category": "Technology & Innovation",
"categoryLabel": "Technology",
"title": "Launch of AI-Enhanced Learning Platform",
"description": "Introduced an adaptive digital learning system that personalises study pathways and enhances student engagement and outcomes.",
"image": "/uploads/history/ai-enhanced-learning-platform.png",
"imageAlt": "Abstract artificial intelligence and digital learning visualization",
"stats": [],
"featured": false
},
{
"id": "strategic-academic-framework",
"year": "2023",
"yearRange": "2020 - Present",
"category": "Academic Programs",
"categoryLabel": "Academic",
"title": "Strategic Academic Framework Introduced",
"description": "Established a future-focused academic model aligned with international standards, integrating applied learning, digital competencies, and global perspectives.",
"image": "/uploads/history/2023.png",
"imageAlt": "",
"stats": [],
"featured": false
}
]
}
}
+127
View File
@@ -0,0 +1,127 @@
{
"hero": {
"badge": "Global NetworkGlobal NetworkGlobal Netwo",
"title": "Industry & Academic Partnerships.Industry & Academic Partnerships.Industry & Academic Part",
"description": "Connecting our students with leading organizations for real-world experience, research opportunities, and career advancement.Connecting our students with leading organizations for real-world experience, research opportun",
"linkLabel": "Explore DirectoryExplore DirectoryExplor",
"image": "/uploads/partnerships/kVI17_2B.webp",
"imageAlt": "Modern university campus and corporate office buildingModern university campus and corporate office buildingModern unive"
},
"directory": {
"heading": "Partner DirectoryPartner DirectoryPartner DirectoryPartner DirectoryPa",
"description": "Discover the organizations shaping the future of education with us.Discover the organizations shaping the future of education with us.Discover the organizations shaping the future ",
"tabs": [
"All PartnersAll PartnersAll Pa",
"IndustryIndustryIndustryIndust",
"AcademicAcademicAcademicAcadem",
"CommunityCommunityCommunityCom",
"hehehehehehehehehehehehehehehe",
"hehehehehehehhehehehehehehhehe"
],
"loadMoreLabel": "Load More PartnersLoad More PartnersLoad",
"partners": [
{
"id": "techvanguardtechvanguardtechvanguardtechvanguardte",
"name": "TechvanguardTechvanguardTechvanguardTechvanguardTechvanguardTechvanguardTechvanguardTechva",
"category": "IndustryIndustryIndustryIndust",
"summary": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
"logo": "/uploads/partnerships/tech-logo.png",
"logoAlt": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
"about": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
"collabType": "IndustryIndustryIndustryIndustryIndustry",
"benefits": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn"
},
{
"id": "IndustryIndustryIndustryIndustryIndustryIndustryIn",
"name": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
"category": "IndustryIndustryIndustryIndust",
"summary": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
"logo": "/uploads/partnerships/royalcosmetics.png",
"logoAlt": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
"about": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
"collabType": "Industry Partner",
"benefits": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndust"
},
{
"id": "IndustryIndustryIndustryIndustryIndustryIndustryIn",
"name": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
"category": "IndustryIndustryIndustryIndust",
"summary": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
"logo": "/uploads/partnerships/swiss.jpg",
"logoAlt": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
"about": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
"collabType": "IndustryIndustryIndustryIndustryIndustry",
"benefits": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndust"
},
{
"id": "IndustryIndustryIndustryIndustryIndustryIndustryIn",
"name": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
"category": "IndustryIndustryIndustryIndust",
"summary": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
"logo": "/uploads/partnerships/uldp.jpg",
"logoAlt": "Université Libérale de Paris logo",
"about": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
"collabType": "IndustryIndustryIndustryIndustryIndustry",
"benefits": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndust"
},
{
"id": "IndustryIndustryIndustryIndustryIndustryIndustryIn",
"name": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
"category": "IndustryIndustryIndustryIndust",
"summary": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
"logo": "/uploads/partnerships/ENG_main_2022-05-20-070029_kstp.jpg",
"logoAlt": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
"about": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
"collabType": "IndustryIndustryIndustryIndustryIndustry",
"benefits": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndust"
},
{
"id": "IndustryIndustryIndustryIndustryIndustryIndustryIn",
"name": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
"category": "IndustryIndustryIndustryIndust",
"summary": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
"logo": "/uploads/partnerships/horizons.jpg",
"logoAlt": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
"about": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
"collabType": "IndustryIndustryIndustryIndustryIndustry",
"benefits": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndust"
}
]
},
"cta": {
"heading": "Join the ecosystem.Join the ecosystem.Join the ecosystem.Join the ecosystem.Join",
"description": "Partner with London Academy of Management and Sciences to build talent pipelines, collaborate on research, and shape the next generation of leaders.Partner with London Academy of Management and Sciences to build talent p",
"buttonLabel": "Become a partnerBecome a partnerBecome a"
},
"inquiryForm": {
"title": "Partnership InquiryPartnership InquiryPartnership InquiryPar",
"fields": {
"firstName": {
"label": "Partnership InquiryPartnership",
"placeholder": "Partnership InquiryPartnership InquiryPa"
},
"lastName": {
"label": "Partnership InquiryPartnership",
"placeholder": "Partnership InquiryPartnership InquiryPa"
},
"organization": {
"label": "Partnership InquiryPartnership InquiryPa",
"placeholder": "Partnership InquiryPartnership InquiryPartnership InquiryPar"
},
"partnershipType": {
"label": "Partnership InquiryPartnership InquiryPa",
"options": [
"Partnership InquiryPartnership InquiryPartnership ",
"Partnership InquiryPartnership InquiryPartnership ",
"Partnership InquiryPartnership InquiryPartnership ",
"Partnership InquiryPartnership InquiryPartnership "
]
},
"message": {
"label": "Partnership InquiryPartnership",
"placeholder": "Partnership InquiryPartnership InquiryPartnership InquiryPartnership InquiryPart"
}
},
"submitLabel": "Partnership InquiryPartnership InquiryPa"
}
}
+178
View File
@@ -0,0 +1,178 @@
{
"hero": {
"badge": "Policies",
"icon": "fa-scale-balanced",
"titlePrefix": "Our Commitment to",
"titleHighlight": "Transparency",
"description": "Review our policies, terms of service, and commitments to privacy and accessibility. We believe in clear, straightforward communication with our academic community.",
"lastUpdated": "Last updated: September 15, 2025"
},
"sidebar": {
"heading": "Policies",
"helperText": "Need clarification on a policy?",
"contactLabel": "Contact Policy Team",
"contactHref": "/contact"
},
"policies": [
{
"id": "privacy",
"navLabel": "Privacy Policy",
"title": "Privacy Policy",
"effectiveDate": "Effective Date: September 15, 2025",
"intro": "At LAMS, we are committed to protecting your privacy and ensuring the security of your personal information. This Privacy Policy outlines how we collect, use, and safeguard the data of our students, applicants, and website visitors.",
"sections": [
{
"type": "list",
"heading": "1. Information We Collect",
"intro": "We collect information that you provide directly to us when you apply for admission, enroll in courses, request information, or contact our support teams. This may include:",
"items": [
"Personal identification information such as name, address, email address, phone number, date of birth, and government-issued ID numbers where required.",
"Academic records such as transcripts, previous educational history, standardized test scores, and current academic performance data.",
"Financial information such as payment details, financial aid applications, and billing history."
]
},
{
"type": "list",
"heading": "2. How We Use Your Information",
"intro": "Your information is primarily used to provide educational services and manage your student journey. Specific uses include:",
"items": [
"Processing admissions applications and enrollment.",
"Delivering course materials, grades, and academic advising.",
"Processing tuition payments and administering financial aid.",
"Communicating important university updates and policy changes."
]
},
{
"type": "text",
"heading": "3. Data Sharing and Third Parties",
"paragraphs": [
{
"text": "We do not sell your personal information. We may share your data with trusted third-party service providers who assist us in operating our university, including learning management systems and payment processors. These partners are bound by strict confidentiality agreements. For more details, refer to our Vendor Data Processing Addendum.",
"links": [
{
"label": "Vendor Data Processing Addendum",
"href": "#"
}
]
},
{
"text": "If you have questions about this policy, please contact our Data Protection Officer at privacy@LAMS.edu.",
"links": [
{
"label": "privacy@LAMS.edu",
"href": "mailto:privacy@LAMS.edu"
}
]
}
]
}
]
},
{
"id": "terms",
"navLabel": "Terms of Use",
"title": "Terms of Use",
"effectiveDate": "Effective Date: January 1, 2025",
"intro": "Welcome to LAMS. By accessing our website, student portal, or utilizing our educational services, you agree to be bound by these Terms of Use and our Privacy Policy.",
"sections": [
{
"type": "text",
"heading": "1. Academic Integrity",
"paragraphs": [
{
"text": "As a student of LAMS, you are expected to uphold the highest standards of academic honesty. Plagiarism, cheating, and the unauthorized sharing of course materials are strictly prohibited and may result in disciplinary action."
}
]
},
{
"type": "text",
"heading": "2. Account Security",
"paragraphs": [
{
"text": "You are responsible for maintaining the confidentiality of your student portal credentials. You must immediately notify the IT Helpdesk of any unauthorized use of your account."
}
]
},
{
"type": "cards",
"cards": [
{
"icon": "fa-book-open",
"title": "Course Materials",
"description": "All course content provided via the learning management system is the intellectual property of LAMS or its licensors. It is for personal educational use only."
},
{
"icon": "fa-credit-card",
"title": "Subscription Terms",
"description": "Monthly subscriptions automatically renew unless canceled prior to the billing cycle. See the Financial Policies for refund criteria.",
"link": {
"label": "Financial Policies",
"href": "#"
}
}
]
}
]
},
{
"id": "accessibility",
"navLabel": "Accessibility Statement",
"title": "Accessibility Statement",
"effectiveDate": "Effective Date: September 15, 2025",
"intro": "LAMS is committed to providing digital learning experiences that are accessible to all students, applicants, faculty, and visitors.",
"sections": [
{
"type": "list",
"heading": "1. Our Accessibility Commitments",
"items": [
"We design learning materials and digital services with accessibility in mind.",
"We review core student journeys for keyboard access, screen reader support, and readable contrast.",
"We provide reasonable accommodations through our student support and advising teams."
]
},
{
"type": "text",
"heading": "2. Requesting Support",
"paragraphs": [
{
"text": "If you encounter an accessibility barrier, contact our support team so we can review the issue and provide an appropriate path forward.",
"links": [
{
"label": "contact our support team",
"href": "/contact"
}
]
}
]
}
]
},
{
"id": "cookies",
"navLabel": "Cookie Preferences",
"title": "Cookie Preferences",
"effectiveDate": "Effective Date: September 15, 2025",
"intro": "We use cookies and similar technologies to operate our website, understand usage patterns, and improve the student experience.",
"sections": [
{
"type": "list",
"heading": "1. Cookie Categories",
"items": [
"Essential cookies keep core services such as authentication and security running.",
"Analytics cookies help us understand aggregate site usage and improve content.",
"Preference cookies remember non-sensitive choices such as language and display settings."
]
},
{
"type": "text",
"heading": "2. Managing Preferences",
"paragraphs": [
{
"text": "You can manage cookies through your browser settings. Some essential cookies cannot be disabled because they are required for secure access to student services."
}
]
}
]
}
]
}
+28
View File
@@ -0,0 +1,28 @@
const mongoose = require("mongoose");
const jsonHelper = require("../utils/jsonHelper");
function createSingletonPageModel(modelName, collectionName, dataFileName) {
const schema = new mongoose.Schema(
{},
{
strict: false,
timestamps: true,
collection: collectionName,
},
);
schema.statics.getSingle = async function getSingle() {
let doc = await this.findOne();
if (!doc) {
const defaultData = jsonHelper.readJsonFile(dataFileName) || {};
doc = await this.create(defaultData);
}
return doc;
};
return mongoose.model(modelName, schema);
}
module.exports = createSingletonPageModel;
+7
View File
@@ -0,0 +1,7 @@
const createSingletonPageModel = require("./_createSingletonPageModel");
module.exports = createSingletonPageModel(
"AccreditationPage",
"accreditation_pages",
"accreditation",
);
+7
View File
@@ -0,0 +1,7 @@
const createSingletonPageModel = require("./_createSingletonPageModel");
module.exports = createSingletonPageModel(
"AdmissionsPage",
"admissions_pages",
"admissions",
);
+7
View File
@@ -0,0 +1,7 @@
const createSingletonPageModel = require("./_createSingletonPageModel");
module.exports = createSingletonPageModel(
"HistoryPage",
"history_pages",
"history",
);
+7
View File
@@ -0,0 +1,7 @@
const createSingletonPageModel = require("./_createSingletonPageModel");
module.exports = createSingletonPageModel(
"PartnershipsPage",
"partnerships_pages",
"partnerships",
);
+7
View File
@@ -0,0 +1,7 @@
const createSingletonPageModel = require("./_createSingletonPageModel");
module.exports = createSingletonPageModel(
"PoliciesPage",
"policies_pages",
"policies",
);
+571
View File
@@ -0,0 +1,571 @@
(function () {
const config = window.pageEditorConfig;
const initialData = window.pageEditorData;
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
const form = document.getElementById("pageContentForm");
const pageJsonInput = document.getElementById("pageJson");
const activeTabInput = document.getElementById("activeTabInput");
if (!config || !initialData || !form || !pageJsonInput || !activeTabInput) {
return;
}
const state = JSON.parse(JSON.stringify(initialData));
const iconOptions = Array.from(
new Set(
(config.tabs || [])
.flatMap((tab) => collectIcons(tab.schema))
.filter(Boolean),
),
);
ensureIconDatalist(iconOptions);
renderAllSections();
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
tabTrigger.addEventListener("shown.bs.tab", function () {
activeTabInput.value = this.dataset.tabKey;
});
});
form.addEventListener("submit", function () {
pageJsonInput.value = JSON.stringify(state);
});
form.addEventListener("reset", function () {
window.setTimeout(function () {
Object.keys(state).forEach((key) => delete state[key]);
Object.assign(state, JSON.parse(JSON.stringify(initialData)));
renderAllSections();
}, 0);
});
function renderAllSections() {
config.tabs.forEach((tab) => renderSection(tab.key));
}
function renderSection(tabKey) {
const tab = config.tabs.find((item) => item.key === tabKey);
const container = document.querySelector(
`.page-editor-section[data-section-key="${tabKey}"]`,
);
if (!tab || !container) return;
container.innerHTML = "";
renderField(tab.schema, container, state, tab.schema.key, tabKey);
}
function renderField(schema, container, parent, key, tabKey) {
if (schema.type === "object") {
if (!isObject(parent[key])) {
parent[key] = {};
}
const groupWrapper = document.createElement("div");
groupWrapper.className = "row g-3";
container.appendChild(groupWrapper);
(schema.fields || []).forEach((field) => {
renderField(field, groupWrapper, parent[key], field.key, tabKey);
});
return;
}
if (schema.type === "array") {
if (!Array.isArray(parent[key])) {
parent[key] = [];
}
const col = createCol(schema.colClass || "col-12");
const card = document.createElement("div");
card.className = "border rounded-3 bg-light-subtle p-3";
const header = document.createElement("div");
header.className = "d-flex justify-content-between align-items-center mb-3";
header.innerHTML = `
<div>
<label class="form-label fw-semibold mb-1">${escapeHtml(schema.label)}</label>
${schema.helpText ? `<div class="form-text mt-0">${escapeHtml(schema.helpText)}</div>` : ""}
</div>
<button type="button" class="btn btn-outline-primary btn-sm">
<i class="fas fa-plus me-1"></i>Add ${escapeHtml(schema.itemLabel || "Item")}
</button>
`;
header.querySelector("button").addEventListener("click", function () {
parent[key].push(createDefaultValue(schema.itemSchema));
renderSection(tabKey);
});
card.appendChild(header);
if (parent[key].length === 0) {
const empty = document.createElement("div");
empty.className = "text-muted small";
empty.textContent = `No ${schema.itemLabel || "items"} yet.`;
card.appendChild(empty);
} else {
parent[key].forEach((item, index) => {
const itemCard = document.createElement("div");
itemCard.className = "card shadow-sm border-0 mb-3";
const itemHeader = document.createElement("div");
itemHeader.className = "card-header bg-white d-flex justify-content-between align-items-center";
itemHeader.innerHTML = `
<span class="fw-semibold">${escapeHtml(schema.itemLabel || "Item")} ${index + 1}</span>
<button type="button" class="btn btn-outline-danger btn-sm">
<i class="fas fa-trash-alt me-1"></i>Remove
</button>
`;
itemHeader.querySelector("button").addEventListener("click", function () {
parent[key].splice(index, 1);
renderSection(tabKey);
});
const itemBody = document.createElement("div");
itemBody.className = "card-body";
if (schema.itemSchema.type === "primitive") {
renderPrimitiveArrayItem(schema, itemBody, parent[key], index);
} else if (schema.itemSchema.type === "variant") {
renderVariantArrayItem(schema.itemSchema, itemBody, parent[key], index, tabKey);
} else {
const bodyRow = document.createElement("div");
bodyRow.className = "row g-3";
itemBody.appendChild(bodyRow);
(schema.itemSchema.fields || []).forEach((field) => {
renderField(field, bodyRow, parent[key][index], field.key, tabKey);
});
}
itemCard.appendChild(itemHeader);
itemCard.appendChild(itemBody);
card.appendChild(itemCard);
});
}
col.appendChild(card);
container.appendChild(col);
return;
}
if (schema.type === "checkbox") {
renderCheckbox(schema, container, parent, key);
return;
}
renderLeafField(schema, container, parent, key);
}
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index) {
const fieldSchema = arraySchema.itemSchema;
const row = document.createElement("div");
row.className = "row g-3";
container.appendChild(row);
const holder = { value: targetArray[index] || "" };
renderLeafField(
{
key: "value",
label: fieldSchema.label || arraySchema.itemLabel || "Value",
type: fieldSchema.fieldType || "text",
maxLength: fieldSchema.maxLength,
placeholder: fieldSchema.placeholder,
helpText: fieldSchema.helpText,
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
},
row,
holder,
"value",
);
const input = row.querySelector("input, textarea");
if (input) {
input.addEventListener("input", function () {
targetArray[index] = holder.value;
});
input.addEventListener("change", function () {
targetArray[index] = holder.value;
});
}
}
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey) {
const item = targetArray[index];
if (!isObject(item)) {
targetArray[index] = {};
}
const currentType =
targetArray[index][variantSchema.discriminator] ||
variantSchema.options[0].value;
targetArray[index][variantSchema.discriminator] = currentType;
const currentVariant = variantSchema.variants[currentType];
const typeRow = document.createElement("div");
typeRow.className = "row g-3 mb-2";
container.appendChild(typeRow);
renderLeafField(
{
key: variantSchema.discriminator,
label: "Section Type",
type: "select",
options: variantSchema.options,
},
typeRow,
targetArray[index],
variantSchema.discriminator,
);
const selectInput = typeRow.querySelector("select");
if (selectInput) {
selectInput.addEventListener("change", function () {
const newType = this.value;
targetArray[index] = { type: newType };
renderSection(tabKey);
});
}
if (currentVariant && currentVariant.schema) {
const sectionRow = document.createElement("div");
sectionRow.className = "row g-3";
container.appendChild(sectionRow);
(currentVariant.schema.fields || []).forEach((field) => {
renderField(field, sectionRow, targetArray[index], field.key, tabKey);
});
}
}
function renderLeafField(schema, container, parent, key) {
if (schema.type === "hidden") {
parent[key] = parent[key] || "";
return;
}
if (typeof parent[key] === "undefined" || parent[key] === null) {
parent[key] = schema.type === "number" ? 0 : "";
}
const col = createCol(schema.colClass || inferColClass(schema.type));
const label = document.createElement("label");
label.className = "form-label fw-semibold";
label.textContent = schema.label || key;
col.appendChild(label);
if (schema.type === "textarea") {
const textarea = document.createElement("textarea");
textarea.className = "form-control";
textarea.rows = schema.rows || 4;
textarea.value = parent[key] || "";
if (schema.placeholder) textarea.placeholder = schema.placeholder;
if (schema.maxLength) textarea.maxLength = schema.maxLength;
textarea.addEventListener("input", function () {
parent[key] = textarea.value;
updateCounter(counter, textarea.value.length, schema.maxLength);
});
col.appendChild(textarea);
const counter = appendHelp(col, schema, parent[key]);
container.appendChild(col);
return;
}
if (schema.type === "image") {
const group = document.createElement("div");
group.className = "input-group";
const input = document.createElement("input");
input.type = "text";
input.className = "form-control";
input.value = parent[key] || "";
input.addEventListener("input", function () {
parent[key] = input.value;
preview.src = resolveImageUrl(input.value);
preview.classList.toggle("d-none", !input.value);
});
const button = document.createElement("button");
button.type = "button";
button.className = "btn btn-outline-primary";
button.innerHTML = '<i class="fas fa-upload me-1"></i>Upload';
button.addEventListener("click", function () {
openImagePicker(schema.imageType || config.imageType, function (path) {
parent[key] = path;
input.value = path;
preview.src = resolveImageUrl(path);
preview.classList.toggle("d-none", !path);
});
});
group.appendChild(input);
group.appendChild(button);
col.appendChild(group);
const preview = document.createElement("img");
preview.className = "img-thumbnail uploaded-preview mt-2";
preview.style.maxHeight = "200px";
preview.src = resolveImageUrl(parent[key]);
preview.classList.toggle("d-none", !parent[key]);
col.appendChild(preview);
appendHelp(col, schema, parent[key], schema.imageHint);
container.appendChild(col);
return;
}
const input =
schema.type === "select" ? document.createElement("select") : document.createElement("input");
input.className = "form-control";
if (schema.type === "select") {
(schema.options || []).forEach((option) => {
const optionEl = document.createElement("option");
if (typeof option === "string") {
optionEl.value = option;
optionEl.textContent = option;
} else {
optionEl.value = option.value;
optionEl.textContent = option.label;
}
input.appendChild(optionEl);
});
input.value = parent[key] || input.options[0]?.value || "";
parent[key] = input.value;
input.addEventListener("change", function () {
parent[key] = input.value;
});
} else {
input.type =
schema.type === "url" || schema.type === "number" || schema.type === "color"
? schema.type
: "text";
input.value = parent[key] || "";
if (schema.placeholder) input.placeholder = schema.placeholder;
if (schema.maxLength) input.maxLength = schema.maxLength;
if (schema.step) input.step = schema.step;
if (schema.type === "icon") {
input.setAttribute("list", "cms-icon-options");
}
input.addEventListener("input", function () {
parent[key] =
schema.type === "number" ? Number(input.value || 0) : input.value;
updateCounter(counter, String(input.value || "").length, schema.maxLength);
});
}
col.appendChild(input);
const counter = appendHelp(col, schema, parent[key]);
container.appendChild(col);
}
function renderCheckbox(schema, container, parent, key) {
if (typeof parent[key] !== "boolean") {
parent[key] = Boolean(parent[key]);
}
const col = createCol(schema.colClass || "col-12");
const wrapper = document.createElement("div");
wrapper.className = "form-check mt-4";
const input = document.createElement("input");
input.type = "checkbox";
input.className = "form-check-input";
input.checked = parent[key];
input.addEventListener("change", function () {
parent[key] = input.checked;
});
const label = document.createElement("label");
label.className = "form-check-label fw-semibold";
label.textContent = schema.label || key;
wrapper.appendChild(input);
wrapper.appendChild(label);
col.appendChild(wrapper);
if (schema.helpText) {
const help = document.createElement("div");
help.className = "form-text";
help.textContent = schema.helpText;
col.appendChild(help);
}
container.appendChild(col);
}
function appendHelp(col, schema, value, extraHint) {
const wrapper = document.createElement("div");
wrapper.className = "d-flex justify-content-between gap-3";
const help = document.createElement("div");
help.className = "form-text";
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
wrapper.appendChild(help);
let counter = null;
if (schema.maxLength) {
counter = document.createElement("div");
counter.className = "form-text text-end ms-auto";
updateCounter(counter, String(value || "").length, schema.maxLength);
wrapper.appendChild(counter);
}
if (help.textContent || counter) {
col.appendChild(wrapper);
}
return counter;
}
function updateCounter(counter, currentLength, maxLength) {
if (!counter || !maxLength) return;
counter.textContent = `${currentLength}/${maxLength}`;
}
function openImagePicker(imageType, onSuccess) {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "image/*";
fileInput.style.display = "none";
document.body.appendChild(fileInput);
fileInput.addEventListener("change", async function () {
if (!fileInput.files || !fileInput.files[0]) {
fileInput.remove();
return;
}
try {
const formData = new FormData();
formData.append("image", fileInput.files[0]);
const response = await fetch(
`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`,
{
method: "POST",
body: formData,
},
);
const result = await response.json();
if (!result.success || !result.path) {
throw new Error(result.error || "Upload failed");
}
onSuccess(result.path);
showToast("Success", "Image uploaded successfully", "success");
} catch (error) {
showToast("Error", error.message || "Upload failed", "danger");
} finally {
fileInput.remove();
}
});
fileInput.click();
}
function showToast(title, message, type) {
const container =
document.querySelector(".toast-container") || createToastContainer();
const toast = document.createElement("div");
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
toast.setAttribute("role", "alert");
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(
title,
)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
container.appendChild(toast);
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
toast.addEventListener("hidden.bs.toast", function () {
toast.remove();
});
}
function createToastContainer() {
const container = document.createElement("div");
container.className = "toast-container position-fixed top-0 end-0 p-3";
document.body.appendChild(container);
return container;
}
function createDefaultValue(schema) {
if (!schema) return "";
if (schema.type === "primitive") return "";
if (schema.type === "variant") {
return { [schema.discriminator]: schema.options[0].value };
}
if (schema.type === "object") {
const value = {};
(schema.fields || []).forEach((field) => {
if (field.type === "array") value[field.key] = [];
else if (field.type === "object") value[field.key] = createDefaultValue(field);
else if (field.type === "checkbox") value[field.key] = false;
else if (field.type === "number") value[field.key] = 0;
else value[field.key] = "";
});
return value;
}
return "";
}
function createCol(colClass) {
const div = document.createElement("div");
div.className = colClass;
return div;
}
function inferColClass(type) {
if (type === "textarea" || type === "image") return "col-12";
if (type === "checkbox") return "col-12";
return "col-md-6";
}
function resolveImageUrl(path) {
if (!path) return "";
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith("/")) return `${backendUrl}${path}`;
return `${backendUrl}/${path}`;
}
function collectIcons(schema) {
if (!schema) return [];
if (schema.type === "icon") return schema.options || [];
if (schema.type === "object") return (schema.fields || []).flatMap(collectIcons);
if (schema.type === "array") return collectIcons(schema.itemSchema);
if (schema.type === "variant") {
return Object.values(schema.variants || {}).flatMap((variant) =>
collectIcons(variant.schema),
);
}
return [];
}
function ensureIconDatalist(options) {
const existing = document.getElementById("cms-icon-options");
if (existing) existing.remove();
const dataList = document.createElement("datalist");
dataList.id = "cms-icon-options";
options.forEach((option) => {
const item = document.createElement("option");
item.value = option;
dataList.appendChild(item);
});
document.body.appendChild(dataList);
}
function isObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+31
View File
@@ -7,6 +7,11 @@ const homeController = require("../controllers/homeController");
const headerController = require("../controllers/headerController"); const headerController = require("../controllers/headerController");
const footerController = require("../controllers/footerController"); const footerController = require("../controllers/footerController");
const aboutUsController = require("../controllers/aboutUsController"); const aboutUsController = require("../controllers/aboutUsController");
const partnershipsController = require("../controllers/partnershipsController");
const historyPageController = require("../controllers/historyPageController");
const accreditationController = require("../controllers/accreditationController");
const admissionsController = require("../controllers/admissionsController");
const policiesController = require("../controllers/policiesController");
const formController = require("../controllers/formController"); const formController = require("../controllers/formController");
const contactController = require("../controllers/contactController"); const contactController = require("../controllers/contactController");
const pageController = require("../controllers/pageController"); const pageController = require("../controllers/pageController");
@@ -50,6 +55,32 @@ router.param("code", (req, res, next, code) => {
// About Us // About Us
router.get("/about-us", ensureAuthenticated, aboutUsController.index); router.get("/about-us", ensureAuthenticated, aboutUsController.index);
router.post("/about-us/update", ensureAuthenticated, aboutUsController.update); router.post("/about-us/update", ensureAuthenticated, aboutUsController.update);
router.get("/partnerships", ensureAuthenticated, partnershipsController.index);
router.post(
"/partnerships/update",
ensureAuthenticated,
partnershipsController.update,
);
router.get("/history", ensureAuthenticated, historyPageController.index);
router.post("/history/update", ensureAuthenticated, historyPageController.update);
router.get(
"/accreditation",
ensureAuthenticated,
accreditationController.index,
);
router.post(
"/accreditation/update",
ensureAuthenticated,
accreditationController.update,
);
router.get("/admissions", ensureAuthenticated, admissionsController.index);
router.post(
"/admissions/update",
ensureAuthenticated,
admissionsController.update,
);
router.get("/policies", ensureAuthenticated, policiesController.index);
router.post("/policies/update", ensureAuthenticated, policiesController.update);
// Booking admin CRUD removed // Booking admin CRUD removed
+10
View File
@@ -3,6 +3,11 @@ const path = require("path");
const router = express.Router(); const router = express.Router();
const homeController = require("../controllers/homeController"); const homeController = require("../controllers/homeController");
const aboutUsController = require("../controllers/aboutUsController"); const aboutUsController = require("../controllers/aboutUsController");
const partnershipsController = require("../controllers/partnershipsController");
const historyPageController = require("../controllers/historyPageController");
const accreditationController = require("../controllers/accreditationController");
const admissionsController = require("../controllers/admissionsController");
const policiesController = require("../controllers/policiesController");
const headerController = require("../controllers/headerController"); 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");
@@ -39,6 +44,11 @@ router.get("/api/home", homeController.api);
// API để lấy dữ liệu about // API để lấy dữ liệu about
router.get("/api/about", aboutUsController.getAbout); router.get("/api/about", aboutUsController.getAbout);
router.put("/api/about", aboutUsController.updateAbout); router.put("/api/about", aboutUsController.updateAbout);
router.get("/api/partnerships", partnershipsController.api);
router.get("/api/history", historyPageController.api);
router.get("/api/accreditation", accreditationController.api);
router.get("/api/admissions", admissionsController.api);
router.get("/api/policies", policiesController.api);
// Public about-us page and API (legacy support) // Public about-us page and API (legacy support)
router.get("/about-us", aboutUsController.getAbout); router.get("/about-us", aboutUsController.getAbout);
@@ -0,0 +1,8 @@
const migrateSingletonPage = require("./_migrate-singleton-page");
migrateSingletonPage({
migrationName: "import_partnerships_content",
modelPath: "../models/partnerships",
dataFile: "partnerships.json",
label: "Partnerships",
});
+8
View File
@@ -0,0 +1,8 @@
const migrateSingletonPage = require("./_migrate-singleton-page");
migrateSingletonPage({
migrationName: "import_history_content",
modelPath: "../models/historyPage",
dataFile: "history.json",
label: "History",
});
@@ -0,0 +1,8 @@
const migrateSingletonPage = require("./_migrate-singleton-page");
migrateSingletonPage({
migrationName: "import_accreditation_content",
modelPath: "../models/accreditationPage",
dataFile: "accreditation.json",
label: "Accreditation",
});
+8
View File
@@ -0,0 +1,8 @@
const migrateSingletonPage = require("./_migrate-singleton-page");
migrateSingletonPage({
migrationName: "import_admissions_content",
modelPath: "../models/admissionsPage",
dataFile: "admissions.json",
label: "Admissions",
});
+8
View File
@@ -0,0 +1,8 @@
const migrateSingletonPage = require("./_migrate-singleton-page");
migrateSingletonPage({
migrationName: "import_policies_content",
modelPath: "../models/policiesPage",
dataFile: "policies.json",
label: "Policies",
});
+38
View File
@@ -0,0 +1,38 @@
require("dotenv").config();
const fs = require("fs").promises;
const path = require("path");
const connectDB = require("../config/database");
async function migrateSingletonPage({
migrationName,
modelPath,
dataFile,
label,
}) {
try {
await connectDB();
console.log(`🚀 Starting migration: ${migrationName}...`);
const Model = require(modelPath);
console.log(`${label} model registered successfully`);
const dataPath = path.join(__dirname, "..", "data", dataFile);
const raw = await fs.readFile(dataPath, "utf8");
const pageData = JSON.parse(raw);
console.log(`📖 ${label} data loaded from: ${dataPath}`);
await Model.deleteMany({});
console.log(`🧹 Existing ${label} documents cleared`);
const created = await Model.create(pageData);
console.log(`${label} document created with _id: ${created._id.toString()}`);
console.log(`🎉 Migration ${migrationName} completed successfully.`);
process.exit(0);
} catch (error) {
console.error(`❌ Migration ${migrationName} failed:`, error);
process.exit(1);
}
}
module.exports = migrateSingletonPage;
+703
View File
@@ -0,0 +1,703 @@
const ICON_OPTIONS = [
"fa-scale-balanced",
"fa-shield-check",
"fa-magnifying-glass",
"fa-graduation-cap",
"fa-check-double",
"fa-building-columns",
"fa-award",
"fa-trophy",
"fa-arrow-down",
"fa-arrow-right",
"fa-check-circle",
"fa-exchange-alt",
"fa-bolt",
"fa-book-open",
"fa-credit-card",
];
const text = (key, label, options = {}) => ({
key,
label,
type: "text",
...options,
});
const textarea = (key, label, options = {}) => ({
key,
label,
type: "textarea",
rows: options.rows || 4,
...options,
});
const image = (key, label, options = {}) => ({
key,
label,
type: "image",
...options,
});
const icon = (key, label, options = {}) => ({
key,
label,
type: "icon",
options: options.options || ICON_OPTIONS,
...options,
});
const checkbox = (key, label, options = {}) => ({
key,
label,
type: "checkbox",
...options,
});
const url = (key, label, options = {}) => ({
key,
label,
type: "url",
...options,
});
const object = (key, label, fields, options = {}) => ({
key,
label,
type: "object",
fields,
...options,
});
const stringList = (key, label, options = {}) => ({
key,
label,
type: "array",
itemLabel: options.itemLabel || "Item",
itemSchema: {
type: "primitive",
fieldType: options.fieldType || "text",
label: options.itemLabel || "Item",
maxLength: options.maxLength,
placeholder: options.placeholder,
helpText: options.itemHelpText,
},
...options,
});
const objectList = (key, label, fields, options = {}) => ({
key,
label,
type: "array",
itemLabel: options.itemLabel || "Item",
itemSchema: {
type: "object",
fields,
},
...options,
});
const variantList = (key, label, variants, options = {}) => ({
key,
label,
type: "array",
itemLabel: options.itemLabel || "Item",
itemSchema: {
type: "variant",
discriminator: "type",
options: Object.keys(variants).map((value) => ({
value,
label: variants[value].label,
})),
variants,
},
...options,
});
const linkFields = (prefix = "Link") => [
text("label", `${prefix} Label`, { maxLength: 60 }),
url("href", `${prefix} URL`, { maxLength: 255 }),
];
module.exports = {
partnerships: {
key: "partnerships",
title: "Partnerships Management",
subtitle: "Edit content displayed on the partnerships page",
routeBase: "/admin/partnerships",
apiPath: "/api/partnerships",
previewPath: "/about/partnerships",
dataFile: "partnerships",
imageType: "partnerships",
tabs: [
{
key: "hero",
label: "Hero",
icon: "fas fa-image",
schema: object("hero", "Hero", [
text("badge", "Badge", { maxLength: 40 }),
text("title", "Title", { maxLength: 90 }),
textarea("description", "Description", { maxLength: 220, rows: 4 }),
text("linkLabel", "Link Label", { maxLength: 40 }),
image("image", "Hero Image", {
imageHint: "Recommended 720x630 px",
helpText: "Upload a landscape hero image for the right panel.",
}),
text("imageAlt", "Hero Image Alt Text", { maxLength: 120 }),
]),
},
{
key: "directory",
label: "Directory",
icon: "fas fa-handshake",
schema: object("directory", "Directory", [
text("heading", "Heading", { maxLength: 70 }),
textarea("description", "Description", { maxLength: 180, rows: 3 }),
stringList("tabs", "Tabs", {
itemLabel: "Tab",
maxLength: 30,
placeholder: "Industry",
}),
text("loadMoreLabel", "Load More Label", { maxLength: 40 }),
objectList(
"partners",
"Partners",
[
text("id", "Partner ID", {
maxLength: 50,
helpText: "Stable ID used by the frontend filtering state.",
}),
text("name", "Name", { maxLength: 90 }),
text("category", "Category", { maxLength: 30 }),
textarea("summary", "Summary", {
maxLength: 130,
rows: 3,
helpText:
"The card preview uses a 130 character cap in the frontend.",
}),
image("logo", "Logo", {
imageHint: "Recommended 105x80 px minimum visible ratio",
helpText: "Use a clean logo with transparent or simple background.",
}),
text("logoAlt", "Logo Alt Text", { maxLength: 120 }),
textarea("about", "About", { maxLength: 600, rows: 5 }),
text("collabType", "Collaboration Type", { maxLength: 40 }),
textarea("benefits", "Benefits", { maxLength: 240, rows: 4 }),
],
{ itemLabel: "Partner" },
),
]),
},
{
key: "cta",
label: "CTA",
icon: "fas fa-bullhorn",
schema: object("cta", "CTA", [
text("heading", "Heading", { maxLength: 80 }),
textarea("description", "Description", { maxLength: 220, rows: 4 }),
text("buttonLabel", "Button Label", { maxLength: 40 }),
]),
},
{
key: "inquiryForm",
label: "Inquiry Form",
icon: "fas fa-envelope",
schema: object("inquiryForm", "Inquiry Form", [
text("title", "Title", { maxLength: 60 }),
object("fields", "Fields", [
object("firstName", "First Name Field", [
text("label", "Label", { maxLength: 30 }),
text("placeholder", "Placeholder", { maxLength: 40 }),
]),
object("lastName", "Last Name Field", [
text("label", "Label", { maxLength: 30 }),
text("placeholder", "Placeholder", { maxLength: 40 }),
]),
object("organization", "Organization Field", [
text("label", "Label", { maxLength: 40 }),
text("placeholder", "Placeholder", { maxLength: 60 }),
]),
object("partnershipType", "Partnership Type Field", [
text("label", "Label", { maxLength: 40 }),
stringList("options", "Options", {
itemLabel: "Option",
maxLength: 50,
}),
]),
object("message", "Message Field", [
text("label", "Label", { maxLength: 30 }),
text("placeholder", "Placeholder", { maxLength: 80 }),
]),
]),
text("submitLabel", "Submit Label", { maxLength: 40 }),
]),
},
],
},
history: {
key: "history",
title: "History Management",
subtitle: "Edit content displayed on the history page",
routeBase: "/admin/history",
apiPath: "/api/history",
previewPath: "/about/history",
dataFile: "history",
imageType: "history",
tabs: [
{
key: "highlight",
label: "Highlight",
icon: "fas fa-star",
schema: object("highlight", "Highlight Banner", [
icon("icon", "Icon"),
text("text", "Text", { maxLength: 110 }),
text("linkLabel", "Link Label", { maxLength: 30 }),
url("href", "Link URL", { maxLength: 255 }),
]),
},
{
key: "hero",
label: "Hero",
icon: "fas fa-image",
schema: object("hero", "Hero", [
text("badge", "Badge", { maxLength: 40 }),
text("title", "Title", { maxLength: 90 }),
textarea("description", "Description", { maxLength: 220, rows: 4 }),
]),
},
{
key: "filters",
label: "Filters",
icon: "fas fa-filter",
schema: object("filters", "Filters", [
text("yearLabel", "Year Label", { maxLength: 30 }),
text("categoryLabel", "Category Label", { maxLength: 30 }),
text("buttonLabel", "Button Label", { maxLength: 30 }),
stringList("yearOptions", "Year Options", {
itemLabel: "Year Option",
maxLength: 30,
}),
stringList("categoryOptions", "Category Options", {
itemLabel: "Category Option",
maxLength: 40,
}),
]),
},
{
key: "timeline",
label: "Timeline",
icon: "fas fa-clock-rotate-left",
schema: object("timeline", "Timeline", [
text("loadMoreLabel", "Load More Label", { maxLength: 40 }),
objectList(
"items",
"Milestones",
[
text("id", "Milestone ID", { maxLength: 60 }),
text("year", "Year", { maxLength: 10 }),
text("yearRange", "Year Range", { maxLength: 30 }),
text("category", "Category", { maxLength: 40 }),
text("categoryLabel", "Category Label", { maxLength: 30 }),
text("title", "Title", { maxLength: 90 }),
textarea("description", "Description", {
maxLength: 260,
rows: 4,
}),
image("image", "Milestone Image", {
imageHint: "Recommended 436x190 px",
helpText: "Wide image used inside the milestone card.",
}),
text("imageAlt", "Image Alt Text", { maxLength: 120 }),
objectList(
"stats",
"Stats",
[
text("value", "Value", { maxLength: 24 }),
text("label", "Label", { maxLength: 50 }),
],
{ itemLabel: "Stat" },
),
checkbox("featured", "Featured"),
],
{ itemLabel: "Milestone" },
),
]),
},
],
},
accreditation: {
key: "accreditation",
title: "Accreditation Management",
subtitle: "Edit content displayed on the accreditation page",
routeBase: "/admin/accreditation",
apiPath: "/api/accreditation",
previewPath: "/about/accreditation",
dataFile: "accreditation",
imageType: "accreditation",
tabs: [
{
key: "trustBanner",
label: "Trust Banner",
icon: "fas fa-shield-check",
schema: object("trustBanner", "Trust Banner", [
icon("icon", "Icon"),
text("text", "Text", { maxLength: 120 }),
objectList(
"links",
"Links",
[
text("label", "Label", { maxLength: 40 }),
url("href", "URL", { maxLength: 255 }),
icon("icon", "Icon"),
],
{ itemLabel: "Link" },
),
]),
},
{
key: "hero",
label: "Hero",
icon: "fas fa-image",
schema: object("hero", "Hero", [
text("badge", "Badge", { maxLength: 40 }),
text("title", "Title", { maxLength: 60 }),
textarea("description", "Description", { maxLength: 420, rows: 5 }),
]),
},
{
key: "grid",
label: "Grid",
icon: "fas fa-table-cells-large",
schema: object("grid", "Grid", [
stringList("tabs", "Tabs", {
itemLabel: "Tab",
maxLength: 30,
}),
objectList(
"items",
"Items",
[
text("id", "Item ID", { maxLength: 50 }),
icon("icon", "Icon"),
image("image", "Image", {
imageHint: "Recommended 118x58 px minimum visible ratio",
helpText: "Logo or badge used at the top of the card.",
}),
text("status", "Status", { maxLength: 20 }),
text("category", "Category", { maxLength: 30 }),
text("title", "Title", {
maxLength: 17,
helpText: "Frontend title space is capped at 17 characters.",
}),
textarea("description", "Description", {
maxLength: 300,
rows: 5,
helpText:
"Frontend description preview is capped at 300 characters.",
}),
text("scopeLabel", "Scope Label", { maxLength: 20 }),
text("scope", "Scope", { maxLength: 60 }),
text("validUntilLabel", "Valid Until Label", { maxLength: 24 }),
text("validUntil", "Valid Until", { maxLength: 40 }),
text("buttonLabel", "Button Label", { maxLength: 30 }),
url("certificateHref", "Certificate URL", { maxLength: 255 }),
],
{ itemLabel: "Accreditation Item" },
),
]),
},
],
},
admissions: {
key: "admissions",
title: "Admissions Management",
subtitle: "Edit content displayed on the admissions page",
routeBase: "/admin/admissions",
apiPath: "/api/admissions",
previewPath: "/admissions",
dataFile: "admissions",
imageType: "admissions",
tabs: [
{
key: "hero",
label: "Hero",
icon: "fas fa-image",
schema: object("hero", "Hero", [
text("badge", "Badge", { maxLength: 40 }),
text("title", "Title", { maxLength: 80 }),
textarea("description", "Description", { maxLength: 240, rows: 4 }),
object("primaryCta", "Primary CTA", linkFields("Primary CTA")),
object("secondaryCta", "Secondary CTA", linkFields("Secondary CTA")),
image("image", "Hero Image", {
imageHint: "Recommended 720x646 px",
helpText: "Large image rendered in the right hero panel.",
}),
text("imageAlt", "Hero Image Alt Text", { maxLength: 120 }),
]),
},
{
key: "process",
label: "Process",
icon: "fas fa-list-ol",
schema: object("process", "Process", [
text("id", "Section ID", { maxLength: 40 }),
text("title", "Title", { maxLength: 60 }),
textarea("description", "Description", { maxLength: 180, rows: 3 }),
objectList(
"steps",
"Steps",
[
text("number", "Number", { maxLength: 8 }),
text("title", "Title", { maxLength: 50 }),
textarea("description", "Description", { maxLength: 180, rows: 3 }),
checkbox("active", "Active"),
],
{ itemLabel: "Step" },
),
]),
},
{
key: "eligibility",
label: "Eligibility",
icon: "fas fa-check-circle",
schema: object("eligibility", "Eligibility", [
text("id", "Section ID", { maxLength: 40 }),
text("title", "Title", { maxLength: 60 }),
objectList(
"cards",
"Cards",
[
text("title", "Title", { maxLength: 50 }),
icon("icon", "Icon"),
stringList("items", "Items", {
itemLabel: "Bullet Item",
maxLength: 120,
}),
],
{ itemLabel: "Card" },
),
]),
},
{
key: "tuition",
label: "Tuition",
icon: "fas fa-chart-column",
schema: object("tuition", "Tuition", [
text("id", "Section ID", { maxLength: 40 }),
text("title", "Title", { maxLength: 60 }),
text("chartTitle", "Chart Title", { maxLength: 60 }),
textarea("chartDescription", "Chart Description", {
maxLength: 140,
rows: 3,
}),
objectList(
"series",
"Chart Series",
[
text("label", "Label", { maxLength: 40 }),
{ key: "color", label: "Color", type: "color" },
stringList("values", "Values", {
itemLabel: "Point",
fieldType: "number",
}),
],
{ itemLabel: "Series" },
),
]),
},
{
key: "keyDates",
label: "Key Dates",
icon: "fas fa-calendar-days",
schema: object("keyDates", "Key Dates", [
text("id", "Section ID", { maxLength: 40 }),
text("title", "Title", { maxLength: 60 }),
stringList("columns", "Columns", {
itemLabel: "Column",
maxLength: 40,
}),
objectList(
"rows",
"Rows",
[
text("term", "Term", { maxLength: 40 }),
text("applicationDeadline", "Application Deadline", {
maxLength: 40,
}),
text("classesStart", "Classes Start", { maxLength: 40 }),
],
{ itemLabel: "Row" },
),
]),
},
{
key: "calculator",
label: "Calculator",
icon: "fas fa-calculator",
schema: object("calculator", "Calculator", [
text("title", "Title", { maxLength: 60 }),
textarea("description", "Description", { maxLength: 120, rows: 3 }),
stringList("modelOptions", "Model Options", {
itemLabel: "Option",
maxLength: 30,
}),
text("paceLabel", "Pace Label", { maxLength: 30 }),
text("minPaceLabel", "Min Pace Label", { maxLength: 20 }),
text("maxPaceLabel", "Max Pace Label", { maxLength: 20 }),
text("resultLabel", "Result Label", { maxLength: 40 }),
text("monthlyAmount", "Monthly Amount", { maxLength: 20 }),
text("monthlySuffix", "Monthly Suffix", { maxLength: 10 }),
icon("noteIcon", "Note Icon"),
text("note", "Note", { maxLength: 60 }),
object("cta", "CTA", linkFields("CTA")),
]),
},
{
key: "scholarships",
label: "Scholarships",
icon: "fas fa-award",
schema: object("scholarships", "Scholarships", [
text("title", "Title", { maxLength: 50 }),
icon("icon", "Icon"),
objectList(
"items",
"Scholarship Items",
[
text("title", "Title", { maxLength: 50 }),
text("amount", "Amount", { maxLength: 24 }),
textarea("description", "Description", { maxLength: 160, rows: 3 }),
],
{ itemLabel: "Scholarship Item" },
),
]),
},
],
},
policies: {
key: "policies",
title: "Policies Management",
subtitle: "Edit content displayed on the policies page",
routeBase: "/admin/policies",
apiPath: "/api/policies",
previewPath: "/policies",
dataFile: "policies",
imageType: "policies",
tabs: [
{
key: "hero",
label: "Hero",
icon: "fas fa-scale-balanced",
schema: object("hero", "Hero", [
text("badge", "Badge", { maxLength: 40 }),
icon("icon", "Icon", {
helpText: "Policies uses icon-only controls and does not require image upload.",
}),
text("titlePrefix", "Title Prefix", { maxLength: 50 }),
text("titleHighlight", "Title Highlight", { maxLength: 40 }),
textarea("description", "Description", { maxLength: 220, rows: 4 }),
text("lastUpdated", "Last Updated Label", { maxLength: 50 }),
]),
},
{
key: "sidebar",
label: "Sidebar",
icon: "fas fa-bars",
schema: object("sidebar", "Sidebar", [
text("heading", "Heading", { maxLength: 30 }),
text("helperText", "Helper Text", { maxLength: 60 }),
text("contactLabel", "Contact Label", { maxLength: 40 }),
url("contactHref", "Contact URL", { maxLength: 255 }),
]),
},
{
key: "policies",
label: "Policies",
icon: "fas fa-file-lines",
schema: objectList(
"policies",
"Policies",
[
text("id", "Policy ID", { maxLength: 40 }),
text("navLabel", "Navigation Label", { maxLength: 40 }),
text("title", "Title", { maxLength: 70 }),
text("effectiveDate", "Effective Date", { maxLength: 50 }),
textarea("intro", "Intro", { maxLength: 260, rows: 4 }),
variantList(
"sections",
"Sections",
{
text: {
label: "Text Section",
schema: object("section", "Text Section", [
text("heading", "Heading", { maxLength: 60 }),
objectList(
"paragraphs",
"Paragraphs",
[
textarea("text", "Text", {
maxLength: 500,
rows: 4,
}),
objectList(
"links",
"Links",
[
text("label", "Label", { maxLength: 50 }),
url("href", "URL", { maxLength: 255 }),
text("tabId", "Target Policy ID", {
maxLength: 40,
helpText:
"Use this when the link should switch to another policy tab.",
}),
],
{ itemLabel: "Link" },
),
],
{ itemLabel: "Paragraph" },
),
]),
},
list: {
label: "List Section",
schema: object("section", "List Section", [
text("heading", "Heading", { maxLength: 60 }),
textarea("intro", "Intro", { maxLength: 220, rows: 3 }),
stringList("items", "Items", {
itemLabel: "List Item",
maxLength: 180,
fieldType: "textarea",
}),
]),
},
cards: {
label: "Cards Section",
schema: object("section", "Cards Section", [
objectList(
"cards",
"Cards",
[
icon("icon", "Icon"),
text("title", "Title", { maxLength: 50 }),
textarea("description", "Description", {
maxLength: 220,
rows: 4,
}),
object("link", "Link", [
text("label", "Label", { maxLength: 50 }),
url("href", "URL", { maxLength: 255 }),
text("tabId", "Target Policy ID", { maxLength: 40 }),
]),
],
{ itemLabel: "Card" },
),
]),
},
},
{ itemLabel: "Section" },
),
],
{ itemLabel: "Policy" },
),
},
],
},
};
+73
View File
@@ -0,0 +1,73 @@
<div class="container">
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
<div>
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
<p class="text-muted mb-0"><%= subtitle %></p>
</div>
<div>
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>View Page
</a>
</div>
</div>
<div class="row">
<div class="col-12">
<form
action="<%= editorConfig.routeBase %>/update"
method="POST"
id="pageContentForm"
class="content-with-fixed-buttons"
>
<input type="hidden" name="pageJson" id="pageJson" />
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
<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">
<% editorConfig.tabs.forEach((tab) => { %>
<li class="nav-item">
<a
class="nav-link <%= activeTab === tab.key ? 'active' : '' %>"
data-bs-toggle="tab"
href="#<%= tab.key %>"
role="tab"
data-tab-key="<%= tab.key %>"
>
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
</a>
</li>
<% }) %>
</ul>
</div>
<div class="card-body">
<div class="tab-content">
<% editorConfig.tabs.forEach((tab) => { %>
<%- include("partials/tab-pane", { tab, activeTab }) %>
<% }) %>
</div>
</div>
</div>
<div class="fixed-bottom-buttons">
<button type="reset" class="btn btn-secondary">
<i class="fas fa-undo"></i>
<span>Reset</span>
</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
<span>Save Changes</span>
</button>
</div>
</form>
</div>
</div>
</div>
<script>
window.pageEditorConfig = <%- JSON.stringify(editorConfig) %>;
window.pageEditorData = <%- JSON.stringify(data) %>;
window.pageEditorBackendUrl = <%- JSON.stringify(backendUrl) %>;
</script>
<script src="/js/page-content-editor.js"></script>
@@ -0,0 +1,12 @@
<div class="tab-pane fade <%= activeTab === tab.key ? 'show active' : '' %>" id="<%= tab.key %>" role="tabpanel">
<div class="card border shadow-sm">
<div class="card-header bg-white">
<h6 class="mb-0">
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
</h6>
</div>
<div class="card-body p-4">
<div class="page-editor-section" data-section-key="<%= tab.key %>"></div>
</div>
</div>
</div>
+14 -4
View File
@@ -720,9 +720,19 @@
</li> </li>
</ul> </ul>
</li> </li>
<li class="nav-item"> <li class="nav-item dropdown">
<a class="nav-link <%= currentPath === '/admin/about-us' ? 'active' : '' %>" <a class="nav-link dropdown-toggle <%= ['/admin/about-us','/admin/partnerships','/admin/history','/admin/accreditation','/admin/admissions','/admin/policies'].includes(currentPath) ? 'active' : '' %>"
href="/admin/about-us">About</a> href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
About
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item <%= currentPath === '/admin/about-us' ? 'active' : '' %>" href="/admin/about-us">About Us</a></li>
<li><a class="dropdown-item <%= currentPath === '/admin/partnerships' ? 'active' : '' %>" href="/admin/partnerships">Partnerships</a></li>
<li><a class="dropdown-item <%= currentPath === '/admin/history' ? 'active' : '' %>" href="/admin/history">History</a></li>
<li><a class="dropdown-item <%= currentPath === '/admin/accreditation' ? 'active' : '' %>" href="/admin/accreditation">Accreditation</a></li>
<li><a class="dropdown-item <%= currentPath === '/admin/admissions' ? 'active' : '' %>" href="/admin/admissions">Admissions</a></li>
<li><a class="dropdown-item <%= currentPath === '/admin/policies' ? 'active' : '' %>" href="/admin/policies">Policies</a></li>
</ul>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link <%= currentPath === '/admin/service' ? 'active' : '' %>" <a class="nav-link <%= currentPath === '/admin/service' ? 'active' : '' %>"
@@ -1149,4 +1159,4 @@
<%- script %> <%- script %>
</body> </body>
</html> </html>