From a39c336dd493089fd7f3d6494d6b4172d918ec64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=E1=BB=91ng=20Th=C3=A0nh=20=C4=90=E1=BA=A1t?= <84076965+tongthanhdat009@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:06:03 +0700 Subject: [PATCH] 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. --- controllers/accreditationController.js | 28 ++++ controllers/admissionsController.js | 94 ++++++++++-- controllers/partnershipsController.js | 119 ++++++++++++++- utils/contentEditors/accreditationConfig.js | 2 +- utils/contentEditors/admissionsConfig.js | 95 +++++++++--- utils/contentEditors/historyConfig.js | 4 +- utils/contentEditors/partnershipsConfig.js | 11 +- utils/contentEditors/policiesConfig.js | 2 +- utils/contentEditors/sharedFields.js | 2 +- .../accreditation/partials/editor-script.ejs | 73 ++++++++- views/admin/admissions/calculator-option.ejs | 126 ++++++++++++---- .../admissions/partials/editor-script.ejs | 142 +++++++++++++----- views/admin/partnerships/index.ejs | 39 ++--- views/admin/partnerships/partials/cta-tab.ejs | 13 +- .../partnerships/partials/directory-tab.ejs | 21 +-- .../partnerships/partials/editor-script.ejs | 135 ++++++++++++++--- .../admin/partnerships/partials/hero-tab.ejs | 25 +-- .../partials/inquiry-form-tab.ejs | 11 +- .../admin/partnerships/partials/templates.ejs | 76 +++++----- 19 files changed, 790 insertions(+), 228 deletions(-) diff --git a/controllers/accreditationController.js b/controllers/accreditationController.js index 8bead64..705038f 100644 --- a/controllers/accreditationController.js +++ b/controllers/accreditationController.js @@ -9,6 +9,34 @@ const controller = createPageContentController({ modelName: "AccreditationPage", auditAction: AUDIT_ACTIONS.UPDATE_ACCREDITATION, editorConfig: accreditationConfig, + preparePayload(rawPayload) { + const payload = JSON.parse(JSON.stringify(rawPayload || {})); + const tabs = Array.isArray(payload?.grid?.tabs) ? payload.grid.tabs : []; + const seenTabs = new Set(); + const duplicateTabs = []; + + tabs.forEach((tab) => { + const normalizedTab = String(tab || "").trim().toLowerCase(); + if (!normalizedTab) { + return; + } + + if (seenTabs.has(normalizedTab)) { + duplicateTabs.push(String(tab || "").trim()); + return; + } + + seenTabs.add(normalizedTab); + }); + + if (duplicateTabs.length > 0) { + throw new Error( + `Category tab already exists: ${duplicateTabs[0]}. Please use unique tab names.`, + ); + } + + return payload; + }, }); controller.index = async function index(req, res) { diff --git a/controllers/admissionsController.js b/controllers/admissionsController.js index d88c1d0..2a082aa 100644 --- a/controllers/admissionsController.js +++ b/controllers/admissionsController.js @@ -9,6 +9,38 @@ const diffObject = require("../audit/diffObject"); const { ensureUniqueIds } = require("../utils/contentEditorIds"); const { ICON_OPTIONS } = require("../utils/contentEditors/sharedFields"); +function getAdmissionsEditorUi() { + return admissionsConfig.editorUi || {}; +} + +function getDefaultCalculatorOptionValues() { + return getAdmissionsEditorUi().calculator?.defaultOption || {}; +} + +function getCalculatorOptionFieldConfig(fieldKey) { + const calculatorTab = (admissionsConfig.tabs || []).find((tab) => tab.key === "calculator"); + const calculatorFields = calculatorTab?.schema?.fields || []; + const optionsField = calculatorFields.find((field) => field.key === "options"); + const optionItemFields = optionsField?.itemSchema?.fields || []; + + return optionItemFields.find((field) => field.key === fieldKey) || {}; +} + +function hasDuplicateCalculatorOptionLabel(options, currentOptionId, nextLabel) { + const normalizedLabel = String(nextLabel || "").trim().toLowerCase(); + if (!normalizedLabel) { + return false; + } + + return (options || []).some((option) => { + if (String(option?.id || "") === String(currentOptionId || "")) { + return false; + } + + return String(option?.label || "").trim().toLowerCase() === normalizedLabel; + }); +} + function normalizePositiveAmount(value, fallback = "1") { const match = String(value || "").match(/\d[\d,]*/); const numericValue = Number((match ? match[0] : "").replace(/,/g, "")); @@ -17,18 +49,25 @@ function normalizePositiveAmount(value, fallback = "1") { function normalizeCalculatorOption(option, index, calculator) { const source = typeof option === "string" ? { label: option } : { ...(option || {}) }; + const defaultOption = getDefaultCalculatorOptionValues(); + const labelMaxLength = getCalculatorOptionFieldConfig("label").maxLength || 12; return { ...source, - label: String(source.label || source.title || `Option ${index + 1}`).slice(0, 12), - paceLabel: String(source.paceLabel || calculator.paceLabel || "Target Pace"), - minPaceLabel: String(source.minPaceLabel || calculator.minPaceLabel || "Relaxed"), - maxPaceLabel: String(source.maxPaceLabel || calculator.maxPaceLabel || "Accelerated"), - resultLabel: String(source.resultLabel || calculator.resultLabel || "Estimated Monthly Payment"), - monthlyAmount: normalizePositiveAmount(source.monthlyAmount || calculator.monthlyAmount || "299", "299"), - monthlySuffix: String(source.monthlySuffix || calculator.monthlySuffix || "/mo"), - noteIcon: String(source.noteIcon || calculator.noteIcon || "fa-bolt"), - note: String(source.note || calculator.note || ""), + label: String(source.label || source.title || `Option ${index + 1}`).slice(0, labelMaxLength), + paceLabel: String(source.paceLabel || calculator.paceLabel || defaultOption.paceLabel || "Target Pace"), + minPaceLabel: String(source.minPaceLabel || calculator.minPaceLabel || defaultOption.minPaceLabel || "Relaxed"), + maxPaceLabel: String(source.maxPaceLabel || calculator.maxPaceLabel || defaultOption.maxPaceLabel || "Accelerated"), + resultLabel: String( + source.resultLabel || calculator.resultLabel || defaultOption.resultLabel || "Estimated Monthly Payment", + ), + monthlyAmount: normalizePositiveAmount( + source.monthlyAmount || calculator.monthlyAmount || defaultOption.monthlyAmount || "299", + String(defaultOption.monthlyAmount || "299"), + ), + monthlySuffix: String(source.monthlySuffix || calculator.monthlySuffix || defaultOption.monthlySuffix || "/mo"), + noteIcon: String(source.noteIcon || calculator.noteIcon || defaultOption.noteIcon || "fa-bolt"), + note: String(source.note || calculator.note || defaultOption.note || ""), }; } @@ -174,12 +213,41 @@ controller.editCalculatorOption = async function editCalculatorOption(req, res) const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const backendUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`; + const fieldKeys = [ + "label", + "paceLabel", + "minPaceLabel", + "maxPaceLabel", + "resultLabel", + "monthlyAmount", + "monthlySuffix", + "noteIcon", + "note", + ]; + const fieldConfig = fieldKeys.reduce((acc, key) => { + acc[key] = getCalculatorOptionFieldConfig(key); + return acc; + }, {}); return res.render("admin/admissions/calculator-option", { layout: "layouts/main", title: `Edit ${option.label}`, subtitle: "Update the calculator option details", option, + existingOptionLabels: data.calculator.options + .filter((item) => item.id !== optionId) + .map((item) => item.label) + .filter(Boolean), + fieldLimits: { + label: fieldConfig.label.maxLength || 12, + paceLabel: fieldConfig.paceLabel.maxLength || 20, + minPaceLabel: fieldConfig.minPaceLabel.maxLength || 7, + maxPaceLabel: fieldConfig.maxPaceLabel.maxLength || 7, + resultLabel: fieldConfig.resultLabel.maxLength || 40, + monthlySuffix: fieldConfig.monthlySuffix.maxLength || 10, + note: fieldConfig.note.maxLength || 60, + }, + fieldConfig, iconOptions: ICON_OPTIONS, editorConfig: admissionsConfig, previewUrl: `${frontendUrl}${admissionsConfig.previewPath}`, @@ -207,9 +275,15 @@ controller.updateCalculatorOption = async function updateCalculatorOption(req, r return req.session.save(() => res.redirect("/admin/admissions?tab=calculator")); } + const nextLabel = String(req.body.label || "").trim().slice(0, getCalculatorOptionFieldConfig("label").maxLength || 12); + if (hasDuplicateCalculatorOptionLabel(payload.calculator.options, optionId, nextLabel)) { + req.flash("error_msg", `Option label "${nextLabel}" already exists. Please use a unique label.`); + return req.session.save(() => res.redirect(`/admin/admissions/calculator/${optionId}`)); + } + payload.calculator.options[optionIndex] = { ...payload.calculator.options[optionIndex], - label: String(req.body.label || "").trim().slice(0, 12), + label: nextLabel, paceLabel: String(req.body.paceLabel || "").trim(), minPaceLabel: String(req.body.minPaceLabel || "").trim(), maxPaceLabel: String(req.body.maxPaceLabel || "").trim(), diff --git a/controllers/partnershipsController.js b/controllers/partnershipsController.js index 40723d1..be5df7e 100644 --- a/controllers/partnershipsController.js +++ b/controllers/partnershipsController.js @@ -2,9 +2,71 @@ const PartnershipsPage = require("../models/partnerships"); const AUDIT_ACTIONS = require("../constants/auditAction"); const partnershipsConfig = require("../utils/contentEditors/partnershipsConfig"); const createPageContentController = require("./_createPageContentController"); -const createRenderSingletonPageView = require("./_renderSingletonPageView"); const { ensureUniqueIds } = require("../utils/contentEditorIds"); +function getTabConfig(tabKey) { + return (partnershipsConfig.tabs || []).find((tab) => tab.key === tabKey) || {}; +} + +function getObjectField(tabKey, fieldKey) { + const fields = getTabConfig(tabKey)?.schema?.fields || []; + return fields.find((field) => field.key === fieldKey) || {}; +} + +function getArrayItemField(tabKey, arrayKey, fieldKey) { + const arrayField = getObjectField(tabKey, arrayKey); + const itemFields = arrayField?.itemSchema?.fields || []; + return itemFields.find((field) => field.key === fieldKey) || {}; +} + +function getPartnershipsEditorUi() { + return { + hero: { + badge: getObjectField("hero", "badge"), + title: getObjectField("hero", "title"), + description: getObjectField("hero", "description"), + linkLabel: getObjectField("hero", "linkLabel"), + image: getObjectField("hero", "image"), + imageAlt: getObjectField("hero", "imageAlt"), + }, + directory: { + heading: getObjectField("directory", "heading"), + description: getObjectField("directory", "description"), + tabs: getObjectField("directory", "tabs"), + partnerFields: { + name: getArrayItemField("directory", "partners", "name"), + category: getArrayItemField("directory", "partners", "category"), + summary: getArrayItemField("directory", "partners", "summary"), + logo: getArrayItemField("directory", "partners", "logo"), + logoAlt: getArrayItemField("directory", "partners", "logoAlt"), + about: getArrayItemField("directory", "partners", "about"), + collabType: getArrayItemField("directory", "partners", "collabType"), + benefits: getArrayItemField("directory", "partners", "benefits"), + }, + tabsFrontendHint: partnershipsConfig.editorUi?.directory?.tabsFrontendHint || "", + partnersHelpText: partnershipsConfig.editorUi?.directory?.partnersHelpText || "", + }, + cta: { + heading: getObjectField("cta", "heading"), + description: getObjectField("cta", "description"), + buttonLabel: getObjectField("cta", "buttonLabel"), + }, + inquiryForm: { + title: getObjectField("inquiryForm", "title"), + fields: getObjectField("inquiryForm", "fields"), + fieldFields: { + label: getArrayItemField("inquiryForm", "fields", "label"), + placeholder: getArrayItemField("inquiryForm", "fields", "placeholder"), + type: getArrayItemField("inquiryForm", "fields", "type"), + width: getArrayItemField("inquiryForm", "fields", "width"), + options: getArrayItemField("inquiryForm", "fields", "options"), + required: getArrayItemField("inquiryForm", "fields", "required"), + }, + fieldsHelpText: partnershipsConfig.editorUi?.inquiryForm?.fieldsHelpText || "", + }, + }; +} + function toInquiryField(id, field, type, width) { return { id, @@ -53,9 +115,34 @@ function prepareInquiryPayload(payload) { const fields = Array.isArray(normalized?.inquiryForm?.fields) ? normalized.inquiryForm.fields : []; + const tabs = Array.isArray(normalized?.directory?.tabs) + ? normalized.directory.tabs + : []; const partners = Array.isArray(normalized?.directory?.partners) ? normalized.directory.partners : []; + const seenTabs = new Set(); + const duplicateTabs = []; + + tabs.forEach((tab) => { + const normalizedTab = String(tab || "").trim().toLowerCase(); + if (!normalizedTab) { + return; + } + + if (seenTabs.has(normalizedTab)) { + duplicateTabs.push(String(tab || "").trim()); + return; + } + + seenTabs.add(normalizedTab); + }); + + if (duplicateTabs.length > 0) { + throw new Error( + `Category tab already exists: ${duplicateTabs[0]}. Please use unique tab names.`, + ); + } return { ...normalized, @@ -100,12 +187,32 @@ const controller = createPageContentController({ controller.index = async function index(req, res) { try { - return await createRenderSingletonPageView({ - model: PartnershipsPage, + const doc = await PartnershipsPage.getSingle(); + const rawData = doc.toObject(); + const data = normalizeInquiryForm(rawData); + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const backendUrl = + process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`; + const defaultTab = partnershipsConfig.tabs[0]?.key; + const requestedTab = req.query.tab; + const activeTab = partnershipsConfig.tabs.some((tab) => tab.key === requestedTab) + ? requestedTab + : defaultTab; + + return res.render("admin/partnerships/index", { + layout: "layouts/main", + title: partnershipsConfig.title, + subtitle: partnershipsConfig.subtitle, + data, editorConfig: partnershipsConfig, - view: "admin/partnerships/index", - normalizeForEditor: normalizeInquiryForm, - })(req, res); + editorUi: getPartnershipsEditorUi(), + activeTab, + frontendUrl, + backendUrl, + previewUrl: `${frontendUrl}${partnershipsConfig.previewPath}`, + currentPath: req.path, + user: req.session.user, + }); } catch (error) { console.error("partnerships index error:", error); req.flash("error_msg", "Error loading Partnerships Management"); diff --git a/utils/contentEditors/accreditationConfig.js b/utils/contentEditors/accreditationConfig.js index 21c7854..a0ce8f6 100644 --- a/utils/contentEditors/accreditationConfig.js +++ b/utils/contentEditors/accreditationConfig.js @@ -30,7 +30,7 @@ module.exports = { label: "Hero", icon: "fas fa-image", schema: object("hero", "Hero", [ - text("badge", "Eyebrow label", { maxLength: 40 }), + text("badge", "Eyebrow label", { maxLength: 30 }), text("title", "Headline", { maxLength: 60 }), textarea("description", "Supporting text", { maxLength: 420, rows: 5 }), ]), diff --git a/utils/contentEditors/admissionsConfig.js b/utils/contentEditors/admissionsConfig.js index afed38f..e9bcb55 100644 --- a/utils/contentEditors/admissionsConfig.js +++ b/utils/contentEditors/admissionsConfig.js @@ -20,6 +20,39 @@ module.exports = { previewPath: "/admissions", dataFile: "admissions", imageType: "admissions", + editorUi: { + keyDates: { + tableLabel: "Key dates table", + tableHelpText: "Manage the table directly by adding or removing columns and rows.", + addColumnLabel: "Add Column", + addRowLabel: "Add Row", + columnPlaceholder: "Column name", + emptyRowsText: "No rows yet.", + actionsLabel: "Actions", + columnLabelMaxLength: 40, + cellMaxLength: 60, + }, + calculator: { + ctaLabel: "Primary button", + ctaHelpText: "This button appears at the bottom of the calculator card.", + optionsLabel: "Calculator options", + optionsHelpText: "Each option has its own pricing labels, amount, note, and icon. Open the edit page to update the option details.", + optionsEmptyText: "No calculator options yet.", + addOptionLabel: "Add calculator option", + maxOptions: 3, + limitHelpText: "You can add up to 3 calculator options.", + defaultOption: { + paceLabel: "Target Pace", + minPaceLabel: "Relaxed", + maxPaceLabel: "Accelerated", + resultLabel: "Estimated Monthly Payment", + monthlyAmount: "299", + monthlySuffix: "/mo", + noteIcon: "fa-bolt", + note: "", + }, + }, + }, tabs: [ { key: "hero", @@ -102,8 +135,8 @@ module.exports = { label: "Tuition", icon: "fas fa-chart-column", schema: object("tuition", "Tuition", [ - text("title", "Section title", { maxLength: 60 }), - text("chartTitle", "Chart title", { maxLength: 60 }), + text("title", "Section title", { maxLength: 45 }), + text("chartTitle", "Chart title", { maxLength: 40 }), textarea("chartDescription", "Chart description", { maxLength: 140, rows: 3, @@ -112,14 +145,14 @@ module.exports = { "series", "Chart series", [ - text("label", "Series label", { maxLength: 40 }), + text("label", "Series label", { maxLength: 20 }), { key: "color", label: "Series color", type: "color" }, objectList( "points", "Data points", [ text("time", "Time label", { - maxLength: 30, + maxLength: 14, placeholder: "Year 1", }), { @@ -170,18 +203,44 @@ module.exports = { objectList( "options", "Calculator options", - [ - hidden("id"), - text("label", "Option label", { maxLength: 12 }), - text("paceLabel", "Pace label", { maxLength: 30 }), - text("minPaceLabel", "Minimum pace label", { maxLength: 20 }), - text("maxPaceLabel", "Maximum pace label", { maxLength: 20 }), - text("resultLabel", "Result label", { maxLength: 40 }), - text("monthlyAmount", "Monthly amount", { maxLength: 20 }), - text("monthlySuffix", "Monthly suffix", { maxLength: 10 }), - icon("noteIcon", "Note icon"), - text("note", "Note text", { maxLength: 60 }), - ], + [ + hidden("id"), + text("label", "Option label", { + maxLength: 12, + helpText: "This label appears in the calculator option switcher.", + }), + text("paceLabel", "Pace label", { + maxLength: 20, + helpText: "This appears above the pace slider.", + }), + text("minPaceLabel", "Minimum pace label", { + maxLength: 7, + helpText: "This appears on the left side of the pace slider.", + }), + text("maxPaceLabel", "Maximum pace label", { + maxLength: 7, + helpText: "This appears on the right side of the pace slider.", + }), + text("resultLabel", "Result label", { + maxLength: 40, + helpText: "This label appears above the calculated amount.", + }), + text("monthlyAmount", "Monthly amount", { + maxLength: 20, + helpText: "Enter digits only. The currency symbol is added on the website automatically.", + }), + text("monthlySuffix", "Monthly suffix", { + maxLength: 10, + helpText: "Example: /mo", + }), + icon("noteIcon", "Note icon", { + helpText: "Choose the icon shown beside the note.", + }), + text("note", "Note text", { + maxLength: 60, + helpText: "This short note appears under the amount.", + }), + ], { itemLabel: "Option", sortable: true, @@ -210,8 +269,8 @@ module.exports = { "items", "Scholarship items", [ - text("title", "Title", { maxLength: 50 }), - text("amount", "Amount", { maxLength: 24 }), + text("title", "Title", { maxLength: 40 }), + text("amount", "Amount", { maxLength: 12 }), textarea("description", "Description", { maxLength: 160, rows: 3, diff --git a/utils/contentEditors/historyConfig.js b/utils/contentEditors/historyConfig.js index 1d98b2a..342d210 100644 --- a/utils/contentEditors/historyConfig.js +++ b/utils/contentEditors/historyConfig.js @@ -73,7 +73,7 @@ module.exports = { "Milestones", [ text("year", "Year", { - maxLength: 30, + maxLength: 4, }), combobox("yearRange", "Year range", { maxLength: 30, @@ -83,7 +83,7 @@ module.exports = { maxLength: 40, optionsPath: "filters.categoryOptions", }), - text("categoryLabel", "Category badge label", { maxLength: 30 }), + text("categoryLabel", "Category badge label", { maxLength: 25 }), text("title", "Milestone title", { maxLength: 90 }), textarea("description", "Description", { maxLength: 260, diff --git a/utils/contentEditors/partnershipsConfig.js b/utils/contentEditors/partnershipsConfig.js index 43a17b1..9fbfdbf 100644 --- a/utils/contentEditors/partnershipsConfig.js +++ b/utils/contentEditors/partnershipsConfig.js @@ -30,6 +30,15 @@ module.exports = { previewPath: "/about/partnerships", dataFile: "partnerships", imageType: "partnerships", + editorUi: { + directory: { + tabsFrontendHint: "The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.", + partnersHelpText: "Each partner card keeps its own open or closed state automatically.", + }, + inquiryForm: { + fieldsHelpText: "Manage labels, placeholders, type, width, and dropdown options.", + }, + }, tabs: [ { key: "hero", @@ -115,7 +124,7 @@ module.exports = { label: "Inquiry Form", icon: "fas fa-envelope", schema: object("inquiryForm", "Inquiry form", [ - text("title", "Modal title", { maxLength: 60 }), + text("title", "Modal title", { maxLength: 35 }), objectList( "fields", "Form fields", diff --git a/utils/contentEditors/policiesConfig.js b/utils/contentEditors/policiesConfig.js index 95b100e..97f132d 100644 --- a/utils/contentEditors/policiesConfig.js +++ b/utils/contentEditors/policiesConfig.js @@ -46,7 +46,7 @@ const baseConfig = { schema: object("sidebar", "Sidebar", [ text("heading", "Sidebar heading", { maxLength: 30 }), 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 }), ]), }, diff --git a/utils/contentEditors/sharedFields.js b/utils/contentEditors/sharedFields.js index 6da8d58..23f5791 100644 --- a/utils/contentEditors/sharedFields.js +++ b/utils/contentEditors/sharedFields.js @@ -169,7 +169,7 @@ const variantList = (key, label, variants, options = {}) => ({ }); const linkFields = (prefix = "Link") => [ - text("label", `${prefix} label`, { maxLength: 60 }), + text("label", `${prefix} label`, { maxLength: 40 }), url("href", `${prefix} URL`, { maxLength: 255 }), ]; diff --git a/views/admin/accreditation/partials/editor-script.ejs b/views/admin/accreditation/partials/editor-script.ejs index ecf4b04..f7b66fa 100644 --- a/views/admin/accreditation/partials/editor-script.ejs +++ b/views/admin/accreditation/partials/editor-script.ejs @@ -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); }); @@ -52,6 +67,62 @@ 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) { const url = new URL(window.location.href); url.searchParams.set("tab", tabKey); diff --git a/views/admin/admissions/calculator-option.ejs b/views/admin/admissions/calculator-option.ejs index 295b7d1..51f7c0d 100644 --- a/views/admin/admissions/calculator-option.ejs +++ b/views/admin/admissions/calculator-option.ejs @@ -24,69 +24,69 @@
- - + +
-
This label appears in the calculator option switcher.
-
0/12
+
<%= fieldConfig.label?.helpText || "" %>
+
0/<%= fieldLimits.label %>
- - + +
-
This appears above the pace slider.
-
0/30
+
<%= fieldConfig.paceLabel?.helpText || "" %>
+
0/<%= fieldLimits.paceLabel %>
- - + +
-
This appears on the left side of the pace slider.
-
0/20
+
<%= fieldConfig.minPaceLabel?.helpText || "" %>
+
0/<%= fieldLimits.minPaceLabel %>
- - + +
-
This appears on the right side of the pace slider.
-
0/20
+
<%= fieldConfig.maxPaceLabel?.helpText || "" %>
+
0/<%= fieldLimits.maxPaceLabel %>
- - + +
-
This label appears above the calculated amount.
-
0/40
+
<%= fieldConfig.resultLabel?.helpText || "" %>
+
0/<%= fieldLimits.resultLabel %>
- +
-
Enter digits only. The currency symbol is added on the website automatically.
+
<%= fieldConfig.monthlyAmount?.helpText || "" %>
- - + +
-
Example: /mo
-
0/10
+
<%= fieldConfig.monthlySuffix?.helpText || "" %>
+
0/<%= fieldLimits.monthlySuffix %>
- +
-
Choose the icon shown beside the note.
+
<%= fieldConfig.noteIcon?.helpText || "" %>
- - + +
-
This short note appears under the amount.
-
0/60
+
<%= fieldConfig.note?.helpText || "" %>
+
0/<%= fieldLimits.note %>
@@ -137,7 +137,9 @@ diff --git a/views/admin/admissions/partials/editor-script.ejs b/views/admin/admissions/partials/editor-script.ejs index e88d3ac..c92db29 100644 --- a/views/admin/admissions/partials/editor-script.ejs +++ b/views/admin/admissions/partials/editor-script.ejs @@ -3,6 +3,7 @@ const config = window.pageEditorConfig; const initialData = window.pageEditorData; const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, ""); + const admissionsUi = config?.editorUi || {}; const form = document.getElementById("cmsEditorForm"); const pageJsonInput = document.getElementById("pageJson"); const activeTabInput = document.getElementById("activeTabInput"); @@ -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) { normalizeKeyDatesState(); const keyDates = state.keyDates; + const keyDatesUi = admissionsUi.keyDates || {}; + const titleField = getObjectFieldConfig("keyDates", "title"); const row = document.createElement("div"); row.className = "row g-3"; container.appendChild(row); renderLeafField( - { key: "title", label: "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, keyDates, "title", @@ -118,15 +142,15 @@ header.className = "d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3"; header.innerHTML = `
- -
Manage the table directly by adding or removing columns and rows.
+ +
${escapeHtml(keyDatesUi.tableHelpText || "Manage the table directly by adding or removing columns and rows.")}
`; @@ -150,8 +174,8 @@ const input = document.createElement("input"); input.type = "text"; input.className = "form-control"; - input.maxLength = 40; - input.placeholder = "Column name"; + input.maxLength = keyDatesUi.columnLabelMaxLength || 40; + input.placeholder = keyDatesUi.columnPlaceholder || "Column name"; input.value = column.label || ""; input.addEventListener("input", function () { column.label = input.value; @@ -183,7 +207,7 @@ const actionHead = document.createElement("th"); actionHead.className = "text-end"; actionHead.style.width = "72px"; - actionHead.textContent = "Actions"; + actionHead.textContent = keyDatesUi.actionsLabel || "Actions"; headRow.appendChild(actionHead); thead.appendChild(headRow); table.appendChild(thead); @@ -194,7 +218,7 @@ const emptyCell = document.createElement("td"); emptyCell.colSpan = keyDates.columns.length + 1; emptyCell.className = "text-center text-muted py-4"; - emptyCell.textContent = "No rows yet."; + emptyCell.textContent = keyDatesUi.emptyRowsText || "No rows yet."; emptyRow.appendChild(emptyCell); tbody.appendChild(emptyRow); } else { @@ -206,7 +230,7 @@ const input = document.createElement("input"); input.type = "text"; input.className = "form-control"; - input.maxLength = 60; + input.maxLength = keyDatesUi.cellMaxLength || 60; input.placeholder = column.label || `Column ${columnIndex + 1}`; input.value = rowItem.cells[columnIndex] || ""; input.addEventListener("input", function () { @@ -389,18 +413,21 @@ : Array.isArray(calculator.modelOptions) ? calculator.modelOptions.map((label) => ({ label })) : []; + const calculatorUi = admissionsUi.calculator || {}; + const defaultOption = calculatorUi.defaultOption || {}; + const optionLabelField = getObjectListItemFieldConfig("calculator", "options", "label"); calculator.options = rawOptions.slice(0, 3).map((option, index) => ({ id: String(option?.id || sanitizeId(option?.label || `option-${index + 1}`) || `option-${index + 1}`), - label: String(option?.label || `Option ${index + 1}`).slice(0, 12), - paceLabel: String(option?.paceLabel || "Target Pace"), - minPaceLabel: String(option?.minPaceLabel || "Relaxed"), - maxPaceLabel: String(option?.maxPaceLabel || "Accelerated"), - resultLabel: String(option?.resultLabel || "Estimated Monthly Payment"), - monthlyAmount: (((String(option?.monthlyAmount || "299").match(/\d[\d,]*/) || [])[0] || "299").replace(/,/g, "")), - monthlySuffix: String(option?.monthlySuffix || "/mo"), - noteIcon: String(option?.noteIcon || "fa-bolt"), - note: String(option?.note || ""), + label: String(option?.label || `Option ${index + 1}`).slice(0, optionLabelField.maxLength || 12), + paceLabel: String(option?.paceLabel || defaultOption.paceLabel || "Target Pace"), + minPaceLabel: String(option?.minPaceLabel || defaultOption.minPaceLabel || "Relaxed"), + maxPaceLabel: String(option?.maxPaceLabel || defaultOption.maxPaceLabel || "Accelerated"), + resultLabel: String(option?.resultLabel || defaultOption.resultLabel || "Estimated Monthly Payment"), + monthlyAmount: (((String(option?.monthlyAmount || defaultOption.monthlyAmount || "299").match(/\d[\d,]*/) || [])[0] || String(defaultOption.monthlyAmount || "299")).replace(/,/g, "")), + monthlySuffix: String(option?.monthlySuffix || defaultOption.monthlySuffix || "/mo"), + noteIcon: String(option?.noteIcon || defaultOption.noteIcon || "fa-bolt"), + note: String(option?.note || defaultOption.note || ""), })); delete calculator.modelOptions; @@ -418,19 +445,42 @@ normalizeCalculatorState(); const calculator = state.calculator; + const calculatorUi = admissionsUi.calculator || {}; + const titleField = getObjectFieldConfig("calculator", "title"); + const descriptionField = getObjectFieldConfig("calculator", "description"); + const ctaField = getObjectFieldConfig("calculator", "cta"); + const optionsField = getObjectFieldConfig("calculator", "options"); + const ctaLabelField = (ctaField.fields || []).find((field) => field.key === "label") || {}; + const ctaHrefField = (ctaField.fields || []).find((field) => field.key === "href") || {}; + const optionLabelField = getObjectListItemFieldConfig("calculator", "options", "label"); + const defaultOption = calculatorUi.defaultOption || {}; + const maxOptions = Number(calculatorUi.maxOptions) || 3; const row = document.createElement("div"); row.className = "row g-3"; container.appendChild(row); renderLeafField( - { key: "title", label: "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, calculator, "title", { path: "calculator.title", root: state, item: calculator }, ); 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, calculator, "description", @@ -443,22 +493,34 @@ const ctaHeader = document.createElement("div"); ctaHeader.className = "mb-3"; ctaHeader.innerHTML = ` - -
This button appears at the bottom of the calculator card.
+ +
${escapeHtml(calculatorUi.ctaHelpText || "This button appears at the bottom of the calculator card.")}
`; ctaCard.appendChild(ctaHeader); const ctaRow = document.createElement("div"); ctaRow.className = "row g-3"; ctaCard.appendChild(ctaRow); renderLeafField( - { key: "label", label: "Button label", type: "text", maxLength: 15 }, + { + key: "label", + label: ctaLabelField.label || "Button label", + type: ctaLabelField.type || "text", + maxLength: ctaLabelField.maxLength || 15, + helpText: ctaLabelField.helpText, + }, ctaRow, calculator.cta, "label", { path: "calculator.cta.label", root: state, item: calculator.cta }, ); renderLeafField( - { key: "href", label: "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, calculator.cta, "href", @@ -473,8 +535,8 @@ const optionsHeader = document.createElement("div"); optionsHeader.className = "mb-3"; optionsHeader.innerHTML = ` - -
Each option has its own pricing labels, amount, note, and icon. Open the edit page to update the option details.
+ +
${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.")}
`; optionsCard.appendChild(optionsHeader); @@ -485,7 +547,7 @@ if (!calculator.options.length) { const empty = document.createElement("div"); empty.className = "text-muted small"; - empty.textContent = "No calculator options yet."; + empty.textContent = optionsField.emptyText || calculatorUi.optionsEmptyText || "No calculator options yet."; list.appendChild(empty); } else { calculator.options.forEach((option, index) => { @@ -519,24 +581,24 @@ }); } - if (calculator.options.length < 3) { + if (calculator.options.length < maxOptions) { const addButton = document.createElement("button"); addButton.type = "button"; addButton.className = "cms-add-button mt-3"; - addButton.innerHTML = 'Add calculator option'; + addButton.innerHTML = `${escapeHtml(optionsField.addLabel || calculatorUi.addOptionLabel || "Add calculator option")}`; addButton.addEventListener("click", function () { const nextIndex = calculator.options.length + 1; calculator.options.push({ id: `option-${Date.now()}`, - label: `Option ${nextIndex}`.slice(0, 12), - paceLabel: "Target Pace", - minPaceLabel: "Relaxed", - maxPaceLabel: "Accelerated", - resultLabel: "Estimated Monthly Payment", - monthlyAmount: "299", - monthlySuffix: "/mo", - noteIcon: "fa-bolt", - note: "", + label: String(`Option ${nextIndex}`).slice(0, optionLabelField.maxLength || 12), + paceLabel: String(defaultOption.paceLabel || "Target Pace"), + minPaceLabel: String(defaultOption.minPaceLabel || "Relaxed"), + maxPaceLabel: String(defaultOption.maxPaceLabel || "Accelerated"), + resultLabel: String(defaultOption.resultLabel || "Estimated Monthly Payment"), + monthlyAmount: String(defaultOption.monthlyAmount || "299"), + monthlySuffix: String(defaultOption.monthlySuffix || "/mo"), + noteIcon: String(defaultOption.noteIcon || "fa-bolt"), + note: String(defaultOption.note || ""), }); renderSection("calculator"); }); @@ -544,7 +606,7 @@ } else { const limitNote = document.createElement("div"); 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); } diff --git a/views/admin/partnerships/index.ejs b/views/admin/partnerships/index.ejs index 8a4f41e..b44a7eb 100644 --- a/views/admin/partnerships/index.ejs +++ b/views/admin/partnerships/index.ejs @@ -20,34 +20,21 @@
- <%- include("partials/hero-tab", { activeTab, data, backendUrl }) %> - <%- include("partials/directory-tab", { activeTab, data }) %> - <%- include("partials/cta-tab", { activeTab, data }) %> - <%- include("partials/inquiry-form-tab", { activeTab, data }) %> + <%- include("partials/hero-tab", { activeTab, data, backendUrl, editorUi }) %> + <%- include("partials/directory-tab", { activeTab, data, editorUi }) %> + <%- include("partials/cta-tab", { activeTab, data, editorUi }) %> + <%- include("partials/inquiry-form-tab", { activeTab, data, editorUi }) %>
@@ -66,11 +53,13 @@ -<%- include("partials/templates") %> +<%- include("partials/templates", { editorUi }) %> <%- include("partials/editor-script") %> diff --git a/views/admin/partnerships/partials/cta-tab.ejs b/views/admin/partnerships/partials/cta-tab.ejs index f4ff0cf..6fdc5b2 100644 --- a/views/admin/partnerships/partials/cta-tab.ejs +++ b/views/admin/partnerships/partials/cta-tab.ejs @@ -1,4 +1,5 @@
+ <% const ctaUi = editorUi.cta || {}; %>
Call To Action
@@ -6,16 +7,16 @@
- - + +
- - + +
- - + +
diff --git a/views/admin/partnerships/partials/directory-tab.ejs b/views/admin/partnerships/partials/directory-tab.ejs index 14804fb..cfbc648 100644 --- a/views/admin/partnerships/partials/directory-tab.ejs +++ b/views/admin/partnerships/partials/directory-tab.ejs @@ -1,4 +1,5 @@
+ <% const directoryUi = editorUi.directory || {}; %>
Partner Directory
@@ -6,38 +7,38 @@
- - + +
- - + +
- -
The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.
+ +
<%= directoryUi.tabsFrontendHint || directoryUi.tabs?.helpText || "" %>
- -
Each partner card keeps its own open or closed state automatically.
+ +
<%= directoryUi.partnersHelpText || directoryUi.partners?.helpText || "" %>
diff --git a/views/admin/partnerships/partials/editor-script.ejs b/views/admin/partnerships/partials/editor-script.ejs index d3d1a48..24da14d 100644 --- a/views/admin/partnerships/partials/editor-script.ejs +++ b/views/admin/partnerships/partials/editor-script.ejs @@ -2,6 +2,8 @@ (function () { const initialData = window.partnershipsPageData; const backendUrl = (window.partnershipsBackendUrl || "").replace(/\/$/, ""); + const editorConfig = window.partnershipsEditorConfig || {}; + const editorUi = window.partnershipsEditorUi || {}; const form = document.getElementById("cmsEditorForm"); const pageJsonInput = document.getElementById("pageJson"); const activeTabInput = document.getElementById("activeTabInput"); @@ -14,6 +16,10 @@ } const state = JSON.parse(JSON.stringify(initialData)); + const directoryUi = editorUi.directory || {}; + const inquiryUi = editorUi.inquiryForm || {}; + const partnerFields = directoryUi.partnerFields || {}; + const inquiryFieldFields = inquiryUi.fieldFields || {}; const slugifyValue = (value, fallback) => String(value || "") .trim() @@ -32,6 +38,42 @@ renderAll(); initStaticCounters(); + function getTabConfig(tabKey) { + return (editorConfig.tabs || []).find((tab) => tab.key === tabKey) || {}; + } + + function getObjectFieldConfig(tabKey, fieldKey) { + const fields = getTabConfig(tabKey)?.schema?.fields || []; + return fields.find((field) => field.key === fieldKey) || {}; + } + + function getDefaultPartner() { + return { + name: "", + category: "", + summary: "", + logo: "", + logoAlt: "", + about: "", + collabType: "", + benefits: "", + }; + } + + function getDefaultInquiryField() { + const typeOptions = inquiryFieldFields.type?.options || []; + const widthOptions = inquiryFieldFields.width?.options || []; + + return { + label: "", + placeholder: "", + type: typeOptions[0]?.value || "text", + width: widthOptions[0]?.value || "full", + required: true, + options: [], + }; + } + function bindStaticEvents() { document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => { tabTrigger.addEventListener("shown.bs.tab", function () { @@ -50,29 +92,13 @@ }); document.getElementById("addPartnerBtn")?.addEventListener("click", function () { - state.directory.partners.push({ - name: "", - category: "", - summary: "", - logo: "", - logoAlt: "", - about: "", - collabType: "", - benefits: "", - }); + state.directory.partners.push(getDefaultPartner()); ensurePartnershipIds(); renderPartners(); }); document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () { - state.inquiryForm.fields.push({ - label: "", - placeholder: "", - type: "text", - width: "full", - required: true, - options: [], - }); + state.inquiryForm.fields.push(getDefaultInquiryField()); ensurePartnershipIds(); renderInquiryFields(); }); @@ -81,8 +107,24 @@ window.location.reload(); }); - form.addEventListener("submit", function () { + form.addEventListener("submit", function (event) { syncStaticFields(); + const duplicateTabs = getDuplicateTabs(state?.directory?.tabs); + + clearDirectoryTabsValidation(); + + if (duplicateTabs.length > 0) { + const tabsLabel = directoryUi.tabs?.label || "Category tab"; + event.preventDefault(); + highlightDuplicateDirectoryTabs(duplicateTabs); + showToast( + `Duplicate ${tabsLabel.toLowerCase()}`, + `${tabsLabel} "${duplicateTabs[0]}" already exists. Please use unique tab names before saving.`, + "danger", + ); + return; + } + ensurePartnershipIds(); pageJsonInput.value = JSON.stringify(state); }); @@ -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() { renderDirectoryTabs(); renderPartners(); @@ -205,7 +296,7 @@ input.addEventListener("input", function () { partner[field] = input.value; if (field === "name") { - title.textContent = input.value || `Partner ${index + 1}`; + title.textContent = input.value || `${partnerFields.name?.label || "Partner"} ${index + 1}`; } if (field === "category") { subtitle.textContent = input.value || ""; @@ -287,8 +378,8 @@ input.value = field[key] || ""; input.addEventListener("input", function () { field[key] = input.value; - if (key === "label") { - title.textContent = input.value || `Field ${index + 1}`; + if (key === "label") { + title.textContent = input.value || `${inquiryFieldFields.label?.label || "Field"} ${index + 1}`; } if (key === "type") { subtitle.textContent = input.value || ""; diff --git a/views/admin/partnerships/partials/hero-tab.ejs b/views/admin/partnerships/partials/hero-tab.ejs index 4af8b70..655d6d6 100644 --- a/views/admin/partnerships/partials/hero-tab.ejs +++ b/views/admin/partnerships/partials/hero-tab.ejs @@ -1,4 +1,5 @@
+ <% const heroUi = editorUi.hero || {}; %>
Hero
@@ -6,35 +7,35 @@
- - + +
- - + +
- - + +
- - + +
- +
-
Recommended 720x630 px
+
<%= [heroUi.image?.helpText, heroUi.image?.imageHint].filter(Boolean).join(" ") %>
- - + +
diff --git a/views/admin/partnerships/partials/inquiry-form-tab.ejs b/views/admin/partnerships/partials/inquiry-form-tab.ejs index 3813c61..0328e78 100644 --- a/views/admin/partnerships/partials/inquiry-form-tab.ejs +++ b/views/admin/partnerships/partials/inquiry-form-tab.ejs @@ -1,4 +1,5 @@
+ <% const inquiryUi = editorUi.inquiryForm || {}; %>
Inquiry Form
@@ -6,21 +7,21 @@
- - + +
- -
Manage labels, placeholders, type, width, and dropdown options.
+ +
<%= inquiryUi.fieldsHelpText || inquiryUi.fields?.helpText || "" %>
diff --git a/views/admin/partnerships/partials/templates.ejs b/views/admin/partnerships/partials/templates.ejs index d5726f2..6d0c05a 100644 --- a/views/admin/partnerships/partials/templates.ejs +++ b/views/admin/partnerships/partials/templates.ejs @@ -1,3 +1,8 @@ +<% const directoryUi = editorUi.directory || {}; %> +<% const partnerFields = directoryUi.partnerFields || {}; %> +<% const inquiryUi = editorUi.inquiryForm || {}; %> +<% const inquiryFieldFields = inquiryUi.fieldFields || {}; %> + @@ -47,44 +52,44 @@
- - + +
- - + +
- - -
The card preview is capped at 130 characters.
+ + +
<%= partnerFields.summary?.helpText || "" %>
- +
-
Recommended 105x80 px minimum visible ratio
+
<%= [partnerFields.logo?.helpText, partnerFields.logo?.imageHint].filter(Boolean).join(" ") %>
- - + +
- - + +
- - + +
- - + +
@@ -115,43 +120,44 @@
- - + +
- - + +
- +
- +
- +
- +
-
Only used when the field type is Dropdown.
+
<%= inquiryFieldFields.options?.helpText || "" %>
@@ -165,7 +171,7 @@ - +