forked from UKSOURCE/cms.lams
feat(cms): enhance content editors with dynamic UI configs and validation
Implement a more flexible, configuration-driven approach for CMS editors across accreditation, admissions, and partnerships modules. - Move UI labels, help texts, and field limits from hardcoded views to `editorUi` configurations in config files. - Add server-side and client-side validation to prevent duplicate category tabs in accreditation and partnerships editors. - Refactor partnership and admission views to dynamically render tabs and fields based on the provided configuration. - Update field length constraints and default values across multiple content editors to better align with frontend requirements. - Improve the admissions calculator editor with dynamic field configurations and default value fallbacks.
This commit is contained in:
@@ -9,6 +9,34 @@ const controller = createPageContentController({
|
|||||||
modelName: "AccreditationPage",
|
modelName: "AccreditationPage",
|
||||||
auditAction: AUDIT_ACTIONS.UPDATE_ACCREDITATION,
|
auditAction: AUDIT_ACTIONS.UPDATE_ACCREDITATION,
|
||||||
editorConfig: accreditationConfig,
|
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) {
|
controller.index = async function index(req, res) {
|
||||||
|
|||||||
@@ -9,6 +9,38 @@ const diffObject = require("../audit/diffObject");
|
|||||||
const { ensureUniqueIds } = require("../utils/contentEditorIds");
|
const { ensureUniqueIds } = require("../utils/contentEditorIds");
|
||||||
const { ICON_OPTIONS } = require("../utils/contentEditors/sharedFields");
|
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") {
|
function normalizePositiveAmount(value, fallback = "1") {
|
||||||
const match = String(value || "").match(/\d[\d,]*/);
|
const match = String(value || "").match(/\d[\d,]*/);
|
||||||
const numericValue = Number((match ? match[0] : "").replace(/,/g, ""));
|
const numericValue = Number((match ? match[0] : "").replace(/,/g, ""));
|
||||||
@@ -17,18 +49,25 @@ function normalizePositiveAmount(value, fallback = "1") {
|
|||||||
|
|
||||||
function normalizeCalculatorOption(option, index, calculator) {
|
function normalizeCalculatorOption(option, index, calculator) {
|
||||||
const source = typeof option === "string" ? { label: option } : { ...(option || {}) };
|
const source = typeof option === "string" ? { label: option } : { ...(option || {}) };
|
||||||
|
const defaultOption = getDefaultCalculatorOptionValues();
|
||||||
|
const labelMaxLength = getCalculatorOptionFieldConfig("label").maxLength || 12;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...source,
|
...source,
|
||||||
label: String(source.label || source.title || `Option ${index + 1}`).slice(0, 12),
|
label: String(source.label || source.title || `Option ${index + 1}`).slice(0, labelMaxLength),
|
||||||
paceLabel: String(source.paceLabel || calculator.paceLabel || "Target Pace"),
|
paceLabel: String(source.paceLabel || calculator.paceLabel || defaultOption.paceLabel || "Target Pace"),
|
||||||
minPaceLabel: String(source.minPaceLabel || calculator.minPaceLabel || "Relaxed"),
|
minPaceLabel: String(source.minPaceLabel || calculator.minPaceLabel || defaultOption.minPaceLabel || "Relaxed"),
|
||||||
maxPaceLabel: String(source.maxPaceLabel || calculator.maxPaceLabel || "Accelerated"),
|
maxPaceLabel: String(source.maxPaceLabel || calculator.maxPaceLabel || defaultOption.maxPaceLabel || "Accelerated"),
|
||||||
resultLabel: String(source.resultLabel || calculator.resultLabel || "Estimated Monthly Payment"),
|
resultLabel: String(
|
||||||
monthlyAmount: normalizePositiveAmount(source.monthlyAmount || calculator.monthlyAmount || "299", "299"),
|
source.resultLabel || calculator.resultLabel || defaultOption.resultLabel || "Estimated Monthly Payment",
|
||||||
monthlySuffix: String(source.monthlySuffix || calculator.monthlySuffix || "/mo"),
|
),
|
||||||
noteIcon: String(source.noteIcon || calculator.noteIcon || "fa-bolt"),
|
monthlyAmount: normalizePositiveAmount(
|
||||||
note: String(source.note || calculator.note || ""),
|
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 || ""),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,12 +213,41 @@ controller.editCalculatorOption = async function editCalculatorOption(req, res)
|
|||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||||
const backendUrl =
|
const backendUrl =
|
||||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||||
|
const 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", {
|
return res.render("admin/admissions/calculator-option", {
|
||||||
layout: "layouts/main",
|
layout: "layouts/main",
|
||||||
title: `Edit ${option.label}`,
|
title: `Edit ${option.label}`,
|
||||||
subtitle: "Update the calculator option details",
|
subtitle: "Update the calculator option details",
|
||||||
option,
|
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,
|
iconOptions: ICON_OPTIONS,
|
||||||
editorConfig: admissionsConfig,
|
editorConfig: admissionsConfig,
|
||||||
previewUrl: `${frontendUrl}${admissionsConfig.previewPath}`,
|
previewUrl: `${frontendUrl}${admissionsConfig.previewPath}`,
|
||||||
@@ -207,9 +275,15 @@ controller.updateCalculatorOption = async function updateCalculatorOption(req, r
|
|||||||
return req.session.save(() => res.redirect("/admin/admissions?tab=calculator"));
|
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] = {
|
||||||
...payload.calculator.options[optionIndex],
|
...payload.calculator.options[optionIndex],
|
||||||
label: String(req.body.label || "").trim().slice(0, 12),
|
label: nextLabel,
|
||||||
paceLabel: String(req.body.paceLabel || "").trim(),
|
paceLabel: String(req.body.paceLabel || "").trim(),
|
||||||
minPaceLabel: String(req.body.minPaceLabel || "").trim(),
|
minPaceLabel: String(req.body.minPaceLabel || "").trim(),
|
||||||
maxPaceLabel: String(req.body.maxPaceLabel || "").trim(),
|
maxPaceLabel: String(req.body.maxPaceLabel || "").trim(),
|
||||||
|
|||||||
@@ -2,9 +2,71 @@ const PartnershipsPage = require("../models/partnerships");
|
|||||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||||
const partnershipsConfig = require("../utils/contentEditors/partnershipsConfig");
|
const partnershipsConfig = require("../utils/contentEditors/partnershipsConfig");
|
||||||
const createPageContentController = require("./_createPageContentController");
|
const createPageContentController = require("./_createPageContentController");
|
||||||
const createRenderSingletonPageView = require("./_renderSingletonPageView");
|
|
||||||
const { ensureUniqueIds } = require("../utils/contentEditorIds");
|
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) {
|
function toInquiryField(id, field, type, width) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -53,9 +115,34 @@ function prepareInquiryPayload(payload) {
|
|||||||
const fields = Array.isArray(normalized?.inquiryForm?.fields)
|
const fields = Array.isArray(normalized?.inquiryForm?.fields)
|
||||||
? normalized.inquiryForm.fields
|
? normalized.inquiryForm.fields
|
||||||
: [];
|
: [];
|
||||||
|
const tabs = Array.isArray(normalized?.directory?.tabs)
|
||||||
|
? normalized.directory.tabs
|
||||||
|
: [];
|
||||||
const partners = Array.isArray(normalized?.directory?.partners)
|
const partners = Array.isArray(normalized?.directory?.partners)
|
||||||
? 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 {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
@@ -100,12 +187,32 @@ const controller = createPageContentController({
|
|||||||
|
|
||||||
controller.index = async function index(req, res) {
|
controller.index = async function index(req, res) {
|
||||||
try {
|
try {
|
||||||
return await createRenderSingletonPageView({
|
const doc = await PartnershipsPage.getSingle();
|
||||||
model: PartnershipsPage,
|
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,
|
editorConfig: partnershipsConfig,
|
||||||
view: "admin/partnerships/index",
|
editorUi: getPartnershipsEditorUi(),
|
||||||
normalizeForEditor: normalizeInquiryForm,
|
activeTab,
|
||||||
})(req, res);
|
frontendUrl,
|
||||||
|
backendUrl,
|
||||||
|
previewUrl: `${frontendUrl}${partnershipsConfig.previewPath}`,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("partnerships index error:", error);
|
console.error("partnerships index error:", error);
|
||||||
req.flash("error_msg", "Error loading Partnerships Management");
|
req.flash("error_msg", "Error loading Partnerships Management");
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ module.exports = {
|
|||||||
label: "Hero",
|
label: "Hero",
|
||||||
icon: "fas fa-image",
|
icon: "fas fa-image",
|
||||||
schema: object("hero", "Hero", [
|
schema: object("hero", "Hero", [
|
||||||
text("badge", "Eyebrow label", { maxLength: 40 }),
|
text("badge", "Eyebrow label", { maxLength: 30 }),
|
||||||
text("title", "Headline", { maxLength: 60 }),
|
text("title", "Headline", { maxLength: 60 }),
|
||||||
textarea("description", "Supporting text", { maxLength: 420, rows: 5 }),
|
textarea("description", "Supporting text", { maxLength: 420, rows: 5 }),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -20,6 +20,39 @@ module.exports = {
|
|||||||
previewPath: "/admissions",
|
previewPath: "/admissions",
|
||||||
dataFile: "admissions",
|
dataFile: "admissions",
|
||||||
imageType: "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: [
|
tabs: [
|
||||||
{
|
{
|
||||||
key: "hero",
|
key: "hero",
|
||||||
@@ -102,8 +135,8 @@ module.exports = {
|
|||||||
label: "Tuition",
|
label: "Tuition",
|
||||||
icon: "fas fa-chart-column",
|
icon: "fas fa-chart-column",
|
||||||
schema: object("tuition", "Tuition", [
|
schema: object("tuition", "Tuition", [
|
||||||
text("title", "Section title", { maxLength: 60 }),
|
text("title", "Section title", { maxLength: 45 }),
|
||||||
text("chartTitle", "Chart title", { maxLength: 60 }),
|
text("chartTitle", "Chart title", { maxLength: 40 }),
|
||||||
textarea("chartDescription", "Chart description", {
|
textarea("chartDescription", "Chart description", {
|
||||||
maxLength: 140,
|
maxLength: 140,
|
||||||
rows: 3,
|
rows: 3,
|
||||||
@@ -112,14 +145,14 @@ module.exports = {
|
|||||||
"series",
|
"series",
|
||||||
"Chart series",
|
"Chart series",
|
||||||
[
|
[
|
||||||
text("label", "Series label", { maxLength: 40 }),
|
text("label", "Series label", { maxLength: 20 }),
|
||||||
{ key: "color", label: "Series color", type: "color" },
|
{ key: "color", label: "Series color", type: "color" },
|
||||||
objectList(
|
objectList(
|
||||||
"points",
|
"points",
|
||||||
"Data points",
|
"Data points",
|
||||||
[
|
[
|
||||||
text("time", "Time label", {
|
text("time", "Time label", {
|
||||||
maxLength: 30,
|
maxLength: 14,
|
||||||
placeholder: "Year 1",
|
placeholder: "Year 1",
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -170,18 +203,44 @@ module.exports = {
|
|||||||
objectList(
|
objectList(
|
||||||
"options",
|
"options",
|
||||||
"Calculator options",
|
"Calculator options",
|
||||||
[
|
[
|
||||||
hidden("id"),
|
hidden("id"),
|
||||||
text("label", "Option label", { maxLength: 12 }),
|
text("label", "Option label", {
|
||||||
text("paceLabel", "Pace label", { maxLength: 30 }),
|
maxLength: 12,
|
||||||
text("minPaceLabel", "Minimum pace label", { maxLength: 20 }),
|
helpText: "This label appears in the calculator option switcher.",
|
||||||
text("maxPaceLabel", "Maximum pace label", { maxLength: 20 }),
|
}),
|
||||||
text("resultLabel", "Result label", { maxLength: 40 }),
|
text("paceLabel", "Pace label", {
|
||||||
text("monthlyAmount", "Monthly amount", { maxLength: 20 }),
|
maxLength: 20,
|
||||||
text("monthlySuffix", "Monthly suffix", { maxLength: 10 }),
|
helpText: "This appears above the pace slider.",
|
||||||
icon("noteIcon", "Note icon"),
|
}),
|
||||||
text("note", "Note text", { maxLength: 60 }),
|
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",
|
itemLabel: "Option",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
@@ -210,8 +269,8 @@ module.exports = {
|
|||||||
"items",
|
"items",
|
||||||
"Scholarship items",
|
"Scholarship items",
|
||||||
[
|
[
|
||||||
text("title", "Title", { maxLength: 50 }),
|
text("title", "Title", { maxLength: 40 }),
|
||||||
text("amount", "Amount", { maxLength: 24 }),
|
text("amount", "Amount", { maxLength: 12 }),
|
||||||
textarea("description", "Description", {
|
textarea("description", "Description", {
|
||||||
maxLength: 160,
|
maxLength: 160,
|
||||||
rows: 3,
|
rows: 3,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ module.exports = {
|
|||||||
"Milestones",
|
"Milestones",
|
||||||
[
|
[
|
||||||
text("year", "Year", {
|
text("year", "Year", {
|
||||||
maxLength: 30,
|
maxLength: 4,
|
||||||
}),
|
}),
|
||||||
combobox("yearRange", "Year range", {
|
combobox("yearRange", "Year range", {
|
||||||
maxLength: 30,
|
maxLength: 30,
|
||||||
@@ -83,7 +83,7 @@ module.exports = {
|
|||||||
maxLength: 40,
|
maxLength: 40,
|
||||||
optionsPath: "filters.categoryOptions",
|
optionsPath: "filters.categoryOptions",
|
||||||
}),
|
}),
|
||||||
text("categoryLabel", "Category badge label", { maxLength: 30 }),
|
text("categoryLabel", "Category badge label", { maxLength: 25 }),
|
||||||
text("title", "Milestone title", { maxLength: 90 }),
|
text("title", "Milestone title", { maxLength: 90 }),
|
||||||
textarea("description", "Description", {
|
textarea("description", "Description", {
|
||||||
maxLength: 260,
|
maxLength: 260,
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ module.exports = {
|
|||||||
previewPath: "/about/partnerships",
|
previewPath: "/about/partnerships",
|
||||||
dataFile: "partnerships",
|
dataFile: "partnerships",
|
||||||
imageType: "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: [
|
tabs: [
|
||||||
{
|
{
|
||||||
key: "hero",
|
key: "hero",
|
||||||
@@ -115,7 +124,7 @@ module.exports = {
|
|||||||
label: "Inquiry Form",
|
label: "Inquiry Form",
|
||||||
icon: "fas fa-envelope",
|
icon: "fas fa-envelope",
|
||||||
schema: object("inquiryForm", "Inquiry form", [
|
schema: object("inquiryForm", "Inquiry form", [
|
||||||
text("title", "Modal title", { maxLength: 60 }),
|
text("title", "Modal title", { maxLength: 35 }),
|
||||||
objectList(
|
objectList(
|
||||||
"fields",
|
"fields",
|
||||||
"Form fields",
|
"Form fields",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const baseConfig = {
|
|||||||
schema: object("sidebar", "Sidebar", [
|
schema: object("sidebar", "Sidebar", [
|
||||||
text("heading", "Sidebar heading", { maxLength: 30 }),
|
text("heading", "Sidebar heading", { maxLength: 30 }),
|
||||||
text("helperText", "Helper text", { maxLength: 60 }),
|
text("helperText", "Helper text", { maxLength: 60 }),
|
||||||
text("contactLabel", "Contact link label", { maxLength: 40 }),
|
text("contactLabel", "Contact link label", { maxLength: 25 }),
|
||||||
url("contactHref", "Contact URL", { maxLength: 255 }),
|
url("contactHref", "Contact URL", { maxLength: 255 }),
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ const variantList = (key, label, variants, options = {}) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const linkFields = (prefix = "Link") => [
|
const linkFields = (prefix = "Link") => [
|
||||||
text("label", `${prefix} label`, { maxLength: 60 }),
|
text("label", `${prefix} label`, { maxLength: 40 }),
|
||||||
url("href", `${prefix} URL`, { maxLength: 255 }),
|
url("href", `${prefix} URL`, { maxLength: 255 }),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,22 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
form.addEventListener("submit", function () {
|
form.addEventListener("submit", function (event) {
|
||||||
|
const duplicateTabs = getDuplicateTabs(state?.grid?.tabs);
|
||||||
|
|
||||||
|
clearCategoryTabsValidation();
|
||||||
|
|
||||||
|
if (duplicateTabs.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
highlightDuplicateCategoryTabs(duplicateTabs);
|
||||||
|
showToast(
|
||||||
|
"Duplicate category tab",
|
||||||
|
`Category tab "${duplicateTabs[0]}" already exists. Please use unique tab names before saving.`,
|
||||||
|
"danger",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
pageJsonInput.value = JSON.stringify(state);
|
pageJsonInput.value = JSON.stringify(state);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,6 +67,62 @@
|
|||||||
config.tabs.forEach((tab) => renderSection(tab.key));
|
config.tabs.forEach((tab) => renderSection(tab.key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 clearCategoryTabsValidation() {
|
||||||
|
const tabsList = document.querySelector(
|
||||||
|
'[data-section-key="grid"] .page-editor-array-list',
|
||||||
|
);
|
||||||
|
|
||||||
|
tabsList
|
||||||
|
?.querySelectorAll(".is-invalid")
|
||||||
|
.forEach((element) => element.classList.remove("is-invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightDuplicateCategoryTabs(duplicateTabs) {
|
||||||
|
const tabsList = document.querySelector(
|
||||||
|
'[data-section-key="grid"] .page-editor-array-list',
|
||||||
|
);
|
||||||
|
const normalizedDuplicates = new Set(
|
||||||
|
duplicateTabs.map((tab) => String(tab || "").trim().toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const duplicateInputs = Array.from(
|
||||||
|
tabsList?.querySelectorAll(".form-control") || [],
|
||||||
|
).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 updateTabUrl(tabKey) {
|
function updateTabUrl(tabKey) {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set("tab", tabKey);
|
url.searchParams.set("tab", tabKey);
|
||||||
|
|||||||
@@ -24,69 +24,69 @@
|
|||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="label" class="form-label fw-semibold">Option label</label>
|
<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="12" value="<%= option.label %>" required />
|
<input id="label" name="label" type="text" class="form-control" maxlength="<%= fieldLimits.label %>" value="<%= option.label %>" required />
|
||||||
<div class="field-meta-row">
|
<div class="field-meta-row">
|
||||||
<div class="form-text">This label appears in the calculator option switcher.</div>
|
<div class="form-text"><%= fieldConfig.label?.helpText || "" %></div>
|
||||||
<div class="field-char-count" data-counter-for="label">0/12</div>
|
<div class="field-char-count" data-counter-for="label">0/<%= fieldLimits.label %></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="paceLabel" class="form-label fw-semibold">Pace label</label>
|
<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="30" value="<%= option.paceLabel %>" required />
|
<input id="paceLabel" name="paceLabel" type="text" class="form-control" maxlength="<%= fieldLimits.paceLabel %>" value="<%= option.paceLabel %>" required />
|
||||||
<div class="field-meta-row">
|
<div class="field-meta-row">
|
||||||
<div class="form-text">This appears above the pace slider.</div>
|
<div class="form-text"><%= fieldConfig.paceLabel?.helpText || "" %></div>
|
||||||
<div class="field-char-count" data-counter-for="paceLabel">0/30</div>
|
<div class="field-char-count" data-counter-for="paceLabel">0/<%= fieldLimits.paceLabel %></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="minPaceLabel" class="form-label fw-semibold">Minimum pace label</label>
|
<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="20" value="<%= option.minPaceLabel %>" required />
|
<input id="minPaceLabel" name="minPaceLabel" type="text" class="form-control" maxlength="<%= fieldLimits.minPaceLabel %>" value="<%= option.minPaceLabel %>" required />
|
||||||
<div class="field-meta-row">
|
<div class="field-meta-row">
|
||||||
<div class="form-text">This appears on the left side of the pace slider.</div>
|
<div class="form-text"><%= fieldConfig.minPaceLabel?.helpText || "" %></div>
|
||||||
<div class="field-char-count" data-counter-for="minPaceLabel">0/20</div>
|
<div class="field-char-count" data-counter-for="minPaceLabel">0/<%= fieldLimits.minPaceLabel %></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="maxPaceLabel" class="form-label fw-semibold">Maximum pace label</label>
|
<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="20" value="<%= option.maxPaceLabel %>" required />
|
<input id="maxPaceLabel" name="maxPaceLabel" type="text" class="form-control" maxlength="<%= fieldLimits.maxPaceLabel %>" value="<%= option.maxPaceLabel %>" required />
|
||||||
<div class="field-meta-row">
|
<div class="field-meta-row">
|
||||||
<div class="form-text">This appears on the right side of the pace slider.</div>
|
<div class="form-text"><%= fieldConfig.maxPaceLabel?.helpText || "" %></div>
|
||||||
<div class="field-char-count" data-counter-for="maxPaceLabel">0/20</div>
|
<div class="field-char-count" data-counter-for="maxPaceLabel">0/<%= fieldLimits.maxPaceLabel %></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="resultLabel" class="form-label fw-semibold">Result label</label>
|
<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="40" value="<%= option.resultLabel %>" required />
|
<input id="resultLabel" name="resultLabel" type="text" class="form-control" maxlength="<%= fieldLimits.resultLabel %>" value="<%= option.resultLabel %>" required />
|
||||||
<div class="field-meta-row">
|
<div class="field-meta-row">
|
||||||
<div class="form-text">This label appears above the calculated amount.</div>
|
<div class="form-text"><%= fieldConfig.resultLabel?.helpText || "" %></div>
|
||||||
<div class="field-char-count" data-counter-for="resultLabel">0/40</div>
|
<div class="field-char-count" data-counter-for="resultLabel">0/<%= fieldLimits.resultLabel %></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="monthlyAmount" class="form-label fw-semibold">Monthly amount</label>
|
<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 />
|
<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="field-meta-row">
|
||||||
<div class="form-text">Enter digits only. The currency symbol is added on the website automatically.</div>
|
<div class="form-text"><%= fieldConfig.monthlyAmount?.helpText || "" %></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="monthlySuffix" class="form-label fw-semibold">Monthly suffix</label>
|
<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="10" value="<%= option.monthlySuffix %>" required />
|
<input id="monthlySuffix" name="monthlySuffix" type="text" class="form-control" maxlength="<%= fieldLimits.monthlySuffix %>" value="<%= option.monthlySuffix %>" required />
|
||||||
<div class="field-meta-row">
|
<div class="field-meta-row">
|
||||||
<div class="form-text">Example: /mo</div>
|
<div class="form-text"><%= fieldConfig.monthlySuffix?.helpText || "" %></div>
|
||||||
<div class="field-char-count" data-counter-for="monthlySuffix">0/10</div>
|
<div class="field-char-count" data-counter-for="monthlySuffix">0/<%= fieldLimits.monthlySuffix %></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">Note icon</label>
|
<label class="form-label fw-semibold"><%= fieldConfig.noteIcon?.label || "Note icon" %></label>
|
||||||
<div class="cms-icon-combobox" id="noteIconCombobox">
|
<div class="cms-icon-combobox" id="noteIconCombobox">
|
||||||
<input type="hidden" name="noteIcon" id="noteIcon" value="<%= option.noteIcon %>" />
|
<input type="hidden" name="noteIcon" id="noteIcon" value="<%= option.noteIcon %>" />
|
||||||
<button type="button" class="cms-icon-dropdown-trigger" id="noteIconTrigger">
|
<button type="button" class="cms-icon-dropdown-trigger" id="noteIconTrigger">
|
||||||
@@ -103,16 +103,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-meta-row">
|
<div class="field-meta-row">
|
||||||
<div class="form-text">Choose the icon shown beside the note.</div>
|
<div class="form-text"><%= fieldConfig.noteIcon?.helpText || "" %></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label for="note" class="form-label fw-semibold">Note text</label>
|
<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="60" value="<%= option.note %>" required />
|
<input id="note" name="note" type="text" class="form-control" maxlength="<%= fieldLimits.note %>" value="<%= option.note %>" required />
|
||||||
<div class="field-meta-row">
|
<div class="field-meta-row">
|
||||||
<div class="form-text">This short note appears under the amount.</div>
|
<div class="form-text"><%= fieldConfig.note?.helpText || "" %></div>
|
||||||
<div class="field-char-count" data-counter-for="note">0/60</div>
|
<div class="field-char-count" data-counter-for="note">0/<%= fieldLimits.note %></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +137,9 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const iconOptions = <%- JSON.stringify(iconOptions) %>;
|
const iconOptions = <%- JSON.stringify(iconOptions) %>;
|
||||||
|
const existingOptionLabels = <%- JSON.stringify(existingOptionLabels || []) %>;
|
||||||
const form = document.getElementById("calculatorOptionForm");
|
const form = document.getElementById("calculatorOptionForm");
|
||||||
|
const labelInput = document.getElementById("label");
|
||||||
const hiddenInput = document.getElementById("noteIcon");
|
const hiddenInput = document.getElementById("noteIcon");
|
||||||
const combobox = document.getElementById("noteIconCombobox");
|
const combobox = document.getElementById("noteIconCombobox");
|
||||||
const trigger = document.getElementById("noteIconTrigger");
|
const trigger = document.getElementById("noteIconTrigger");
|
||||||
@@ -159,6 +161,43 @@
|
|||||||
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");
|
const monthlyAmountInput = document.getElementById("monthlyAmount");
|
||||||
monthlyAmountInput.addEventListener("input", function () {
|
monthlyAmountInput.addEventListener("input", function () {
|
||||||
this.value = this.value.replace(/[^\d]/g, "");
|
this.value = this.value.replace(/[^\d]/g, "");
|
||||||
@@ -240,5 +279,28 @@
|
|||||||
.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>
|
</script>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
const config = window.pageEditorConfig;
|
const config = window.pageEditorConfig;
|
||||||
const initialData = window.pageEditorData;
|
const initialData = window.pageEditorData;
|
||||||
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
|
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
|
||||||
|
const admissionsUi = config?.editorUi || {};
|
||||||
const form = document.getElementById("cmsEditorForm");
|
const form = document.getElementById("cmsEditorForm");
|
||||||
const pageJsonInput = document.getElementById("pageJson");
|
const pageJsonInput = document.getElementById("pageJson");
|
||||||
const activeTabInput = document.getElementById("activeTabInput");
|
const activeTabInput = document.getElementById("activeTabInput");
|
||||||
@@ -94,16 +95,39 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 renderKeyDatesSection(container) {
|
function renderKeyDatesSection(container) {
|
||||||
normalizeKeyDatesState();
|
normalizeKeyDatesState();
|
||||||
|
|
||||||
const keyDates = state.keyDates;
|
const keyDates = state.keyDates;
|
||||||
|
const keyDatesUi = admissionsUi.keyDates || {};
|
||||||
|
const titleField = getObjectFieldConfig("keyDates", "title");
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "row g-3";
|
row.className = "row g-3";
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
|
|
||||||
renderLeafField(
|
renderLeafField(
|
||||||
{ key: "title", label: "Section title", type: "text", maxLength: 60 },
|
{
|
||||||
|
key: "title",
|
||||||
|
label: titleField.label || "Section title",
|
||||||
|
type: titleField.type || "text",
|
||||||
|
maxLength: titleField.maxLength || 60,
|
||||||
|
helpText: titleField.helpText,
|
||||||
|
},
|
||||||
row,
|
row,
|
||||||
keyDates,
|
keyDates,
|
||||||
"title",
|
"title",
|
||||||
@@ -118,15 +142,15 @@
|
|||||||
header.className = "d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3";
|
header.className = "d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3";
|
||||||
header.innerHTML = `
|
header.innerHTML = `
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label fw-semibold mb-1">Key dates table</label>
|
<label class="form-label fw-semibold mb-1">${escapeHtml(keyDatesUi.tableLabel || "Key dates table")}</label>
|
||||||
<div class="form-text mt-0">Manage the table directly by adding or removing columns and rows.</div>
|
<div class="form-text mt-0">${escapeHtml(keyDatesUi.tableHelpText || "Manage the table directly by adding or removing columns and rows.")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-add-column="true">
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-add-column="true">
|
||||||
<i class="fas fa-table-columns me-1"></i>Add Column
|
<i class="fas fa-table-columns me-1"></i>${escapeHtml(keyDatesUi.addColumnLabel || "Add Column")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm" data-add-row="true">
|
<button type="button" class="btn btn-outline-primary btn-sm" data-add-row="true">
|
||||||
<i class="fas fa-plus me-1"></i>Add Row
|
<i class="fas fa-plus me-1"></i>${escapeHtml(keyDatesUi.addRowLabel || "Add Row")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -150,8 +174,8 @@
|
|||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "text";
|
input.type = "text";
|
||||||
input.className = "form-control";
|
input.className = "form-control";
|
||||||
input.maxLength = 40;
|
input.maxLength = keyDatesUi.columnLabelMaxLength || 40;
|
||||||
input.placeholder = "Column name";
|
input.placeholder = keyDatesUi.columnPlaceholder || "Column name";
|
||||||
input.value = column.label || "";
|
input.value = column.label || "";
|
||||||
input.addEventListener("input", function () {
|
input.addEventListener("input", function () {
|
||||||
column.label = input.value;
|
column.label = input.value;
|
||||||
@@ -183,7 +207,7 @@
|
|||||||
const actionHead = document.createElement("th");
|
const actionHead = document.createElement("th");
|
||||||
actionHead.className = "text-end";
|
actionHead.className = "text-end";
|
||||||
actionHead.style.width = "72px";
|
actionHead.style.width = "72px";
|
||||||
actionHead.textContent = "Actions";
|
actionHead.textContent = keyDatesUi.actionsLabel || "Actions";
|
||||||
headRow.appendChild(actionHead);
|
headRow.appendChild(actionHead);
|
||||||
thead.appendChild(headRow);
|
thead.appendChild(headRow);
|
||||||
table.appendChild(thead);
|
table.appendChild(thead);
|
||||||
@@ -194,7 +218,7 @@
|
|||||||
const emptyCell = document.createElement("td");
|
const emptyCell = document.createElement("td");
|
||||||
emptyCell.colSpan = keyDates.columns.length + 1;
|
emptyCell.colSpan = keyDates.columns.length + 1;
|
||||||
emptyCell.className = "text-center text-muted py-4";
|
emptyCell.className = "text-center text-muted py-4";
|
||||||
emptyCell.textContent = "No rows yet.";
|
emptyCell.textContent = keyDatesUi.emptyRowsText || "No rows yet.";
|
||||||
emptyRow.appendChild(emptyCell);
|
emptyRow.appendChild(emptyCell);
|
||||||
tbody.appendChild(emptyRow);
|
tbody.appendChild(emptyRow);
|
||||||
} else {
|
} else {
|
||||||
@@ -206,7 +230,7 @@
|
|||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "text";
|
input.type = "text";
|
||||||
input.className = "form-control";
|
input.className = "form-control";
|
||||||
input.maxLength = 60;
|
input.maxLength = keyDatesUi.cellMaxLength || 60;
|
||||||
input.placeholder = column.label || `Column ${columnIndex + 1}`;
|
input.placeholder = column.label || `Column ${columnIndex + 1}`;
|
||||||
input.value = rowItem.cells[columnIndex] || "";
|
input.value = rowItem.cells[columnIndex] || "";
|
||||||
input.addEventListener("input", function () {
|
input.addEventListener("input", function () {
|
||||||
@@ -389,18 +413,21 @@
|
|||||||
: Array.isArray(calculator.modelOptions)
|
: Array.isArray(calculator.modelOptions)
|
||||||
? calculator.modelOptions.map((label) => ({ label }))
|
? 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) => ({
|
calculator.options = rawOptions.slice(0, 3).map((option, index) => ({
|
||||||
id: String(option?.id || sanitizeId(option?.label || `option-${index + 1}`) || `option-${index + 1}`),
|
id: String(option?.id || sanitizeId(option?.label || `option-${index + 1}`) || `option-${index + 1}`),
|
||||||
label: String(option?.label || `Option ${index + 1}`).slice(0, 12),
|
label: String(option?.label || `Option ${index + 1}`).slice(0, optionLabelField.maxLength || 12),
|
||||||
paceLabel: String(option?.paceLabel || "Target Pace"),
|
paceLabel: String(option?.paceLabel || defaultOption.paceLabel || "Target Pace"),
|
||||||
minPaceLabel: String(option?.minPaceLabel || "Relaxed"),
|
minPaceLabel: String(option?.minPaceLabel || defaultOption.minPaceLabel || "Relaxed"),
|
||||||
maxPaceLabel: String(option?.maxPaceLabel || "Accelerated"),
|
maxPaceLabel: String(option?.maxPaceLabel || defaultOption.maxPaceLabel || "Accelerated"),
|
||||||
resultLabel: String(option?.resultLabel || "Estimated Monthly Payment"),
|
resultLabel: String(option?.resultLabel || defaultOption.resultLabel || "Estimated Monthly Payment"),
|
||||||
monthlyAmount: (((String(option?.monthlyAmount || "299").match(/\d[\d,]*/) || [])[0] || "299").replace(/,/g, "")),
|
monthlyAmount: (((String(option?.monthlyAmount || defaultOption.monthlyAmount || "299").match(/\d[\d,]*/) || [])[0] || String(defaultOption.monthlyAmount || "299")).replace(/,/g, "")),
|
||||||
monthlySuffix: String(option?.monthlySuffix || "/mo"),
|
monthlySuffix: String(option?.monthlySuffix || defaultOption.monthlySuffix || "/mo"),
|
||||||
noteIcon: String(option?.noteIcon || "fa-bolt"),
|
noteIcon: String(option?.noteIcon || defaultOption.noteIcon || "fa-bolt"),
|
||||||
note: String(option?.note || ""),
|
note: String(option?.note || defaultOption.note || ""),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
delete calculator.modelOptions;
|
delete calculator.modelOptions;
|
||||||
@@ -418,19 +445,42 @@
|
|||||||
normalizeCalculatorState();
|
normalizeCalculatorState();
|
||||||
|
|
||||||
const calculator = state.calculator;
|
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");
|
const row = document.createElement("div");
|
||||||
row.className = "row g-3";
|
row.className = "row g-3";
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
|
|
||||||
renderLeafField(
|
renderLeafField(
|
||||||
{ key: "title", label: "Card title", type: "text", maxLength: 60 },
|
{
|
||||||
|
key: "title",
|
||||||
|
label: titleField.label || "Card title",
|
||||||
|
type: titleField.type || "text",
|
||||||
|
maxLength: titleField.maxLength || 60,
|
||||||
|
helpText: titleField.helpText,
|
||||||
|
},
|
||||||
row,
|
row,
|
||||||
calculator,
|
calculator,
|
||||||
"title",
|
"title",
|
||||||
{ path: "calculator.title", root: state, item: calculator },
|
{ path: "calculator.title", root: state, item: calculator },
|
||||||
);
|
);
|
||||||
renderLeafField(
|
renderLeafField(
|
||||||
{ key: "description", label: "Card description", type: "textarea", maxLength: 120, rows: 3 },
|
{
|
||||||
|
key: "description",
|
||||||
|
label: descriptionField.label || "Card description",
|
||||||
|
type: descriptionField.type || "textarea",
|
||||||
|
maxLength: descriptionField.maxLength || 120,
|
||||||
|
rows: descriptionField.rows || 3,
|
||||||
|
helpText: descriptionField.helpText,
|
||||||
|
},
|
||||||
row,
|
row,
|
||||||
calculator,
|
calculator,
|
||||||
"description",
|
"description",
|
||||||
@@ -443,22 +493,34 @@
|
|||||||
const ctaHeader = document.createElement("div");
|
const ctaHeader = document.createElement("div");
|
||||||
ctaHeader.className = "mb-3";
|
ctaHeader.className = "mb-3";
|
||||||
ctaHeader.innerHTML = `
|
ctaHeader.innerHTML = `
|
||||||
<label class="form-label fw-semibold mb-1">Primary button</label>
|
<label class="form-label fw-semibold mb-1">${escapeHtml(calculatorUi.ctaLabel || ctaField.label || "Primary button")}</label>
|
||||||
<div class="form-text mt-0">This button appears at the bottom of the calculator card.</div>
|
<div class="form-text mt-0">${escapeHtml(calculatorUi.ctaHelpText || "This button appears at the bottom of the calculator card.")}</div>
|
||||||
`;
|
`;
|
||||||
ctaCard.appendChild(ctaHeader);
|
ctaCard.appendChild(ctaHeader);
|
||||||
const ctaRow = document.createElement("div");
|
const ctaRow = document.createElement("div");
|
||||||
ctaRow.className = "row g-3";
|
ctaRow.className = "row g-3";
|
||||||
ctaCard.appendChild(ctaRow);
|
ctaCard.appendChild(ctaRow);
|
||||||
renderLeafField(
|
renderLeafField(
|
||||||
{ key: "label", label: "Button label", type: "text", maxLength: 15 },
|
{
|
||||||
|
key: "label",
|
||||||
|
label: ctaLabelField.label || "Button label",
|
||||||
|
type: ctaLabelField.type || "text",
|
||||||
|
maxLength: ctaLabelField.maxLength || 15,
|
||||||
|
helpText: ctaLabelField.helpText,
|
||||||
|
},
|
||||||
ctaRow,
|
ctaRow,
|
||||||
calculator.cta,
|
calculator.cta,
|
||||||
"label",
|
"label",
|
||||||
{ path: "calculator.cta.label", root: state, item: calculator.cta },
|
{ path: "calculator.cta.label", root: state, item: calculator.cta },
|
||||||
);
|
);
|
||||||
renderLeafField(
|
renderLeafField(
|
||||||
{ key: "href", label: "Button URL", type: "text", maxLength: 255 },
|
{
|
||||||
|
key: "href",
|
||||||
|
label: ctaHrefField.label || "Button URL",
|
||||||
|
type: ctaHrefField.type || "text",
|
||||||
|
maxLength: ctaHrefField.maxLength || 255,
|
||||||
|
helpText: ctaHrefField.helpText,
|
||||||
|
},
|
||||||
ctaRow,
|
ctaRow,
|
||||||
calculator.cta,
|
calculator.cta,
|
||||||
"href",
|
"href",
|
||||||
@@ -473,8 +535,8 @@
|
|||||||
const optionsHeader = document.createElement("div");
|
const optionsHeader = document.createElement("div");
|
||||||
optionsHeader.className = "mb-3";
|
optionsHeader.className = "mb-3";
|
||||||
optionsHeader.innerHTML = `
|
optionsHeader.innerHTML = `
|
||||||
<label class="form-label fw-semibold mb-1">Calculator options</label>
|
<label class="form-label fw-semibold mb-1">${escapeHtml(optionsField.label || calculatorUi.optionsLabel || "Calculator options")}</label>
|
||||||
<div class="form-text mt-0">Each option has its own pricing labels, amount, note, and icon. Open the edit page to update the option details.</div>
|
<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);
|
optionsCard.appendChild(optionsHeader);
|
||||||
|
|
||||||
@@ -485,7 +547,7 @@
|
|||||||
if (!calculator.options.length) {
|
if (!calculator.options.length) {
|
||||||
const empty = document.createElement("div");
|
const empty = document.createElement("div");
|
||||||
empty.className = "text-muted small";
|
empty.className = "text-muted small";
|
||||||
empty.textContent = "No calculator options yet.";
|
empty.textContent = optionsField.emptyText || calculatorUi.optionsEmptyText || "No calculator options yet.";
|
||||||
list.appendChild(empty);
|
list.appendChild(empty);
|
||||||
} else {
|
} else {
|
||||||
calculator.options.forEach((option, index) => {
|
calculator.options.forEach((option, index) => {
|
||||||
@@ -519,24 +581,24 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (calculator.options.length < 3) {
|
if (calculator.options.length < maxOptions) {
|
||||||
const addButton = document.createElement("button");
|
const addButton = document.createElement("button");
|
||||||
addButton.type = "button";
|
addButton.type = "button";
|
||||||
addButton.className = "cms-add-button mt-3";
|
addButton.className = "cms-add-button mt-3";
|
||||||
addButton.innerHTML = '<i class="fas fa-plus me-2"></i>Add calculator option';
|
addButton.innerHTML = `<i class="fas fa-plus me-2"></i>${escapeHtml(optionsField.addLabel || calculatorUi.addOptionLabel || "Add calculator option")}`;
|
||||||
addButton.addEventListener("click", function () {
|
addButton.addEventListener("click", function () {
|
||||||
const nextIndex = calculator.options.length + 1;
|
const nextIndex = calculator.options.length + 1;
|
||||||
calculator.options.push({
|
calculator.options.push({
|
||||||
id: `option-${Date.now()}`,
|
id: `option-${Date.now()}`,
|
||||||
label: `Option ${nextIndex}`.slice(0, 12),
|
label: String(`Option ${nextIndex}`).slice(0, optionLabelField.maxLength || 12),
|
||||||
paceLabel: "Target Pace",
|
paceLabel: String(defaultOption.paceLabel || "Target Pace"),
|
||||||
minPaceLabel: "Relaxed",
|
minPaceLabel: String(defaultOption.minPaceLabel || "Relaxed"),
|
||||||
maxPaceLabel: "Accelerated",
|
maxPaceLabel: String(defaultOption.maxPaceLabel || "Accelerated"),
|
||||||
resultLabel: "Estimated Monthly Payment",
|
resultLabel: String(defaultOption.resultLabel || "Estimated Monthly Payment"),
|
||||||
monthlyAmount: "299",
|
monthlyAmount: String(defaultOption.monthlyAmount || "299"),
|
||||||
monthlySuffix: "/mo",
|
monthlySuffix: String(defaultOption.monthlySuffix || "/mo"),
|
||||||
noteIcon: "fa-bolt",
|
noteIcon: String(defaultOption.noteIcon || "fa-bolt"),
|
||||||
note: "",
|
note: String(defaultOption.note || ""),
|
||||||
});
|
});
|
||||||
renderSection("calculator");
|
renderSection("calculator");
|
||||||
});
|
});
|
||||||
@@ -544,7 +606,7 @@
|
|||||||
} else {
|
} else {
|
||||||
const limitNote = document.createElement("div");
|
const limitNote = document.createElement("div");
|
||||||
limitNote.className = "form-text mt-3";
|
limitNote.className = "form-text mt-3";
|
||||||
limitNote.textContent = "You can add up to 3 calculator options.";
|
limitNote.textContent = calculatorUi.limitHelpText || `You can add up to ${maxOptions} calculator options.`;
|
||||||
optionsCard.appendChild(limitNote);
|
optionsCard.appendChild(limitNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,34 +20,21 @@
|
|||||||
<div class="card shadow-sm border-0 mb-4">
|
<div class="card shadow-sm border-0 mb-4">
|
||||||
<div class="card-header bg-white border-bottom">
|
<div class="card-header bg-white border-bottom">
|
||||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||||
<li class="nav-item">
|
<% editorConfig.tabs.forEach((tab) => { %>
|
||||||
<a class="nav-link <%= activeTab === 'hero' ? 'active' : '' %>" data-bs-toggle="tab" href="#hero" role="tab" data-tab-key="hero">
|
<li class="nav-item">
|
||||||
<i class="fas fa-image me-2"></i>Hero
|
<a class="nav-link <%= activeTab === tab.key ? 'active' : '' %>" data-bs-toggle="tab" href="#<%= tab.key %>" role="tab" data-tab-key="<%= tab.key %>">
|
||||||
</a>
|
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<a class="nav-link <%= activeTab === 'directory' ? 'active' : '' %>" data-bs-toggle="tab" href="#directory" role="tab" data-tab-key="directory">
|
<% }) %>
|
||||||
<i class="fas fa-handshake me-2"></i>Partner Directory
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link <%= activeTab === 'cta' ? 'active' : '' %>" data-bs-toggle="tab" href="#cta" role="tab" data-tab-key="cta">
|
|
||||||
<i class="fas fa-bullhorn me-2"></i>Call To Action
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link <%= activeTab === 'inquiryForm' ? 'active' : '' %>" data-bs-toggle="tab" href="#inquiryForm" role="tab" data-tab-key="inquiryForm">
|
|
||||||
<i class="fas fa-envelope me-2"></i>Inquiry Form
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<%- include("partials/hero-tab", { activeTab, data, backendUrl }) %>
|
<%- include("partials/hero-tab", { activeTab, data, backendUrl, editorUi }) %>
|
||||||
<%- include("partials/directory-tab", { activeTab, data }) %>
|
<%- include("partials/directory-tab", { activeTab, data, editorUi }) %>
|
||||||
<%- include("partials/cta-tab", { activeTab, data }) %>
|
<%- include("partials/cta-tab", { activeTab, data, editorUi }) %>
|
||||||
<%- include("partials/inquiry-form-tab", { activeTab, data }) %>
|
<%- include("partials/inquiry-form-tab", { activeTab, data, editorUi }) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -66,11 +53,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%- include("partials/templates") %>
|
<%- include("partials/templates", { editorUi }) %>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.partnershipsPageData = <%- JSON.stringify(data) %>;
|
window.partnershipsPageData = <%- JSON.stringify(data) %>;
|
||||||
window.partnershipsBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
window.partnershipsBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||||
|
window.partnershipsEditorConfig = <%- JSON.stringify(editorConfig) %>;
|
||||||
|
window.partnershipsEditorUi = <%- JSON.stringify(editorUi) %>;
|
||||||
</script>
|
</script>
|
||||||
<%- include("partials/editor-script") %>
|
<%- include("partials/editor-script") %>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<div class="tab-pane fade <%= activeTab === 'cta' ? 'show active' : '' %>" id="cta" role="tabpanel">
|
<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 border shadow-sm">
|
||||||
<div class="card-header bg-white">
|
<div class="card-header bg-white">
|
||||||
<h6 class="mb-0"><i class="fas fa-bullhorn me-2"></i>Call To Action</h6>
|
<h6 class="mb-0"><i class="fas fa-bullhorn me-2"></i>Call To Action</h6>
|
||||||
@@ -6,16 +7,16 @@
|
|||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">Headline</label>
|
<label class="form-label fw-semibold"><%= ctaUi.heading?.label || "Headline" %></label>
|
||||||
<input class="form-control" id="ctaHeading" maxlength="80" value="<%= data.cta?.heading || '' %>">
|
<input class="form-control" id="ctaHeading" maxlength="<%= ctaUi.heading?.maxLength || 80 %>" value="<%= data.cta?.heading || '' %>">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">Supporting text</label>
|
<label class="form-label fw-semibold"><%= ctaUi.description?.label || "Supporting text" %></label>
|
||||||
<textarea class="form-control" id="ctaDescription" rows="4" maxlength="220"><%= data.cta?.description || '' %></textarea>
|
<textarea class="form-control" id="ctaDescription" rows="<%= ctaUi.description?.rows || 4 %>" maxlength="<%= ctaUi.description?.maxLength || 220 %>"><%= data.cta?.description || '' %></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-semibold">Button label</label>
|
<label class="form-label fw-semibold"><%= ctaUi.buttonLabel?.label || "Button label" %></label>
|
||||||
<input class="form-control" id="ctaButtonLabel" maxlength="40" value="<%= data.cta?.buttonLabel || '' %>">
|
<input class="form-control" id="ctaButtonLabel" maxlength="<%= ctaUi.buttonLabel?.maxLength || 40 %>" value="<%= data.cta?.buttonLabel || '' %>">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<div class="tab-pane fade <%= activeTab === 'directory' ? 'show active' : '' %>" id="directory" role="tabpanel">
|
<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 border shadow-sm">
|
||||||
<div class="card-header bg-white">
|
<div class="card-header bg-white">
|
||||||
<h6 class="mb-0"><i class="fas fa-handshake me-2"></i>Partner Directory</h6>
|
<h6 class="mb-0"><i class="fas fa-handshake me-2"></i>Partner Directory</h6>
|
||||||
@@ -6,38 +7,38 @@
|
|||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">Section heading</label>
|
<label class="form-label fw-semibold"><%= directoryUi.heading?.label || "Section heading" %></label>
|
||||||
<input class="form-control" id="directoryHeading" maxlength="70" value="<%= data.directory?.heading || '' %>">
|
<input class="form-control" id="directoryHeading" maxlength="<%= directoryUi.heading?.maxLength || 70 %>" value="<%= data.directory?.heading || '' %>">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">Section description</label>
|
<label class="form-label fw-semibold"><%= directoryUi.description?.label || "Section description" %></label>
|
||||||
<textarea class="form-control" id="directoryDescription" rows="3" maxlength="180"><%= data.directory?.description || '' %></textarea>
|
<textarea class="form-control" id="directoryDescription" rows="<%= directoryUi.description?.rows || 3 %>" maxlength="<%= directoryUi.description?.maxLength || 180 %>"><%= data.directory?.description || '' %></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cms-editor-group mb-4">
|
<div class="cms-editor-group mb-4">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label fw-semibold mb-1">Category tabs</label>
|
<label class="form-label fw-semibold mb-1"><%= directoryUi.tabs?.label || "Category tabs" %></label>
|
||||||
<div class="form-text mt-0">The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.</div>
|
<div class="form-text mt-0"><%= directoryUi.tabsFrontendHint || directoryUi.tabs?.helpText || "" %></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="directoryTabsList"></div>
|
<div id="directoryTabsList"></div>
|
||||||
<button type="button" class="cms-add-button mt-3" id="addDirectoryTabBtn">
|
<button type="button" class="cms-add-button mt-3" id="addDirectoryTabBtn">
|
||||||
<i class="fas fa-plus me-2"></i>Add Tab
|
<i class="fas fa-plus me-2"></i><%= directoryUi.tabs?.addLabel || "Add Tab" %>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cms-editor-group">
|
<div class="cms-editor-group">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label fw-semibold mb-1">Partners</label>
|
<label class="form-label fw-semibold mb-1"><%= directoryUi.partners?.label || "Partners" %></label>
|
||||||
<div class="form-text mt-0">Each partner card keeps its own open or closed state automatically.</div>
|
<div class="form-text mt-0"><%= directoryUi.partnersHelpText || directoryUi.partners?.helpText || "" %></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="partnersList"></div>
|
<div id="partnersList"></div>
|
||||||
<button type="button" class="cms-add-button mt-3" id="addPartnerBtn">
|
<button type="button" class="cms-add-button mt-3" id="addPartnerBtn">
|
||||||
<i class="fas fa-plus me-2"></i>Add Partner
|
<i class="fas fa-plus me-2"></i><%= directoryUi.partners?.addLabel || "Add Partner" %>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const initialData = window.partnershipsPageData;
|
const initialData = window.partnershipsPageData;
|
||||||
const backendUrl = (window.partnershipsBackendUrl || "").replace(/\/$/, "");
|
const backendUrl = (window.partnershipsBackendUrl || "").replace(/\/$/, "");
|
||||||
|
const editorConfig = window.partnershipsEditorConfig || {};
|
||||||
|
const editorUi = window.partnershipsEditorUi || {};
|
||||||
const form = document.getElementById("cmsEditorForm");
|
const form = document.getElementById("cmsEditorForm");
|
||||||
const pageJsonInput = document.getElementById("pageJson");
|
const pageJsonInput = document.getElementById("pageJson");
|
||||||
const activeTabInput = document.getElementById("activeTabInput");
|
const activeTabInput = document.getElementById("activeTabInput");
|
||||||
@@ -14,6 +16,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = JSON.parse(JSON.stringify(initialData));
|
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) =>
|
const slugifyValue = (value, fallback) =>
|
||||||
String(value || "")
|
String(value || "")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -32,6 +38,42 @@
|
|||||||
renderAll();
|
renderAll();
|
||||||
initStaticCounters();
|
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() {
|
function bindStaticEvents() {
|
||||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||||
@@ -50,29 +92,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("addPartnerBtn")?.addEventListener("click", function () {
|
document.getElementById("addPartnerBtn")?.addEventListener("click", function () {
|
||||||
state.directory.partners.push({
|
state.directory.partners.push(getDefaultPartner());
|
||||||
name: "",
|
|
||||||
category: "",
|
|
||||||
summary: "",
|
|
||||||
logo: "",
|
|
||||||
logoAlt: "",
|
|
||||||
about: "",
|
|
||||||
collabType: "",
|
|
||||||
benefits: "",
|
|
||||||
});
|
|
||||||
ensurePartnershipIds();
|
ensurePartnershipIds();
|
||||||
renderPartners();
|
renderPartners();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () {
|
document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () {
|
||||||
state.inquiryForm.fields.push({
|
state.inquiryForm.fields.push(getDefaultInquiryField());
|
||||||
label: "",
|
|
||||||
placeholder: "",
|
|
||||||
type: "text",
|
|
||||||
width: "full",
|
|
||||||
required: true,
|
|
||||||
options: [],
|
|
||||||
});
|
|
||||||
ensurePartnershipIds();
|
ensurePartnershipIds();
|
||||||
renderInquiryFields();
|
renderInquiryFields();
|
||||||
});
|
});
|
||||||
@@ -81,8 +107,24 @@
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
|
|
||||||
form.addEventListener("submit", function () {
|
form.addEventListener("submit", function (event) {
|
||||||
syncStaticFields();
|
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();
|
ensurePartnershipIds();
|
||||||
pageJsonInput.value = JSON.stringify(state);
|
pageJsonInput.value = JSON.stringify(state);
|
||||||
});
|
});
|
||||||
@@ -152,6 +194,55 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function renderAll() {
|
||||||
renderDirectoryTabs();
|
renderDirectoryTabs();
|
||||||
renderPartners();
|
renderPartners();
|
||||||
@@ -205,7 +296,7 @@
|
|||||||
input.addEventListener("input", function () {
|
input.addEventListener("input", function () {
|
||||||
partner[field] = input.value;
|
partner[field] = input.value;
|
||||||
if (field === "name") {
|
if (field === "name") {
|
||||||
title.textContent = input.value || `Partner ${index + 1}`;
|
title.textContent = input.value || `${partnerFields.name?.label || "Partner"} ${index + 1}`;
|
||||||
}
|
}
|
||||||
if (field === "category") {
|
if (field === "category") {
|
||||||
subtitle.textContent = input.value || "";
|
subtitle.textContent = input.value || "";
|
||||||
@@ -287,8 +378,8 @@
|
|||||||
input.value = field[key] || "";
|
input.value = field[key] || "";
|
||||||
input.addEventListener("input", function () {
|
input.addEventListener("input", function () {
|
||||||
field[key] = input.value;
|
field[key] = input.value;
|
||||||
if (key === "label") {
|
if (key === "label") {
|
||||||
title.textContent = input.value || `Field ${index + 1}`;
|
title.textContent = input.value || `${inquiryFieldFields.label?.label || "Field"} ${index + 1}`;
|
||||||
}
|
}
|
||||||
if (key === "type") {
|
if (key === "type") {
|
||||||
subtitle.textContent = input.value || "";
|
subtitle.textContent = input.value || "";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<div class="tab-pane fade <%= activeTab === 'hero' ? 'show active' : '' %>" id="hero" role="tabpanel">
|
<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 border shadow-sm">
|
||||||
<div class="card-header bg-white">
|
<div class="card-header bg-white">
|
||||||
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero</h6>
|
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero</h6>
|
||||||
@@ -6,35 +7,35 @@
|
|||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-semibold">Eyebrow label</label>
|
<label class="form-label fw-semibold"><%= heroUi.badge?.label || "Eyebrow label" %></label>
|
||||||
<input class="form-control" id="heroBadge" maxlength="40" value="<%= data.hero?.badge || '' %>">
|
<input class="form-control" id="heroBadge" maxlength="<%= heroUi.badge?.maxLength || 40 %>" value="<%= data.hero?.badge || '' %>">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-semibold">Scroll link label</label>
|
<label class="form-label fw-semibold"><%= heroUi.linkLabel?.label || "Scroll link label" %></label>
|
||||||
<input class="form-control" id="heroLinkLabel" maxlength="40" value="<%= data.hero?.linkLabel || '' %>">
|
<input class="form-control" id="heroLinkLabel" maxlength="<%= heroUi.linkLabel?.maxLength || 40 %>" value="<%= data.hero?.linkLabel || '' %>">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">Headline</label>
|
<label class="form-label fw-semibold"><%= heroUi.title?.label || "Headline" %></label>
|
||||||
<input class="form-control" id="heroTitle" maxlength="90" value="<%= data.hero?.title || '' %>">
|
<input class="form-control" id="heroTitle" maxlength="<%= heroUi.title?.maxLength || 90 %>" value="<%= data.hero?.title || '' %>">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">Supporting text</label>
|
<label class="form-label fw-semibold"><%= heroUi.description?.label || "Supporting text" %></label>
|
||||||
<textarea class="form-control" id="heroDescription" rows="4" maxlength="220"><%= data.hero?.description || '' %></textarea>
|
<textarea class="form-control" id="heroDescription" rows="<%= heroUi.description?.rows || 4 %>" maxlength="<%= heroUi.description?.maxLength || 220 %>"><%= data.hero?.description || '' %></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">Hero image</label>
|
<label class="form-label fw-semibold"><%= heroUi.image?.label || "Hero image" %></label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input class="form-control" id="heroImage" value="<%= data.hero?.image || '' %>">
|
<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">
|
<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
|
<i class="fas fa-upload me-1"></i>Upload
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">Recommended 720x630 px</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;">
|
<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>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">Hero image alt text</label>
|
<label class="form-label fw-semibold"><%= heroUi.imageAlt?.label || "Hero image alt text" %></label>
|
||||||
<input class="form-control" id="heroImageAlt" maxlength="120" value="<%= data.hero?.imageAlt || '' %>">
|
<input class="form-control" id="heroImageAlt" maxlength="<%= heroUi.imageAlt?.maxLength || 120 %>" value="<%= data.hero?.imageAlt || '' %>">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<div class="tab-pane fade <%= activeTab === 'inquiryForm' ? 'show active' : '' %>" id="inquiryForm" role="tabpanel">
|
<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 border shadow-sm">
|
||||||
<div class="card-header bg-white">
|
<div class="card-header bg-white">
|
||||||
<h6 class="mb-0"><i class="fas fa-envelope me-2"></i>Inquiry Form</h6>
|
<h6 class="mb-0"><i class="fas fa-envelope me-2"></i>Inquiry Form</h6>
|
||||||
@@ -6,21 +7,21 @@
|
|||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">Modal title</label>
|
<label class="form-label fw-semibold"><%= inquiryUi.title?.label || "Modal title" %></label>
|
||||||
<input class="form-control" id="inquiryTitle" maxlength="60" value="<%= data.inquiryForm?.title || '' %>">
|
<input class="form-control" id="inquiryTitle" maxlength="<%= inquiryUi.title?.maxLength || 60 %>" value="<%= data.inquiryForm?.title || '' %>">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cms-editor-group">
|
<div class="cms-editor-group">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label fw-semibold mb-1">Form fields</label>
|
<label class="form-label fw-semibold mb-1"><%= inquiryUi.fields?.label || "Form fields" %></label>
|
||||||
<div class="form-text mt-0">Manage labels, placeholders, type, width, and dropdown options.</div>
|
<div class="form-text mt-0"><%= inquiryUi.fieldsHelpText || inquiryUi.fields?.helpText || "" %></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="inquiryFieldsList"></div>
|
<div id="inquiryFieldsList"></div>
|
||||||
<button type="button" class="cms-add-button mt-3" id="addInquiryFieldBtn">
|
<button type="button" class="cms-add-button mt-3" id="addInquiryFieldBtn">
|
||||||
<i class="fas fa-plus me-2"></i>Add Field
|
<i class="fas fa-plus me-2"></i><%= inquiryUi.fields?.addLabel || "Add Field" %>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
<% const directoryUi = editorUi.directory || {}; %>
|
||||||
|
<% const partnerFields = directoryUi.partnerFields || {}; %>
|
||||||
|
<% const inquiryUi = editorUi.inquiryForm || {}; %>
|
||||||
|
<% const inquiryFieldFields = inquiryUi.fieldFields || {}; %>
|
||||||
|
|
||||||
<template id="directoryTabTemplate">
|
<template id="directoryTabTemplate">
|
||||||
<div class="card cms-item-card mb-3" data-item="directory-tab">
|
<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="card-header d-flex justify-content-between align-items-center gap-2">
|
||||||
@@ -17,8 +22,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<label class="form-label fw-semibold">Tab label</label>
|
<label class="form-label fw-semibold"><%= directoryUi.tabs?.itemLabel || "Tab label" %></label>
|
||||||
<input class="form-control" data-field="label" maxlength="30" placeholder="Industry">
|
<input class="form-control" data-field="label" maxlength="<%= directoryUi.tabs?.itemSchema?.maxLength || 30 %>" placeholder="<%= directoryUi.tabs?.itemSchema?.placeholder || 'Industry' %>">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -47,44 +52,44 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label fw-semibold">Partner name</label>
|
<label class="form-label fw-semibold"><%= partnerFields.name?.label || "Partner name" %></label>
|
||||||
<input class="form-control" data-field="name" maxlength="90">
|
<input class="form-control" data-field="name" maxlength="<%= partnerFields.name?.maxLength || 90 %>">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label fw-semibold">Category</label>
|
<label class="form-label fw-semibold"><%= partnerFields.category?.label || "Category" %></label>
|
||||||
<input class="form-control" data-field="category" list="">
|
<input class="form-control" data-field="category" list="" maxlength="<%= partnerFields.category?.maxLength || 30 %>">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">Card summary</label>
|
<label class="form-label fw-semibold"><%= partnerFields.summary?.label || "Card summary" %></label>
|
||||||
<textarea class="form-control" data-field="summary" rows="3" maxlength="130"></textarea>
|
<textarea class="form-control" data-field="summary" rows="<%= partnerFields.summary?.rows || 3 %>" maxlength="<%= partnerFields.summary?.maxLength || 130 %>"></textarea>
|
||||||
<div class="form-text">The card preview is capped at 130 characters.</div>
|
<div class="form-text"><%= partnerFields.summary?.helpText || "" %></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">Partner logo</label>
|
<label class="form-label fw-semibold"><%= partnerFields.logo?.label || "Partner logo" %></label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input class="form-control" data-field="logo">
|
<input class="form-control" data-field="logo">
|
||||||
<button class="btn btn-outline-primary" type="button" data-upload-button>
|
<button class="btn btn-outline-primary" type="button" data-upload-button>
|
||||||
<i class="fas fa-upload me-1"></i>Upload
|
<i class="fas fa-upload me-1"></i>Upload
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">Recommended 105x80 px minimum visible ratio</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;">
|
<img class="img-thumbnail mt-2 d-none" data-preview style="max-height: 180px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-semibold">Logo alt text</label>
|
<label class="form-label fw-semibold"><%= partnerFields.logoAlt?.label || "Logo alt text" %></label>
|
||||||
<input class="form-control" data-field="logoAlt" maxlength="120">
|
<input class="form-control" data-field="logoAlt" maxlength="<%= partnerFields.logoAlt?.maxLength || 120 %>">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-semibold">Collaboration type</label>
|
<label class="form-label fw-semibold"><%= partnerFields.collabType?.label || "Collaboration type" %></label>
|
||||||
<input class="form-control" data-field="collabType" maxlength="40">
|
<input class="form-control" data-field="collabType" maxlength="<%= partnerFields.collabType?.maxLength || 40 %>">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">About text</label>
|
<label class="form-label fw-semibold"><%= partnerFields.about?.label || "About text" %></label>
|
||||||
<textarea class="form-control" data-field="about" rows="5" maxlength="600"></textarea>
|
<textarea class="form-control" data-field="about" rows="<%= partnerFields.about?.rows || 5 %>" maxlength="<%= partnerFields.about?.maxLength || 600 %>"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">Benefits</label>
|
<label class="form-label fw-semibold"><%= partnerFields.benefits?.label || "Benefits" %></label>
|
||||||
<textarea class="form-control" data-field="benefits" rows="4" maxlength="240"></textarea>
|
<textarea class="form-control" data-field="benefits" rows="<%= partnerFields.benefits?.rows || 4 %>" maxlength="<%= partnerFields.benefits?.maxLength || 240 %>"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,43 +120,44 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label fw-semibold">Field label</label>
|
<label class="form-label fw-semibold"><%= inquiryFieldFields.label?.label || "Field label" %></label>
|
||||||
<input class="form-control" data-field="label" maxlength="40">
|
<input class="form-control" data-field="label" maxlength="<%= inquiryFieldFields.label?.maxLength || 40 %>">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label fw-semibold">Placeholder text</label>
|
<label class="form-label fw-semibold"><%= inquiryFieldFields.placeholder?.label || "Placeholder text" %></label>
|
||||||
<input class="form-control" data-field="placeholder" maxlength="80">
|
<input class="form-control" data-field="placeholder" maxlength="<%= inquiryFieldFields.placeholder?.maxLength || 80 %>">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label fw-semibold">Field type</label>
|
<label class="form-label fw-semibold"><%= inquiryFieldFields.type?.label || "Field type" %></label>
|
||||||
<select class="form-select" data-field="type">
|
<select class="form-select" data-field="type">
|
||||||
<option value="text">Single line text</option>
|
<% (inquiryFieldFields.type?.options || []).forEach((option) => { %>
|
||||||
<option value="textarea">Paragraph</option>
|
<option value="<%= option.value %>"><%= option.label %></option>
|
||||||
<option value="select">Dropdown</option>
|
<% }) %>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label fw-semibold">Field width</label>
|
<label class="form-label fw-semibold"><%= inquiryFieldFields.width?.label || "Field width" %></label>
|
||||||
<select class="form-select" data-field="width">
|
<select class="form-select" data-field="width">
|
||||||
<option value="half">Half width</option>
|
<% (inquiryFieldFields.width?.options || []).forEach((option) => { %>
|
||||||
<option value="full">Full width</option>
|
<option value="<%= option.value %>"><%= option.label %></option>
|
||||||
|
<% }) %>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 d-flex align-items-end">
|
<div class="col-md-4 d-flex align-items-end">
|
||||||
<div class="form-check mb-2">
|
<div class="form-check mb-2">
|
||||||
<input class="form-check-input" type="checkbox" data-field="required">
|
<input class="form-check-input" type="checkbox" data-field="required">
|
||||||
<label class="form-check-label fw-semibold">Required field</label>
|
<label class="form-check-label fw-semibold"><%= inquiryFieldFields.required?.label || "Required field" %></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12" data-options-wrap>
|
<div class="col-12" data-options-wrap>
|
||||||
<div class="cms-editor-group">
|
<div class="cms-editor-group">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label fw-semibold mb-0">Dropdown options</label>
|
<label class="form-label fw-semibold mb-0"><%= inquiryFieldFields.options?.label || "Dropdown options" %></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text mb-2">Only used when the field type is Dropdown.</div>
|
<div class="form-text mb-2"><%= inquiryFieldFields.options?.helpText || "" %></div>
|
||||||
<div data-options-list></div>
|
<div data-options-list></div>
|
||||||
<button type="button" class="cms-add-button mt-3" data-add-option>
|
<button type="button" class="cms-add-button mt-3" data-add-option>
|
||||||
<i class="fas fa-plus me-2"></i>Add Option
|
<i class="fas fa-plus me-2"></i><%= inquiryFieldFields.options?.addLabel || "Add Option" %>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,7 +171,7 @@
|
|||||||
<button type="button" class="drag-handle" title="Drag to reorder">
|
<button type="button" class="drag-handle" title="Drag to reorder">
|
||||||
<i class="fas fa-grip-vertical"></i>
|
<i class="fas fa-grip-vertical"></i>
|
||||||
</button>
|
</button>
|
||||||
<input class="form-control" data-option-value maxlength="50" placeholder="Option label">
|
<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">
|
<button type="button" class="cms-remove-button" data-remove-item title="Remove option">
|
||||||
<i class="fas fa-trash-alt"></i>
|
<i class="fas fa-trash-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user