forked from UKSOURCE/cms.lams
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:
@@ -11,6 +11,11 @@ function createRenderSingletonPageView({
|
|||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||||
const backendUrl =
|
const backendUrl =
|
||||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
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, {
|
return res.render(view, {
|
||||||
layout: "layouts/main",
|
layout: "layouts/main",
|
||||||
@@ -18,7 +23,7 @@ function createRenderSingletonPageView({
|
|||||||
subtitle: editorConfig.subtitle,
|
subtitle: editorConfig.subtitle,
|
||||||
data,
|
data,
|
||||||
editorConfig,
|
editorConfig,
|
||||||
activeTab: req.query.tab || editorConfig.tabs[0].key,
|
activeTab,
|
||||||
frontendUrl,
|
frontendUrl,
|
||||||
backendUrl,
|
backendUrl,
|
||||||
previewUrl: `${frontendUrl}${editorConfig.previewPath}`,
|
previewUrl: `${frontendUrl}${editorConfig.previewPath}`,
|
||||||
|
|||||||
@@ -3,12 +3,101 @@ const AUDIT_ACTIONS = require("../constants/auditAction");
|
|||||||
const admissionsConfig = require("../utils/contentEditors/admissionsConfig");
|
const admissionsConfig = require("../utils/contentEditors/admissionsConfig");
|
||||||
const createPageContentController = require("./_createPageContentController");
|
const createPageContentController = require("./_createPageContentController");
|
||||||
const createRenderSingletonPageView = require("./_renderSingletonPageView");
|
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({
|
const controller = createPageContentController({
|
||||||
model: AdmissionsPage,
|
model: AdmissionsPage,
|
||||||
modelName: "AdmissionsPage",
|
modelName: "AdmissionsPage",
|
||||||
auditAction: AUDIT_ACTIONS.UPDATE_ADMISSIONS,
|
auditAction: AUDIT_ACTIONS.UPDATE_ADMISSIONS,
|
||||||
editorConfig: admissionsConfig,
|
editorConfig: admissionsConfig,
|
||||||
|
preparePayload: normalizeAdmissionsPayload,
|
||||||
|
normalizeForEditor: normalizeAdmissionsPayload,
|
||||||
|
normalizeForApi: normalizeAdmissionsPayload,
|
||||||
});
|
});
|
||||||
|
|
||||||
controller.index = async function index(req, res) {
|
controller.index = async function index(req, res) {
|
||||||
@@ -17,6 +106,7 @@ controller.index = async function index(req, res) {
|
|||||||
model: AdmissionsPage,
|
model: AdmissionsPage,
|
||||||
editorConfig: admissionsConfig,
|
editorConfig: admissionsConfig,
|
||||||
view: "admin/admissions/index",
|
view: "admin/admissions/index",
|
||||||
|
normalizeForEditor: normalizeAdmissionsPayload,
|
||||||
})(req, res);
|
})(req, res);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("admissions index error:", 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;
|
module.exports = controller;
|
||||||
|
|||||||
@@ -3,12 +3,109 @@ const AUDIT_ACTIONS = require("../constants/auditAction");
|
|||||||
const historyConfig = require("../utils/contentEditors/historyConfig");
|
const historyConfig = require("../utils/contentEditors/historyConfig");
|
||||||
const createPageContentController = require("./_createPageContentController");
|
const createPageContentController = require("./_createPageContentController");
|
||||||
const createRenderSingletonPageView = require("./_renderSingletonPageView");
|
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({
|
const controller = createPageContentController({
|
||||||
model: HistoryPage,
|
model: HistoryPage,
|
||||||
modelName: "HistoryPage",
|
modelName: "HistoryPage",
|
||||||
auditAction: AUDIT_ACTIONS.UPDATE_HISTORY,
|
auditAction: AUDIT_ACTIONS.UPDATE_HISTORY,
|
||||||
editorConfig: historyConfig,
|
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) {
|
controller.index = async function index(req, res) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const AUDIT_ACTIONS = require("../constants/auditAction");
|
|||||||
const partnershipsConfig = require("../utils/contentEditors/partnershipsConfig");
|
const partnershipsConfig = require("../utils/contentEditors/partnershipsConfig");
|
||||||
const createPageContentController = require("./_createPageContentController");
|
const createPageContentController = require("./_createPageContentController");
|
||||||
const createRenderSingletonPageView = require("./_renderSingletonPageView");
|
const createRenderSingletonPageView = require("./_renderSingletonPageView");
|
||||||
|
const { ensureUniqueIds } = require("../utils/contentEditorIds");
|
||||||
|
|
||||||
function toInquiryField(id, field, type, width) {
|
function toInquiryField(id, field, type, width) {
|
||||||
return {
|
return {
|
||||||
@@ -52,17 +53,30 @@ function prepareInquiryPayload(payload) {
|
|||||||
const fields = Array.isArray(normalized?.inquiryForm?.fields)
|
const fields = Array.isArray(normalized?.inquiryForm?.fields)
|
||||||
? normalized.inquiryForm.fields
|
? normalized.inquiryForm.fields
|
||||||
: [];
|
: [];
|
||||||
|
const partners = Array.isArray(normalized?.directory?.partners)
|
||||||
|
? normalized.directory.partners
|
||||||
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
|
directory: {
|
||||||
|
...normalized.directory,
|
||||||
|
partners: ensureUniqueIds(
|
||||||
|
partners,
|
||||||
|
(partner) => partner.id,
|
||||||
|
(partner, index) => partner.name || partner.category || `partner-${index + 1}`,
|
||||||
|
"partner",
|
||||||
|
),
|
||||||
|
},
|
||||||
inquiryForm: {
|
inquiryForm: {
|
||||||
...normalized.inquiryForm,
|
...normalized.inquiryForm,
|
||||||
fields: fields.map((field, index) => ({
|
fields: ensureUniqueIds(
|
||||||
id:
|
fields,
|
||||||
String(field.id || `field-${index + 1}`)
|
(field) => field.id,
|
||||||
.trim()
|
(field, index) => field.label || field.placeholder || `field-${index + 1}`,
|
||||||
.replace(/\s+/g, "")
|
"field",
|
||||||
.replace(/[^a-zA-Z0-9_-]/g, "") || `field-${index + 1}`,
|
).map((field) => ({
|
||||||
|
id: field.id,
|
||||||
label: field.label || "",
|
label: field.label || "",
|
||||||
placeholder: field.placeholder || "",
|
placeholder: field.placeholder || "",
|
||||||
type: field.type || "text",
|
type: field.type || "text",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const createRenderSingletonPageView = require("./_renderSingletonPageView");
|
|||||||
const writeAuditLog = require("../audit/writeAuditLog");
|
const writeAuditLog = require("../audit/writeAuditLog");
|
||||||
const diffObject = require("../audit/diffObject");
|
const diffObject = require("../audit/diffObject");
|
||||||
const jsonHelper = require("../utils/jsonHelper");
|
const jsonHelper = require("../utils/jsonHelper");
|
||||||
|
const { ensureUniqueIds } = require("../utils/contentEditorIds");
|
||||||
|
|
||||||
function formatLastUpdated(date = new Date()) {
|
function formatLastUpdated(date = new Date()) {
|
||||||
return `Last updated: ${new Intl.DateTimeFormat("en-US", {
|
return `Last updated: ${new Intl.DateTimeFormat("en-US", {
|
||||||
@@ -18,9 +19,26 @@ function formatLastUpdated(date = new Date()) {
|
|||||||
}).format(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 {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
|
policies,
|
||||||
hero: {
|
hero: {
|
||||||
...(payload.hero || {}),
|
...(payload.hero || {}),
|
||||||
lastUpdated: formatLastUpdated(),
|
lastUpdated: formatLastUpdated(),
|
||||||
|
|||||||
@@ -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": {
|
"hero": {
|
||||||
"badge": "Accreditation Save Check",
|
"badge": "Accreditation Save Check",
|
||||||
"title": "ACCREDITATION AND RECOGNITION",
|
"title": "ACCREDITATION AND RECOGNITION",
|
||||||
|
|||||||
+105
-53
@@ -1,29 +1,29 @@
|
|||||||
{
|
{
|
||||||
"hero": {
|
"hero": {
|
||||||
"badge": "Your Path Starts Here",
|
"badge": "Your Path Starts HereYour Path Starts He",
|
||||||
"title": "Admissions & Transparent Tuition",
|
"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.",
|
"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": {
|
"primaryCta": {
|
||||||
"label": "Start Application",
|
"label": "Start ApplicationStart ApplicationStart ApplicationStart App",
|
||||||
"href": "http://localhost:3001/admin/admissions"
|
"href": "http://localhost:3001/admin/admissions"
|
||||||
},
|
},
|
||||||
"secondaryCta": {
|
"secondaryCta": {
|
||||||
"label": "View Tuition",
|
"label": "View TuitionView TuitionView TuitionView TuitionView Tuition",
|
||||||
"href": "http://localhost:3001/admin/admissions"
|
"href": "http://localhost:3001/admin/admissions"
|
||||||
},
|
},
|
||||||
"image": "/uploads/admissions/hero-students.png",
|
"image": "/uploads/admissions/Colorful_Square_Background.png",
|
||||||
"imageAlt": "Diverse adult students studying online"
|
"imageAlt": "View TuitionView TuitionView TuitionView TuitionView TuitionView TuitionView TuitionView TuitionView TuitionView Tuition"
|
||||||
},
|
},
|
||||||
"process": {
|
"process": {
|
||||||
"id": "admissions-process",
|
"id": "admissions-process",
|
||||||
"title": "Admissions Process",
|
"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.",
|
"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": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"number": "01",
|
"number": "01",
|
||||||
"title": "Send Transcripts",
|
"title": "Send Transcripts",
|
||||||
"description": "Request official transcripts from previous institutions for credit evaluation.",
|
"description": "Request official transcripts from previous institutions for credit evaluation.",
|
||||||
"active": false
|
"active": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"number": "02",
|
"number": "02",
|
||||||
@@ -41,52 +41,53 @@
|
|||||||
},
|
},
|
||||||
"eligibility": {
|
"eligibility": {
|
||||||
"id": "eligibility",
|
"id": "eligibility",
|
||||||
"title": "Eligibility & Transfer Credits",
|
"title": "Eligibility & Transfer CreditsEligibility & Transfer Credits",
|
||||||
"cards": [
|
"cards": [
|
||||||
{
|
{
|
||||||
"title": "Basic Eligibility",
|
"title": "Basic EligibilityBasic EligibilityBasic Eligibilit",
|
||||||
"icon": "fa-check-circle",
|
"icon": "fa-check-circle",
|
||||||
"items": [
|
"items": [
|
||||||
"High school diploma or equivalent",
|
"High school diploma or equivalentHigh school diploma or equivalentHigh school diploma or equivalentHigh school diploma o",
|
||||||
"Minimum 2.0 GPA for transfer students",
|
"Minimum 2.0 GPA for transfer studentsMinimum 2.0 GPA for transfer studentsMinimum 2.0 GPA for transfer studentsMinimum 2",
|
||||||
"English proficiency if applicable"
|
"English proficiency if applicableEnglish proficiency if applicableEnglish proficiency if applicableEnglish proficiency i"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Transfer Policy",
|
"title": "Transfer PolicyTransfer PolicyTransfer PolicyTrans",
|
||||||
"icon": "fa-exchange-alt",
|
"icon": "fa-exchange-alt",
|
||||||
"items": [
|
"items": [
|
||||||
"Up to 90 credits accepted for Bachelor's",
|
"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 hours",
|
"Free unofficial evaluation within 48 hoursFree unofficial evaluation within 48 hoursFree unofficial evaluation within 48",
|
||||||
"Credit for prior learning and certifications"
|
"Credit for prior learning and certificationsCredit for prior learning and certificationsCredit for prior learning and ce"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"tuition": {
|
"tuition": {
|
||||||
"id": "tuition-breakdown",
|
"id": "tuition-breakdown",
|
||||||
"title": "Tuition Breakdown",
|
"title": "Tuition BreakdownTuition BreakdownTuition BreakdownTuition B",
|
||||||
"chartTitle": "Savings vs. Traditional University",
|
"chartTitle": "Savings vs. Traditional UniversitySavings vs. Traditional Un",
|
||||||
"chartDescription": "Estimated total cost for a 4-year degree",
|
"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": [
|
"series": [
|
||||||
{
|
{
|
||||||
"label": "Traditional University",
|
"label": "Traditional University",
|
||||||
"color": "#0F172A",
|
"color": "#850f0f",
|
||||||
"values": [
|
"values": [
|
||||||
25000,
|
25000,
|
||||||
50000,
|
49998,
|
||||||
75000,
|
30000,
|
||||||
100000
|
100000,
|
||||||
|
120000
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "LAMS",
|
"label": "LAMS",
|
||||||
"color": "#c49b27",
|
"color": "#a700b3",
|
||||||
"values": [
|
"values": [
|
||||||
3588,
|
3588,
|
||||||
7176,
|
7176,
|
||||||
10764,
|
10764,
|
||||||
14352
|
10000
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -95,61 +96,112 @@
|
|||||||
"id": "key-dates",
|
"id": "key-dates",
|
||||||
"title": "Key Dates & Deadlines",
|
"title": "Key Dates & Deadlines",
|
||||||
"columns": [
|
"columns": [
|
||||||
"Term",
|
{
|
||||||
"Application Deadline",
|
"id": "Term",
|
||||||
"Classes Start"
|
"label": "Term"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Application-Deadline",
|
||||||
|
"label": "Application Deadline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Classes-Start",
|
||||||
|
"label": "Classes Start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "column-4",
|
||||||
|
"label": "Column 4"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"rows": [
|
"rows": [
|
||||||
{
|
{
|
||||||
"term": "Fall Term 1",
|
"id": "row-1",
|
||||||
"applicationDeadline": "August 15, 2026",
|
"cells": [
|
||||||
"classesStart": "September 1, 2026"
|
"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",
|
"id": "row-2",
|
||||||
"applicationDeadline": "October 15, 2026",
|
"cells": [
|
||||||
"classesStart": "November 1, 2026"
|
"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",
|
"id": "row-3",
|
||||||
"applicationDeadline": "December 15, 2026",
|
"cells": [
|
||||||
"classesStart": "January 5, 2027"
|
"Spring Term 1",
|
||||||
|
"December 15, 2026December 15, 2026December 15, 2026December ",
|
||||||
|
"January 5, 2027",
|
||||||
|
"September 1, 2026September 1, 2026September 1, 2026September"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"calculator": {
|
"calculator": {
|
||||||
"title": "Affordability Calculator",
|
"title": "Affordability Calculator",
|
||||||
"description": "Estimate your monthly investment.",
|
"description": "Estimate your monthly investment.",
|
||||||
"modelOptions": [
|
"cta": {
|
||||||
"Subscription",
|
"label": "Apply Now",
|
||||||
"Per Course"
|
"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",
|
"paceLabel": "Target Pace",
|
||||||
"minPaceLabel": "Relaxed",
|
"minPaceLabel": "Relaxed",
|
||||||
"maxPaceLabel": "Accelerated",
|
"maxPaceLabel": "Accelerated",
|
||||||
"resultLabel": "Estimated Monthly Payment",
|
"resultLabel": "Estimated Monthly Payment",
|
||||||
"monthlyAmount": "$299",
|
"monthlyAmount": "500",
|
||||||
"monthlySuffix": "/mo",
|
"monthlySuffix": "/mo",
|
||||||
"noteIcon": "fa-bolt",
|
"noteIcon": "fa-bolt",
|
||||||
"note": "Flat rate, unlimited courses",
|
"note": "Flat rate, unlimited courses"
|
||||||
"cta": {
|
},
|
||||||
"label": "Apply Now",
|
{
|
||||||
"href": "#apply"
|
"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": {
|
"scholarships": {
|
||||||
"title": "Scholarships & Aid",
|
"title": "Scholarships & Aid",
|
||||||
"icon": "fa-award",
|
"icon": "fa-award",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"title": "Working Adult Grant",
|
"title": "Working Adult GrantWorking Adult GrantWorking Adul",
|
||||||
"amount": "Up to $1,500",
|
"amount": "Up to $1,500Up to $1,500",
|
||||||
"description": "For students employed full-time while studying."
|
"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",
|
"title": "Military Discount",
|
||||||
"amount": "15% Off",
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-15
@@ -1,24 +1,22 @@
|
|||||||
{
|
{
|
||||||
"highlight": {
|
"highlight": {
|
||||||
"icon": "fa-trophy",
|
"icon": "fa-magnifying-glass",
|
||||||
"text": "History Save Check",
|
"text": "2025 Milestone Reached: A Rapidly Growing Global Community!2025 Milestone Reached: A Rapidly Growing Global Co",
|
||||||
"linkLabel": "Read Full Story",
|
"linkLabel": "Read Full StoryRead Full Story",
|
||||||
"href": "#timeline-content"
|
"href": "/admin/history"
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"badge": "Our Journey",
|
"badge": "Our JourneyOur JourneyOur JourneyOur Jou",
|
||||||
"title": "Building the Future of Education.",
|
"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."
|
"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": {
|
"filters": {
|
||||||
"yearLabel": "Decade / Year",
|
|
||||||
"categoryLabel": "Category",
|
|
||||||
"buttonLabel": "Apply Filters",
|
|
||||||
"yearOptions": [
|
"yearOptions": [
|
||||||
"All Years",
|
"All Years",
|
||||||
"2020 - Present",
|
"2020 - Present",
|
||||||
"2010 - 2019",
|
"2010 - 2019",
|
||||||
"2005 - 2009"
|
"2005 - 2009",
|
||||||
|
"2015 - 2019"
|
||||||
],
|
],
|
||||||
"categoryOptions": [
|
"categoryOptions": [
|
||||||
"All Categories",
|
"All Categories",
|
||||||
@@ -30,7 +28,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
"loadMoreLabel": "Load Earlier Milestones",
|
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "student-experience-innovation",
|
"id": "student-experience-innovation",
|
||||||
@@ -40,7 +37,7 @@
|
|||||||
"categoryLabel": "Student Experience",
|
"categoryLabel": "Student Experience",
|
||||||
"title": "Innovation in Student Experience",
|
"title": "Innovation in Student Experience",
|
||||||
"description": "Enhanced student support through integrated digital services, academic advising, and career development platforms.",
|
"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": "",
|
"imageAlt": "",
|
||||||
"stats": [],
|
"stats": [],
|
||||||
"featured": true
|
"featured": true
|
||||||
@@ -53,7 +50,7 @@
|
|||||||
"categoryLabel": "Global",
|
"categoryLabel": "Global",
|
||||||
"title": "Expansion of International Partnerships",
|
"title": "Expansion of International Partnerships",
|
||||||
"description": "Established collaborations with academic institutions and industry partners across regions, enabling dual qualifications and cross-border learning opportunities.",
|
"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": "",
|
"imageAlt": "",
|
||||||
"stats": [],
|
"stats": [],
|
||||||
"featured": false
|
"featured": false
|
||||||
@@ -92,10 +89,23 @@
|
|||||||
"categoryLabel": "Academic",
|
"categoryLabel": "Academic",
|
||||||
"title": "Strategic Academic Framework Introduced",
|
"title": "Strategic Academic Framework Introduced",
|
||||||
"description": "Established a future-focused academic model aligned with international standards, integrating applied learning, digital competencies, and global perspectives.",
|
"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": "",
|
"imageAlt": "",
|
||||||
"stats": [],
|
"stats": [],
|
||||||
"featured": false
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"hehehehehehehehehehehehehehehe",
|
"hehehehehehehehehehehehehehehe",
|
||||||
"hehehehehehehhehehehehehehhehe"
|
"hehehehehehehhehehehehehehhehe"
|
||||||
],
|
],
|
||||||
"loadMoreLabel": "Load More PartnersLoad More PartnersLoad",
|
|
||||||
"partners": [
|
"partners": [
|
||||||
{
|
{
|
||||||
"id": "techvanguardtechvanguardtechvanguardtechvanguardte",
|
"id": "techvanguardtechvanguardtechvanguardtechvanguardte",
|
||||||
@@ -102,7 +101,7 @@
|
|||||||
"placeholder": "Partnership InquiryPartnership InquiryPa",
|
"placeholder": "Partnership InquiryPartnership InquiryPa",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"width": "half",
|
"width": "half",
|
||||||
"required": true,
|
"required": false,
|
||||||
"options": []
|
"options": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -146,7 +145,6 @@
|
|||||||
"required": true,
|
"required": true,
|
||||||
"options": []
|
"options": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"submitLabel": "Partnership InquiryPartnership InquiryPa"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+9
-1
@@ -5,7 +5,7 @@
|
|||||||
"titlePrefix": "Our Commitment to",
|
"titlePrefix": "Our Commitment to",
|
||||||
"titleHighlight": "Transparency",
|
"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.",
|
"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": {
|
"sidebar": {
|
||||||
"heading": "Policies",
|
"heading": "Policies",
|
||||||
@@ -173,6 +173,14 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"navLabel": "hehehe",
|
||||||
|
"title": "hehehe",
|
||||||
|
"effectiveDate": "hehehe",
|
||||||
|
"intro": "hehehe",
|
||||||
|
"id": "hehehe",
|
||||||
|
"sections": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -77,6 +77,16 @@ router.post(
|
|||||||
accreditationController.update,
|
accreditationController.update,
|
||||||
);
|
);
|
||||||
router.get("/admissions", ensureAuthenticated, admissionsController.index);
|
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(
|
router.post(
|
||||||
"/admissions/update",
|
"/admissions/update",
|
||||||
ensureAuthenticated,
|
ensureAuthenticated,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -3,7 +3,6 @@ const {
|
|||||||
textarea,
|
textarea,
|
||||||
image,
|
image,
|
||||||
icon,
|
icon,
|
||||||
url,
|
|
||||||
select,
|
select,
|
||||||
combobox,
|
combobox,
|
||||||
object,
|
object,
|
||||||
@@ -19,40 +18,13 @@ const statusOptions = [
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
key: "accreditation",
|
key: "accreditation",
|
||||||
title: "Accreditation Management",
|
title: "Accreditation Management",
|
||||||
subtitle: "Edit content displayed on the accreditation page",
|
subtitle: "Manage the content for the accreditation page",
|
||||||
routeBase: "/admin/accreditation",
|
routeBase: "/admin/accreditation",
|
||||||
apiPath: "/api/accreditation",
|
apiPath: "/api/accreditation",
|
||||||
previewPath: "/about/accreditation",
|
previewPath: "/about/accreditation",
|
||||||
dataFile: "accreditation",
|
dataFile: "accreditation",
|
||||||
imageType: "accreditation",
|
imageType: "accreditation",
|
||||||
tabs: [
|
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",
|
key: "hero",
|
||||||
label: "Hero",
|
label: "Hero",
|
||||||
@@ -72,8 +44,7 @@ module.exports = {
|
|||||||
itemLabel: "Tab",
|
itemLabel: "Tab",
|
||||||
maxLength: 30,
|
maxLength: 30,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
helpText:
|
helpText: "Add and reorder the category tabs shown on the page.",
|
||||||
"The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.",
|
|
||||||
}),
|
}),
|
||||||
objectList(
|
objectList(
|
||||||
"items",
|
"items",
|
||||||
@@ -82,7 +53,7 @@ module.exports = {
|
|||||||
icon("icon", "Fallback icon"),
|
icon("icon", "Fallback icon"),
|
||||||
image("image", "Card image", {
|
image("image", "Card image", {
|
||||||
imageHint: "Recommended 118x58 px minimum visible ratio",
|
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),
|
select("status", "Status", statusOptions),
|
||||||
combobox("category", "Category", {
|
combobox("category", "Category", {
|
||||||
@@ -91,19 +62,13 @@ module.exports = {
|
|||||||
}),
|
}),
|
||||||
text("title", "Card title", {
|
text("title", "Card title", {
|
||||||
maxLength: 17,
|
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", {
|
textarea("description", "Card description", {
|
||||||
maxLength: 300,
|
maxLength: 300,
|
||||||
rows: 5,
|
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",
|
itemLabel: "Accreditation",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
key: "admissions",
|
key: "admissions",
|
||||||
title: "Admissions Management",
|
title: "Admissions Management",
|
||||||
subtitle: "Edit content displayed on the admissions page",
|
subtitle: "Manage the content for the admissions page",
|
||||||
routeBase: "/admin/admissions",
|
routeBase: "/admin/admissions",
|
||||||
apiPath: "/api/admissions",
|
apiPath: "/api/admissions",
|
||||||
previewPath: "/admissions",
|
previewPath: "/admissions",
|
||||||
@@ -43,7 +43,6 @@ module.exports = {
|
|||||||
label: "Admissions Process",
|
label: "Admissions Process",
|
||||||
icon: "fas fa-list-ol",
|
icon: "fas fa-list-ol",
|
||||||
schema: object("process", "Admissions process", [
|
schema: object("process", "Admissions process", [
|
||||||
text("id", "Section key", { maxLength: 40 }),
|
|
||||||
text("title", "Section title", { maxLength: 60 }),
|
text("title", "Section title", { maxLength: 60 }),
|
||||||
textarea("description", "Section description", {
|
textarea("description", "Section description", {
|
||||||
maxLength: 180,
|
maxLength: 180,
|
||||||
@@ -76,7 +75,6 @@ module.exports = {
|
|||||||
label: "Eligibility",
|
label: "Eligibility",
|
||||||
icon: "fas fa-check-circle",
|
icon: "fas fa-check-circle",
|
||||||
schema: object("eligibility", "Eligibility", [
|
schema: object("eligibility", "Eligibility", [
|
||||||
text("id", "Section key", { maxLength: 40 }),
|
|
||||||
text("title", "Section title", { maxLength: 60 }),
|
text("title", "Section title", { maxLength: 60 }),
|
||||||
objectList(
|
objectList(
|
||||||
"cards",
|
"cards",
|
||||||
@@ -104,7 +102,6 @@ module.exports = {
|
|||||||
label: "Tuition",
|
label: "Tuition",
|
||||||
icon: "fas fa-chart-column",
|
icon: "fas fa-chart-column",
|
||||||
schema: object("tuition", "Tuition", [
|
schema: object("tuition", "Tuition", [
|
||||||
text("id", "Section key", { maxLength: 40 }),
|
|
||||||
text("title", "Section title", { maxLength: 60 }),
|
text("title", "Section title", { maxLength: 60 }),
|
||||||
text("chartTitle", "Chart title", { maxLength: 60 }),
|
text("chartTitle", "Chart title", { maxLength: 60 }),
|
||||||
textarea("chartDescription", "Chart description", {
|
textarea("chartDescription", "Chart description", {
|
||||||
@@ -120,6 +117,7 @@ module.exports = {
|
|||||||
stringList("values", "Data points", {
|
stringList("values", "Data points", {
|
||||||
itemLabel: "Point",
|
itemLabel: "Point",
|
||||||
fieldType: "number",
|
fieldType: "number",
|
||||||
|
min: 0,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -137,30 +135,7 @@ module.exports = {
|
|||||||
label: "Key Dates",
|
label: "Key Dates",
|
||||||
icon: "fas fa-calendar-days",
|
icon: "fas fa-calendar-days",
|
||||||
schema: object("keyDates", "Key dates", [
|
schema: object("keyDates", "Key dates", [
|
||||||
text("id", "Section key", { maxLength: 40 }),
|
|
||||||
text("title", "Section title", { maxLength: 60 }),
|
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,11 +148,13 @@ module.exports = {
|
|||||||
maxLength: 120,
|
maxLength: 120,
|
||||||
rows: 3,
|
rows: 3,
|
||||||
}),
|
}),
|
||||||
stringList("modelOptions", "Model options", {
|
object("cta", "Button", linkFields("Button")),
|
||||||
itemLabel: "Option",
|
objectList(
|
||||||
maxLength: 30,
|
"options",
|
||||||
sortable: true,
|
"Calculator options",
|
||||||
}),
|
[
|
||||||
|
hidden("id"),
|
||||||
|
text("label", "Option label", { maxLength: 12 }),
|
||||||
text("paceLabel", "Pace label", { maxLength: 30 }),
|
text("paceLabel", "Pace label", { maxLength: 30 }),
|
||||||
text("minPaceLabel", "Minimum pace label", { maxLength: 20 }),
|
text("minPaceLabel", "Minimum pace label", { maxLength: 20 }),
|
||||||
text("maxPaceLabel", "Maximum pace label", { maxLength: 20 }),
|
text("maxPaceLabel", "Maximum pace label", { maxLength: 20 }),
|
||||||
@@ -186,7 +163,22 @@ module.exports = {
|
|||||||
text("monthlySuffix", "Monthly suffix", { maxLength: 10 }),
|
text("monthlySuffix", "Monthly suffix", { maxLength: 10 }),
|
||||||
icon("noteIcon", "Note icon"),
|
icon("noteIcon", "Note icon"),
|
||||||
text("note", "Note text", { maxLength: 60 }),
|
text("note", "Note text", { maxLength: 60 }),
|
||||||
object("cta", "Button", linkFields("Button")),
|
],
|
||||||
|
{
|
||||||
|
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}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
key: "history",
|
key: "history",
|
||||||
title: "History Management",
|
title: "History Management",
|
||||||
subtitle: "Edit content displayed on the history page",
|
subtitle: "Manage the content for the history page",
|
||||||
routeBase: "/admin/history",
|
routeBase: "/admin/history",
|
||||||
apiPath: "/api/history",
|
apiPath: "/api/history",
|
||||||
previewPath: "/about/history",
|
previewPath: "/about/history",
|
||||||
@@ -29,7 +29,10 @@ module.exports = {
|
|||||||
icon("icon", "Highlight icon"),
|
icon("icon", "Highlight icon"),
|
||||||
text("text", "Message", { maxLength: 110 }),
|
text("text", "Message", { maxLength: 110 }),
|
||||||
text("linkLabel", "Link label", { maxLength: 30 }),
|
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",
|
label: "Filter Controls",
|
||||||
icon: "fas fa-filter",
|
icon: "fas fa-filter",
|
||||||
schema: object("filters", "Filter controls", [
|
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", {
|
stringList("yearOptions", "Year options", {
|
||||||
itemLabel: "Year option",
|
itemLabel: "Year option",
|
||||||
maxLength: 30,
|
maxLength: 30,
|
||||||
sortable: true,
|
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", {
|
stringList("categoryOptions", "Category options", {
|
||||||
itemLabel: "Category option",
|
itemLabel: "Category option",
|
||||||
@@ -68,13 +68,13 @@ module.exports = {
|
|||||||
label: "Timeline",
|
label: "Timeline",
|
||||||
icon: "fas fa-clock-rotate-left",
|
icon: "fas fa-clock-rotate-left",
|
||||||
schema: object("timeline", "Timeline", [
|
schema: object("timeline", "Timeline", [
|
||||||
text("loadMoreLabel", "Load more button label", { maxLength: 40 }),
|
|
||||||
objectList(
|
objectList(
|
||||||
"items",
|
"items",
|
||||||
"Milestones",
|
"Milestones",
|
||||||
[
|
[
|
||||||
text("id", "Milestone key", { maxLength: 60 }),
|
text("year", "Year", {
|
||||||
text("year", "Year", { maxLength: 10 }),
|
maxLength: 30,
|
||||||
|
}),
|
||||||
combobox("yearRange", "Year range", {
|
combobox("yearRange", "Year range", {
|
||||||
maxLength: 30,
|
maxLength: 30,
|
||||||
optionsPath: "filters.yearOptions",
|
optionsPath: "filters.yearOptions",
|
||||||
@@ -90,7 +90,7 @@ module.exports = {
|
|||||||
rows: 4,
|
rows: 4,
|
||||||
}),
|
}),
|
||||||
image("image", "Milestone image", {
|
image("image", "Milestone image", {
|
||||||
imageHint: "Recommended 436x190 px",
|
imageHint: "Recommended 436x190 px, 16:9 aspect ratio.",
|
||||||
helpText: "Wide image used inside the milestone card.",
|
helpText: "Wide image used inside the milestone card.",
|
||||||
}),
|
}),
|
||||||
text("imageAlt", "Image alt text", { maxLength: 120 }),
|
text("imageAlt", "Image alt text", { maxLength: 120 }),
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const widthOptions = [
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
key: "partnerships",
|
key: "partnerships",
|
||||||
title: "Partnerships Management",
|
title: "Partnerships Management",
|
||||||
subtitle: "Edit content displayed on the partnerships page",
|
subtitle: "Manage the content for the partnerships page",
|
||||||
routeBase: "/admin/partnerships",
|
routeBase: "/admin/partnerships",
|
||||||
apiPath: "/api/partnerships",
|
apiPath: "/api/partnerships",
|
||||||
previewPath: "/about/partnerships",
|
previewPath: "/about/partnerships",
|
||||||
@@ -62,19 +62,12 @@ module.exports = {
|
|||||||
maxLength: 30,
|
maxLength: 30,
|
||||||
placeholder: "Industry",
|
placeholder: "Industry",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
helpText:
|
helpText: "Add and reorder the category tabs shown on the page.",
|
||||||
"The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.",
|
|
||||||
}),
|
}),
|
||||||
text("loadMoreLabel", "Load more button label", { maxLength: 40 }),
|
|
||||||
objectList(
|
objectList(
|
||||||
"partners",
|
"partners",
|
||||||
"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 }),
|
text("name", "Partner name", { maxLength: 90 }),
|
||||||
combobox("category", "Category", {
|
combobox("category", "Category", {
|
||||||
maxLength: 30,
|
maxLength: 30,
|
||||||
@@ -83,7 +76,7 @@ module.exports = {
|
|||||||
textarea("summary", "Card summary", {
|
textarea("summary", "Card summary", {
|
||||||
maxLength: 130,
|
maxLength: 130,
|
||||||
rows: 3,
|
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", {
|
image("logo", "Partner logo", {
|
||||||
imageHint: "Recommended 105x80 px minimum visible ratio",
|
imageHint: "Recommended 105x80 px minimum visible ratio",
|
||||||
@@ -127,11 +120,6 @@ module.exports = {
|
|||||||
"fields",
|
"fields",
|
||||||
"Form 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("label", "Field label", { maxLength: 40 }),
|
||||||
text("placeholder", "Placeholder text", { maxLength: 80 }),
|
text("placeholder", "Placeholder text", { maxLength: 80 }),
|
||||||
select("type", "Field type", inquiryFieldOptions),
|
select("type", "Field type", inquiryFieldOptions),
|
||||||
@@ -153,7 +141,6 @@ module.exports = {
|
|||||||
emptyText: "No inquiry fields yet.",
|
emptyText: "No inquiry fields yet.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
text("submitLabel", "Submit button label", { maxLength: 40 }),
|
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const {
|
|||||||
const baseConfig = {
|
const baseConfig = {
|
||||||
key: "policies",
|
key: "policies",
|
||||||
title: "Policies Management",
|
title: "Policies Management",
|
||||||
subtitle: "Edit content displayed on the policies page",
|
subtitle: "Manage the content for the policies page",
|
||||||
routeBase: "/admin/policies",
|
routeBase: "/admin/policies",
|
||||||
apiPath: "/api/policies",
|
apiPath: "/api/policies",
|
||||||
previewPath: "/policies",
|
previewPath: "/policies",
|
||||||
@@ -56,7 +56,6 @@ const baseConfig = {
|
|||||||
"policies",
|
"policies",
|
||||||
"Policies",
|
"Policies",
|
||||||
[
|
[
|
||||||
text("id", "Policy key", { maxLength: 40 }),
|
|
||||||
text("navLabel", "Sidebar label", { maxLength: 40 }),
|
text("navLabel", "Sidebar label", { maxLength: 40 }),
|
||||||
text("title", "Policy title", { maxLength: 70 }),
|
text("title", "Policy title", { maxLength: 70 }),
|
||||||
text("effectiveDate", "Effective date", { maxLength: 50 }),
|
text("effectiveDate", "Effective date", { maxLength: 50 }),
|
||||||
@@ -128,7 +127,7 @@ function createPoliciesSectionEditorConfig(policy, allPolicies = []) {
|
|||||||
maxLength: 40,
|
maxLength: 40,
|
||||||
options: policyOptions,
|
options: policyOptions,
|
||||||
helpText:
|
helpText:
|
||||||
"Optional. Choose another policy to switch tabs on the frontend.",
|
"Optional. Choose another policy to open when this link is clicked.",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ const stringList = (key, label, options = {}) => ({
|
|||||||
fieldType: options.fieldType || "text",
|
fieldType: options.fieldType || "text",
|
||||||
label: options.itemLabel || "Item",
|
label: options.itemLabel || "Item",
|
||||||
maxLength: options.maxLength,
|
maxLength: options.maxLength,
|
||||||
|
min: options.min,
|
||||||
|
max: options.max,
|
||||||
|
step: options.step,
|
||||||
placeholder: options.placeholder,
|
placeholder: options.placeholder,
|
||||||
helpText: options.itemHelpText,
|
helpText: options.itemHelpText,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,7 +31,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<%- include("partials/trust-banner-tab", { activeTab }) %>
|
|
||||||
<%- include("partials/hero-tab", { activeTab }) %>
|
<%- include("partials/hero-tab", { activeTab }) %>
|
||||||
<%- include("partials/grid-tab", { activeTab }) %>
|
<%- include("partials/grid-tab", { activeTab }) %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -468,37 +468,46 @@
|
|||||||
renderAllSections();
|
renderAllSections();
|
||||||
});
|
});
|
||||||
col.appendChild(input);
|
col.appendChild(input);
|
||||||
appendHelp(col, schema, parent[key]);
|
appendHelp(col, { ...schema, maxLength: undefined }, parent[key]);
|
||||||
container.appendChild(col);
|
container.appendChild(col);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (schema.type === "combobox") {
|
if (schema.type === "combobox") {
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("select");
|
||||||
const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
|
input.className = "form-select";
|
||||||
input.className = "form-control";
|
const options = resolveOptions(schema, context.root);
|
||||||
input.type = "text";
|
const optionValues = options.map((option) =>
|
||||||
input.value = parent[key] || "";
|
typeof option === "string" ? option : option.value,
|
||||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
);
|
||||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
|
||||||
input.setAttribute("list", listId);
|
options.forEach((option) => {
|
||||||
input.addEventListener("input", function () {
|
const optionEl = document.createElement("option");
|
||||||
parent[key] = input.value;
|
if (typeof option === "string") {
|
||||||
updateCounter(counter, input.value.length, schema.maxLength);
|
optionEl.value = option;
|
||||||
|
optionEl.textContent = option;
|
||||||
|
} else {
|
||||||
|
optionEl.value = option.value;
|
||||||
|
optionEl.textContent = option.label;
|
||||||
|
}
|
||||||
|
input.appendChild(optionEl);
|
||||||
});
|
});
|
||||||
|
|
||||||
const dataList = document.createElement("datalist");
|
if (parent[key] && !optionValues.includes(parent[key])) {
|
||||||
dataList.id = listId;
|
const legacyOption = document.createElement("option");
|
||||||
resolveOptions(schema, context.root).forEach((option) => {
|
legacyOption.value = parent[key];
|
||||||
const item = document.createElement("option");
|
legacyOption.textContent = parent[key];
|
||||||
item.value = typeof option === "string" ? option : option.value;
|
input.appendChild(legacyOption);
|
||||||
item.label = typeof option === "string" ? option : option.label;
|
}
|
||||||
dataList.appendChild(item);
|
|
||||||
|
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(input);
|
||||||
col.appendChild(dataList);
|
appendHelp(col, { ...schema, maxLength: undefined }, parent[key]);
|
||||||
const counter = appendHelp(col, schema, parent[key]);
|
|
||||||
container.appendChild(col);
|
container.appendChild(col);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -524,52 +533,113 @@
|
|||||||
function renderIconField(schema, col, parent, key) {
|
function renderIconField(schema, col, parent, key) {
|
||||||
const selected = parent[key] || "";
|
const selected = parent[key] || "";
|
||||||
const wrapper = document.createElement("div");
|
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");
|
const triggerValue = document.createElement("div");
|
||||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
triggerValue.className = "cms-icon-dropdown-value";
|
||||||
|
|
||||||
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 preview = document.createElement("div");
|
const preview = document.createElement("div");
|
||||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
preview.className = "cms-icon-preview";
|
||||||
preview.style.width = "52px";
|
triggerValue.appendChild(preview);
|
||||||
preview.innerHTML = selected
|
|
||||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
|
||||||
: '<span class="text-muted small">--</span>';
|
|
||||||
top.appendChild(preview);
|
|
||||||
wrapper.appendChild(top);
|
|
||||||
|
|
||||||
const grid = document.createElement("div");
|
const triggerText = document.createElement("div");
|
||||||
grid.className = "d-flex flex-wrap gap-2";
|
triggerText.className = "cms-icon-dropdown-text";
|
||||||
(schema.options || []).forEach((option) => {
|
triggerValue.appendChild(triggerText);
|
||||||
|
trigger.appendChild(triggerValue);
|
||||||
|
|
||||||
|
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");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`;
|
||||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
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 () {
|
button.addEventListener("click", function () {
|
||||||
parent[key] = option;
|
parent[key] = option;
|
||||||
input.value = option;
|
searchInput.value = option;
|
||||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
updateTrigger(option);
|
||||||
renderAllSections();
|
renderOptions(option);
|
||||||
|
setOpen(false);
|
||||||
});
|
});
|
||||||
grid.appendChild(button);
|
optionsWrap.appendChild(button);
|
||||||
});
|
});
|
||||||
wrapper.appendChild(grid);
|
};
|
||||||
|
|
||||||
input.addEventListener("input", function () {
|
trigger.addEventListener("click", function () {
|
||||||
parent[key] = input.value;
|
const nextOpen = panel.classList.contains("d-none");
|
||||||
preview.innerHTML = input.value
|
setOpen(nextOpen);
|
||||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
if (nextOpen) {
|
||||||
: '<span class="text-muted small">--</span>';
|
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);
|
col.appendChild(wrapper);
|
||||||
appendHelp(col, schema, parent[key]);
|
appendHelp(col, schema, parent[key]);
|
||||||
}
|
}
|
||||||
@@ -607,7 +677,7 @@
|
|||||||
container.appendChild(col);
|
container.appendChild(col);
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendHelp(col, schema, value, extraHint) {
|
function appendHelp(col, schema, value, extraHint, providedCounter) {
|
||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
wrapper.className = "field-meta-row";
|
wrapper.className = "field-meta-row";
|
||||||
|
|
||||||
@@ -616,11 +686,14 @@
|
|||||||
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
|
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
|
||||||
wrapper.appendChild(help);
|
wrapper.appendChild(help);
|
||||||
|
|
||||||
let counter = null;
|
let counter = providedCounter || null;
|
||||||
if (schema.maxLength) {
|
if (!counter && schema.maxLength) {
|
||||||
counter = document.createElement("div");
|
counter = document.createElement("div");
|
||||||
counter.className = "field-char-count";
|
counter.className = "field-char-count";
|
||||||
updateCounter(counter, String(value || "").length, schema.maxLength);
|
updateCounter(counter, String(value || "").length, schema.maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counter) {
|
||||||
wrapper.appendChild(counter);
|
wrapper.appendChild(counter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -883,3 +956,4 @@
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
</script>
|
</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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -12,6 +12,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = JSON.parse(JSON.stringify(initialData));
|
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(
|
const iconOptions = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
(config.tabs || [])
|
(config.tabs || [])
|
||||||
@@ -71,6 +76,17 @@
|
|||||||
if (!tab || !container) return;
|
if (!tab || !container) return;
|
||||||
|
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
if (tabKey === "keyDates") {
|
||||||
|
renderKeyDatesSection(container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabKey === "calculator") {
|
||||||
|
renderCalculatorSection(container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
|
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
|
||||||
path: tab.schema.key,
|
path: tab.schema.key,
|
||||||
root: state,
|
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) {
|
function renderField(schema, container, parent, key, tabKey, context) {
|
||||||
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
|
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
|
||||||
return;
|
return;
|
||||||
@@ -283,6 +757,9 @@
|
|||||||
label: fieldSchema.label || arraySchema.itemLabel || "Value",
|
label: fieldSchema.label || arraySchema.itemLabel || "Value",
|
||||||
type: fieldSchema.fieldType || "text",
|
type: fieldSchema.fieldType || "text",
|
||||||
maxLength: fieldSchema.maxLength,
|
maxLength: fieldSchema.maxLength,
|
||||||
|
min: fieldSchema.min,
|
||||||
|
max: fieldSchema.max,
|
||||||
|
step: fieldSchema.step,
|
||||||
placeholder: fieldSchema.placeholder,
|
placeholder: fieldSchema.placeholder,
|
||||||
helpText: fieldSchema.helpText,
|
helpText: fieldSchema.helpText,
|
||||||
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
|
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
|
||||||
@@ -510,11 +987,28 @@
|
|||||||
input.value = parent[key] || "";
|
input.value = parent[key] || "";
|
||||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
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;
|
if (schema.step) input.step = schema.step;
|
||||||
input.addEventListener("input", function () {
|
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);
|
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);
|
col.appendChild(input);
|
||||||
const counter = appendHelp(col, schema, parent[key]);
|
const counter = appendHelp(col, schema, parent[key]);
|
||||||
@@ -524,52 +1018,113 @@
|
|||||||
function renderIconField(schema, col, parent, key) {
|
function renderIconField(schema, col, parent, key) {
|
||||||
const selected = parent[key] || "";
|
const selected = parent[key] || "";
|
||||||
const wrapper = document.createElement("div");
|
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");
|
const triggerValue = document.createElement("div");
|
||||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
triggerValue.className = "cms-icon-dropdown-value";
|
||||||
|
|
||||||
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 preview = document.createElement("div");
|
const preview = document.createElement("div");
|
||||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
preview.className = "cms-icon-preview";
|
||||||
preview.style.width = "52px";
|
triggerValue.appendChild(preview);
|
||||||
preview.innerHTML = selected
|
|
||||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
|
||||||
: '<span class="text-muted small">--</span>';
|
|
||||||
top.appendChild(preview);
|
|
||||||
wrapper.appendChild(top);
|
|
||||||
|
|
||||||
const grid = document.createElement("div");
|
const triggerText = document.createElement("div");
|
||||||
grid.className = "d-flex flex-wrap gap-2";
|
triggerText.className = "cms-icon-dropdown-text";
|
||||||
(schema.options || []).forEach((option) => {
|
triggerValue.appendChild(triggerText);
|
||||||
|
trigger.appendChild(triggerValue);
|
||||||
|
|
||||||
|
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");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`;
|
||||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
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 () {
|
button.addEventListener("click", function () {
|
||||||
parent[key] = option;
|
parent[key] = option;
|
||||||
input.value = option;
|
searchInput.value = option;
|
||||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
updateTrigger(option);
|
||||||
renderAllSections();
|
renderOptions(option);
|
||||||
|
setOpen(false);
|
||||||
});
|
});
|
||||||
grid.appendChild(button);
|
optionsWrap.appendChild(button);
|
||||||
});
|
});
|
||||||
wrapper.appendChild(grid);
|
};
|
||||||
|
|
||||||
input.addEventListener("input", function () {
|
trigger.addEventListener("click", function () {
|
||||||
parent[key] = input.value;
|
const nextOpen = panel.classList.contains("d-none");
|
||||||
preview.innerHTML = input.value
|
setOpen(nextOpen);
|
||||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
if (nextOpen) {
|
||||||
: '<span class="text-muted small">--</span>';
|
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);
|
col.appendChild(wrapper);
|
||||||
appendHelp(col, schema, parent[key]);
|
appendHelp(col, schema, parent[key]);
|
||||||
}
|
}
|
||||||
@@ -636,6 +1191,35 @@
|
|||||||
counter.textContent = `${currentLength}/${maxLength}`;
|
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) {
|
function openImagePicker(imageType, onSuccess) {
|
||||||
const fileInput = document.createElement("input");
|
const fileInput = document.createElement("input");
|
||||||
fileInput.type = "file";
|
fileInput.type = "file";
|
||||||
@@ -883,3 +1467,4 @@
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -468,37 +468,46 @@
|
|||||||
renderAllSections();
|
renderAllSections();
|
||||||
});
|
});
|
||||||
col.appendChild(input);
|
col.appendChild(input);
|
||||||
appendHelp(col, schema, parent[key]);
|
appendHelp(col, { ...schema, maxLength: undefined }, parent[key]);
|
||||||
container.appendChild(col);
|
container.appendChild(col);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (schema.type === "combobox") {
|
if (schema.type === "combobox") {
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("select");
|
||||||
const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
|
input.className = "form-select";
|
||||||
input.className = "form-control";
|
const options = resolveOptions(schema, context.root);
|
||||||
input.type = "text";
|
const optionValues = options.map((option) =>
|
||||||
input.value = parent[key] || "";
|
typeof option === "string" ? option : option.value,
|
||||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
);
|
||||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
|
||||||
input.setAttribute("list", listId);
|
options.forEach((option) => {
|
||||||
input.addEventListener("input", function () {
|
const optionEl = document.createElement("option");
|
||||||
parent[key] = input.value;
|
if (typeof option === "string") {
|
||||||
updateCounter(counter, input.value.length, schema.maxLength);
|
optionEl.value = option;
|
||||||
|
optionEl.textContent = option;
|
||||||
|
} else {
|
||||||
|
optionEl.value = option.value;
|
||||||
|
optionEl.textContent = option.label;
|
||||||
|
}
|
||||||
|
input.appendChild(optionEl);
|
||||||
});
|
});
|
||||||
|
|
||||||
const dataList = document.createElement("datalist");
|
if (parent[key] && !optionValues.includes(parent[key])) {
|
||||||
dataList.id = listId;
|
const legacyOption = document.createElement("option");
|
||||||
resolveOptions(schema, context.root).forEach((option) => {
|
legacyOption.value = parent[key];
|
||||||
const item = document.createElement("option");
|
legacyOption.textContent = parent[key];
|
||||||
item.value = typeof option === "string" ? option : option.value;
|
input.appendChild(legacyOption);
|
||||||
item.label = typeof option === "string" ? option : option.label;
|
}
|
||||||
dataList.appendChild(item);
|
|
||||||
|
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(input);
|
||||||
col.appendChild(dataList);
|
appendHelp(col, { ...schema, maxLength: undefined }, parent[key]);
|
||||||
const counter = appendHelp(col, schema, parent[key]);
|
|
||||||
container.appendChild(col);
|
container.appendChild(col);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -524,52 +533,113 @@
|
|||||||
function renderIconField(schema, col, parent, key) {
|
function renderIconField(schema, col, parent, key) {
|
||||||
const selected = parent[key] || "";
|
const selected = parent[key] || "";
|
||||||
const wrapper = document.createElement("div");
|
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");
|
const triggerValue = document.createElement("div");
|
||||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
triggerValue.className = "cms-icon-dropdown-value";
|
||||||
|
|
||||||
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 preview = document.createElement("div");
|
const preview = document.createElement("div");
|
||||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
preview.className = "cms-icon-preview";
|
||||||
preview.style.width = "52px";
|
triggerValue.appendChild(preview);
|
||||||
preview.innerHTML = selected
|
|
||||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
|
||||||
: '<span class="text-muted small">--</span>';
|
|
||||||
top.appendChild(preview);
|
|
||||||
wrapper.appendChild(top);
|
|
||||||
|
|
||||||
const grid = document.createElement("div");
|
const triggerText = document.createElement("div");
|
||||||
grid.className = "d-flex flex-wrap gap-2";
|
triggerText.className = "cms-icon-dropdown-text";
|
||||||
(schema.options || []).forEach((option) => {
|
triggerValue.appendChild(triggerText);
|
||||||
|
trigger.appendChild(triggerValue);
|
||||||
|
|
||||||
|
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");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`;
|
||||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
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 () {
|
button.addEventListener("click", function () {
|
||||||
parent[key] = option;
|
parent[key] = option;
|
||||||
input.value = option;
|
searchInput.value = option;
|
||||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
updateTrigger(option);
|
||||||
renderAllSections();
|
renderOptions(option);
|
||||||
|
setOpen(false);
|
||||||
});
|
});
|
||||||
grid.appendChild(button);
|
optionsWrap.appendChild(button);
|
||||||
});
|
});
|
||||||
wrapper.appendChild(grid);
|
};
|
||||||
|
|
||||||
input.addEventListener("input", function () {
|
trigger.addEventListener("click", function () {
|
||||||
parent[key] = input.value;
|
const nextOpen = panel.classList.contains("d-none");
|
||||||
preview.innerHTML = input.value
|
setOpen(nextOpen);
|
||||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
if (nextOpen) {
|
||||||
: '<span class="text-muted small">--</span>';
|
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);
|
col.appendChild(wrapper);
|
||||||
appendHelp(col, schema, parent[key]);
|
appendHelp(col, schema, parent[key]);
|
||||||
}
|
}
|
||||||
@@ -607,7 +677,7 @@
|
|||||||
container.appendChild(col);
|
container.appendChild(col);
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendHelp(col, schema, value, extraHint) {
|
function appendHelp(col, schema, value, extraHint, providedCounter) {
|
||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
wrapper.className = "field-meta-row";
|
wrapper.className = "field-meta-row";
|
||||||
|
|
||||||
@@ -616,11 +686,14 @@
|
|||||||
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
|
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
|
||||||
wrapper.appendChild(help);
|
wrapper.appendChild(help);
|
||||||
|
|
||||||
let counter = null;
|
let counter = providedCounter || null;
|
||||||
if (schema.maxLength) {
|
if (!counter && schema.maxLength) {
|
||||||
counter = document.createElement("div");
|
counter = document.createElement("div");
|
||||||
counter.className = "field-char-count";
|
counter.className = "field-char-count";
|
||||||
updateCounter(counter, String(value || "").length, schema.maxLength);
|
updateCounter(counter, String(value || "").length, schema.maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counter) {
|
||||||
wrapper.appendChild(counter);
|
wrapper.appendChild(counter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -883,3 +956,4 @@
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="row g-3 mb-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>
|
<label class="form-label fw-semibold">Section heading</label>
|
||||||
<input class="form-control" id="directoryHeading" maxlength="70" value="<%= data.directory?.heading || '' %>">
|
<input class="form-control" id="directoryHeading" maxlength="70" value="<%= data.directory?.heading || '' %>">
|
||||||
</div>
|
</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">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">Section description</label>
|
<label class="form-label fw-semibold">Section description</label>
|
||||||
<textarea class="form-control" id="directoryDescription" rows="3" maxlength="180"><%= data.directory?.description || '' %></textarea>
|
<textarea class="form-control" id="directoryDescription" rows="3" maxlength="180"><%= data.directory?.description || '' %></textarea>
|
||||||
@@ -36,7 +32,7 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label fw-semibold mb-1">Partners</label>
|
<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>
|
</div>
|
||||||
<div id="partnersList"></div>
|
<div id="partnersList"></div>
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = JSON.parse(JSON.stringify(initialData));
|
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 = {
|
const templates = {
|
||||||
tab: document.getElementById("directoryTabTemplate"),
|
tab: document.getElementById("directoryTabTemplate"),
|
||||||
partner: document.getElementById("partnerTemplate"),
|
partner: document.getElementById("partnerTemplate"),
|
||||||
@@ -21,8 +27,10 @@
|
|||||||
inquiryOption: document.getElementById("inquiryOptionTemplate"),
|
inquiryOption: document.getElementById("inquiryOptionTemplate"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ensurePartnershipIds();
|
||||||
bindStaticEvents();
|
bindStaticEvents();
|
||||||
renderAll();
|
renderAll();
|
||||||
|
initStaticCounters();
|
||||||
|
|
||||||
function bindStaticEvents() {
|
function bindStaticEvents() {
|
||||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||||
@@ -43,7 +51,6 @@
|
|||||||
|
|
||||||
document.getElementById("addPartnerBtn")?.addEventListener("click", function () {
|
document.getElementById("addPartnerBtn")?.addEventListener("click", function () {
|
||||||
state.directory.partners.push({
|
state.directory.partners.push({
|
||||||
id: "",
|
|
||||||
name: "",
|
name: "",
|
||||||
category: "",
|
category: "",
|
||||||
summary: "",
|
summary: "",
|
||||||
@@ -53,12 +60,12 @@
|
|||||||
collabType: "",
|
collabType: "",
|
||||||
benefits: "",
|
benefits: "",
|
||||||
});
|
});
|
||||||
|
ensurePartnershipIds();
|
||||||
renderPartners();
|
renderPartners();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () {
|
document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () {
|
||||||
state.inquiryForm.fields.push({
|
state.inquiryForm.fields.push({
|
||||||
id: "",
|
|
||||||
label: "",
|
label: "",
|
||||||
placeholder: "",
|
placeholder: "",
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -66,6 +73,7 @@
|
|||||||
required: true,
|
required: true,
|
||||||
options: [],
|
options: [],
|
||||||
});
|
});
|
||||||
|
ensurePartnershipIds();
|
||||||
renderInquiryFields();
|
renderInquiryFields();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,6 +83,7 @@
|
|||||||
|
|
||||||
form.addEventListener("submit", function () {
|
form.addEventListener("submit", function () {
|
||||||
syncStaticFields();
|
syncStaticFields();
|
||||||
|
ensurePartnershipIds();
|
||||||
pageJsonInput.value = JSON.stringify(state);
|
pageJsonInput.value = JSON.stringify(state);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,7 +114,6 @@
|
|||||||
|
|
||||||
state.directory.heading = getValue("directoryHeading");
|
state.directory.heading = getValue("directoryHeading");
|
||||||
state.directory.description = getValue("directoryDescription");
|
state.directory.description = getValue("directoryDescription");
|
||||||
state.directory.loadMoreLabel = getValue("directoryLoadMoreLabel");
|
|
||||||
|
|
||||||
state.cta = {
|
state.cta = {
|
||||||
heading: getValue("ctaHeading"),
|
heading: getValue("ctaHeading"),
|
||||||
@@ -114,7 +122,34 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
state.inquiryForm.title = getValue("inquiryTitle");
|
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() {
|
function renderAll() {
|
||||||
@@ -143,6 +178,7 @@
|
|||||||
node.querySelector("[data-toggle-item]")?.addEventListener("click", function () {
|
node.querySelector("[data-toggle-item]")?.addEventListener("click", function () {
|
||||||
node.classList.toggle("is-collapsed");
|
node.classList.toggle("is-collapsed");
|
||||||
});
|
});
|
||||||
|
attachCounters(node);
|
||||||
|
|
||||||
directoryTabsList.appendChild(node);
|
directoryTabsList.appendChild(node);
|
||||||
});
|
});
|
||||||
@@ -210,6 +246,7 @@
|
|||||||
node.querySelector("[data-toggle-item]")?.addEventListener("click", function () {
|
node.querySelector("[data-toggle-item]")?.addEventListener("click", function () {
|
||||||
node.classList.toggle("is-collapsed");
|
node.classList.toggle("is-collapsed");
|
||||||
});
|
});
|
||||||
|
attachCounters(node);
|
||||||
|
|
||||||
partnersList.appendChild(node);
|
partnersList.appendChild(node);
|
||||||
});
|
});
|
||||||
@@ -304,6 +341,7 @@
|
|||||||
field.options.splice(optionIndex, 1);
|
field.options.splice(optionIndex, 1);
|
||||||
renderInquiryFields();
|
renderInquiryFields();
|
||||||
});
|
});
|
||||||
|
attachCounters(optionNode);
|
||||||
optionsList.appendChild(optionNode);
|
optionsList.appendChild(optionNode);
|
||||||
});
|
});
|
||||||
initSortable(optionsList, field.options, renderInquiryFields, '[data-item="inquiry-option"]');
|
initSortable(optionsList, field.options, renderInquiryFields, '[data-item="inquiry-option"]');
|
||||||
@@ -311,6 +349,7 @@
|
|||||||
|
|
||||||
toggleOptions();
|
toggleOptions();
|
||||||
renderOptions();
|
renderOptions();
|
||||||
|
attachCounters(node);
|
||||||
inquiryFieldsList.appendChild(node);
|
inquiryFieldsList.appendChild(node);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -339,6 +378,74 @@
|
|||||||
return template.content.firstElementChild.cloneNode(true);
|
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) {
|
function getValue(id) {
|
||||||
return document.getElementById(id)?.value || "";
|
return document.getElementById(id)?.value || "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="row g-3 mb-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>
|
<label class="form-label fw-semibold">Modal title</label>
|
||||||
<input class="form-control" id="inquiryTitle" maxlength="60" value="<%= data.inquiryForm?.title || '' %>">
|
<input class="form-control" id="inquiryTitle" maxlength="60" value="<%= data.inquiryForm?.title || '' %>">
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="cms-editor-group">
|
<div class="cms-editor-group">
|
||||||
|
|||||||
@@ -46,11 +46,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row g-3">
|
<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">
|
<div class="col-md-4">
|
||||||
<label class="form-label fw-semibold">Partner name</label>
|
<label class="form-label fw-semibold">Partner name</label>
|
||||||
<input class="form-control" data-field="name" maxlength="90">
|
<input class="form-control" data-field="name" maxlength="90">
|
||||||
@@ -119,10 +114,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row g-3">
|
<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">
|
<div class="col-md-4">
|
||||||
<label class="form-label fw-semibold">Field label</label>
|
<label class="form-label fw-semibold">Field label</label>
|
||||||
<input class="form-control" data-field="label" maxlength="40">
|
<input class="form-control" data-field="label" maxlength="40">
|
||||||
|
|||||||
@@ -524,52 +524,113 @@
|
|||||||
function renderIconField(schema, col, parent, key) {
|
function renderIconField(schema, col, parent, key) {
|
||||||
const selected = parent[key] || "";
|
const selected = parent[key] || "";
|
||||||
const wrapper = document.createElement("div");
|
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");
|
const triggerValue = document.createElement("div");
|
||||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
triggerValue.className = "cms-icon-dropdown-value";
|
||||||
|
|
||||||
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 preview = document.createElement("div");
|
const preview = document.createElement("div");
|
||||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
preview.className = "cms-icon-preview";
|
||||||
preview.style.width = "52px";
|
triggerValue.appendChild(preview);
|
||||||
preview.innerHTML = selected
|
|
||||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
|
||||||
: '<span class="text-muted small">--</span>';
|
|
||||||
top.appendChild(preview);
|
|
||||||
wrapper.appendChild(top);
|
|
||||||
|
|
||||||
const grid = document.createElement("div");
|
const triggerText = document.createElement("div");
|
||||||
grid.className = "d-flex flex-wrap gap-2";
|
triggerText.className = "cms-icon-dropdown-text";
|
||||||
(schema.options || []).forEach((option) => {
|
triggerValue.appendChild(triggerText);
|
||||||
|
trigger.appendChild(triggerValue);
|
||||||
|
|
||||||
|
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");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`;
|
||||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
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 () {
|
button.addEventListener("click", function () {
|
||||||
parent[key] = option;
|
parent[key] = option;
|
||||||
input.value = option;
|
searchInput.value = option;
|
||||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
updateTrigger(option);
|
||||||
renderAllSections();
|
renderOptions(option);
|
||||||
|
setOpen(false);
|
||||||
});
|
});
|
||||||
grid.appendChild(button);
|
optionsWrap.appendChild(button);
|
||||||
});
|
});
|
||||||
wrapper.appendChild(grid);
|
};
|
||||||
|
|
||||||
input.addEventListener("input", function () {
|
trigger.addEventListener("click", function () {
|
||||||
parent[key] = input.value;
|
const nextOpen = panel.classList.contains("d-none");
|
||||||
preview.innerHTML = input.value
|
setOpen(nextOpen);
|
||||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
if (nextOpen) {
|
||||||
: '<span class="text-muted small">--</span>';
|
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);
|
col.appendChild(wrapper);
|
||||||
appendHelp(col, schema, parent[key]);
|
appendHelp(col, schema, parent[key]);
|
||||||
}
|
}
|
||||||
@@ -883,3 +944,4 @@
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -524,52 +524,113 @@
|
|||||||
function renderIconField(schema, col, parent, key) {
|
function renderIconField(schema, col, parent, key) {
|
||||||
const selected = parent[key] || "";
|
const selected = parent[key] || "";
|
||||||
const wrapper = document.createElement("div");
|
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");
|
const triggerValue = document.createElement("div");
|
||||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
triggerValue.className = "cms-icon-dropdown-value";
|
||||||
|
|
||||||
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 preview = document.createElement("div");
|
const preview = document.createElement("div");
|
||||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
preview.className = "cms-icon-preview";
|
||||||
preview.style.width = "52px";
|
triggerValue.appendChild(preview);
|
||||||
preview.innerHTML = selected
|
|
||||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
|
||||||
: '<span class="text-muted small">--</span>';
|
|
||||||
top.appendChild(preview);
|
|
||||||
wrapper.appendChild(top);
|
|
||||||
|
|
||||||
const grid = document.createElement("div");
|
const triggerText = document.createElement("div");
|
||||||
grid.className = "d-flex flex-wrap gap-2";
|
triggerText.className = "cms-icon-dropdown-text";
|
||||||
(schema.options || []).forEach((option) => {
|
triggerValue.appendChild(triggerText);
|
||||||
|
trigger.appendChild(triggerValue);
|
||||||
|
|
||||||
|
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");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`;
|
||||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
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 () {
|
button.addEventListener("click", function () {
|
||||||
parent[key] = option;
|
parent[key] = option;
|
||||||
input.value = option;
|
searchInput.value = option;
|
||||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
updateTrigger(option);
|
||||||
renderAllSections();
|
renderOptions(option);
|
||||||
|
setOpen(false);
|
||||||
});
|
});
|
||||||
grid.appendChild(button);
|
optionsWrap.appendChild(button);
|
||||||
});
|
});
|
||||||
wrapper.appendChild(grid);
|
};
|
||||||
|
|
||||||
input.addEventListener("input", function () {
|
trigger.addEventListener("click", function () {
|
||||||
parent[key] = input.value;
|
const nextOpen = panel.classList.contains("d-none");
|
||||||
preview.innerHTML = input.value
|
setOpen(nextOpen);
|
||||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
if (nextOpen) {
|
||||||
: '<span class="text-muted small">--</span>';
|
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);
|
col.appendChild(wrapper);
|
||||||
appendHelp(col, schema, parent[key]);
|
appendHelp(col, schema, parent[key]);
|
||||||
}
|
}
|
||||||
@@ -883,3 +944,4 @@
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -477,6 +477,150 @@
|
|||||||
margin-left: auto;
|
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 {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user