Files
Tống Thành Đạt 2dd5769416 feat(admissions): implement tuition section in admin editor
Add functionality to manage tuition data within the admissions admin editor. This includes implementing state normalization for tuition series, label extraction, and the rendering logic for the tuition tab.
- Add `renderTuitionSection` and `normalizeTuitionState` logic
- Implement helper functions for tuition series and point normalization
- Integrate tuition tab routing in the editor script
2026-04-22 20:39:47 +07:00

1944 lines
67 KiB
Plaintext

<script>
(function () {
const config = window.pageEditorConfig;
const initialData = window.pageEditorData;
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
const admissionsUi = config?.editorUi || {};
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));
const persistedCalculatorOptionIds = new Set(
Array.isArray(initialData?.calculator?.options)
? initialData.calculator.options.map((item) => item.id).filter(Boolean)
: [],
);
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 = "";
if (tabKey === "keyDates") {
renderKeyDatesSection(container);
return;
}
if (tabKey === "tuition") {
renderTuitionSection(container);
return;
}
if (tabKey === "calculator") {
renderCalculatorSection(container);
return;
}
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
path: tab.schema.key,
root: state,
item: null,
});
}
function getTabConfig(tabKey) {
return (config.tabs || []).find((tab) => tab.key === tabKey) || {};
}
function getObjectFieldConfig(tabKey, fieldKey) {
const fields = getTabConfig(tabKey)?.schema?.fields || [];
return fields.find((field) => field.key === fieldKey) || {};
}
function getObjectListItemFieldConfig(tabKey, listKey, fieldKey) {
const listField = getObjectFieldConfig(tabKey, listKey);
const fields = listField?.itemSchema?.fields || [];
return fields.find((field) => field.key === fieldKey) || {};
}
function getNestedObjectListItemFieldConfig(tabKey, listKey, nestedListKey, fieldKey) {
const listField = getObjectFieldConfig(tabKey, listKey);
const itemFields = listField?.itemSchema?.fields || [];
const nestedListField = itemFields.find((field) => field.key === nestedListKey) || {};
const nestedItemFields = nestedListField?.itemSchema?.fields || [];
return nestedItemFields.find((field) => field.key === fieldKey) || {};
}
function renderKeyDatesSection(container) {
normalizeKeyDatesState();
const keyDates = state.keyDates;
const keyDatesUi = admissionsUi.keyDates || {};
const titleField = getObjectFieldConfig("keyDates", "title");
const row = document.createElement("div");
row.className = "row g-3";
container.appendChild(row);
renderLeafField(
{
key: "title",
label: titleField.label || "Section title",
type: titleField.type || "text",
maxLength: titleField.maxLength || 60,
helpText: titleField.helpText,
},
row,
keyDates,
"title",
{ path: "keyDates.title", root: state, item: keyDates },
);
const tableCol = createCol("col-12");
const card = document.createElement("div");
card.className = "cms-editor-group";
const header = document.createElement("div");
header.className = "d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3";
header.innerHTML = `
<div>
<label class="form-label fw-semibold mb-1">${escapeHtml(keyDatesUi.tableLabel || "Key dates table")}</label>
<div class="form-text mt-0">${escapeHtml(keyDatesUi.tableHelpText || "Manage the table directly by adding or removing columns and rows.")}</div>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" data-add-column="true">
<i class="fas fa-table-columns me-1"></i>${escapeHtml(keyDatesUi.addColumnLabel || "Add Column")}
</button>
<button type="button" class="btn btn-outline-primary btn-sm" data-add-row="true">
<i class="fas fa-plus me-1"></i>${escapeHtml(keyDatesUi.addRowLabel || "Add Row")}
</button>
</div>
`;
card.appendChild(header);
const tableWrap = document.createElement("div");
tableWrap.className = "table-responsive";
const table = document.createElement("table");
table.className = "table align-middle mb-0";
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
keyDates.columns.forEach((column, columnIndex) => {
const th = document.createElement("th");
th.style.minWidth = "220px";
const group = document.createElement("div");
group.className = "d-flex align-items-start gap-2";
const input = document.createElement("input");
input.type = "text";
input.className = "form-control";
input.maxLength = keyDatesUi.columnLabelMaxLength || 40;
input.placeholder = keyDatesUi.columnPlaceholder || "Column name";
input.value = column.label || "";
input.addEventListener("input", function () {
column.label = input.value;
});
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "cms-remove-button mt-1";
removeButton.title = "Remove column";
removeButton.innerHTML = '<i class="fas fa-trash-alt"></i>';
removeButton.disabled = keyDates.columns.length <= 1;
removeButton.addEventListener("click", function () {
if (keyDates.columns.length <= 1) {
return;
}
keyDates.columns.splice(columnIndex, 1);
keyDates.rows.forEach((rowItem) => {
rowItem.cells.splice(columnIndex, 1);
});
renderSection("keyDates");
});
group.appendChild(input);
group.appendChild(removeButton);
th.appendChild(group);
headRow.appendChild(th);
});
const actionHead = document.createElement("th");
actionHead.className = "text-end";
actionHead.style.width = "72px";
actionHead.textContent = keyDatesUi.actionsLabel || "Actions";
headRow.appendChild(actionHead);
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = document.createElement("tbody");
if (keyDates.rows.length === 0) {
const emptyRow = document.createElement("tr");
const emptyCell = document.createElement("td");
emptyCell.colSpan = keyDates.columns.length + 1;
emptyCell.className = "text-center text-muted py-4";
emptyCell.textContent = keyDatesUi.emptyRowsText || "No rows yet.";
emptyRow.appendChild(emptyCell);
tbody.appendChild(emptyRow);
} else {
keyDates.rows.forEach((rowItem, rowIndex) => {
const tr = document.createElement("tr");
keyDates.columns.forEach((column, columnIndex) => {
const td = document.createElement("td");
const input = document.createElement("input");
input.type = "text";
input.className = "form-control";
input.maxLength = keyDatesUi.cellMaxLength || 60;
input.placeholder = column.label || `Column ${columnIndex + 1}`;
input.value = rowItem.cells[columnIndex] || "";
input.addEventListener("input", function () {
rowItem.cells[columnIndex] = input.value;
});
td.appendChild(input);
tr.appendChild(td);
});
const actionCell = document.createElement("td");
actionCell.className = "text-end";
const removeRowButton = document.createElement("button");
removeRowButton.type = "button";
removeRowButton.className = "cms-remove-button";
removeRowButton.title = "Remove row";
removeRowButton.innerHTML = '<i class="fas fa-trash-alt"></i>';
removeRowButton.addEventListener("click", function () {
keyDates.rows.splice(rowIndex, 1);
renderSection("keyDates");
});
actionCell.appendChild(removeRowButton);
tr.appendChild(actionCell);
tbody.appendChild(tr);
});
}
table.appendChild(tbody);
tableWrap.appendChild(table);
card.appendChild(tableWrap);
tableCol.appendChild(card);
container.appendChild(tableCol);
header.querySelector('[data-add-column="true"]').addEventListener("click", function () {
addKeyDatesColumn();
renderSection("keyDates");
});
header.querySelector('[data-add-row="true"]').addEventListener("click", function () {
addKeyDatesRow();
renderSection("keyDates");
});
}
function normalizeKeyDatesState() {
if (!isObject(state.keyDates)) {
state.keyDates = {};
}
const keyDates = state.keyDates;
if (typeof keyDates.id !== "string") {
keyDates.id = "";
}
if (typeof keyDates.title !== "string") {
keyDates.title = "";
}
const legacyColumns = Array.isArray(keyDates.columns) ? keyDates.columns : [];
const legacyRows = Array.isArray(keyDates.rows) ? keyDates.rows : [];
keyDates.columns = normalizeKeyDatesColumns(legacyColumns, legacyRows);
keyDates.rows = normalizeKeyDatesRows(legacyRows, keyDates.columns);
}
function normalizeKeyDatesColumns(columns, rows) {
if (Array.isArray(columns) && columns.length > 0) {
return columns.map((column, index) => {
if (isObject(column)) {
return {
id: column.id || `column-${index + 1}`,
label: column.label || `Column ${index + 1}`,
};
}
return {
id: sanitizeId(column) || `column-${index + 1}`,
label: String(column || `Column ${index + 1}`),
};
});
}
const defaultLabels = extractLegacyKeyDateLabels(rows);
return defaultLabels.map((label, index) => ({
id: sanitizeId(label) || `column-${index + 1}`,
label,
}));
}
function normalizeKeyDatesRows(rows, columns) {
if (!Array.isArray(rows)) {
return [];
}
return rows.map((row, rowIndex) => {
if (isObject(row) && Array.isArray(row.cells)) {
return {
id: row.id || `row-${rowIndex + 1}`,
cells: columns.map((_, columnIndex) => String(row.cells[columnIndex] || "")),
};
}
return {
id: isObject(row) && row.id ? row.id : `row-${rowIndex + 1}`,
cells: columns.map((column, columnIndex) =>
extractLegacyKeyDateCell(row, column, columnIndex),
),
};
});
}
function extractLegacyKeyDateLabels(rows) {
const defaultLabels = ["Term", "Application Deadline", "Classes Start"];
if (!Array.isArray(rows) || rows.length === 0 || !isObject(rows[0])) {
return defaultLabels;
}
const row = rows[0];
if ("term" in row || "applicationDeadline" in row || "classesStart" in row) {
return defaultLabels;
}
const keys = Object.keys(row).filter((key) => key !== "id");
return keys.length ? keys : defaultLabels;
}
function extractLegacyKeyDateCell(row, column, columnIndex) {
if (!isObject(row)) {
return "";
}
const legacyKeys = ["term", "applicationDeadline", "classesStart"];
const legacyKey = legacyKeys[columnIndex];
if (legacyKey && typeof row[legacyKey] !== "undefined") {
return String(row[legacyKey] || "");
}
if (column && column.id && typeof row[column.id] !== "undefined") {
return String(row[column.id] || "");
}
return "";
}
function addKeyDatesColumn() {
normalizeKeyDatesState();
const keyDates = state.keyDates;
const nextIndex = keyDates.columns.length + 1;
keyDates.columns.push({
id: `column-${nextIndex}`,
label: `Column ${nextIndex}`,
});
keyDates.rows.forEach((row) => {
row.cells.push("");
});
}
function addKeyDatesRow() {
normalizeKeyDatesState();
const keyDates = state.keyDates;
keyDates.rows.push({
id: `row-${Date.now()}`,
cells: keyDates.columns.map(() => ""),
});
}
function normalizeTuitionState() {
if (!isObject(state.tuition)) {
state.tuition = {};
}
const tuition = state.tuition;
tuition.id = String(tuition.id || "");
tuition.title = String(tuition.title || "");
tuition.chartTitle = String(tuition.chartTitle || "");
tuition.chartDescription = String(tuition.chartDescription || "");
const rawSeries = Array.isArray(tuition.series) ? tuition.series : [];
const rawLabels = extractTuitionLabels(rawSeries);
tuition.series = rawSeries.length
? rawSeries.map((series, seriesIndex) => normalizeTuitionSeries(series, seriesIndex, rawLabels))
: [createDefaultTuitionSeries(0, rawLabels)];
const normalizedLabels = extractTuitionLabels(tuition.series);
tuition.series.forEach((series, seriesIndex) => {
series.points = normalizedLabels.map((label, pointIndex) => {
const existingPoint = Array.isArray(series.points) ? series.points[pointIndex] : null;
return {
time: label,
value: Number.isFinite(Number(existingPoint?.value))
? Number(existingPoint.value)
: 0,
};
});
if (!series.label) {
series.label = `Series ${seriesIndex + 1}`;
}
});
}
function normalizeTuitionSeries(series, seriesIndex, labels) {
const source = isObject(series) ? series : {};
const rawPoints = Array.isArray(source.points) ? source.points : [];
const effectiveLabels = labels.length ? labels : extractTuitionLabels([source]);
return {
label: String(source.label || `Series ${seriesIndex + 1}`),
color: String(source.color || defaultTuitionSeriesColor(seriesIndex)),
points: effectiveLabels.map((label, pointIndex) => {
const point = rawPoints[pointIndex];
return {
time: label,
value: Number.isFinite(Number(point?.value)) ? Number(point.value) : 0,
};
}),
};
}
function extractTuitionLabels(seriesList) {
const labels = [];
(Array.isArray(seriesList) ? seriesList : []).forEach((series) => {
(Array.isArray(series?.points) ? series.points : []).forEach((point, pointIndex) => {
const label = String(point?.time || point?.label || `Year ${pointIndex + 1}`).trim();
if (!labels.includes(label)) {
labels.push(label);
}
});
});
return labels.length ? labels : ["Year 1", "Year 2", "Year 3", "Year 4"];
}
function createDefaultTuitionSeries(seriesIndex, labels) {
const effectiveLabels = Array.isArray(labels) && labels.length
? labels
: ["Year 1", "Year 2", "Year 3", "Year 4"];
return {
label: `Series ${seriesIndex + 1}`,
color: defaultTuitionSeriesColor(seriesIndex),
points: effectiveLabels.map((label) => ({
time: label,
value: 0,
})),
};
}
function defaultTuitionSeriesColor(seriesIndex) {
return ["#0F172A", "#c49b27", "#2563eb", "#16a34a"][seriesIndex] || "#0F172A";
}
function renderTuitionSection(container) {
normalizeTuitionState();
const tuition = state.tuition;
const titleField = getObjectFieldConfig("tuition", "title");
const chartTitleField = getObjectFieldConfig("tuition", "chartTitle");
const chartDescriptionField = getObjectFieldConfig("tuition", "chartDescription");
const seriesField = getObjectFieldConfig("tuition", "series");
const seriesLabelField = getObjectListItemFieldConfig("tuition", "series", "label");
const pointTimeField = getNestedObjectListItemFieldConfig("tuition", "series", "points", "time");
const pointValueField = getNestedObjectListItemFieldConfig("tuition", "series", "points", "value");
const row = document.createElement("div");
row.className = "row g-3";
container.appendChild(row);
renderLeafField(
{
key: "title",
label: titleField.label || "Section title",
type: titleField.type || "text",
maxLength: titleField.maxLength || 45,
helpText: titleField.helpText,
},
row,
tuition,
"title",
{ path: "tuition.title", root: state, item: tuition },
);
renderLeafField(
{
key: "chartTitle",
label: chartTitleField.label || "Chart title",
type: chartTitleField.type || "text",
maxLength: chartTitleField.maxLength || 40,
helpText: chartTitleField.helpText,
},
row,
tuition,
"chartTitle",
{ path: "tuition.chartTitle", root: state, item: tuition },
);
renderLeafField(
{
key: "chartDescription",
label: chartDescriptionField.label || "Chart description",
type: chartDescriptionField.type || "textarea",
maxLength: chartDescriptionField.maxLength || 140,
rows: chartDescriptionField.rows || 3,
helpText: chartDescriptionField.helpText,
},
row,
tuition,
"chartDescription",
{ path: "tuition.chartDescription", root: state, item: tuition },
);
const matrixCol = createCol("col-12");
const matrixCard = document.createElement("div");
matrixCard.className = "cms-editor-group";
const matrixHeader = document.createElement("div");
matrixHeader.className = "d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3";
matrixHeader.innerHTML = `
<div>
<label class="form-label fw-semibold mb-1">${escapeHtml(seriesField.label || "Chart series")}</label>
<div class="form-text mt-0">${escapeHtml(seriesField.helpText || "Edit chart data as a matrix: each row is an X label and each series is a column.")}</div>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" data-add-series="true">
<i class="fas fa-chart-line me-1"></i>Add Series
</button>
<button type="button" class="btn btn-outline-primary btn-sm" data-add-row="true">
<i class="fas fa-plus me-1"></i>Add Row
</button>
</div>
`;
matrixCard.appendChild(matrixHeader);
const tableWrap = document.createElement("div");
tableWrap.className = "table-responsive";
const table = document.createElement("table");
table.className = "table align-middle mb-0";
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
const xLabelHead = document.createElement("th");
xLabelHead.style.minWidth = "220px";
xLabelHead.innerHTML = `
<div class="fw-semibold">${escapeHtml(pointTimeField.label || "X label")}</div>
<div class="small text-muted">${escapeHtml(pointTimeField.placeholder || "Example: Year 1")}</div>
`;
headRow.appendChild(xLabelHead);
tuition.series.forEach((series, seriesIndex) => {
const th = document.createElement("th");
th.style.minWidth = "220px";
const seriesWrap = document.createElement("div");
seriesWrap.className = "d-flex flex-column gap-2";
const topRow = document.createElement("div");
topRow.className = "d-flex align-items-center gap-2";
const labelInput = document.createElement("input");
labelInput.type = "text";
labelInput.className = "form-control form-control-sm";
labelInput.maxLength = seriesLabelField.maxLength || 20;
labelInput.placeholder = seriesLabelField.label || `Series ${seriesIndex + 1}`;
labelInput.value = series.label || "";
labelInput.addEventListener("input", function () {
series.label = labelInput.value;
});
const colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.className = "form-control form-control-color";
colorInput.value = series.color || defaultTuitionSeriesColor(seriesIndex);
colorInput.title = "Series color";
colorInput.addEventListener("input", function () {
series.color = colorInput.value;
});
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "cms-remove-button";
removeButton.title = "Remove series";
removeButton.innerHTML = '<i class="fas fa-trash-alt"></i>';
removeButton.disabled = tuition.series.length <= 1;
removeButton.addEventListener("click", function () {
if (tuition.series.length <= 1) {
return;
}
tuition.series.splice(seriesIndex, 1);
renderSection("tuition");
});
topRow.appendChild(labelInput);
topRow.appendChild(colorInput);
topRow.appendChild(removeButton);
const meta = document.createElement("div");
meta.className = "small text-muted";
meta.textContent = `${pointValueField.label || "Value"} column`;
seriesWrap.appendChild(topRow);
seriesWrap.appendChild(meta);
th.appendChild(seriesWrap);
headRow.appendChild(th);
});
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = document.createElement("tbody");
const labels = extractTuitionLabels(tuition.series);
if (!labels.length) {
const emptyRow = document.createElement("tr");
const emptyCell = document.createElement("td");
emptyCell.colSpan = tuition.series.length + 1;
emptyCell.className = "text-center text-muted py-4";
emptyCell.textContent = "No chart rows yet.";
emptyRow.appendChild(emptyCell);
tbody.appendChild(emptyRow);
} else {
labels.forEach((label, rowIndex) => {
const tr = document.createElement("tr");
const labelCell = document.createElement("td");
const labelGroup = document.createElement("div");
labelGroup.className = "d-flex align-items-start gap-2";
const labelInput = document.createElement("input");
labelInput.type = "text";
labelInput.className = "form-control";
labelInput.maxLength = pointTimeField.maxLength || 14;
labelInput.placeholder = pointTimeField.placeholder || `Year ${rowIndex + 1}`;
labelInput.value = label;
labelInput.addEventListener("input", function () {
const nextLabel = labelInput.value;
tuition.series.forEach((series) => {
if (!Array.isArray(series.points)) {
series.points = [];
}
if (!series.points[rowIndex]) {
series.points[rowIndex] = { time: nextLabel, value: 0 };
}
series.points[rowIndex].time = nextLabel;
});
});
const removeRowButton = document.createElement("button");
removeRowButton.type = "button";
removeRowButton.className = "cms-remove-button mt-1";
removeRowButton.title = "Remove row";
removeRowButton.innerHTML = '<i class="fas fa-trash-alt"></i>';
removeRowButton.disabled = labels.length <= 1;
removeRowButton.addEventListener("click", function () {
if (labels.length <= 1) {
return;
}
tuition.series.forEach((series) => {
if (Array.isArray(series.points)) {
series.points.splice(rowIndex, 1);
}
});
renderSection("tuition");
});
labelGroup.appendChild(labelInput);
labelGroup.appendChild(removeRowButton);
labelCell.appendChild(labelGroup);
tr.appendChild(labelCell);
tuition.series.forEach((series) => {
const td = document.createElement("td");
const input = document.createElement("input");
input.type = "number";
input.className = "form-control";
input.min = String(pointValueField.min || 0);
input.step = pointValueField.step || "1";
input.value = Number.isFinite(Number(series.points?.[rowIndex]?.value))
? String(series.points[rowIndex].value)
: "0";
input.addEventListener("input", function () {
if (!Array.isArray(series.points)) {
series.points = [];
}
if (!series.points[rowIndex]) {
series.points[rowIndex] = { time: labels[rowIndex], value: 0 };
}
series.points[rowIndex].value = Number(input.value || 0);
});
td.appendChild(input);
tr.appendChild(td);
});
tbody.appendChild(tr);
});
}
table.appendChild(tbody);
tableWrap.appendChild(table);
matrixCard.appendChild(tableWrap);
matrixHeader.querySelector('[data-add-series="true"]').addEventListener("click", function () {
const labels = extractTuitionLabels(tuition.series);
tuition.series.push(createDefaultTuitionSeries(tuition.series.length, labels));
renderSection("tuition");
});
matrixHeader.querySelector('[data-add-row="true"]').addEventListener("click", function () {
const nextLabel = `Year ${extractTuitionLabels(tuition.series).length + 1}`;
tuition.series.forEach((series) => {
if (!Array.isArray(series.points)) {
series.points = [];
}
series.points.push({
time: nextLabel,
value: 0,
});
});
renderSection("tuition");
});
const note = document.createElement("div");
note.className = "form-text mt-3";
note.textContent = "Each row is one X-axis label. Each series column stores the numeric value for that label.";
matrixCard.appendChild(note);
matrixCol.appendChild(matrixCard);
container.appendChild(matrixCol);
}
function normalizeCalculatorState() {
if (!isObject(state.calculator)) {
state.calculator = {};
}
const calculator = state.calculator;
calculator.title = String(calculator.title || "");
calculator.description = String(calculator.description || "");
calculator.cta = {
label: String(calculator?.cta?.label || "").slice(0, 15),
href: String(calculator?.cta?.href || ""),
};
const rawOptions = Array.isArray(calculator.options)
? calculator.options
: Array.isArray(calculator.modelOptions)
? calculator.modelOptions.map((label) => ({ label }))
: [];
const calculatorUi = admissionsUi.calculator || {};
const defaultOption = calculatorUi.defaultOption || {};
const optionLabelField = getObjectListItemFieldConfig("calculator", "options", "label");
calculator.options = rawOptions.slice(0, 3).map((option, index) => ({
id: String(option?.id || sanitizeId(option?.label || `option-${index + 1}`) || `option-${index + 1}`),
label: String(option?.label || `Option ${index + 1}`).slice(0, optionLabelField.maxLength || 12),
paceLabel: String(option?.paceLabel || defaultOption.paceLabel || "Target Pace"),
minPaceLabel: String(option?.minPaceLabel || defaultOption.minPaceLabel || "Relaxed"),
maxPaceLabel: String(option?.maxPaceLabel || defaultOption.maxPaceLabel || "Accelerated"),
resultLabel: String(option?.resultLabel || defaultOption.resultLabel || "Estimated Monthly Payment"),
monthlyAmount: (((String(option?.monthlyAmount || defaultOption.monthlyAmount || "299").match(/\d[\d,]*/) || [])[0] || String(defaultOption.monthlyAmount || "299")).replace(/,/g, "")),
monthlySuffix: String(option?.monthlySuffix || defaultOption.monthlySuffix || "/mo"),
noteIcon: String(option?.noteIcon || defaultOption.noteIcon || "fa-bolt"),
note: String(option?.note || defaultOption.note || ""),
}));
delete calculator.modelOptions;
delete calculator.paceLabel;
delete calculator.minPaceLabel;
delete calculator.maxPaceLabel;
delete calculator.resultLabel;
delete calculator.monthlyAmount;
delete calculator.monthlySuffix;
delete calculator.noteIcon;
delete calculator.note;
}
function renderCalculatorSection(container) {
normalizeCalculatorState();
const calculator = state.calculator;
const calculatorUi = admissionsUi.calculator || {};
const titleField = getObjectFieldConfig("calculator", "title");
const descriptionField = getObjectFieldConfig("calculator", "description");
const ctaField = getObjectFieldConfig("calculator", "cta");
const optionsField = getObjectFieldConfig("calculator", "options");
const ctaLabelField = (ctaField.fields || []).find((field) => field.key === "label") || {};
const ctaHrefField = (ctaField.fields || []).find((field) => field.key === "href") || {};
const optionLabelField = getObjectListItemFieldConfig("calculator", "options", "label");
const defaultOption = calculatorUi.defaultOption || {};
const maxOptions = Number(calculatorUi.maxOptions) || 3;
const row = document.createElement("div");
row.className = "row g-3";
container.appendChild(row);
renderLeafField(
{
key: "title",
label: titleField.label || "Card title",
type: titleField.type || "text",
maxLength: titleField.maxLength || 60,
helpText: titleField.helpText,
},
row,
calculator,
"title",
{ path: "calculator.title", root: state, item: calculator },
);
renderLeafField(
{
key: "description",
label: descriptionField.label || "Card description",
type: descriptionField.type || "textarea",
maxLength: descriptionField.maxLength || 120,
rows: descriptionField.rows || 3,
helpText: descriptionField.helpText,
},
row,
calculator,
"description",
{ path: "calculator.description", root: state, item: calculator },
);
const ctaCardCol = createCol("col-12");
const ctaCard = document.createElement("div");
ctaCard.className = "cms-editor-group";
const ctaHeader = document.createElement("div");
ctaHeader.className = "mb-3";
ctaHeader.innerHTML = `
<label class="form-label fw-semibold mb-1">${escapeHtml(calculatorUi.ctaLabel || ctaField.label || "Primary button")}</label>
<div class="form-text mt-0">${escapeHtml(calculatorUi.ctaHelpText || "This button appears at the bottom of the calculator card.")}</div>
`;
ctaCard.appendChild(ctaHeader);
const ctaRow = document.createElement("div");
ctaRow.className = "row g-3";
ctaCard.appendChild(ctaRow);
renderLeafField(
{
key: "label",
label: ctaLabelField.label || "Button label",
type: ctaLabelField.type || "text",
maxLength: ctaLabelField.maxLength || 15,
helpText: ctaLabelField.helpText,
},
ctaRow,
calculator.cta,
"label",
{ path: "calculator.cta.label", root: state, item: calculator.cta },
);
renderLeafField(
{
key: "href",
label: ctaHrefField.label || "Button URL",
type: ctaHrefField.type || "text",
maxLength: ctaHrefField.maxLength || 255,
helpText: ctaHrefField.helpText,
},
ctaRow,
calculator.cta,
"href",
{ path: "calculator.cta.href", root: state, item: calculator.cta },
);
ctaCardCol.appendChild(ctaCard);
container.appendChild(ctaCardCol);
const optionsCol = createCol("col-12");
const optionsCard = document.createElement("div");
optionsCard.className = "cms-editor-group";
const optionsHeader = document.createElement("div");
optionsHeader.className = "mb-3";
optionsHeader.innerHTML = `
<label class="form-label fw-semibold mb-1">${escapeHtml(optionsField.label || calculatorUi.optionsLabel || "Calculator options")}</label>
<div class="form-text mt-0">${escapeHtml(optionsField.helpText || calculatorUi.optionsHelpText || "Each option has its own pricing labels, amount, note, and icon. Open the edit page to update the option details.")}</div>
`;
optionsCard.appendChild(optionsHeader);
const list = document.createElement("div");
list.className = "d-flex flex-column gap-3";
optionsCard.appendChild(list);
if (!calculator.options.length) {
const empty = document.createElement("div");
empty.className = "text-muted small";
empty.textContent = optionsField.emptyText || calculatorUi.optionsEmptyText || "No calculator options yet.";
list.appendChild(empty);
} else {
calculator.options.forEach((option, index) => {
const item = document.createElement("div");
item.className = "card cms-item-card";
item.innerHTML = `
<div class="card-header d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div>
<div class="fw-semibold">${escapeHtml(option.label || `Option ${index + 1}`)}</div>
<div class="small text-muted">${escapeHtml(option.monthlyAmount)}${escapeHtml(option.monthlySuffix || "")}</div>
</div>
<div class="d-flex align-items-center gap-2">
${
persistedCalculatorOptionIds.has(option.id)
? `<a href="/admin/admissions/calculator/${encodeURIComponent(option.id)}" class="btn btn-outline-primary btn-sm"><i class="fas fa-pen me-1"></i>Edit option</a>`
: `<button type="button" class="btn btn-outline-secondary btn-sm" disabled><i class="fas fa-save me-1"></i>Save first</button>`
}
<button type="button" class="cms-remove-button" data-remove-option="${index}" title="Remove option">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
`;
item
.querySelector(`[data-remove-option="${index}"]`)
.addEventListener("click", function () {
calculator.options.splice(index, 1);
renderSection("calculator");
});
list.appendChild(item);
});
}
if (calculator.options.length < maxOptions) {
const addButton = document.createElement("button");
addButton.type = "button";
addButton.className = "cms-add-button mt-3";
addButton.innerHTML = `<i class="fas fa-plus me-2"></i>${escapeHtml(optionsField.addLabel || calculatorUi.addOptionLabel || "Add calculator option")}`;
addButton.addEventListener("click", function () {
const nextIndex = calculator.options.length + 1;
calculator.options.push({
id: `option-${Date.now()}`,
label: String(`Option ${nextIndex}`).slice(0, optionLabelField.maxLength || 12),
paceLabel: String(defaultOption.paceLabel || "Target Pace"),
minPaceLabel: String(defaultOption.minPaceLabel || "Relaxed"),
maxPaceLabel: String(defaultOption.maxPaceLabel || "Accelerated"),
resultLabel: String(defaultOption.resultLabel || "Estimated Monthly Payment"),
monthlyAmount: String(defaultOption.monthlyAmount || "299"),
monthlySuffix: String(defaultOption.monthlySuffix || "/mo"),
noteIcon: String(defaultOption.noteIcon || "fa-bolt"),
note: String(defaultOption.note || ""),
});
renderSection("calculator");
});
optionsCard.appendChild(addButton);
} else {
const limitNote = document.createElement("div");
limitNote.className = "form-text mt-3";
limitNote.textContent = calculatorUi.limitHelpText || `You can add up to ${maxOptions} calculator options.`;
optionsCard.appendChild(limitNote);
}
optionsCol.appendChild(optionsCard);
container.appendChild(optionsCol);
}
function renderField(schema, container, parent, key, tabKey, context) {
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
return;
}
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,
min: fieldSchema.min,
max: fieldSchema.max,
step: fieldSchema.step,
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 (typeof schema.min !== "undefined") input.min = String(schema.min);
if (typeof schema.max !== "undefined") input.max = String(schema.max);
if (schema.step) input.step = schema.step;
input.addEventListener("input", function () {
if (schema.type === "number") {
const normalizedValue = normalizeNumberInput(schema, input.value);
input.value = normalizedValue.displayValue;
parent[key] = normalizedValue.numericValue;
} else {
parent[key] = input.value;
}
updateCounter(counter, String(input.value || "").length, schema.maxLength);
});
input.addEventListener("change", function () {
if (schema.type !== "number") {
return;
}
const normalizedValue = normalizeNumberInput(schema, input.value);
input.value = normalizedValue.displayValue;
parent[key] = normalizedValue.numericValue;
});
col.appendChild(input);
const counter = appendHelp(col, schema, parent[key]);
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) {
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 = null;
if (schema.maxLength) {
counter = document.createElement("div");
counter.className = "field-char-count";
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 normalizeNumberInput(schema, rawValue) {
if (rawValue === "" || rawValue === null || typeof rawValue === "undefined") {
const emptyFallback =
typeof schema.min !== "undefined" ? Number(schema.min) : 0;
return {
numericValue: emptyFallback,
displayValue: String(emptyFallback),
};
}
let numericValue = Number(rawValue);
if (!Number.isFinite(numericValue)) {
numericValue = typeof schema.min !== "undefined" ? Number(schema.min) : 0;
}
if (typeof schema.min !== "undefined" && numericValue < Number(schema.min)) {
numericValue = Number(schema.min);
}
if (typeof schema.max !== "undefined" && numericValue > Number(schema.max)) {
numericValue = Number(schema.max);
}
return {
numericValue,
displayValue: String(numericValue),
};
}
function openImagePicker(imageType, onSuccess) {
const fileInput = document.createElement("input");
fileInput.type = "file";
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
})();
</script>