diff --git a/controllers/_renderSingletonPageView.js b/controllers/_renderSingletonPageView.js
index 3567858..e70650d 100644
--- a/controllers/_renderSingletonPageView.js
+++ b/controllers/_renderSingletonPageView.js
@@ -11,6 +11,11 @@ function createRenderSingletonPageView({
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl =
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
+ const defaultTab = editorConfig.tabs[0]?.key;
+ const requestedTab = req.query.tab;
+ const activeTab = editorConfig.tabs.some((tab) => tab.key === requestedTab)
+ ? requestedTab
+ : defaultTab;
return res.render(view, {
layout: "layouts/main",
@@ -18,7 +23,7 @@ function createRenderSingletonPageView({
subtitle: editorConfig.subtitle,
data,
editorConfig,
- activeTab: req.query.tab || editorConfig.tabs[0].key,
+ activeTab,
frontendUrl,
backendUrl,
previewUrl: `${frontendUrl}${editorConfig.previewPath}`,
diff --git a/controllers/admissionsController.js b/controllers/admissionsController.js
index e6ab901..1bc6c42 100644
--- a/controllers/admissionsController.js
+++ b/controllers/admissionsController.js
@@ -3,12 +3,101 @@ const AUDIT_ACTIONS = require("../constants/auditAction");
const admissionsConfig = require("../utils/contentEditors/admissionsConfig");
const createPageContentController = require("./_createPageContentController");
const createRenderSingletonPageView = require("./_renderSingletonPageView");
+const jsonHelper = require("../utils/jsonHelper");
+const writeAuditLog = require("../audit/writeAuditLog");
+const diffObject = require("../audit/diffObject");
+const { ensureUniqueIds } = require("../utils/contentEditorIds");
+const { ICON_OPTIONS } = require("../utils/contentEditors/sharedFields");
+
+function normalizePositiveAmount(value, fallback = "1") {
+ const match = String(value || "").match(/\d[\d,]*/);
+ const numericValue = Number((match ? match[0] : "").replace(/,/g, ""));
+ return numericValue > 0 ? String(numericValue) : fallback;
+}
+
+function normalizeCalculatorOption(option, index, calculator) {
+ const source = typeof option === "string" ? { label: option } : { ...(option || {}) };
+
+ return {
+ ...source,
+ label: String(source.label || source.title || `Option ${index + 1}`).slice(0, 12),
+ paceLabel: String(source.paceLabel || calculator.paceLabel || "Target Pace"),
+ minPaceLabel: String(source.minPaceLabel || calculator.minPaceLabel || "Relaxed"),
+ maxPaceLabel: String(source.maxPaceLabel || calculator.maxPaceLabel || "Accelerated"),
+ resultLabel: String(source.resultLabel || calculator.resultLabel || "Estimated Monthly Payment"),
+ monthlyAmount: normalizePositiveAmount(source.monthlyAmount || calculator.monthlyAmount || "299", "299"),
+ monthlySuffix: String(source.monthlySuffix || calculator.monthlySuffix || "/mo"),
+ noteIcon: String(source.noteIcon || calculator.noteIcon || "fa-bolt"),
+ note: String(source.note || calculator.note || ""),
+ };
+}
+
+function normalizeCalculator(calculator) {
+ const nextCalculator = { ...(calculator || {}) };
+ const legacyOptions = Array.isArray(nextCalculator.modelOptions) ? nextCalculator.modelOptions : [];
+ const rawOptions = Array.isArray(nextCalculator.options) && nextCalculator.options.length
+ ? nextCalculator.options
+ : legacyOptions;
+
+ nextCalculator.title = String(nextCalculator.title || "");
+ nextCalculator.description = String(nextCalculator.description || "");
+ nextCalculator.cta = {
+ label: String(nextCalculator?.cta?.label || "").slice(0, 15),
+ href: String(nextCalculator?.cta?.href || ""),
+ };
+
+ nextCalculator.options = ensureUniqueIds(
+ rawOptions.slice(0, 3).map((option, index) => normalizeCalculatorOption(option, index, nextCalculator)),
+ (item) => item.id,
+ (item) => item.label,
+ "calculator-option",
+ );
+
+ delete nextCalculator.modelOptions;
+ delete nextCalculator.paceLabel;
+ delete nextCalculator.minPaceLabel;
+ delete nextCalculator.maxPaceLabel;
+ delete nextCalculator.resultLabel;
+ delete nextCalculator.monthlyAmount;
+ delete nextCalculator.monthlySuffix;
+ delete nextCalculator.noteIcon;
+ delete nextCalculator.note;
+
+ return nextCalculator;
+}
+
+function normalizeAdmissionsPayload(rawPayload) {
+ const payload = JSON.parse(JSON.stringify(rawPayload || {}));
+
+ payload.process = {
+ ...(payload.process || {}),
+ id: "admissions-process",
+ };
+ payload.eligibility = {
+ ...(payload.eligibility || {}),
+ id: "eligibility",
+ };
+ payload.tuition = {
+ ...(payload.tuition || {}),
+ id: "tuition-breakdown",
+ };
+ payload.keyDates = {
+ ...(payload.keyDates || {}),
+ id: "key-dates",
+ };
+ payload.calculator = normalizeCalculator(payload.calculator);
+
+ return payload;
+}
const controller = createPageContentController({
model: AdmissionsPage,
modelName: "AdmissionsPage",
auditAction: AUDIT_ACTIONS.UPDATE_ADMISSIONS,
editorConfig: admissionsConfig,
+ preparePayload: normalizeAdmissionsPayload,
+ normalizeForEditor: normalizeAdmissionsPayload,
+ normalizeForApi: normalizeAdmissionsPayload,
});
controller.index = async function index(req, res) {
@@ -17,6 +106,7 @@ controller.index = async function index(req, res) {
model: AdmissionsPage,
editorConfig: admissionsConfig,
view: "admin/admissions/index",
+ normalizeForEditor: normalizeAdmissionsPayload,
})(req, res);
} catch (error) {
console.error("admissions index error:", error);
@@ -25,4 +115,100 @@ controller.index = async function index(req, res) {
}
};
+controller.editCalculatorOption = async function editCalculatorOption(req, res) {
+ try {
+ const optionId = String(req.params.optionId || "");
+ const doc = await AdmissionsPage.getSingle();
+ const data = normalizeAdmissionsPayload(doc.toObject());
+ const option = data.calculator.options.find((item) => item.id === optionId);
+
+ if (!option) {
+ req.flash("error_msg", "Calculator option not found");
+ return req.session.save(() => res.redirect("/admin/admissions?tab=calculator"));
+ }
+
+ const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
+ const backendUrl =
+ process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
+
+ return res.render("admin/admissions/calculator-option", {
+ layout: "layouts/main",
+ title: `Edit ${option.label}`,
+ subtitle: "Update the calculator option details",
+ option,
+ iconOptions: ICON_OPTIONS,
+ editorConfig: admissionsConfig,
+ previewUrl: `${frontendUrl}${admissionsConfig.previewPath}`,
+ currentPath: req.path,
+ backendUrl,
+ user: req.session.user,
+ });
+ } catch (error) {
+ console.error("admissions calculator option index error:", error);
+ req.flash("error_msg", "Error loading calculator option");
+ return req.session.save(() => res.redirect("/admin/admissions?tab=calculator"));
+ }
+};
+
+controller.updateCalculatorOption = async function updateCalculatorOption(req, res) {
+ try {
+ const optionId = String(req.params.optionId || "");
+ const doc = await AdmissionsPage.getSingle();
+ const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
+ const payload = normalizeAdmissionsPayload(beforeData);
+ const optionIndex = payload.calculator.options.findIndex((item) => item.id === optionId);
+
+ if (optionIndex === -1) {
+ req.flash("error_msg", "Calculator option not found");
+ return req.session.save(() => res.redirect("/admin/admissions?tab=calculator"));
+ }
+
+ payload.calculator.options[optionIndex] = {
+ ...payload.calculator.options[optionIndex],
+ label: String(req.body.label || "").trim().slice(0, 12),
+ paceLabel: String(req.body.paceLabel || "").trim(),
+ minPaceLabel: String(req.body.minPaceLabel || "").trim(),
+ maxPaceLabel: String(req.body.maxPaceLabel || "").trim(),
+ resultLabel: String(req.body.resultLabel || "").trim(),
+ monthlyAmount: normalizePositiveAmount(req.body.monthlyAmount, payload.calculator.options[optionIndex].monthlyAmount || "1"),
+ monthlySuffix: String(req.body.monthlySuffix || "").trim(),
+ noteIcon: String(req.body.noteIcon || "").trim(),
+ note: String(req.body.note || "").trim(),
+ };
+
+ const normalizedPayload = normalizeAdmissionsPayload(payload);
+ doc.set(normalizedPayload);
+ Object.keys(normalizedPayload).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: "AdmissionsPage",
+ documentId: doc._id,
+ action: AUDIT_ACTIONS.UPDATE_ADMISSIONS,
+ before: beforeData,
+ after: afterData,
+ changes,
+ req,
+ });
+ }
+
+ const finalData = await AdmissionsPage
+ .findOne()
+ .select("-_id -__v -createdAt -updatedAt")
+ .lean();
+ jsonHelper.writeJsonFile(admissionsConfig.dataFile, normalizeAdmissionsPayload(finalData));
+
+ req.flash("success_msg", "Calculator option updated successfully");
+ return req.session.save(() => res.redirect(`/admin/admissions/calculator/${optionId}`));
+ } catch (error) {
+ console.error("admissions calculator option update error:", error);
+ req.flash("error_msg", `Error updating calculator option: ${error.message}`);
+ return req.session.save(() => res.redirect(`/admin/admissions/calculator/${req.params.optionId}`));
+ }
+};
+
module.exports = controller;
diff --git a/controllers/historyPageController.js b/controllers/historyPageController.js
index e4f004b..794418b 100644
--- a/controllers/historyPageController.js
+++ b/controllers/historyPageController.js
@@ -3,12 +3,109 @@ const AUDIT_ACTIONS = require("../constants/auditAction");
const historyConfig = require("../utils/contentEditors/historyConfig");
const createPageContentController = require("./_createPageContentController");
const createRenderSingletonPageView = require("./_renderSingletonPageView");
+const { ensureUniqueIds } = require("../utils/contentEditorIds");
+
+function getFrontendUrl(req) {
+ return (process.env.FRONTEND_URL || "http://localhost:3000").replace(/\/$/, "");
+}
+
+function normalizeInternalHistoryHref(rawHref, req) {
+ const href = String(rawHref || "").trim();
+
+ if (!href) {
+ return "";
+ }
+
+ if (href.startsWith("#")) {
+ return href;
+ }
+
+ if (href.startsWith("/")) {
+ return href;
+ }
+
+ const frontendUrl = getFrontendUrl(req);
+
+ try {
+ const url = new URL(href);
+ const frontendOrigin = new URL(frontendUrl).origin;
+
+ if (url.origin !== frontendOrigin) {
+ throw new Error("Highlight link only supports internal anchors or frontend paths.");
+ }
+
+ return `${url.pathname}${url.search}${url.hash}` || "/";
+ } catch (error) {
+ if (href.startsWith("http://") || href.startsWith("https://")) {
+ throw new Error("Highlight link only supports internal anchors or frontend paths.");
+ }
+
+ return href.startsWith("?") ? `/about/history${href}` : `/${href.replace(/^\/+/, "")}`;
+ }
+}
+
+function normalizeHistoryForApi(rawData, req) {
+ const data = JSON.parse(JSON.stringify(rawData || {}));
+ const href = data?.highlight?.href;
+ const frontendUrl = getFrontendUrl(req);
+ const backendUrl = `${req.protocol}://${req.get("host")}`.replace(/\/$/, "");
+
+ if (!href || href.startsWith("#")) {
+ return data;
+ }
+
+ if (/^https?:\/\//i.test(href)) {
+ try {
+ const url = new URL(href);
+ const frontendOrigin = new URL(frontendUrl).origin;
+ const backendOrigin = new URL(backendUrl).origin;
+
+ if (url.origin === frontendOrigin) {
+ data.highlight.href = `${frontendUrl}${url.pathname}${url.search}${url.hash}`;
+ return data;
+ }
+
+ if (url.origin === backendOrigin) {
+ data.highlight.href = `${frontendUrl}/about/history${url.hash || ""}`;
+ return data;
+ }
+ } catch {
+ data.highlight.href = `${frontendUrl}/about/history`;
+ return data;
+ }
+
+ data.highlight.href = `${frontendUrl}/about/history`;
+ return data;
+ }
+
+ data.highlight.href = `${frontendUrl}${href.startsWith("/") ? href : `/${href}`}`;
+ return data;
+}
const controller = createPageContentController({
model: HistoryPage,
modelName: "HistoryPage",
auditAction: AUDIT_ACTIONS.UPDATE_HISTORY,
editorConfig: historyConfig,
+ preparePayload(rawPayload, { req }) {
+ const payload = JSON.parse(JSON.stringify(rawPayload || {}));
+
+ if (payload.highlight) {
+ payload.highlight.href = normalizeInternalHistoryHref(payload.highlight.href, req);
+ }
+
+ if (payload.timeline && Array.isArray(payload.timeline.items)) {
+ payload.timeline.items = ensureUniqueIds(
+ payload.timeline.items,
+ (item) => item.id,
+ (item, index) => item.title || item.year || `milestone-${index + 1}`,
+ "milestone",
+ );
+ }
+
+ return payload;
+ },
+ normalizeForApi: normalizeHistoryForApi,
});
controller.index = async function index(req, res) {
diff --git a/controllers/partnershipsController.js b/controllers/partnershipsController.js
index 6d3ccff..40723d1 100644
--- a/controllers/partnershipsController.js
+++ b/controllers/partnershipsController.js
@@ -3,6 +3,7 @@ const AUDIT_ACTIONS = require("../constants/auditAction");
const partnershipsConfig = require("../utils/contentEditors/partnershipsConfig");
const createPageContentController = require("./_createPageContentController");
const createRenderSingletonPageView = require("./_renderSingletonPageView");
+const { ensureUniqueIds } = require("../utils/contentEditorIds");
function toInquiryField(id, field, type, width) {
return {
@@ -52,17 +53,30 @@ function prepareInquiryPayload(payload) {
const fields = Array.isArray(normalized?.inquiryForm?.fields)
? normalized.inquiryForm.fields
: [];
+ const partners = Array.isArray(normalized?.directory?.partners)
+ ? normalized.directory.partners
+ : [];
return {
...normalized,
+ directory: {
+ ...normalized.directory,
+ partners: ensureUniqueIds(
+ partners,
+ (partner) => partner.id,
+ (partner, index) => partner.name || partner.category || `partner-${index + 1}`,
+ "partner",
+ ),
+ },
inquiryForm: {
...normalized.inquiryForm,
- fields: fields.map((field, index) => ({
- id:
- String(field.id || `field-${index + 1}`)
- .trim()
- .replace(/\s+/g, "")
- .replace(/[^a-zA-Z0-9_-]/g, "") || `field-${index + 1}`,
+ fields: ensureUniqueIds(
+ fields,
+ (field) => field.id,
+ (field, index) => field.label || field.placeholder || `field-${index + 1}`,
+ "field",
+ ).map((field) => ({
+ id: field.id,
label: field.label || "",
placeholder: field.placeholder || "",
type: field.type || "text",
diff --git a/controllers/policiesController.js b/controllers/policiesController.js
index c7cd580..2428991 100644
--- a/controllers/policiesController.js
+++ b/controllers/policiesController.js
@@ -9,6 +9,7 @@ const createRenderSingletonPageView = require("./_renderSingletonPageView");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const jsonHelper = require("../utils/jsonHelper");
+const { ensureUniqueIds } = require("../utils/contentEditorIds");
function formatLastUpdated(date = new Date()) {
return `Last updated: ${new Intl.DateTimeFormat("en-US", {
@@ -18,9 +19,26 @@ function formatLastUpdated(date = new Date()) {
}).format(date)}`;
}
-function withLastUpdated(payload) {
+function withLastUpdated(payload, { beforeData } = {}) {
+ const existingPolicies = Array.isArray(beforeData?.policies) ? beforeData.policies : [];
+
+ const policies = ensureUniqueIds(
+ Array.isArray(payload.policies) ? payload.policies : [],
+ (policy) => policy.id,
+ (policy, index) => policy.navLabel || policy.title || `policy-${index + 1}`,
+ "policy",
+ ).map((policy) => {
+ const existingPolicy = existingPolicies.find((item) => item.id === policy.id);
+
+ return {
+ ...policy,
+ sections: Array.isArray(existingPolicy?.sections) ? existingPolicy.sections : [],
+ };
+ });
+
return {
...payload,
+ policies,
hero: {
...(payload.hero || {}),
lastUpdated: formatLastUpdated(),
diff --git a/data/accreditation.json b/data/accreditation.json
index ba4ddc5..a8e498c 100644
--- a/data/accreditation.json
+++ b/data/accreditation.json
@@ -1,20 +1,4 @@
{
- "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": "Accreditation Save Check",
"title": "ACCREDITATION AND RECOGNITION",
@@ -91,4 +75,4 @@
}
]
}
-}
\ No newline at end of file
+}
diff --git a/data/admissions.json b/data/admissions.json
index 1dc456d..30dd3d5 100644
--- a/data/admissions.json
+++ b/data/admissions.json
@@ -1,29 +1,29 @@
{
"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.",
+ "badge": "Your Path Starts HereYour Path Starts He",
+ "title": "Admissions & Transparent TuitionAdmissions & Transparent TuitionAdmissions & Tra",
+ "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.We believe high-quality education should be accessible to everyone. Exp",
"primaryCta": {
- "label": "Start Application",
+ "label": "Start ApplicationStart ApplicationStart ApplicationStart App",
"href": "http://localhost:3001/admin/admissions"
},
"secondaryCta": {
- "label": "View Tuition",
+ "label": "View TuitionView TuitionView TuitionView TuitionView Tuition",
"href": "http://localhost:3001/admin/admissions"
},
- "image": "/uploads/admissions/hero-students.png",
- "imageAlt": "Diverse adult students studying online"
+ "image": "/uploads/admissions/Colorful_Square_Background.png",
+ "imageAlt": "View TuitionView TuitionView TuitionView TuitionView TuitionView TuitionView TuitionView TuitionView TuitionView Tuition"
},
"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.",
+ "title": "Admissions ProcessAdmissions ProcessAdmissions ProcessAdmiss",
+ "description": "Our streamlined process gets you from application to enrolled in days, not months. No application fees, no standardized tests.Our streamlined process gets you from application to e",
"steps": [
{
"number": "01",
"title": "Send Transcripts",
"description": "Request official transcripts from previous institutions for credit evaluation.",
- "active": false
+ "active": true
},
{
"number": "02",
@@ -41,52 +41,53 @@
},
"eligibility": {
"id": "eligibility",
- "title": "Eligibility & Transfer Credits",
+ "title": "Eligibility & Transfer CreditsEligibility & Transfer Credits",
"cards": [
{
- "title": "Basic Eligibility",
+ "title": "Basic EligibilityBasic EligibilityBasic Eligibilit",
"icon": "fa-check-circle",
"items": [
- "High school diploma or equivalent",
- "Minimum 2.0 GPA for transfer students",
- "English proficiency if applicable"
+ "High school diploma or equivalentHigh school diploma or equivalentHigh school diploma or equivalentHigh school diploma o",
+ "Minimum 2.0 GPA for transfer studentsMinimum 2.0 GPA for transfer studentsMinimum 2.0 GPA for transfer studentsMinimum 2",
+ "English proficiency if applicableEnglish proficiency if applicableEnglish proficiency if applicableEnglish proficiency i"
]
},
{
- "title": "Transfer Policy",
+ "title": "Transfer PolicyTransfer PolicyTransfer PolicyTrans",
"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"
+ "Up to 90 credits accepted for Bachelor'sUp to 90 credits accepted for Bachelor'sUp to 90 credits accepted for Bachelor's",
+ "Free unofficial evaluation within 48 hoursFree unofficial evaluation within 48 hoursFree unofficial evaluation within 48",
+ "Credit for prior learning and certificationsCredit for prior learning and certificationsCredit for prior learning and ce"
]
}
]
},
"tuition": {
"id": "tuition-breakdown",
- "title": "Tuition Breakdown",
- "chartTitle": "Savings vs. Traditional University",
- "chartDescription": "Estimated total cost for a 4-year degree",
+ "title": "Tuition BreakdownTuition BreakdownTuition BreakdownTuition B",
+ "chartTitle": "Savings vs. Traditional UniversitySavings vs. Traditional Un",
+ "chartDescription": "Estimated total cost for a 4-year degreeEstimated total cost for a 4-year degreeEstimated total cost for a 4-year degreeEstimated total cost",
"series": [
{
"label": "Traditional University",
- "color": "#0F172A",
+ "color": "#850f0f",
"values": [
25000,
- 50000,
- 75000,
- 100000
+ 49998,
+ 30000,
+ 100000,
+ 120000
]
},
{
"label": "LAMS",
- "color": "#c49b27",
+ "color": "#a700b3",
"values": [
3588,
7176,
10764,
- 14352
+ 10000
]
}
]
@@ -95,61 +96,112 @@
"id": "key-dates",
"title": "Key Dates & Deadlines",
"columns": [
- "Term",
- "Application Deadline",
- "Classes Start"
+ {
+ "id": "Term",
+ "label": "Term"
+ },
+ {
+ "id": "Application-Deadline",
+ "label": "Application Deadline"
+ },
+ {
+ "id": "Classes-Start",
+ "label": "Classes Start"
+ },
+ {
+ "id": "column-4",
+ "label": "Column 4"
+ }
],
"rows": [
{
- "term": "Fall Term 1",
- "applicationDeadline": "August 15, 2026",
- "classesStart": "September 1, 2026"
+ "id": "row-1",
+ "cells": [
+ "Fall Term 1",
+ "August 15, 2026August 15, 2026August 15, 2026August 15, 2026",
+ "September 1, 2026September 1, 2026September 1, 2026September",
+ "September 1, 2026September 1, 2026September 1, 2026September"
+ ]
},
{
- "term": "Fall Term 2",
- "applicationDeadline": "October 15, 2026",
- "classesStart": "November 1, 2026"
+ "id": "row-2",
+ "cells": [
+ "Fall Term 2",
+ "October 15, 2026October 15, 2026October 15, 2026October 15, ",
+ "November 1, 2026November 1, 2026November 1, 2026November 1, ",
+ "September 1, 2026September 1, 2026September 1, 2026September"
+ ]
},
{
- "term": "Spring Term 1",
- "applicationDeadline": "December 15, 2026",
- "classesStart": "January 5, 2027"
+ "id": "row-3",
+ "cells": [
+ "Spring Term 1",
+ "December 15, 2026December 15, 2026December 15, 2026December ",
+ "January 5, 2027",
+ "September 1, 2026September 1, 2026September 1, 2026September"
+ ]
}
]
},
"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"
- }
+ },
+ "options": [
+ {
+ "id": "subscription",
+ "label": "Subscription",
+ "paceLabel": "Target PaceTarget PaceTarget P",
+ "minPaceLabel": "RelaxedRelaxedRelaxe",
+ "maxPaceLabel": "AcceleratedAccelerat",
+ "resultLabel": "Estimated Monthly PaymentEstimated Month",
+ "monthlyAmount": "5000000",
+ "monthlySuffix": "/mo",
+ "noteIcon": "fa-bolt",
+ "note": "Flat rate, unlimited coursesFlat rate, unlimited coursesFlat"
+ },
+ {
+ "id": "per-course",
+ "label": "Per Course",
+ "paceLabel": "Target Pace",
+ "minPaceLabel": "Relaxed",
+ "maxPaceLabel": "Accelerated",
+ "resultLabel": "Estimated Monthly Payment",
+ "monthlyAmount": "500",
+ "monthlySuffix": "/mo",
+ "noteIcon": "fa-bolt",
+ "note": "Flat rate, unlimited courses"
+ },
+ {
+ "id": "d-course",
+ "label": "D Course",
+ "paceLabel": "Target Pace",
+ "minPaceLabel": "Relaxed",
+ "maxPaceLabel": "Accelerated",
+ "resultLabel": "Estimated Monthly Payment",
+ "monthlyAmount": "500",
+ "monthlySuffix": "/mo",
+ "noteIcon": "fa-bolt",
+ "note": "Flat rate, unlimited courses"
+ }
+ ]
},
"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": "Working Adult GrantWorking Adult GrantWorking Adul",
+ "amount": "Up to $1,500Up to $1,500",
+ "description": "For students employed full-time while studying.For students employed full-time while studying.For students employed full-time while studying.For students employ"
},
{
"title": "Military Discount",
"amount": "15% Off",
- "description": "Active duty, veterans, and spouses."
+ "description": "For students employed full-time while studying.For students employed full-time while studying.For students employed full-time while studying.For students employ"
}
]
}
diff --git a/data/history.json b/data/history.json
index f7fbd2d..cef9de3 100644
--- a/data/history.json
+++ b/data/history.json
@@ -1,24 +1,22 @@
{
"highlight": {
- "icon": "fa-trophy",
- "text": "History Save Check",
- "linkLabel": "Read Full Story",
- "href": "#timeline-content"
+ "icon": "fa-magnifying-glass",
+ "text": "2025 Milestone Reached: A Rapidly Growing Global Community!2025 Milestone Reached: A Rapidly Growing Global Co",
+ "linkLabel": "Read Full StoryRead Full Story",
+ "href": "/admin/history"
},
"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."
+ "badge": "Our JourneyOur JourneyOur JourneyOur Jou",
+ "title": "Building the Future of Education.Building the Future of Education.Building the Future of E",
+ "description": "From our humble beginnings to becoming a global leader in online education, explore the key moments that define our legacy.From our humble beginnings to becoming a global leader in online education, explore the key momen"
},
"filters": {
- "yearLabel": "Decade / Year",
- "categoryLabel": "Category",
- "buttonLabel": "Apply Filters",
"yearOptions": [
"All Years",
"2020 - Present",
"2010 - 2019",
- "2005 - 2009"
+ "2005 - 2009",
+ "2015 - 2019"
],
"categoryOptions": [
"All Categories",
@@ -30,7 +28,6 @@
]
},
"timeline": {
- "loadMoreLabel": "Load Earlier Milestones",
"items": [
{
"id": "student-experience-innovation",
@@ -40,7 +37,7 @@
"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",
+ "image": "/uploads/history/Colorful_Square_Background.png",
"imageAlt": "",
"stats": [],
"featured": true
@@ -53,7 +50,7 @@
"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",
+ "image": "/uploads/history/7281.jpg",
"imageAlt": "",
"stats": [],
"featured": false
@@ -92,11 +89,24 @@
"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",
+ "image": "/uploads/history/7281.jpg",
"imageAlt": "",
"stats": [],
"featured": false
+ },
+ {
+ "id": "strategic-academic-framework",
+ "year": "2027",
+ "yearRange": "2020 - Present",
+ "category": "All Categories",
+ "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/kVI17_2B.webp",
+ "imageAlt": "aaa",
+ "stats": [],
+ "featured": true
}
]
}
-}
\ No newline at end of file
+}
diff --git a/data/partnerships.json b/data/partnerships.json
index 631092a..e3790bd 100644
--- a/data/partnerships.json
+++ b/data/partnerships.json
@@ -18,7 +18,6 @@
"hehehehehehehehehehehehehehehe",
"hehehehehehehhehehehehehehhehe"
],
- "loadMoreLabel": "Load More PartnersLoad More PartnersLoad",
"partners": [
{
"id": "techvanguardtechvanguardtechvanguardtechvanguardte",
@@ -102,7 +101,7 @@
"placeholder": "Partnership InquiryPartnership InquiryPa",
"type": "text",
"width": "half",
- "required": true,
+ "required": false,
"options": []
},
{
@@ -146,7 +145,6 @@
"required": true,
"options": []
}
- ],
- "submitLabel": "Partnership InquiryPartnership InquiryPa"
+ ]
}
-}
\ No newline at end of file
+}
diff --git a/data/policies.json b/data/policies.json
index 75c8dde..2ff9360 100644
--- a/data/policies.json
+++ b/data/policies.json
@@ -5,7 +5,7 @@
"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: April 20, 2026"
+ "lastUpdated": "Last updated: April 21, 2026"
},
"sidebar": {
"heading": "Policies",
@@ -173,6 +173,14 @@
]
}
]
+ },
+ {
+ "navLabel": "hehehe",
+ "title": "hehehe",
+ "effectiveDate": "hehehe",
+ "intro": "hehehe",
+ "id": "hehehe",
+ "sections": []
}
]
}
\ No newline at end of file
diff --git a/routes/admin.js b/routes/admin.js
index 6c69f6d..8e4b413 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -77,6 +77,16 @@ router.post(
accreditationController.update,
);
router.get("/admissions", ensureAuthenticated, admissionsController.index);
+router.get(
+ "/admissions/calculator/:optionId",
+ ensureAuthenticated,
+ admissionsController.editCalculatorOption,
+);
+router.post(
+ "/admissions/calculator/:optionId/update",
+ ensureAuthenticated,
+ admissionsController.updateCalculatorOption,
+);
router.post(
"/admissions/update",
ensureAuthenticated,
diff --git a/utils/contentEditorIds.js b/utils/contentEditorIds.js
new file mode 100644
index 0000000..e6ff2d9
--- /dev/null
+++ b/utils/contentEditorIds.js
@@ -0,0 +1,35 @@
+function slugifyContentId(value, fallback = "item") {
+ const normalized = String(value || "")
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+
+ return normalized || fallback;
+}
+
+function ensureUniqueIds(items, getExistingId, getSourceValue, fallbackPrefix) {
+ const usedIds = new Set();
+
+ return (Array.isArray(items) ? items : []).map((item, index) => {
+ const existingId = String(getExistingId(item, index) || "").trim();
+ let nextId = existingId || slugifyContentId(getSourceValue(item, index), `${fallbackPrefix}-${index + 1}`);
+ let suffix = 2;
+
+ while (usedIds.has(nextId)) {
+ nextId = `${existingId || slugifyContentId(getSourceValue(item, index), fallbackPrefix)}-${suffix}`;
+ suffix += 1;
+ }
+
+ usedIds.add(nextId);
+ return {
+ ...item,
+ id: nextId,
+ };
+ });
+}
+
+module.exports = {
+ slugifyContentId,
+ ensureUniqueIds,
+};
diff --git a/utils/contentEditors/accreditationConfig.js b/utils/contentEditors/accreditationConfig.js
index f2d8065..21c7854 100644
--- a/utils/contentEditors/accreditationConfig.js
+++ b/utils/contentEditors/accreditationConfig.js
@@ -3,7 +3,6 @@ const {
textarea,
image,
icon,
- url,
select,
combobox,
object,
@@ -19,40 +18,13 @@ const statusOptions = [
module.exports = {
key: "accreditation",
title: "Accreditation Management",
- subtitle: "Edit content displayed on the accreditation page",
+ subtitle: "Manage the content for 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", "Banner icon"),
- text("text", "Banner message", {
- maxLength: 120,
- helpText:
- "This is the slim credibility strip shown above the accreditation content.",
- }),
- objectList(
- "links",
- "Banner links",
- [
- text("label", "Link label", { maxLength: 40 }),
- url("href", "Link URL", { maxLength: 255 }),
- icon("icon", "Link icon"),
- ],
- {
- itemLabel: "Link",
- sortable: true,
- emptyText: "No trust banner links yet.",
- },
- ),
- ]),
- },
{
key: "hero",
label: "Hero",
@@ -72,8 +44,7 @@ module.exports = {
itemLabel: "Tab",
maxLength: 30,
sortable: true,
- helpText:
- "The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.",
+ helpText: "Add and reorder the category tabs shown on the page.",
}),
objectList(
"items",
@@ -82,7 +53,7 @@ module.exports = {
icon("icon", "Fallback icon"),
image("image", "Card image", {
imageHint: "Recommended 118x58 px minimum visible ratio",
- helpText: "Logo or badge used at the top of the card.",
+ helpText: "Logo or badge shown at the top of the card.",
}),
select("status", "Status", statusOptions),
combobox("category", "Category", {
@@ -91,19 +62,13 @@ module.exports = {
}),
text("title", "Card title", {
maxLength: 17,
- helpText: "Frontend title space is capped at 17 characters.",
+ helpText: "Keep this very short. Around 17 characters fits best in the card title area.",
}),
textarea("description", "Card description", {
maxLength: 300,
rows: 5,
- helpText: "Frontend description preview is capped at 300 characters.",
+ helpText: "Keep this concise. Around 300 characters fits best in the card description area.",
}),
- text("scopeLabel", "Scope label", { maxLength: 20 }),
- text("scope", "Scope text", { maxLength: 60 }),
- text("validUntilLabel", "Validity label", { maxLength: 24 }),
- text("validUntil", "Validity text", { maxLength: 40 }),
- text("buttonLabel", "Certificate button label", { maxLength: 30 }),
- url("certificateHref", "Certificate URL", { maxLength: 255 }),
],
{
itemLabel: "Accreditation",
diff --git a/utils/contentEditors/admissionsConfig.js b/utils/contentEditors/admissionsConfig.js
index ad63533..4482452 100644
--- a/utils/contentEditors/admissionsConfig.js
+++ b/utils/contentEditors/admissionsConfig.js
@@ -14,7 +14,7 @@ const {
module.exports = {
key: "admissions",
title: "Admissions Management",
- subtitle: "Edit content displayed on the admissions page",
+ subtitle: "Manage the content for the admissions page",
routeBase: "/admin/admissions",
apiPath: "/api/admissions",
previewPath: "/admissions",
@@ -43,7 +43,6 @@ module.exports = {
label: "Admissions Process",
icon: "fas fa-list-ol",
schema: object("process", "Admissions process", [
- text("id", "Section key", { maxLength: 40 }),
text("title", "Section title", { maxLength: 60 }),
textarea("description", "Section description", {
maxLength: 180,
@@ -76,7 +75,6 @@ module.exports = {
label: "Eligibility",
icon: "fas fa-check-circle",
schema: object("eligibility", "Eligibility", [
- text("id", "Section key", { maxLength: 40 }),
text("title", "Section title", { maxLength: 60 }),
objectList(
"cards",
@@ -104,7 +102,6 @@ module.exports = {
label: "Tuition",
icon: "fas fa-chart-column",
schema: object("tuition", "Tuition", [
- text("id", "Section key", { maxLength: 40 }),
text("title", "Section title", { maxLength: 60 }),
text("chartTitle", "Chart title", { maxLength: 60 }),
textarea("chartDescription", "Chart description", {
@@ -120,6 +117,7 @@ module.exports = {
stringList("values", "Data points", {
itemLabel: "Point",
fieldType: "number",
+ min: 0,
sortable: true,
}),
],
@@ -137,30 +135,7 @@ module.exports = {
label: "Key Dates",
icon: "fas fa-calendar-days",
schema: object("keyDates", "Key dates", [
- text("id", "Section key", { maxLength: 40 }),
text("title", "Section title", { maxLength: 60 }),
- stringList("columns", "Table columns", {
- itemLabel: "Column",
- maxLength: 40,
- sortable: true,
- }),
- objectList(
- "rows",
- "Table rows",
- [
- text("term", "Term", { maxLength: 40 }),
- text("applicationDeadline", "Application deadline", {
- maxLength: 40,
- }),
- text("classesStart", "Classes start", { maxLength: 40 }),
- ],
- {
- itemLabel: "Row",
- sortable: true,
- itemTitleKey: "term",
- emptyText: "No key date rows yet.",
- },
- ),
]),
},
{
@@ -173,20 +148,37 @@ module.exports = {
maxLength: 120,
rows: 3,
}),
- stringList("modelOptions", "Model options", {
- itemLabel: "Option",
- maxLength: 30,
- sortable: true,
- }),
- text("paceLabel", "Pace label", { maxLength: 30 }),
- text("minPaceLabel", "Minimum pace label", { maxLength: 20 }),
- text("maxPaceLabel", "Maximum 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 text", { maxLength: 60 }),
object("cta", "Button", linkFields("Button")),
+ objectList(
+ "options",
+ "Calculator options",
+ [
+ hidden("id"),
+ text("label", "Option label", { maxLength: 12 }),
+ text("paceLabel", "Pace label", { maxLength: 30 }),
+ text("minPaceLabel", "Minimum pace label", { maxLength: 20 }),
+ text("maxPaceLabel", "Maximum 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 text", { maxLength: 60 }),
+ ],
+ {
+ itemLabel: "Option",
+ sortable: true,
+ itemTitleKey: "label",
+ emptyText: "No calculator options yet.",
+ itemActions: [
+ {
+ label: "Edit option",
+ icon: "fas fa-pen",
+ className: "btn btn-outline-primary btn-sm",
+ hrefTemplate: "/admin/admissions/calculator/{id}",
+ },
+ ],
+ },
+ ),
]),
},
{
diff --git a/utils/contentEditors/historyConfig.js b/utils/contentEditors/historyConfig.js
index de18966..1d98b2a 100644
--- a/utils/contentEditors/historyConfig.js
+++ b/utils/contentEditors/historyConfig.js
@@ -14,7 +14,7 @@ const {
module.exports = {
key: "history",
title: "History Management",
- subtitle: "Edit content displayed on the history page",
+ subtitle: "Manage the content for the history page",
routeBase: "/admin/history",
apiPath: "/api/history",
previewPath: "/about/history",
@@ -29,7 +29,10 @@ module.exports = {
icon("icon", "Highlight icon"),
text("text", "Message", { maxLength: 110 }),
text("linkLabel", "Link label", { maxLength: 30 }),
- url("href", "Link URL", { maxLength: 255 }),
+ url("href", "Link URL", {
+ maxLength: 255,
+ helpText: "Use an anchor or website path only. Examples: #milestones, /about/history, /contact",
+ }),
]),
},
{
@@ -47,14 +50,11 @@ module.exports = {
label: "Filter Controls",
icon: "fas fa-filter",
schema: object("filters", "Filter controls", [
- text("yearLabel", "Year filter label", { maxLength: 30 }),
- text("categoryLabel", "Category filter label", { maxLength: 30 }),
- text("buttonLabel", "Apply button label", { maxLength: 30 }),
stringList("yearOptions", "Year options", {
itemLabel: "Year option",
maxLength: 30,
sortable: true,
- helpText: "Reorder to control the dropdown order shown to users.",
+ helpText: "Reorder these options to change the order in the Year range dropdown.",
}),
stringList("categoryOptions", "Category options", {
itemLabel: "Category option",
@@ -68,13 +68,13 @@ module.exports = {
label: "Timeline",
icon: "fas fa-clock-rotate-left",
schema: object("timeline", "Timeline", [
- text("loadMoreLabel", "Load more button label", { maxLength: 40 }),
objectList(
"items",
"Milestones",
[
- text("id", "Milestone key", { maxLength: 60 }),
- text("year", "Year", { maxLength: 10 }),
+ text("year", "Year", {
+ maxLength: 30,
+ }),
combobox("yearRange", "Year range", {
maxLength: 30,
optionsPath: "filters.yearOptions",
@@ -90,7 +90,7 @@ module.exports = {
rows: 4,
}),
image("image", "Milestone image", {
- imageHint: "Recommended 436x190 px",
+ imageHint: "Recommended 436x190 px, 16:9 aspect ratio.",
helpText: "Wide image used inside the milestone card.",
}),
text("imageAlt", "Image alt text", { maxLength: 120 }),
diff --git a/utils/contentEditors/partnershipsConfig.js b/utils/contentEditors/partnershipsConfig.js
index 5752fcb..43a17b1 100644
--- a/utils/contentEditors/partnershipsConfig.js
+++ b/utils/contentEditors/partnershipsConfig.js
@@ -24,7 +24,7 @@ const widthOptions = [
module.exports = {
key: "partnerships",
title: "Partnerships Management",
- subtitle: "Edit content displayed on the partnerships page",
+ subtitle: "Manage the content for the partnerships page",
routeBase: "/admin/partnerships",
apiPath: "/api/partnerships",
previewPath: "/about/partnerships",
@@ -62,19 +62,12 @@ module.exports = {
maxLength: 30,
placeholder: "Industry",
sortable: true,
- helpText:
- "The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.",
+ helpText: "Add and reorder the category tabs shown on the page.",
}),
- text("loadMoreLabel", "Load more button label", { maxLength: 40 }),
objectList(
"partners",
"Partners",
[
- text("id", "Partner key", {
- maxLength: 50,
- helpText:
- "Use a short unique key. It keeps each card state separate on the frontend.",
- }),
text("name", "Partner name", { maxLength: 90 }),
combobox("category", "Category", {
maxLength: 30,
@@ -83,7 +76,7 @@ module.exports = {
textarea("summary", "Card summary", {
maxLength: 130,
rows: 3,
- helpText: "The card preview is capped at 130 characters.",
+ helpText: "Keep this short. Around 130 characters works best in the card layout.",
}),
image("logo", "Partner logo", {
imageHint: "Recommended 105x80 px minimum visible ratio",
@@ -127,11 +120,6 @@ module.exports = {
"fields",
"Form fields",
[
- text("id", "Field key", {
- maxLength: 40,
- helpText:
- "Use a short unique key such as firstName or organization.",
- }),
text("label", "Field label", { maxLength: 40 }),
text("placeholder", "Placeholder text", { maxLength: 80 }),
select("type", "Field type", inquiryFieldOptions),
@@ -153,7 +141,6 @@ module.exports = {
emptyText: "No inquiry fields yet.",
},
),
- text("submitLabel", "Submit button label", { maxLength: 40 }),
]),
},
],
diff --git a/utils/contentEditors/policiesConfig.js b/utils/contentEditors/policiesConfig.js
index 6742c34..af9fa64 100644
--- a/utils/contentEditors/policiesConfig.js
+++ b/utils/contentEditors/policiesConfig.js
@@ -13,7 +13,7 @@ const {
const baseConfig = {
key: "policies",
title: "Policies Management",
- subtitle: "Edit content displayed on the policies page",
+ subtitle: "Manage the content for the policies page",
routeBase: "/admin/policies",
apiPath: "/api/policies",
previewPath: "/policies",
@@ -56,7 +56,6 @@ const baseConfig = {
"policies",
"Policies",
[
- text("id", "Policy key", { maxLength: 40 }),
text("navLabel", "Sidebar label", { maxLength: 40 }),
text("title", "Policy title", { maxLength: 70 }),
text("effectiveDate", "Effective date", { maxLength: 50 }),
@@ -128,7 +127,7 @@ function createPoliciesSectionEditorConfig(policy, allPolicies = []) {
maxLength: 40,
options: policyOptions,
helpText:
- "Optional. Choose another policy to switch tabs on the frontend.",
+ "Optional. Choose another policy to open when this link is clicked.",
}),
],
{
diff --git a/utils/contentEditors/sharedFields.js b/utils/contentEditors/sharedFields.js
index ebe332d..0e34342 100644
--- a/utils/contentEditors/sharedFields.js
+++ b/utils/contentEditors/sharedFields.js
@@ -112,6 +112,9 @@ const stringList = (key, label, options = {}) => ({
fieldType: options.fieldType || "text",
label: options.itemLabel || "Item",
maxLength: options.maxLength,
+ min: options.min,
+ max: options.max,
+ step: options.step,
placeholder: options.placeholder,
helpText: options.itemHelpText,
},
diff --git a/views/admin/accreditation/index.ejs b/views/admin/accreditation/index.ejs
index 6ce97f6..737bfd3 100644
--- a/views/admin/accreditation/index.ejs
+++ b/views/admin/accreditation/index.ejs
@@ -31,7 +31,6 @@
- <%- include("partials/trust-banner-tab", { activeTab }) %>
<%- include("partials/hero-tab", { activeTab }) %>
<%- include("partials/grid-tab", { activeTab }) %>
diff --git a/views/admin/accreditation/partials/editor-script.ejs b/views/admin/accreditation/partials/editor-script.ejs
index ba01854..ecf4b04 100644
--- a/views/admin/accreditation/partials/editor-script.ejs
+++ b/views/admin/accreditation/partials/editor-script.ejs
@@ -468,37 +468,46 @@
renderAllSections();
});
col.appendChild(input);
- appendHelp(col, schema, parent[key]);
+ appendHelp(col, { ...schema, maxLength: undefined }, parent[key]);
container.appendChild(col);
return;
}
if (schema.type === "combobox") {
- const input = document.createElement("input");
- const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
- input.className = "form-control";
- input.type = "text";
- input.value = parent[key] || "";
- if (schema.placeholder) input.placeholder = schema.placeholder;
- if (schema.maxLength) input.maxLength = schema.maxLength;
- input.setAttribute("list", listId);
- input.addEventListener("input", function () {
- parent[key] = input.value;
- updateCounter(counter, input.value.length, schema.maxLength);
+ const input = document.createElement("select");
+ input.className = "form-select";
+ const options = resolveOptions(schema, context.root);
+ const optionValues = options.map((option) =>
+ typeof option === "string" ? option : option.value,
+ );
+
+ 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);
});
- const dataList = document.createElement("datalist");
- dataList.id = listId;
- resolveOptions(schema, context.root).forEach((option) => {
- const item = document.createElement("option");
- item.value = typeof option === "string" ? option : option.value;
- item.label = typeof option === "string" ? option : option.label;
- dataList.appendChild(item);
+ if (parent[key] && !optionValues.includes(parent[key])) {
+ const legacyOption = document.createElement("option");
+ legacyOption.value = parent[key];
+ legacyOption.textContent = parent[key];
+ input.appendChild(legacyOption);
+ }
+
+ input.value = parent[key] || input.options[0]?.value || "";
+ parent[key] = input.value;
+ input.addEventListener("change", function () {
+ parent[key] = input.value;
});
col.appendChild(input);
- col.appendChild(dataList);
- const counter = appendHelp(col, schema, parent[key]);
+ appendHelp(col, { ...schema, maxLength: undefined }, parent[key]);
container.appendChild(col);
return;
}
@@ -524,52 +533,113 @@
function renderIconField(schema, col, parent, key) {
const selected = parent[key] || "";
const wrapper = document.createElement("div");
- wrapper.className = "border rounded-3 bg-white p-3";
+ wrapper.className = "cms-icon-combobox";
+ const trigger = document.createElement("button");
+ trigger.type = "button";
+ trigger.className = "cms-icon-dropdown-trigger";
- const top = document.createElement("div");
- top.className = "d-flex gap-2 align-items-center mb-3";
-
- const input = document.createElement("input");
- input.type = "text";
- input.className = "form-control";
- input.value = selected;
- input.placeholder = "fa-shield-check";
- input.setAttribute("list", "cms-icon-options");
- top.appendChild(input);
+ const triggerValue = document.createElement("div");
+ triggerValue.className = "cms-icon-dropdown-value";
const preview = document.createElement("div");
- preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
- preview.style.width = "52px";
- preview.innerHTML = selected
- ? ``
- : '--';
- top.appendChild(preview);
- wrapper.appendChild(top);
+ preview.className = "cms-icon-preview";
+ triggerValue.appendChild(preview);
- const grid = document.createElement("div");
- grid.className = "d-flex flex-wrap gap-2";
- (schema.options || []).forEach((option) => {
- const button = document.createElement("button");
- button.type = "button";
- button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
- button.innerHTML = `${escapeHtml(option)}`;
- button.addEventListener("click", function () {
- parent[key] = option;
- input.value = option;
- preview.innerHTML = ``;
- renderAllSections();
- });
- grid.appendChild(button);
- });
- wrapper.appendChild(grid);
+ const triggerText = document.createElement("div");
+ triggerText.className = "cms-icon-dropdown-text";
+ triggerValue.appendChild(triggerText);
+ trigger.appendChild(triggerValue);
- input.addEventListener("input", function () {
- parent[key] = input.value;
- preview.innerHTML = input.value
- ? ``
+ const caret = document.createElement("i");
+ caret.className = "fas fa-chevron-down cms-icon-dropdown-caret";
+ trigger.appendChild(caret);
+ wrapper.appendChild(trigger);
+
+ const panel = document.createElement("div");
+ panel.className = "cms-icon-dropdown-panel d-none";
+
+ const searchInput = document.createElement("input");
+ searchInput.type = "text";
+ searchInput.className = "form-control";
+ searchInput.placeholder = "Search icon name";
+ panel.appendChild(searchInput);
+
+ const optionsWrap = document.createElement("div");
+ optionsWrap.className = "cms-icon-options";
+ panel.appendChild(optionsWrap);
+
+ const empty = document.createElement("div");
+ empty.className = "cms-icon-option-empty d-none";
+ empty.textContent = "No matching icons";
+ panel.appendChild(empty);
+ wrapper.appendChild(panel);
+
+ const setOpen = function (isOpen) {
+ wrapper.classList.toggle("is-open", isOpen);
+ panel.classList.toggle("d-none", !isOpen);
+ if (isOpen) {
+ searchInput.focus();
+ searchInput.select();
+ }
+ };
+
+ const updateTrigger = function (value) {
+ preview.innerHTML = value
+ ? ``
: '--';
+ triggerText.innerHTML = value
+ ? `${escapeHtml(value)}Selected icon`
+ : 'Select iconNo icon selected';
+ };
+
+ const renderOptions = function (searchTerm = "") {
+ const normalized = String(searchTerm || "").trim().toLowerCase();
+ const filteredOptions = (schema.options || []).filter((option) =>
+ option.toLowerCase().includes(normalized),
+ );
+
+ optionsWrap.innerHTML = "";
+ empty.classList.toggle("d-none", filteredOptions.length > 0);
+
+ filteredOptions.forEach((option) => {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`;
+ button.innerHTML = `${escapeHtml(option)}${parent[key] === option ? '' : ""}`;
+ button.addEventListener("click", function () {
+ parent[key] = option;
+ searchInput.value = option;
+ updateTrigger(option);
+ renderOptions(option);
+ setOpen(false);
+ });
+ optionsWrap.appendChild(button);
+ });
+ };
+
+ trigger.addEventListener("click", function () {
+ const nextOpen = panel.classList.contains("d-none");
+ setOpen(nextOpen);
+ if (nextOpen) {
+ renderOptions(searchInput.value || parent[key] || "");
+ }
});
+ searchInput.addEventListener("input", function () {
+ renderOptions(searchInput.value);
+ });
+
+ wrapper.addEventListener("focusout", function () {
+ window.setTimeout(function () {
+ if (!wrapper.contains(document.activeElement)) {
+ setOpen(false);
+ }
+ }, 0);
+ });
+
+ searchInput.value = selected;
+ updateTrigger(selected);
+ renderOptions(selected);
col.appendChild(wrapper);
appendHelp(col, schema, parent[key]);
}
@@ -607,7 +677,7 @@
container.appendChild(col);
}
- function appendHelp(col, schema, value, extraHint) {
+ function appendHelp(col, schema, value, extraHint, providedCounter) {
const wrapper = document.createElement("div");
wrapper.className = "field-meta-row";
@@ -616,11 +686,14 @@
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
wrapper.appendChild(help);
- let counter = null;
- if (schema.maxLength) {
+ let counter = providedCounter || null;
+ if (!counter && schema.maxLength) {
counter = document.createElement("div");
counter.className = "field-char-count";
updateCounter(counter, String(value || "").length, schema.maxLength);
+ }
+
+ if (counter) {
wrapper.appendChild(counter);
}
@@ -883,3 +956,4 @@
})();
+
diff --git a/views/admin/admissions/calculator-option.ejs b/views/admin/admissions/calculator-option.ejs
new file mode 100644
index 0000000..295b7d1
--- /dev/null
+++ b/views/admin/admissions/calculator-option.ejs
@@ -0,0 +1,244 @@
+
+
+
+
<%= title %>
+
<%= subtitle %>
+
+
+
+
+
+
+
+
diff --git a/views/admin/admissions/partials/editor-script.ejs b/views/admin/admissions/partials/editor-script.ejs
index ba01854..e88d3ac 100644
--- a/views/admin/admissions/partials/editor-script.ejs
+++ b/views/admin/admissions/partials/editor-script.ejs
@@ -12,6 +12,11 @@
}
const state = JSON.parse(JSON.stringify(initialData));
+ const persistedCalculatorOptionIds = new Set(
+ Array.isArray(initialData?.calculator?.options)
+ ? initialData.calculator.options.map((item) => item.id).filter(Boolean)
+ : [],
+ );
const iconOptions = Array.from(
new Set(
(config.tabs || [])
@@ -71,6 +76,17 @@
if (!tab || !container) return;
container.innerHTML = "";
+
+ if (tabKey === "keyDates") {
+ renderKeyDatesSection(container);
+ return;
+ }
+
+ if (tabKey === "calculator") {
+ renderCalculatorSection(container);
+ return;
+ }
+
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
path: tab.schema.key,
root: state,
@@ -78,6 +94,464 @@
});
}
+ function renderKeyDatesSection(container) {
+ normalizeKeyDatesState();
+
+ const keyDates = state.keyDates;
+ const row = document.createElement("div");
+ row.className = "row g-3";
+ container.appendChild(row);
+
+ renderLeafField(
+ { key: "title", label: "Section title", type: "text", maxLength: 60 },
+ row,
+ keyDates,
+ "title",
+ { path: "keyDates.title", root: state, item: keyDates },
+ );
+
+ const tableCol = createCol("col-12");
+ const card = document.createElement("div");
+ card.className = "cms-editor-group";
+
+ const header = document.createElement("div");
+ header.className = "d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3";
+ header.innerHTML = `
+
+
+
Manage the table directly by adding or removing columns and rows.
+
+
+
+
+
+ `;
+ card.appendChild(header);
+
+ const tableWrap = document.createElement("div");
+ tableWrap.className = "table-responsive";
+ const table = document.createElement("table");
+ table.className = "table align-middle mb-0";
+
+ const thead = document.createElement("thead");
+ const headRow = document.createElement("tr");
+
+ keyDates.columns.forEach((column, columnIndex) => {
+ const th = document.createElement("th");
+ th.style.minWidth = "220px";
+
+ const group = document.createElement("div");
+ group.className = "d-flex align-items-start gap-2";
+
+ const input = document.createElement("input");
+ input.type = "text";
+ input.className = "form-control";
+ input.maxLength = 40;
+ input.placeholder = "Column name";
+ input.value = column.label || "";
+ input.addEventListener("input", function () {
+ column.label = input.value;
+ });
+
+ const removeButton = document.createElement("button");
+ removeButton.type = "button";
+ removeButton.className = "cms-remove-button mt-1";
+ removeButton.title = "Remove column";
+ removeButton.innerHTML = '';
+ removeButton.disabled = keyDates.columns.length <= 1;
+ removeButton.addEventListener("click", function () {
+ if (keyDates.columns.length <= 1) {
+ return;
+ }
+ keyDates.columns.splice(columnIndex, 1);
+ keyDates.rows.forEach((rowItem) => {
+ rowItem.cells.splice(columnIndex, 1);
+ });
+ renderSection("keyDates");
+ });
+
+ group.appendChild(input);
+ group.appendChild(removeButton);
+ th.appendChild(group);
+ headRow.appendChild(th);
+ });
+
+ const actionHead = document.createElement("th");
+ actionHead.className = "text-end";
+ actionHead.style.width = "72px";
+ actionHead.textContent = "Actions";
+ headRow.appendChild(actionHead);
+ thead.appendChild(headRow);
+ table.appendChild(thead);
+
+ const tbody = document.createElement("tbody");
+ if (keyDates.rows.length === 0) {
+ const emptyRow = document.createElement("tr");
+ const emptyCell = document.createElement("td");
+ emptyCell.colSpan = keyDates.columns.length + 1;
+ emptyCell.className = "text-center text-muted py-4";
+ emptyCell.textContent = "No rows yet.";
+ emptyRow.appendChild(emptyCell);
+ tbody.appendChild(emptyRow);
+ } else {
+ keyDates.rows.forEach((rowItem, rowIndex) => {
+ const tr = document.createElement("tr");
+
+ keyDates.columns.forEach((column, columnIndex) => {
+ const td = document.createElement("td");
+ const input = document.createElement("input");
+ input.type = "text";
+ input.className = "form-control";
+ input.maxLength = 60;
+ input.placeholder = column.label || `Column ${columnIndex + 1}`;
+ input.value = rowItem.cells[columnIndex] || "";
+ input.addEventListener("input", function () {
+ rowItem.cells[columnIndex] = input.value;
+ });
+ td.appendChild(input);
+ tr.appendChild(td);
+ });
+
+ const actionCell = document.createElement("td");
+ actionCell.className = "text-end";
+ const removeRowButton = document.createElement("button");
+ removeRowButton.type = "button";
+ removeRowButton.className = "cms-remove-button";
+ removeRowButton.title = "Remove row";
+ removeRowButton.innerHTML = '';
+ removeRowButton.addEventListener("click", function () {
+ keyDates.rows.splice(rowIndex, 1);
+ renderSection("keyDates");
+ });
+ actionCell.appendChild(removeRowButton);
+ tr.appendChild(actionCell);
+
+ tbody.appendChild(tr);
+ });
+ }
+
+ table.appendChild(tbody);
+ tableWrap.appendChild(table);
+ card.appendChild(tableWrap);
+ tableCol.appendChild(card);
+ container.appendChild(tableCol);
+
+ header.querySelector('[data-add-column="true"]').addEventListener("click", function () {
+ addKeyDatesColumn();
+ renderSection("keyDates");
+ });
+
+ header.querySelector('[data-add-row="true"]').addEventListener("click", function () {
+ addKeyDatesRow();
+ renderSection("keyDates");
+ });
+ }
+
+ function normalizeKeyDatesState() {
+ if (!isObject(state.keyDates)) {
+ state.keyDates = {};
+ }
+
+ const keyDates = state.keyDates;
+ if (typeof keyDates.id !== "string") {
+ keyDates.id = "";
+ }
+ if (typeof keyDates.title !== "string") {
+ keyDates.title = "";
+ }
+
+ const legacyColumns = Array.isArray(keyDates.columns) ? keyDates.columns : [];
+ const legacyRows = Array.isArray(keyDates.rows) ? keyDates.rows : [];
+ keyDates.columns = normalizeKeyDatesColumns(legacyColumns, legacyRows);
+ keyDates.rows = normalizeKeyDatesRows(legacyRows, keyDates.columns);
+ }
+
+ function normalizeKeyDatesColumns(columns, rows) {
+ if (Array.isArray(columns) && columns.length > 0) {
+ return columns.map((column, index) => {
+ if (isObject(column)) {
+ return {
+ id: column.id || `column-${index + 1}`,
+ label: column.label || `Column ${index + 1}`,
+ };
+ }
+
+ return {
+ id: sanitizeId(column) || `column-${index + 1}`,
+ label: String(column || `Column ${index + 1}`),
+ };
+ });
+ }
+
+ const defaultLabels = extractLegacyKeyDateLabels(rows);
+ return defaultLabels.map((label, index) => ({
+ id: sanitizeId(label) || `column-${index + 1}`,
+ label,
+ }));
+ }
+
+ function normalizeKeyDatesRows(rows, columns) {
+ if (!Array.isArray(rows)) {
+ return [];
+ }
+
+ return rows.map((row, rowIndex) => {
+ if (isObject(row) && Array.isArray(row.cells)) {
+ return {
+ id: row.id || `row-${rowIndex + 1}`,
+ cells: columns.map((_, columnIndex) => String(row.cells[columnIndex] || "")),
+ };
+ }
+
+ return {
+ id: isObject(row) && row.id ? row.id : `row-${rowIndex + 1}`,
+ cells: columns.map((column, columnIndex) =>
+ extractLegacyKeyDateCell(row, column, columnIndex),
+ ),
+ };
+ });
+ }
+
+ function extractLegacyKeyDateLabels(rows) {
+ const defaultLabels = ["Term", "Application Deadline", "Classes Start"];
+ if (!Array.isArray(rows) || rows.length === 0 || !isObject(rows[0])) {
+ return defaultLabels;
+ }
+
+ const row = rows[0];
+ if ("term" in row || "applicationDeadline" in row || "classesStart" in row) {
+ return defaultLabels;
+ }
+
+ const keys = Object.keys(row).filter((key) => key !== "id");
+ return keys.length ? keys : defaultLabels;
+ }
+
+ function extractLegacyKeyDateCell(row, column, columnIndex) {
+ if (!isObject(row)) {
+ return "";
+ }
+
+ const legacyKeys = ["term", "applicationDeadline", "classesStart"];
+ const legacyKey = legacyKeys[columnIndex];
+ if (legacyKey && typeof row[legacyKey] !== "undefined") {
+ return String(row[legacyKey] || "");
+ }
+
+ if (column && column.id && typeof row[column.id] !== "undefined") {
+ return String(row[column.id] || "");
+ }
+
+ return "";
+ }
+
+ function addKeyDatesColumn() {
+ normalizeKeyDatesState();
+ const keyDates = state.keyDates;
+ const nextIndex = keyDates.columns.length + 1;
+ keyDates.columns.push({
+ id: `column-${nextIndex}`,
+ label: `Column ${nextIndex}`,
+ });
+ keyDates.rows.forEach((row) => {
+ row.cells.push("");
+ });
+ }
+
+ function addKeyDatesRow() {
+ normalizeKeyDatesState();
+ const keyDates = state.keyDates;
+ keyDates.rows.push({
+ id: `row-${Date.now()}`,
+ cells: keyDates.columns.map(() => ""),
+ });
+ }
+
+ function normalizeCalculatorState() {
+ if (!isObject(state.calculator)) {
+ state.calculator = {};
+ }
+
+ const calculator = state.calculator;
+ calculator.title = String(calculator.title || "");
+ calculator.description = String(calculator.description || "");
+ calculator.cta = {
+ label: String(calculator?.cta?.label || "").slice(0, 15),
+ href: String(calculator?.cta?.href || ""),
+ };
+
+ const rawOptions = Array.isArray(calculator.options)
+ ? calculator.options
+ : Array.isArray(calculator.modelOptions)
+ ? calculator.modelOptions.map((label) => ({ label }))
+ : [];
+
+ calculator.options = rawOptions.slice(0, 3).map((option, index) => ({
+ id: String(option?.id || sanitizeId(option?.label || `option-${index + 1}`) || `option-${index + 1}`),
+ label: String(option?.label || `Option ${index + 1}`).slice(0, 12),
+ paceLabel: String(option?.paceLabel || "Target Pace"),
+ minPaceLabel: String(option?.minPaceLabel || "Relaxed"),
+ maxPaceLabel: String(option?.maxPaceLabel || "Accelerated"),
+ resultLabel: String(option?.resultLabel || "Estimated Monthly Payment"),
+ monthlyAmount: (((String(option?.monthlyAmount || "299").match(/\d[\d,]*/) || [])[0] || "299").replace(/,/g, "")),
+ monthlySuffix: String(option?.monthlySuffix || "/mo"),
+ noteIcon: String(option?.noteIcon || "fa-bolt"),
+ note: String(option?.note || ""),
+ }));
+
+ delete calculator.modelOptions;
+ delete calculator.paceLabel;
+ delete calculator.minPaceLabel;
+ delete calculator.maxPaceLabel;
+ delete calculator.resultLabel;
+ delete calculator.monthlyAmount;
+ delete calculator.monthlySuffix;
+ delete calculator.noteIcon;
+ delete calculator.note;
+ }
+
+ function renderCalculatorSection(container) {
+ normalizeCalculatorState();
+
+ const calculator = state.calculator;
+ const row = document.createElement("div");
+ row.className = "row g-3";
+ container.appendChild(row);
+
+ renderLeafField(
+ { key: "title", label: "Card title", type: "text", maxLength: 60 },
+ row,
+ calculator,
+ "title",
+ { path: "calculator.title", root: state, item: calculator },
+ );
+ renderLeafField(
+ { key: "description", label: "Card description", type: "textarea", maxLength: 120, rows: 3 },
+ row,
+ calculator,
+ "description",
+ { path: "calculator.description", root: state, item: calculator },
+ );
+
+ const ctaCardCol = createCol("col-12");
+ const ctaCard = document.createElement("div");
+ ctaCard.className = "cms-editor-group";
+ const ctaHeader = document.createElement("div");
+ ctaHeader.className = "mb-3";
+ ctaHeader.innerHTML = `
+
+ This button appears at the bottom of the calculator card.
+ `;
+ ctaCard.appendChild(ctaHeader);
+ const ctaRow = document.createElement("div");
+ ctaRow.className = "row g-3";
+ ctaCard.appendChild(ctaRow);
+ renderLeafField(
+ { key: "label", label: "Button label", type: "text", maxLength: 15 },
+ ctaRow,
+ calculator.cta,
+ "label",
+ { path: "calculator.cta.label", root: state, item: calculator.cta },
+ );
+ renderLeafField(
+ { key: "href", label: "Button URL", type: "text", maxLength: 255 },
+ ctaRow,
+ calculator.cta,
+ "href",
+ { path: "calculator.cta.href", root: state, item: calculator.cta },
+ );
+ ctaCardCol.appendChild(ctaCard);
+ container.appendChild(ctaCardCol);
+
+ const optionsCol = createCol("col-12");
+ const optionsCard = document.createElement("div");
+ optionsCard.className = "cms-editor-group";
+ const optionsHeader = document.createElement("div");
+ optionsHeader.className = "mb-3";
+ optionsHeader.innerHTML = `
+
+ Each option has its own pricing labels, amount, note, and icon. Open the edit page to update the option details.
+ `;
+ optionsCard.appendChild(optionsHeader);
+
+ const list = document.createElement("div");
+ list.className = "d-flex flex-column gap-3";
+ optionsCard.appendChild(list);
+
+ if (!calculator.options.length) {
+ const empty = document.createElement("div");
+ empty.className = "text-muted small";
+ empty.textContent = "No calculator options yet.";
+ list.appendChild(empty);
+ } else {
+ calculator.options.forEach((option, index) => {
+ const item = document.createElement("div");
+ item.className = "card cms-item-card";
+ item.innerHTML = `
+
+ `;
+ item
+ .querySelector(`[data-remove-option="${index}"]`)
+ .addEventListener("click", function () {
+ calculator.options.splice(index, 1);
+ renderSection("calculator");
+ });
+ list.appendChild(item);
+ });
+ }
+
+ if (calculator.options.length < 3) {
+ const addButton = document.createElement("button");
+ addButton.type = "button";
+ addButton.className = "cms-add-button mt-3";
+ addButton.innerHTML = 'Add calculator option';
+ addButton.addEventListener("click", function () {
+ const nextIndex = calculator.options.length + 1;
+ calculator.options.push({
+ id: `option-${Date.now()}`,
+ label: `Option ${nextIndex}`.slice(0, 12),
+ paceLabel: "Target Pace",
+ minPaceLabel: "Relaxed",
+ maxPaceLabel: "Accelerated",
+ resultLabel: "Estimated Monthly Payment",
+ monthlyAmount: "299",
+ monthlySuffix: "/mo",
+ noteIcon: "fa-bolt",
+ note: "",
+ });
+ renderSection("calculator");
+ });
+ optionsCard.appendChild(addButton);
+ } else {
+ const limitNote = document.createElement("div");
+ limitNote.className = "form-text mt-3";
+ limitNote.textContent = "You can add up to 3 calculator options.";
+ optionsCard.appendChild(limitNote);
+ }
+
+ optionsCol.appendChild(optionsCard);
+ container.appendChild(optionsCol);
+ }
+
function renderField(schema, container, parent, key, tabKey, context) {
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
return;
@@ -283,6 +757,9 @@
label: fieldSchema.label || arraySchema.itemLabel || "Value",
type: fieldSchema.fieldType || "text",
maxLength: fieldSchema.maxLength,
+ min: fieldSchema.min,
+ max: fieldSchema.max,
+ step: fieldSchema.step,
placeholder: fieldSchema.placeholder,
helpText: fieldSchema.helpText,
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
@@ -510,11 +987,28 @@
input.value = parent[key] || "";
if (schema.placeholder) input.placeholder = schema.placeholder;
if (schema.maxLength) input.maxLength = schema.maxLength;
+ if (typeof schema.min !== "undefined") input.min = String(schema.min);
+ if (typeof schema.max !== "undefined") input.max = String(schema.max);
if (schema.step) input.step = schema.step;
input.addEventListener("input", function () {
- parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value;
+ if (schema.type === "number") {
+ const normalizedValue = normalizeNumberInput(schema, input.value);
+ input.value = normalizedValue.displayValue;
+ parent[key] = normalizedValue.numericValue;
+ } else {
+ parent[key] = input.value;
+ }
updateCounter(counter, String(input.value || "").length, schema.maxLength);
});
+ input.addEventListener("change", function () {
+ if (schema.type !== "number") {
+ return;
+ }
+
+ const normalizedValue = normalizeNumberInput(schema, input.value);
+ input.value = normalizedValue.displayValue;
+ parent[key] = normalizedValue.numericValue;
+ });
col.appendChild(input);
const counter = appendHelp(col, schema, parent[key]);
@@ -524,52 +1018,113 @@
function renderIconField(schema, col, parent, key) {
const selected = parent[key] || "";
const wrapper = document.createElement("div");
- wrapper.className = "border rounded-3 bg-white p-3";
+ wrapper.className = "cms-icon-combobox";
+ const trigger = document.createElement("button");
+ trigger.type = "button";
+ trigger.className = "cms-icon-dropdown-trigger";
- const top = document.createElement("div");
- top.className = "d-flex gap-2 align-items-center mb-3";
-
- const input = document.createElement("input");
- input.type = "text";
- input.className = "form-control";
- input.value = selected;
- input.placeholder = "fa-shield-check";
- input.setAttribute("list", "cms-icon-options");
- top.appendChild(input);
+ const triggerValue = document.createElement("div");
+ triggerValue.className = "cms-icon-dropdown-value";
const preview = document.createElement("div");
- preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
- preview.style.width = "52px";
- preview.innerHTML = selected
- ? ``
- : '--';
- top.appendChild(preview);
- wrapper.appendChild(top);
+ preview.className = "cms-icon-preview";
+ triggerValue.appendChild(preview);
- const grid = document.createElement("div");
- grid.className = "d-flex flex-wrap gap-2";
- (schema.options || []).forEach((option) => {
- const button = document.createElement("button");
- button.type = "button";
- button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
- button.innerHTML = `${escapeHtml(option)}`;
- button.addEventListener("click", function () {
- parent[key] = option;
- input.value = option;
- preview.innerHTML = ``;
- renderAllSections();
- });
- grid.appendChild(button);
- });
- wrapper.appendChild(grid);
+ const triggerText = document.createElement("div");
+ triggerText.className = "cms-icon-dropdown-text";
+ triggerValue.appendChild(triggerText);
+ trigger.appendChild(triggerValue);
- input.addEventListener("input", function () {
- parent[key] = input.value;
- preview.innerHTML = input.value
- ? ``
+ const caret = document.createElement("i");
+ caret.className = "fas fa-chevron-down cms-icon-dropdown-caret";
+ trigger.appendChild(caret);
+ wrapper.appendChild(trigger);
+
+ const panel = document.createElement("div");
+ panel.className = "cms-icon-dropdown-panel d-none";
+
+ const searchInput = document.createElement("input");
+ searchInput.type = "text";
+ searchInput.className = "form-control";
+ searchInput.placeholder = "Search icon name";
+ panel.appendChild(searchInput);
+
+ const optionsWrap = document.createElement("div");
+ optionsWrap.className = "cms-icon-options";
+ panel.appendChild(optionsWrap);
+
+ const empty = document.createElement("div");
+ empty.className = "cms-icon-option-empty d-none";
+ empty.textContent = "No matching icons";
+ panel.appendChild(empty);
+ wrapper.appendChild(panel);
+
+ const setOpen = function (isOpen) {
+ wrapper.classList.toggle("is-open", isOpen);
+ panel.classList.toggle("d-none", !isOpen);
+ if (isOpen) {
+ searchInput.focus();
+ searchInput.select();
+ }
+ };
+
+ const updateTrigger = function (value) {
+ preview.innerHTML = value
+ ? ``
: '--';
+ triggerText.innerHTML = value
+ ? `${escapeHtml(value)}Selected icon`
+ : 'Select iconNo icon selected';
+ };
+
+ const renderOptions = function (searchTerm = "") {
+ const normalized = String(searchTerm || "").trim().toLowerCase();
+ const filteredOptions = (schema.options || []).filter((option) =>
+ option.toLowerCase().includes(normalized),
+ );
+
+ optionsWrap.innerHTML = "";
+ empty.classList.toggle("d-none", filteredOptions.length > 0);
+
+ filteredOptions.forEach((option) => {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`;
+ button.innerHTML = `${escapeHtml(option)}${parent[key] === option ? '' : ""}`;
+ button.addEventListener("click", function () {
+ parent[key] = option;
+ searchInput.value = option;
+ updateTrigger(option);
+ renderOptions(option);
+ setOpen(false);
+ });
+ optionsWrap.appendChild(button);
+ });
+ };
+
+ trigger.addEventListener("click", function () {
+ const nextOpen = panel.classList.contains("d-none");
+ setOpen(nextOpen);
+ if (nextOpen) {
+ renderOptions(searchInput.value || parent[key] || "");
+ }
});
+ searchInput.addEventListener("input", function () {
+ renderOptions(searchInput.value);
+ });
+
+ wrapper.addEventListener("focusout", function () {
+ window.setTimeout(function () {
+ if (!wrapper.contains(document.activeElement)) {
+ setOpen(false);
+ }
+ }, 0);
+ });
+
+ searchInput.value = selected;
+ updateTrigger(selected);
+ renderOptions(selected);
col.appendChild(wrapper);
appendHelp(col, schema, parent[key]);
}
@@ -636,6 +1191,35 @@
counter.textContent = `${currentLength}/${maxLength}`;
}
+ function normalizeNumberInput(schema, rawValue) {
+ if (rawValue === "" || rawValue === null || typeof rawValue === "undefined") {
+ const emptyFallback =
+ typeof schema.min !== "undefined" ? Number(schema.min) : 0;
+ return {
+ numericValue: emptyFallback,
+ displayValue: String(emptyFallback),
+ };
+ }
+
+ let numericValue = Number(rawValue);
+ if (!Number.isFinite(numericValue)) {
+ numericValue = typeof schema.min !== "undefined" ? Number(schema.min) : 0;
+ }
+
+ if (typeof schema.min !== "undefined" && numericValue < Number(schema.min)) {
+ numericValue = Number(schema.min);
+ }
+
+ if (typeof schema.max !== "undefined" && numericValue > Number(schema.max)) {
+ numericValue = Number(schema.max);
+ }
+
+ return {
+ numericValue,
+ displayValue: String(numericValue),
+ };
+ }
+
function openImagePicker(imageType, onSuccess) {
const fileInput = document.createElement("input");
fileInput.type = "file";
@@ -883,3 +1467,4 @@
})();
+
diff --git a/views/admin/history/partials/editor-script.ejs b/views/admin/history/partials/editor-script.ejs
index ba01854..ecf4b04 100644
--- a/views/admin/history/partials/editor-script.ejs
+++ b/views/admin/history/partials/editor-script.ejs
@@ -468,37 +468,46 @@
renderAllSections();
});
col.appendChild(input);
- appendHelp(col, schema, parent[key]);
+ appendHelp(col, { ...schema, maxLength: undefined }, parent[key]);
container.appendChild(col);
return;
}
if (schema.type === "combobox") {
- const input = document.createElement("input");
- const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
- input.className = "form-control";
- input.type = "text";
- input.value = parent[key] || "";
- if (schema.placeholder) input.placeholder = schema.placeholder;
- if (schema.maxLength) input.maxLength = schema.maxLength;
- input.setAttribute("list", listId);
- input.addEventListener("input", function () {
- parent[key] = input.value;
- updateCounter(counter, input.value.length, schema.maxLength);
+ const input = document.createElement("select");
+ input.className = "form-select";
+ const options = resolveOptions(schema, context.root);
+ const optionValues = options.map((option) =>
+ typeof option === "string" ? option : option.value,
+ );
+
+ 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);
});
- const dataList = document.createElement("datalist");
- dataList.id = listId;
- resolveOptions(schema, context.root).forEach((option) => {
- const item = document.createElement("option");
- item.value = typeof option === "string" ? option : option.value;
- item.label = typeof option === "string" ? option : option.label;
- dataList.appendChild(item);
+ if (parent[key] && !optionValues.includes(parent[key])) {
+ const legacyOption = document.createElement("option");
+ legacyOption.value = parent[key];
+ legacyOption.textContent = parent[key];
+ input.appendChild(legacyOption);
+ }
+
+ input.value = parent[key] || input.options[0]?.value || "";
+ parent[key] = input.value;
+ input.addEventListener("change", function () {
+ parent[key] = input.value;
});
col.appendChild(input);
- col.appendChild(dataList);
- const counter = appendHelp(col, schema, parent[key]);
+ appendHelp(col, { ...schema, maxLength: undefined }, parent[key]);
container.appendChild(col);
return;
}
@@ -524,52 +533,113 @@
function renderIconField(schema, col, parent, key) {
const selected = parent[key] || "";
const wrapper = document.createElement("div");
- wrapper.className = "border rounded-3 bg-white p-3";
+ wrapper.className = "cms-icon-combobox";
+ const trigger = document.createElement("button");
+ trigger.type = "button";
+ trigger.className = "cms-icon-dropdown-trigger";
- const top = document.createElement("div");
- top.className = "d-flex gap-2 align-items-center mb-3";
-
- const input = document.createElement("input");
- input.type = "text";
- input.className = "form-control";
- input.value = selected;
- input.placeholder = "fa-shield-check";
- input.setAttribute("list", "cms-icon-options");
- top.appendChild(input);
+ const triggerValue = document.createElement("div");
+ triggerValue.className = "cms-icon-dropdown-value";
const preview = document.createElement("div");
- preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
- preview.style.width = "52px";
- preview.innerHTML = selected
- ? ``
- : '--';
- top.appendChild(preview);
- wrapper.appendChild(top);
+ preview.className = "cms-icon-preview";
+ triggerValue.appendChild(preview);
- const grid = document.createElement("div");
- grid.className = "d-flex flex-wrap gap-2";
- (schema.options || []).forEach((option) => {
- const button = document.createElement("button");
- button.type = "button";
- button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
- button.innerHTML = `${escapeHtml(option)}`;
- button.addEventListener("click", function () {
- parent[key] = option;
- input.value = option;
- preview.innerHTML = ``;
- renderAllSections();
- });
- grid.appendChild(button);
- });
- wrapper.appendChild(grid);
+ const triggerText = document.createElement("div");
+ triggerText.className = "cms-icon-dropdown-text";
+ triggerValue.appendChild(triggerText);
+ trigger.appendChild(triggerValue);
- input.addEventListener("input", function () {
- parent[key] = input.value;
- preview.innerHTML = input.value
- ? ``
+ const caret = document.createElement("i");
+ caret.className = "fas fa-chevron-down cms-icon-dropdown-caret";
+ trigger.appendChild(caret);
+ wrapper.appendChild(trigger);
+
+ const panel = document.createElement("div");
+ panel.className = "cms-icon-dropdown-panel d-none";
+
+ const searchInput = document.createElement("input");
+ searchInput.type = "text";
+ searchInput.className = "form-control";
+ searchInput.placeholder = "Search icon name";
+ panel.appendChild(searchInput);
+
+ const optionsWrap = document.createElement("div");
+ optionsWrap.className = "cms-icon-options";
+ panel.appendChild(optionsWrap);
+
+ const empty = document.createElement("div");
+ empty.className = "cms-icon-option-empty d-none";
+ empty.textContent = "No matching icons";
+ panel.appendChild(empty);
+ wrapper.appendChild(panel);
+
+ const setOpen = function (isOpen) {
+ wrapper.classList.toggle("is-open", isOpen);
+ panel.classList.toggle("d-none", !isOpen);
+ if (isOpen) {
+ searchInput.focus();
+ searchInput.select();
+ }
+ };
+
+ const updateTrigger = function (value) {
+ preview.innerHTML = value
+ ? ``
: '--';
+ triggerText.innerHTML = value
+ ? `${escapeHtml(value)}Selected icon`
+ : 'Select iconNo icon selected';
+ };
+
+ const renderOptions = function (searchTerm = "") {
+ const normalized = String(searchTerm || "").trim().toLowerCase();
+ const filteredOptions = (schema.options || []).filter((option) =>
+ option.toLowerCase().includes(normalized),
+ );
+
+ optionsWrap.innerHTML = "";
+ empty.classList.toggle("d-none", filteredOptions.length > 0);
+
+ filteredOptions.forEach((option) => {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`;
+ button.innerHTML = `${escapeHtml(option)}${parent[key] === option ? '' : ""}`;
+ button.addEventListener("click", function () {
+ parent[key] = option;
+ searchInput.value = option;
+ updateTrigger(option);
+ renderOptions(option);
+ setOpen(false);
+ });
+ optionsWrap.appendChild(button);
+ });
+ };
+
+ trigger.addEventListener("click", function () {
+ const nextOpen = panel.classList.contains("d-none");
+ setOpen(nextOpen);
+ if (nextOpen) {
+ renderOptions(searchInput.value || parent[key] || "");
+ }
});
+ searchInput.addEventListener("input", function () {
+ renderOptions(searchInput.value);
+ });
+
+ wrapper.addEventListener("focusout", function () {
+ window.setTimeout(function () {
+ if (!wrapper.contains(document.activeElement)) {
+ setOpen(false);
+ }
+ }, 0);
+ });
+
+ searchInput.value = selected;
+ updateTrigger(selected);
+ renderOptions(selected);
col.appendChild(wrapper);
appendHelp(col, schema, parent[key]);
}
@@ -607,7 +677,7 @@
container.appendChild(col);
}
- function appendHelp(col, schema, value, extraHint) {
+ function appendHelp(col, schema, value, extraHint, providedCounter) {
const wrapper = document.createElement("div");
wrapper.className = "field-meta-row";
@@ -616,11 +686,14 @@
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
wrapper.appendChild(help);
- let counter = null;
- if (schema.maxLength) {
+ let counter = providedCounter || null;
+ if (!counter && schema.maxLength) {
counter = document.createElement("div");
counter.className = "field-char-count";
updateCounter(counter, String(value || "").length, schema.maxLength);
+ }
+
+ if (counter) {
wrapper.appendChild(counter);
}
@@ -883,3 +956,4 @@
})();
+
diff --git a/views/admin/partnerships/partials/directory-tab.ejs b/views/admin/partnerships/partials/directory-tab.ejs
index ff00505..14804fb 100644
--- a/views/admin/partnerships/partials/directory-tab.ejs
+++ b/views/admin/partnerships/partials/directory-tab.ejs
@@ -5,14 +5,10 @@
-
+
-
-
-
-
@@ -36,7 +32,7 @@
-
Use a short unique partner key so card state stays stable.
+
Each partner card keeps its own open or closed state automatically.
diff --git a/views/admin/partnerships/partials/editor-script.ejs b/views/admin/partnerships/partials/editor-script.ejs
index 02dc400..d3d1a48 100644
--- a/views/admin/partnerships/partials/editor-script.ejs
+++ b/views/admin/partnerships/partials/editor-script.ejs
@@ -14,6 +14,12 @@
}
const state = JSON.parse(JSON.stringify(initialData));
+ const slugifyValue = (value, fallback) =>
+ String(value || "")
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "") || fallback;
const templates = {
tab: document.getElementById("directoryTabTemplate"),
partner: document.getElementById("partnerTemplate"),
@@ -21,8 +27,10 @@
inquiryOption: document.getElementById("inquiryOptionTemplate"),
};
+ ensurePartnershipIds();
bindStaticEvents();
renderAll();
+ initStaticCounters();
function bindStaticEvents() {
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
@@ -43,7 +51,6 @@
document.getElementById("addPartnerBtn")?.addEventListener("click", function () {
state.directory.partners.push({
- id: "",
name: "",
category: "",
summary: "",
@@ -53,12 +60,12 @@
collabType: "",
benefits: "",
});
+ ensurePartnershipIds();
renderPartners();
});
document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () {
state.inquiryForm.fields.push({
- id: "",
label: "",
placeholder: "",
type: "text",
@@ -66,6 +73,7 @@
required: true,
options: [],
});
+ ensurePartnershipIds();
renderInquiryFields();
});
@@ -75,6 +83,7 @@
form.addEventListener("submit", function () {
syncStaticFields();
+ ensurePartnershipIds();
pageJsonInput.value = JSON.stringify(state);
});
@@ -105,7 +114,6 @@
state.directory.heading = getValue("directoryHeading");
state.directory.description = getValue("directoryDescription");
- state.directory.loadMoreLabel = getValue("directoryLoadMoreLabel");
state.cta = {
heading: getValue("ctaHeading"),
@@ -114,7 +122,34 @@
};
state.inquiryForm.title = getValue("inquiryTitle");
- state.inquiryForm.submitLabel = getValue("inquirySubmitLabel");
+ }
+
+ function ensurePartnershipIds() {
+ const usedPartnerIds = new Set();
+ state.directory.partners = (state.directory.partners || []).map((partner, index) => {
+ let id = String(partner.id || "").trim() || slugifyValue(partner.name, `partner-${index + 1}`);
+ let suffix = 2;
+ while (usedPartnerIds.has(id)) {
+ id = `${slugifyValue(partner.name, "partner")}-${suffix}`;
+ suffix += 1;
+ }
+ usedPartnerIds.add(id);
+ return { ...partner, id };
+ });
+
+ const usedFieldIds = new Set();
+ state.inquiryForm.fields = (state.inquiryForm.fields || []).map((field, index) => {
+ let id =
+ String(field.id || "").trim() ||
+ slugifyValue(field.label || field.placeholder, `field-${index + 1}`);
+ let suffix = 2;
+ while (usedFieldIds.has(id)) {
+ id = `${slugifyValue(field.label || field.placeholder, "field")}-${suffix}`;
+ suffix += 1;
+ }
+ usedFieldIds.add(id);
+ return { ...field, id };
+ });
}
function renderAll() {
@@ -143,6 +178,7 @@
node.querySelector("[data-toggle-item]")?.addEventListener("click", function () {
node.classList.toggle("is-collapsed");
});
+ attachCounters(node);
directoryTabsList.appendChild(node);
});
@@ -210,6 +246,7 @@
node.querySelector("[data-toggle-item]")?.addEventListener("click", function () {
node.classList.toggle("is-collapsed");
});
+ attachCounters(node);
partnersList.appendChild(node);
});
@@ -304,6 +341,7 @@
field.options.splice(optionIndex, 1);
renderInquiryFields();
});
+ attachCounters(optionNode);
optionsList.appendChild(optionNode);
});
initSortable(optionsList, field.options, renderInquiryFields, '[data-item="inquiry-option"]');
@@ -311,6 +349,7 @@
toggleOptions();
renderOptions();
+ attachCounters(node);
inquiryFieldsList.appendChild(node);
});
@@ -339,6 +378,74 @@
return template.content.firstElementChild.cloneNode(true);
}
+ function initStaticCounters() {
+ attachCounters(form);
+ }
+
+ function attachCounters(root) {
+ root.querySelectorAll("input[maxlength], textarea[maxlength]").forEach((input, index) => {
+ if (input.dataset.counterReady === "true") {
+ updateInputCounter(input);
+ return;
+ }
+
+ const counterId =
+ input.id ||
+ input.name ||
+ input.dataset.field ||
+ input.dataset.optionValue ||
+ `counter-${index}-${Math.random().toString(36).slice(2, 8)}`;
+ let counter = root.querySelector(`[data-counter-for="${counterId}"]`);
+
+ if (!counter) {
+ counter = document.createElement("div");
+ counter.className = "field-char-count";
+ counter.dataset.counterFor = counterId;
+
+ const next = input.nextElementSibling;
+ if (next && next.classList.contains("form-text")) {
+ let metaRow = next.nextElementSibling;
+ if (!metaRow || !metaRow.classList.contains("field-meta-row")) {
+ metaRow = document.createElement("div");
+ metaRow.className = "field-meta-row";
+ next.insertAdjacentElement("afterend", metaRow);
+ }
+ metaRow.appendChild(counter);
+ } else {
+ const metaRow = document.createElement("div");
+ metaRow.className = "field-meta-row";
+ metaRow.appendChild(counter);
+ input.insertAdjacentElement("afterend", metaRow);
+ }
+ }
+
+ const sync = function () {
+ updateInputCounter(input);
+ };
+
+ input.addEventListener("input", sync);
+ input.addEventListener("change", sync);
+ input.dataset.counterReady = "true";
+ updateInputCounter(input);
+ });
+ }
+
+ function updateInputCounter(input) {
+ const maxLength = Number(input.getAttribute("maxlength"));
+ if (!maxLength) return;
+
+ const counterId =
+ input.id || input.name || input.dataset.field || input.dataset.optionValue;
+ const scope = input.closest("[data-item]") || input.parentElement || form;
+ let counter = scope.querySelector(`[data-counter-for="${counterId}"]`);
+ if (!counter) {
+ counter = form.querySelector(`[data-counter-for="${counterId}"]`);
+ }
+ if (!counter) return;
+
+ counter.textContent = `${String(input.value || "").length}/${maxLength}`;
+ }
+
function getValue(id) {
return document.getElementById(id)?.value || "";
}
diff --git a/views/admin/partnerships/partials/inquiry-form-tab.ejs b/views/admin/partnerships/partials/inquiry-form-tab.ejs
index 36b468a..3813c61 100644
--- a/views/admin/partnerships/partials/inquiry-form-tab.ejs
+++ b/views/admin/partnerships/partials/inquiry-form-tab.ejs
@@ -5,14 +5,10 @@
-
diff --git a/views/admin/partnerships/partials/templates.ejs b/views/admin/partnerships/partials/templates.ejs
index 6d51aa8..d5726f2 100644
--- a/views/admin/partnerships/partials/templates.ejs
+++ b/views/admin/partnerships/partials/templates.ejs
@@ -46,11 +46,6 @@
-
-
-
-
Use a short unique key.
-
@@ -119,10 +114,6 @@
-
-
-
-
diff --git a/views/admin/policies/partials/editor-script.ejs b/views/admin/policies/partials/editor-script.ejs
index ba01854..ba83e46 100644
--- a/views/admin/policies/partials/editor-script.ejs
+++ b/views/admin/policies/partials/editor-script.ejs
@@ -524,52 +524,113 @@
function renderIconField(schema, col, parent, key) {
const selected = parent[key] || "";
const wrapper = document.createElement("div");
- wrapper.className = "border rounded-3 bg-white p-3";
+ wrapper.className = "cms-icon-combobox";
+ const trigger = document.createElement("button");
+ trigger.type = "button";
+ trigger.className = "cms-icon-dropdown-trigger";
- const top = document.createElement("div");
- top.className = "d-flex gap-2 align-items-center mb-3";
-
- const input = document.createElement("input");
- input.type = "text";
- input.className = "form-control";
- input.value = selected;
- input.placeholder = "fa-shield-check";
- input.setAttribute("list", "cms-icon-options");
- top.appendChild(input);
+ const triggerValue = document.createElement("div");
+ triggerValue.className = "cms-icon-dropdown-value";
const preview = document.createElement("div");
- preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
- preview.style.width = "52px";
- preview.innerHTML = selected
- ? ``
- : '--';
- top.appendChild(preview);
- wrapper.appendChild(top);
+ preview.className = "cms-icon-preview";
+ triggerValue.appendChild(preview);
- const grid = document.createElement("div");
- grid.className = "d-flex flex-wrap gap-2";
- (schema.options || []).forEach((option) => {
- const button = document.createElement("button");
- button.type = "button";
- button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
- button.innerHTML = `${escapeHtml(option)}`;
- button.addEventListener("click", function () {
- parent[key] = option;
- input.value = option;
- preview.innerHTML = ``;
- renderAllSections();
- });
- grid.appendChild(button);
- });
- wrapper.appendChild(grid);
+ const triggerText = document.createElement("div");
+ triggerText.className = "cms-icon-dropdown-text";
+ triggerValue.appendChild(triggerText);
+ trigger.appendChild(triggerValue);
- input.addEventListener("input", function () {
- parent[key] = input.value;
- preview.innerHTML = input.value
- ? ``
+ const caret = document.createElement("i");
+ caret.className = "fas fa-chevron-down cms-icon-dropdown-caret";
+ trigger.appendChild(caret);
+ wrapper.appendChild(trigger);
+
+ const panel = document.createElement("div");
+ panel.className = "cms-icon-dropdown-panel d-none";
+
+ const searchInput = document.createElement("input");
+ searchInput.type = "text";
+ searchInput.className = "form-control";
+ searchInput.placeholder = "Search icon name";
+ panel.appendChild(searchInput);
+
+ const optionsWrap = document.createElement("div");
+ optionsWrap.className = "cms-icon-options";
+ panel.appendChild(optionsWrap);
+
+ const empty = document.createElement("div");
+ empty.className = "cms-icon-option-empty d-none";
+ empty.textContent = "No matching icons";
+ panel.appendChild(empty);
+ wrapper.appendChild(panel);
+
+ const setOpen = function (isOpen) {
+ wrapper.classList.toggle("is-open", isOpen);
+ panel.classList.toggle("d-none", !isOpen);
+ if (isOpen) {
+ searchInput.focus();
+ searchInput.select();
+ }
+ };
+
+ const updateTrigger = function (value) {
+ preview.innerHTML = value
+ ? ``
: '--';
+ triggerText.innerHTML = value
+ ? `${escapeHtml(value)}Selected icon`
+ : 'Select iconNo icon selected';
+ };
+
+ const renderOptions = function (searchTerm = "") {
+ const normalized = String(searchTerm || "").trim().toLowerCase();
+ const filteredOptions = (schema.options || []).filter((option) =>
+ option.toLowerCase().includes(normalized),
+ );
+
+ optionsWrap.innerHTML = "";
+ empty.classList.toggle("d-none", filteredOptions.length > 0);
+
+ filteredOptions.forEach((option) => {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`;
+ button.innerHTML = `${escapeHtml(option)}${parent[key] === option ? '' : ""}`;
+ button.addEventListener("click", function () {
+ parent[key] = option;
+ searchInput.value = option;
+ updateTrigger(option);
+ renderOptions(option);
+ setOpen(false);
+ });
+ optionsWrap.appendChild(button);
+ });
+ };
+
+ trigger.addEventListener("click", function () {
+ const nextOpen = panel.classList.contains("d-none");
+ setOpen(nextOpen);
+ if (nextOpen) {
+ renderOptions(searchInput.value || parent[key] || "");
+ }
});
+ searchInput.addEventListener("input", function () {
+ renderOptions(searchInput.value);
+ });
+
+ wrapper.addEventListener("focusout", function () {
+ window.setTimeout(function () {
+ if (!wrapper.contains(document.activeElement)) {
+ setOpen(false);
+ }
+ }, 0);
+ });
+
+ searchInput.value = selected;
+ updateTrigger(selected);
+ renderOptions(selected);
col.appendChild(wrapper);
appendHelp(col, schema, parent[key]);
}
@@ -883,3 +944,4 @@
})();
+
diff --git a/views/admin/policies/partials/sections-editor-script.ejs b/views/admin/policies/partials/sections-editor-script.ejs
index 5eda695..65f089d 100644
--- a/views/admin/policies/partials/sections-editor-script.ejs
+++ b/views/admin/policies/partials/sections-editor-script.ejs
@@ -524,52 +524,113 @@
function renderIconField(schema, col, parent, key) {
const selected = parent[key] || "";
const wrapper = document.createElement("div");
- wrapper.className = "border rounded-3 bg-white p-3";
+ wrapper.className = "cms-icon-combobox";
+ const trigger = document.createElement("button");
+ trigger.type = "button";
+ trigger.className = "cms-icon-dropdown-trigger";
- const top = document.createElement("div");
- top.className = "d-flex gap-2 align-items-center mb-3";
-
- const input = document.createElement("input");
- input.type = "text";
- input.className = "form-control";
- input.value = selected;
- input.placeholder = "fa-shield-check";
- input.setAttribute("list", "cms-icon-options");
- top.appendChild(input);
+ const triggerValue = document.createElement("div");
+ triggerValue.className = "cms-icon-dropdown-value";
const preview = document.createElement("div");
- preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
- preview.style.width = "52px";
- preview.innerHTML = selected
- ? ``
- : '--';
- top.appendChild(preview);
- wrapper.appendChild(top);
+ preview.className = "cms-icon-preview";
+ triggerValue.appendChild(preview);
- const grid = document.createElement("div");
- grid.className = "d-flex flex-wrap gap-2";
- (schema.options || []).forEach((option) => {
- const button = document.createElement("button");
- button.type = "button";
- button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
- button.innerHTML = `${escapeHtml(option)}`;
- button.addEventListener("click", function () {
- parent[key] = option;
- input.value = option;
- preview.innerHTML = ``;
- renderAllSections();
- });
- grid.appendChild(button);
- });
- wrapper.appendChild(grid);
+ const triggerText = document.createElement("div");
+ triggerText.className = "cms-icon-dropdown-text";
+ triggerValue.appendChild(triggerText);
+ trigger.appendChild(triggerValue);
- input.addEventListener("input", function () {
- parent[key] = input.value;
- preview.innerHTML = input.value
- ? ``
+ const caret = document.createElement("i");
+ caret.className = "fas fa-chevron-down cms-icon-dropdown-caret";
+ trigger.appendChild(caret);
+ wrapper.appendChild(trigger);
+
+ const panel = document.createElement("div");
+ panel.className = "cms-icon-dropdown-panel d-none";
+
+ const searchInput = document.createElement("input");
+ searchInput.type = "text";
+ searchInput.className = "form-control";
+ searchInput.placeholder = "Search icon name";
+ panel.appendChild(searchInput);
+
+ const optionsWrap = document.createElement("div");
+ optionsWrap.className = "cms-icon-options";
+ panel.appendChild(optionsWrap);
+
+ const empty = document.createElement("div");
+ empty.className = "cms-icon-option-empty d-none";
+ empty.textContent = "No matching icons";
+ panel.appendChild(empty);
+ wrapper.appendChild(panel);
+
+ const setOpen = function (isOpen) {
+ wrapper.classList.toggle("is-open", isOpen);
+ panel.classList.toggle("d-none", !isOpen);
+ if (isOpen) {
+ searchInput.focus();
+ searchInput.select();
+ }
+ };
+
+ const updateTrigger = function (value) {
+ preview.innerHTML = value
+ ? ``
: '--';
+ triggerText.innerHTML = value
+ ? `${escapeHtml(value)}Selected icon`
+ : 'Select iconNo icon selected';
+ };
+
+ const renderOptions = function (searchTerm = "") {
+ const normalized = String(searchTerm || "").trim().toLowerCase();
+ const filteredOptions = (schema.options || []).filter((option) =>
+ option.toLowerCase().includes(normalized),
+ );
+
+ optionsWrap.innerHTML = "";
+ empty.classList.toggle("d-none", filteredOptions.length > 0);
+
+ filteredOptions.forEach((option) => {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`;
+ button.innerHTML = `${escapeHtml(option)}${parent[key] === option ? '' : ""}`;
+ button.addEventListener("click", function () {
+ parent[key] = option;
+ searchInput.value = option;
+ updateTrigger(option);
+ renderOptions(option);
+ setOpen(false);
+ });
+ optionsWrap.appendChild(button);
+ });
+ };
+
+ trigger.addEventListener("click", function () {
+ const nextOpen = panel.classList.contains("d-none");
+ setOpen(nextOpen);
+ if (nextOpen) {
+ renderOptions(searchInput.value || parent[key] || "");
+ }
});
+ searchInput.addEventListener("input", function () {
+ renderOptions(searchInput.value);
+ });
+
+ wrapper.addEventListener("focusout", function () {
+ window.setTimeout(function () {
+ if (!wrapper.contains(document.activeElement)) {
+ setOpen(false);
+ }
+ }, 0);
+ });
+
+ searchInput.value = selected;
+ updateTrigger(selected);
+ renderOptions(selected);
col.appendChild(wrapper);
appendHelp(col, schema, parent[key]);
}
@@ -883,3 +944,4 @@
})();
+
diff --git a/views/layouts/main.ejs b/views/layouts/main.ejs
index a3d544c..f16277d 100644
--- a/views/layouts/main.ejs
+++ b/views/layouts/main.ejs
@@ -477,6 +477,150 @@
margin-left: auto;
}
+ .cms-icon-combobox {
+ position: relative;
+ }
+
+ .cms-icon-dropdown-trigger {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: 10px;
+ background: #fff;
+ padding: 0.75rem;
+ color: #334155;
+ text-align: left;
+ }
+
+ .cms-icon-dropdown-trigger:hover {
+ border-color: rgba(184, 183, 106, 0.55);
+ background: rgba(248, 250, 252, 0.96);
+ }
+
+ .cms-icon-combobox.is-open .cms-icon-dropdown-trigger {
+ border-color: rgba(184, 183, 106, 0.65);
+ box-shadow: 0 0 0 0.2rem rgba(184, 183, 106, 0.12);
+ }
+
+ .cms-icon-dropdown-value {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ min-width: 0;
+ flex: 1;
+ }
+
+ .cms-icon-dropdown-text {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ }
+
+ .cms-icon-dropdown-text strong,
+ .cms-icon-dropdown-text span {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .cms-icon-dropdown-caret {
+ color: #94a3b8;
+ transition: transform 0.18s ease;
+ }
+
+ .cms-icon-combobox.is-open .cms-icon-dropdown-caret {
+ transform: rotate(180deg);
+ }
+
+ .cms-icon-preview {
+ width: 44px;
+ min-width: 44px;
+ height: 44px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: 8px;
+ background: #fff;
+ color: #334155;
+ }
+
+ .cms-icon-dropdown-panel {
+ position: absolute;
+ top: calc(100% + 0.5rem);
+ left: 0;
+ right: 0;
+ z-index: 30;
+ background: #fff;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: 10px;
+ padding: 0.75rem;
+ box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
+ }
+
+ .cms-icon-dropdown-panel .form-control {
+ margin-bottom: 0.75rem;
+ }
+
+ .cms-icon-options {
+ max-height: 220px;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ }
+
+ .cms-icon-option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ width: 100%;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ background: #fff;
+ color: #334155;
+ border-radius: 8px;
+ padding: 0.55rem 0.7rem;
+ text-align: left;
+ font-size: 0.875rem;
+ }
+
+ .cms-icon-option-main {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ }
+
+ .cms-icon-option-main span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .cms-icon-option:hover {
+ border-color: rgba(184, 183, 106, 0.55);
+ background: rgba(248, 250, 252, 0.96);
+ color: #0f172a;
+ }
+
+ .cms-icon-option.is-active {
+ border-color: rgba(184, 183, 106, 0.65);
+ background: rgba(184, 183, 106, 0.12);
+ color: #0f172a;
+ }
+
+ .cms-icon-option-empty {
+ color: #94a3b8;
+ font-size: 0.75rem;
+ padding: 0.5rem 0.25rem 0;
+ }
+
main {
flex: 1;
z-index: 1;