feat(cms): enhance content editors and implement automatic ID generation

Improve the CMS administration interface across multiple pages (Accreditation, Admissions, History, Partnerships, and Policies) with a focus on usability and data integrity.
Key changes include:
- Implement `ensureUniqueIds` utility to automatically generate and maintain unique slugs for content items, removing the need for manual ID entry in the UI.
- Refactor the Admissions calculator to support detailed per-option editing via a new dedicated view and routes.
- Replace basic datalists with a custom, searchable icon combobox component for better visual selection.
- Update `_renderSingletonPageView` to handle active tab persistence via query parameters.
- Streamline editor configurations by removing redundant fields and improving help text.
- Enhance the Admissions "Key Dates" editor with a dynamic table interface for managing columns and rows.
- Normalize data payloads in controllers to ensure consistent API responses and internal linking.
This commit is contained in:
Tống Thành Đạt
2026-04-21 12:37:19 +07:00
parent 122df1a46e
commit 5d0fae6d51
30 changed files with 2174 additions and 477 deletions
+6 -1
View File
@@ -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}`,
+186
View File
@@ -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;
+97
View File
@@ -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) {
+20 -6
View File
@@ -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",
+19 -1
View File
@@ -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(),
+1 -17
View File
@@ -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 @@
}
]
}
}
}
+109 -57
View File
@@ -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"
}
]
}
+26 -16
View File
@@ -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
}
]
}
}
}
+3 -5
View File
@@ -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"
]
}
}
}
+9 -1
View File
@@ -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": []
}
]
}
+10
View File
@@ -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,
+35
View File
@@ -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,
};
+5 -40
View File
@@ -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",
+32 -40
View File
@@ -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}",
},
],
},
),
]),
},
{
+10 -10
View File
@@ -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 }),
+3 -16
View File
@@ -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 }),
]),
},
],
+2 -3
View File
@@ -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.",
}),
],
{
+3
View File
@@ -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,
},
-1
View File
@@ -31,7 +31,6 @@
</div>
<div class="tab-content">
<%- include("partials/trust-banner-tab", { activeTab }) %>
<%- include("partials/hero-tab", { activeTab }) %>
<%- include("partials/grid-tab", { activeTab }) %>
</div>
@@ -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
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
: '<span class="text-muted small">--</span>';
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 = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
button.addEventListener("click", function () {
parent[key] = option;
input.value = option;
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
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
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
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
? `<i class="fa-solid ${escapeHtml(value)}"></i>`
: '<span class="text-muted small">--</span>';
triggerText.innerHTML = value
? `<strong>${escapeHtml(value)}</strong><span class="text-muted small">Selected icon</span>`
: '<strong>Select icon</strong><span class="text-muted small">No icon selected</span>';
};
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 = `<span class="cms-icon-option-main"><i class="fa-solid ${escapeHtml(option)}"></i><span>${escapeHtml(option)}</span></span>${parent[key] === option ? '<i class="fas fa-check small"></i>' : ""}`;
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 @@
})();
</script>
@@ -0,0 +1,244 @@
<div class="container">
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
<div>
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
<p class="text-muted mb-0"><%= subtitle %></p>
</div>
<div class="d-flex gap-2">
<a href="/admin/admissions?tab=calculator" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Back to Calculator
</a>
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>View Page
</a>
</div>
</div>
<div class="row">
<div class="col-12">
<form action="/admin/admissions/calculator/<%= option.id %>/update" method="POST" class="content-with-fixed-buttons" id="calculatorOptionForm" novalidate>
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white">
<h6 class="mb-0"><i class="fas fa-calculator me-2"></i>Option Details</h6>
</div>
<div class="card-body p-4">
<div class="row g-3">
<div class="col-md-6">
<label for="label" class="form-label fw-semibold">Option label</label>
<input id="label" name="label" type="text" class="form-control" maxlength="12" value="<%= option.label %>" required />
<div class="field-meta-row">
<div class="form-text">This label appears in the calculator option switcher.</div>
<div class="field-char-count" data-counter-for="label">0/12</div>
</div>
</div>
<div class="col-md-6">
<label for="paceLabel" class="form-label fw-semibold">Pace label</label>
<input id="paceLabel" name="paceLabel" type="text" class="form-control" maxlength="30" value="<%= option.paceLabel %>" required />
<div class="field-meta-row">
<div class="form-text">This appears above the pace slider.</div>
<div class="field-char-count" data-counter-for="paceLabel">0/30</div>
</div>
</div>
<div class="col-md-6">
<label for="minPaceLabel" class="form-label fw-semibold">Minimum pace label</label>
<input id="minPaceLabel" name="minPaceLabel" type="text" class="form-control" maxlength="20" value="<%= option.minPaceLabel %>" required />
<div class="field-meta-row">
<div class="form-text">This appears on the left side of the pace slider.</div>
<div class="field-char-count" data-counter-for="minPaceLabel">0/20</div>
</div>
</div>
<div class="col-md-6">
<label for="maxPaceLabel" class="form-label fw-semibold">Maximum pace label</label>
<input id="maxPaceLabel" name="maxPaceLabel" type="text" class="form-control" maxlength="20" value="<%= option.maxPaceLabel %>" required />
<div class="field-meta-row">
<div class="form-text">This appears on the right side of the pace slider.</div>
<div class="field-char-count" data-counter-for="maxPaceLabel">0/20</div>
</div>
</div>
<div class="col-md-6">
<label for="resultLabel" class="form-label fw-semibold">Result label</label>
<input id="resultLabel" name="resultLabel" type="text" class="form-control" maxlength="40" value="<%= option.resultLabel %>" required />
<div class="field-meta-row">
<div class="form-text">This label appears above the calculated amount.</div>
<div class="field-char-count" data-counter-for="resultLabel">0/40</div>
</div>
</div>
<div class="col-md-6">
<label for="monthlyAmount" class="form-label fw-semibold">Monthly amount</label>
<input id="monthlyAmount" name="monthlyAmount" type="number" class="form-control" min="1" step="1" inputmode="numeric" value="<%= option.monthlyAmount %>" required />
<div class="field-meta-row">
<div class="form-text">Enter digits only. The currency symbol is added on the website automatically.</div>
</div>
</div>
<div class="col-md-6">
<label for="monthlySuffix" class="form-label fw-semibold">Monthly suffix</label>
<input id="monthlySuffix" name="monthlySuffix" type="text" class="form-control" maxlength="10" value="<%= option.monthlySuffix %>" required />
<div class="field-meta-row">
<div class="form-text">Example: /mo</div>
<div class="field-char-count" data-counter-for="monthlySuffix">0/10</div>
</div>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Note icon</label>
<div class="cms-icon-combobox" id="noteIconCombobox">
<input type="hidden" name="noteIcon" id="noteIcon" value="<%= option.noteIcon %>" />
<button type="button" class="cms-icon-dropdown-trigger" id="noteIconTrigger">
<div class="cms-icon-dropdown-value">
<div class="cms-icon-preview" id="noteIconPreview"></div>
<div class="cms-icon-dropdown-text" id="noteIconText"></div>
</div>
<i class="fas fa-chevron-down cms-icon-dropdown-caret"></i>
</button>
<div class="cms-icon-dropdown-panel d-none" id="noteIconPanel">
<input type="text" class="form-control" id="noteIconSearch" placeholder="Search icon name" />
<div class="cms-icon-options" id="noteIconOptions"></div>
<div class="cms-icon-option-empty d-none" id="noteIconEmpty">No matching icons</div>
</div>
</div>
<div class="field-meta-row">
<div class="form-text">Choose the icon shown beside the note.</div>
</div>
</div>
<div class="col-12">
<label for="note" class="form-label fw-semibold">Note text</label>
<input id="note" name="note" type="text" class="form-control" maxlength="60" value="<%= option.note %>" required />
<div class="field-meta-row">
<div class="form-text">This short note appears under the amount.</div>
<div class="field-char-count" data-counter-for="note">0/60</div>
</div>
</div>
</div>
</div>
</div>
<div class="fixed-bottom-buttons">
<a href="/admin/admissions?tab=calculator" class="btn btn-secondary">
<i class="fas fa-times"></i>
<span>Cancel</span>
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
<span>Save Changes</span>
</button>
</div>
</form>
</div>
</div>
</div>
<script>
(function () {
const iconOptions = <%- JSON.stringify(iconOptions) %>;
const form = document.getElementById("calculatorOptionForm");
const hiddenInput = document.getElementById("noteIcon");
const combobox = document.getElementById("noteIconCombobox");
const trigger = document.getElementById("noteIconTrigger");
const panel = document.getElementById("noteIconPanel");
const preview = document.getElementById("noteIconPreview");
const text = document.getElementById("noteIconText");
const search = document.getElementById("noteIconSearch");
const optionsWrap = document.getElementById("noteIconOptions");
const empty = document.getElementById("noteIconEmpty");
form.querySelectorAll("[maxlength]").forEach((input) => {
const counter = form.querySelector(`[data-counter-for="${input.id}"]`);
const updateCounter = () => {
if (counter) {
counter.textContent = `${input.value.length}/${input.maxLength}`;
}
};
input.addEventListener("input", updateCounter);
updateCounter();
});
const monthlyAmountInput = document.getElementById("monthlyAmount");
monthlyAmountInput.addEventListener("input", function () {
this.value = this.value.replace(/[^\d]/g, "");
});
monthlyAmountInput.addEventListener("change", function () {
const numericValue = Number(this.value);
this.value = numericValue > 0 ? String(Math.floor(numericValue)) : "1";
});
function updateTrigger(value) {
preview.innerHTML = value
? `<i class="fa-solid ${escapeHtml(value)}"></i>`
: '<span class="text-muted small">--</span>';
text.innerHTML = value
? `<strong>${escapeHtml(value)}</strong><span class="text-muted small">Selected icon</span>`
: '<strong>Select icon</strong><span class="text-muted small">No icon selected</span>';
}
function setOpen(isOpen) {
combobox.classList.toggle("is-open", isOpen);
panel.classList.toggle("d-none", !isOpen);
if (isOpen) {
search.focus();
search.select();
}
}
function renderOptions(term) {
const query = String(term || "").trim().toLowerCase();
const filtered = iconOptions.filter((option) => option.toLowerCase().includes(query));
optionsWrap.innerHTML = "";
empty.classList.toggle("d-none", filtered.length > 0);
filtered.forEach((option) => {
const button = document.createElement("button");
button.type = "button";
button.className = `cms-icon-option ${hiddenInput.value === option ? "is-active" : ""}`;
button.innerHTML = `<span class="cms-icon-option-main"><i class="fa-solid ${escapeHtml(option)}"></i><span>${escapeHtml(option)}</span></span>${hiddenInput.value === option ? '<i class="fas fa-check small"></i>' : ""}`;
button.addEventListener("click", function () {
hiddenInput.value = option;
search.value = option;
updateTrigger(option);
renderOptions(option);
setOpen(false);
});
optionsWrap.appendChild(button);
});
}
trigger.addEventListener("click", function () {
const shouldOpen = panel.classList.contains("d-none");
setOpen(shouldOpen);
if (shouldOpen) {
renderOptions(search.value || hiddenInput.value);
}
});
search.addEventListener("input", function () {
renderOptions(search.value);
});
combobox.addEventListener("focusout", function () {
window.setTimeout(function () {
if (!combobox.contains(document.activeElement)) {
setOpen(false);
}
}, 0);
});
search.value = hiddenInput.value;
updateTrigger(hiddenInput.value);
renderOptions(hiddenInput.value);
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
})();
</script>
+624 -39
View File
@@ -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 = `
<div>
<label class="form-label fw-semibold mb-1">Key dates table</label>
<div class="form-text mt-0">Manage the table directly by adding or removing columns and rows.</div>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" data-add-column="true">
<i class="fas fa-table-columns me-1"></i>Add Column
</button>
<button type="button" class="btn btn-outline-primary btn-sm" data-add-row="true">
<i class="fas fa-plus me-1"></i>Add Row
</button>
</div>
`;
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 = '<i class="fas fa-trash-alt"></i>';
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 = '<i class="fas fa-trash-alt"></i>';
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 = `
<label class="form-label fw-semibold mb-1">Primary button</label>
<div class="form-text mt-0">This button appears at the bottom of the calculator card.</div>
`;
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 = `
<label class="form-label fw-semibold mb-1">Calculator options</label>
<div class="form-text mt-0">Each option has its own pricing labels, amount, note, and icon. Open the edit page to update the option details.</div>
`;
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 = `
<div class="card-header d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div>
<div class="fw-semibold">${escapeHtml(option.label || `Option ${index + 1}`)}</div>
<div class="small text-muted">${escapeHtml(option.monthlyAmount)}${escapeHtml(option.monthlySuffix || "")}</div>
</div>
<div class="d-flex align-items-center gap-2">
${
persistedCalculatorOptionIds.has(option.id)
? `<a href="/admin/admissions/calculator/${encodeURIComponent(option.id)}" class="btn btn-outline-primary btn-sm"><i class="fas fa-pen me-1"></i>Edit option</a>`
: `<button type="button" class="btn btn-outline-secondary btn-sm" disabled><i class="fas fa-save me-1"></i>Save first</button>`
}
<button type="button" class="cms-remove-button" data-remove-option="${index}" title="Remove option">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
`;
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 = '<i class="fas fa-plus me-2"></i>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
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
: '<span class="text-muted small">--</span>';
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 = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
button.addEventListener("click", function () {
parent[key] = option;
input.value = option;
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
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
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
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
? `<i class="fa-solid ${escapeHtml(value)}"></i>`
: '<span class="text-muted small">--</span>';
triggerText.innerHTML = value
? `<strong>${escapeHtml(value)}</strong><span class="text-muted small">Selected icon</span>`
: '<strong>Select icon</strong><span class="text-muted small">No icon selected</span>';
};
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 = `<span class="cms-icon-option-main"><i class="fa-solid ${escapeHtml(option)}"></i><span>${escapeHtml(option)}</span></span>${parent[key] === option ? '<i class="fas fa-check small"></i>' : ""}`;
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 @@
})();
</script>
+136 -62
View File
@@ -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
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
: '<span class="text-muted small">--</span>';
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 = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
button.addEventListener("click", function () {
parent[key] = option;
input.value = option;
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
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
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
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
? `<i class="fa-solid ${escapeHtml(value)}"></i>`
: '<span class="text-muted small">--</span>';
triggerText.innerHTML = value
? `<strong>${escapeHtml(value)}</strong><span class="text-muted small">Selected icon</span>`
: '<strong>Select icon</strong><span class="text-muted small">No icon selected</span>';
};
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 = `<span class="cms-icon-option-main"><i class="fa-solid ${escapeHtml(option)}"></i><span>${escapeHtml(option)}</span></span>${parent[key] === option ? '<i class="fas fa-check small"></i>' : ""}`;
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 @@
})();
</script>
@@ -5,14 +5,10 @@
</div>
<div class="card-body p-4">
<div class="row g-3 mb-4">
<div class="col-md-8">
<div class="col-12">
<label class="form-label fw-semibold">Section heading</label>
<input class="form-control" id="directoryHeading" maxlength="70" value="<%= data.directory?.heading || '' %>">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Load more button label</label>
<input class="form-control" id="directoryLoadMoreLabel" maxlength="40" value="<%= data.directory?.loadMoreLabel || '' %>">
</div>
<div class="col-12">
<label class="form-label fw-semibold">Section description</label>
<textarea class="form-control" id="directoryDescription" rows="3" maxlength="180"><%= data.directory?.description || '' %></textarea>
@@ -36,7 +32,7 @@
<div class="mb-3">
<div>
<label class="form-label fw-semibold mb-1">Partners</label>
<div class="form-text mt-0">Use a short unique partner key so card state stays stable.</div>
<div class="form-text mt-0">Each partner card keeps its own open or closed state automatically.</div>
</div>
</div>
<div id="partnersList"></div>
@@ -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 || "";
}
@@ -5,14 +5,10 @@
</div>
<div class="card-body p-4">
<div class="row g-3 mb-4">
<div class="col-md-8">
<div class="col-12">
<label class="form-label fw-semibold">Modal title</label>
<input class="form-control" id="inquiryTitle" maxlength="60" value="<%= data.inquiryForm?.title || '' %>">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Submit button label</label>
<input class="form-control" id="inquirySubmitLabel" maxlength="40" value="<%= data.inquiryForm?.submitLabel || '' %>">
</div>
</div>
<div class="cms-editor-group">
@@ -46,11 +46,6 @@
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label fw-semibold">Partner key</label>
<input class="form-control" data-field="id" maxlength="50">
<div class="form-text">Use a short unique key.</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Partner name</label>
<input class="form-control" data-field="name" maxlength="90">
@@ -119,10 +114,6 @@
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label fw-semibold">Field key</label>
<input class="form-control" data-field="id" maxlength="40">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Field label</label>
<input class="form-control" data-field="label" maxlength="40">
+100 -38
View File
@@ -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
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
: '<span class="text-muted small">--</span>';
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 = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
button.addEventListener("click", function () {
parent[key] = option;
input.value = option;
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
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
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
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
? `<i class="fa-solid ${escapeHtml(value)}"></i>`
: '<span class="text-muted small">--</span>';
triggerText.innerHTML = value
? `<strong>${escapeHtml(value)}</strong><span class="text-muted small">Selected icon</span>`
: '<strong>Select icon</strong><span class="text-muted small">No icon selected</span>';
};
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 = `<span class="cms-icon-option-main"><i class="fa-solid ${escapeHtml(option)}"></i><span>${escapeHtml(option)}</span></span>${parent[key] === option ? '<i class="fas fa-check small"></i>' : ""}`;
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 @@
})();
</script>
@@ -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
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
: '<span class="text-muted small">--</span>';
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 = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
button.addEventListener("click", function () {
parent[key] = option;
input.value = option;
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
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
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
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
? `<i class="fa-solid ${escapeHtml(value)}"></i>`
: '<span class="text-muted small">--</span>';
triggerText.innerHTML = value
? `<strong>${escapeHtml(value)}</strong><span class="text-muted small">Selected icon</span>`
: '<strong>Select icon</strong><span class="text-muted small">No icon selected</span>';
};
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 = `<span class="cms-icon-option-main"><i class="fa-solid ${escapeHtml(option)}"></i><span>${escapeHtml(option)}</span></span>${parent[key] === option ? '<i class="fas fa-check small"></i>' : ""}`;
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 @@
})();
</script>
+144
View File
@@ -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;