Merge pull request 'fea/dat-20042026-CMS-Partnerships-Accreditation-History-Admissions-Policies' (#7) from fea/dat-20042026-CMS-Partnerships-Accreditation-History-Admissions-Policies into develop
Reviewed-on: UKSOURCE/cms.lams#7
@@ -22,6 +22,7 @@ pids
|
||||
#cursor
|
||||
.cursor
|
||||
package-lock.json
|
||||
AGENTS.md
|
||||
|
||||
.vscode
|
||||
.kiro/
|
||||
|
||||
@@ -22,6 +22,11 @@ const AUDIT_ACTIONS = Object.freeze({
|
||||
|
||||
// About Us
|
||||
UPDATE_ABOUT_US: "UPDATE_ABOUT_US",
|
||||
UPDATE_PARTNERSHIPS: "UPDATE_PARTNERSHIPS",
|
||||
UPDATE_HISTORY: "UPDATE_HISTORY",
|
||||
UPDATE_ACCREDITATION: "UPDATE_ACCREDITATION",
|
||||
UPDATE_ADMISSIONS: "UPDATE_ADMISSIONS",
|
||||
UPDATE_POLICIES: "UPDATE_POLICIES",
|
||||
|
||||
// Header
|
||||
UPDATE_HEADER: "UPDATE_HEADER",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const jsonHelper = require("../utils/jsonHelper");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
|
||||
function createPageContentController({
|
||||
model,
|
||||
modelName,
|
||||
auditAction,
|
||||
editorConfig,
|
||||
normalizeForEditor,
|
||||
normalizeForApi,
|
||||
preparePayload,
|
||||
}) {
|
||||
return {
|
||||
async index(req, res) {
|
||||
try {
|
||||
const doc = await model.getSingle();
|
||||
const rawData = doc.toObject();
|
||||
const data = normalizeForEditor ? normalizeForEditor(rawData, req) : rawData;
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const backendUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
|
||||
res.render("admin/pageContent/index", {
|
||||
layout: "layouts/main",
|
||||
title: editorConfig.title,
|
||||
subtitle: editorConfig.subtitle,
|
||||
data,
|
||||
editorConfig,
|
||||
activeTab: req.query.tab || editorConfig.tabs[0].key,
|
||||
frontendUrl,
|
||||
backendUrl,
|
||||
previewUrl: `${frontendUrl}${editorConfig.previewPath}`,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`${editorConfig.key} index error:`, error);
|
||||
req.flash("error_msg", `Error loading ${editorConfig.title}`);
|
||||
return req.session.save(() => res.redirect("/admin/dashboard"));
|
||||
}
|
||||
},
|
||||
|
||||
async update(req, res) {
|
||||
try {
|
||||
const rawPayload =
|
||||
typeof req.body.pageJson === "string"
|
||||
? JSON.parse(req.body.pageJson)
|
||||
: req.body.pageJson || {};
|
||||
|
||||
const activeTab = req.body.activeTab || editorConfig.tabs[0].key;
|
||||
const doc = await model.getSingle();
|
||||
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||
const payload = preparePayload
|
||||
? preparePayload(rawPayload, { req, doc, beforeData })
|
||||
: rawPayload;
|
||||
|
||||
doc.set(payload);
|
||||
Object.keys(payload).forEach((key) => doc.markModified(key));
|
||||
await doc.save();
|
||||
|
||||
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: modelName,
|
||||
documentId: doc._id,
|
||||
action: auditAction,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
const finalData = await model
|
||||
.findOne()
|
||||
.select("-_id -__v -createdAt -updatedAt")
|
||||
.lean();
|
||||
jsonHelper.writeJsonFile(editorConfig.dataFile, finalData);
|
||||
|
||||
req.flash("success_msg", `${editorConfig.title} updated successfully`);
|
||||
return req.session.save(() =>
|
||||
res.redirect(`${editorConfig.routeBase}?tab=${activeTab}`),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`${editorConfig.key} update error:`, error);
|
||||
req.flash(
|
||||
"error_msg",
|
||||
`Error updating ${editorConfig.title}: ${error.message}`,
|
||||
);
|
||||
return req.session.save(() =>
|
||||
res.redirect(
|
||||
`${editorConfig.routeBase}?tab=${req.body.activeTab || ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async api(req, res) {
|
||||
try {
|
||||
const doc = await model.getSingle();
|
||||
const rawData = doc.toObject();
|
||||
const backendUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
const normalized = normalizeForApi ? normalizeForApi(rawData, req) : rawData;
|
||||
const processed = addBaseUrlToImages(normalized, backendUrl);
|
||||
return res.json(processed);
|
||||
} catch (error) {
|
||||
console.error(`${editorConfig.key} api error:`, error);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Error loading ${editorConfig.key} data` });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = createPageContentController;
|
||||
@@ -0,0 +1,36 @@
|
||||
function createRenderSingletonPageView({
|
||||
model,
|
||||
editorConfig,
|
||||
view,
|
||||
normalizeForEditor,
|
||||
}) {
|
||||
return async function renderSingletonPageView(req, res) {
|
||||
const doc = await model.getSingle();
|
||||
const rawData = doc.toObject();
|
||||
const data = normalizeForEditor ? normalizeForEditor(rawData, req) : rawData;
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const backendUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
const defaultTab = editorConfig.tabs[0]?.key;
|
||||
const requestedTab = req.query.tab;
|
||||
const activeTab = editorConfig.tabs.some((tab) => tab.key === requestedTab)
|
||||
? requestedTab
|
||||
: defaultTab;
|
||||
|
||||
return res.render(view, {
|
||||
layout: "layouts/main",
|
||||
title: editorConfig.title,
|
||||
subtitle: editorConfig.subtitle,
|
||||
data,
|
||||
editorConfig,
|
||||
activeTab,
|
||||
frontendUrl,
|
||||
backendUrl,
|
||||
previewUrl: `${frontendUrl}${editorConfig.previewPath}`,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = createRenderSingletonPageView;
|
||||
@@ -0,0 +1,56 @@
|
||||
const AccreditationPage = require("../models/accreditationPage");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
const accreditationConfig = require("../utils/contentEditors/accreditationConfig");
|
||||
const createPageContentController = require("./_createPageContentController");
|
||||
const createRenderSingletonPageView = require("./_renderSingletonPageView");
|
||||
|
||||
const controller = createPageContentController({
|
||||
model: AccreditationPage,
|
||||
modelName: "AccreditationPage",
|
||||
auditAction: AUDIT_ACTIONS.UPDATE_ACCREDITATION,
|
||||
editorConfig: accreditationConfig,
|
||||
preparePayload(rawPayload) {
|
||||
const payload = JSON.parse(JSON.stringify(rawPayload || {}));
|
||||
const tabs = Array.isArray(payload?.grid?.tabs) ? payload.grid.tabs : [];
|
||||
const seenTabs = new Set();
|
||||
const duplicateTabs = [];
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
const normalizedTab = String(tab || "").trim().toLowerCase();
|
||||
if (!normalizedTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (seenTabs.has(normalizedTab)) {
|
||||
duplicateTabs.push(String(tab || "").trim());
|
||||
return;
|
||||
}
|
||||
|
||||
seenTabs.add(normalizedTab);
|
||||
});
|
||||
|
||||
if (duplicateTabs.length > 0) {
|
||||
throw new Error(
|
||||
`Category tab already exists: ${duplicateTabs[0]}. Please use unique tab names.`,
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
|
||||
controller.index = async function index(req, res) {
|
||||
try {
|
||||
return await createRenderSingletonPageView({
|
||||
model: AccreditationPage,
|
||||
editorConfig: accreditationConfig,
|
||||
view: "admin/accreditation/index",
|
||||
})(req, res);
|
||||
} catch (error) {
|
||||
console.error("accreditation index error:", error);
|
||||
req.flash("error_msg", "Error loading Accreditation Management");
|
||||
return req.session.save(() => res.redirect("/admin/dashboard"));
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = controller;
|
||||
@@ -0,0 +1,332 @@
|
||||
const AdmissionsPage = require("../models/admissionsPage");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
const admissionsConfig = require("../utils/contentEditors/admissionsConfig");
|
||||
const createPageContentController = require("./_createPageContentController");
|
||||
const createRenderSingletonPageView = require("./_renderSingletonPageView");
|
||||
const jsonHelper = require("../utils/jsonHelper");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const { ensureUniqueIds } = require("../utils/contentEditorIds");
|
||||
const { ICON_OPTIONS } = require("../utils/contentEditors/sharedFields");
|
||||
|
||||
function getAdmissionsEditorUi() {
|
||||
return admissionsConfig.editorUi || {};
|
||||
}
|
||||
|
||||
function getDefaultCalculatorOptionValues() {
|
||||
return getAdmissionsEditorUi().calculator?.defaultOption || {};
|
||||
}
|
||||
|
||||
function getCalculatorOptionFieldConfig(fieldKey) {
|
||||
const calculatorTab = (admissionsConfig.tabs || []).find((tab) => tab.key === "calculator");
|
||||
const calculatorFields = calculatorTab?.schema?.fields || [];
|
||||
const optionsField = calculatorFields.find((field) => field.key === "options");
|
||||
const optionItemFields = optionsField?.itemSchema?.fields || [];
|
||||
|
||||
return optionItemFields.find((field) => field.key === fieldKey) || {};
|
||||
}
|
||||
|
||||
function hasDuplicateCalculatorOptionLabel(options, currentOptionId, nextLabel) {
|
||||
const normalizedLabel = String(nextLabel || "").trim().toLowerCase();
|
||||
if (!normalizedLabel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (options || []).some((option) => {
|
||||
if (String(option?.id || "") === String(currentOptionId || "")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return String(option?.label || "").trim().toLowerCase() === normalizedLabel;
|
||||
});
|
||||
}
|
||||
|
||||
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 || {}) };
|
||||
const defaultOption = getDefaultCalculatorOptionValues();
|
||||
const labelMaxLength = getCalculatorOptionFieldConfig("label").maxLength || 12;
|
||||
|
||||
return {
|
||||
...source,
|
||||
label: String(source.label || source.title || `Option ${index + 1}`).slice(0, labelMaxLength),
|
||||
paceLabel: String(source.paceLabel || calculator.paceLabel || defaultOption.paceLabel || "Target Pace"),
|
||||
minPaceLabel: String(source.minPaceLabel || calculator.minPaceLabel || defaultOption.minPaceLabel || "Relaxed"),
|
||||
maxPaceLabel: String(source.maxPaceLabel || calculator.maxPaceLabel || defaultOption.maxPaceLabel || "Accelerated"),
|
||||
resultLabel: String(
|
||||
source.resultLabel || calculator.resultLabel || defaultOption.resultLabel || "Estimated Monthly Payment",
|
||||
),
|
||||
monthlyAmount: normalizePositiveAmount(
|
||||
source.monthlyAmount || calculator.monthlyAmount || defaultOption.monthlyAmount || "299",
|
||||
String(defaultOption.monthlyAmount || "299"),
|
||||
),
|
||||
monthlySuffix: String(source.monthlySuffix || calculator.monthlySuffix || defaultOption.monthlySuffix || "/mo"),
|
||||
noteIcon: String(source.noteIcon || calculator.noteIcon || defaultOption.noteIcon || "fa-bolt"),
|
||||
note: String(source.note || calculator.note || defaultOption.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 normalizeTuitionPoint(point, index) {
|
||||
if (point && typeof point === "object" && !Array.isArray(point)) {
|
||||
const numericValue = Number(point.value);
|
||||
return {
|
||||
time: String(point.time || point.label || `Year ${index + 1}`),
|
||||
value: Number.isFinite(numericValue) && numericValue >= 0 ? numericValue : 0,
|
||||
};
|
||||
}
|
||||
|
||||
const numericValue = Number(point);
|
||||
return {
|
||||
time: `Year ${index + 1}`,
|
||||
value: Number.isFinite(numericValue) && numericValue >= 0 ? numericValue : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTuitionSeries(series, index) {
|
||||
const source = typeof series === "string" ? { label: series } : { ...(series || {}) };
|
||||
const rawPoints = Array.isArray(source.points) && source.points.length
|
||||
? source.points
|
||||
: Array.isArray(source.values)
|
||||
? source.values
|
||||
: [];
|
||||
|
||||
return {
|
||||
label: String(source.label || `Series ${index + 1}`).slice(0, 40),
|
||||
color: String(source.color || "#0F172A"),
|
||||
points: rawPoints.map(normalizeTuitionPoint),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTuition(tuition) {
|
||||
const nextTuition = { ...(tuition || {}) };
|
||||
|
||||
nextTuition.title = String(nextTuition.title || "");
|
||||
nextTuition.chartTitle = String(nextTuition.chartTitle || "");
|
||||
nextTuition.chartDescription = String(nextTuition.chartDescription || "");
|
||||
nextTuition.series = Array.isArray(nextTuition.series)
|
||||
? nextTuition.series.map(normalizeTuitionSeries)
|
||||
: [];
|
||||
|
||||
return nextTuition;
|
||||
}
|
||||
|
||||
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 = {
|
||||
...normalizeTuition(payload.tuition),
|
||||
id: "tuition-breakdown",
|
||||
};
|
||||
payload.keyDates = {
|
||||
...(payload.keyDates || {}),
|
||||
id: "key-dates",
|
||||
};
|
||||
payload.calculator = normalizeCalculator(payload.calculator);
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
const controller = createPageContentController({
|
||||
model: AdmissionsPage,
|
||||
modelName: "AdmissionsPage",
|
||||
auditAction: AUDIT_ACTIONS.UPDATE_ADMISSIONS,
|
||||
editorConfig: admissionsConfig,
|
||||
preparePayload: normalizeAdmissionsPayload,
|
||||
normalizeForEditor: normalizeAdmissionsPayload,
|
||||
normalizeForApi: normalizeAdmissionsPayload,
|
||||
});
|
||||
|
||||
controller.index = async function index(req, res) {
|
||||
try {
|
||||
return await createRenderSingletonPageView({
|
||||
model: AdmissionsPage,
|
||||
editorConfig: admissionsConfig,
|
||||
view: "admin/admissions/index",
|
||||
normalizeForEditor: normalizeAdmissionsPayload,
|
||||
})(req, res);
|
||||
} catch (error) {
|
||||
console.error("admissions index error:", error);
|
||||
req.flash("error_msg", "Error loading Admissions Management");
|
||||
return req.session.save(() => res.redirect("/admin/dashboard"));
|
||||
}
|
||||
};
|
||||
|
||||
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")}`;
|
||||
const fieldKeys = [
|
||||
"label",
|
||||
"paceLabel",
|
||||
"minPaceLabel",
|
||||
"maxPaceLabel",
|
||||
"resultLabel",
|
||||
"monthlyAmount",
|
||||
"monthlySuffix",
|
||||
"noteIcon",
|
||||
"note",
|
||||
];
|
||||
const fieldConfig = fieldKeys.reduce((acc, key) => {
|
||||
acc[key] = getCalculatorOptionFieldConfig(key);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return res.render("admin/admissions/calculator-option", {
|
||||
layout: "layouts/main",
|
||||
title: `Edit ${option.label}`,
|
||||
subtitle: "Update the calculator option details",
|
||||
option,
|
||||
existingOptionLabels: data.calculator.options
|
||||
.filter((item) => item.id !== optionId)
|
||||
.map((item) => item.label)
|
||||
.filter(Boolean),
|
||||
fieldLimits: {
|
||||
label: fieldConfig.label.maxLength || 12,
|
||||
paceLabel: fieldConfig.paceLabel.maxLength || 20,
|
||||
minPaceLabel: fieldConfig.minPaceLabel.maxLength || 7,
|
||||
maxPaceLabel: fieldConfig.maxPaceLabel.maxLength || 7,
|
||||
resultLabel: fieldConfig.resultLabel.maxLength || 40,
|
||||
monthlySuffix: fieldConfig.monthlySuffix.maxLength || 10,
|
||||
note: fieldConfig.note.maxLength || 60,
|
||||
},
|
||||
fieldConfig,
|
||||
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"));
|
||||
}
|
||||
|
||||
const nextLabel = String(req.body.label || "").trim().slice(0, getCalculatorOptionFieldConfig("label").maxLength || 12);
|
||||
if (hasDuplicateCalculatorOptionLabel(payload.calculator.options, optionId, nextLabel)) {
|
||||
req.flash("error_msg", `Option label "${nextLabel}" already exists. Please use a unique label.`);
|
||||
return req.session.save(() => res.redirect(`/admin/admissions/calculator/${optionId}`));
|
||||
}
|
||||
|
||||
payload.calculator.options[optionIndex] = {
|
||||
...payload.calculator.options[optionIndex],
|
||||
label: nextLabel,
|
||||
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;
|
||||
@@ -0,0 +1,125 @@
|
||||
const HistoryPage = require("../models/historyPage");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
const historyConfig = require("../utils/contentEditors/historyConfig");
|
||||
const createPageContentController = require("./_createPageContentController");
|
||||
const createRenderSingletonPageView = require("./_renderSingletonPageView");
|
||||
const { ensureUniqueIds } = require("../utils/contentEditorIds");
|
||||
|
||||
function getFrontendUrl(req) {
|
||||
return (process.env.FRONTEND_URL || "http://localhost:3000").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function normalizeInternalHistoryHref(rawHref, req) {
|
||||
const href = String(rawHref || "").trim();
|
||||
|
||||
if (!href) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (href.startsWith("#")) {
|
||||
return href;
|
||||
}
|
||||
|
||||
if (href.startsWith("/")) {
|
||||
return href;
|
||||
}
|
||||
|
||||
const frontendUrl = getFrontendUrl(req);
|
||||
|
||||
try {
|
||||
const url = new URL(href);
|
||||
const frontendOrigin = new URL(frontendUrl).origin;
|
||||
|
||||
if (url.origin !== frontendOrigin) {
|
||||
throw new Error("Highlight link only supports internal anchors or frontend paths.");
|
||||
}
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}` || "/";
|
||||
} catch (error) {
|
||||
if (href.startsWith("http://") || href.startsWith("https://")) {
|
||||
throw new Error("Highlight link only supports internal anchors or frontend paths.");
|
||||
}
|
||||
|
||||
return href.startsWith("?") ? `/about/history${href}` : `/${href.replace(/^\/+/, "")}`;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHistoryForApi(rawData, req) {
|
||||
const data = JSON.parse(JSON.stringify(rawData || {}));
|
||||
const href = data?.highlight?.href;
|
||||
const frontendUrl = getFrontendUrl(req);
|
||||
const backendUrl = `${req.protocol}://${req.get("host")}`.replace(/\/$/, "");
|
||||
|
||||
if (!href || href.startsWith("#")) {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(href)) {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
const frontendOrigin = new URL(frontendUrl).origin;
|
||||
const backendOrigin = new URL(backendUrl).origin;
|
||||
|
||||
if (url.origin === frontendOrigin) {
|
||||
data.highlight.href = `${frontendUrl}${url.pathname}${url.search}${url.hash}`;
|
||||
return data;
|
||||
}
|
||||
|
||||
if (url.origin === backendOrigin) {
|
||||
data.highlight.href = `${frontendUrl}/about/history${url.hash || ""}`;
|
||||
return data;
|
||||
}
|
||||
} catch {
|
||||
data.highlight.href = `${frontendUrl}/about/history`;
|
||||
return data;
|
||||
}
|
||||
|
||||
data.highlight.href = `${frontendUrl}/about/history`;
|
||||
return data;
|
||||
}
|
||||
|
||||
data.highlight.href = `${frontendUrl}${href.startsWith("/") ? href : `/${href}`}`;
|
||||
return data;
|
||||
}
|
||||
|
||||
const controller = createPageContentController({
|
||||
model: HistoryPage,
|
||||
modelName: "HistoryPage",
|
||||
auditAction: AUDIT_ACTIONS.UPDATE_HISTORY,
|
||||
editorConfig: historyConfig,
|
||||
preparePayload(rawPayload, { req }) {
|
||||
const payload = JSON.parse(JSON.stringify(rawPayload || {}));
|
||||
|
||||
if (payload.highlight) {
|
||||
payload.highlight.href = normalizeInternalHistoryHref(payload.highlight.href, req);
|
||||
}
|
||||
|
||||
if (payload.timeline && Array.isArray(payload.timeline.items)) {
|
||||
payload.timeline.items = ensureUniqueIds(
|
||||
payload.timeline.items,
|
||||
(item) => item.id,
|
||||
(item, index) => item.title || item.year || `milestone-${index + 1}`,
|
||||
"milestone",
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
},
|
||||
normalizeForApi: normalizeHistoryForApi,
|
||||
});
|
||||
|
||||
controller.index = async function index(req, res) {
|
||||
try {
|
||||
return await createRenderSingletonPageView({
|
||||
model: HistoryPage,
|
||||
editorConfig: historyConfig,
|
||||
view: "admin/history/index",
|
||||
})(req, res);
|
||||
} catch (error) {
|
||||
console.error("history index error:", error);
|
||||
req.flash("error_msg", "Error loading History Management");
|
||||
return req.session.save(() => res.redirect("/admin/dashboard"));
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = controller;
|
||||
@@ -0,0 +1,223 @@
|
||||
const PartnershipsPage = require("../models/partnerships");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
const partnershipsConfig = require("../utils/contentEditors/partnershipsConfig");
|
||||
const createPageContentController = require("./_createPageContentController");
|
||||
const { ensureUniqueIds } = require("../utils/contentEditorIds");
|
||||
|
||||
function getTabConfig(tabKey) {
|
||||
return (partnershipsConfig.tabs || []).find((tab) => tab.key === tabKey) || {};
|
||||
}
|
||||
|
||||
function getObjectField(tabKey, fieldKey) {
|
||||
const fields = getTabConfig(tabKey)?.schema?.fields || [];
|
||||
return fields.find((field) => field.key === fieldKey) || {};
|
||||
}
|
||||
|
||||
function getArrayItemField(tabKey, arrayKey, fieldKey) {
|
||||
const arrayField = getObjectField(tabKey, arrayKey);
|
||||
const itemFields = arrayField?.itemSchema?.fields || [];
|
||||
return itemFields.find((field) => field.key === fieldKey) || {};
|
||||
}
|
||||
|
||||
function getPartnershipsEditorUi() {
|
||||
return {
|
||||
hero: {
|
||||
badge: getObjectField("hero", "badge"),
|
||||
title: getObjectField("hero", "title"),
|
||||
description: getObjectField("hero", "description"),
|
||||
linkLabel: getObjectField("hero", "linkLabel"),
|
||||
image: getObjectField("hero", "image"),
|
||||
imageAlt: getObjectField("hero", "imageAlt"),
|
||||
},
|
||||
directory: {
|
||||
heading: getObjectField("directory", "heading"),
|
||||
description: getObjectField("directory", "description"),
|
||||
tabs: getObjectField("directory", "tabs"),
|
||||
partnerFields: {
|
||||
name: getArrayItemField("directory", "partners", "name"),
|
||||
category: getArrayItemField("directory", "partners", "category"),
|
||||
summary: getArrayItemField("directory", "partners", "summary"),
|
||||
logo: getArrayItemField("directory", "partners", "logo"),
|
||||
logoAlt: getArrayItemField("directory", "partners", "logoAlt"),
|
||||
about: getArrayItemField("directory", "partners", "about"),
|
||||
collabType: getArrayItemField("directory", "partners", "collabType"),
|
||||
benefits: getArrayItemField("directory", "partners", "benefits"),
|
||||
},
|
||||
tabsFrontendHint: partnershipsConfig.editorUi?.directory?.tabsFrontendHint || "",
|
||||
partnersHelpText: partnershipsConfig.editorUi?.directory?.partnersHelpText || "",
|
||||
},
|
||||
cta: {
|
||||
heading: getObjectField("cta", "heading"),
|
||||
description: getObjectField("cta", "description"),
|
||||
buttonLabel: getObjectField("cta", "buttonLabel"),
|
||||
},
|
||||
inquiryForm: {
|
||||
title: getObjectField("inquiryForm", "title"),
|
||||
fields: getObjectField("inquiryForm", "fields"),
|
||||
fieldFields: {
|
||||
label: getArrayItemField("inquiryForm", "fields", "label"),
|
||||
placeholder: getArrayItemField("inquiryForm", "fields", "placeholder"),
|
||||
type: getArrayItemField("inquiryForm", "fields", "type"),
|
||||
width: getArrayItemField("inquiryForm", "fields", "width"),
|
||||
options: getArrayItemField("inquiryForm", "fields", "options"),
|
||||
required: getArrayItemField("inquiryForm", "fields", "required"),
|
||||
},
|
||||
fieldsHelpText: partnershipsConfig.editorUi?.inquiryForm?.fieldsHelpText || "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toInquiryField(id, field, type, width) {
|
||||
return {
|
||||
id,
|
||||
label: field?.label || "",
|
||||
placeholder: field?.placeholder || "",
|
||||
type,
|
||||
width,
|
||||
required: true,
|
||||
options: Array.isArray(field?.options) ? field.options : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInquiryForm(data) {
|
||||
if (!data?.inquiryForm) {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (Array.isArray(data.inquiryForm.fields)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const legacyFields = data.inquiryForm.fields || {};
|
||||
|
||||
return {
|
||||
...data,
|
||||
inquiryForm: {
|
||||
...data.inquiryForm,
|
||||
fields: [
|
||||
toInquiryField("firstName", legacyFields.firstName, "text", "half"),
|
||||
toInquiryField("lastName", legacyFields.lastName, "text", "half"),
|
||||
toInquiryField("organization", legacyFields.organization, "text", "full"),
|
||||
toInquiryField(
|
||||
"partnershipType",
|
||||
legacyFields.partnershipType,
|
||||
"select",
|
||||
"full",
|
||||
),
|
||||
toInquiryField("message", legacyFields.message, "textarea", "full"),
|
||||
].filter((field) => field.label || field.placeholder || field.id),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function prepareInquiryPayload(payload) {
|
||||
const normalized = normalizeInquiryForm(payload);
|
||||
const fields = Array.isArray(normalized?.inquiryForm?.fields)
|
||||
? normalized.inquiryForm.fields
|
||||
: [];
|
||||
const tabs = Array.isArray(normalized?.directory?.tabs)
|
||||
? normalized.directory.tabs
|
||||
: [];
|
||||
const partners = Array.isArray(normalized?.directory?.partners)
|
||||
? normalized.directory.partners
|
||||
: [];
|
||||
const seenTabs = new Set();
|
||||
const duplicateTabs = [];
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
const normalizedTab = String(tab || "").trim().toLowerCase();
|
||||
if (!normalizedTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (seenTabs.has(normalizedTab)) {
|
||||
duplicateTabs.push(String(tab || "").trim());
|
||||
return;
|
||||
}
|
||||
|
||||
seenTabs.add(normalizedTab);
|
||||
});
|
||||
|
||||
if (duplicateTabs.length > 0) {
|
||||
throw new Error(
|
||||
`Category tab already exists: ${duplicateTabs[0]}. Please use unique tab names.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
directory: {
|
||||
...normalized.directory,
|
||||
partners: ensureUniqueIds(
|
||||
partners,
|
||||
(partner) => partner.id,
|
||||
(partner, index) => partner.name || partner.category || `partner-${index + 1}`,
|
||||
"partner",
|
||||
),
|
||||
},
|
||||
inquiryForm: {
|
||||
...normalized.inquiryForm,
|
||||
fields: ensureUniqueIds(
|
||||
fields,
|
||||
(field) => field.id,
|
||||
(field, index) => field.label || field.placeholder || `field-${index + 1}`,
|
||||
"field",
|
||||
).map((field) => ({
|
||||
id: field.id,
|
||||
label: field.label || "",
|
||||
placeholder: field.placeholder || "",
|
||||
type: field.type || "text",
|
||||
width: field.width || "full",
|
||||
required: Boolean(field.required),
|
||||
options: Array.isArray(field.options) ? field.options.filter(Boolean) : [],
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const controller = createPageContentController({
|
||||
model: PartnershipsPage,
|
||||
modelName: "PartnershipsPage",
|
||||
auditAction: AUDIT_ACTIONS.UPDATE_PARTNERSHIPS,
|
||||
editorConfig: partnershipsConfig,
|
||||
normalizeForEditor: normalizeInquiryForm,
|
||||
normalizeForApi: normalizeInquiryForm,
|
||||
preparePayload: prepareInquiryPayload,
|
||||
});
|
||||
|
||||
controller.index = async function index(req, res) {
|
||||
try {
|
||||
const doc = await PartnershipsPage.getSingle();
|
||||
const rawData = doc.toObject();
|
||||
const data = normalizeInquiryForm(rawData);
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const backendUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
const defaultTab = partnershipsConfig.tabs[0]?.key;
|
||||
const requestedTab = req.query.tab;
|
||||
const activeTab = partnershipsConfig.tabs.some((tab) => tab.key === requestedTab)
|
||||
? requestedTab
|
||||
: defaultTab;
|
||||
|
||||
return res.render("admin/partnerships/index", {
|
||||
layout: "layouts/main",
|
||||
title: partnershipsConfig.title,
|
||||
subtitle: partnershipsConfig.subtitle,
|
||||
data,
|
||||
editorConfig: partnershipsConfig,
|
||||
editorUi: getPartnershipsEditorUi(),
|
||||
activeTab,
|
||||
frontendUrl,
|
||||
backendUrl,
|
||||
previewUrl: `${frontendUrl}${partnershipsConfig.previewPath}`,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("partnerships index error:", error);
|
||||
req.flash("error_msg", "Error loading Partnerships Management");
|
||||
return req.session.save(() => res.redirect("/admin/dashboard"));
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = controller;
|
||||
@@ -0,0 +1,222 @@
|
||||
const PoliciesPage = require("../models/policiesPage");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
const {
|
||||
baseConfig: policiesConfig,
|
||||
createPoliciesSectionEditorConfig,
|
||||
} = require("../utils/contentEditors/policiesConfig");
|
||||
const createPageContentController = require("./_createPageContentController");
|
||||
const createRenderSingletonPageView = require("./_renderSingletonPageView");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const jsonHelper = require("../utils/jsonHelper");
|
||||
const { ensureUniqueIds } = require("../utils/contentEditorIds");
|
||||
const {
|
||||
normalizePolicy,
|
||||
normalizePoliciesDocument,
|
||||
validateContent,
|
||||
} = require("../utils/policiesBlockContent");
|
||||
|
||||
function formatLastUpdated(date = new Date()) {
|
||||
return `Last updated: ${new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date)}`;
|
||||
}
|
||||
|
||||
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);
|
||||
const normalizedExistingPolicy = normalizePolicy(existingPolicy || policy);
|
||||
|
||||
return {
|
||||
...policy,
|
||||
content: normalizedExistingPolicy.content,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...payload,
|
||||
policies,
|
||||
hero: {
|
||||
...(payload.hero || {}),
|
||||
lastUpdated: formatLastUpdated(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseController = createPageContentController({
|
||||
model: PoliciesPage,
|
||||
modelName: "PoliciesPage",
|
||||
auditAction: AUDIT_ACTIONS.UPDATE_POLICIES,
|
||||
editorConfig: policiesConfig,
|
||||
normalizeForEditor: normalizePoliciesDocument,
|
||||
normalizeForApi: normalizePoliciesDocument,
|
||||
preparePayload: withLastUpdated,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
...baseController,
|
||||
async index(req, res) {
|
||||
try {
|
||||
return await createRenderSingletonPageView({
|
||||
model: PoliciesPage,
|
||||
editorConfig: policiesConfig,
|
||||
view: "admin/policies/index",
|
||||
})(req, res);
|
||||
} catch (error) {
|
||||
console.error("policies index error:", error);
|
||||
req.flash("error_msg", "Error loading Policies Management");
|
||||
return req.session.save(() => res.redirect("/admin/dashboard"));
|
||||
}
|
||||
},
|
||||
|
||||
async editSections(req, res) {
|
||||
try {
|
||||
const doc = await PoliciesPage.getSingle();
|
||||
const data = normalizePoliciesDocument(doc.toObject());
|
||||
const policy = (data.policies || []).find(
|
||||
(item) => item.id === req.params.policyId,
|
||||
);
|
||||
|
||||
if (!policy) {
|
||||
req.flash("error_msg", "Policy not found");
|
||||
return req.session.save(() => res.redirect("/admin/policies"));
|
||||
}
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const backendUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
const editorConfig = createPoliciesSectionEditorConfig(
|
||||
policy,
|
||||
data.policies || [],
|
||||
);
|
||||
|
||||
return res.render("admin/policies/sections", {
|
||||
layout: "layouts/main",
|
||||
title: editorConfig.title,
|
||||
subtitle: editorConfig.subtitle,
|
||||
data: {
|
||||
policy,
|
||||
content: policy.content,
|
||||
},
|
||||
editorConfig,
|
||||
activeTab: "sections",
|
||||
frontendUrl,
|
||||
backendUrl,
|
||||
previewUrl: `${frontendUrl}${editorConfig.previewPath}`,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("policies section index error:", error);
|
||||
req.flash("error_msg", "Error loading policy sections");
|
||||
return req.session.save(() => res.redirect("/admin/policies"));
|
||||
}
|
||||
},
|
||||
|
||||
async updateSections(req, res) {
|
||||
try {
|
||||
const payload =
|
||||
typeof req.body.pageJson === "string"
|
||||
? JSON.parse(req.body.pageJson)
|
||||
: req.body.pageJson || {};
|
||||
const doc = await PoliciesPage.getSingle();
|
||||
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||
const policyIndex = (doc.policies || []).findIndex(
|
||||
(item) => item.id === req.params.policyId,
|
||||
);
|
||||
|
||||
if (policyIndex === -1) {
|
||||
req.flash("error_msg", "Policy not found");
|
||||
return req.session.save(() => res.redirect("/admin/policies"));
|
||||
}
|
||||
|
||||
const policyIds = (doc.policies || [])
|
||||
.map((item) => item.id)
|
||||
.filter(Boolean);
|
||||
const validation = validateContent(payload.content, policyIds);
|
||||
|
||||
if (validation.errors.length > 0) {
|
||||
req.flash("error_msg", validation.errors.join(" "));
|
||||
return req.session.save(() =>
|
||||
res.redirect(`/admin/policies/${req.params.policyId}/section`),
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedPolicies = (doc.policies || []).map((item) =>
|
||||
normalizePolicy(JSON.parse(JSON.stringify(item))),
|
||||
);
|
||||
const currentPolicy = normalizedPolicies[policyIndex];
|
||||
const updatedPolicy = {
|
||||
...currentPolicy,
|
||||
content: validation.content,
|
||||
};
|
||||
|
||||
delete updatedPolicy.sections;
|
||||
delete updatedPolicy.contentByLanguage;
|
||||
normalizedPolicies.splice(policyIndex, 1, updatedPolicy);
|
||||
const nextHero = {
|
||||
...(doc.hero || {}),
|
||||
lastUpdated: formatLastUpdated(),
|
||||
};
|
||||
await PoliciesPage.updateOne(
|
||||
{ _id: doc._id },
|
||||
{
|
||||
$set: {
|
||||
policies: normalizedPolicies,
|
||||
hero: nextHero,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const reloadedDoc = await PoliciesPage.findById(doc._id);
|
||||
const afterData = JSON.parse(JSON.stringify(reloadedDoc.toObject()));
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "PoliciesPage",
|
||||
documentId: doc._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_POLICIES,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
const finalData = normalizePoliciesDocument(
|
||||
(await PoliciesPage.findOne()
|
||||
.select("-_id -__v -createdAt -updatedAt")
|
||||
.lean()) || {},
|
||||
);
|
||||
jsonHelper.writeJsonFile(policiesConfig.dataFile, finalData);
|
||||
|
||||
const successMessage = validation.warnings.length
|
||||
? `Policy content updated with warnings: ${validation.warnings.join(" ")}`
|
||||
: "Policy content updated successfully";
|
||||
req.flash("success_msg", successMessage);
|
||||
const redirectUrl =
|
||||
req.body.intent === "save-back"
|
||||
? "/admin/policies?tab=policies"
|
||||
: `/admin/policies/${req.params.policyId}/section`;
|
||||
return req.session.save(() =>
|
||||
res.redirect(redirectUrl),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("policies section update error:", error);
|
||||
req.flash("error_msg", `Error updating policy sections: ${error.message}`);
|
||||
return req.session.save(() =>
|
||||
res.redirect(`/admin/policies/${req.params.policyId}/section`),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "Accreditation Save Check",
|
||||
"title": "ACCREDITATION AND RECOGNITION",
|
||||
"description": "At the London Academy of Management and Sciences (LAMS), we are committed to maintaining the highest academic standards and delivering quality education that aligns with international benchmarks. Our accreditations reflect our dedication to excellence, credibility, and continuous improvement in higher education and professional learning."
|
||||
},
|
||||
"grid": {
|
||||
"tabs": [
|
||||
"All",
|
||||
"Registry",
|
||||
"Certification",
|
||||
"Accreditation",
|
||||
"Quality Assurance"
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"id": "ukrlp",
|
||||
"icon": "fa-graduation-cap",
|
||||
"image": "/uploads/accreditation/ukrlp.png",
|
||||
"status": "Active",
|
||||
"category": "Registry",
|
||||
"title": "UKRLP",
|
||||
"description": "The UK Register of Learning Providers is a national register in the United Kingdom for verified organizations delivering education and training. Inclusion confirms a validation process and official listing as a recognized learning provider, supporting transparency and enabling stakeholders to verify provider details.",
|
||||
"scopeLabel": "Scope",
|
||||
"scope": "All Programs",
|
||||
"validUntilLabel": "Valid Until",
|
||||
"validUntil": "Ongoing",
|
||||
"buttonLabel": "View Certificate",
|
||||
"certificateHref": "#"
|
||||
},
|
||||
{
|
||||
"id": "ico",
|
||||
"icon": "fa-check-double",
|
||||
"image": "/uploads/accreditation/ico.png",
|
||||
"status": "Active",
|
||||
"category": "Certification",
|
||||
"title": "ICO",
|
||||
"description": "The International Certification Organization is an international certification body that assesses educational institutions against quality management, operational, and governance standards. ICO certification indicates alignment with internationally accepted frameworks and compliance with structured quality and administrative practices.",
|
||||
"scopeLabel": "Scope",
|
||||
"scope": "Quality Management",
|
||||
"validUntilLabel": "Valid Until",
|
||||
"validUntil": "Ongoing",
|
||||
"buttonLabel": "View Certificate",
|
||||
"certificateHref": "#"
|
||||
},
|
||||
{
|
||||
"id": "head",
|
||||
"icon": "fa-building-columns",
|
||||
"image": "/uploads/accreditation/head.png",
|
||||
"status": "Active",
|
||||
"category": "Accreditation",
|
||||
"title": "HEAD",
|
||||
"description": "The Higher Education Accreditation Division is an independent accreditation body focused on evaluating higher education institutions. HEAD assesses academic quality, institutional governance, curriculum design, and internal quality assurance mechanisms, reflecting adherence to established standards for higher education delivery and institutional effectiveness.",
|
||||
"scopeLabel": "Scope",
|
||||
"scope": "Higher Education",
|
||||
"validUntilLabel": "Valid Until",
|
||||
"validUntil": "Ongoing",
|
||||
"buttonLabel": "View Certificate",
|
||||
"certificateHref": "#"
|
||||
},
|
||||
{
|
||||
"id": "qahe",
|
||||
"icon": "fa-award",
|
||||
"image": "/uploads/accreditation/QAHE.png",
|
||||
"status": "Active",
|
||||
"category": "Quality Assurance",
|
||||
"title": "QAHE",
|
||||
"description": "Quality Assurance in Higher Education is an international quality assurance agency that evaluates institutions based on academic standards, teaching and learning processes, assessment practices, and institutional management systems. QAHE accreditation signifies that an institution meets defined benchmarks for quality assurance and continuous improvement within the higher education sector.",
|
||||
"scopeLabel": "Scope",
|
||||
"scope": "Quality Assurance",
|
||||
"validUntilLabel": "Valid Until",
|
||||
"validUntil": "Ongoing",
|
||||
"buttonLabel": "View Certificate",
|
||||
"certificateHref": "#"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "Your Path Starts HereYour Path Starts He",
|
||||
"title": "Admissions & Transparent TuitionAdmissions & Transparent TuitionAdmissions & Tra",
|
||||
"description": "We believe high-quality education should be accessible to everyone. Explore our straightforward admissions process and flexible payment models designed to fit your life.We believe high-quality education should be accessible to everyone. Exp",
|
||||
"primaryCta": {
|
||||
"label": "Start ApplicationStart ApplicationStart ApplicationStart App",
|
||||
"href": "http://localhost:3001/admin/admissions"
|
||||
},
|
||||
"secondaryCta": {
|
||||
"label": "View TuitionView TuitionView TuitionView TuitionView Tuition",
|
||||
"href": "http://localhost:3001/admin/admissions"
|
||||
},
|
||||
"image": "/uploads/admissions/Colorful_Square_Background.png",
|
||||
"imageAlt": "View TuitionView TuitionView TuitionView TuitionView TuitionView TuitionView TuitionView TuitionView TuitionView Tuition"
|
||||
},
|
||||
"process": {
|
||||
"id": "admissions-process",
|
||||
"title": "Admissions ProcessAdmissions ProcessAdmissions ProcessAdmiss",
|
||||
"description": "Our streamlined process gets you from application to enrolled in days, not months. No application fees, no standardized tests.Our streamlined process gets you from application to e",
|
||||
"steps": [
|
||||
{
|
||||
"number": "01",
|
||||
"title": "Send Transcripts",
|
||||
"description": "Request official transcripts from previous institutions for credit evaluation.",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"number": "02",
|
||||
"title": "Submit Application",
|
||||
"description": "Fill out our online form in under 15 minutes. Basic personal and educational history required.",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"number": "03",
|
||||
"title": "Choose Payment Plan",
|
||||
"description": "Select between our monthly subscription or pay-per-course model.",
|
||||
"active": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"eligibility": {
|
||||
"id": "eligibility",
|
||||
"title": "Eligibility & Transfer CreditsEligibility & Transfer Credits",
|
||||
"cards": [
|
||||
{
|
||||
"title": "Basic EligibilityBasic EligibilityBasic Eligibilit",
|
||||
"icon": "fa-check-circle",
|
||||
"items": [
|
||||
"High school diploma or equivalentHigh school diploma or equivalentHigh school diploma or equivalentHigh school diploma o",
|
||||
"Minimum 2.0 GPA for transfer studentsMinimum 2.0 GPA for transfer studentsMinimum 2.0 GPA for transfer studentsMinimum 2",
|
||||
"English proficiency if applicableEnglish proficiency if applicableEnglish proficiency if applicableEnglish proficiency i"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Transfer PolicyTransfer PolicyTransfer PolicyTrans",
|
||||
"icon": "fa-exchange-alt",
|
||||
"items": [
|
||||
"Up to 90 credits accepted for Bachelor'sUp to 90 credits accepted for Bachelor'sUp to 90 credits accepted for Bachelor's",
|
||||
"Free unofficial evaluation within 48 hoursFree unofficial evaluation within 48 hoursFree unofficial evaluation within 48",
|
||||
"Credit for prior learning and certificationsCredit for prior learning and certificationsCredit for prior learning and ce"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"tuition": {
|
||||
"id": "tuition-breakdown",
|
||||
"title": "Tuition BreakdownTuition BreakdownTuition BreakdownTuition B",
|
||||
"chartTitle": "Savings vs. Traditional UniversitySavings vs. Traditional Un",
|
||||
"chartDescription": "Estimated total cost for a 4-year degreeEstimated total cost for a 4-year degreeEstimated total cost for a 4-year degreeEstimated total cost",
|
||||
"series": [
|
||||
{
|
||||
"label": "Traditional University",
|
||||
"color": "#850f0f",
|
||||
"points": [
|
||||
{
|
||||
"time": "Year 1",
|
||||
"value": 25000
|
||||
},
|
||||
{
|
||||
"time": "Year 2",
|
||||
"value": 49998
|
||||
},
|
||||
{
|
||||
"time": "Year 3",
|
||||
"value": 30000
|
||||
},
|
||||
{
|
||||
"time": "Year 4",
|
||||
"value": 100000
|
||||
},
|
||||
{
|
||||
"time": "Year 5",
|
||||
"value": 120000
|
||||
},
|
||||
{
|
||||
"time": "Year 6",
|
||||
"value": 500000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "LAMS",
|
||||
"color": "#a700b3",
|
||||
"points": [
|
||||
{
|
||||
"time": "Year 1",
|
||||
"value": 3588
|
||||
},
|
||||
{
|
||||
"time": "Year 2",
|
||||
"value": 7176
|
||||
},
|
||||
{
|
||||
"time": "Year 3",
|
||||
"value": 10764
|
||||
},
|
||||
{
|
||||
"time": "Year 4",
|
||||
"value": 10000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"keyDates": {
|
||||
"id": "key-dates",
|
||||
"title": "Key Dates & Deadlines",
|
||||
"columns": [
|
||||
{
|
||||
"id": "Term",
|
||||
"label": "Term"
|
||||
},
|
||||
{
|
||||
"id": "Application-Deadline",
|
||||
"label": "Application Deadline"
|
||||
},
|
||||
{
|
||||
"id": "Classes-Start",
|
||||
"label": "Classes Start"
|
||||
},
|
||||
{
|
||||
"id": "column-4",
|
||||
"label": "Column 4"
|
||||
}
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"id": "row-1",
|
||||
"cells": [
|
||||
"Fall Term 1",
|
||||
"August 15, 2026August 15, 2026August 15, 2026August 15, 2026",
|
||||
"September 1, 2026September 1, 2026September 1, 2026September",
|
||||
"September 1, 2026September 1, 2026September 1, 2026September"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "row-2",
|
||||
"cells": [
|
||||
"Fall Term 2",
|
||||
"October 15, 2026October 15, 2026October 15, 2026October 15, ",
|
||||
"November 1, 2026November 1, 2026November 1, 2026November 1, ",
|
||||
"September 1, 2026September 1, 2026September 1, 2026September"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "row-3",
|
||||
"cells": [
|
||||
"Spring Term 1",
|
||||
"December 15, 2026December 15, 2026December 15, 2026December ",
|
||||
"January 5, 2027",
|
||||
"September 1, 2026September 1, 2026September 1, 2026September"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"calculator": {
|
||||
"title": "Affordability Calculator",
|
||||
"description": "Estimate your monthly investment.",
|
||||
"cta": {
|
||||
"label": "Apply Now",
|
||||
"href": "#apply"
|
||||
},
|
||||
"options": [
|
||||
{
|
||||
"id": "subscription",
|
||||
"label": "Subscription",
|
||||
"paceLabel": "Target PaceTarget PaceTarget P",
|
||||
"minPaceLabel": "RelaxedRelaxedRelaxe",
|
||||
"maxPaceLabel": "AcceleratedAccelerat",
|
||||
"resultLabel": "Estimated Monthly PaymentEstimated Month",
|
||||
"monthlyAmount": "5000000",
|
||||
"monthlySuffix": "/mo",
|
||||
"noteIcon": "fa-bolt",
|
||||
"note": "Flat rate, unlimited coursesFlat rate, unlimited coursesFlat"
|
||||
},
|
||||
{
|
||||
"id": "per-course",
|
||||
"label": "Per Course",
|
||||
"paceLabel": "Target Pace",
|
||||
"minPaceLabel": "Relaxed",
|
||||
"maxPaceLabel": "Accelerated",
|
||||
"resultLabel": "Estimated Monthly Payment",
|
||||
"monthlyAmount": "500",
|
||||
"monthlySuffix": "/mo",
|
||||
"noteIcon": "fa-bolt",
|
||||
"note": "Flat rate, unlimited courses"
|
||||
},
|
||||
{
|
||||
"id": "d-course",
|
||||
"label": "D Course",
|
||||
"paceLabel": "Target Pace",
|
||||
"minPaceLabel": "Relaxed",
|
||||
"maxPaceLabel": "Accelerated",
|
||||
"resultLabel": "Estimated Monthly Payment",
|
||||
"monthlyAmount": "500",
|
||||
"monthlySuffix": "/mo",
|
||||
"noteIcon": "fa-bolt",
|
||||
"note": "Flat rate, unlimited courses"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scholarships": {
|
||||
"title": "Scholarships & Aid",
|
||||
"icon": "fa-award",
|
||||
"items": [
|
||||
{
|
||||
"title": "Working Adult GrantWorking Adult GrantWorking Adul",
|
||||
"amount": "Up to $1,500",
|
||||
"description": "For students employed full-time while studying.For students employed full-time while studying.For students employed full-time while studying.For students employ"
|
||||
},
|
||||
{
|
||||
"title": "Military Discount",
|
||||
"amount": "15% Off",
|
||||
"description": "For students employed full-time while studying.For students employed full-time while studying.For students employed full-time while studying.For students employ"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"highlight": {
|
||||
"icon": "fa-magnifying-glass",
|
||||
"text": "2025 Milestone Reached: A Rapidly Growing Global Community!2025 Milestone Reached: A Rapidly Growing Global Co",
|
||||
"linkLabel": "Read Full StoryRead Full Story",
|
||||
"href": "/admin/history"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Our JourneyOur JourneyOur JourneyOur Jou",
|
||||
"title": "Building the Future of Education.Building the Future of Education.Building the Future of E",
|
||||
"description": "From our humble beginnings to becoming a global leader in online education, explore the key moments that define our legacy.From our humble beginnings to becoming a global leader in online education, explore the key momen"
|
||||
},
|
||||
"filters": {
|
||||
"yearOptions": [
|
||||
"All Years",
|
||||
"2020 - Present",
|
||||
"2010 - 2019",
|
||||
"2005 - 2009",
|
||||
"2015 - 2019"
|
||||
],
|
||||
"categoryOptions": [
|
||||
"All Categories",
|
||||
"Academic Programs",
|
||||
"Global Expansion",
|
||||
"Technology & Innovation",
|
||||
"Student Experience",
|
||||
"Awards & Recognition"
|
||||
]
|
||||
},
|
||||
"timeline": {
|
||||
"items": [
|
||||
{
|
||||
"id": "student-experience-innovation",
|
||||
"year": "2026",
|
||||
"yearRange": "2020 - Present",
|
||||
"category": "Student Experience",
|
||||
"categoryLabel": "Student Experience",
|
||||
"title": "Innovation in Student Experience",
|
||||
"description": "Enhanced student support through integrated digital services, academic advising, and career development platforms.",
|
||||
"image": "/uploads/history/Colorful_Square_Background.png",
|
||||
"imageAlt": "",
|
||||
"stats": [],
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": "international-partnerships-expansion",
|
||||
"year": "2025",
|
||||
"yearRange": "2020 - Present",
|
||||
"category": "Global Expansion",
|
||||
"categoryLabel": "Global",
|
||||
"title": "Expansion of International Partnerships",
|
||||
"description": "Established collaborations with academic institutions and industry partners across regions, enabling dual qualifications and cross-border learning opportunities.",
|
||||
"image": "/uploads/history/7281.jpg",
|
||||
"imageAlt": "",
|
||||
"stats": [],
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "academic-programmes-expansion",
|
||||
"year": "2024",
|
||||
"yearRange": "2020 - Present",
|
||||
"category": "Academic Programs",
|
||||
"categoryLabel": "Academic",
|
||||
"title": "Expansion of Academic Programmes",
|
||||
"description": "Launched a portfolio of undergraduate and postgraduate programmes designed to meet global market demands and emerging industry needs.",
|
||||
"image": "/uploads/history/2024.png",
|
||||
"imageAlt": "",
|
||||
"stats": [],
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "ai-enhanced-learning-platform",
|
||||
"year": "2024",
|
||||
"yearRange": "2020 - Present",
|
||||
"category": "Technology & Innovation",
|
||||
"categoryLabel": "Technology",
|
||||
"title": "Launch of AI-Enhanced Learning Platform",
|
||||
"description": "Introduced an adaptive digital learning system that personalises study pathways and enhances student engagement and outcomes.",
|
||||
"image": "/uploads/history/ai-enhanced-learning-platform.png",
|
||||
"imageAlt": "Abstract artificial intelligence and digital learning visualization",
|
||||
"stats": [],
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "strategic-academic-framework",
|
||||
"year": "2023",
|
||||
"yearRange": "2020 - Present",
|
||||
"category": "Academic Programs",
|
||||
"categoryLabel": "Academic",
|
||||
"title": "Strategic Academic Framework Introduced",
|
||||
"description": "Established a future-focused academic model aligned with international standards, integrating applied learning, digital competencies, and global perspectives.",
|
||||
"image": "/uploads/history/7281.jpg",
|
||||
"imageAlt": "",
|
||||
"stats": [],
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "strategic-academic-framework",
|
||||
"year": "2027",
|
||||
"yearRange": "2020 - Present",
|
||||
"category": "All Categories",
|
||||
"categoryLabel": "Academic",
|
||||
"title": "Strategic Academic Framework Introduced",
|
||||
"description": "Established a future-focused academic model aligned with international standards, integrating applied learning, digital competencies, and global perspectives.",
|
||||
"image": "/uploads/history/kVI17_2B.webp",
|
||||
"imageAlt": "aaa",
|
||||
"stats": [],
|
||||
"featured": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "Global NetworkGlobal NetworkGlobal Netwo",
|
||||
"title": "Industry & Academic Partnerships.Industry & Academic Partnerships.Industry & Academic Part",
|
||||
"description": "Global NetworkGlobal NetworkGlobal NetwoGlobal NetworkGlobal NetworkGlobal NetwoGlobal NetworkGlobal NetworkGlobal NetwoGlobal NetworkGlobal NetworkGlobal NetwoGlobal NetworkGlobal NetworkGlobal NetwoGlobal NetworkGlobal",
|
||||
"linkLabel": "Explore DirectoryExplore DirectoryExplor",
|
||||
"image": "/uploads/partnerships/kVI17_2B.webp",
|
||||
"imageAlt": "Modern university campus and corporate office buildingModern university campus and corporate office buildingModern unive"
|
||||
},
|
||||
"directory": {
|
||||
"heading": "Partner DirectoryPartner DirectoryPartne",
|
||||
"description": "Discover the organizations shaping the future of education with us.Discover the organizations shaping the future of education with us.Discover the organizations shaping the future ",
|
||||
"tabs": [
|
||||
"All PartnersAll PartnersAll Pa",
|
||||
"IndustryIndustryIndustryIndust",
|
||||
"AcademicAcademicAcademicAcadem",
|
||||
"CommunityCommunityCommunityCom",
|
||||
"hehehehehehehehehehehehehehehe",
|
||||
"hehehehehehehhehehehehehehhehe"
|
||||
],
|
||||
"partners": [
|
||||
{
|
||||
"id": "techvanguardtechvanguardtechvanguardtechvanguardte",
|
||||
"name": "TechvanguardTechvanguardTechvanguardTechvanguardTechvanguardTechvanguardTechvanguardTechva",
|
||||
"category": "IndustryIndustryIndustryIndust",
|
||||
"summary": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"logo": "/uploads/partnerships/tech-logo.png",
|
||||
"logoAlt": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
|
||||
"about": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"collabType": "IndustryIndustryIndustryIndustryIndustry",
|
||||
"benefits": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn"
|
||||
},
|
||||
{
|
||||
"id": "IndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"name": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"category": "IndustryIndustryIndustryIndust",
|
||||
"summary": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"logo": "/uploads/partnerships/royalcosmetics.png",
|
||||
"logoAlt": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
|
||||
"about": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
|
||||
"collabType": "Industry Partner",
|
||||
"benefits": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndust"
|
||||
},
|
||||
{
|
||||
"id": "IndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"name": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"category": "IndustryIndustryIndustryIndust",
|
||||
"summary": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"logo": "/uploads/partnerships/swiss.jpg",
|
||||
"logoAlt": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
|
||||
"about": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
|
||||
"collabType": "IndustryIndustryIndustryIndustryIndustry",
|
||||
"benefits": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndust"
|
||||
},
|
||||
{
|
||||
"id": "IndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"name": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"category": "IndustryIndustryIndustryIndust",
|
||||
"summary": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"logo": "/uploads/partnerships/uldp.jpg",
|
||||
"logoAlt": "Université Libérale de Paris logo",
|
||||
"about": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
|
||||
"collabType": "IndustryIndustryIndustryIndustryIndustry",
|
||||
"benefits": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndust"
|
||||
},
|
||||
{
|
||||
"id": "IndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"name": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"category": "IndustryIndustryIndustryIndust",
|
||||
"summary": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"logo": "/uploads/partnerships/ENG_main_2022-05-20-070029_kstp.jpg",
|
||||
"logoAlt": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
|
||||
"about": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
|
||||
"collabType": "IndustryIndustryIndustryIndustryIndustry",
|
||||
"benefits": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndust"
|
||||
},
|
||||
{
|
||||
"id": "IndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"name": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"category": "IndustryIndustryIndustryIndust",
|
||||
"summary": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIn",
|
||||
"logo": "/uploads/partnerships/horizons.jpg",
|
||||
"logoAlt": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
|
||||
"about": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustry",
|
||||
"collabType": "IndustryIndustryIndustryIndustryIndustry",
|
||||
"benefits": "IndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryInIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndustryIndust"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cta": {
|
||||
"heading": "Join the ecosystem",
|
||||
"description": "Partner with London Academy of Management and Sciences to build talent pipelines, collaborate on research, and shape the next generation of leaders.Partner with London Academy of Management and Sciences to build talent p",
|
||||
"buttonLabel": "Become a partnerBecome a partnerBecome a"
|
||||
},
|
||||
"inquiryForm": {
|
||||
"title": "Partnership InquiryPartnership InquiryPartnership InquiryPar",
|
||||
"fields": [
|
||||
{
|
||||
"id": "firstName",
|
||||
"label": "Partnership InquiryPartnership",
|
||||
"placeholder": "Partnership InquiryPartnership InquiryPa",
|
||||
"type": "text",
|
||||
"width": "half",
|
||||
"required": false,
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"id": "lastName",
|
||||
"label": "Partnership InquiryPartnership",
|
||||
"placeholder": "Partnership InquiryPartnership InquiryPa",
|
||||
"type": "text",
|
||||
"width": "half",
|
||||
"required": true,
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"id": "organization",
|
||||
"label": "Partnership InquiryPartnership InquiryPa",
|
||||
"placeholder": "Partnership InquiryPartnership InquiryPartnership InquiryPar",
|
||||
"type": "text",
|
||||
"width": "full",
|
||||
"required": true,
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"id": "partnershipType",
|
||||
"label": "Partnership InquiryPartnership InquiryPa",
|
||||
"placeholder": "",
|
||||
"type": "select",
|
||||
"width": "full",
|
||||
"required": true,
|
||||
"options": [
|
||||
"Partnership InquiryPartnership InquiryPartnership ",
|
||||
"Partnership InquiryPartnership InquiryPartnership ",
|
||||
"Partnership InquiryPartnership InquiryPartnership ",
|
||||
"Partnership InquiryPartnership InquiryPartnership "
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "message",
|
||||
"label": "Partnership InquiryPartnership",
|
||||
"placeholder": "Partnership InquiryPartnership InquiryPartnership InquiryPartnership InquiryPart",
|
||||
"type": "textarea",
|
||||
"width": "full",
|
||||
"required": true,
|
||||
"options": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "Policies",
|
||||
"icon": "fa-scale-balanced",
|
||||
"titlePrefix": "Our Commitment to",
|
||||
"titleHighlight": "Transparency",
|
||||
"description": "Review our policies, terms of service, and commitments to privacy and accessibility. We believe in clear, straightforward communication with our academic community.",
|
||||
"lastUpdated": "Last updated: April 22, 2026"
|
||||
},
|
||||
"sidebar": {
|
||||
"heading": "Policies",
|
||||
"helperText": "Need clarification on a policy?",
|
||||
"contactLabel": "Contact Policy Team",
|
||||
"contactHref": "/contact"
|
||||
},
|
||||
"policies": [
|
||||
{
|
||||
"id": "privacy",
|
||||
"navLabel": "Privacy Policy",
|
||||
"title": "Privacy Policy",
|
||||
"effectiveDate": "Effective Date: September 15, 2025",
|
||||
"intro": "At LAMS, we are committed to protecting your privacy and ensuring the security of your personal information. This Privacy Policy outlines how we collect, use, and safeguard the data of our students, applicants, and website visitors.",
|
||||
"content": {
|
||||
"blocks": [
|
||||
{
|
||||
"id": "1-information-we-collect-qa-20260422-041603",
|
||||
"type": "heading",
|
||||
"level": 2,
|
||||
"html": "<span>1. Information We Collect QA 20260422 041603</span>"
|
||||
},
|
||||
{
|
||||
"id": "1-information-we-collect-qa-20260422-041603-intro",
|
||||
"type": "paragraph",
|
||||
"html": "<p><b>We collect information directly from students and applicants. Validation marker QA 20260422 041603.</b></p>"
|
||||
},
|
||||
{
|
||||
"id": "block-1776821411652-j34gz",
|
||||
"type": "quote",
|
||||
"html": "<p>Quote QA 20260422 0127</p>",
|
||||
"caption": "QA Source"
|
||||
},
|
||||
{
|
||||
"id": "1-information-we-collect-qa-20260422-041603-2",
|
||||
"type": "list",
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
{
|
||||
"id": "personal-identification-information-such-as-name-address-email-address-phone-number-date-of-birth-and-government-issued-id-numbers-where-required",
|
||||
"html": "<p>Personal identification information such as name, address, email address, phone number, date of birth, and government-issued ID numbers where required.</p>"
|
||||
},
|
||||
{
|
||||
"id": "financial-information-such-as-payment-details-financial-aid-applications-and-billing-history",
|
||||
"html": "<p>Financial information such as payment details, financial aid applications, and billing history.</p>"
|
||||
},
|
||||
{
|
||||
"id": "audit-ready-retention-notice-qa-20260422-041603",
|
||||
"html": "<p>Audit-ready retention notice QA 20260422 041603</p>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "3-data-sharing-and-third-parties",
|
||||
"type": "heading",
|
||||
"level": 2,
|
||||
"html": "<span>3. Data Sharing and Third Parties</span>"
|
||||
},
|
||||
{
|
||||
"id": "we-do-not-sell-your-personal-information-we-may-share-your-data-with-trusted-third-party-service-providers-who-assist-us-in-operating-our-university-including-learning-management-systems-and-payment-processors-these-partners-are-bound-by-strict-confidentiality-agreements-for-more-details-refer-to-our-vendor-data-processing-addendum",
|
||||
"type": "paragraph",
|
||||
"html": "<p>We do not sell your personal information. We may share your data with trusted third-party service providers who assist us in operating our university, including learning management systems and payment processors. These partners are bound by strict confidentiality agreements. For more details, refer to our Vendor Data Processing Addendum.</p>"
|
||||
},
|
||||
{
|
||||
"id": "if-you-have-questions-about-this-policy-please-contact-our-data-protection-officer-at-privacy-lams-edu",
|
||||
"type": "paragraph",
|
||||
"html": "<p>If you have questions about this policy, please contact our Data Protection Officer at privacy@LAMS.edu.</p>"
|
||||
},
|
||||
{
|
||||
"id": "for-policy-navigation-see-terms-of-use-qa-20260422-041603",
|
||||
"type": "paragraph",
|
||||
"html": "<p>For policy navigation, see Terms of Use QA 20260422 041603.</p>"
|
||||
},
|
||||
{
|
||||
"id": "2-how-we-use-your-information",
|
||||
"type": "heading",
|
||||
"level": 2,
|
||||
"html": "<span>2. How We Use Your Information</span>"
|
||||
},
|
||||
{
|
||||
"id": "2-how-we-use-your-information-intro",
|
||||
"type": "paragraph",
|
||||
"html": "<p>Your information is primarily used to provide educational services and manage your student journey. Specific uses include:</p>"
|
||||
},
|
||||
{
|
||||
"id": "2-how-we-use-your-information-2",
|
||||
"type": "list",
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
{
|
||||
"id": "processing-admissions-applications-and-enrollment",
|
||||
"html": "<p>Processing admissions applications and enrollment.</p>"
|
||||
},
|
||||
{
|
||||
"id": "delivering-course-materials-grades-and-academic-advising",
|
||||
"html": "<p>Delivering course materials, grades, and academic advising.</p>"
|
||||
},
|
||||
{
|
||||
"id": "processing-tuition-payments-and-administering-financial-aid",
|
||||
"html": "<p>Processing tuition payments and administering financial aid.</p>"
|
||||
},
|
||||
{
|
||||
"id": "communicating-important-university-updates-and-policy-changes",
|
||||
"html": "<p>Communicating important university updates and policy changes.</p>"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "terms",
|
||||
"navLabel": "Terms of Use",
|
||||
"title": "Terms of Use",
|
||||
"effectiveDate": "Effective Date: January 1, 2025",
|
||||
"intro": "Welcome to LAMS. By accessing our website, student portal, or utilizing our educational services, you agree to be bound by these Terms of Use and our Privacy Policy.",
|
||||
"content": {
|
||||
"blocks": [
|
||||
{
|
||||
"id": "1-academic-integrity",
|
||||
"type": "heading",
|
||||
"level": 2,
|
||||
"html": "<span>1. Academic Integrity</span>"
|
||||
},
|
||||
{
|
||||
"id": "as-a-student-of-lams-you-are-expected-to-uphold-the-highest-standards-of-academic-honesty-plagiarism-cheating-and-the-unauthorized-sharing-of-course-materials-are-strictly-prohibited-and-may-result-in-disciplinary-action",
|
||||
"type": "paragraph",
|
||||
"html": "<p>As a student of LAMS, you are expected to uphold the highest standards of academic honesty. Plagiarism, cheating, and the unauthorized sharing of course materials are strictly prohibited and may result in disciplinary action.</p>"
|
||||
},
|
||||
{
|
||||
"id": "2-account-security",
|
||||
"type": "heading",
|
||||
"level": 2,
|
||||
"html": "<span>2. Account Security</span>"
|
||||
},
|
||||
{
|
||||
"id": "you-are-responsible-for-maintaining-the-confidentiality-of-your-student-portal-credentials-you-must-immediately-notify-the-it-helpdesk-of-any-unauthorized-use-of-your-account",
|
||||
"type": "paragraph",
|
||||
"html": "<p>You are responsible for maintaining the confidentiality of your student portal credentials. You must immediately notify the IT Helpdesk of any unauthorized use of your account.</p>"
|
||||
},
|
||||
{
|
||||
"id": "course-materials",
|
||||
"type": "callout",
|
||||
"tone": "info",
|
||||
"title": "Course Materials",
|
||||
"icon": "fa-book-open",
|
||||
"html": "<p>All course content provided via the learning management system is the intellectual property of LAMS or its licensors. It is for personal educational use only.</p>"
|
||||
},
|
||||
{
|
||||
"id": "subscription-terms",
|
||||
"type": "callout",
|
||||
"tone": "info",
|
||||
"title": "Subscription Terms",
|
||||
"icon": "fa-credit-card",
|
||||
"html": "<p>Monthly subscriptions automatically renew unless canceled prior to the billing cycle. See the Financial Policies for refund criteria.</p><p><a href=\"#\">Financial Policies</a></p>"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "accessibility",
|
||||
"navLabel": "Accessibility Statement",
|
||||
"title": "Accessibility Statement",
|
||||
"effectiveDate": "Effective Date: September 15, 2025",
|
||||
"intro": "LAMS is committed to providing digital learning experiences that are accessible to all students, applicants, faculty, and visitors.",
|
||||
"content": {
|
||||
"blocks": [
|
||||
{
|
||||
"id": "1-our-accessibility-commitments",
|
||||
"type": "heading",
|
||||
"level": 2,
|
||||
"html": "<span>1. Our Accessibility Commitments</span>"
|
||||
},
|
||||
{
|
||||
"id": "1-our-accessibility-commitments-2",
|
||||
"type": "list",
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
{
|
||||
"id": "we-design-learning-materials-and-digital-services-with-accessibility-in-mind",
|
||||
"html": "<p>We design learning materials and digital services with accessibility in mind.</p>"
|
||||
},
|
||||
{
|
||||
"id": "we-review-core-student-journeys-for-keyboard-access-screen-reader-support-and-readable-contrast",
|
||||
"html": "<p>We review core student journeys for keyboard access, screen reader support, and readable contrast.</p>"
|
||||
},
|
||||
{
|
||||
"id": "we-provide-reasonable-accommodations-through-our-student-support-and-advising-teams",
|
||||
"html": "<p>We provide reasonable accommodations through our student support and advising teams.</p>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "2-requesting-support",
|
||||
"type": "heading",
|
||||
"level": 2,
|
||||
"html": "<span>2. Requesting Support</span>"
|
||||
},
|
||||
{
|
||||
"id": "if-you-encounter-an-accessibility-barrier-contact-our-support-team-so-we-can-review-the-issue-and-provide-an-appropriate-path-forward",
|
||||
"type": "paragraph",
|
||||
"html": "<p>If you encounter an accessibility barrier, <a href=\"/contact\">contact our support team</a> so we can review the issue and provide an appropriate path forward.</p>"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cookies",
|
||||
"navLabel": "Cookie Preferences",
|
||||
"title": "Cookie Preferences",
|
||||
"effectiveDate": "Effective Date: September 15, 2025",
|
||||
"intro": "We use cookies and similar technologies to operate our website, understand usage patterns, and improve the student experience.",
|
||||
"content": {
|
||||
"blocks": [
|
||||
{
|
||||
"id": "1-cookie-categories",
|
||||
"type": "heading",
|
||||
"level": 2,
|
||||
"html": "<span>1. Cookie Categories</span>"
|
||||
},
|
||||
{
|
||||
"id": "1-cookie-categories-2",
|
||||
"type": "list",
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
{
|
||||
"id": "essential-cookies-keep-core-services-such-as-authentication-and-security-running",
|
||||
"html": "<p>Essential cookies keep core services such as authentication and security running.</p>"
|
||||
},
|
||||
{
|
||||
"id": "analytics-cookies-help-us-understand-aggregate-site-usage-and-improve-content",
|
||||
"html": "<p>Analytics cookies help us understand aggregate site usage and improve content.</p>"
|
||||
},
|
||||
{
|
||||
"id": "preference-cookies-remember-non-sensitive-choices-such-as-language-and-display-settings",
|
||||
"html": "<p>Preference cookies remember non-sensitive choices such as language and display settings.</p>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "2-managing-preferences",
|
||||
"type": "heading",
|
||||
"level": 2,
|
||||
"html": "<span>2. Managing Preferences</span>"
|
||||
},
|
||||
{
|
||||
"id": "you-can-manage-cookies-through-your-browser-settings-some-essential-cookies-cannot-be-disabled-because-they-are-required-for-secure-access-to-student-services",
|
||||
"type": "paragraph",
|
||||
"html": "<p>You can manage cookies through your browser settings. Some essential cookies cannot be disabled because they are required for secure access to student services.</p>"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"navLabel": "hehehehehehehehehehehehehehehehehehehehe",
|
||||
"title": "hehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehe",
|
||||
"effectiveDate": "Effective Date: April 22, 2026",
|
||||
"intro": "hehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehe",
|
||||
"id": "hehehe",
|
||||
"content": {
|
||||
"blocks": [
|
||||
{
|
||||
"id": "block-1776828969787-k3scn",
|
||||
"type": "heading",
|
||||
"level": 1,
|
||||
"html": "<p>hehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehe</p>"
|
||||
},
|
||||
{
|
||||
"id": "block-1776828970153-r5lpx",
|
||||
"type": "paragraph",
|
||||
"html": "<p>Paragraph persist QA 2.</p>"
|
||||
},
|
||||
{
|
||||
"id": "block-1776828972201-p2bgf",
|
||||
"type": "list",
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
{
|
||||
"id": "block-1776828972201-p2bgf-item-1",
|
||||
"html": "<p>Item A2</p>"
|
||||
},
|
||||
{
|
||||
"id": "block-1776828972201-p2bgf-item-2",
|
||||
"html": "<p>hehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehe</p>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "block-1776828974269-s4z7m",
|
||||
"type": "quote",
|
||||
"html": "<p>hehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehe</p>",
|
||||
"caption": "Persist Source 2"
|
||||
},
|
||||
{
|
||||
"id": "block-1776828976335-unxd1",
|
||||
"type": "callout",
|
||||
"tone": "success",
|
||||
"title": "Callout Persist 2",
|
||||
"icon": "fa-circle-check",
|
||||
"html": "<p>hehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehe</p>"
|
||||
},
|
||||
{
|
||||
"id": "block-1776828978384-r45ps",
|
||||
"type": "divider"
|
||||
},
|
||||
{
|
||||
"id": "block-1776829917567-zycm5",
|
||||
"type": "callout",
|
||||
"tone": "success",
|
||||
"title": "Callout save repro",
|
||||
"icon": "fa-c",
|
||||
"html": "<p>hehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehehe</p>"
|
||||
},
|
||||
{
|
||||
"id": "block-1776829967348-g03qc",
|
||||
"type": "callout",
|
||||
"tone": "warning",
|
||||
"title": "Callout save fixed",
|
||||
"icon": "fa-envelope",
|
||||
"html": "<p>Callout body fixed persist</p>"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
const mongoose = require("mongoose");
|
||||
const jsonHelper = require("../utils/jsonHelper");
|
||||
|
||||
function createSingletonPageModel(modelName, collectionName, dataFileName) {
|
||||
const schema = new mongoose.Schema(
|
||||
{},
|
||||
{
|
||||
strict: false,
|
||||
timestamps: true,
|
||||
collection: collectionName,
|
||||
},
|
||||
);
|
||||
|
||||
schema.statics.getSingle = async function getSingle() {
|
||||
let doc = await this.findOne();
|
||||
|
||||
if (!doc) {
|
||||
const defaultData = jsonHelper.readJsonFile(dataFileName) || {};
|
||||
doc = await this.create(defaultData);
|
||||
}
|
||||
|
||||
return doc;
|
||||
};
|
||||
|
||||
return mongoose.model(modelName, schema);
|
||||
}
|
||||
|
||||
module.exports = createSingletonPageModel;
|
||||
@@ -0,0 +1,7 @@
|
||||
const createSingletonPageModel = require("./_createSingletonPageModel");
|
||||
|
||||
module.exports = createSingletonPageModel(
|
||||
"AccreditationPage",
|
||||
"accreditation_pages",
|
||||
"accreditation",
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
const createSingletonPageModel = require("./_createSingletonPageModel");
|
||||
|
||||
module.exports = createSingletonPageModel(
|
||||
"AdmissionsPage",
|
||||
"admissions_pages",
|
||||
"admissions",
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
const createSingletonPageModel = require("./_createSingletonPageModel");
|
||||
|
||||
module.exports = createSingletonPageModel(
|
||||
"HistoryPage",
|
||||
"history_pages",
|
||||
"history",
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
const createSingletonPageModel = require("./_createSingletonPageModel");
|
||||
|
||||
module.exports = createSingletonPageModel(
|
||||
"PartnershipsPage",
|
||||
"partnerships_pages",
|
||||
"partnerships",
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
const createSingletonPageModel = require("./_createSingletonPageModel");
|
||||
|
||||
module.exports = createSingletonPageModel(
|
||||
"PoliciesPage",
|
||||
"policies_pages",
|
||||
"policies",
|
||||
);
|
||||
@@ -0,0 +1,871 @@
|
||||
(function () {
|
||||
const config = window.pageEditorConfig;
|
||||
const initialData = window.pageEditorData;
|
||||
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
|
||||
const form = document.getElementById("pageContentForm");
|
||||
const pageJsonInput = document.getElementById("pageJson");
|
||||
const activeTabInput = document.getElementById("activeTabInput");
|
||||
|
||||
if (!config || !initialData || !form || !pageJsonInput || !activeTabInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = JSON.parse(JSON.stringify(initialData));
|
||||
const iconOptions = Array.from(
|
||||
new Set(
|
||||
(config.tabs || [])
|
||||
.flatMap((tab) => collectIcons(tab.schema))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
ensureIconDatalist(iconOptions);
|
||||
renderAllSections();
|
||||
|
||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||
const tabKey = this.dataset.tabKey;
|
||||
|
||||
if (!tabKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeTabInput.value = tabKey;
|
||||
updateTabUrl(tabKey);
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
pageJsonInput.value = JSON.stringify(state);
|
||||
});
|
||||
|
||||
form.addEventListener("reset", function () {
|
||||
window.setTimeout(function () {
|
||||
Object.keys(state).forEach((key) => delete state[key]);
|
||||
Object.assign(state, JSON.parse(JSON.stringify(initialData)));
|
||||
renderAllSections();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
function renderAllSections() {
|
||||
config.tabs.forEach((tab) => renderSection(tab.key));
|
||||
}
|
||||
|
||||
function updateTabUrl(tabKey) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${url.pathname}?${url.searchParams.toString()}${url.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection(tabKey) {
|
||||
const tab = config.tabs.find((item) => item.key === tabKey);
|
||||
const container = document.querySelector(
|
||||
`.page-editor-section[data-section-key="${tabKey}"]`,
|
||||
);
|
||||
|
||||
if (!tab || !container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
|
||||
path: tab.schema.key,
|
||||
root: state,
|
||||
item: null,
|
||||
});
|
||||
}
|
||||
|
||||
function renderField(schema, container, parent, key, tabKey, context) {
|
||||
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "object") {
|
||||
if (!isObject(parent[key])) {
|
||||
parent[key] = {};
|
||||
}
|
||||
|
||||
const groupWrapper = document.createElement("div");
|
||||
groupWrapper.className = "row g-3";
|
||||
container.appendChild(groupWrapper);
|
||||
|
||||
(schema.fields || []).forEach((field) => {
|
||||
renderField(field, groupWrapper, parent[key], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: parent[key],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(parent[key])) {
|
||||
parent[key] = [];
|
||||
}
|
||||
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const card = document.createElement("div");
|
||||
card.className = "border rounded-3 bg-light-subtle p-3";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "d-flex justify-content-between align-items-center mb-3 gap-3";
|
||||
header.innerHTML = `
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">${escapeHtml(schema.label)}</label>
|
||||
${schema.helpText ? `<div class="form-text mt-0">${escapeHtml(schema.helpText)}</div>` : ""}
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-plus me-1"></i>${escapeHtml(schema.addLabel || `Add ${schema.itemLabel || "Item"}`)}
|
||||
</button>
|
||||
`;
|
||||
|
||||
header.querySelector("button").addEventListener("click", function () {
|
||||
parent[key].push(createDefaultValue(schema.itemSchema));
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
if (parent[key].length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "text-muted small";
|
||||
empty.textContent = schema.emptyText || `No ${schema.itemLabel || "items"} yet.`;
|
||||
card.appendChild(empty);
|
||||
} else {
|
||||
const list = document.createElement("div");
|
||||
list.className = "page-editor-array-list";
|
||||
card.appendChild(list);
|
||||
|
||||
parent[key].forEach((item, index) => {
|
||||
const itemCard = document.createElement("div");
|
||||
itemCard.className = "card shadow-sm border-0 mb-3";
|
||||
itemCard.dataset.index = String(index);
|
||||
|
||||
const itemHeader = document.createElement("div");
|
||||
itemHeader.className = "card-header bg-white d-flex justify-content-between align-items-center gap-2 flex-wrap";
|
||||
|
||||
const title = getArrayItemTitle(schema, item, index);
|
||||
const subtitle = getArrayItemSubtitle(schema, item);
|
||||
itemHeader.innerHTML = `
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${
|
||||
schema.sortable
|
||||
? '<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder"><i class="fas fa-grip-vertical"></i></button>'
|
||||
: ""
|
||||
}
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(title)}</div>
|
||||
${subtitle ? `<div class="small text-muted">${escapeHtml(subtitle)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
${renderItemActions(schema.itemActions, item)}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item="true">
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
itemHeader
|
||||
.querySelector('[data-remove-item="true"]')
|
||||
.addEventListener("click", function () {
|
||||
parent[key].splice(index, 1);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
itemHeader.querySelectorAll("[data-item-href]").forEach((actionButton) => {
|
||||
actionButton.addEventListener("click", function () {
|
||||
window.location.href = actionButton.dataset.itemHref;
|
||||
});
|
||||
});
|
||||
|
||||
const itemBody = document.createElement("div");
|
||||
itemBody.className = "card-body";
|
||||
|
||||
if (schema.itemSchema.type === "primitive") {
|
||||
renderPrimitiveArrayItem(schema, itemBody, parent[key], index, tabKey, context);
|
||||
} else if (schema.itemSchema.type === "variant") {
|
||||
renderVariantArrayItem(
|
||||
schema.itemSchema,
|
||||
itemBody,
|
||||
parent[key],
|
||||
index,
|
||||
tabKey,
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const bodyRow = document.createElement("div");
|
||||
bodyRow.className = "row g-3";
|
||||
itemBody.appendChild(bodyRow);
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
renderField(field, bodyRow, parent[key][index], field.key, tabKey, {
|
||||
path: appendPath(context.path, `${index}.${field.key}`),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
itemCard.appendChild(itemHeader);
|
||||
itemCard.appendChild(itemBody);
|
||||
list.appendChild(itemCard);
|
||||
});
|
||||
|
||||
if (schema.sortable && window.Sortable) {
|
||||
window.Sortable.create(list, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
onEnd: function (event) {
|
||||
if (
|
||||
typeof event.oldIndex !== "number" ||
|
||||
typeof event.newIndex !== "number" ||
|
||||
event.oldIndex === event.newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedItem = parent[key].splice(event.oldIndex, 1)[0];
|
||||
parent[key].splice(event.newIndex, 0, movedItem);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
col.appendChild(card);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "checkbox") {
|
||||
renderCheckbox(schema, container, parent, key);
|
||||
return;
|
||||
}
|
||||
|
||||
renderLeafField(schema, container, parent, key, context);
|
||||
}
|
||||
|
||||
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index, tabKey, context) {
|
||||
const fieldSchema = arraySchema.itemSchema;
|
||||
const row = document.createElement("div");
|
||||
row.className = "row g-3";
|
||||
container.appendChild(row);
|
||||
|
||||
const holder = { value: targetArray[index] || "" };
|
||||
renderLeafField(
|
||||
{
|
||||
key: "value",
|
||||
label: fieldSchema.label || arraySchema.itemLabel || "Value",
|
||||
type: fieldSchema.fieldType || "text",
|
||||
maxLength: fieldSchema.maxLength,
|
||||
placeholder: fieldSchema.placeholder,
|
||||
helpText: fieldSchema.helpText,
|
||||
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
|
||||
},
|
||||
row,
|
||||
holder,
|
||||
"value",
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: holder,
|
||||
},
|
||||
);
|
||||
|
||||
const input = row.querySelector("input, textarea, select");
|
||||
if (input) {
|
||||
const sync = function () {
|
||||
targetArray[index] =
|
||||
fieldSchema.fieldType === "number" ? Number(holder.value || 0) : holder.value;
|
||||
};
|
||||
input.addEventListener("input", sync);
|
||||
input.addEventListener("change", sync);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey, context) {
|
||||
const item = targetArray[index];
|
||||
if (!isObject(item)) {
|
||||
targetArray[index] = {};
|
||||
}
|
||||
|
||||
const currentType =
|
||||
targetArray[index][variantSchema.discriminator] ||
|
||||
variantSchema.options[0].value;
|
||||
targetArray[index][variantSchema.discriminator] = currentType;
|
||||
|
||||
const currentVariant = variantSchema.variants[currentType];
|
||||
|
||||
const typeRow = document.createElement("div");
|
||||
typeRow.className = "row g-3 mb-2";
|
||||
container.appendChild(typeRow);
|
||||
|
||||
renderLeafField(
|
||||
{
|
||||
key: variantSchema.discriminator,
|
||||
label: "Section type",
|
||||
type: "select",
|
||||
options: variantSchema.options,
|
||||
},
|
||||
typeRow,
|
||||
targetArray[index],
|
||||
variantSchema.discriminator,
|
||||
context,
|
||||
);
|
||||
|
||||
const selectInput = typeRow.querySelector("select");
|
||||
if (selectInput) {
|
||||
selectInput.addEventListener("change", function () {
|
||||
const newType = this.value;
|
||||
targetArray[index] = { type: newType };
|
||||
renderSection(tabKey);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentVariant && currentVariant.schema) {
|
||||
const sectionRow = document.createElement("div");
|
||||
sectionRow.className = "row g-3";
|
||||
container.appendChild(sectionRow);
|
||||
|
||||
(currentVariant.schema.fields || []).forEach((field) => {
|
||||
renderField(field, sectionRow, targetArray[index], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: targetArray[index],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeafField(schema, container, parent, key, context) {
|
||||
if (schema.type === "hidden") {
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.defaultValue || "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.type === "number" ? 0 : "";
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || inferColClass(schema.type));
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
col.appendChild(label);
|
||||
|
||||
if (schema.type === "textarea") {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "form-control";
|
||||
textarea.rows = schema.rows || 4;
|
||||
textarea.value = parent[key] || "";
|
||||
if (schema.placeholder) textarea.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) textarea.maxLength = schema.maxLength;
|
||||
textarea.addEventListener("input", function () {
|
||||
parent[key] = textarea.value;
|
||||
updateCounter(counter, textarea.value.length, schema.maxLength);
|
||||
});
|
||||
col.appendChild(textarea);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "image") {
|
||||
const group = document.createElement("div");
|
||||
group.className = "input-group";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = parent[key] || "";
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.src = resolveImageUrl(input.value);
|
||||
preview.classList.toggle("d-none", !input.value);
|
||||
});
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn btn-outline-primary";
|
||||
button.innerHTML = '<i class="fas fa-upload me-1"></i>Upload';
|
||||
button.addEventListener("click", function () {
|
||||
openImagePicker(schema.imageType || config.imageType, function (path) {
|
||||
parent[key] = path;
|
||||
input.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.toggle("d-none", !path);
|
||||
});
|
||||
});
|
||||
|
||||
group.appendChild(input);
|
||||
group.appendChild(button);
|
||||
col.appendChild(group);
|
||||
|
||||
const preview = document.createElement("img");
|
||||
preview.className = "img-thumbnail uploaded-preview mt-2";
|
||||
preview.style.maxHeight = "200px";
|
||||
preview.src = resolveImageUrl(parent[key]);
|
||||
preview.classList.toggle("d-none", !parent[key]);
|
||||
col.appendChild(preview);
|
||||
|
||||
appendHelp(col, schema, parent[key], schema.imageHint);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "icon") {
|
||||
renderIconField(schema, col, parent, key);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "select") {
|
||||
const input = document.createElement("select");
|
||||
input.className = "form-select";
|
||||
const options = resolveOptions(schema, context.root);
|
||||
options.forEach((option) => {
|
||||
const optionEl = document.createElement("option");
|
||||
if (typeof option === "string") {
|
||||
optionEl.value = option;
|
||||
optionEl.textContent = option;
|
||||
} else {
|
||||
optionEl.value = option.value;
|
||||
optionEl.textContent = option.label;
|
||||
}
|
||||
input.appendChild(optionEl);
|
||||
});
|
||||
input.value = parent[key] || input.options[0]?.value || "";
|
||||
parent[key] = input.value;
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.value;
|
||||
renderAllSections();
|
||||
});
|
||||
col.appendChild(input);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "combobox") {
|
||||
const input = document.createElement("input");
|
||||
const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
|
||||
input.className = "form-control";
|
||||
input.type = "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
input.setAttribute("list", listId);
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
updateCounter(counter, input.value.length, schema.maxLength);
|
||||
});
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = listId;
|
||||
resolveOptions(schema, context.root).forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = typeof option === "string" ? option : option.value;
|
||||
item.label = typeof option === "string" ? option : option.label;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
col.appendChild(dataList);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.className = "form-control";
|
||||
input.type =
|
||||
schema.type === "number" || schema.type === "color" ? schema.type : "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
if (schema.step) input.step = schema.step;
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value;
|
||||
updateCounter(counter, String(input.value || "").length, schema.maxLength);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function renderIconField(schema, col, parent, key) {
|
||||
const selected = parent[key] || "";
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "border rounded-3 bg-white p-3";
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = selected;
|
||||
input.placeholder = "fa-shield-check";
|
||||
input.setAttribute("list", "cms-icon-options");
|
||||
top.appendChild(input);
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
||||
preview.style.width = "52px";
|
||||
preview.innerHTML = selected
|
||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
top.appendChild(preview);
|
||||
wrapper.appendChild(top);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "d-flex flex-wrap gap-2";
|
||||
(schema.options || []).forEach((option) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
||||
button.addEventListener("click", function () {
|
||||
parent[key] = option;
|
||||
input.value = option;
|
||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
||||
renderAllSections();
|
||||
});
|
||||
grid.appendChild(button);
|
||||
});
|
||||
wrapper.appendChild(grid);
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.innerHTML = input.value
|
||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
});
|
||||
|
||||
col.appendChild(wrapper);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
}
|
||||
|
||||
function renderCheckbox(schema, container, parent, key) {
|
||||
if (typeof parent[key] !== "boolean") {
|
||||
parent[key] = Boolean(parent[key]);
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "form-check mt-4";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.className = "form-check-input";
|
||||
input.checked = parent[key];
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.checked;
|
||||
});
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-check-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(label);
|
||||
col.appendChild(wrapper);
|
||||
if (schema.helpText) {
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = schema.helpText;
|
||||
col.appendChild(help);
|
||||
}
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function appendHelp(col, schema, value, extraHint) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "d-flex justify-content-between gap-3";
|
||||
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
|
||||
wrapper.appendChild(help);
|
||||
|
||||
let counter = null;
|
||||
if (schema.maxLength) {
|
||||
counter = document.createElement("div");
|
||||
counter.className = "form-text text-end ms-auto";
|
||||
updateCounter(counter, String(value || "").length, schema.maxLength);
|
||||
wrapper.appendChild(counter);
|
||||
}
|
||||
|
||||
if (help.textContent || counter) {
|
||||
col.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return counter;
|
||||
}
|
||||
|
||||
function updateCounter(counter, currentLength, maxLength) {
|
||||
if (!counter || !maxLength) return;
|
||||
counter.textContent = `${currentLength}/${maxLength}`;
|
||||
}
|
||||
|
||||
function openImagePicker(imageType, onSuccess) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", async function () {
|
||||
if (!fileInput.files || !fileInput.files[0]) {
|
||||
fileInput.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileInput.files[0]);
|
||||
|
||||
const response = await fetch(
|
||||
`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.path) {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
|
||||
onSuccess(result.path);
|
||||
showToast("Success", "Image uploaded successfully", "success");
|
||||
} catch (error) {
|
||||
showToast("Error", error.message || "Upload failed", "danger");
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function showToast(title, message, type) {
|
||||
const container =
|
||||
document.querySelector(".toast-container") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(
|
||||
title,
|
||||
)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", function () {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function createDefaultValue(schema) {
|
||||
if (!schema) return "";
|
||||
if (schema.type === "primitive") return schema.fieldType === "number" ? 0 : "";
|
||||
if (schema.type === "variant") {
|
||||
return { [schema.discriminator]: schema.options[0].value };
|
||||
}
|
||||
if (schema.type === "object") {
|
||||
const value = {};
|
||||
(schema.fields || []).forEach((field) => {
|
||||
if (field.type === "array") value[field.key] = [];
|
||||
else if (field.type === "object") value[field.key] = createDefaultValue(field);
|
||||
else if (field.type === "checkbox") value[field.key] = false;
|
||||
else if (field.type === "number") value[field.key] = 0;
|
||||
else value[field.key] = field.defaultValue || "";
|
||||
});
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function createCol(colClass) {
|
||||
const div = document.createElement("div");
|
||||
div.className = colClass;
|
||||
return div;
|
||||
}
|
||||
|
||||
function inferColClass(type) {
|
||||
if (type === "textarea" || type === "image" || type === "icon") return "col-12";
|
||||
if (type === "checkbox") return "col-12";
|
||||
return "col-md-6";
|
||||
}
|
||||
|
||||
function resolveImageUrl(path) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/")) return `${backendUrl}${path}`;
|
||||
return `${backendUrl}/${path}`;
|
||||
}
|
||||
|
||||
function resolveOptions(schema, root) {
|
||||
if (schema.optionsPath) {
|
||||
const value = getValueByPath(root, schema.optionsPath);
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
return schema.options || [];
|
||||
}
|
||||
|
||||
function collectIcons(schema) {
|
||||
if (!schema) return [];
|
||||
if (schema.type === "icon") return schema.options || [];
|
||||
if (schema.type === "object") return (schema.fields || []).flatMap(collectIcons);
|
||||
if (schema.type === "array") return collectIcons(schema.itemSchema);
|
||||
if (schema.type === "variant") {
|
||||
return Object.values(schema.variants || {}).flatMap((variant) =>
|
||||
collectIcons(variant.schema),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function ensureIconDatalist(options) {
|
||||
const existing = document.getElementById("cms-icon-options");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = "cms-icon-options";
|
||||
options.forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = option;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
document.body.appendChild(dataList);
|
||||
}
|
||||
|
||||
function applyAutoSequenceToArray(schema, targetArray) {
|
||||
if (!schema || !Array.isArray(targetArray) || schema.itemSchema.type !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
if (field.type !== "hidden" || !field.autoSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetArray.forEach((item, index) => {
|
||||
const value = String(index + 1);
|
||||
const padLength = field.autoSequence.padLength || 0;
|
||||
item[field.key] = padLength > 0 ? value.padStart(padLength, "0") : value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getArrayItemTitle(schema, item, index) {
|
||||
const value =
|
||||
item && schema.itemTitleKey && typeof item[schema.itemTitleKey] !== "undefined"
|
||||
? item[schema.itemTitleKey]
|
||||
: null;
|
||||
|
||||
return value || `${schema.itemLabel || "Item"} ${index + 1}`;
|
||||
}
|
||||
|
||||
function getArrayItemSubtitle(schema, item) {
|
||||
if (!item || !schema.itemSubtitleKey) return "";
|
||||
return item[schema.itemSubtitleKey] || "";
|
||||
}
|
||||
|
||||
function renderItemActions(actions, item) {
|
||||
if (!Array.isArray(actions) || !actions.length || !item) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const href = fillTemplate(action.hrefTemplate, item);
|
||||
if (!href) return "";
|
||||
return `<button type="button" class="${escapeHtml(
|
||||
action.className || "btn btn-outline-primary btn-sm",
|
||||
)}" data-item-href="${escapeHtml(href)}">${
|
||||
action.icon ? `<i class="${escapeHtml(action.icon)} me-1"></i>` : ""
|
||||
}${escapeHtml(action.label || "Open")}</button>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function passesVisibility(condition, parent, context) {
|
||||
if (!condition || !condition.path) return true;
|
||||
const target =
|
||||
condition.path === "$item"
|
||||
? context.item
|
||||
: getValueByPath(parent, condition.path) ??
|
||||
getValueByPath(context.item, condition.path) ??
|
||||
getValueByPath(context.root, condition.path);
|
||||
|
||||
if (Array.isArray(condition.equals)) {
|
||||
return condition.equals.includes(target);
|
||||
}
|
||||
|
||||
return target === condition.equals;
|
||||
}
|
||||
|
||||
function appendPath(basePath, segment) {
|
||||
return basePath ? `${basePath}.${segment}` : segment;
|
||||
}
|
||||
|
||||
function getValueByPath(target, path) {
|
||||
if (!target || !path) return undefined;
|
||||
return String(path)
|
||||
.split(".")
|
||||
.reduce((current, segment) => {
|
||||
if (current === null || typeof current === "undefined") return undefined;
|
||||
return current[segment];
|
||||
}, target);
|
||||
}
|
||||
|
||||
function fillTemplate(template, item) {
|
||||
if (!template) return "";
|
||||
return template.replace(/\{([^}]+)\}/g, function (_, key) {
|
||||
return item[key] || "";
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 568 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -7,7 +7,12 @@ const uploadController = require("../controllers/uploadController");
|
||||
const homeController = require("../controllers/homeController");
|
||||
const headerController = require("../controllers/headerController");
|
||||
const footerController = require("../controllers/footerController");
|
||||
const aboutController = require("../controllers/aboutController");
|
||||
const aboutUsController = require("../controllers/aboutUsController");
|
||||
const partnershipsController = require("../controllers/partnershipsController");
|
||||
const historyPageController = require("../controllers/historyPageController");
|
||||
const accreditationController = require("../controllers/accreditationController");
|
||||
const admissionsController = require("../controllers/admissionsController");
|
||||
const policiesController = require("../controllers/policiesController");
|
||||
const formController = require("../controllers/formController");
|
||||
const contactController = require("../controllers/contactController");
|
||||
const studentSupportController = require("../controllers/studentSupportController");
|
||||
@@ -49,9 +54,55 @@ router.param("code", (req, res, next, code) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// About
|
||||
router.get("/about", ensureAuthenticated, aboutController.index);
|
||||
router.post("/about/update", ensureAuthenticated, aboutController.update);
|
||||
// About Us
|
||||
router.get("/about-us", ensureAuthenticated, aboutUsController.index);
|
||||
router.post("/about-us/update", ensureAuthenticated, aboutUsController.update);
|
||||
router.get("/partnerships", ensureAuthenticated, partnershipsController.index);
|
||||
router.post(
|
||||
"/partnerships/update",
|
||||
ensureAuthenticated,
|
||||
partnershipsController.update,
|
||||
);
|
||||
router.get("/history", ensureAuthenticated, historyPageController.index);
|
||||
router.post("/history/update", ensureAuthenticated, historyPageController.update);
|
||||
router.get(
|
||||
"/accreditation",
|
||||
ensureAuthenticated,
|
||||
accreditationController.index,
|
||||
);
|
||||
router.post(
|
||||
"/accreditation/update",
|
||||
ensureAuthenticated,
|
||||
accreditationController.update,
|
||||
);
|
||||
router.get("/admissions", ensureAuthenticated, admissionsController.index);
|
||||
router.get(
|
||||
"/admissions/calculator/:optionId",
|
||||
ensureAuthenticated,
|
||||
admissionsController.editCalculatorOption,
|
||||
);
|
||||
router.post(
|
||||
"/admissions/calculator/:optionId/update",
|
||||
ensureAuthenticated,
|
||||
admissionsController.updateCalculatorOption,
|
||||
);
|
||||
router.post(
|
||||
"/admissions/update",
|
||||
ensureAuthenticated,
|
||||
admissionsController.update,
|
||||
);
|
||||
router.get("/policies", ensureAuthenticated, policiesController.index);
|
||||
router.post("/policies/update", ensureAuthenticated, policiesController.update);
|
||||
router.get(
|
||||
"/policies/:policyId/section",
|
||||
ensureAuthenticated,
|
||||
policiesController.editSections,
|
||||
);
|
||||
router.post(
|
||||
"/policies/:policyId/section/update",
|
||||
ensureAuthenticated,
|
||||
policiesController.updateSections,
|
||||
);
|
||||
|
||||
// Booking admin CRUD removed
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@ const express = require("express");
|
||||
const path = require("path");
|
||||
const router = express.Router();
|
||||
const homeController = require("../controllers/homeController");
|
||||
const aboutController = require("../controllers/aboutController");
|
||||
const aboutUsController = require("../controllers/aboutUsController");
|
||||
const partnershipsController = require("../controllers/partnershipsController");
|
||||
const historyPageController = require("../controllers/historyPageController");
|
||||
const accreditationController = require("../controllers/accreditationController");
|
||||
const admissionsController = require("../controllers/admissionsController");
|
||||
const policiesController = require("../controllers/policiesController");
|
||||
const headerController = require("../controllers/headerController");
|
||||
|
||||
const socialLinkController = require("../controllers/socialLinkController");
|
||||
@@ -38,7 +43,17 @@ router.get("/", (req, res) => {
|
||||
router.get("/api/home", homeController.api);
|
||||
|
||||
// API để lấy dữ liệu about
|
||||
router.get("/api/about", aboutController.api);
|
||||
router.get("/api/about", aboutUsController.getAbout);
|
||||
router.put("/api/about", aboutUsController.updateAbout);
|
||||
router.get("/api/partnerships", partnershipsController.api);
|
||||
router.get("/api/history", historyPageController.api);
|
||||
router.get("/api/accreditation", accreditationController.api);
|
||||
router.get("/api/admissions", admissionsController.api);
|
||||
router.get("/api/policies", policiesController.api);
|
||||
|
||||
// Public about-us page and API (legacy support)
|
||||
router.get("/about-us", aboutUsController.getAbout);
|
||||
router.get("/api/about-us", aboutUsController.getAbout);
|
||||
|
||||
// Header API route
|
||||
router.get("/api/header", headerController.api);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
const migrateSingletonPage = require("./_migrate-singleton-page");
|
||||
|
||||
migrateSingletonPage({
|
||||
migrationName: "import_partnerships_content",
|
||||
modelPath: "../models/partnerships",
|
||||
dataFile: "partnerships.json",
|
||||
label: "Partnerships",
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
const migrateSingletonPage = require("./_migrate-singleton-page");
|
||||
|
||||
migrateSingletonPage({
|
||||
migrationName: "import_history_content",
|
||||
modelPath: "../models/historyPage",
|
||||
dataFile: "history.json",
|
||||
label: "History",
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
const migrateSingletonPage = require("./_migrate-singleton-page");
|
||||
|
||||
migrateSingletonPage({
|
||||
migrationName: "import_accreditation_content",
|
||||
modelPath: "../models/accreditationPage",
|
||||
dataFile: "accreditation.json",
|
||||
label: "Accreditation",
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
const migrateSingletonPage = require("./_migrate-singleton-page");
|
||||
|
||||
migrateSingletonPage({
|
||||
migrationName: "import_admissions_content",
|
||||
modelPath: "../models/admissionsPage",
|
||||
dataFile: "admissions.json",
|
||||
label: "Admissions",
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
const migrateSingletonPage = require("./_migrate-singleton-page");
|
||||
|
||||
migrateSingletonPage({
|
||||
migrationName: "import_policies_content",
|
||||
modelPath: "../models/policiesPage",
|
||||
dataFile: "policies.json",
|
||||
label: "Policies",
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
require("dotenv").config();
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
const connectDB = require("../config/database");
|
||||
|
||||
async function migrateSingletonPage({
|
||||
migrationName,
|
||||
modelPath,
|
||||
dataFile,
|
||||
label,
|
||||
}) {
|
||||
try {
|
||||
await connectDB();
|
||||
console.log(`🚀 Starting migration: ${migrationName}...`);
|
||||
|
||||
const Model = require(modelPath);
|
||||
console.log(`✅ ${label} model registered successfully`);
|
||||
|
||||
const dataPath = path.join(__dirname, "..", "data", dataFile);
|
||||
const raw = await fs.readFile(dataPath, "utf8");
|
||||
const pageData = JSON.parse(raw);
|
||||
console.log(`📖 ${label} data loaded from: ${dataPath}`);
|
||||
|
||||
await Model.deleteMany({});
|
||||
console.log(`🧹 Existing ${label} documents cleared`);
|
||||
|
||||
const created = await Model.create(pageData);
|
||||
console.log(`✅ ${label} document created with _id: ${created._id.toString()}`);
|
||||
|
||||
console.log(`🎉 Migration ${migrationName} completed successfully.`);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error(`❌ Migration ${migrationName} failed:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = migrateSingletonPage;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
const {
|
||||
text,
|
||||
textarea,
|
||||
image,
|
||||
icon,
|
||||
select,
|
||||
combobox,
|
||||
object,
|
||||
stringList,
|
||||
objectList,
|
||||
} = require("./sharedFields");
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "Active", label: "Active" },
|
||||
{ value: "Inactive", label: "Inactive" },
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
key: "accreditation",
|
||||
title: "Accreditation Management",
|
||||
subtitle: "Manage the content for the accreditation page",
|
||||
routeBase: "/admin/accreditation",
|
||||
apiPath: "/api/accreditation",
|
||||
previewPath: "/about/accreditation",
|
||||
dataFile: "accreditation",
|
||||
imageType: "accreditation",
|
||||
tabs: [
|
||||
{
|
||||
key: "hero",
|
||||
label: "Hero",
|
||||
icon: "fas fa-image",
|
||||
schema: object("hero", "Hero", [
|
||||
text("badge", "Eyebrow label", { maxLength: 30 }),
|
||||
text("title", "Headline", { maxLength: 60 }),
|
||||
textarea("description", "Supporting text", { maxLength: 420, rows: 5 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "grid",
|
||||
label: "Accreditation Grid",
|
||||
icon: "fas fa-table-cells-large",
|
||||
schema: object("grid", "Accreditation grid", [
|
||||
stringList("tabs", "Category tabs", {
|
||||
itemLabel: "Tab",
|
||||
maxLength: 30,
|
||||
sortable: true,
|
||||
helpText: "Add and reorder the category tabs shown on the page.",
|
||||
}),
|
||||
objectList(
|
||||
"items",
|
||||
"Accreditation cards",
|
||||
[
|
||||
icon("icon", "Fallback icon"),
|
||||
image("image", "Card image", {
|
||||
imageHint: "Recommended 118x58 px minimum visible ratio",
|
||||
helpText: "Logo or badge shown at the top of the card.",
|
||||
}),
|
||||
select("status", "Status", statusOptions),
|
||||
combobox("category", "Category", {
|
||||
maxLength: 30,
|
||||
optionsPath: "grid.tabs",
|
||||
}),
|
||||
text("title", "Card title", {
|
||||
maxLength: 17,
|
||||
helpText: "Keep this very short. Around 17 characters fits best in the card title area.",
|
||||
}),
|
||||
textarea("description", "Card description", {
|
||||
maxLength: 300,
|
||||
rows: 5,
|
||||
helpText: "Keep this concise. Around 300 characters fits best in the card description area.",
|
||||
}),
|
||||
],
|
||||
{
|
||||
itemLabel: "Accreditation",
|
||||
sortable: true,
|
||||
itemTitleKey: "title",
|
||||
itemSubtitleKey: "category",
|
||||
emptyText: "No accreditation cards yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,290 @@
|
||||
const {
|
||||
text,
|
||||
hidden,
|
||||
textarea,
|
||||
image,
|
||||
icon,
|
||||
checkbox,
|
||||
object,
|
||||
stringList,
|
||||
objectList,
|
||||
linkFields,
|
||||
} = require("./sharedFields");
|
||||
|
||||
module.exports = {
|
||||
key: "admissions",
|
||||
title: "Admissions Management",
|
||||
subtitle: "Manage the content for the admissions page",
|
||||
routeBase: "/admin/admissions",
|
||||
apiPath: "/api/admissions",
|
||||
previewPath: "/admissions",
|
||||
dataFile: "admissions",
|
||||
imageType: "admissions",
|
||||
editorUi: {
|
||||
keyDates: {
|
||||
tableLabel: "Key dates table",
|
||||
tableHelpText: "Manage the table directly by adding or removing columns and rows.",
|
||||
addColumnLabel: "Add Column",
|
||||
addRowLabel: "Add Row",
|
||||
columnPlaceholder: "Column name",
|
||||
emptyRowsText: "No rows yet.",
|
||||
actionsLabel: "Actions",
|
||||
columnLabelMaxLength: 40,
|
||||
cellMaxLength: 60,
|
||||
},
|
||||
calculator: {
|
||||
ctaLabel: "Primary button",
|
||||
ctaHelpText: "This button appears at the bottom of the calculator card.",
|
||||
optionsLabel: "Calculator options",
|
||||
optionsHelpText: "Each option has its own pricing labels, amount, note, and icon. Open the edit page to update the option details.",
|
||||
optionsEmptyText: "No calculator options yet.",
|
||||
addOptionLabel: "Add calculator option",
|
||||
maxOptions: 3,
|
||||
limitHelpText: "You can add up to 3 calculator options.",
|
||||
defaultOption: {
|
||||
paceLabel: "Target Pace",
|
||||
minPaceLabel: "Relaxed",
|
||||
maxPaceLabel: "Accelerated",
|
||||
resultLabel: "Estimated Monthly Payment",
|
||||
monthlyAmount: "299",
|
||||
monthlySuffix: "/mo",
|
||||
noteIcon: "fa-bolt",
|
||||
note: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
tabs: [
|
||||
{
|
||||
key: "hero",
|
||||
label: "Hero",
|
||||
icon: "fas fa-image",
|
||||
schema: object("hero", "Hero", [
|
||||
text("badge", "Eyebrow label", { maxLength: 40 }),
|
||||
text("title", "Headline", { maxLength: 80 }),
|
||||
textarea("description", "Supporting text", { maxLength: 240, rows: 4 }),
|
||||
object("primaryCta", "Primary button", linkFields("Primary button")),
|
||||
object("secondaryCta", "Secondary button", linkFields("Secondary button")),
|
||||
image("image", "Hero image", {
|
||||
imageHint: "Recommended 720x646 px",
|
||||
helpText: "Large image rendered in the right hero panel.",
|
||||
}),
|
||||
text("imageAlt", "Hero image alt text", { maxLength: 120 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "process",
|
||||
label: "Admissions Process",
|
||||
icon: "fas fa-list-ol",
|
||||
schema: object("process", "Admissions process", [
|
||||
text("title", "Section title", { maxLength: 60 }),
|
||||
textarea("description", "Section description", {
|
||||
maxLength: 180,
|
||||
rows: 3,
|
||||
}),
|
||||
objectList(
|
||||
"steps",
|
||||
"Steps",
|
||||
[
|
||||
hidden("number", { autoSequence: { padLength: 2 } }),
|
||||
text("title", "Step title", { maxLength: 50 }),
|
||||
textarea("description", "Step description", {
|
||||
maxLength: 180,
|
||||
rows: 3,
|
||||
}),
|
||||
checkbox("active", "Highlight this step"),
|
||||
],
|
||||
{
|
||||
itemLabel: "Step",
|
||||
sortable: true,
|
||||
itemTitleKey: "title",
|
||||
itemSubtitleKey: "number",
|
||||
emptyText: "No process steps yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "eligibility",
|
||||
label: "Eligibility",
|
||||
icon: "fas fa-check-circle",
|
||||
schema: object("eligibility", "Eligibility", [
|
||||
text("title", "Section title", { maxLength: 60 }),
|
||||
objectList(
|
||||
"cards",
|
||||
"Eligibility cards",
|
||||
[
|
||||
text("title", "Card title", { maxLength: 50 }),
|
||||
icon("icon", "Card icon"),
|
||||
stringList("items", "Checklist items", {
|
||||
itemLabel: "Checklist item",
|
||||
maxLength: 120,
|
||||
sortable: true,
|
||||
}),
|
||||
],
|
||||
{
|
||||
itemLabel: "Card",
|
||||
sortable: true,
|
||||
itemTitleKey: "title",
|
||||
emptyText: "No eligibility cards yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "tuition",
|
||||
label: "Tuition",
|
||||
icon: "fas fa-chart-column",
|
||||
schema: object("tuition", "Tuition", [
|
||||
text("title", "Section title", { maxLength: 45 }),
|
||||
text("chartTitle", "Chart title", { maxLength: 40 }),
|
||||
textarea("chartDescription", "Chart description", {
|
||||
maxLength: 140,
|
||||
rows: 3,
|
||||
}),
|
||||
objectList(
|
||||
"series",
|
||||
"Chart series",
|
||||
[
|
||||
text("label", "Series label", { maxLength: 20 }),
|
||||
{ key: "color", label: "Series color", type: "color" },
|
||||
objectList(
|
||||
"points",
|
||||
"Data points",
|
||||
[
|
||||
text("time", "Time label", {
|
||||
maxLength: 14,
|
||||
placeholder: "Year 1",
|
||||
}),
|
||||
{
|
||||
key: "value",
|
||||
label: "Value",
|
||||
type: "number",
|
||||
min: 0,
|
||||
},
|
||||
],
|
||||
{
|
||||
itemLabel: "Point",
|
||||
sortable: true,
|
||||
itemTitleKey: "time",
|
||||
itemSubtitleKey: "value",
|
||||
helpText: "Each chart point requires both a time label and a numeric value.",
|
||||
emptyText: "No data points yet.",
|
||||
},
|
||||
),
|
||||
],
|
||||
{
|
||||
itemLabel: "Series",
|
||||
sortable: true,
|
||||
itemTitleKey: "label",
|
||||
emptyText: "No chart series yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "keyDates",
|
||||
label: "Key Dates",
|
||||
icon: "fas fa-calendar-days",
|
||||
schema: object("keyDates", "Key dates", [
|
||||
text("title", "Section title", { maxLength: 60 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "calculator",
|
||||
label: "Calculator",
|
||||
icon: "fas fa-calculator",
|
||||
schema: object("calculator", "Calculator", [
|
||||
text("title", "Card title", { maxLength: 60 }),
|
||||
textarea("description", "Card description", {
|
||||
maxLength: 120,
|
||||
rows: 3,
|
||||
}),
|
||||
object("cta", "Button", linkFields("Button")),
|
||||
objectList(
|
||||
"options",
|
||||
"Calculator options",
|
||||
[
|
||||
hidden("id"),
|
||||
text("label", "Option label", {
|
||||
maxLength: 12,
|
||||
helpText: "This label appears in the calculator option switcher.",
|
||||
}),
|
||||
text("paceLabel", "Pace label", {
|
||||
maxLength: 20,
|
||||
helpText: "This appears above the pace slider.",
|
||||
}),
|
||||
text("minPaceLabel", "Minimum pace label", {
|
||||
maxLength: 7,
|
||||
helpText: "This appears on the left side of the pace slider.",
|
||||
}),
|
||||
text("maxPaceLabel", "Maximum pace label", {
|
||||
maxLength: 7,
|
||||
helpText: "This appears on the right side of the pace slider.",
|
||||
}),
|
||||
text("resultLabel", "Result label", {
|
||||
maxLength: 40,
|
||||
helpText: "This label appears above the calculated amount.",
|
||||
}),
|
||||
text("monthlyAmount", "Monthly amount", {
|
||||
maxLength: 20,
|
||||
helpText: "Enter digits only. The currency symbol is added on the website automatically.",
|
||||
}),
|
||||
text("monthlySuffix", "Monthly suffix", {
|
||||
maxLength: 10,
|
||||
helpText: "Example: /mo",
|
||||
}),
|
||||
icon("noteIcon", "Note icon", {
|
||||
helpText: "Choose the icon shown beside the note.",
|
||||
}),
|
||||
text("note", "Note text", {
|
||||
maxLength: 60,
|
||||
helpText: "This short note appears under the amount.",
|
||||
}),
|
||||
],
|
||||
{
|
||||
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}",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "scholarships",
|
||||
label: "Scholarships",
|
||||
icon: "fas fa-award",
|
||||
schema: object("scholarships", "Scholarships", [
|
||||
text("title", "Card title", { maxLength: 50 }),
|
||||
icon("icon", "Card icon"),
|
||||
objectList(
|
||||
"items",
|
||||
"Scholarship items",
|
||||
[
|
||||
text("title", "Title", { maxLength: 40 }),
|
||||
text("amount", "Amount", { maxLength: 12 }),
|
||||
textarea("description", "Description", {
|
||||
maxLength: 160,
|
||||
rows: 3,
|
||||
}),
|
||||
],
|
||||
{
|
||||
itemLabel: "Scholarship item",
|
||||
sortable: true,
|
||||
itemTitleKey: "title",
|
||||
itemSubtitleKey: "amount",
|
||||
emptyText: "No scholarship items yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
const {
|
||||
text,
|
||||
textarea,
|
||||
image,
|
||||
icon,
|
||||
checkbox,
|
||||
url,
|
||||
combobox,
|
||||
object,
|
||||
stringList,
|
||||
objectList,
|
||||
} = require("./sharedFields");
|
||||
|
||||
module.exports = {
|
||||
key: "history",
|
||||
title: "History Management",
|
||||
subtitle: "Manage the content for the history page",
|
||||
routeBase: "/admin/history",
|
||||
apiPath: "/api/history",
|
||||
previewPath: "/about/history",
|
||||
dataFile: "history",
|
||||
imageType: "history",
|
||||
tabs: [
|
||||
{
|
||||
key: "highlight",
|
||||
label: "Highlight Bar",
|
||||
icon: "fas fa-star",
|
||||
schema: object("highlight", "Highlight bar", [
|
||||
icon("icon", "Highlight icon"),
|
||||
text("text", "Message", { maxLength: 110 }),
|
||||
text("linkLabel", "Link label", { maxLength: 30 }),
|
||||
url("href", "Link URL", {
|
||||
maxLength: 255,
|
||||
helpText: "Use an anchor or website path only. Examples: #milestones, /about/history, /contact",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "hero",
|
||||
label: "Hero",
|
||||
icon: "fas fa-image",
|
||||
schema: object("hero", "Hero", [
|
||||
text("badge", "Eyebrow label", { maxLength: 40 }),
|
||||
text("title", "Headline", { maxLength: 90 }),
|
||||
textarea("description", "Supporting text", { maxLength: 220, rows: 4 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "filters",
|
||||
label: "Filter Controls",
|
||||
icon: "fas fa-filter",
|
||||
schema: object("filters", "Filter controls", [
|
||||
stringList("yearOptions", "Year options", {
|
||||
itemLabel: "Year option",
|
||||
maxLength: 30,
|
||||
sortable: true,
|
||||
helpText: "Reorder these options to change the order in the Year range dropdown.",
|
||||
}),
|
||||
stringList("categoryOptions", "Category options", {
|
||||
itemLabel: "Category option",
|
||||
maxLength: 40,
|
||||
sortable: true,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "timeline",
|
||||
label: "Timeline",
|
||||
icon: "fas fa-clock-rotate-left",
|
||||
schema: object("timeline", "Timeline", [
|
||||
objectList(
|
||||
"items",
|
||||
"Milestones",
|
||||
[
|
||||
text("year", "Year", {
|
||||
maxLength: 4,
|
||||
}),
|
||||
combobox("yearRange", "Year range", {
|
||||
maxLength: 30,
|
||||
optionsPath: "filters.yearOptions",
|
||||
}),
|
||||
combobox("category", "Category", {
|
||||
maxLength: 40,
|
||||
optionsPath: "filters.categoryOptions",
|
||||
}),
|
||||
text("categoryLabel", "Category badge label", { maxLength: 25 }),
|
||||
text("title", "Milestone title", { maxLength: 90 }),
|
||||
textarea("description", "Description", {
|
||||
maxLength: 260,
|
||||
rows: 4,
|
||||
}),
|
||||
image("image", "Milestone image", {
|
||||
imageHint: "Recommended 436x190 px, 16:9 aspect ratio.",
|
||||
helpText: "Wide image used inside the milestone card.",
|
||||
}),
|
||||
text("imageAlt", "Image alt text", { maxLength: 120 }),
|
||||
objectList(
|
||||
"stats",
|
||||
"Stats",
|
||||
[
|
||||
text("value", "Value", { maxLength: 24 }),
|
||||
text("label", "Label", { maxLength: 50 }),
|
||||
],
|
||||
{
|
||||
itemLabel: "Stat",
|
||||
sortable: true,
|
||||
emptyText: "No stats yet.",
|
||||
},
|
||||
),
|
||||
checkbox("featured", "Featured milestone"),
|
||||
],
|
||||
{
|
||||
itemLabel: "Milestone",
|
||||
sortable: true,
|
||||
itemTitleKey: "title",
|
||||
itemSubtitleKey: "year",
|
||||
emptyText: "No milestones yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
const {
|
||||
text,
|
||||
textarea,
|
||||
image,
|
||||
checkbox,
|
||||
select,
|
||||
combobox,
|
||||
object,
|
||||
stringList,
|
||||
objectList,
|
||||
} = require("./sharedFields");
|
||||
|
||||
const inquiryFieldOptions = [
|
||||
{ value: "text", label: "Single line text" },
|
||||
{ value: "textarea", label: "Paragraph" },
|
||||
{ value: "select", label: "Dropdown" },
|
||||
];
|
||||
|
||||
const widthOptions = [
|
||||
{ value: "half", label: "Half width" },
|
||||
{ value: "full", label: "Full width" },
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
key: "partnerships",
|
||||
title: "Partnerships Management",
|
||||
subtitle: "Manage the content for the partnerships page",
|
||||
routeBase: "/admin/partnerships",
|
||||
apiPath: "/api/partnerships",
|
||||
previewPath: "/about/partnerships",
|
||||
dataFile: "partnerships",
|
||||
imageType: "partnerships",
|
||||
editorUi: {
|
||||
directory: {
|
||||
tabsFrontendHint: "The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.",
|
||||
partnersHelpText: "Each partner card keeps its own open or closed state automatically.",
|
||||
},
|
||||
inquiryForm: {
|
||||
fieldsHelpText: "Manage labels, placeholders, type, width, and dropdown options.",
|
||||
},
|
||||
},
|
||||
tabs: [
|
||||
{
|
||||
key: "hero",
|
||||
label: "Hero",
|
||||
icon: "fas fa-image",
|
||||
schema: object("hero", "Hero", [
|
||||
text("badge", "Eyebrow label", { maxLength: 40 }),
|
||||
text("title", "Headline", { maxLength: 90 }),
|
||||
textarea("description", "Supporting text", { maxLength: 220, rows: 4 }),
|
||||
text("linkLabel", "Scroll link label", { maxLength: 40 }),
|
||||
image("image", "Hero image", {
|
||||
imageHint: "Recommended 720x630 px",
|
||||
helpText: "Upload the image shown in the right hero panel.",
|
||||
}),
|
||||
text("imageAlt", "Hero image alt text", { maxLength: 120 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "directory",
|
||||
label: "Partner Directory",
|
||||
icon: "fas fa-handshake",
|
||||
schema: object("directory", "Partner directory", [
|
||||
text("heading", "Section heading", { maxLength: 70 }),
|
||||
textarea("description", "Section description", {
|
||||
maxLength: 180,
|
||||
rows: 3,
|
||||
}),
|
||||
stringList("tabs", "Category tabs", {
|
||||
itemLabel: "Category tab",
|
||||
maxLength: 30,
|
||||
placeholder: "Industry",
|
||||
sortable: true,
|
||||
helpText: "Add and reorder the category tabs shown on the page.",
|
||||
}),
|
||||
objectList(
|
||||
"partners",
|
||||
"Partners",
|
||||
[
|
||||
text("name", "Partner name", { maxLength: 90 }),
|
||||
combobox("category", "Category", {
|
||||
maxLength: 30,
|
||||
optionsPath: "directory.tabs",
|
||||
}),
|
||||
textarea("summary", "Card summary", {
|
||||
maxLength: 130,
|
||||
rows: 3,
|
||||
helpText: "Keep this short. Around 130 characters works best in the card layout.",
|
||||
}),
|
||||
image("logo", "Partner logo", {
|
||||
imageHint: "Recommended 105x80 px minimum visible ratio",
|
||||
helpText: "Use a clean logo with transparent or simple background.",
|
||||
}),
|
||||
text("logoAlt", "Logo alt text", { maxLength: 120 }),
|
||||
textarea("about", "About text", { maxLength: 600, rows: 5 }),
|
||||
text("collabType", "Collaboration type", { maxLength: 40 }),
|
||||
textarea("benefits", "Benefits", { maxLength: 240, rows: 4 }),
|
||||
],
|
||||
{
|
||||
itemLabel: "Partner",
|
||||
sortable: true,
|
||||
itemTitleKey: "name",
|
||||
itemSubtitleKey: "category",
|
||||
emptyText: "No partners yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "cta",
|
||||
label: "Call To Action",
|
||||
icon: "fas fa-bullhorn",
|
||||
schema: object("cta", "Call to action", [
|
||||
text("heading", "Headline", { maxLength: 80 }),
|
||||
textarea("description", "Supporting text", {
|
||||
maxLength: 220,
|
||||
rows: 4,
|
||||
}),
|
||||
text("buttonLabel", "Button label", { maxLength: 40 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "inquiryForm",
|
||||
label: "Inquiry Form",
|
||||
icon: "fas fa-envelope",
|
||||
schema: object("inquiryForm", "Inquiry form", [
|
||||
text("title", "Modal title", { maxLength: 35 }),
|
||||
objectList(
|
||||
"fields",
|
||||
"Form fields",
|
||||
[
|
||||
text("label", "Field label", { maxLength: 40 }),
|
||||
text("placeholder", "Placeholder text", { maxLength: 80 }),
|
||||
select("type", "Field type", inquiryFieldOptions),
|
||||
select("width", "Field width", widthOptions),
|
||||
stringList("options", "Dropdown options", {
|
||||
itemLabel: "Option",
|
||||
maxLength: 50,
|
||||
sortable: true,
|
||||
helpText: "Only used when the field type is Dropdown.",
|
||||
visibleWhen: { path: "type", equals: "select" },
|
||||
}),
|
||||
checkbox("required", "Required field"),
|
||||
],
|
||||
{
|
||||
itemLabel: "Field",
|
||||
sortable: true,
|
||||
itemTitleKey: "label",
|
||||
itemSubtitleKey: "type",
|
||||
emptyText: "No inquiry fields yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
const {
|
||||
ICON_OPTIONS,
|
||||
text,
|
||||
date,
|
||||
textarea,
|
||||
icon,
|
||||
url,
|
||||
combobox,
|
||||
object,
|
||||
objectList,
|
||||
stringList,
|
||||
variantList,
|
||||
} = require("./sharedFields");
|
||||
|
||||
const baseConfig = {
|
||||
key: "policies",
|
||||
title: "Policies Management",
|
||||
subtitle: "Manage policy metadata and block-based content for the policies page",
|
||||
routeBase: "/admin/policies",
|
||||
apiPath: "/api/policies",
|
||||
previewPath: "/policies",
|
||||
dataFile: "policies",
|
||||
imageType: "policies",
|
||||
tabs: [
|
||||
{
|
||||
key: "hero",
|
||||
label: "Hero",
|
||||
icon: "fas fa-scale-balanced",
|
||||
schema: object("hero", "Hero", [
|
||||
text("badge", "Eyebrow label", { maxLength: 40 }),
|
||||
icon("icon", "Hero icon", {
|
||||
helpText: "Policies uses icon-only controls and does not require image upload.",
|
||||
}),
|
||||
text("titlePrefix", "Headline prefix", { maxLength: 50 }),
|
||||
text("titleHighlight", "Headline highlight", { maxLength: 40 }),
|
||||
textarea("description", "Supporting text", {
|
||||
maxLength: 220,
|
||||
rows: 4,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "sidebar",
|
||||
label: "Sidebar",
|
||||
icon: "fas fa-bars",
|
||||
schema: object("sidebar", "Sidebar", [
|
||||
text("heading", "Sidebar heading", { maxLength: 30 }),
|
||||
text("helperText", "Helper text", { maxLength: 60 }),
|
||||
text("contactLabel", "Contact link label", { maxLength: 25 }),
|
||||
url("contactHref", "Contact URL", { maxLength: 255 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "policies",
|
||||
label: "Policies",
|
||||
icon: "fas fa-file-lines",
|
||||
schema: objectList(
|
||||
"policies",
|
||||
"Policies",
|
||||
[
|
||||
text("navLabel", "Sidebar label", { maxLength: 40 }),
|
||||
text("title", "Policy title", { maxLength: 70 }),
|
||||
date("effectiveDate", "Effective date", {
|
||||
helpText: "Pick a calendar date. It will be saved in the standard policy display format.",
|
||||
}),
|
||||
textarea("intro", "Intro text", { maxLength: 260, rows: 4 }),
|
||||
],
|
||||
{
|
||||
itemLabel: "Policy",
|
||||
sortable: true,
|
||||
itemTitleKey: "title",
|
||||
itemSubtitleKey: "effectiveDate",
|
||||
emptyText: "No policies yet.",
|
||||
itemActions: [
|
||||
{
|
||||
label: "Edit Content",
|
||||
icon: "fas fa-pen-to-square",
|
||||
hrefTemplate: "/admin/policies/{id}/section",
|
||||
className: "btn btn-outline-primary btn-sm",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function createPoliciesSectionEditorConfig(policy, allPolicies = []) {
|
||||
const policyOptions = allPolicies
|
||||
.filter((item) => item.id && item.id !== policy.id)
|
||||
.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.title || item.navLabel || item.id,
|
||||
}));
|
||||
|
||||
return {
|
||||
key: "policyContent",
|
||||
title: `Policy Content: ${policy.title || policy.id}`,
|
||||
subtitle: "Edit block-based policy content with realtime preview",
|
||||
routeBase: `/admin/policies/${policy.id}/section`,
|
||||
previewPath: "/policies",
|
||||
imageType: "policies",
|
||||
policyId: policy.id,
|
||||
policyOptions,
|
||||
iconOptions: ICON_OPTIONS,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
baseConfig,
|
||||
createPoliciesSectionEditorConfig,
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
const ICON_OPTIONS = [
|
||||
"fa-scale-balanced",
|
||||
"fa-shield-check",
|
||||
"fa-magnifying-glass",
|
||||
"fa-graduation-cap",
|
||||
"fa-check-double",
|
||||
"fa-building-columns",
|
||||
"fa-award",
|
||||
"fa-trophy",
|
||||
"fa-arrow-down",
|
||||
"fa-arrow-right",
|
||||
"fa-check-circle",
|
||||
"fa-exchange-alt",
|
||||
"fa-bolt",
|
||||
"fa-book-open",
|
||||
"fa-credit-card",
|
||||
"fa-calendar-days",
|
||||
"fa-envelope",
|
||||
"fa-file-lines",
|
||||
"fa-globe",
|
||||
"fa-briefcase",
|
||||
"fa-handshake",
|
||||
"fa-circle-info",
|
||||
"fa-star",
|
||||
"fa-list-check",
|
||||
];
|
||||
|
||||
const text = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "text",
|
||||
...options,
|
||||
});
|
||||
|
||||
const date = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "date",
|
||||
...options,
|
||||
});
|
||||
|
||||
const hidden = (key, options = {}) => ({
|
||||
key,
|
||||
type: "hidden",
|
||||
...options,
|
||||
});
|
||||
|
||||
const textarea = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "textarea",
|
||||
rows: options.rows || 4,
|
||||
...options,
|
||||
});
|
||||
|
||||
const image = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "image",
|
||||
...options,
|
||||
});
|
||||
|
||||
const icon = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "icon",
|
||||
options: options.options || ICON_OPTIONS,
|
||||
...options,
|
||||
});
|
||||
|
||||
const checkbox = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "checkbox",
|
||||
...options,
|
||||
});
|
||||
|
||||
const url = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "url",
|
||||
...options,
|
||||
});
|
||||
|
||||
const select = (key, label, options = [], extra = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "select",
|
||||
options,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const combobox = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "combobox",
|
||||
...options,
|
||||
});
|
||||
|
||||
const object = (key, label, fields, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "object",
|
||||
fields,
|
||||
...options,
|
||||
});
|
||||
|
||||
const stringList = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "array",
|
||||
itemLabel: options.itemLabel || "Item",
|
||||
sortable: options.sortable,
|
||||
addLabel: options.addLabel,
|
||||
helpText: options.helpText,
|
||||
emptyText: options.emptyText,
|
||||
itemSchema: {
|
||||
type: "primitive",
|
||||
fieldType: options.fieldType || "text",
|
||||
label: options.itemLabel || "Item",
|
||||
maxLength: options.maxLength,
|
||||
min: options.min,
|
||||
max: options.max,
|
||||
step: options.step,
|
||||
placeholder: options.placeholder,
|
||||
helpText: options.itemHelpText,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
const objectList = (key, label, fields, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "array",
|
||||
itemLabel: options.itemLabel || "Item",
|
||||
sortable: options.sortable,
|
||||
addLabel: options.addLabel,
|
||||
helpText: options.helpText,
|
||||
emptyText: options.emptyText,
|
||||
itemTitleKey: options.itemTitleKey,
|
||||
itemSubtitleKey: options.itemSubtitleKey,
|
||||
itemActions: options.itemActions,
|
||||
itemSchema: {
|
||||
type: "object",
|
||||
fields,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
const variantList = (key, label, variants, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "array",
|
||||
itemLabel: options.itemLabel || "Item",
|
||||
sortable: options.sortable,
|
||||
addLabel: options.addLabel,
|
||||
helpText: options.helpText,
|
||||
emptyText: options.emptyText,
|
||||
itemSchema: {
|
||||
type: "variant",
|
||||
discriminator: "type",
|
||||
options: Object.keys(variants).map((value) => ({
|
||||
value,
|
||||
label: variants[value].label,
|
||||
})),
|
||||
variants,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
const linkFields = (prefix = "Link") => [
|
||||
text("label", `${prefix} label`, { maxLength: 40 }),
|
||||
url("href", `${prefix} URL`, { maxLength: 255 }),
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
ICON_OPTIONS,
|
||||
text,
|
||||
date,
|
||||
hidden,
|
||||
textarea,
|
||||
image,
|
||||
icon,
|
||||
checkbox,
|
||||
url,
|
||||
select,
|
||||
combobox,
|
||||
object,
|
||||
stringList,
|
||||
objectList,
|
||||
variantList,
|
||||
linkFields,
|
||||
};
|
||||
@@ -0,0 +1,404 @@
|
||||
const { ensureUniqueIds, slugifyContentId } = require("./contentEditorIds");
|
||||
|
||||
const BLOCK_TYPES = new Set([
|
||||
"heading",
|
||||
"paragraph",
|
||||
"list",
|
||||
"quote",
|
||||
"divider",
|
||||
"callout",
|
||||
]);
|
||||
|
||||
function createBlockId(prefix, value, index) {
|
||||
return slugifyContentId(value, `${prefix}-${index + 1}`);
|
||||
}
|
||||
|
||||
function stripHtml(html = "") {
|
||||
return String(html || "")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<\/p>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeHtml(value = "") {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function ensureParagraphHtml(value = "") {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (/<[a-z][\s\S]*>/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return `<p>${escapeHtml(raw)}</p>`;
|
||||
}
|
||||
|
||||
function createInternalAnchor(policyId, label) {
|
||||
return `<a href="#${escapeHtml(policyId)}" data-policy-id="${escapeHtml(
|
||||
policyId,
|
||||
)}" data-link-kind="internal">${escapeHtml(label)}</a>`;
|
||||
}
|
||||
|
||||
function createExternalAnchor(href, label) {
|
||||
return `<a href="${escapeHtml(href)}">${escapeHtml(label)}</a>`;
|
||||
}
|
||||
|
||||
function paragraphToHtml(paragraph = {}) {
|
||||
const text = String(paragraph.text || "");
|
||||
const links = Array.isArray(paragraph.links) ? paragraph.links : [];
|
||||
let html = escapeHtml(text);
|
||||
|
||||
links.forEach((link, index) => {
|
||||
const label = String(link.label || "").trim();
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = link.tabId
|
||||
? createInternalAnchor(link.tabId, label)
|
||||
: link.href
|
||||
? createExternalAnchor(link.href, label)
|
||||
: escapeHtml(label);
|
||||
|
||||
if (html.includes(escapeHtml(label))) {
|
||||
html = html.replace(escapeHtml(label), anchor);
|
||||
return;
|
||||
}
|
||||
|
||||
const suffix = index === 0 ? "" : " ";
|
||||
html = `${html}${suffix}${anchor}`;
|
||||
});
|
||||
|
||||
return `<p>${html}</p>`;
|
||||
}
|
||||
|
||||
function legacySectionToBlocks(section = {}, sectionIndex = 0) {
|
||||
const blocks = [];
|
||||
const heading = String(section.heading || section.title || "").trim();
|
||||
|
||||
if (heading) {
|
||||
blocks.push({
|
||||
id: createBlockId("heading", heading, sectionIndex),
|
||||
type: "heading",
|
||||
level: 2,
|
||||
html: `<span>${escapeHtml(heading)}</span>`,
|
||||
});
|
||||
}
|
||||
|
||||
if (section.type === "text") {
|
||||
const paragraphs = Array.isArray(section.paragraphs) ? section.paragraphs : [];
|
||||
paragraphs.forEach((paragraph, paragraphIndex) => {
|
||||
blocks.push({
|
||||
id: createBlockId(
|
||||
"paragraph",
|
||||
paragraph.text || `${heading || "paragraph"}-${paragraphIndex + 1}`,
|
||||
paragraphIndex,
|
||||
),
|
||||
type: "paragraph",
|
||||
html: paragraphToHtml(paragraph),
|
||||
});
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
if (section.type === "list") {
|
||||
if (section.intro) {
|
||||
blocks.push({
|
||||
id: createBlockId("paragraph", `${heading || "list"}-intro`, sectionIndex),
|
||||
type: "paragraph",
|
||||
html: ensureParagraphHtml(section.intro),
|
||||
});
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
id: createBlockId("list", heading || `list-${sectionIndex + 1}`, sectionIndex),
|
||||
type: "list",
|
||||
style: "unordered",
|
||||
items: (Array.isArray(section.items) ? section.items : []).map((item, itemIndex) => ({
|
||||
id: createBlockId("item", item || `item-${itemIndex + 1}`, itemIndex),
|
||||
html: ensureParagraphHtml(item),
|
||||
})),
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
if (section.type === "cards") {
|
||||
const cards = Array.isArray(section.cards) ? section.cards : [];
|
||||
cards.forEach((card, cardIndex) => {
|
||||
const description = String(card.description || "");
|
||||
const linkHtml = card.link?.tabId
|
||||
? `<p>${createInternalAnchor(card.link.tabId, card.link.label || "Open")}</p>`
|
||||
: card.link?.href
|
||||
? `<p>${createExternalAnchor(card.link.href, card.link.label || card.link.href)}</p>`
|
||||
: "";
|
||||
|
||||
blocks.push({
|
||||
id: createBlockId("callout", card.title || `card-${cardIndex + 1}`, cardIndex),
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: String(card.title || ""),
|
||||
icon: String(card.icon || ""),
|
||||
html: `${ensureParagraphHtml(description)}${linkHtml}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function legacySectionsToBlocks(sections = []) {
|
||||
return ensureUniqueIds(
|
||||
(Array.isArray(sections) ? sections : []).flatMap(legacySectionToBlocks),
|
||||
(block) => block.id,
|
||||
(block, index) => stripHtml(block.html) || `${block.type}-${index + 1}`,
|
||||
"block",
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeListItems(items = []) {
|
||||
return ensureUniqueIds(
|
||||
(Array.isArray(items) ? items : [])
|
||||
.map((item, index) => {
|
||||
if (typeof item === "string") {
|
||||
return {
|
||||
id: createBlockId("item", item, index),
|
||||
html: ensureParagraphHtml(item),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id || createBlockId("item", stripHtml(item.html), index),
|
||||
html: ensureParagraphHtml(item.html),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.html),
|
||||
(item) => item.id,
|
||||
(item, index) => stripHtml(item.html) || `item-${index + 1}`,
|
||||
"item",
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBlock(block = {}, index = 0) {
|
||||
const type = BLOCK_TYPES.has(block.type) ? block.type : "paragraph";
|
||||
const normalized = {
|
||||
id:
|
||||
block.id ||
|
||||
createBlockId(type, block.title || stripHtml(block.html) || type, index),
|
||||
type,
|
||||
};
|
||||
|
||||
if (type === "heading") {
|
||||
normalized.level = [1, 2, 3].includes(Number(block.level))
|
||||
? Number(block.level)
|
||||
: 2;
|
||||
normalized.html = ensureParagraphHtml(block.html || block.content || block.title || "");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (type === "paragraph" || type === "quote") {
|
||||
normalized.html = ensureParagraphHtml(block.html || block.content || "");
|
||||
if (type === "quote") {
|
||||
normalized.caption = String(block.caption || "");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (type === "list") {
|
||||
normalized.style = block.style === "ordered" ? "ordered" : "unordered";
|
||||
normalized.items = normalizeListItems(block.items);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (type === "divider") {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
normalized.tone = ["info", "warning", "success"].includes(block.tone)
|
||||
? block.tone
|
||||
: "info";
|
||||
normalized.title = String(block.title || "");
|
||||
normalized.icon = String(block.icon || "");
|
||||
normalized.html = ensureParagraphHtml(block.html || block.content || "");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeContent(content = {}) {
|
||||
const blocks = ensureUniqueIds(
|
||||
(Array.isArray(content.blocks) ? content.blocks : []).map(normalizeBlock),
|
||||
(block) => block.id,
|
||||
(block, index) => stripHtml(block.title || block.html) || `${block.type}-${index + 1}`,
|
||||
"block",
|
||||
);
|
||||
|
||||
return {
|
||||
blocks,
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyContent() {
|
||||
return {
|
||||
blocks: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePolicyContent(policy = {}) {
|
||||
if (policy.content && typeof policy.content === "object") {
|
||||
return normalizeContent(policy.content);
|
||||
}
|
||||
|
||||
if (policy.contentByLanguage && typeof policy.contentByLanguage === "object") {
|
||||
const englishContent = policy.contentByLanguage.en || policy.contentByLanguage.vi;
|
||||
return normalizeContent(englishContent || createEmptyContent());
|
||||
}
|
||||
|
||||
const migratedBlocks = legacySectionsToBlocks(policy.sections);
|
||||
return normalizeContent({ blocks: migratedBlocks });
|
||||
}
|
||||
|
||||
function normalizePolicy(policy = {}) {
|
||||
const normalized = {
|
||||
...policy,
|
||||
content: normalizePolicyContent(policy),
|
||||
};
|
||||
|
||||
delete normalized.sections;
|
||||
delete normalized.contentByLanguage;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePoliciesDocument(data = {}) {
|
||||
return {
|
||||
...data,
|
||||
policies: (Array.isArray(data.policies) ? data.policies : []).map(normalizePolicy),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAnchorAttributes(source = "") {
|
||||
const attributes = {};
|
||||
const regex = /([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
||||
let match = regex.exec(source);
|
||||
|
||||
while (match) {
|
||||
attributes[match[1]] = match[3] || match[4] || "";
|
||||
match = regex.exec(source);
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
function collectLinkErrors(html = "", validPolicyIds = []) {
|
||||
const errors = [];
|
||||
const anchorRegex = /<a\b([^>]*)>/gi;
|
||||
let match = anchorRegex.exec(String(html || ""));
|
||||
|
||||
while (match) {
|
||||
const attrs = parseAnchorAttributes(match[1]);
|
||||
const href = String(attrs.href || "").trim();
|
||||
const policyId = String(attrs["data-policy-id"] || "").trim();
|
||||
const isInternal = String(attrs["data-link-kind"] || "") === "internal";
|
||||
|
||||
if (isInternal) {
|
||||
if (!policyId || !validPolicyIds.includes(policyId)) {
|
||||
errors.push("Internal link points to an invalid policy.");
|
||||
}
|
||||
} else if (
|
||||
href &&
|
||||
!/^(https?:\/\/|mailto:|tel:|\/|#)/i.test(href)
|
||||
) {
|
||||
errors.push(`Invalid link URL "${href}".`);
|
||||
}
|
||||
|
||||
if (!isInternal && !href) {
|
||||
errors.push("Link is missing a URL.");
|
||||
}
|
||||
|
||||
match = anchorRegex.exec(String(html || ""));
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateBlocks(blocks = [], validPolicyIds = []) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
let h1Count = 0;
|
||||
|
||||
blocks.forEach((block, index) => {
|
||||
const label = `Block ${index + 1}`;
|
||||
|
||||
if (block.type === "heading" && Number(block.level) === 1) {
|
||||
h1Count += 1;
|
||||
}
|
||||
|
||||
if (block.type === "divider") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (block.type === "list") {
|
||||
if (!Array.isArray(block.items) || block.items.length === 0) {
|
||||
warnings.push(`${label} has no list items.`);
|
||||
}
|
||||
|
||||
block.items.forEach((item, itemIndex) => {
|
||||
if (!stripHtml(item.html)) {
|
||||
warnings.push(`${label} item ${itemIndex + 1} is empty.`);
|
||||
}
|
||||
});
|
||||
|
||||
errors.push(...collectLinkErrors(JSON.stringify(block.items), validPolicyIds));
|
||||
return;
|
||||
}
|
||||
|
||||
if (block.type === "callout" && !stripHtml(block.html) && !stripHtml(block.title)) {
|
||||
warnings.push(`${label} is empty.`);
|
||||
} else if (!stripHtml(block.html) && block.type !== "divider") {
|
||||
warnings.push(`${label} is empty.`);
|
||||
}
|
||||
|
||||
errors.push(...collectLinkErrors(block.html, validPolicyIds));
|
||||
});
|
||||
|
||||
if (h1Count > 1) {
|
||||
errors.push(
|
||||
`Content has ${h1Count} H1 blocks. Only one H1 is allowed.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
function validateContent(content = {}, policyIds = []) {
|
||||
const normalized = normalizeContent(content || createEmptyContent());
|
||||
const { errors, warnings } = validateBlocks(normalized.blocks, policyIds);
|
||||
|
||||
if (normalized.blocks.length === 0) {
|
||||
warnings.push("Content is empty.");
|
||||
}
|
||||
|
||||
return {
|
||||
content: normalized,
|
||||
errors: Array.from(new Set(errors)),
|
||||
warnings: Array.from(new Set(warnings)),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createEmptyContent,
|
||||
normalizePolicy,
|
||||
normalizePoliciesDocument,
|
||||
normalizePolicyContent,
|
||||
validateContent,
|
||||
stripHtml,
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
||||
<p class="text-muted mb-0"><%= subtitle %></p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="<%= editorConfig.routeBase %>/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<% editorConfig.tabs.forEach((tab) => { %>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === tab.key ? 'active' : '' %>" data-bs-toggle="tab" href="#<%= tab.key %>" role="tab" data-tab-key="<%= tab.key %>">
|
||||
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
|
||||
</a>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<%- include("partials/hero-tab", { activeTab }) %>
|
||||
<%- include("partials/grid-tab", { activeTab }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed-bottom-buttons">
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.pageEditorConfig = <%- JSON.stringify(editorConfig) %>;
|
||||
window.pageEditorData = <%- JSON.stringify(data) %>;
|
||||
window.pageEditorBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||
</script>
|
||||
<%- include("partials/editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'grid' ? 'show active' : '' %>" id="grid" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-table-cells-large me-2"></i>Accreditation Grid</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'hero' ? 'show active' : '' %>" id="hero" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="hero"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'trustBanner' ? 'show active' : '' %>" id="trustBanner" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-shield-check me-2"></i>Trust Banner</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="trustBanner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
<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"><%= fieldConfig.label?.label || "Option label" %></label>
|
||||
<input id="label" name="label" type="text" class="form-control" maxlength="<%= fieldLimits.label %>" value="<%= option.label %>" required />
|
||||
<div class="field-meta-row">
|
||||
<div class="form-text"><%= fieldConfig.label?.helpText || "" %></div>
|
||||
<div class="field-char-count" data-counter-for="label">0/<%= fieldLimits.label %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="paceLabel" class="form-label fw-semibold"><%= fieldConfig.paceLabel?.label || "Pace label" %></label>
|
||||
<input id="paceLabel" name="paceLabel" type="text" class="form-control" maxlength="<%= fieldLimits.paceLabel %>" value="<%= option.paceLabel %>" required />
|
||||
<div class="field-meta-row">
|
||||
<div class="form-text"><%= fieldConfig.paceLabel?.helpText || "" %></div>
|
||||
<div class="field-char-count" data-counter-for="paceLabel">0/<%= fieldLimits.paceLabel %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="minPaceLabel" class="form-label fw-semibold"><%= fieldConfig.minPaceLabel?.label || "Minimum pace label" %></label>
|
||||
<input id="minPaceLabel" name="minPaceLabel" type="text" class="form-control" maxlength="<%= fieldLimits.minPaceLabel %>" value="<%= option.minPaceLabel %>" required />
|
||||
<div class="field-meta-row">
|
||||
<div class="form-text"><%= fieldConfig.minPaceLabel?.helpText || "" %></div>
|
||||
<div class="field-char-count" data-counter-for="minPaceLabel">0/<%= fieldLimits.minPaceLabel %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="maxPaceLabel" class="form-label fw-semibold"><%= fieldConfig.maxPaceLabel?.label || "Maximum pace label" %></label>
|
||||
<input id="maxPaceLabel" name="maxPaceLabel" type="text" class="form-control" maxlength="<%= fieldLimits.maxPaceLabel %>" value="<%= option.maxPaceLabel %>" required />
|
||||
<div class="field-meta-row">
|
||||
<div class="form-text"><%= fieldConfig.maxPaceLabel?.helpText || "" %></div>
|
||||
<div class="field-char-count" data-counter-for="maxPaceLabel">0/<%= fieldLimits.maxPaceLabel %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="resultLabel" class="form-label fw-semibold"><%= fieldConfig.resultLabel?.label || "Result label" %></label>
|
||||
<input id="resultLabel" name="resultLabel" type="text" class="form-control" maxlength="<%= fieldLimits.resultLabel %>" value="<%= option.resultLabel %>" required />
|
||||
<div class="field-meta-row">
|
||||
<div class="form-text"><%= fieldConfig.resultLabel?.helpText || "" %></div>
|
||||
<div class="field-char-count" data-counter-for="resultLabel">0/<%= fieldLimits.resultLabel %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="monthlyAmount" class="form-label fw-semibold"><%= fieldConfig.monthlyAmount?.label || "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"><%= fieldConfig.monthlyAmount?.helpText || "" %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="monthlySuffix" class="form-label fw-semibold"><%= fieldConfig.monthlySuffix?.label || "Monthly suffix" %></label>
|
||||
<input id="monthlySuffix" name="monthlySuffix" type="text" class="form-control" maxlength="<%= fieldLimits.monthlySuffix %>" value="<%= option.monthlySuffix %>" required />
|
||||
<div class="field-meta-row">
|
||||
<div class="form-text"><%= fieldConfig.monthlySuffix?.helpText || "" %></div>
|
||||
<div class="field-char-count" data-counter-for="monthlySuffix">0/<%= fieldLimits.monthlySuffix %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold"><%= fieldConfig.noteIcon?.label || "Note icon" %></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text icon-preview-cell" style="min-width:38px"></span>
|
||||
<input
|
||||
type="text"
|
||||
name="noteIcon"
|
||||
id="noteIcon"
|
||||
class="form-control"
|
||||
value="<%= option.noteIcon %>"
|
||||
placeholder="Click to pick..."
|
||||
readonly
|
||||
style="cursor:pointer;background:#fff"
|
||||
data-icon-picker-value-mode="icon-name"
|
||||
data-icon-picker-preview-prefix="fa-solid"
|
||||
/>
|
||||
<button type="button" class="btn btn-outline-secondary" id="noteIconButton">
|
||||
<i class="fas fa-icons me-1"></i>Pick Icon
|
||||
</button>
|
||||
</div>
|
||||
<div class="field-meta-row">
|
||||
<div class="form-text"><%= fieldConfig.noteIcon?.helpText || "" %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label for="note" class="form-label fw-semibold"><%= fieldConfig.note?.label || "Note text" %></label>
|
||||
<input id="note" name="note" type="text" class="form-control" maxlength="<%= fieldLimits.note %>" value="<%= option.note %>" required />
|
||||
<div class="field-meta-row">
|
||||
<div class="form-text"><%= fieldConfig.note?.helpText || "" %></div>
|
||||
<div class="field-char-count" data-counter-for="note">0/<%= fieldLimits.note %></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 existingOptionLabels = <%- JSON.stringify(existingOptionLabels || []) %>;
|
||||
const form = document.getElementById("calculatorOptionForm");
|
||||
const labelInput = document.getElementById("label");
|
||||
const noteIconInput = document.getElementById("noteIcon");
|
||||
const noteIconButton = document.getElementById("noteIconButton");
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
function normalizeLabel(value) {
|
||||
return String(value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function clearLabelValidation() {
|
||||
labelInput.classList.remove("is-invalid");
|
||||
}
|
||||
|
||||
function validateUniqueLabel() {
|
||||
const normalizedValue = normalizeLabel(labelInput.value);
|
||||
const isDuplicate = normalizedValue
|
||||
? existingOptionLabels.some((label) => normalizeLabel(label) === normalizedValue)
|
||||
: false;
|
||||
|
||||
labelInput.classList.toggle("is-invalid", isDuplicate);
|
||||
return !isDuplicate;
|
||||
}
|
||||
|
||||
labelInput.addEventListener("input", function () {
|
||||
clearLabelValidation();
|
||||
validateUniqueLabel();
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function (event) {
|
||||
if (validateUniqueLabel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
showToast(
|
||||
"Duplicate option label",
|
||||
`Option label "${labelInput.value.trim()}" already exists. Please use a unique label before saving.`,
|
||||
"danger",
|
||||
);
|
||||
labelInput.focus();
|
||||
});
|
||||
|
||||
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";
|
||||
});
|
||||
|
||||
noteIconInput?.addEventListener("click", function () {
|
||||
openIconPickerForInput(noteIconInput);
|
||||
});
|
||||
|
||||
noteIconButton?.addEventListener("click", function () {
|
||||
openIconPickerForInput(noteIconInput);
|
||||
});
|
||||
|
||||
syncIconPickerPreview(noteIconInput);
|
||||
|
||||
function openIconPickerForInput(input) {
|
||||
window.__cmsIconPickerActiveInput = input;
|
||||
patchIconPickerForIconNames();
|
||||
window.IconPicker?.open(input);
|
||||
}
|
||||
|
||||
function patchIconPickerForIconNames() {
|
||||
if (!window.IconPicker || window.IconPicker.__cmsIconNamePatched) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalPick = typeof window.IconPicker.pick === "function"
|
||||
? window.IconPicker.pick.bind(window.IconPicker)
|
||||
: null;
|
||||
|
||||
if (!originalPick) {
|
||||
return;
|
||||
}
|
||||
|
||||
const patchedPick = function (value) {
|
||||
originalPick(value);
|
||||
|
||||
const activeInput = window.__cmsIconPickerActiveInput;
|
||||
if (!activeInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeInput.dataset.iconPickerValueMode === "icon-name") {
|
||||
activeInput.dataset.iconPickerPreviewPrefix = extractIconStyle(
|
||||
activeInput.value || value,
|
||||
) || determineIconPreviewPrefix(activeInput);
|
||||
activeInput.value = extractIconName(activeInput.value || value);
|
||||
syncIconPickerPreview(activeInput);
|
||||
activeInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
activeInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
window.__cmsIconPickerActiveInput = null;
|
||||
};
|
||||
|
||||
window.IconPicker.pick = patchedPick;
|
||||
window.IconPickerPick = patchedPick;
|
||||
window.IconPicker.__cmsIconNamePatched = true;
|
||||
}
|
||||
|
||||
function syncIconPickerPreview(input) {
|
||||
if (!input) return;
|
||||
|
||||
const previewCell = input.closest(".input-group")?.querySelector(".icon-preview-cell");
|
||||
if (!previewCell) return;
|
||||
|
||||
const previewClass = resolveIconPreviewClass(input);
|
||||
previewCell.innerHTML = previewClass ? `<i class="${escapeHtml(previewClass)}"></i>` : "";
|
||||
|
||||
if (input.dataset.iconPickerValueMode === "icon-name") {
|
||||
loadIconStyleLookup().then(function () {
|
||||
const nextPrefix = determineIconPreviewPrefix(input);
|
||||
if (nextPrefix !== input.dataset.iconPickerPreviewPrefix) {
|
||||
input.dataset.iconPickerPreviewPrefix = nextPrefix;
|
||||
const nextPreviewClass = resolveIconPreviewClass(input);
|
||||
previewCell.innerHTML = nextPreviewClass
|
||||
? `<i class="${escapeHtml(nextPreviewClass)}"></i>`
|
||||
: "";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveIconPreviewClass(input) {
|
||||
const normalizedValue = String(input?.value || "").trim();
|
||||
if (!normalizedValue) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (input?.dataset.iconPickerValueMode === "icon-name") {
|
||||
const prefix = determineIconPreviewPrefix(input);
|
||||
return `${prefix} ${normalizedValue}`.trim();
|
||||
}
|
||||
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
function extractIconName(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.find(
|
||||
(token) =>
|
||||
/^fa-[a-z0-9-]+$/i.test(token) && !/^fa-(solid|regular|brands)$/i.test(token),
|
||||
) || "";
|
||||
}
|
||||
|
||||
function extractIconStyle(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.find((token) => /^fa-(solid|regular|brands)$/i.test(token)) || "";
|
||||
}
|
||||
|
||||
function determineIconPreviewPrefix(input) {
|
||||
const explicitPrefix = String(input?.dataset.iconPickerPreviewPrefix || "").trim();
|
||||
if (explicitPrefix && explicitPrefix !== "fa-solid") {
|
||||
return explicitPrefix;
|
||||
}
|
||||
|
||||
const iconName = extractIconName(input?.value || "");
|
||||
const knownStyles = window.__cmsIconStyleLookup?.[iconName] || [];
|
||||
|
||||
if (knownStyles.includes("fa-brands")) return "fa-brands";
|
||||
if (knownStyles.includes("fa-regular")) return "fa-regular";
|
||||
if (knownStyles.includes("fa-solid")) return "fa-solid";
|
||||
|
||||
return explicitPrefix || "fa-solid";
|
||||
}
|
||||
|
||||
function loadIconStyleLookup() {
|
||||
if (window.__cmsIconStyleLookupPromise) {
|
||||
return window.__cmsIconStyleLookupPromise;
|
||||
}
|
||||
|
||||
window.__cmsIconStyleLookupPromise = fetch("/js/fa-icons.json")
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((json) => {
|
||||
const lookup = {};
|
||||
Object.entries(json || {}).forEach(([name, meta]) => {
|
||||
lookup[`fa-${name}`] = (meta.styles || [])
|
||||
.filter((style) => ["solid", "regular", "brands"].includes(style))
|
||||
.map((style) => `fa-${style}`);
|
||||
});
|
||||
window.__cmsIconStyleLookup = lookup;
|
||||
return lookup;
|
||||
})
|
||||
.catch(() => {
|
||||
window.__cmsIconStyleLookup = window.__cmsIconStyleLookup || {};
|
||||
return window.__cmsIconStyleLookup;
|
||||
});
|
||||
|
||||
return window.__cmsIconStyleLookupPromise;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function showToast(title, message, type) {
|
||||
const container =
|
||||
document.querySelector(".toast-container") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(
|
||||
title,
|
||||
)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", function () {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
||||
<p class="text-muted mb-0"><%= subtitle %></p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="<%= editorConfig.routeBase %>/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<% editorConfig.tabs.forEach((tab) => { %>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === tab.key ? 'active' : '' %>" data-bs-toggle="tab" href="#<%= tab.key %>" role="tab" data-tab-key="<%= tab.key %>">
|
||||
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
|
||||
</a>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<%- include("partials/hero-tab", { activeTab }) %>
|
||||
<%- include("partials/process-tab", { activeTab }) %>
|
||||
<%- include("partials/eligibility-tab", { activeTab }) %>
|
||||
<%- include("partials/tuition-tab", { activeTab }) %>
|
||||
<%- include("partials/key-dates-tab", { activeTab }) %>
|
||||
<%- include("partials/calculator-tab", { activeTab }) %>
|
||||
<%- include("partials/scholarships-tab", { activeTab }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed-bottom-buttons">
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.pageEditorConfig = <%- JSON.stringify(editorConfig) %>;
|
||||
window.pageEditorData = <%- JSON.stringify(data) %>;
|
||||
window.pageEditorBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||
</script>
|
||||
<%- include("partials/editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'calculator' ? 'show active' : '' %>" id="calculator" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-calculator me-2"></i>Calculator</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="calculator"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'eligibility' ? 'show active' : '' %>" id="eligibility" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-check-circle me-2"></i>Eligibility</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="eligibility"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'hero' ? 'show active' : '' %>" id="hero" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="hero"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'keyDates' ? 'show active' : '' %>" id="keyDates" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-calendar-days me-2"></i>Key Dates</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="keyDates"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'process' ? 'show active' : '' %>" id="process" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-list-ol me-2"></i>Admissions Process</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="process"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'scholarships' ? 'show active' : '' %>" id="scholarships" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-award me-2"></i>Scholarships</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="scholarships"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'tuition' ? 'show active' : '' %>" id="tuition" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-chart-column me-2"></i>Tuition</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="tuition"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
||||
<p class="text-muted mb-0"><%= subtitle %></p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="<%= editorConfig.routeBase %>/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<% editorConfig.tabs.forEach((tab) => { %>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === tab.key ? 'active' : '' %>" data-bs-toggle="tab" href="#<%= tab.key %>" role="tab" data-tab-key="<%= tab.key %>">
|
||||
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
|
||||
</a>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<%- include("partials/highlight-tab", { activeTab }) %>
|
||||
<%- include("partials/hero-tab", { activeTab }) %>
|
||||
<%- include("partials/filters-tab", { activeTab }) %>
|
||||
<%- include("partials/timeline-tab", { activeTab }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed-bottom-buttons">
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.pageEditorConfig = <%- JSON.stringify(editorConfig) %>;
|
||||
window.pageEditorData = <%- JSON.stringify(data) %>;
|
||||
window.pageEditorBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||
</script>
|
||||
<%- include("partials/editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,995 @@
|
||||
<script>
|
||||
(function () {
|
||||
const config = window.pageEditorConfig;
|
||||
const initialData = window.pageEditorData;
|
||||
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
|
||||
const form = document.getElementById("cmsEditorForm");
|
||||
const pageJsonInput = document.getElementById("pageJson");
|
||||
const activeTabInput = document.getElementById("activeTabInput");
|
||||
|
||||
if (!config || !initialData || !form || !pageJsonInput || !activeTabInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = JSON.parse(JSON.stringify(initialData));
|
||||
renderAllSections();
|
||||
|
||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||
const tabKey = this.dataset.tabKey;
|
||||
|
||||
if (!tabKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeTabInput.value = tabKey;
|
||||
updateTabUrl(tabKey);
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
pageJsonInput.value = JSON.stringify(state);
|
||||
});
|
||||
|
||||
form.addEventListener("reset", function () {
|
||||
window.setTimeout(function () {
|
||||
Object.keys(state).forEach((key) => delete state[key]);
|
||||
Object.assign(state, JSON.parse(JSON.stringify(initialData)));
|
||||
renderAllSections();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
function renderAllSections() {
|
||||
config.tabs.forEach((tab) => renderSection(tab.key));
|
||||
}
|
||||
|
||||
function updateTabUrl(tabKey) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${url.pathname}?${url.searchParams.toString()}${url.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection(tabKey) {
|
||||
const tab = config.tabs.find((item) => item.key === tabKey);
|
||||
const container = document.querySelector(
|
||||
`.page-editor-section[data-section-key="${tabKey}"]`,
|
||||
);
|
||||
|
||||
if (!tab || !container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
|
||||
path: tab.schema.key,
|
||||
root: state,
|
||||
item: null,
|
||||
});
|
||||
}
|
||||
|
||||
function renderField(schema, container, parent, key, tabKey, context) {
|
||||
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "object") {
|
||||
if (!isObject(parent[key])) {
|
||||
parent[key] = {};
|
||||
}
|
||||
|
||||
const groupWrapper = document.createElement("div");
|
||||
groupWrapper.className = "row g-3";
|
||||
container.appendChild(groupWrapper);
|
||||
|
||||
(schema.fields || []).forEach((field) => {
|
||||
renderField(field, groupWrapper, parent[key], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: parent[key],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(parent[key])) {
|
||||
parent[key] = [];
|
||||
}
|
||||
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const card = document.createElement("div");
|
||||
card.className = "cms-editor-group";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "mb-3";
|
||||
header.innerHTML = `
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">${escapeHtml(schema.label)}</label>
|
||||
${schema.helpText ? `<div class="form-text mt-0">${escapeHtml(schema.helpText)}</div>` : ""}
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
if (parent[key].length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "text-muted small";
|
||||
empty.textContent = schema.emptyText || `No ${schema.itemLabel || "items"} yet.`;
|
||||
card.appendChild(empty);
|
||||
} else {
|
||||
const list = document.createElement("div");
|
||||
list.className = "page-editor-array-list";
|
||||
card.appendChild(list);
|
||||
|
||||
parent[key].forEach((item, index) => {
|
||||
const itemCard = document.createElement("div");
|
||||
itemCard.className = "card cms-item-card mb-3";
|
||||
itemCard.dataset.index = String(index);
|
||||
|
||||
const itemHeader = document.createElement("div");
|
||||
itemHeader.className = "card-header d-flex justify-content-between align-items-center gap-2 flex-wrap";
|
||||
|
||||
const title = getArrayItemTitle(schema, item, index);
|
||||
const subtitle = getArrayItemSubtitle(schema, item);
|
||||
itemHeader.innerHTML = `
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${
|
||||
schema.sortable
|
||||
? '<button type="button" class="drag-handle" title="Drag to reorder"><i class="fas fa-grip-vertical"></i></button>'
|
||||
: ""
|
||||
}
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(title)}</div>
|
||||
${subtitle ? `<div class="small text-muted">${escapeHtml(subtitle)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<button type="button" class="cms-collapse-toggle" data-toggle-item="true" title="Collapse section">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
${renderItemActions(schema.itemActions, item)}
|
||||
<button type="button" class="cms-remove-button" data-remove-item="true" title="Remove item">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
itemHeader
|
||||
.querySelector('[data-toggle-item="true"]')
|
||||
.addEventListener("click", function () {
|
||||
itemCard.classList.toggle("is-collapsed");
|
||||
});
|
||||
|
||||
itemHeader
|
||||
.querySelector('[data-remove-item="true"]')
|
||||
.addEventListener("click", function () {
|
||||
parent[key].splice(index, 1);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
itemHeader.querySelectorAll("[data-item-href]").forEach((actionButton) => {
|
||||
actionButton.addEventListener("click", function () {
|
||||
window.location.href = actionButton.dataset.itemHref;
|
||||
});
|
||||
});
|
||||
|
||||
const itemBody = document.createElement("div");
|
||||
itemBody.className = "card-body";
|
||||
|
||||
if (schema.itemSchema.type === "primitive") {
|
||||
renderPrimitiveArrayItem(schema, itemBody, parent[key], index, tabKey, context);
|
||||
} else if (schema.itemSchema.type === "variant") {
|
||||
renderVariantArrayItem(
|
||||
schema.itemSchema,
|
||||
itemBody,
|
||||
parent[key],
|
||||
index,
|
||||
tabKey,
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const bodyRow = document.createElement("div");
|
||||
bodyRow.className = "row g-3";
|
||||
itemBody.appendChild(bodyRow);
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
renderField(field, bodyRow, parent[key][index], field.key, tabKey, {
|
||||
path: appendPath(context.path, `${index}.${field.key}`),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
itemCard.appendChild(itemHeader);
|
||||
itemCard.appendChild(itemBody);
|
||||
list.appendChild(itemCard);
|
||||
});
|
||||
|
||||
const addButton = document.createElement("button");
|
||||
addButton.type = "button";
|
||||
addButton.className = "cms-add-button mt-2";
|
||||
addButton.innerHTML = `<i class="fas fa-plus me-2"></i>${escapeHtml(schema.addLabel || `Add ${schema.itemLabel || "Item"}`)}`;
|
||||
addButton.addEventListener("click", function () {
|
||||
parent[key].push(createDefaultValue(schema.itemSchema));
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
card.appendChild(addButton);
|
||||
|
||||
if (schema.sortable && window.Sortable) {
|
||||
window.Sortable.create(list, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
onEnd: function (event) {
|
||||
if (
|
||||
typeof event.oldIndex !== "number" ||
|
||||
typeof event.newIndex !== "number" ||
|
||||
event.oldIndex === event.newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedItem = parent[key].splice(event.oldIndex, 1)[0];
|
||||
parent[key].splice(event.newIndex, 0, movedItem);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
col.appendChild(card);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "checkbox") {
|
||||
renderCheckbox(schema, container, parent, key);
|
||||
return;
|
||||
}
|
||||
|
||||
renderLeafField(schema, container, parent, key, context);
|
||||
}
|
||||
|
||||
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index, tabKey, context) {
|
||||
const fieldSchema = arraySchema.itemSchema;
|
||||
const row = document.createElement("div");
|
||||
row.className = "row g-3";
|
||||
container.appendChild(row);
|
||||
|
||||
const holder = { value: targetArray[index] || "" };
|
||||
renderLeafField(
|
||||
{
|
||||
key: "value",
|
||||
label: fieldSchema.label || arraySchema.itemLabel || "Value",
|
||||
type: fieldSchema.fieldType || "text",
|
||||
maxLength: fieldSchema.maxLength,
|
||||
placeholder: fieldSchema.placeholder,
|
||||
helpText: fieldSchema.helpText,
|
||||
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
|
||||
},
|
||||
row,
|
||||
holder,
|
||||
"value",
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: holder,
|
||||
},
|
||||
);
|
||||
|
||||
const input = row.querySelector("input, textarea, select");
|
||||
if (input) {
|
||||
const sync = function () {
|
||||
targetArray[index] =
|
||||
fieldSchema.fieldType === "number" ? Number(holder.value || 0) : holder.value;
|
||||
};
|
||||
input.addEventListener("input", sync);
|
||||
input.addEventListener("change", sync);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey, context) {
|
||||
const item = targetArray[index];
|
||||
if (!isObject(item)) {
|
||||
targetArray[index] = {};
|
||||
}
|
||||
|
||||
const currentType =
|
||||
targetArray[index][variantSchema.discriminator] ||
|
||||
variantSchema.options[0].value;
|
||||
targetArray[index][variantSchema.discriminator] = currentType;
|
||||
|
||||
const currentVariant = variantSchema.variants[currentType];
|
||||
|
||||
const typeRow = document.createElement("div");
|
||||
typeRow.className = "row g-3 mb-2";
|
||||
container.appendChild(typeRow);
|
||||
|
||||
renderLeafField(
|
||||
{
|
||||
key: variantSchema.discriminator,
|
||||
label: "Section type",
|
||||
type: "select",
|
||||
options: variantSchema.options,
|
||||
},
|
||||
typeRow,
|
||||
targetArray[index],
|
||||
variantSchema.discriminator,
|
||||
context,
|
||||
);
|
||||
|
||||
const selectInput = typeRow.querySelector("select");
|
||||
if (selectInput) {
|
||||
selectInput.addEventListener("change", function () {
|
||||
const newType = this.value;
|
||||
targetArray[index] = { type: newType };
|
||||
renderSection(tabKey);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentVariant && currentVariant.schema) {
|
||||
const sectionRow = document.createElement("div");
|
||||
sectionRow.className = "row g-3";
|
||||
container.appendChild(sectionRow);
|
||||
|
||||
(currentVariant.schema.fields || []).forEach((field) => {
|
||||
renderField(field, sectionRow, targetArray[index], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: targetArray[index],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeafField(schema, container, parent, key, context) {
|
||||
if (schema.type === "hidden") {
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.defaultValue || "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.type === "number" ? 0 : "";
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || inferColClass(schema.type));
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
col.appendChild(label);
|
||||
|
||||
if (schema.type === "textarea") {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "form-control";
|
||||
textarea.rows = schema.rows || 4;
|
||||
textarea.value = parent[key] || "";
|
||||
if (schema.placeholder) textarea.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) textarea.maxLength = schema.maxLength;
|
||||
textarea.addEventListener("input", function () {
|
||||
parent[key] = textarea.value;
|
||||
updateCounter(counter, textarea.value.length, schema.maxLength);
|
||||
});
|
||||
col.appendChild(textarea);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "image") {
|
||||
const group = document.createElement("div");
|
||||
group.className = "input-group";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = parent[key] || "";
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.src = resolveImageUrl(input.value);
|
||||
preview.classList.toggle("d-none", !input.value);
|
||||
});
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn btn-outline-primary";
|
||||
button.innerHTML = '<i class="fas fa-upload me-1"></i>Upload';
|
||||
button.addEventListener("click", function () {
|
||||
openImagePicker(schema.imageType || config.imageType, function (path) {
|
||||
parent[key] = path;
|
||||
input.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.toggle("d-none", !path);
|
||||
});
|
||||
});
|
||||
|
||||
group.appendChild(input);
|
||||
group.appendChild(button);
|
||||
col.appendChild(group);
|
||||
|
||||
const preview = document.createElement("img");
|
||||
preview.className = "img-thumbnail uploaded-preview mt-2";
|
||||
preview.style.maxHeight = "200px";
|
||||
preview.src = resolveImageUrl(parent[key]);
|
||||
preview.classList.toggle("d-none", !parent[key]);
|
||||
col.appendChild(preview);
|
||||
|
||||
appendHelp(col, schema, parent[key], schema.imageHint);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "icon") {
|
||||
renderIconField(schema, col, parent, key);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "select") {
|
||||
const input = document.createElement("select");
|
||||
input.className = "form-select";
|
||||
const options = resolveOptions(schema, context.root);
|
||||
options.forEach((option) => {
|
||||
const optionEl = document.createElement("option");
|
||||
if (typeof option === "string") {
|
||||
optionEl.value = option;
|
||||
optionEl.textContent = option;
|
||||
} else {
|
||||
optionEl.value = option.value;
|
||||
optionEl.textContent = option.label;
|
||||
}
|
||||
input.appendChild(optionEl);
|
||||
});
|
||||
input.value = parent[key] || input.options[0]?.value || "";
|
||||
parent[key] = input.value;
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.value;
|
||||
renderAllSections();
|
||||
});
|
||||
col.appendChild(input);
|
||||
appendHelp(col, { ...schema, maxLength: undefined }, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "combobox") {
|
||||
const input = document.createElement("select");
|
||||
input.className = "form-select";
|
||||
const options = resolveOptions(schema, context.root);
|
||||
const optionValues = options.map((option) =>
|
||||
typeof option === "string" ? option : option.value,
|
||||
);
|
||||
|
||||
options.forEach((option) => {
|
||||
const optionEl = document.createElement("option");
|
||||
if (typeof option === "string") {
|
||||
optionEl.value = option;
|
||||
optionEl.textContent = option;
|
||||
} else {
|
||||
optionEl.value = option.value;
|
||||
optionEl.textContent = option.label;
|
||||
}
|
||||
input.appendChild(optionEl);
|
||||
});
|
||||
|
||||
if (parent[key] && !optionValues.includes(parent[key])) {
|
||||
const legacyOption = document.createElement("option");
|
||||
legacyOption.value = parent[key];
|
||||
legacyOption.textContent = parent[key];
|
||||
input.appendChild(legacyOption);
|
||||
}
|
||||
|
||||
input.value = parent[key] || input.options[0]?.value || "";
|
||||
parent[key] = input.value;
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.value;
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
appendHelp(col, { ...schema, maxLength: undefined }, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.className = "form-control";
|
||||
input.type =
|
||||
schema.type === "number" || schema.type === "color" ? schema.type : "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
if (schema.step) input.step = schema.step;
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value;
|
||||
updateCounter(counter, String(input.value || "").length, schema.maxLength);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function renderIconField(schema, col, parent, key) {
|
||||
const group = document.createElement("div");
|
||||
group.className = "input-group";
|
||||
|
||||
const preview = document.createElement("span");
|
||||
preview.className = "input-group-text icon-preview-cell";
|
||||
preview.style.minWidth = "38px";
|
||||
group.appendChild(preview);
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = parent[key] || "";
|
||||
input.readOnly = true;
|
||||
input.placeholder = schema.placeholder || "Click to pick...";
|
||||
input.style.cursor = "pointer";
|
||||
input.style.backgroundColor = "#fff";
|
||||
input.dataset.iconPickerValueMode = "icon-name";
|
||||
input.dataset.iconPickerPreviewPrefix = "fa-solid";
|
||||
input.addEventListener("click", function () {
|
||||
openIconPickerForInput(input);
|
||||
});
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
});
|
||||
group.appendChild(input);
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn btn-outline-secondary";
|
||||
button.innerHTML = '<i class="fas fa-icons me-1"></i>Pick Icon';
|
||||
button.addEventListener("click", function () {
|
||||
openIconPickerForInput(input);
|
||||
});
|
||||
group.appendChild(button);
|
||||
|
||||
col.appendChild(group);
|
||||
syncIconPickerPreview(input);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
}
|
||||
|
||||
function openIconPickerForInput(input) {
|
||||
window.__cmsIconPickerActiveInput = input;
|
||||
patchIconPickerForIconNames();
|
||||
window.IconPicker?.open(input);
|
||||
}
|
||||
|
||||
function patchIconPickerForIconNames() {
|
||||
if (!window.IconPicker || window.IconPicker.__cmsIconNamePatched) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalPick = typeof window.IconPicker.pick === "function"
|
||||
? window.IconPicker.pick.bind(window.IconPicker)
|
||||
: null;
|
||||
|
||||
if (!originalPick) {
|
||||
return;
|
||||
}
|
||||
|
||||
const patchedPick = function (value) {
|
||||
originalPick(value);
|
||||
|
||||
const activeInput = window.__cmsIconPickerActiveInput;
|
||||
if (!activeInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeInput.dataset.iconPickerValueMode === "icon-name") {
|
||||
activeInput.dataset.iconPickerPreviewPrefix = extractIconStyle(
|
||||
activeInput.value || value,
|
||||
) || determineIconPreviewPrefix(activeInput);
|
||||
activeInput.value = extractIconName(activeInput.value || value);
|
||||
syncIconPickerPreview(activeInput);
|
||||
activeInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
activeInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
window.__cmsIconPickerActiveInput = null;
|
||||
};
|
||||
|
||||
window.IconPicker.pick = patchedPick;
|
||||
window.IconPickerPick = patchedPick;
|
||||
window.IconPicker.__cmsIconNamePatched = true;
|
||||
}
|
||||
|
||||
function syncIconPickerPreview(input) {
|
||||
if (!input) return;
|
||||
|
||||
const previewCell = input.closest(".input-group")?.querySelector(".icon-preview-cell");
|
||||
if (!previewCell) return;
|
||||
|
||||
const previewClass = resolveIconPreviewClass(input);
|
||||
previewCell.innerHTML = previewClass ? `<i class="${escapeHtml(previewClass)}"></i>` : "";
|
||||
|
||||
if (input.dataset.iconPickerValueMode === "icon-name") {
|
||||
loadIconStyleLookup().then(function () {
|
||||
const nextPrefix = determineIconPreviewPrefix(input);
|
||||
if (nextPrefix !== input.dataset.iconPickerPreviewPrefix) {
|
||||
input.dataset.iconPickerPreviewPrefix = nextPrefix;
|
||||
const nextPreviewClass = resolveIconPreviewClass(input);
|
||||
previewCell.innerHTML = nextPreviewClass
|
||||
? `<i class="${escapeHtml(nextPreviewClass)}"></i>`
|
||||
: "";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveIconPreviewClass(input) {
|
||||
const normalizedValue = String(input?.value || "").trim();
|
||||
if (!normalizedValue) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (input?.dataset.iconPickerValueMode === "icon-name") {
|
||||
const prefix = determineIconPreviewPrefix(input);
|
||||
return `${prefix} ${normalizedValue}`.trim();
|
||||
}
|
||||
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
function extractIconName(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.find(
|
||||
(token) =>
|
||||
/^fa-[a-z0-9-]+$/i.test(token) && !/^fa-(solid|regular|brands)$/i.test(token),
|
||||
) || "";
|
||||
}
|
||||
|
||||
function extractIconStyle(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.find((token) => /^fa-(solid|regular|brands)$/i.test(token)) || "";
|
||||
}
|
||||
|
||||
function determineIconPreviewPrefix(input) {
|
||||
const explicitPrefix = String(input?.dataset.iconPickerPreviewPrefix || "").trim();
|
||||
if (explicitPrefix && explicitPrefix !== "fa-solid") {
|
||||
return explicitPrefix;
|
||||
}
|
||||
|
||||
const iconName = extractIconName(input?.value || "");
|
||||
const knownStyles = window.__cmsIconStyleLookup?.[iconName] || [];
|
||||
|
||||
if (knownStyles.includes("fa-brands")) return "fa-brands";
|
||||
if (knownStyles.includes("fa-regular")) return "fa-regular";
|
||||
if (knownStyles.includes("fa-solid")) return "fa-solid";
|
||||
|
||||
return explicitPrefix || "fa-solid";
|
||||
}
|
||||
|
||||
function loadIconStyleLookup() {
|
||||
if (window.__cmsIconStyleLookupPromise) {
|
||||
return window.__cmsIconStyleLookupPromise;
|
||||
}
|
||||
|
||||
window.__cmsIconStyleLookupPromise = fetch("/js/fa-icons.json")
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((json) => {
|
||||
const lookup = {};
|
||||
Object.entries(json || {}).forEach(([name, meta]) => {
|
||||
lookup[`fa-${name}`] = (meta.styles || [])
|
||||
.filter((style) => ["solid", "regular", "brands"].includes(style))
|
||||
.map((style) => `fa-${style}`);
|
||||
});
|
||||
window.__cmsIconStyleLookup = lookup;
|
||||
return lookup;
|
||||
})
|
||||
.catch(() => {
|
||||
window.__cmsIconStyleLookup = window.__cmsIconStyleLookup || {};
|
||||
return window.__cmsIconStyleLookup;
|
||||
});
|
||||
|
||||
return window.__cmsIconStyleLookupPromise;
|
||||
}
|
||||
|
||||
function renderCheckbox(schema, container, parent, key) {
|
||||
if (typeof parent[key] !== "boolean") {
|
||||
parent[key] = Boolean(parent[key]);
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "form-check mt-4";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.className = "form-check-input";
|
||||
input.checked = parent[key];
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.checked;
|
||||
});
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-check-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(label);
|
||||
col.appendChild(wrapper);
|
||||
if (schema.helpText) {
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = schema.helpText;
|
||||
col.appendChild(help);
|
||||
}
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function appendHelp(col, schema, value, extraHint, providedCounter) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "field-meta-row";
|
||||
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
|
||||
wrapper.appendChild(help);
|
||||
|
||||
let counter = providedCounter || null;
|
||||
if (!counter && schema.maxLength) {
|
||||
counter = document.createElement("div");
|
||||
counter.className = "field-char-count";
|
||||
updateCounter(counter, String(value || "").length, schema.maxLength);
|
||||
}
|
||||
|
||||
if (counter) {
|
||||
wrapper.appendChild(counter);
|
||||
}
|
||||
|
||||
if (help.textContent || counter) {
|
||||
col.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return counter;
|
||||
}
|
||||
|
||||
function updateCounter(counter, currentLength, maxLength) {
|
||||
if (!counter || !maxLength) return;
|
||||
counter.textContent = `${currentLength}/${maxLength}`;
|
||||
}
|
||||
|
||||
function openImagePicker(imageType, onSuccess) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", async function () {
|
||||
if (!fileInput.files || !fileInput.files[0]) {
|
||||
fileInput.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileInput.files[0]);
|
||||
|
||||
const response = await fetch(
|
||||
`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.path) {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
|
||||
onSuccess(result.path);
|
||||
showToast("Success", "Image uploaded successfully", "success");
|
||||
} catch (error) {
|
||||
showToast("Error", error.message || "Upload failed", "danger");
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function showToast(title, message, type) {
|
||||
const container =
|
||||
document.querySelector(".toast-container") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(
|
||||
title,
|
||||
)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", function () {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function createDefaultValue(schema) {
|
||||
if (!schema) return "";
|
||||
if (schema.type === "primitive") return schema.fieldType === "number" ? 0 : "";
|
||||
if (schema.type === "variant") {
|
||||
return { [schema.discriminator]: schema.options[0].value };
|
||||
}
|
||||
if (schema.type === "object") {
|
||||
const value = {};
|
||||
(schema.fields || []).forEach((field) => {
|
||||
if (field.type === "array") value[field.key] = [];
|
||||
else if (field.type === "object") value[field.key] = createDefaultValue(field);
|
||||
else if (field.type === "checkbox") value[field.key] = false;
|
||||
else if (field.type === "number") value[field.key] = 0;
|
||||
else value[field.key] = field.defaultValue || "";
|
||||
});
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function createCol(colClass) {
|
||||
const div = document.createElement("div");
|
||||
div.className = colClass;
|
||||
return div;
|
||||
}
|
||||
|
||||
function inferColClass(type) {
|
||||
if (type === "textarea" || type === "image" || type === "icon") return "col-12";
|
||||
if (type === "checkbox") return "col-12";
|
||||
return "col-md-6";
|
||||
}
|
||||
|
||||
function resolveImageUrl(path) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/")) return `${backendUrl}${path}`;
|
||||
return `${backendUrl}/${path}`;
|
||||
}
|
||||
|
||||
function resolveOptions(schema, root) {
|
||||
if (schema.optionsPath) {
|
||||
const value = getValueByPath(root, schema.optionsPath);
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
return schema.options || [];
|
||||
}
|
||||
|
||||
function applyAutoSequenceToArray(schema, targetArray) {
|
||||
if (!schema || !Array.isArray(targetArray) || schema.itemSchema.type !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
if (field.type !== "hidden" || !field.autoSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetArray.forEach((item, index) => {
|
||||
const value = String(index + 1);
|
||||
const padLength = field.autoSequence.padLength || 0;
|
||||
item[field.key] = padLength > 0 ? value.padStart(padLength, "0") : value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getArrayItemTitle(schema, item, index) {
|
||||
const value =
|
||||
item && schema.itemTitleKey && typeof item[schema.itemTitleKey] !== "undefined"
|
||||
? item[schema.itemTitleKey]
|
||||
: null;
|
||||
|
||||
return value || `${schema.itemLabel || "Item"} ${index + 1}`;
|
||||
}
|
||||
|
||||
function getArrayItemSubtitle(schema, item) {
|
||||
if (!item || !schema.itemSubtitleKey) return "";
|
||||
return item[schema.itemSubtitleKey] || "";
|
||||
}
|
||||
|
||||
function renderItemActions(actions, item) {
|
||||
if (!Array.isArray(actions) || !actions.length || !item) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const href = fillTemplate(action.hrefTemplate, item);
|
||||
if (!href) return "";
|
||||
return `<button type="button" class="${escapeHtml(
|
||||
action.className || "btn btn-outline-primary btn-sm",
|
||||
)}" data-item-href="${escapeHtml(href)}">${
|
||||
action.icon ? `<i class="${escapeHtml(action.icon)} me-1"></i>` : ""
|
||||
}${escapeHtml(action.label || "Open")}</button>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function passesVisibility(condition, parent, context) {
|
||||
if (!condition || !condition.path) return true;
|
||||
const target =
|
||||
condition.path === "$item"
|
||||
? context.item
|
||||
: getValueByPath(parent, condition.path) ??
|
||||
getValueByPath(context.item, condition.path) ??
|
||||
getValueByPath(context.root, condition.path);
|
||||
|
||||
if (Array.isArray(condition.equals)) {
|
||||
return condition.equals.includes(target);
|
||||
}
|
||||
|
||||
return target === condition.equals;
|
||||
}
|
||||
|
||||
function appendPath(basePath, segment) {
|
||||
return basePath ? `${basePath}.${segment}` : segment;
|
||||
}
|
||||
|
||||
function getValueByPath(target, path) {
|
||||
if (!target || !path) return undefined;
|
||||
return String(path)
|
||||
.split(".")
|
||||
.reduce((current, segment) => {
|
||||
if (current === null || typeof current === "undefined") return undefined;
|
||||
return current[segment];
|
||||
}, target);
|
||||
}
|
||||
|
||||
function fillTemplate(template, item) {
|
||||
if (!template) return "";
|
||||
return template.replace(/\{([^}]+)\}/g, function (_, key) {
|
||||
return item[key] || "";
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'filters' ? 'show active' : '' %>" id="filters" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-filter me-2"></i>Filter Controls</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="filters"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'hero' ? 'show active' : '' %>" id="hero" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="hero"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'highlight' ? 'show active' : '' %>" id="highlight" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-star me-2"></i>Highlight Bar</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="highlight"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'timeline' ? 'show active' : '' %>" id="timeline" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-clock-rotate-left me-2"></i>Timeline</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="timeline"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
||||
<p class="text-muted mb-0"><%= subtitle %></p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="/admin/partnerships/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<% editorConfig.tabs.forEach((tab) => { %>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === tab.key ? 'active' : '' %>" data-bs-toggle="tab" href="#<%= tab.key %>" role="tab" data-tab-key="<%= tab.key %>">
|
||||
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
|
||||
</a>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<%- include("partials/hero-tab", { activeTab, data, backendUrl, editorUi }) %>
|
||||
<%- include("partials/directory-tab", { activeTab, data, editorUi }) %>
|
||||
<%- include("partials/cta-tab", { activeTab, data, editorUi }) %>
|
||||
<%- include("partials/inquiry-form-tab", { activeTab, data, editorUi }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed-bottom-buttons">
|
||||
<button type="button" class="btn btn-secondary" id="resetPartnershipsForm">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include("partials/templates", { editorUi }) %>
|
||||
|
||||
<script>
|
||||
window.partnershipsPageData = <%- JSON.stringify(data) %>;
|
||||
window.partnershipsBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||
window.partnershipsEditorConfig = <%- JSON.stringify(editorConfig) %>;
|
||||
window.partnershipsEditorUi = <%- JSON.stringify(editorUi) %>;
|
||||
</script>
|
||||
<%- include("partials/editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'cta' ? 'show active' : '' %>" id="cta" role="tabpanel">
|
||||
<% const ctaUi = editorUi.cta || {}; %>
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-bullhorn me-2"></i>Call To Action</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold"><%= ctaUi.heading?.label || "Headline" %></label>
|
||||
<input class="form-control" id="ctaHeading" maxlength="<%= ctaUi.heading?.maxLength || 80 %>" value="<%= data.cta?.heading || '' %>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold"><%= ctaUi.description?.label || "Supporting text" %></label>
|
||||
<textarea class="form-control" id="ctaDescription" rows="<%= ctaUi.description?.rows || 4 %>" maxlength="<%= ctaUi.description?.maxLength || 220 %>"><%= data.cta?.description || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold"><%= ctaUi.buttonLabel?.label || "Button label" %></label>
|
||||
<input class="form-control" id="ctaButtonLabel" maxlength="<%= ctaUi.buttonLabel?.maxLength || 40 %>" value="<%= data.cta?.buttonLabel || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'directory' ? 'show active' : '' %>" id="directory" role="tabpanel">
|
||||
<% const directoryUi = editorUi.directory || {}; %>
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-handshake me-2"></i>Partner Directory</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold"><%= directoryUi.heading?.label || "Section heading" %></label>
|
||||
<input class="form-control" id="directoryHeading" maxlength="<%= directoryUi.heading?.maxLength || 70 %>" value="<%= data.directory?.heading || '' %>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold"><%= directoryUi.description?.label || "Section description" %></label>
|
||||
<textarea class="form-control" id="directoryDescription" rows="<%= directoryUi.description?.rows || 3 %>" maxlength="<%= directoryUi.description?.maxLength || 180 %>"><%= data.directory?.description || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cms-editor-group mb-4">
|
||||
<div class="mb-3">
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1"><%= directoryUi.tabs?.label || "Category tabs" %></label>
|
||||
<div class="form-text mt-0"><%= directoryUi.tabsFrontendHint || directoryUi.tabs?.helpText || "" %></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="directoryTabsList"></div>
|
||||
<button type="button" class="cms-add-button mt-3" id="addDirectoryTabBtn">
|
||||
<i class="fas fa-plus me-2"></i><%= directoryUi.tabs?.addLabel || "Add Tab" %>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cms-editor-group">
|
||||
<div class="mb-3">
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1"><%= directoryUi.partners?.label || "Partners" %></label>
|
||||
<div class="form-text mt-0"><%= directoryUi.partnersHelpText || directoryUi.partners?.helpText || "" %></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="partnersList"></div>
|
||||
<button type="button" class="cms-add-button mt-3" id="addPartnerBtn">
|
||||
<i class="fas fa-plus me-2"></i><%= directoryUi.partners?.addLabel || "Add Partner" %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
<script>
|
||||
(function () {
|
||||
const initialData = window.partnershipsPageData;
|
||||
const backendUrl = (window.partnershipsBackendUrl || "").replace(/\/$/, "");
|
||||
const editorConfig = window.partnershipsEditorConfig || {};
|
||||
const editorUi = window.partnershipsEditorUi || {};
|
||||
const form = document.getElementById("cmsEditorForm");
|
||||
const pageJsonInput = document.getElementById("pageJson");
|
||||
const activeTabInput = document.getElementById("activeTabInput");
|
||||
const directoryTabsList = document.getElementById("directoryTabsList");
|
||||
const partnersList = document.getElementById("partnersList");
|
||||
const inquiryFieldsList = document.getElementById("inquiryFieldsList");
|
||||
|
||||
if (!initialData || !form || !pageJsonInput || !activeTabInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = JSON.parse(JSON.stringify(initialData));
|
||||
const directoryUi = editorUi.directory || {};
|
||||
const inquiryUi = editorUi.inquiryForm || {};
|
||||
const partnerFields = directoryUi.partnerFields || {};
|
||||
const inquiryFieldFields = inquiryUi.fieldFields || {};
|
||||
const slugifyValue = (value, fallback) =>
|
||||
String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || fallback;
|
||||
const templates = {
|
||||
tab: document.getElementById("directoryTabTemplate"),
|
||||
partner: document.getElementById("partnerTemplate"),
|
||||
inquiryField: document.getElementById("inquiryFieldTemplate"),
|
||||
inquiryOption: document.getElementById("inquiryOptionTemplate"),
|
||||
};
|
||||
|
||||
ensurePartnershipIds();
|
||||
bindStaticEvents();
|
||||
renderAll();
|
||||
initStaticCounters();
|
||||
|
||||
function getTabConfig(tabKey) {
|
||||
return (editorConfig.tabs || []).find((tab) => tab.key === tabKey) || {};
|
||||
}
|
||||
|
||||
function getObjectFieldConfig(tabKey, fieldKey) {
|
||||
const fields = getTabConfig(tabKey)?.schema?.fields || [];
|
||||
return fields.find((field) => field.key === fieldKey) || {};
|
||||
}
|
||||
|
||||
function getDefaultPartner() {
|
||||
return {
|
||||
name: "",
|
||||
category: "",
|
||||
summary: "",
|
||||
logo: "",
|
||||
logoAlt: "",
|
||||
about: "",
|
||||
collabType: "",
|
||||
benefits: "",
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultInquiryField() {
|
||||
const typeOptions = inquiryFieldFields.type?.options || [];
|
||||
const widthOptions = inquiryFieldFields.width?.options || [];
|
||||
|
||||
return {
|
||||
label: "",
|
||||
placeholder: "",
|
||||
type: typeOptions[0]?.value || "text",
|
||||
width: widthOptions[0]?.value || "full",
|
||||
required: true,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function bindStaticEvents() {
|
||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||
const tabKey = this.dataset.tabKey;
|
||||
if (!tabKey) return;
|
||||
activeTabInput.value = tabKey;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState({}, "", `${url.pathname}?${url.searchParams.toString()}`);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("addDirectoryTabBtn")?.addEventListener("click", function () {
|
||||
state.directory.tabs.push("");
|
||||
renderDirectoryTabs();
|
||||
});
|
||||
|
||||
document.getElementById("addPartnerBtn")?.addEventListener("click", function () {
|
||||
state.directory.partners.push(getDefaultPartner());
|
||||
ensurePartnershipIds();
|
||||
renderPartners();
|
||||
});
|
||||
|
||||
document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () {
|
||||
state.inquiryForm.fields.push(getDefaultInquiryField());
|
||||
ensurePartnershipIds();
|
||||
renderInquiryFields();
|
||||
});
|
||||
|
||||
document.getElementById("resetPartnershipsForm")?.addEventListener("click", function () {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function (event) {
|
||||
syncStaticFields();
|
||||
const duplicateTabs = getDuplicateTabs(state?.directory?.tabs);
|
||||
|
||||
clearDirectoryTabsValidation();
|
||||
|
||||
if (duplicateTabs.length > 0) {
|
||||
const tabsLabel = directoryUi.tabs?.label || "Category tab";
|
||||
event.preventDefault();
|
||||
highlightDuplicateDirectoryTabs(duplicateTabs);
|
||||
showToast(
|
||||
`Duplicate ${tabsLabel.toLowerCase()}`,
|
||||
`${tabsLabel} "${duplicateTabs[0]}" already exists. Please use unique tab names before saving.`,
|
||||
"danger",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ensurePartnershipIds();
|
||||
pageJsonInput.value = JSON.stringify(state);
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-upload-target]").forEach((button) => {
|
||||
button.addEventListener("click", function () {
|
||||
const targetId = button.getAttribute("data-upload-target");
|
||||
const previewId = button.getAttribute("data-preview-target");
|
||||
const input = document.getElementById(targetId);
|
||||
const preview = document.getElementById(previewId);
|
||||
openImagePicker(function (path) {
|
||||
input.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.toggle("d-none", !path);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function syncStaticFields() {
|
||||
state.hero = {
|
||||
badge: getValue("heroBadge"),
|
||||
title: getValue("heroTitle"),
|
||||
description: getValue("heroDescription"),
|
||||
linkLabel: getValue("heroLinkLabel"),
|
||||
image: getValue("heroImage"),
|
||||
imageAlt: getValue("heroImageAlt"),
|
||||
};
|
||||
|
||||
state.directory.heading = getValue("directoryHeading");
|
||||
state.directory.description = getValue("directoryDescription");
|
||||
|
||||
state.cta = {
|
||||
heading: getValue("ctaHeading"),
|
||||
description: getValue("ctaDescription"),
|
||||
buttonLabel: getValue("ctaButtonLabel"),
|
||||
};
|
||||
|
||||
state.inquiryForm.title = getValue("inquiryTitle");
|
||||
}
|
||||
|
||||
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 getDuplicateTabs(tabs) {
|
||||
if (!Array.isArray(tabs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const duplicates = [];
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
const trimmedTab = String(tab || "").trim();
|
||||
const normalizedTab = trimmedTab.toLowerCase();
|
||||
|
||||
if (!normalizedTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (seen.has(normalizedTab)) {
|
||||
duplicates.push(trimmedTab);
|
||||
return;
|
||||
}
|
||||
|
||||
seen.add(normalizedTab);
|
||||
});
|
||||
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
function clearDirectoryTabsValidation() {
|
||||
directoryTabsList
|
||||
?.querySelectorAll(".is-invalid")
|
||||
.forEach((element) => element.classList.remove("is-invalid"));
|
||||
}
|
||||
|
||||
function highlightDuplicateDirectoryTabs(duplicateTabs) {
|
||||
const normalizedDuplicates = new Set(
|
||||
duplicateTabs.map((tab) => String(tab || "").trim().toLowerCase()),
|
||||
);
|
||||
|
||||
const duplicateInputs = Array.from(
|
||||
directoryTabsList?.querySelectorAll('[data-field="label"]') || [],
|
||||
).filter((input) => {
|
||||
const inputValue = String(input.value || "").trim().toLowerCase();
|
||||
return inputValue && normalizedDuplicates.has(inputValue);
|
||||
});
|
||||
|
||||
duplicateInputs.forEach((input) => input.classList.add("is-invalid"));
|
||||
duplicateInputs[0]?.focus();
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderDirectoryTabs();
|
||||
renderPartners();
|
||||
renderInquiryFields();
|
||||
}
|
||||
|
||||
function renderDirectoryTabs() {
|
||||
directoryTabsList.innerHTML = "";
|
||||
|
||||
state.directory.tabs.forEach((tab, index) => {
|
||||
const node = cloneTemplate(templates.tab);
|
||||
const input = node.querySelector('[data-field="label"]');
|
||||
input.value = tab || "";
|
||||
input.addEventListener("input", function () {
|
||||
state.directory.tabs[index] = input.value;
|
||||
refreshPartnerCategoryLists();
|
||||
});
|
||||
|
||||
node.querySelector("[data-remove-item]").addEventListener("click", function () {
|
||||
state.directory.tabs.splice(index, 1);
|
||||
renderDirectoryTabs();
|
||||
renderPartners();
|
||||
});
|
||||
node.querySelector("[data-toggle-item]")?.addEventListener("click", function () {
|
||||
node.classList.toggle("is-collapsed");
|
||||
});
|
||||
attachCounters(node);
|
||||
|
||||
directoryTabsList.appendChild(node);
|
||||
});
|
||||
|
||||
initSortable(directoryTabsList, state.directory.tabs, renderDirectoryTabs, '[data-item="directory-tab"]');
|
||||
}
|
||||
|
||||
function renderPartners() {
|
||||
partnersList.innerHTML = "";
|
||||
|
||||
state.directory.partners.forEach((partner, index) => {
|
||||
const node = cloneTemplate(templates.partner);
|
||||
const title = node.querySelector("[data-title]");
|
||||
const subtitle = node.querySelector("[data-subtitle]");
|
||||
const categoryInput = node.querySelector('[data-field="category"]');
|
||||
const categoryListId = `partner-category-options-${index}`;
|
||||
|
||||
title.textContent = partner.name || `Partner ${index + 1}`;
|
||||
subtitle.textContent = partner.category || "";
|
||||
|
||||
node.querySelectorAll("[data-field]").forEach((input) => {
|
||||
const field = input.getAttribute("data-field");
|
||||
input.value = partner[field] || "";
|
||||
input.addEventListener("input", function () {
|
||||
partner[field] = input.value;
|
||||
if (field === "name") {
|
||||
title.textContent = input.value || `${partnerFields.name?.label || "Partner"} ${index + 1}`;
|
||||
}
|
||||
if (field === "category") {
|
||||
subtitle.textContent = input.value || "";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
categoryInput.setAttribute("list", categoryListId);
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = categoryListId;
|
||||
state.directory.tabs.forEach((tab) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = tab;
|
||||
dataList.appendChild(option);
|
||||
});
|
||||
node.appendChild(dataList);
|
||||
|
||||
const preview = node.querySelector("[data-preview]");
|
||||
const logoInput = node.querySelector('[data-field="logo"]');
|
||||
if (partner.logo) {
|
||||
preview.src = resolveImageUrl(partner.logo);
|
||||
preview.classList.remove("d-none");
|
||||
}
|
||||
|
||||
node.querySelector("[data-upload-button]").addEventListener("click", function () {
|
||||
openImagePicker(function (path) {
|
||||
partner.logo = path;
|
||||
logoInput.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.remove("d-none");
|
||||
});
|
||||
});
|
||||
|
||||
node.querySelector("[data-remove-item]").addEventListener("click", function () {
|
||||
state.directory.partners.splice(index, 1);
|
||||
renderPartners();
|
||||
});
|
||||
node.querySelector("[data-toggle-item]")?.addEventListener("click", function () {
|
||||
node.classList.toggle("is-collapsed");
|
||||
});
|
||||
attachCounters(node);
|
||||
|
||||
partnersList.appendChild(node);
|
||||
});
|
||||
|
||||
initSortable(partnersList, state.directory.partners, renderPartners, '[data-item="partner"]');
|
||||
}
|
||||
|
||||
function refreshPartnerCategoryLists() {
|
||||
partnersList.querySelectorAll("datalist").forEach((list) => list.remove());
|
||||
renderPartners();
|
||||
}
|
||||
|
||||
function renderInquiryFields() {
|
||||
inquiryFieldsList.innerHTML = "";
|
||||
|
||||
state.inquiryForm.fields.forEach((field, index) => {
|
||||
const node = cloneTemplate(templates.inquiryField);
|
||||
const title = node.querySelector("[data-title]");
|
||||
const subtitle = node.querySelector("[data-subtitle]");
|
||||
const optionsWrap = node.querySelector("[data-options-wrap]");
|
||||
const optionsList = node.querySelector("[data-options-list]");
|
||||
const typeSelect = node.querySelector('[data-field="type"]');
|
||||
|
||||
title.textContent = field.label || `Field ${index + 1}`;
|
||||
subtitle.textContent = field.type || "";
|
||||
|
||||
node.querySelectorAll("[data-field]").forEach((input) => {
|
||||
const key = input.getAttribute("data-field");
|
||||
|
||||
if (input.type === "checkbox") {
|
||||
input.checked = Boolean(field[key]);
|
||||
input.addEventListener("change", function () {
|
||||
field[key] = input.checked;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = field[key] || "";
|
||||
input.addEventListener("input", function () {
|
||||
field[key] = input.value;
|
||||
if (key === "label") {
|
||||
title.textContent = input.value || `${inquiryFieldFields.label?.label || "Field"} ${index + 1}`;
|
||||
}
|
||||
if (key === "type") {
|
||||
subtitle.textContent = input.value || "";
|
||||
toggleOptions();
|
||||
}
|
||||
});
|
||||
if (input.tagName === "SELECT") {
|
||||
input.addEventListener("change", function () {
|
||||
field[key] = input.value;
|
||||
if (key === "type") {
|
||||
subtitle.textContent = input.value || "";
|
||||
if (input.value !== "select") {
|
||||
field.options = [];
|
||||
}
|
||||
toggleOptions();
|
||||
renderInquiryFields();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
node.querySelector("[data-remove-item]").addEventListener("click", function () {
|
||||
state.inquiryForm.fields.splice(index, 1);
|
||||
renderInquiryFields();
|
||||
});
|
||||
|
||||
node.querySelector("[data-add-option]").addEventListener("click", function () {
|
||||
field.options = Array.isArray(field.options) ? field.options : [];
|
||||
field.options.push("");
|
||||
renderInquiryFields();
|
||||
});
|
||||
node.querySelector("[data-toggle-item]")?.addEventListener("click", function () {
|
||||
node.classList.toggle("is-collapsed");
|
||||
});
|
||||
|
||||
function toggleOptions() {
|
||||
optionsWrap.classList.toggle("d-none", typeSelect.value !== "select");
|
||||
}
|
||||
|
||||
function renderOptions() {
|
||||
optionsList.innerHTML = "";
|
||||
(field.options || []).forEach((optionValue, optionIndex) => {
|
||||
const optionNode = cloneTemplate(templates.inquiryOption);
|
||||
const optionInput = optionNode.querySelector("[data-option-value]");
|
||||
optionInput.value = optionValue || "";
|
||||
optionInput.addEventListener("input", function () {
|
||||
field.options[optionIndex] = optionInput.value;
|
||||
});
|
||||
optionNode.querySelector("[data-remove-item]").addEventListener("click", function () {
|
||||
field.options.splice(optionIndex, 1);
|
||||
renderInquiryFields();
|
||||
});
|
||||
attachCounters(optionNode);
|
||||
optionsList.appendChild(optionNode);
|
||||
});
|
||||
initSortable(optionsList, field.options, renderInquiryFields, '[data-item="inquiry-option"]');
|
||||
}
|
||||
|
||||
toggleOptions();
|
||||
renderOptions();
|
||||
attachCounters(node);
|
||||
inquiryFieldsList.appendChild(node);
|
||||
});
|
||||
|
||||
initSortable(inquiryFieldsList, state.inquiryForm.fields, renderInquiryFields, '[data-item="inquiry-field"]');
|
||||
}
|
||||
|
||||
function initSortable(container, list, rerender, draggableSelector) {
|
||||
if (!window.Sortable || !container) return;
|
||||
if (container._sortableInstance) {
|
||||
container._sortableInstance.destroy();
|
||||
}
|
||||
container._sortableInstance = window.Sortable.create(container, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
draggable: draggableSelector,
|
||||
onEnd: function (event) {
|
||||
if (event.oldIndex === event.newIndex) return;
|
||||
const moved = list.splice(event.oldIndex, 1)[0];
|
||||
list.splice(event.newIndex, 0, moved);
|
||||
rerender();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cloneTemplate(template) {
|
||||
return template.content.firstElementChild.cloneNode(true);
|
||||
}
|
||||
|
||||
function initStaticCounters() {
|
||||
attachCounters(form);
|
||||
}
|
||||
|
||||
function attachCounters(root) {
|
||||
root.querySelectorAll("input[maxlength], textarea[maxlength]").forEach((input, index) => {
|
||||
if (input.dataset.counterReady === "true") {
|
||||
updateInputCounter(input);
|
||||
return;
|
||||
}
|
||||
|
||||
const counterId =
|
||||
input.id ||
|
||||
input.name ||
|
||||
input.dataset.field ||
|
||||
input.dataset.optionValue ||
|
||||
`counter-${index}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
let counter = root.querySelector(`[data-counter-for="${counterId}"]`);
|
||||
|
||||
if (!counter) {
|
||||
counter = document.createElement("div");
|
||||
counter.className = "field-char-count";
|
||||
counter.dataset.counterFor = counterId;
|
||||
|
||||
const next = input.nextElementSibling;
|
||||
if (next && next.classList.contains("form-text")) {
|
||||
let metaRow = next.nextElementSibling;
|
||||
if (!metaRow || !metaRow.classList.contains("field-meta-row")) {
|
||||
metaRow = document.createElement("div");
|
||||
metaRow.className = "field-meta-row";
|
||||
next.insertAdjacentElement("afterend", metaRow);
|
||||
}
|
||||
metaRow.appendChild(counter);
|
||||
} else {
|
||||
const metaRow = document.createElement("div");
|
||||
metaRow.className = "field-meta-row";
|
||||
metaRow.appendChild(counter);
|
||||
input.insertAdjacentElement("afterend", metaRow);
|
||||
}
|
||||
}
|
||||
|
||||
const sync = function () {
|
||||
updateInputCounter(input);
|
||||
};
|
||||
|
||||
input.addEventListener("input", sync);
|
||||
input.addEventListener("change", sync);
|
||||
input.dataset.counterReady = "true";
|
||||
updateInputCounter(input);
|
||||
});
|
||||
}
|
||||
|
||||
function updateInputCounter(input) {
|
||||
const maxLength = Number(input.getAttribute("maxlength"));
|
||||
if (!maxLength) return;
|
||||
|
||||
const counterId =
|
||||
input.id || input.name || input.dataset.field || input.dataset.optionValue;
|
||||
const scope = input.closest("[data-item]") || input.parentElement || form;
|
||||
let counter = scope.querySelector(`[data-counter-for="${counterId}"]`);
|
||||
if (!counter) {
|
||||
counter = form.querySelector(`[data-counter-for="${counterId}"]`);
|
||||
}
|
||||
if (!counter) return;
|
||||
|
||||
counter.textContent = `${String(input.value || "").length}/${maxLength}`;
|
||||
}
|
||||
|
||||
function getValue(id) {
|
||||
return document.getElementById(id)?.value || "";
|
||||
}
|
||||
|
||||
function openImagePicker(onSuccess) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", async function () {
|
||||
if (!fileInput.files || !fileInput.files[0]) {
|
||||
fileInput.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileInput.files[0]);
|
||||
|
||||
const response = await fetch("/admin/upload/image?imageType=partnerships", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.path) {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
|
||||
onSuccess(result.path);
|
||||
showToast("Success", "Image uploaded successfully", "success");
|
||||
} catch (error) {
|
||||
showToast("Error", error.message || "Upload failed", "danger");
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function resolveImageUrl(path) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/")) return `${backendUrl}${path}`;
|
||||
return `${backendUrl}/${path}`;
|
||||
}
|
||||
|
||||
function showToast(title, message, type) {
|
||||
const container = document.querySelector(".toast-container") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(title)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", function () {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'hero' ? 'show active' : '' %>" id="hero" role="tabpanel">
|
||||
<% const heroUi = editorUi.hero || {}; %>
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold"><%= heroUi.badge?.label || "Eyebrow label" %></label>
|
||||
<input class="form-control" id="heroBadge" maxlength="<%= heroUi.badge?.maxLength || 40 %>" value="<%= data.hero?.badge || '' %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold"><%= heroUi.linkLabel?.label || "Scroll link label" %></label>
|
||||
<input class="form-control" id="heroLinkLabel" maxlength="<%= heroUi.linkLabel?.maxLength || 40 %>" value="<%= data.hero?.linkLabel || '' %>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold"><%= heroUi.title?.label || "Headline" %></label>
|
||||
<input class="form-control" id="heroTitle" maxlength="<%= heroUi.title?.maxLength || 90 %>" value="<%= data.hero?.title || '' %>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold"><%= heroUi.description?.label || "Supporting text" %></label>
|
||||
<textarea class="form-control" id="heroDescription" rows="<%= heroUi.description?.rows || 4 %>" maxlength="<%= heroUi.description?.maxLength || 220 %>"><%= data.hero?.description || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold"><%= heroUi.image?.label || "Hero image" %></label>
|
||||
<div class="input-group">
|
||||
<input class="form-control" id="heroImage" value="<%= data.hero?.image || '' %>">
|
||||
<button class="btn btn-outline-primary" type="button" data-upload-target="heroImage" data-preview-target="heroImagePreview">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text"><%= [heroUi.image?.helpText, heroUi.image?.imageHint].filter(Boolean).join(" ") %></div>
|
||||
<img id="heroImagePreview" src="<%= data.hero?.image ? `${backendUrl}${data.hero.image}` : '' %>" class="img-thumbnail mt-2 <%= data.hero?.image ? '' : 'd-none' %>" style="max-height: 200px;">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold"><%= heroUi.imageAlt?.label || "Hero image alt text" %></label>
|
||||
<input class="form-control" id="heroImageAlt" maxlength="<%= heroUi.imageAlt?.maxLength || 120 %>" value="<%= data.hero?.imageAlt || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'inquiryForm' ? 'show active' : '' %>" id="inquiryForm" role="tabpanel">
|
||||
<% const inquiryUi = editorUi.inquiryForm || {}; %>
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-envelope me-2"></i>Inquiry Form</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold"><%= inquiryUi.title?.label || "Modal title" %></label>
|
||||
<input class="form-control" id="inquiryTitle" maxlength="<%= inquiryUi.title?.maxLength || 60 %>" value="<%= data.inquiryForm?.title || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cms-editor-group">
|
||||
<div class="mb-3">
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1"><%= inquiryUi.fields?.label || "Form fields" %></label>
|
||||
<div class="form-text mt-0"><%= inquiryUi.fieldsHelpText || inquiryUi.fields?.helpText || "" %></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="inquiryFieldsList"></div>
|
||||
<button type="button" class="cms-add-button mt-3" id="addInquiryFieldBtn">
|
||||
<i class="fas fa-plus me-2"></i><%= inquiryUi.fields?.addLabel || "Add Field" %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<% const directoryUi = editorUi.directory || {}; %>
|
||||
<% const partnerFields = directoryUi.partnerFields || {}; %>
|
||||
<% const inquiryUi = editorUi.inquiryForm || {}; %>
|
||||
<% const inquiryFieldFields = inquiryUi.fieldFields || {}; %>
|
||||
|
||||
<template id="directoryTabTemplate">
|
||||
<div class="card cms-item-card mb-3" data-item="directory-tab">
|
||||
<div class="card-header d-flex justify-content-between align-items-center gap-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="drag-handle" title="Drag to reorder">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</button>
|
||||
<div class="fw-semibold">Category Tab</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="cms-collapse-toggle" data-toggle-item title="Collapse item">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
<button type="button" class="cms-remove-button" data-remove-item title="Remove item">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<label class="form-label fw-semibold"><%= directoryUi.tabs?.itemLabel || "Tab label" %></label>
|
||||
<input class="form-control" data-field="label" maxlength="<%= directoryUi.tabs?.itemSchema?.maxLength || 30 %>" placeholder="<%= directoryUi.tabs?.itemSchema?.placeholder || 'Industry' %>">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="partnerTemplate">
|
||||
<div class="card cms-item-card mb-3" data-item="partner">
|
||||
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="drag-handle" title="Drag to reorder">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</button>
|
||||
<div>
|
||||
<div class="fw-semibold" data-title>Partner</div>
|
||||
<div class="small text-muted" data-subtitle></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="cms-collapse-toggle" data-toggle-item title="Collapse item">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
<button type="button" class="cms-remove-button" data-remove-item title="Remove item">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold"><%= partnerFields.name?.label || "Partner name" %></label>
|
||||
<input class="form-control" data-field="name" maxlength="<%= partnerFields.name?.maxLength || 90 %>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold"><%= partnerFields.category?.label || "Category" %></label>
|
||||
<input class="form-control" data-field="category" list="" maxlength="<%= partnerFields.category?.maxLength || 30 %>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold"><%= partnerFields.summary?.label || "Card summary" %></label>
|
||||
<textarea class="form-control" data-field="summary" rows="<%= partnerFields.summary?.rows || 3 %>" maxlength="<%= partnerFields.summary?.maxLength || 130 %>"></textarea>
|
||||
<div class="form-text"><%= partnerFields.summary?.helpText || "" %></div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold"><%= partnerFields.logo?.label || "Partner logo" %></label>
|
||||
<div class="input-group">
|
||||
<input class="form-control" data-field="logo">
|
||||
<button class="btn btn-outline-primary" type="button" data-upload-button>
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text"><%= [partnerFields.logo?.helpText, partnerFields.logo?.imageHint].filter(Boolean).join(" ") %></div>
|
||||
<img class="img-thumbnail mt-2 d-none" data-preview style="max-height: 180px;">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold"><%= partnerFields.logoAlt?.label || "Logo alt text" %></label>
|
||||
<input class="form-control" data-field="logoAlt" maxlength="<%= partnerFields.logoAlt?.maxLength || 120 %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold"><%= partnerFields.collabType?.label || "Collaboration type" %></label>
|
||||
<input class="form-control" data-field="collabType" maxlength="<%= partnerFields.collabType?.maxLength || 40 %>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold"><%= partnerFields.about?.label || "About text" %></label>
|
||||
<textarea class="form-control" data-field="about" rows="<%= partnerFields.about?.rows || 5 %>" maxlength="<%= partnerFields.about?.maxLength || 600 %>"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold"><%= partnerFields.benefits?.label || "Benefits" %></label>
|
||||
<textarea class="form-control" data-field="benefits" rows="<%= partnerFields.benefits?.rows || 4 %>" maxlength="<%= partnerFields.benefits?.maxLength || 240 %>"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="inquiryFieldTemplate">
|
||||
<div class="card cms-item-card mb-3" data-item="inquiry-field">
|
||||
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="drag-handle" title="Drag to reorder">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</button>
|
||||
<div>
|
||||
<div class="fw-semibold" data-title>Field</div>
|
||||
<div class="small text-muted" data-subtitle></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="cms-collapse-toggle" data-toggle-item title="Collapse item">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
<button type="button" class="cms-remove-button" data-remove-item title="Remove item">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold"><%= inquiryFieldFields.label?.label || "Field label" %></label>
|
||||
<input class="form-control" data-field="label" maxlength="<%= inquiryFieldFields.label?.maxLength || 40 %>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold"><%= inquiryFieldFields.placeholder?.label || "Placeholder text" %></label>
|
||||
<input class="form-control" data-field="placeholder" maxlength="<%= inquiryFieldFields.placeholder?.maxLength || 80 %>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold"><%= inquiryFieldFields.type?.label || "Field type" %></label>
|
||||
<select class="form-select" data-field="type">
|
||||
<% (inquiryFieldFields.type?.options || []).forEach((option) => { %>
|
||||
<option value="<%= option.value %>"><%= option.label %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold"><%= inquiryFieldFields.width?.label || "Field width" %></label>
|
||||
<select class="form-select" data-field="width">
|
||||
<% (inquiryFieldFields.width?.options || []).forEach((option) => { %>
|
||||
<option value="<%= option.value %>"><%= option.label %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" data-field="required">
|
||||
<label class="form-check-label fw-semibold"><%= inquiryFieldFields.required?.label || "Required field" %></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12" data-options-wrap>
|
||||
<div class="cms-editor-group">
|
||||
<div class="mb-2">
|
||||
<label class="form-label fw-semibold mb-0"><%= inquiryFieldFields.options?.label || "Dropdown options" %></label>
|
||||
</div>
|
||||
<div class="form-text mb-2"><%= inquiryFieldFields.options?.helpText || "" %></div>
|
||||
<div data-options-list></div>
|
||||
<button type="button" class="cms-add-button mt-3" data-add-option>
|
||||
<i class="fas fa-plus me-2"></i><%= inquiryFieldFields.options?.addLabel || "Add Option" %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="inquiryOptionTemplate">
|
||||
<div class="input-group mb-2" data-item="inquiry-option">
|
||||
<button type="button" class="drag-handle" title="Drag to reorder">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</button>
|
||||
<input class="form-control" data-option-value maxlength="<%= inquiryFieldFields.options?.itemSchema?.maxLength || 50 %>" placeholder="<%= inquiryFieldFields.options?.itemSchema?.placeholder || 'Option label' %>">
|
||||
<button type="button" class="cms-remove-button" data-remove-item title="Remove option">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
||||
<p class="text-muted mb-0"><%= subtitle %></p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="<%= editorConfig.routeBase %>/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<% editorConfig.tabs.forEach((tab) => { %>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === tab.key ? 'active' : '' %>" data-bs-toggle="tab" href="#<%= tab.key %>" role="tab" data-tab-key="<%= tab.key %>">
|
||||
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
|
||||
</a>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<%- include("partials/hero-tab", { activeTab }) %>
|
||||
<%- include("partials/sidebar-tab", { activeTab }) %>
|
||||
<%- include("partials/policies-tab", { activeTab }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed-bottom-buttons">
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="application/json" id="pageEditorConfigData"><%- JSON.stringify(editorConfig) %></script>
|
||||
<script type="application/json" id="pageEditorDataPayload"><%- JSON.stringify(data) %></script>
|
||||
<script type="application/json" id="pageEditorBackendUrlData"><%- JSON.stringify(backendUrl) %></script>
|
||||
<%- include("partials/editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'hero' ? 'show active' : '' %>" id="hero" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-scale-balanced me-2"></i>Hero</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="hero"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'policies' ? 'show active' : '' %>" id="policies" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-file-lines me-2"></i>Policies</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="policies"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'sidebar' ? 'show active' : '' %>" id="sidebar" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-bars me-2"></i>Sidebar</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="sidebar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,896 @@
|
||||
<style>
|
||||
#policyBlockEditor.container {
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.policy-block-editor {
|
||||
--editor-fullscreen-top-offset: 88px;
|
||||
--editor-border: rgba(148, 163, 184, 0.22);
|
||||
--editor-muted: #64748b;
|
||||
--editor-bg: #f8fafc;
|
||||
--editor-surface: #ffffff;
|
||||
--editor-soft: #eef2ff;
|
||||
--editor-danger: #dc2626;
|
||||
--editor-warning: #d97706;
|
||||
--editor-success: #15803d;
|
||||
--editor-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
|
||||
padding-bottom: 140px;
|
||||
}
|
||||
|
||||
.policy-block-editor.is-editor-fullscreen {
|
||||
position: fixed;
|
||||
inset: var(--editor-fullscreen-top-offset) 0 0 0;
|
||||
z-index: 1080;
|
||||
width: 100%;
|
||||
max-width: none !important;
|
||||
margin: 0;
|
||||
padding: 1rem 1.25rem 140px;
|
||||
background: #f8fafc;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
body.policy-editor-fullscreen {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.98fr) minmax(420px, 1.02fr);
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.policy-block-editor.is-editor-fullscreen .editor-shell {
|
||||
grid-template-columns: minmax(0, 0.88fr) minmax(520px, 1.12fr);
|
||||
min-height: calc(100vh - var(--editor-fullscreen-top-offset) - 77px);
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-panel,
|
||||
.policy-block-editor .preview-panel {
|
||||
border: 1px solid var(--editor-border);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: var(--editor-shadow);
|
||||
}
|
||||
|
||||
.policy-block-editor .preview-mode-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--editor-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem;
|
||||
background: var(--editor-bg);
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.policy-block-editor .segment-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--editor-muted);
|
||||
padding: 0.55rem 0.95rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.policy-block-editor .segment-button.is-active {
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-panel,
|
||||
.policy-block-editor .preview-panel {
|
||||
padding: 1.25rem;
|
||||
position: sticky;
|
||||
top: 92px;
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-panel {
|
||||
min-height: calc(100vh - 210px);
|
||||
}
|
||||
|
||||
.policy-block-editor.is-editor-fullscreen .editor-panel {
|
||||
position: static;
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.policy-block-editor .preview-panel {
|
||||
min-height: calc(100vh - 210px);
|
||||
}
|
||||
|
||||
.policy-block-editor.is-editor-fullscreen .preview-panel {
|
||||
position: static;
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.policy-block-editor .visual-editor {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.policy-block-editor.is-editor-fullscreen .visual-editor {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.policy-block-editor .panel-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-toolbar-main,
|
||||
.policy-block-editor .preview-toolbar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-toolbar-main {
|
||||
flex: 1 1 360px;
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-toolbar-secondary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.policy-block-editor .preview-toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-toolbar-title {
|
||||
min-width: 0;
|
||||
flex: 1 1 320px;
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-toolbar-title h1,
|
||||
.policy-block-editor .editor-toolbar-title p {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-scroll {
|
||||
max-height: calc(100vh - 340px);
|
||||
overflow: auto;
|
||||
padding-right: 0.35rem;
|
||||
}
|
||||
|
||||
.policy-block-editor.is-editor-fullscreen .editor-scroll {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.policy-block-editor.is-editor-fullscreen .preview-frame {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.policy-block-editor .validation-banner {
|
||||
display: none;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(217, 119, 6, 0.25);
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
color: #92400e;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.policy-block-editor .validation-banner.is-visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.policy-block-editor .block-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.policy-block-editor .block-card {
|
||||
position: relative;
|
||||
border: 1px solid var(--editor-border);
|
||||
border-radius: 22px;
|
||||
background: var(--editor-surface);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.policy-block-editor .block-card.has-floating-ui {
|
||||
overflow: visible;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.policy-block-editor .block-card:hover,
|
||||
.policy-block-editor .block-card.is-selected {
|
||||
border-color: rgba(184, 183, 106, 0.62);
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.policy-block-editor .block-card.is-invalid {
|
||||
border-color: rgba(220, 38, 38, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.08);
|
||||
}
|
||||
|
||||
.policy-block-editor .block-card.is-collapsed .block-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.policy-block-editor .block-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.9rem 1rem;
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.95), rgba(255, 255, 255, 0.96));
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.policy-block-editor .block-title {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.policy-block-editor .block-index {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: rgba(184, 183, 106, 0.12);
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.policy-block-editor .block-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
opacity: 0.18;
|
||||
transition: opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.policy-block-editor .block-card:hover .block-actions,
|
||||
.policy-block-editor .block-card.is-selected .block-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-button:hover {
|
||||
border-color: rgba(184, 183, 106, 0.65);
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-button.is-danger:hover {
|
||||
border-color: rgba(220, 38, 38, 0.55);
|
||||
color: var(--editor-danger);
|
||||
}
|
||||
|
||||
.policy-block-editor .drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.policy-block-editor .drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.policy-block-editor .block-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.policy-block-editor .block-content {
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
min-height: 108px;
|
||||
padding: 0.9rem 1rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.policy-block-editor .block-content:focus {
|
||||
border-color: rgba(184, 183, 106, 0.65);
|
||||
box-shadow: 0 0 0 3px rgba(184, 183, 106, 0.12);
|
||||
}
|
||||
|
||||
.policy-block-editor .block-content[data-placeholder]:empty::before,
|
||||
.policy-block-editor .list-item-content[data-placeholder]:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.policy-block-editor .block-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-trigger {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.72rem 0.85rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-trigger:focus,
|
||||
.policy-block-editor .icon-combobox.is-open .icon-combobox-trigger {
|
||||
border-color: rgba(184, 183, 106, 0.65);
|
||||
box-shadow: 0 0 0 3px rgba(184, 183, 106, 0.12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-preview {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(184, 183, 106, 0.12);
|
||||
color: #0f172a;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-text {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-text strong,
|
||||
.policy-block-editor .icon-combobox-text span {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-caret {
|
||||
color: #64748b;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.4rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.12);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-panel.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-search {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-option {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 0.6rem 0.7rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-option:hover,
|
||||
.policy-block-editor .icon-combobox-option.is-active {
|
||||
background: rgba(184, 183, 106, 0.12);
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-option-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-option-main span {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-empty {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.35rem 0.2rem;
|
||||
}
|
||||
|
||||
.policy-block-editor .icon-combobox-empty.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.policy-block-editor .list-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.policy-block-editor .list-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
padding: 0.75rem;
|
||||
border-radius: 18px;
|
||||
background: var(--editor-bg);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.policy-block-editor .list-item-content {
|
||||
min-height: 70px;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: #fff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.policy-block-editor .preview-frame {
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(180deg, #f8fafc, #ffffff);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.policy-block-editor .preview-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.policy-block-editor .preview-device {
|
||||
margin: 0 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.policy-block-editor .preview-device[data-mode="desktop"] {
|
||||
width: min(100%, 920px);
|
||||
}
|
||||
|
||||
.policy-block-editor .preview-device[data-mode="tablet"] {
|
||||
width: min(100%, 820px);
|
||||
}
|
||||
|
||||
.policy-block-editor .preview-device[data-mode="mobile"] {
|
||||
width: min(100%, 390px);
|
||||
}
|
||||
|
||||
.policy-block-editor .preview-canvas {
|
||||
background: #fff;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
padding: 2rem;
|
||||
min-height: 480px;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.policy-block-editor .preview-device[data-mode="desktop"] .preview-canvas {
|
||||
min-height: 720px;
|
||||
padding: 2.5rem;
|
||||
}
|
||||
|
||||
.policy-block-editor .preview-device[data-mode="desktop"] #previewContent {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.policy-block-editor .preview-block {
|
||||
padding: 0.65rem 0;
|
||||
border-radius: 14px;
|
||||
scroll-margin-top: 140px;
|
||||
}
|
||||
|
||||
.policy-block-editor .preview-canvas h2,
|
||||
.policy-block-editor .preview-canvas h3,
|
||||
.policy-block-editor .preview-canvas h4,
|
||||
.policy-block-editor .preview-canvas h5,
|
||||
.policy-block-editor .preview-canvas p,
|
||||
.policy-block-editor .preview-canvas li,
|
||||
.policy-block-editor .preview-canvas blockquote,
|
||||
.policy-block-editor .preview-canvas .text-uppercase {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.policy-block-editor .preview-block.is-highlighted {
|
||||
background: rgba(184, 183, 106, 0.12);
|
||||
outline: 1px solid rgba(184, 183, 106, 0.35);
|
||||
}
|
||||
|
||||
.policy-block-editor .preview-block.is-targeted {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
outline: 1px solid rgba(37, 99, 235, 0.45);
|
||||
box-shadow: 0 0 0 6px rgba(37, 99, 235, 0.08);
|
||||
animation: preview-target-pulse 0.9s ease;
|
||||
}
|
||||
|
||||
@keyframes preview-target-pulse {
|
||||
0% {
|
||||
transform: scale(0.985);
|
||||
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
55% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 10px rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 6px rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.policy-block-editor .callout-block {
|
||||
border-radius: 22px;
|
||||
padding: 1.1rem 1.2rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: #f8fafc;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.policy-block-editor .callout-block > div,
|
||||
.policy-block-editor .callout-block p,
|
||||
.policy-block-editor .callout-block li,
|
||||
.policy-block-editor .callout-block h3,
|
||||
.policy-block-editor .callout-block h4,
|
||||
.policy-block-editor .callout-block h5,
|
||||
.policy-block-editor .callout-block a,
|
||||
.policy-block-editor .callout-block code {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.policy-block-editor .callout-block[data-tone="warning"] {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border-color: rgba(217, 119, 6, 0.28);
|
||||
}
|
||||
|
||||
.policy-block-editor .callout-block[data-tone="success"] {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(21, 128, 61, 0.22);
|
||||
}
|
||||
|
||||
#floatingToolbar,
|
||||
#linkPopover,
|
||||
#slashMenu {
|
||||
position: fixed;
|
||||
z-index: 1045;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(15, 23, 42, 0.96);
|
||||
color: #fff;
|
||||
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.24);
|
||||
padding: 0.55rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#floatingToolbar.is-visible,
|
||||
#linkPopover.is-visible,
|
||||
#slashMenu.is-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#floatingToolbar .floating-toolbar-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
#floatingToolbar button,
|
||||
#slashMenu button {
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#floatingToolbar button:hover,
|
||||
#slashMenu button:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
#slashMenu .slash-menu-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 210px;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1035;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.14);
|
||||
box-shadow: 0 -18px 36px rgba(15, 23, 42, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-footer .btn {
|
||||
min-width: 136px;
|
||||
}
|
||||
|
||||
.policy-block-editor .drop-indicator {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #0f172a, rgba(184, 183, 106, 0.92));
|
||||
margin: -0.35rem 0 0.65rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.policy-block-editor .drop-indicator.is-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.policy-block-editor .sortable-ghost {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.policy-block-editor .sortable-chosen {
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.policy-block-editor .editor-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-panel,
|
||||
.policy-block-editor .preview-panel {
|
||||
position: static;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-scroll {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.policy-block-editor .block-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-toolbar-secondary,
|
||||
.policy-block-editor .preview-toolbar-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.policy-block-editor .editor-footer .btn,
|
||||
.policy-block-editor .editor-footer a {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div
|
||||
class="container policy-block-editor"
|
||||
id="policyBlockEditor"
|
||||
data-policy-id="<%= editorConfig.policyId %>"
|
||||
data-route-base="<%= editorConfig.routeBase %>"
|
||||
>
|
||||
<form action="<%= editorConfig.routeBase %>/update" method="POST" id="policyBlockEditorForm" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="intent" id="editorIntent" value="save" />
|
||||
|
||||
<div class="editor-shell">
|
||||
<section class="editor-panel">
|
||||
<div class="panel-toolbar">
|
||||
<div class="editor-toolbar-main">
|
||||
<div class="editor-toolbar-title">
|
||||
<p class="text-uppercase text-muted small fw-semibold mb-1">Modern CMS Editor</p>
|
||||
<h1 class="h4 mb-0" style="color: var(--primary-dark)"><%= data.policy.title %></h1>
|
||||
</div>
|
||||
<select class="form-select" id="blockTypeSelect" style="min-width: 180px">
|
||||
<option value="paragraph">Paragraph</option>
|
||||
<option value="heading">Heading</option>
|
||||
<option value="list">List</option>
|
||||
<option value="quote">Quote</option>
|
||||
<option value="callout">Callout</option>
|
||||
<option value="divider">Divider</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-primary" id="addBlockButton">
|
||||
<i class="fas fa-plus me-2"></i>Add block
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="toggleFullscreenButton" aria-pressed="false">
|
||||
<i class="fas fa-expand me-2" data-role="fullscreen-icon"></i>
|
||||
<span data-role="fullscreen-label">Full screen edit</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="validation-banner" id="validationBanner">
|
||||
<i class="fas fa-triangle-exclamation mt-1"></i>
|
||||
<div>
|
||||
<div class="fw-semibold">Validation needed before saving</div>
|
||||
<div id="validationSummary" class="small"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visual-editor">
|
||||
<div class="editor-scroll">
|
||||
<div class="drop-indicator" id="dropIndicator"></div>
|
||||
<div class="block-list" id="blockList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="preview-panel">
|
||||
<div class="panel-toolbar">
|
||||
<div>
|
||||
<h2 class="h6 mb-1">Realtime Preview</h2>
|
||||
<p class="text-muted small mb-0">Semantic rendering of the current content.</p>
|
||||
</div>
|
||||
<div class="preview-toolbar-actions">
|
||||
<div class="preview-mode-toggle" data-role="preview-mode-toggle">
|
||||
<button type="button" class="segment-button is-active" data-preview-mode="desktop">Desktop</button>
|
||||
<button type="button" class="segment-button" data-preview-mode="tablet">Tablet</button>
|
||||
<button type="button" class="segment-button" data-preview-mode="mobile">Mobile</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-frame">
|
||||
<div class="preview-device" id="previewDevice" data-mode="desktop">
|
||||
<div class="preview-canvas">
|
||||
<div class="mb-4">
|
||||
<div class="text-uppercase small fw-semibold text-muted mb-2"><%= data.policy.navLabel %></div>
|
||||
<h2 class="h3 mb-2"><%= data.policy.title %></h2>
|
||||
<% if (data.policy.effectiveDate) { %>
|
||||
<p class="text-muted mb-2"><%= data.policy.effectiveDate %></p>
|
||||
<% } %>
|
||||
<% if (data.policy.intro) { %>
|
||||
<p class="text-muted mb-0"><%= data.policy.intro %></p>
|
||||
<% } %>
|
||||
</div>
|
||||
<div id="previewContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="editor-footer">
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a href="/admin/policies?tab=policies" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-2"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button type="submit" class="btn btn-outline-primary" data-intent="save-back">
|
||||
<i class="fas fa-arrow-turn-left me-2"></i>Save & Back
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" data-intent="save">
|
||||
<i class="fas fa-save me-2"></i>Save Content
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="floating-toolbar" id="floatingToolbar">
|
||||
<div class="floating-toolbar-group">
|
||||
<button type="button" data-command="bold"><i class="fas fa-bold"></i></button>
|
||||
<button type="button" data-command="italic"><i class="fas fa-italic"></i></button>
|
||||
<button type="button" data-command="underline"><i class="fas fa-underline"></i></button>
|
||||
<button type="button" data-command="strikeThrough"><i class="fas fa-strikethrough"></i></button>
|
||||
<button type="button" data-command="toggleCode"><i class="fas fa-code"></i></button>
|
||||
<button type="button" data-command="toggleHighlight"><i class="fas fa-highlighter"></i></button>
|
||||
<button type="button" data-command="subscript"><i class="fas fa-subscript"></i></button>
|
||||
<button type="button" data-command="superscript"><i class="fas fa-superscript"></i></button>
|
||||
<input type="color" title="Text color" data-command="foreColor" value="#0f172a" />
|
||||
<input type="color" title="Background color" data-command="hiliteColor" value="#fef08a" />
|
||||
<button type="button" data-command="openLink"><i class="fas fa-link"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="link-popover" id="linkPopover">
|
||||
<div class="mb-2 fw-semibold">Edit link</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label text-white-50 small">URL</label>
|
||||
<input type="text" class="form-control" id="linkUrlInput" placeholder="https://example.com" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-white-50 small">Internal page</label>
|
||||
<select class="form-select" id="linkPolicySelect">
|
||||
<option value="">None</option>
|
||||
<% (editorConfig.policyOptions || []).forEach((option) => { %>
|
||||
<option value="<%= option.value %>"><%= option.label %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex gap-2 justify-content-end">
|
||||
<button type="button" class="btn btn-outline-light btn-sm" id="removeLinkButton">Remove</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="saveLinkButton">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slash-menu" id="slashMenu">
|
||||
<div class="slash-menu-list">
|
||||
<button type="button" data-slash-type="heading">Heading</button>
|
||||
<button type="button" data-slash-type="list">List</button>
|
||||
<button type="button" data-slash-type="divider">Divider</button>
|
||||
<button type="button" data-slash-type="quote">Quote</button>
|
||||
<button type="button" data-slash-type="link">Link</button>
|
||||
<button type="button" disabled>Image (Coming soon)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="application/json" id="policyBlockEditorDataPayload"><%- JSON.stringify(data) %></script>
|
||||
<script type="application/json" id="policyBlockEditorConfigData"><%- JSON.stringify(editorConfig) %></script>
|
||||
<script src="/js/policies-block-editor.js"></script>
|
||||
@@ -283,18 +283,22 @@
|
||||
|
||||
.fixed-bottom-buttons {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 1rem;
|
||||
bottom: 0;
|
||||
padding: 0.875rem 1.5rem;
|
||||
z-index: 1000;
|
||||
width: 25%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.08);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.fixed-bottom-buttons .btn {
|
||||
min-width: 120px;
|
||||
min-width: 136px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -303,7 +307,318 @@
|
||||
|
||||
/* Add padding to prevent content from being hidden behind fixed buttons */
|
||||
.content-with-fixed-buttons {
|
||||
padding-bottom: 80px;
|
||||
padding-bottom: 110px;
|
||||
}
|
||||
|
||||
.content-with-fixed-buttons .card {
|
||||
border: 1px solid rgba(148, 163, 184, 0.16) !important;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.04) !important;
|
||||
}
|
||||
|
||||
.content-with-fixed-buttons .card-header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
|
||||
padding: 0.875rem 1rem;
|
||||
}
|
||||
|
||||
.content-with-fixed-buttons .card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.content-with-fixed-buttons .form-control,
|
||||
.content-with-fixed-buttons .form-select {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.content-with-fixed-buttons .form-control:focus,
|
||||
.content-with-fixed-buttons .form-select:focus {
|
||||
border-color: rgba(184, 183, 106, 0.55);
|
||||
box-shadow: 0 0 0 0.2rem rgba(184, 183, 106, 0.12);
|
||||
}
|
||||
|
||||
.content-with-fixed-buttons .form-text {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.content-with-fixed-buttons .card-header-tabs {
|
||||
margin-bottom: -0.875rem;
|
||||
}
|
||||
|
||||
.content-with-fixed-buttons .nav-tabs {
|
||||
border-bottom: 0;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.content-with-fixed-buttons .nav-tabs .nav-link {
|
||||
border: 0;
|
||||
border-radius: 8px 8px 0 0;
|
||||
color: #64748b;
|
||||
padding: 0.75rem 0.95rem;
|
||||
}
|
||||
|
||||
.content-with-fixed-buttons .nav-tabs .nav-link.active {
|
||||
color: #0f172a;
|
||||
background: rgba(248, 250, 252, 0.96);
|
||||
box-shadow: inset 0 -2px 0 var(--primary-color);
|
||||
}
|
||||
|
||||
.cms-editor-group {
|
||||
background: #f8fafc;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.cms-item-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12) !important;
|
||||
border-radius: 12px;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.cms-item-card .card-header {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
padding: 0.75rem 0.875rem;
|
||||
}
|
||||
|
||||
.cms-item-card .card-body {
|
||||
background: transparent;
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
.cms-item-card.is-collapsed .card-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cms-item-card .drag-handle {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #94a3b8;
|
||||
padding: 0.25rem 0.375rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.cms-item-card .drag-handle:hover {
|
||||
color: #475569;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.cms-collapse-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.cms-collapse-toggle:hover {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.cms-collapse-toggle i {
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.cms-item-card.is-collapsed .cms-collapse-toggle i {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.cms-remove-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
padding: 0.25rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.cms-remove-button:hover {
|
||||
color: #ef4444;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cms-add-button {
|
||||
width: 100%;
|
||||
border: 1px dashed rgba(148, 163, 184, 0.55);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: #475569;
|
||||
padding: 0.8rem 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cms-add-button:hover {
|
||||
background: #fff;
|
||||
border-color: rgba(184, 183, 106, 0.65);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.field-meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.field-char-count {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
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 {
|
||||
@@ -720,9 +1035,19 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= currentPath === '/admin/about-us' ? 'active' : '' %>"
|
||||
href="/admin/about-us">About</a>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle <%= ['/admin/about-us','/admin/partnerships','/admin/history','/admin/accreditation','/admin/admissions','/admin/policies'].includes(currentPath) ? 'active' : '' %>"
|
||||
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
About
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item <%= currentPath === '/admin/about-us' ? 'active' : '' %>" href="/admin/about-us">About Us</a></li>
|
||||
<li><a class="dropdown-item <%= currentPath === '/admin/partnerships' ? 'active' : '' %>" href="/admin/partnerships">Partnerships</a></li>
|
||||
<li><a class="dropdown-item <%= currentPath === '/admin/history' ? 'active' : '' %>" href="/admin/history">History</a></li>
|
||||
<li><a class="dropdown-item <%= currentPath === '/admin/accreditation' ? 'active' : '' %>" href="/admin/accreditation">Accreditation</a></li>
|
||||
<li><a class="dropdown-item <%= currentPath === '/admin/admissions' ? 'active' : '' %>" href="/admin/admissions">Admissions</a></li>
|
||||
<li><a class="dropdown-item <%= currentPath === '/admin/policies' ? 'active' : '' %>" href="/admin/policies">Policies</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= currentPath === '/admin/blog' ? 'active' : '' %>" href="/admin/blog">Blog</a>
|
||||
@@ -1149,4 +1474,4 @@
|
||||
<%- script %>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||