diff --git a/controllers/_renderSingletonPageView.js b/controllers/_renderSingletonPageView.js index 3567858..e70650d 100644 --- a/controllers/_renderSingletonPageView.js +++ b/controllers/_renderSingletonPageView.js @@ -11,6 +11,11 @@ function createRenderSingletonPageView({ const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const backendUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`; + const defaultTab = editorConfig.tabs[0]?.key; + const requestedTab = req.query.tab; + const activeTab = editorConfig.tabs.some((tab) => tab.key === requestedTab) + ? requestedTab + : defaultTab; return res.render(view, { layout: "layouts/main", @@ -18,7 +23,7 @@ function createRenderSingletonPageView({ subtitle: editorConfig.subtitle, data, editorConfig, - activeTab: req.query.tab || editorConfig.tabs[0].key, + activeTab, frontendUrl, backendUrl, previewUrl: `${frontendUrl}${editorConfig.previewPath}`, diff --git a/controllers/admissionsController.js b/controllers/admissionsController.js index e6ab901..1bc6c42 100644 --- a/controllers/admissionsController.js +++ b/controllers/admissionsController.js @@ -3,12 +3,101 @@ const AUDIT_ACTIONS = require("../constants/auditAction"); const admissionsConfig = require("../utils/contentEditors/admissionsConfig"); const createPageContentController = require("./_createPageContentController"); const createRenderSingletonPageView = require("./_renderSingletonPageView"); +const jsonHelper = require("../utils/jsonHelper"); +const writeAuditLog = require("../audit/writeAuditLog"); +const diffObject = require("../audit/diffObject"); +const { ensureUniqueIds } = require("../utils/contentEditorIds"); +const { ICON_OPTIONS } = require("../utils/contentEditors/sharedFields"); + +function normalizePositiveAmount(value, fallback = "1") { + const match = String(value || "").match(/\d[\d,]*/); + const numericValue = Number((match ? match[0] : "").replace(/,/g, "")); + return numericValue > 0 ? String(numericValue) : fallback; +} + +function normalizeCalculatorOption(option, index, calculator) { + const source = typeof option === "string" ? { label: option } : { ...(option || {}) }; + + return { + ...source, + label: String(source.label || source.title || `Option ${index + 1}`).slice(0, 12), + paceLabel: String(source.paceLabel || calculator.paceLabel || "Target Pace"), + minPaceLabel: String(source.minPaceLabel || calculator.minPaceLabel || "Relaxed"), + maxPaceLabel: String(source.maxPaceLabel || calculator.maxPaceLabel || "Accelerated"), + resultLabel: String(source.resultLabel || calculator.resultLabel || "Estimated Monthly Payment"), + monthlyAmount: normalizePositiveAmount(source.monthlyAmount || calculator.monthlyAmount || "299", "299"), + monthlySuffix: String(source.monthlySuffix || calculator.monthlySuffix || "/mo"), + noteIcon: String(source.noteIcon || calculator.noteIcon || "fa-bolt"), + note: String(source.note || calculator.note || ""), + }; +} + +function normalizeCalculator(calculator) { + const nextCalculator = { ...(calculator || {}) }; + const legacyOptions = Array.isArray(nextCalculator.modelOptions) ? nextCalculator.modelOptions : []; + const rawOptions = Array.isArray(nextCalculator.options) && nextCalculator.options.length + ? nextCalculator.options + : legacyOptions; + + nextCalculator.title = String(nextCalculator.title || ""); + nextCalculator.description = String(nextCalculator.description || ""); + nextCalculator.cta = { + label: String(nextCalculator?.cta?.label || "").slice(0, 15), + href: String(nextCalculator?.cta?.href || ""), + }; + + nextCalculator.options = ensureUniqueIds( + rawOptions.slice(0, 3).map((option, index) => normalizeCalculatorOption(option, index, nextCalculator)), + (item) => item.id, + (item) => item.label, + "calculator-option", + ); + + delete nextCalculator.modelOptions; + delete nextCalculator.paceLabel; + delete nextCalculator.minPaceLabel; + delete nextCalculator.maxPaceLabel; + delete nextCalculator.resultLabel; + delete nextCalculator.monthlyAmount; + delete nextCalculator.monthlySuffix; + delete nextCalculator.noteIcon; + delete nextCalculator.note; + + return nextCalculator; +} + +function normalizeAdmissionsPayload(rawPayload) { + const payload = JSON.parse(JSON.stringify(rawPayload || {})); + + payload.process = { + ...(payload.process || {}), + id: "admissions-process", + }; + payload.eligibility = { + ...(payload.eligibility || {}), + id: "eligibility", + }; + payload.tuition = { + ...(payload.tuition || {}), + id: "tuition-breakdown", + }; + payload.keyDates = { + ...(payload.keyDates || {}), + id: "key-dates", + }; + payload.calculator = normalizeCalculator(payload.calculator); + + return payload; +} const controller = createPageContentController({ model: AdmissionsPage, modelName: "AdmissionsPage", auditAction: AUDIT_ACTIONS.UPDATE_ADMISSIONS, editorConfig: admissionsConfig, + preparePayload: normalizeAdmissionsPayload, + normalizeForEditor: normalizeAdmissionsPayload, + normalizeForApi: normalizeAdmissionsPayload, }); controller.index = async function index(req, res) { @@ -17,6 +106,7 @@ controller.index = async function index(req, res) { model: AdmissionsPage, editorConfig: admissionsConfig, view: "admin/admissions/index", + normalizeForEditor: normalizeAdmissionsPayload, })(req, res); } catch (error) { console.error("admissions index error:", error); @@ -25,4 +115,100 @@ controller.index = async function index(req, res) { } }; +controller.editCalculatorOption = async function editCalculatorOption(req, res) { + try { + const optionId = String(req.params.optionId || ""); + const doc = await AdmissionsPage.getSingle(); + const data = normalizeAdmissionsPayload(doc.toObject()); + const option = data.calculator.options.find((item) => item.id === optionId); + + if (!option) { + req.flash("error_msg", "Calculator option not found"); + return req.session.save(() => res.redirect("/admin/admissions?tab=calculator")); + } + + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const backendUrl = + process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`; + + return res.render("admin/admissions/calculator-option", { + layout: "layouts/main", + title: `Edit ${option.label}`, + subtitle: "Update the calculator option details", + option, + iconOptions: ICON_OPTIONS, + editorConfig: admissionsConfig, + previewUrl: `${frontendUrl}${admissionsConfig.previewPath}`, + currentPath: req.path, + backendUrl, + user: req.session.user, + }); + } catch (error) { + console.error("admissions calculator option index error:", error); + req.flash("error_msg", "Error loading calculator option"); + return req.session.save(() => res.redirect("/admin/admissions?tab=calculator")); + } +}; + +controller.updateCalculatorOption = async function updateCalculatorOption(req, res) { + try { + const optionId = String(req.params.optionId || ""); + const doc = await AdmissionsPage.getSingle(); + const beforeData = JSON.parse(JSON.stringify(doc.toObject())); + const payload = normalizeAdmissionsPayload(beforeData); + const optionIndex = payload.calculator.options.findIndex((item) => item.id === optionId); + + if (optionIndex === -1) { + req.flash("error_msg", "Calculator option not found"); + return req.session.save(() => res.redirect("/admin/admissions?tab=calculator")); + } + + payload.calculator.options[optionIndex] = { + ...payload.calculator.options[optionIndex], + label: String(req.body.label || "").trim().slice(0, 12), + paceLabel: String(req.body.paceLabel || "").trim(), + minPaceLabel: String(req.body.minPaceLabel || "").trim(), + maxPaceLabel: String(req.body.maxPaceLabel || "").trim(), + resultLabel: String(req.body.resultLabel || "").trim(), + monthlyAmount: normalizePositiveAmount(req.body.monthlyAmount, payload.calculator.options[optionIndex].monthlyAmount || "1"), + monthlySuffix: String(req.body.monthlySuffix || "").trim(), + noteIcon: String(req.body.noteIcon || "").trim(), + note: String(req.body.note || "").trim(), + }; + + const normalizedPayload = normalizeAdmissionsPayload(payload); + doc.set(normalizedPayload); + Object.keys(normalizedPayload).forEach((key) => doc.markModified(key)); + await doc.save(); + + const afterData = JSON.parse(JSON.stringify(doc.toObject())); + const changes = diffObject(beforeData, afterData); + + if (changes.length > 0) { + await writeAuditLog({ + model: "AdmissionsPage", + documentId: doc._id, + action: AUDIT_ACTIONS.UPDATE_ADMISSIONS, + before: beforeData, + after: afterData, + changes, + req, + }); + } + + const finalData = await AdmissionsPage + .findOne() + .select("-_id -__v -createdAt -updatedAt") + .lean(); + jsonHelper.writeJsonFile(admissionsConfig.dataFile, normalizeAdmissionsPayload(finalData)); + + req.flash("success_msg", "Calculator option updated successfully"); + return req.session.save(() => res.redirect(`/admin/admissions/calculator/${optionId}`)); + } catch (error) { + console.error("admissions calculator option update error:", error); + req.flash("error_msg", `Error updating calculator option: ${error.message}`); + return req.session.save(() => res.redirect(`/admin/admissions/calculator/${req.params.optionId}`)); + } +}; + module.exports = controller; diff --git a/controllers/historyPageController.js b/controllers/historyPageController.js index e4f004b..794418b 100644 --- a/controllers/historyPageController.js +++ b/controllers/historyPageController.js @@ -3,12 +3,109 @@ const AUDIT_ACTIONS = require("../constants/auditAction"); const historyConfig = require("../utils/contentEditors/historyConfig"); const createPageContentController = require("./_createPageContentController"); const createRenderSingletonPageView = require("./_renderSingletonPageView"); +const { ensureUniqueIds } = require("../utils/contentEditorIds"); + +function getFrontendUrl(req) { + return (process.env.FRONTEND_URL || "http://localhost:3000").replace(/\/$/, ""); +} + +function normalizeInternalHistoryHref(rawHref, req) { + const href = String(rawHref || "").trim(); + + if (!href) { + return ""; + } + + if (href.startsWith("#")) { + return href; + } + + if (href.startsWith("/")) { + return href; + } + + const frontendUrl = getFrontendUrl(req); + + try { + const url = new URL(href); + const frontendOrigin = new URL(frontendUrl).origin; + + if (url.origin !== frontendOrigin) { + throw new Error("Highlight link only supports internal anchors or frontend paths."); + } + + return `${url.pathname}${url.search}${url.hash}` || "/"; + } catch (error) { + if (href.startsWith("http://") || href.startsWith("https://")) { + throw new Error("Highlight link only supports internal anchors or frontend paths."); + } + + return href.startsWith("?") ? `/about/history${href}` : `/${href.replace(/^\/+/, "")}`; + } +} + +function normalizeHistoryForApi(rawData, req) { + const data = JSON.parse(JSON.stringify(rawData || {})); + const href = data?.highlight?.href; + const frontendUrl = getFrontendUrl(req); + const backendUrl = `${req.protocol}://${req.get("host")}`.replace(/\/$/, ""); + + if (!href || href.startsWith("#")) { + return data; + } + + if (/^https?:\/\//i.test(href)) { + try { + const url = new URL(href); + const frontendOrigin = new URL(frontendUrl).origin; + const backendOrigin = new URL(backendUrl).origin; + + if (url.origin === frontendOrigin) { + data.highlight.href = `${frontendUrl}${url.pathname}${url.search}${url.hash}`; + return data; + } + + if (url.origin === backendOrigin) { + data.highlight.href = `${frontendUrl}/about/history${url.hash || ""}`; + return data; + } + } catch { + data.highlight.href = `${frontendUrl}/about/history`; + return data; + } + + data.highlight.href = `${frontendUrl}/about/history`; + return data; + } + + data.highlight.href = `${frontendUrl}${href.startsWith("/") ? href : `/${href}`}`; + return data; +} const controller = createPageContentController({ model: HistoryPage, modelName: "HistoryPage", auditAction: AUDIT_ACTIONS.UPDATE_HISTORY, editorConfig: historyConfig, + preparePayload(rawPayload, { req }) { + const payload = JSON.parse(JSON.stringify(rawPayload || {})); + + if (payload.highlight) { + payload.highlight.href = normalizeInternalHistoryHref(payload.highlight.href, req); + } + + if (payload.timeline && Array.isArray(payload.timeline.items)) { + payload.timeline.items = ensureUniqueIds( + payload.timeline.items, + (item) => item.id, + (item, index) => item.title || item.year || `milestone-${index + 1}`, + "milestone", + ); + } + + return payload; + }, + normalizeForApi: normalizeHistoryForApi, }); controller.index = async function index(req, res) { diff --git a/controllers/partnershipsController.js b/controllers/partnershipsController.js index 6d3ccff..40723d1 100644 --- a/controllers/partnershipsController.js +++ b/controllers/partnershipsController.js @@ -3,6 +3,7 @@ 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 toInquiryField(id, field, type, width) { return { @@ -52,17 +53,30 @@ function prepareInquiryPayload(payload) { const fields = Array.isArray(normalized?.inquiryForm?.fields) ? normalized.inquiryForm.fields : []; + const partners = Array.isArray(normalized?.directory?.partners) + ? normalized.directory.partners + : []; return { ...normalized, + directory: { + ...normalized.directory, + partners: ensureUniqueIds( + partners, + (partner) => partner.id, + (partner, index) => partner.name || partner.category || `partner-${index + 1}`, + "partner", + ), + }, inquiryForm: { ...normalized.inquiryForm, - fields: fields.map((field, index) => ({ - id: - String(field.id || `field-${index + 1}`) - .trim() - .replace(/\s+/g, "") - .replace(/[^a-zA-Z0-9_-]/g, "") || `field-${index + 1}`, + fields: ensureUniqueIds( + fields, + (field) => field.id, + (field, index) => field.label || field.placeholder || `field-${index + 1}`, + "field", + ).map((field) => ({ + id: field.id, label: field.label || "", placeholder: field.placeholder || "", type: field.type || "text", diff --git a/controllers/policiesController.js b/controllers/policiesController.js index c7cd580..2428991 100644 --- a/controllers/policiesController.js +++ b/controllers/policiesController.js @@ -9,6 +9,7 @@ const createRenderSingletonPageView = require("./_renderSingletonPageView"); const writeAuditLog = require("../audit/writeAuditLog"); const diffObject = require("../audit/diffObject"); const jsonHelper = require("../utils/jsonHelper"); +const { ensureUniqueIds } = require("../utils/contentEditorIds"); function formatLastUpdated(date = new Date()) { return `Last updated: ${new Intl.DateTimeFormat("en-US", { @@ -18,9 +19,26 @@ function formatLastUpdated(date = new Date()) { }).format(date)}`; } -function withLastUpdated(payload) { +function withLastUpdated(payload, { beforeData } = {}) { + const existingPolicies = Array.isArray(beforeData?.policies) ? beforeData.policies : []; + + const policies = ensureUniqueIds( + Array.isArray(payload.policies) ? payload.policies : [], + (policy) => policy.id, + (policy, index) => policy.navLabel || policy.title || `policy-${index + 1}`, + "policy", + ).map((policy) => { + const existingPolicy = existingPolicies.find((item) => item.id === policy.id); + + return { + ...policy, + sections: Array.isArray(existingPolicy?.sections) ? existingPolicy.sections : [], + }; + }); + return { ...payload, + policies, hero: { ...(payload.hero || {}), lastUpdated: formatLastUpdated(), diff --git a/data/accreditation.json b/data/accreditation.json index ba4ddc5..a8e498c 100644 --- a/data/accreditation.json +++ b/data/accreditation.json @@ -1,20 +1,4 @@ { - "trustBanner": { - "icon": "fa-shield-check", - "text": "All our programs are rigorously evaluated and internationally recognized.", - "links": [ - { - "label": "Verify Status", - "href": "#accreditations-grid", - "icon": "fa-magnifying-glass" - }, - { - "label": "View Legal Disclaimers", - "href": "#accreditations-grid", - "icon": "" - } - ] - }, "hero": { "badge": "Accreditation Save Check", "title": "ACCREDITATION AND RECOGNITION", @@ -91,4 +75,4 @@ } ] } -} \ No newline at end of file +} diff --git a/data/admissions.json b/data/admissions.json index 1dc456d..30dd3d5 100644 --- a/data/admissions.json +++ b/data/admissions.json @@ -1,29 +1,29 @@ { "hero": { - "badge": "Your Path Starts Here", - "title": "Admissions & Transparent Tuition", - "description": "We believe high-quality education should be accessible to everyone. Explore our straightforward admissions process and flexible payment models designed to fit your life.", + "badge": "Your Path Starts HereYour Path Starts He", + "title": "Admissions & Transparent TuitionAdmissions & Transparent TuitionAdmissions & Tra", + "description": "We believe high-quality education should be accessible to everyone. Explore our straightforward admissions process and flexible payment models designed to fit your life.We believe high-quality education should be accessible to everyone. Exp", "primaryCta": { - "label": "Start Application", + "label": "Start ApplicationStart ApplicationStart ApplicationStart App", "href": "http://localhost:3001/admin/admissions" }, "secondaryCta": { - "label": "View Tuition", + "label": "View TuitionView TuitionView TuitionView TuitionView Tuition", "href": "http://localhost:3001/admin/admissions" }, - "image": "/uploads/admissions/hero-students.png", - "imageAlt": "Diverse adult students studying online" + "image": "/uploads/admissions/Colorful_Square_Background.png", + "imageAlt": "View TuitionView TuitionView TuitionView TuitionView TuitionView TuitionView TuitionView TuitionView TuitionView Tuition" }, "process": { "id": "admissions-process", - "title": "Admissions Process", - "description": "Our streamlined process gets you from application to enrolled in days, not months. No application fees, no standardized tests.", + "title": "Admissions ProcessAdmissions ProcessAdmissions ProcessAdmiss", + "description": "Our streamlined process gets you from application to enrolled in days, not months. No application fees, no standardized tests.Our streamlined process gets you from application to e", "steps": [ { "number": "01", "title": "Send Transcripts", "description": "Request official transcripts from previous institutions for credit evaluation.", - "active": false + "active": true }, { "number": "02", @@ -41,52 +41,53 @@ }, "eligibility": { "id": "eligibility", - "title": "Eligibility & Transfer Credits", + "title": "Eligibility & Transfer CreditsEligibility & Transfer Credits", "cards": [ { - "title": "Basic Eligibility", + "title": "Basic EligibilityBasic EligibilityBasic Eligibilit", "icon": "fa-check-circle", "items": [ - "High school diploma or equivalent", - "Minimum 2.0 GPA for transfer students", - "English proficiency if applicable" + "High school diploma or equivalentHigh school diploma or equivalentHigh school diploma or equivalentHigh school diploma o", + "Minimum 2.0 GPA for transfer studentsMinimum 2.0 GPA for transfer studentsMinimum 2.0 GPA for transfer studentsMinimum 2", + "English proficiency if applicableEnglish proficiency if applicableEnglish proficiency if applicableEnglish proficiency i" ] }, { - "title": "Transfer Policy", + "title": "Transfer PolicyTransfer PolicyTransfer PolicyTrans", "icon": "fa-exchange-alt", "items": [ - "Up to 90 credits accepted for Bachelor's", - "Free unofficial evaluation within 48 hours", - "Credit for prior learning and certifications" + "Up to 90 credits accepted for Bachelor'sUp to 90 credits accepted for Bachelor'sUp to 90 credits accepted for Bachelor's", + "Free unofficial evaluation within 48 hoursFree unofficial evaluation within 48 hoursFree unofficial evaluation within 48", + "Credit for prior learning and certificationsCredit for prior learning and certificationsCredit for prior learning and ce" ] } ] }, "tuition": { "id": "tuition-breakdown", - "title": "Tuition Breakdown", - "chartTitle": "Savings vs. Traditional University", - "chartDescription": "Estimated total cost for a 4-year degree", + "title": "Tuition BreakdownTuition BreakdownTuition BreakdownTuition B", + "chartTitle": "Savings vs. Traditional UniversitySavings vs. Traditional Un", + "chartDescription": "Estimated total cost for a 4-year degreeEstimated total cost for a 4-year degreeEstimated total cost for a 4-year degreeEstimated total cost", "series": [ { "label": "Traditional University", - "color": "#0F172A", + "color": "#850f0f", "values": [ 25000, - 50000, - 75000, - 100000 + 49998, + 30000, + 100000, + 120000 ] }, { "label": "LAMS", - "color": "#c49b27", + "color": "#a700b3", "values": [ 3588, 7176, 10764, - 14352 + 10000 ] } ] @@ -95,61 +96,112 @@ "id": "key-dates", "title": "Key Dates & Deadlines", "columns": [ - "Term", - "Application Deadline", - "Classes Start" + { + "id": "Term", + "label": "Term" + }, + { + "id": "Application-Deadline", + "label": "Application Deadline" + }, + { + "id": "Classes-Start", + "label": "Classes Start" + }, + { + "id": "column-4", + "label": "Column 4" + } ], "rows": [ { - "term": "Fall Term 1", - "applicationDeadline": "August 15, 2026", - "classesStart": "September 1, 2026" + "id": "row-1", + "cells": [ + "Fall Term 1", + "August 15, 2026August 15, 2026August 15, 2026August 15, 2026", + "September 1, 2026September 1, 2026September 1, 2026September", + "September 1, 2026September 1, 2026September 1, 2026September" + ] }, { - "term": "Fall Term 2", - "applicationDeadline": "October 15, 2026", - "classesStart": "November 1, 2026" + "id": "row-2", + "cells": [ + "Fall Term 2", + "October 15, 2026October 15, 2026October 15, 2026October 15, ", + "November 1, 2026November 1, 2026November 1, 2026November 1, ", + "September 1, 2026September 1, 2026September 1, 2026September" + ] }, { - "term": "Spring Term 1", - "applicationDeadline": "December 15, 2026", - "classesStart": "January 5, 2027" + "id": "row-3", + "cells": [ + "Spring Term 1", + "December 15, 2026December 15, 2026December 15, 2026December ", + "January 5, 2027", + "September 1, 2026September 1, 2026September 1, 2026September" + ] } ] }, "calculator": { "title": "Affordability Calculator", "description": "Estimate your monthly investment.", - "modelOptions": [ - "Subscription", - "Per Course" - ], - "paceLabel": "Target Pace", - "minPaceLabel": "Relaxed", - "maxPaceLabel": "Accelerated", - "resultLabel": "Estimated Monthly Payment", - "monthlyAmount": "$299", - "monthlySuffix": "/mo", - "noteIcon": "fa-bolt", - "note": "Flat rate, unlimited courses", "cta": { "label": "Apply Now", "href": "#apply" - } + }, + "options": [ + { + "id": "subscription", + "label": "Subscription", + "paceLabel": "Target PaceTarget PaceTarget P", + "minPaceLabel": "RelaxedRelaxedRelaxe", + "maxPaceLabel": "AcceleratedAccelerat", + "resultLabel": "Estimated Monthly PaymentEstimated Month", + "monthlyAmount": "5000000", + "monthlySuffix": "/mo", + "noteIcon": "fa-bolt", + "note": "Flat rate, unlimited coursesFlat rate, unlimited coursesFlat" + }, + { + "id": "per-course", + "label": "Per Course", + "paceLabel": "Target Pace", + "minPaceLabel": "Relaxed", + "maxPaceLabel": "Accelerated", + "resultLabel": "Estimated Monthly Payment", + "monthlyAmount": "500", + "monthlySuffix": "/mo", + "noteIcon": "fa-bolt", + "note": "Flat rate, unlimited courses" + }, + { + "id": "d-course", + "label": "D Course", + "paceLabel": "Target Pace", + "minPaceLabel": "Relaxed", + "maxPaceLabel": "Accelerated", + "resultLabel": "Estimated Monthly Payment", + "monthlyAmount": "500", + "monthlySuffix": "/mo", + "noteIcon": "fa-bolt", + "note": "Flat rate, unlimited courses" + } + ] }, "scholarships": { "title": "Scholarships & Aid", "icon": "fa-award", "items": [ { - "title": "Working Adult Grant", - "amount": "Up to $1,500", - "description": "For students employed full-time while studying." + "title": "Working Adult GrantWorking Adult GrantWorking Adul", + "amount": "Up to $1,500Up to $1,500", + "description": "For students employed full-time while studying.For students employed full-time while studying.For students employed full-time while studying.For students employ" }, { "title": "Military Discount", "amount": "15% Off", - "description": "Active duty, veterans, and spouses." + "description": "For students employed full-time while studying.For students employed full-time while studying.For students employed full-time while studying.For students employ" } ] } diff --git a/data/history.json b/data/history.json index f7fbd2d..cef9de3 100644 --- a/data/history.json +++ b/data/history.json @@ -1,24 +1,22 @@ { "highlight": { - "icon": "fa-trophy", - "text": "History Save Check", - "linkLabel": "Read Full Story", - "href": "#timeline-content" + "icon": "fa-magnifying-glass", + "text": "2025 Milestone Reached: A Rapidly Growing Global Community!2025 Milestone Reached: A Rapidly Growing Global Co", + "linkLabel": "Read Full StoryRead Full Story", + "href": "/admin/history" }, "hero": { - "badge": "Our Journey", - "title": "Building the Future of Education.", - "description": "From our humble beginnings to becoming a global leader in online education, explore the key moments that define our legacy." + "badge": "Our JourneyOur JourneyOur JourneyOur Jou", + "title": "Building the Future of Education.Building the Future of Education.Building the Future of E", + "description": "From our humble beginnings to becoming a global leader in online education, explore the key moments that define our legacy.From our humble beginnings to becoming a global leader in online education, explore the key momen" }, "filters": { - "yearLabel": "Decade / Year", - "categoryLabel": "Category", - "buttonLabel": "Apply Filters", "yearOptions": [ "All Years", "2020 - Present", "2010 - 2019", - "2005 - 2009" + "2005 - 2009", + "2015 - 2019" ], "categoryOptions": [ "All Categories", @@ -30,7 +28,6 @@ ] }, "timeline": { - "loadMoreLabel": "Load Earlier Milestones", "items": [ { "id": "student-experience-innovation", @@ -40,7 +37,7 @@ "categoryLabel": "Student Experience", "title": "Innovation in Student Experience", "description": "Enhanced student support through integrated digital services, academic advising, and career development platforms.", - "image": "/uploads/history/2026.png", + "image": "/uploads/history/Colorful_Square_Background.png", "imageAlt": "", "stats": [], "featured": true @@ -53,7 +50,7 @@ "categoryLabel": "Global", "title": "Expansion of International Partnerships", "description": "Established collaborations with academic institutions and industry partners across regions, enabling dual qualifications and cross-border learning opportunities.", - "image": "/uploads/history/2025.png", + "image": "/uploads/history/7281.jpg", "imageAlt": "", "stats": [], "featured": false @@ -92,11 +89,24 @@ "categoryLabel": "Academic", "title": "Strategic Academic Framework Introduced", "description": "Established a future-focused academic model aligned with international standards, integrating applied learning, digital competencies, and global perspectives.", - "image": "/uploads/history/2023.png", + "image": "/uploads/history/7281.jpg", "imageAlt": "", "stats": [], "featured": false + }, + { + "id": "strategic-academic-framework", + "year": "2027", + "yearRange": "2020 - Present", + "category": "All Categories", + "categoryLabel": "Academic", + "title": "Strategic Academic Framework Introduced", + "description": "Established a future-focused academic model aligned with international standards, integrating applied learning, digital competencies, and global perspectives.", + "image": "/uploads/history/kVI17_2B.webp", + "imageAlt": "aaa", + "stats": [], + "featured": true } ] } -} \ No newline at end of file +} diff --git a/data/partnerships.json b/data/partnerships.json index 631092a..e3790bd 100644 --- a/data/partnerships.json +++ b/data/partnerships.json @@ -18,7 +18,6 @@ "hehehehehehehehehehehehehehehe", "hehehehehehehhehehehehehehhehe" ], - "loadMoreLabel": "Load More PartnersLoad More PartnersLoad", "partners": [ { "id": "techvanguardtechvanguardtechvanguardtechvanguardte", @@ -102,7 +101,7 @@ "placeholder": "Partnership InquiryPartnership InquiryPa", "type": "text", "width": "half", - "required": true, + "required": false, "options": [] }, { @@ -146,7 +145,6 @@ "required": true, "options": [] } - ], - "submitLabel": "Partnership InquiryPartnership InquiryPa" + ] } -} \ No newline at end of file +} diff --git a/data/policies.json b/data/policies.json index 75c8dde..2ff9360 100644 --- a/data/policies.json +++ b/data/policies.json @@ -5,7 +5,7 @@ "titlePrefix": "Our Commitment to", "titleHighlight": "Transparency", "description": "Review our policies, terms of service, and commitments to privacy and accessibility. We believe in clear, straightforward communication with our academic community.", - "lastUpdated": "Last updated: April 20, 2026" + "lastUpdated": "Last updated: April 21, 2026" }, "sidebar": { "heading": "Policies", @@ -173,6 +173,14 @@ ] } ] + }, + { + "navLabel": "hehehe", + "title": "hehehe", + "effectiveDate": "hehehe", + "intro": "hehehe", + "id": "hehehe", + "sections": [] } ] } \ No newline at end of file diff --git a/routes/admin.js b/routes/admin.js index 6c69f6d..8e4b413 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -77,6 +77,16 @@ router.post( accreditationController.update, ); router.get("/admissions", ensureAuthenticated, admissionsController.index); +router.get( + "/admissions/calculator/:optionId", + ensureAuthenticated, + admissionsController.editCalculatorOption, +); +router.post( + "/admissions/calculator/:optionId/update", + ensureAuthenticated, + admissionsController.updateCalculatorOption, +); router.post( "/admissions/update", ensureAuthenticated, diff --git a/utils/contentEditorIds.js b/utils/contentEditorIds.js new file mode 100644 index 0000000..e6ff2d9 --- /dev/null +++ b/utils/contentEditorIds.js @@ -0,0 +1,35 @@ +function slugifyContentId(value, fallback = "item") { + const normalized = String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return normalized || fallback; +} + +function ensureUniqueIds(items, getExistingId, getSourceValue, fallbackPrefix) { + const usedIds = new Set(); + + return (Array.isArray(items) ? items : []).map((item, index) => { + const existingId = String(getExistingId(item, index) || "").trim(); + let nextId = existingId || slugifyContentId(getSourceValue(item, index), `${fallbackPrefix}-${index + 1}`); + let suffix = 2; + + while (usedIds.has(nextId)) { + nextId = `${existingId || slugifyContentId(getSourceValue(item, index), fallbackPrefix)}-${suffix}`; + suffix += 1; + } + + usedIds.add(nextId); + return { + ...item, + id: nextId, + }; + }); +} + +module.exports = { + slugifyContentId, + ensureUniqueIds, +}; diff --git a/utils/contentEditors/accreditationConfig.js b/utils/contentEditors/accreditationConfig.js index f2d8065..21c7854 100644 --- a/utils/contentEditors/accreditationConfig.js +++ b/utils/contentEditors/accreditationConfig.js @@ -3,7 +3,6 @@ const { textarea, image, icon, - url, select, combobox, object, @@ -19,40 +18,13 @@ const statusOptions = [ module.exports = { key: "accreditation", title: "Accreditation Management", - subtitle: "Edit content displayed on the accreditation page", + subtitle: "Manage the content for the accreditation page", routeBase: "/admin/accreditation", apiPath: "/api/accreditation", previewPath: "/about/accreditation", dataFile: "accreditation", imageType: "accreditation", tabs: [ - { - key: "trustBanner", - label: "Trust Banner", - icon: "fas fa-shield-check", - schema: object("trustBanner", "Trust banner", [ - icon("icon", "Banner icon"), - text("text", "Banner message", { - maxLength: 120, - helpText: - "This is the slim credibility strip shown above the accreditation content.", - }), - objectList( - "links", - "Banner links", - [ - text("label", "Link label", { maxLength: 40 }), - url("href", "Link URL", { maxLength: 255 }), - icon("icon", "Link icon"), - ], - { - itemLabel: "Link", - sortable: true, - emptyText: "No trust banner links yet.", - }, - ), - ]), - }, { key: "hero", label: "Hero", @@ -72,8 +44,7 @@ module.exports = { itemLabel: "Tab", maxLength: 30, sortable: true, - helpText: - "The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.", + helpText: "Add and reorder the category tabs shown on the page.", }), objectList( "items", @@ -82,7 +53,7 @@ module.exports = { icon("icon", "Fallback icon"), image("image", "Card image", { imageHint: "Recommended 118x58 px minimum visible ratio", - helpText: "Logo or badge used at the top of the card.", + helpText: "Logo or badge shown at the top of the card.", }), select("status", "Status", statusOptions), combobox("category", "Category", { @@ -91,19 +62,13 @@ module.exports = { }), text("title", "Card title", { maxLength: 17, - helpText: "Frontend title space is capped at 17 characters.", + helpText: "Keep this very short. Around 17 characters fits best in the card title area.", }), textarea("description", "Card description", { maxLength: 300, rows: 5, - helpText: "Frontend description preview is capped at 300 characters.", + helpText: "Keep this concise. Around 300 characters fits best in the card description area.", }), - text("scopeLabel", "Scope label", { maxLength: 20 }), - text("scope", "Scope text", { maxLength: 60 }), - text("validUntilLabel", "Validity label", { maxLength: 24 }), - text("validUntil", "Validity text", { maxLength: 40 }), - text("buttonLabel", "Certificate button label", { maxLength: 30 }), - url("certificateHref", "Certificate URL", { maxLength: 255 }), ], { itemLabel: "Accreditation", diff --git a/utils/contentEditors/admissionsConfig.js b/utils/contentEditors/admissionsConfig.js index ad63533..4482452 100644 --- a/utils/contentEditors/admissionsConfig.js +++ b/utils/contentEditors/admissionsConfig.js @@ -14,7 +14,7 @@ const { module.exports = { key: "admissions", title: "Admissions Management", - subtitle: "Edit content displayed on the admissions page", + subtitle: "Manage the content for the admissions page", routeBase: "/admin/admissions", apiPath: "/api/admissions", previewPath: "/admissions", @@ -43,7 +43,6 @@ module.exports = { label: "Admissions Process", icon: "fas fa-list-ol", schema: object("process", "Admissions process", [ - text("id", "Section key", { maxLength: 40 }), text("title", "Section title", { maxLength: 60 }), textarea("description", "Section description", { maxLength: 180, @@ -76,7 +75,6 @@ module.exports = { label: "Eligibility", icon: "fas fa-check-circle", schema: object("eligibility", "Eligibility", [ - text("id", "Section key", { maxLength: 40 }), text("title", "Section title", { maxLength: 60 }), objectList( "cards", @@ -104,7 +102,6 @@ module.exports = { label: "Tuition", icon: "fas fa-chart-column", schema: object("tuition", "Tuition", [ - text("id", "Section key", { maxLength: 40 }), text("title", "Section title", { maxLength: 60 }), text("chartTitle", "Chart title", { maxLength: 60 }), textarea("chartDescription", "Chart description", { @@ -120,6 +117,7 @@ module.exports = { stringList("values", "Data points", { itemLabel: "Point", fieldType: "number", + min: 0, sortable: true, }), ], @@ -137,30 +135,7 @@ module.exports = { label: "Key Dates", icon: "fas fa-calendar-days", schema: object("keyDates", "Key dates", [ - text("id", "Section key", { maxLength: 40 }), text("title", "Section title", { maxLength: 60 }), - stringList("columns", "Table columns", { - itemLabel: "Column", - maxLength: 40, - sortable: true, - }), - objectList( - "rows", - "Table rows", - [ - text("term", "Term", { maxLength: 40 }), - text("applicationDeadline", "Application deadline", { - maxLength: 40, - }), - text("classesStart", "Classes start", { maxLength: 40 }), - ], - { - itemLabel: "Row", - sortable: true, - itemTitleKey: "term", - emptyText: "No key date rows yet.", - }, - ), ]), }, { @@ -173,20 +148,37 @@ module.exports = { maxLength: 120, rows: 3, }), - stringList("modelOptions", "Model options", { - itemLabel: "Option", - maxLength: 30, - sortable: true, - }), - 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 }), object("cta", "Button", linkFields("Button")), + 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 }), + ], + { + itemLabel: "Option", + sortable: true, + itemTitleKey: "label", + emptyText: "No calculator options yet.", + itemActions: [ + { + label: "Edit option", + icon: "fas fa-pen", + className: "btn btn-outline-primary btn-sm", + hrefTemplate: "/admin/admissions/calculator/{id}", + }, + ], + }, + ), ]), }, { diff --git a/utils/contentEditors/historyConfig.js b/utils/contentEditors/historyConfig.js index de18966..1d98b2a 100644 --- a/utils/contentEditors/historyConfig.js +++ b/utils/contentEditors/historyConfig.js @@ -14,7 +14,7 @@ const { module.exports = { key: "history", title: "History Management", - subtitle: "Edit content displayed on the history page", + subtitle: "Manage the content for the history page", routeBase: "/admin/history", apiPath: "/api/history", previewPath: "/about/history", @@ -29,7 +29,10 @@ module.exports = { icon("icon", "Highlight icon"), text("text", "Message", { maxLength: 110 }), text("linkLabel", "Link label", { maxLength: 30 }), - url("href", "Link URL", { maxLength: 255 }), + url("href", "Link URL", { + maxLength: 255, + helpText: "Use an anchor or website path only. Examples: #milestones, /about/history, /contact", + }), ]), }, { @@ -47,14 +50,11 @@ module.exports = { label: "Filter Controls", icon: "fas fa-filter", schema: object("filters", "Filter controls", [ - text("yearLabel", "Year filter label", { maxLength: 30 }), - text("categoryLabel", "Category filter label", { maxLength: 30 }), - text("buttonLabel", "Apply button label", { maxLength: 30 }), stringList("yearOptions", "Year options", { itemLabel: "Year option", maxLength: 30, sortable: true, - helpText: "Reorder to control the dropdown order shown to users.", + helpText: "Reorder these options to change the order in the Year range dropdown.", }), stringList("categoryOptions", "Category options", { itemLabel: "Category option", @@ -68,13 +68,13 @@ module.exports = { label: "Timeline", icon: "fas fa-clock-rotate-left", schema: object("timeline", "Timeline", [ - text("loadMoreLabel", "Load more button label", { maxLength: 40 }), objectList( "items", "Milestones", [ - text("id", "Milestone key", { maxLength: 60 }), - text("year", "Year", { maxLength: 10 }), + text("year", "Year", { + maxLength: 30, + }), combobox("yearRange", "Year range", { maxLength: 30, optionsPath: "filters.yearOptions", @@ -90,7 +90,7 @@ module.exports = { rows: 4, }), image("image", "Milestone image", { - imageHint: "Recommended 436x190 px", + imageHint: "Recommended 436x190 px, 16:9 aspect ratio.", helpText: "Wide image used inside the milestone card.", }), text("imageAlt", "Image alt text", { maxLength: 120 }), diff --git a/utils/contentEditors/partnershipsConfig.js b/utils/contentEditors/partnershipsConfig.js index 5752fcb..43a17b1 100644 --- a/utils/contentEditors/partnershipsConfig.js +++ b/utils/contentEditors/partnershipsConfig.js @@ -24,7 +24,7 @@ const widthOptions = [ module.exports = { key: "partnerships", title: "Partnerships Management", - subtitle: "Edit content displayed on the partnerships page", + subtitle: "Manage the content for the partnerships page", routeBase: "/admin/partnerships", apiPath: "/api/partnerships", previewPath: "/about/partnerships", @@ -62,19 +62,12 @@ module.exports = { maxLength: 30, placeholder: "Industry", sortable: true, - helpText: - "The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.", + helpText: "Add and reorder the category tabs shown on the page.", }), - text("loadMoreLabel", "Load more button label", { maxLength: 40 }), objectList( "partners", "Partners", [ - text("id", "Partner key", { - maxLength: 50, - helpText: - "Use a short unique key. It keeps each card state separate on the frontend.", - }), text("name", "Partner name", { maxLength: 90 }), combobox("category", "Category", { maxLength: 30, @@ -83,7 +76,7 @@ module.exports = { textarea("summary", "Card summary", { maxLength: 130, rows: 3, - helpText: "The card preview is capped at 130 characters.", + helpText: "Keep this short. Around 130 characters works best in the card layout.", }), image("logo", "Partner logo", { imageHint: "Recommended 105x80 px minimum visible ratio", @@ -127,11 +120,6 @@ module.exports = { "fields", "Form fields", [ - text("id", "Field key", { - maxLength: 40, - helpText: - "Use a short unique key such as firstName or organization.", - }), text("label", "Field label", { maxLength: 40 }), text("placeholder", "Placeholder text", { maxLength: 80 }), select("type", "Field type", inquiryFieldOptions), @@ -153,7 +141,6 @@ module.exports = { emptyText: "No inquiry fields yet.", }, ), - text("submitLabel", "Submit button label", { maxLength: 40 }), ]), }, ], diff --git a/utils/contentEditors/policiesConfig.js b/utils/contentEditors/policiesConfig.js index 6742c34..af9fa64 100644 --- a/utils/contentEditors/policiesConfig.js +++ b/utils/contentEditors/policiesConfig.js @@ -13,7 +13,7 @@ const { const baseConfig = { key: "policies", title: "Policies Management", - subtitle: "Edit content displayed on the policies page", + subtitle: "Manage the content for the policies page", routeBase: "/admin/policies", apiPath: "/api/policies", previewPath: "/policies", @@ -56,7 +56,6 @@ const baseConfig = { "policies", "Policies", [ - text("id", "Policy key", { maxLength: 40 }), text("navLabel", "Sidebar label", { maxLength: 40 }), text("title", "Policy title", { maxLength: 70 }), text("effectiveDate", "Effective date", { maxLength: 50 }), @@ -128,7 +127,7 @@ function createPoliciesSectionEditorConfig(policy, allPolicies = []) { maxLength: 40, options: policyOptions, helpText: - "Optional. Choose another policy to switch tabs on the frontend.", + "Optional. Choose another policy to open when this link is clicked.", }), ], { diff --git a/utils/contentEditors/sharedFields.js b/utils/contentEditors/sharedFields.js index ebe332d..0e34342 100644 --- a/utils/contentEditors/sharedFields.js +++ b/utils/contentEditors/sharedFields.js @@ -112,6 +112,9 @@ const stringList = (key, label, options = {}) => ({ fieldType: options.fieldType || "text", label: options.itemLabel || "Item", maxLength: options.maxLength, + min: options.min, + max: options.max, + step: options.step, placeholder: options.placeholder, helpText: options.itemHelpText, }, diff --git a/views/admin/accreditation/index.ejs b/views/admin/accreditation/index.ejs index 6ce97f6..737bfd3 100644 --- a/views/admin/accreditation/index.ejs +++ b/views/admin/accreditation/index.ejs @@ -31,7 +31,6 @@
- <%- include("partials/trust-banner-tab", { activeTab }) %> <%- include("partials/hero-tab", { activeTab }) %> <%- include("partials/grid-tab", { activeTab }) %>
diff --git a/views/admin/accreditation/partials/editor-script.ejs b/views/admin/accreditation/partials/editor-script.ejs index ba01854..ecf4b04 100644 --- a/views/admin/accreditation/partials/editor-script.ejs +++ b/views/admin/accreditation/partials/editor-script.ejs @@ -468,37 +468,46 @@ renderAllSections(); }); col.appendChild(input); - appendHelp(col, schema, parent[key]); + appendHelp(col, { ...schema, maxLength: undefined }, parent[key]); container.appendChild(col); return; } if (schema.type === "combobox") { - const input = document.createElement("input"); - const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`; - input.className = "form-control"; - input.type = "text"; - input.value = parent[key] || ""; - if (schema.placeholder) input.placeholder = schema.placeholder; - if (schema.maxLength) input.maxLength = schema.maxLength; - input.setAttribute("list", listId); - input.addEventListener("input", function () { - parent[key] = input.value; - updateCounter(counter, input.value.length, schema.maxLength); + const input = document.createElement("select"); + input.className = "form-select"; + const options = resolveOptions(schema, context.root); + const optionValues = options.map((option) => + typeof option === "string" ? option : option.value, + ); + + options.forEach((option) => { + const optionEl = document.createElement("option"); + if (typeof option === "string") { + optionEl.value = option; + optionEl.textContent = option; + } else { + optionEl.value = option.value; + optionEl.textContent = option.label; + } + input.appendChild(optionEl); }); - const dataList = document.createElement("datalist"); - dataList.id = listId; - resolveOptions(schema, context.root).forEach((option) => { - const item = document.createElement("option"); - item.value = typeof option === "string" ? option : option.value; - item.label = typeof option === "string" ? option : option.label; - dataList.appendChild(item); + if (parent[key] && !optionValues.includes(parent[key])) { + const legacyOption = document.createElement("option"); + legacyOption.value = parent[key]; + legacyOption.textContent = parent[key]; + input.appendChild(legacyOption); + } + + input.value = parent[key] || input.options[0]?.value || ""; + parent[key] = input.value; + input.addEventListener("change", function () { + parent[key] = input.value; }); col.appendChild(input); - col.appendChild(dataList); - const counter = appendHelp(col, schema, parent[key]); + appendHelp(col, { ...schema, maxLength: undefined }, parent[key]); container.appendChild(col); return; } @@ -524,52 +533,113 @@ function renderIconField(schema, col, parent, key) { const selected = parent[key] || ""; const wrapper = document.createElement("div"); - wrapper.className = "border rounded-3 bg-white p-3"; + wrapper.className = "cms-icon-combobox"; + const trigger = document.createElement("button"); + trigger.type = "button"; + trigger.className = "cms-icon-dropdown-trigger"; - const top = document.createElement("div"); - top.className = "d-flex gap-2 align-items-center mb-3"; - - const input = document.createElement("input"); - input.type = "text"; - input.className = "form-control"; - input.value = selected; - input.placeholder = "fa-shield-check"; - input.setAttribute("list", "cms-icon-options"); - top.appendChild(input); + const triggerValue = document.createElement("div"); + triggerValue.className = "cms-icon-dropdown-value"; const preview = document.createElement("div"); - preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center"; - preview.style.width = "52px"; - preview.innerHTML = selected - ? `` - : '--'; - top.appendChild(preview); - wrapper.appendChild(top); + preview.className = "cms-icon-preview"; + triggerValue.appendChild(preview); - const grid = document.createElement("div"); - grid.className = "d-flex flex-wrap gap-2"; - (schema.options || []).forEach((option) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`; - button.innerHTML = `${escapeHtml(option)}`; - button.addEventListener("click", function () { - parent[key] = option; - input.value = option; - preview.innerHTML = ``; - renderAllSections(); - }); - grid.appendChild(button); - }); - wrapper.appendChild(grid); + const triggerText = document.createElement("div"); + triggerText.className = "cms-icon-dropdown-text"; + triggerValue.appendChild(triggerText); + trigger.appendChild(triggerValue); - input.addEventListener("input", function () { - parent[key] = input.value; - preview.innerHTML = input.value - ? `` + const caret = document.createElement("i"); + caret.className = "fas fa-chevron-down cms-icon-dropdown-caret"; + trigger.appendChild(caret); + wrapper.appendChild(trigger); + + const panel = document.createElement("div"); + panel.className = "cms-icon-dropdown-panel d-none"; + + const searchInput = document.createElement("input"); + searchInput.type = "text"; + searchInput.className = "form-control"; + searchInput.placeholder = "Search icon name"; + panel.appendChild(searchInput); + + const optionsWrap = document.createElement("div"); + optionsWrap.className = "cms-icon-options"; + panel.appendChild(optionsWrap); + + const empty = document.createElement("div"); + empty.className = "cms-icon-option-empty d-none"; + empty.textContent = "No matching icons"; + panel.appendChild(empty); + wrapper.appendChild(panel); + + const setOpen = function (isOpen) { + wrapper.classList.toggle("is-open", isOpen); + panel.classList.toggle("d-none", !isOpen); + if (isOpen) { + searchInput.focus(); + searchInput.select(); + } + }; + + const updateTrigger = function (value) { + preview.innerHTML = value + ? `` : '--'; + triggerText.innerHTML = value + ? `${escapeHtml(value)}Selected icon` + : 'Select iconNo icon selected'; + }; + + const renderOptions = function (searchTerm = "") { + const normalized = String(searchTerm || "").trim().toLowerCase(); + const filteredOptions = (schema.options || []).filter((option) => + option.toLowerCase().includes(normalized), + ); + + optionsWrap.innerHTML = ""; + empty.classList.toggle("d-none", filteredOptions.length > 0); + + filteredOptions.forEach((option) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`; + button.innerHTML = `${escapeHtml(option)}${parent[key] === option ? '' : ""}`; + button.addEventListener("click", function () { + parent[key] = option; + searchInput.value = option; + updateTrigger(option); + renderOptions(option); + setOpen(false); + }); + optionsWrap.appendChild(button); + }); + }; + + trigger.addEventListener("click", function () { + const nextOpen = panel.classList.contains("d-none"); + setOpen(nextOpen); + if (nextOpen) { + renderOptions(searchInput.value || parent[key] || ""); + } }); + searchInput.addEventListener("input", function () { + renderOptions(searchInput.value); + }); + + wrapper.addEventListener("focusout", function () { + window.setTimeout(function () { + if (!wrapper.contains(document.activeElement)) { + setOpen(false); + } + }, 0); + }); + + searchInput.value = selected; + updateTrigger(selected); + renderOptions(selected); col.appendChild(wrapper); appendHelp(col, schema, parent[key]); } @@ -607,7 +677,7 @@ container.appendChild(col); } - function appendHelp(col, schema, value, extraHint) { + function appendHelp(col, schema, value, extraHint, providedCounter) { const wrapper = document.createElement("div"); wrapper.className = "field-meta-row"; @@ -616,11 +686,14 @@ help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" "); wrapper.appendChild(help); - let counter = null; - if (schema.maxLength) { + let counter = providedCounter || null; + if (!counter && schema.maxLength) { counter = document.createElement("div"); counter.className = "field-char-count"; updateCounter(counter, String(value || "").length, schema.maxLength); + } + + if (counter) { wrapper.appendChild(counter); } @@ -883,3 +956,4 @@ })(); + diff --git a/views/admin/admissions/calculator-option.ejs b/views/admin/admissions/calculator-option.ejs new file mode 100644 index 0000000..295b7d1 --- /dev/null +++ b/views/admin/admissions/calculator-option.ejs @@ -0,0 +1,244 @@ +
+
+
+

<%= title %>

+

<%= subtitle %>

+
+ +
+ +
+
+
+
+
+
Option Details
+
+
+
+
+ + +
+
This label appears in the calculator option switcher.
+
0/12
+
+
+ +
+ + +
+
This appears above the pace slider.
+
0/30
+
+
+ +
+ + +
+
This appears on the left side of the pace slider.
+
0/20
+
+
+ +
+ + +
+
This appears on the right side of the pace slider.
+
0/20
+
+
+ +
+ + +
+
This label appears above the calculated amount.
+
0/40
+
+
+ +
+ + +
+
Enter digits only. The currency symbol is added on the website automatically.
+
+
+ +
+ + +
+
Example: /mo
+
0/10
+
+
+ +
+ +
+ + +
+ +
+
No matching icons
+
+
+
+
Choose the icon shown beside the note.
+
+
+ +
+ + +
+
This short note appears under the amount.
+
0/60
+
+
+
+
+
+ +
+ + + Cancel + + +
+
+
+
+
+ + diff --git a/views/admin/admissions/partials/editor-script.ejs b/views/admin/admissions/partials/editor-script.ejs index ba01854..e88d3ac 100644 --- a/views/admin/admissions/partials/editor-script.ejs +++ b/views/admin/admissions/partials/editor-script.ejs @@ -12,6 +12,11 @@ } const state = JSON.parse(JSON.stringify(initialData)); + const persistedCalculatorOptionIds = new Set( + Array.isArray(initialData?.calculator?.options) + ? initialData.calculator.options.map((item) => item.id).filter(Boolean) + : [], + ); const iconOptions = Array.from( new Set( (config.tabs || []) @@ -71,6 +76,17 @@ if (!tab || !container) return; container.innerHTML = ""; + + if (tabKey === "keyDates") { + renderKeyDatesSection(container); + return; + } + + if (tabKey === "calculator") { + renderCalculatorSection(container); + return; + } + renderField(tab.schema, container, state, tab.schema.key, tabKey, { path: tab.schema.key, root: state, @@ -78,6 +94,464 @@ }); } + function renderKeyDatesSection(container) { + normalizeKeyDatesState(); + + const keyDates = state.keyDates; + const row = document.createElement("div"); + row.className = "row g-3"; + container.appendChild(row); + + renderLeafField( + { key: "title", label: "Section title", type: "text", maxLength: 60 }, + row, + keyDates, + "title", + { path: "keyDates.title", root: state, item: keyDates }, + ); + + const tableCol = createCol("col-12"); + const card = document.createElement("div"); + card.className = "cms-editor-group"; + + const header = document.createElement("div"); + header.className = "d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3"; + header.innerHTML = ` +
+ +
Manage the table directly by adding or removing columns and rows.
+
+
+ + +
+ `; + card.appendChild(header); + + const tableWrap = document.createElement("div"); + tableWrap.className = "table-responsive"; + const table = document.createElement("table"); + table.className = "table align-middle mb-0"; + + const thead = document.createElement("thead"); + const headRow = document.createElement("tr"); + + keyDates.columns.forEach((column, columnIndex) => { + const th = document.createElement("th"); + th.style.minWidth = "220px"; + + const group = document.createElement("div"); + group.className = "d-flex align-items-start gap-2"; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "form-control"; + input.maxLength = 40; + input.placeholder = "Column name"; + input.value = column.label || ""; + input.addEventListener("input", function () { + column.label = input.value; + }); + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "cms-remove-button mt-1"; + removeButton.title = "Remove column"; + removeButton.innerHTML = ''; + removeButton.disabled = keyDates.columns.length <= 1; + removeButton.addEventListener("click", function () { + if (keyDates.columns.length <= 1) { + return; + } + keyDates.columns.splice(columnIndex, 1); + keyDates.rows.forEach((rowItem) => { + rowItem.cells.splice(columnIndex, 1); + }); + renderSection("keyDates"); + }); + + group.appendChild(input); + group.appendChild(removeButton); + th.appendChild(group); + headRow.appendChild(th); + }); + + const actionHead = document.createElement("th"); + actionHead.className = "text-end"; + actionHead.style.width = "72px"; + actionHead.textContent = "Actions"; + headRow.appendChild(actionHead); + thead.appendChild(headRow); + table.appendChild(thead); + + const tbody = document.createElement("tbody"); + if (keyDates.rows.length === 0) { + const emptyRow = document.createElement("tr"); + const emptyCell = document.createElement("td"); + emptyCell.colSpan = keyDates.columns.length + 1; + emptyCell.className = "text-center text-muted py-4"; + emptyCell.textContent = "No rows yet."; + emptyRow.appendChild(emptyCell); + tbody.appendChild(emptyRow); + } else { + keyDates.rows.forEach((rowItem, rowIndex) => { + const tr = document.createElement("tr"); + + keyDates.columns.forEach((column, columnIndex) => { + const td = document.createElement("td"); + const input = document.createElement("input"); + input.type = "text"; + input.className = "form-control"; + input.maxLength = 60; + input.placeholder = column.label || `Column ${columnIndex + 1}`; + input.value = rowItem.cells[columnIndex] || ""; + input.addEventListener("input", function () { + rowItem.cells[columnIndex] = input.value; + }); + td.appendChild(input); + tr.appendChild(td); + }); + + const actionCell = document.createElement("td"); + actionCell.className = "text-end"; + const removeRowButton = document.createElement("button"); + removeRowButton.type = "button"; + removeRowButton.className = "cms-remove-button"; + removeRowButton.title = "Remove row"; + removeRowButton.innerHTML = ''; + removeRowButton.addEventListener("click", function () { + keyDates.rows.splice(rowIndex, 1); + renderSection("keyDates"); + }); + actionCell.appendChild(removeRowButton); + tr.appendChild(actionCell); + + tbody.appendChild(tr); + }); + } + + table.appendChild(tbody); + tableWrap.appendChild(table); + card.appendChild(tableWrap); + tableCol.appendChild(card); + container.appendChild(tableCol); + + header.querySelector('[data-add-column="true"]').addEventListener("click", function () { + addKeyDatesColumn(); + renderSection("keyDates"); + }); + + header.querySelector('[data-add-row="true"]').addEventListener("click", function () { + addKeyDatesRow(); + renderSection("keyDates"); + }); + } + + function normalizeKeyDatesState() { + if (!isObject(state.keyDates)) { + state.keyDates = {}; + } + + const keyDates = state.keyDates; + if (typeof keyDates.id !== "string") { + keyDates.id = ""; + } + if (typeof keyDates.title !== "string") { + keyDates.title = ""; + } + + const legacyColumns = Array.isArray(keyDates.columns) ? keyDates.columns : []; + const legacyRows = Array.isArray(keyDates.rows) ? keyDates.rows : []; + keyDates.columns = normalizeKeyDatesColumns(legacyColumns, legacyRows); + keyDates.rows = normalizeKeyDatesRows(legacyRows, keyDates.columns); + } + + function normalizeKeyDatesColumns(columns, rows) { + if (Array.isArray(columns) && columns.length > 0) { + return columns.map((column, index) => { + if (isObject(column)) { + return { + id: column.id || `column-${index + 1}`, + label: column.label || `Column ${index + 1}`, + }; + } + + return { + id: sanitizeId(column) || `column-${index + 1}`, + label: String(column || `Column ${index + 1}`), + }; + }); + } + + const defaultLabels = extractLegacyKeyDateLabels(rows); + return defaultLabels.map((label, index) => ({ + id: sanitizeId(label) || `column-${index + 1}`, + label, + })); + } + + function normalizeKeyDatesRows(rows, columns) { + if (!Array.isArray(rows)) { + return []; + } + + return rows.map((row, rowIndex) => { + if (isObject(row) && Array.isArray(row.cells)) { + return { + id: row.id || `row-${rowIndex + 1}`, + cells: columns.map((_, columnIndex) => String(row.cells[columnIndex] || "")), + }; + } + + return { + id: isObject(row) && row.id ? row.id : `row-${rowIndex + 1}`, + cells: columns.map((column, columnIndex) => + extractLegacyKeyDateCell(row, column, columnIndex), + ), + }; + }); + } + + function extractLegacyKeyDateLabels(rows) { + const defaultLabels = ["Term", "Application Deadline", "Classes Start"]; + if (!Array.isArray(rows) || rows.length === 0 || !isObject(rows[0])) { + return defaultLabels; + } + + const row = rows[0]; + if ("term" in row || "applicationDeadline" in row || "classesStart" in row) { + return defaultLabels; + } + + const keys = Object.keys(row).filter((key) => key !== "id"); + return keys.length ? keys : defaultLabels; + } + + function extractLegacyKeyDateCell(row, column, columnIndex) { + if (!isObject(row)) { + return ""; + } + + const legacyKeys = ["term", "applicationDeadline", "classesStart"]; + const legacyKey = legacyKeys[columnIndex]; + if (legacyKey && typeof row[legacyKey] !== "undefined") { + return String(row[legacyKey] || ""); + } + + if (column && column.id && typeof row[column.id] !== "undefined") { + return String(row[column.id] || ""); + } + + return ""; + } + + function addKeyDatesColumn() { + normalizeKeyDatesState(); + const keyDates = state.keyDates; + const nextIndex = keyDates.columns.length + 1; + keyDates.columns.push({ + id: `column-${nextIndex}`, + label: `Column ${nextIndex}`, + }); + keyDates.rows.forEach((row) => { + row.cells.push(""); + }); + } + + function addKeyDatesRow() { + normalizeKeyDatesState(); + const keyDates = state.keyDates; + keyDates.rows.push({ + id: `row-${Date.now()}`, + cells: keyDates.columns.map(() => ""), + }); + } + + function normalizeCalculatorState() { + if (!isObject(state.calculator)) { + state.calculator = {}; + } + + const calculator = state.calculator; + calculator.title = String(calculator.title || ""); + calculator.description = String(calculator.description || ""); + calculator.cta = { + label: String(calculator?.cta?.label || "").slice(0, 15), + href: String(calculator?.cta?.href || ""), + }; + + const rawOptions = Array.isArray(calculator.options) + ? calculator.options + : Array.isArray(calculator.modelOptions) + ? calculator.modelOptions.map((label) => ({ label })) + : []; + + calculator.options = rawOptions.slice(0, 3).map((option, index) => ({ + id: String(option?.id || sanitizeId(option?.label || `option-${index + 1}`) || `option-${index + 1}`), + label: String(option?.label || `Option ${index + 1}`).slice(0, 12), + paceLabel: String(option?.paceLabel || "Target Pace"), + minPaceLabel: String(option?.minPaceLabel || "Relaxed"), + maxPaceLabel: String(option?.maxPaceLabel || "Accelerated"), + resultLabel: String(option?.resultLabel || "Estimated Monthly Payment"), + monthlyAmount: (((String(option?.monthlyAmount || "299").match(/\d[\d,]*/) || [])[0] || "299").replace(/,/g, "")), + monthlySuffix: String(option?.monthlySuffix || "/mo"), + noteIcon: String(option?.noteIcon || "fa-bolt"), + note: String(option?.note || ""), + })); + + delete calculator.modelOptions; + delete calculator.paceLabel; + delete calculator.minPaceLabel; + delete calculator.maxPaceLabel; + delete calculator.resultLabel; + delete calculator.monthlyAmount; + delete calculator.monthlySuffix; + delete calculator.noteIcon; + delete calculator.note; + } + + function renderCalculatorSection(container) { + normalizeCalculatorState(); + + const calculator = state.calculator; + const row = document.createElement("div"); + row.className = "row g-3"; + container.appendChild(row); + + renderLeafField( + { key: "title", label: "Card title", type: "text", maxLength: 60 }, + row, + calculator, + "title", + { path: "calculator.title", root: state, item: calculator }, + ); + renderLeafField( + { key: "description", label: "Card description", type: "textarea", maxLength: 120, rows: 3 }, + row, + calculator, + "description", + { path: "calculator.description", root: state, item: calculator }, + ); + + const ctaCardCol = createCol("col-12"); + const ctaCard = document.createElement("div"); + ctaCard.className = "cms-editor-group"; + const ctaHeader = document.createElement("div"); + ctaHeader.className = "mb-3"; + ctaHeader.innerHTML = ` + +
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 }, + ctaRow, + calculator.cta, + "label", + { path: "calculator.cta.label", root: state, item: calculator.cta }, + ); + renderLeafField( + { key: "href", label: "Button URL", type: "text", maxLength: 255 }, + ctaRow, + calculator.cta, + "href", + { path: "calculator.cta.href", root: state, item: calculator.cta }, + ); + ctaCardCol.appendChild(ctaCard); + container.appendChild(ctaCardCol); + + const optionsCol = createCol("col-12"); + const optionsCard = document.createElement("div"); + optionsCard.className = "cms-editor-group"; + const optionsHeader = document.createElement("div"); + optionsHeader.className = "mb-3"; + optionsHeader.innerHTML = ` + +
Each option has its own pricing labels, amount, note, and icon. Open the edit page to update the option details.
+ `; + optionsCard.appendChild(optionsHeader); + + const list = document.createElement("div"); + list.className = "d-flex flex-column gap-3"; + optionsCard.appendChild(list); + + if (!calculator.options.length) { + const empty = document.createElement("div"); + empty.className = "text-muted small"; + empty.textContent = "No calculator options yet."; + list.appendChild(empty); + } else { + calculator.options.forEach((option, index) => { + const item = document.createElement("div"); + item.className = "card cms-item-card"; + item.innerHTML = ` +
+
+
${escapeHtml(option.label || `Option ${index + 1}`)}
+
${escapeHtml(option.monthlyAmount)}${escapeHtml(option.monthlySuffix || "")}
+
+
+ ${ + persistedCalculatorOptionIds.has(option.id) + ? `Edit option` + : `` + } + +
+
+ `; + item + .querySelector(`[data-remove-option="${index}"]`) + .addEventListener("click", function () { + calculator.options.splice(index, 1); + renderSection("calculator"); + }); + list.appendChild(item); + }); + } + + if (calculator.options.length < 3) { + const addButton = document.createElement("button"); + addButton.type = "button"; + addButton.className = "cms-add-button mt-3"; + addButton.innerHTML = 'Add calculator option'; + addButton.addEventListener("click", function () { + const nextIndex = calculator.options.length + 1; + calculator.options.push({ + id: `option-${Date.now()}`, + label: `Option ${nextIndex}`.slice(0, 12), + paceLabel: "Target Pace", + minPaceLabel: "Relaxed", + maxPaceLabel: "Accelerated", + resultLabel: "Estimated Monthly Payment", + monthlyAmount: "299", + monthlySuffix: "/mo", + noteIcon: "fa-bolt", + note: "", + }); + renderSection("calculator"); + }); + optionsCard.appendChild(addButton); + } else { + const limitNote = document.createElement("div"); + limitNote.className = "form-text mt-3"; + limitNote.textContent = "You can add up to 3 calculator options."; + optionsCard.appendChild(limitNote); + } + + optionsCol.appendChild(optionsCard); + container.appendChild(optionsCol); + } + function renderField(schema, container, parent, key, tabKey, context) { if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) { return; @@ -283,6 +757,9 @@ label: fieldSchema.label || arraySchema.itemLabel || "Value", type: fieldSchema.fieldType || "text", maxLength: fieldSchema.maxLength, + min: fieldSchema.min, + max: fieldSchema.max, + step: fieldSchema.step, placeholder: fieldSchema.placeholder, helpText: fieldSchema.helpText, rows: fieldSchema.fieldType === "textarea" ? 3 : undefined, @@ -510,11 +987,28 @@ input.value = parent[key] || ""; if (schema.placeholder) input.placeholder = schema.placeholder; if (schema.maxLength) input.maxLength = schema.maxLength; + if (typeof schema.min !== "undefined") input.min = String(schema.min); + if (typeof schema.max !== "undefined") input.max = String(schema.max); if (schema.step) input.step = schema.step; input.addEventListener("input", function () { - parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value; + if (schema.type === "number") { + const normalizedValue = normalizeNumberInput(schema, input.value); + input.value = normalizedValue.displayValue; + parent[key] = normalizedValue.numericValue; + } else { + parent[key] = input.value; + } updateCounter(counter, String(input.value || "").length, schema.maxLength); }); + input.addEventListener("change", function () { + if (schema.type !== "number") { + return; + } + + const normalizedValue = normalizeNumberInput(schema, input.value); + input.value = normalizedValue.displayValue; + parent[key] = normalizedValue.numericValue; + }); col.appendChild(input); const counter = appendHelp(col, schema, parent[key]); @@ -524,52 +1018,113 @@ function renderIconField(schema, col, parent, key) { const selected = parent[key] || ""; const wrapper = document.createElement("div"); - wrapper.className = "border rounded-3 bg-white p-3"; + wrapper.className = "cms-icon-combobox"; + const trigger = document.createElement("button"); + trigger.type = "button"; + trigger.className = "cms-icon-dropdown-trigger"; - const top = document.createElement("div"); - top.className = "d-flex gap-2 align-items-center mb-3"; - - const input = document.createElement("input"); - input.type = "text"; - input.className = "form-control"; - input.value = selected; - input.placeholder = "fa-shield-check"; - input.setAttribute("list", "cms-icon-options"); - top.appendChild(input); + const triggerValue = document.createElement("div"); + triggerValue.className = "cms-icon-dropdown-value"; const preview = document.createElement("div"); - preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center"; - preview.style.width = "52px"; - preview.innerHTML = selected - ? `` - : '--'; - top.appendChild(preview); - wrapper.appendChild(top); + preview.className = "cms-icon-preview"; + triggerValue.appendChild(preview); - const grid = document.createElement("div"); - grid.className = "d-flex flex-wrap gap-2"; - (schema.options || []).forEach((option) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`; - button.innerHTML = `${escapeHtml(option)}`; - button.addEventListener("click", function () { - parent[key] = option; - input.value = option; - preview.innerHTML = ``; - renderAllSections(); - }); - grid.appendChild(button); - }); - wrapper.appendChild(grid); + const triggerText = document.createElement("div"); + triggerText.className = "cms-icon-dropdown-text"; + triggerValue.appendChild(triggerText); + trigger.appendChild(triggerValue); - input.addEventListener("input", function () { - parent[key] = input.value; - preview.innerHTML = input.value - ? `` + const caret = document.createElement("i"); + caret.className = "fas fa-chevron-down cms-icon-dropdown-caret"; + trigger.appendChild(caret); + wrapper.appendChild(trigger); + + const panel = document.createElement("div"); + panel.className = "cms-icon-dropdown-panel d-none"; + + const searchInput = document.createElement("input"); + searchInput.type = "text"; + searchInput.className = "form-control"; + searchInput.placeholder = "Search icon name"; + panel.appendChild(searchInput); + + const optionsWrap = document.createElement("div"); + optionsWrap.className = "cms-icon-options"; + panel.appendChild(optionsWrap); + + const empty = document.createElement("div"); + empty.className = "cms-icon-option-empty d-none"; + empty.textContent = "No matching icons"; + panel.appendChild(empty); + wrapper.appendChild(panel); + + const setOpen = function (isOpen) { + wrapper.classList.toggle("is-open", isOpen); + panel.classList.toggle("d-none", !isOpen); + if (isOpen) { + searchInput.focus(); + searchInput.select(); + } + }; + + const updateTrigger = function (value) { + preview.innerHTML = value + ? `` : '--'; + triggerText.innerHTML = value + ? `${escapeHtml(value)}Selected icon` + : 'Select iconNo icon selected'; + }; + + const renderOptions = function (searchTerm = "") { + const normalized = String(searchTerm || "").trim().toLowerCase(); + const filteredOptions = (schema.options || []).filter((option) => + option.toLowerCase().includes(normalized), + ); + + optionsWrap.innerHTML = ""; + empty.classList.toggle("d-none", filteredOptions.length > 0); + + filteredOptions.forEach((option) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`; + button.innerHTML = `${escapeHtml(option)}${parent[key] === option ? '' : ""}`; + button.addEventListener("click", function () { + parent[key] = option; + searchInput.value = option; + updateTrigger(option); + renderOptions(option); + setOpen(false); + }); + optionsWrap.appendChild(button); + }); + }; + + trigger.addEventListener("click", function () { + const nextOpen = panel.classList.contains("d-none"); + setOpen(nextOpen); + if (nextOpen) { + renderOptions(searchInput.value || parent[key] || ""); + } }); + searchInput.addEventListener("input", function () { + renderOptions(searchInput.value); + }); + + wrapper.addEventListener("focusout", function () { + window.setTimeout(function () { + if (!wrapper.contains(document.activeElement)) { + setOpen(false); + } + }, 0); + }); + + searchInput.value = selected; + updateTrigger(selected); + renderOptions(selected); col.appendChild(wrapper); appendHelp(col, schema, parent[key]); } @@ -636,6 +1191,35 @@ counter.textContent = `${currentLength}/${maxLength}`; } + function normalizeNumberInput(schema, rawValue) { + if (rawValue === "" || rawValue === null || typeof rawValue === "undefined") { + const emptyFallback = + typeof schema.min !== "undefined" ? Number(schema.min) : 0; + return { + numericValue: emptyFallback, + displayValue: String(emptyFallback), + }; + } + + let numericValue = Number(rawValue); + if (!Number.isFinite(numericValue)) { + numericValue = typeof schema.min !== "undefined" ? Number(schema.min) : 0; + } + + if (typeof schema.min !== "undefined" && numericValue < Number(schema.min)) { + numericValue = Number(schema.min); + } + + if (typeof schema.max !== "undefined" && numericValue > Number(schema.max)) { + numericValue = Number(schema.max); + } + + return { + numericValue, + displayValue: String(numericValue), + }; + } + function openImagePicker(imageType, onSuccess) { const fileInput = document.createElement("input"); fileInput.type = "file"; @@ -883,3 +1467,4 @@ })(); + diff --git a/views/admin/history/partials/editor-script.ejs b/views/admin/history/partials/editor-script.ejs index ba01854..ecf4b04 100644 --- a/views/admin/history/partials/editor-script.ejs +++ b/views/admin/history/partials/editor-script.ejs @@ -468,37 +468,46 @@ renderAllSections(); }); col.appendChild(input); - appendHelp(col, schema, parent[key]); + appendHelp(col, { ...schema, maxLength: undefined }, parent[key]); container.appendChild(col); return; } if (schema.type === "combobox") { - const input = document.createElement("input"); - const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`; - input.className = "form-control"; - input.type = "text"; - input.value = parent[key] || ""; - if (schema.placeholder) input.placeholder = schema.placeholder; - if (schema.maxLength) input.maxLength = schema.maxLength; - input.setAttribute("list", listId); - input.addEventListener("input", function () { - parent[key] = input.value; - updateCounter(counter, input.value.length, schema.maxLength); + const input = document.createElement("select"); + input.className = "form-select"; + const options = resolveOptions(schema, context.root); + const optionValues = options.map((option) => + typeof option === "string" ? option : option.value, + ); + + options.forEach((option) => { + const optionEl = document.createElement("option"); + if (typeof option === "string") { + optionEl.value = option; + optionEl.textContent = option; + } else { + optionEl.value = option.value; + optionEl.textContent = option.label; + } + input.appendChild(optionEl); }); - const dataList = document.createElement("datalist"); - dataList.id = listId; - resolveOptions(schema, context.root).forEach((option) => { - const item = document.createElement("option"); - item.value = typeof option === "string" ? option : option.value; - item.label = typeof option === "string" ? option : option.label; - dataList.appendChild(item); + if (parent[key] && !optionValues.includes(parent[key])) { + const legacyOption = document.createElement("option"); + legacyOption.value = parent[key]; + legacyOption.textContent = parent[key]; + input.appendChild(legacyOption); + } + + input.value = parent[key] || input.options[0]?.value || ""; + parent[key] = input.value; + input.addEventListener("change", function () { + parent[key] = input.value; }); col.appendChild(input); - col.appendChild(dataList); - const counter = appendHelp(col, schema, parent[key]); + appendHelp(col, { ...schema, maxLength: undefined }, parent[key]); container.appendChild(col); return; } @@ -524,52 +533,113 @@ function renderIconField(schema, col, parent, key) { const selected = parent[key] || ""; const wrapper = document.createElement("div"); - wrapper.className = "border rounded-3 bg-white p-3"; + wrapper.className = "cms-icon-combobox"; + const trigger = document.createElement("button"); + trigger.type = "button"; + trigger.className = "cms-icon-dropdown-trigger"; - const top = document.createElement("div"); - top.className = "d-flex gap-2 align-items-center mb-3"; - - const input = document.createElement("input"); - input.type = "text"; - input.className = "form-control"; - input.value = selected; - input.placeholder = "fa-shield-check"; - input.setAttribute("list", "cms-icon-options"); - top.appendChild(input); + const triggerValue = document.createElement("div"); + triggerValue.className = "cms-icon-dropdown-value"; const preview = document.createElement("div"); - preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center"; - preview.style.width = "52px"; - preview.innerHTML = selected - ? `` - : '--'; - top.appendChild(preview); - wrapper.appendChild(top); + preview.className = "cms-icon-preview"; + triggerValue.appendChild(preview); - const grid = document.createElement("div"); - grid.className = "d-flex flex-wrap gap-2"; - (schema.options || []).forEach((option) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`; - button.innerHTML = `${escapeHtml(option)}`; - button.addEventListener("click", function () { - parent[key] = option; - input.value = option; - preview.innerHTML = ``; - renderAllSections(); - }); - grid.appendChild(button); - }); - wrapper.appendChild(grid); + const triggerText = document.createElement("div"); + triggerText.className = "cms-icon-dropdown-text"; + triggerValue.appendChild(triggerText); + trigger.appendChild(triggerValue); - input.addEventListener("input", function () { - parent[key] = input.value; - preview.innerHTML = input.value - ? `` + const caret = document.createElement("i"); + caret.className = "fas fa-chevron-down cms-icon-dropdown-caret"; + trigger.appendChild(caret); + wrapper.appendChild(trigger); + + const panel = document.createElement("div"); + panel.className = "cms-icon-dropdown-panel d-none"; + + const searchInput = document.createElement("input"); + searchInput.type = "text"; + searchInput.className = "form-control"; + searchInput.placeholder = "Search icon name"; + panel.appendChild(searchInput); + + const optionsWrap = document.createElement("div"); + optionsWrap.className = "cms-icon-options"; + panel.appendChild(optionsWrap); + + const empty = document.createElement("div"); + empty.className = "cms-icon-option-empty d-none"; + empty.textContent = "No matching icons"; + panel.appendChild(empty); + wrapper.appendChild(panel); + + const setOpen = function (isOpen) { + wrapper.classList.toggle("is-open", isOpen); + panel.classList.toggle("d-none", !isOpen); + if (isOpen) { + searchInput.focus(); + searchInput.select(); + } + }; + + const updateTrigger = function (value) { + preview.innerHTML = value + ? `` : '--'; + triggerText.innerHTML = value + ? `${escapeHtml(value)}Selected icon` + : 'Select iconNo icon selected'; + }; + + const renderOptions = function (searchTerm = "") { + const normalized = String(searchTerm || "").trim().toLowerCase(); + const filteredOptions = (schema.options || []).filter((option) => + option.toLowerCase().includes(normalized), + ); + + optionsWrap.innerHTML = ""; + empty.classList.toggle("d-none", filteredOptions.length > 0); + + filteredOptions.forEach((option) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`; + button.innerHTML = `${escapeHtml(option)}${parent[key] === option ? '' : ""}`; + button.addEventListener("click", function () { + parent[key] = option; + searchInput.value = option; + updateTrigger(option); + renderOptions(option); + setOpen(false); + }); + optionsWrap.appendChild(button); + }); + }; + + trigger.addEventListener("click", function () { + const nextOpen = panel.classList.contains("d-none"); + setOpen(nextOpen); + if (nextOpen) { + renderOptions(searchInput.value || parent[key] || ""); + } }); + searchInput.addEventListener("input", function () { + renderOptions(searchInput.value); + }); + + wrapper.addEventListener("focusout", function () { + window.setTimeout(function () { + if (!wrapper.contains(document.activeElement)) { + setOpen(false); + } + }, 0); + }); + + searchInput.value = selected; + updateTrigger(selected); + renderOptions(selected); col.appendChild(wrapper); appendHelp(col, schema, parent[key]); } @@ -607,7 +677,7 @@ container.appendChild(col); } - function appendHelp(col, schema, value, extraHint) { + function appendHelp(col, schema, value, extraHint, providedCounter) { const wrapper = document.createElement("div"); wrapper.className = "field-meta-row"; @@ -616,11 +686,14 @@ help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" "); wrapper.appendChild(help); - let counter = null; - if (schema.maxLength) { + let counter = providedCounter || null; + if (!counter && schema.maxLength) { counter = document.createElement("div"); counter.className = "field-char-count"; updateCounter(counter, String(value || "").length, schema.maxLength); + } + + if (counter) { wrapper.appendChild(counter); } @@ -883,3 +956,4 @@ })(); + diff --git a/views/admin/partnerships/partials/directory-tab.ejs b/views/admin/partnerships/partials/directory-tab.ejs index ff00505..14804fb 100644 --- a/views/admin/partnerships/partials/directory-tab.ejs +++ b/views/admin/partnerships/partials/directory-tab.ejs @@ -5,14 +5,10 @@
-
+
-
- - -
@@ -36,7 +32,7 @@
-
Use a short unique partner key so card state stays stable.
+
Each partner card keeps its own open or closed state automatically.
diff --git a/views/admin/partnerships/partials/editor-script.ejs b/views/admin/partnerships/partials/editor-script.ejs index 02dc400..d3d1a48 100644 --- a/views/admin/partnerships/partials/editor-script.ejs +++ b/views/admin/partnerships/partials/editor-script.ejs @@ -14,6 +14,12 @@ } const state = JSON.parse(JSON.stringify(initialData)); + const slugifyValue = (value, fallback) => + String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || fallback; const templates = { tab: document.getElementById("directoryTabTemplate"), partner: document.getElementById("partnerTemplate"), @@ -21,8 +27,10 @@ inquiryOption: document.getElementById("inquiryOptionTemplate"), }; + ensurePartnershipIds(); bindStaticEvents(); renderAll(); + initStaticCounters(); function bindStaticEvents() { document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => { @@ -43,7 +51,6 @@ document.getElementById("addPartnerBtn")?.addEventListener("click", function () { state.directory.partners.push({ - id: "", name: "", category: "", summary: "", @@ -53,12 +60,12 @@ collabType: "", benefits: "", }); + ensurePartnershipIds(); renderPartners(); }); document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () { state.inquiryForm.fields.push({ - id: "", label: "", placeholder: "", type: "text", @@ -66,6 +73,7 @@ required: true, options: [], }); + ensurePartnershipIds(); renderInquiryFields(); }); @@ -75,6 +83,7 @@ form.addEventListener("submit", function () { syncStaticFields(); + ensurePartnershipIds(); pageJsonInput.value = JSON.stringify(state); }); @@ -105,7 +114,6 @@ state.directory.heading = getValue("directoryHeading"); state.directory.description = getValue("directoryDescription"); - state.directory.loadMoreLabel = getValue("directoryLoadMoreLabel"); state.cta = { heading: getValue("ctaHeading"), @@ -114,7 +122,34 @@ }; state.inquiryForm.title = getValue("inquiryTitle"); - state.inquiryForm.submitLabel = getValue("inquirySubmitLabel"); + } + + function ensurePartnershipIds() { + const usedPartnerIds = new Set(); + state.directory.partners = (state.directory.partners || []).map((partner, index) => { + let id = String(partner.id || "").trim() || slugifyValue(partner.name, `partner-${index + 1}`); + let suffix = 2; + while (usedPartnerIds.has(id)) { + id = `${slugifyValue(partner.name, "partner")}-${suffix}`; + suffix += 1; + } + usedPartnerIds.add(id); + return { ...partner, id }; + }); + + const usedFieldIds = new Set(); + state.inquiryForm.fields = (state.inquiryForm.fields || []).map((field, index) => { + let id = + String(field.id || "").trim() || + slugifyValue(field.label || field.placeholder, `field-${index + 1}`); + let suffix = 2; + while (usedFieldIds.has(id)) { + id = `${slugifyValue(field.label || field.placeholder, "field")}-${suffix}`; + suffix += 1; + } + usedFieldIds.add(id); + return { ...field, id }; + }); } function renderAll() { @@ -143,6 +178,7 @@ node.querySelector("[data-toggle-item]")?.addEventListener("click", function () { node.classList.toggle("is-collapsed"); }); + attachCounters(node); directoryTabsList.appendChild(node); }); @@ -210,6 +246,7 @@ node.querySelector("[data-toggle-item]")?.addEventListener("click", function () { node.classList.toggle("is-collapsed"); }); + attachCounters(node); partnersList.appendChild(node); }); @@ -304,6 +341,7 @@ field.options.splice(optionIndex, 1); renderInquiryFields(); }); + attachCounters(optionNode); optionsList.appendChild(optionNode); }); initSortable(optionsList, field.options, renderInquiryFields, '[data-item="inquiry-option"]'); @@ -311,6 +349,7 @@ toggleOptions(); renderOptions(); + attachCounters(node); inquiryFieldsList.appendChild(node); }); @@ -339,6 +378,74 @@ return template.content.firstElementChild.cloneNode(true); } + function initStaticCounters() { + attachCounters(form); + } + + function attachCounters(root) { + root.querySelectorAll("input[maxlength], textarea[maxlength]").forEach((input, index) => { + if (input.dataset.counterReady === "true") { + updateInputCounter(input); + return; + } + + const counterId = + input.id || + input.name || + input.dataset.field || + input.dataset.optionValue || + `counter-${index}-${Math.random().toString(36).slice(2, 8)}`; + let counter = root.querySelector(`[data-counter-for="${counterId}"]`); + + if (!counter) { + counter = document.createElement("div"); + counter.className = "field-char-count"; + counter.dataset.counterFor = counterId; + + const next = input.nextElementSibling; + if (next && next.classList.contains("form-text")) { + let metaRow = next.nextElementSibling; + if (!metaRow || !metaRow.classList.contains("field-meta-row")) { + metaRow = document.createElement("div"); + metaRow.className = "field-meta-row"; + next.insertAdjacentElement("afterend", metaRow); + } + metaRow.appendChild(counter); + } else { + const metaRow = document.createElement("div"); + metaRow.className = "field-meta-row"; + metaRow.appendChild(counter); + input.insertAdjacentElement("afterend", metaRow); + } + } + + const sync = function () { + updateInputCounter(input); + }; + + input.addEventListener("input", sync); + input.addEventListener("change", sync); + input.dataset.counterReady = "true"; + updateInputCounter(input); + }); + } + + function updateInputCounter(input) { + const maxLength = Number(input.getAttribute("maxlength")); + if (!maxLength) return; + + const counterId = + input.id || input.name || input.dataset.field || input.dataset.optionValue; + const scope = input.closest("[data-item]") || input.parentElement || form; + let counter = scope.querySelector(`[data-counter-for="${counterId}"]`); + if (!counter) { + counter = form.querySelector(`[data-counter-for="${counterId}"]`); + } + if (!counter) return; + + counter.textContent = `${String(input.value || "").length}/${maxLength}`; + } + function getValue(id) { return document.getElementById(id)?.value || ""; } diff --git a/views/admin/partnerships/partials/inquiry-form-tab.ejs b/views/admin/partnerships/partials/inquiry-form-tab.ejs index 36b468a..3813c61 100644 --- a/views/admin/partnerships/partials/inquiry-form-tab.ejs +++ b/views/admin/partnerships/partials/inquiry-form-tab.ejs @@ -5,14 +5,10 @@
-
+
-
- - -
diff --git a/views/admin/partnerships/partials/templates.ejs b/views/admin/partnerships/partials/templates.ejs index 6d51aa8..d5726f2 100644 --- a/views/admin/partnerships/partials/templates.ejs +++ b/views/admin/partnerships/partials/templates.ejs @@ -46,11 +46,6 @@
-
- - -
Use a short unique key.
-
@@ -119,10 +114,6 @@
-
- - -
diff --git a/views/admin/policies/partials/editor-script.ejs b/views/admin/policies/partials/editor-script.ejs index ba01854..ba83e46 100644 --- a/views/admin/policies/partials/editor-script.ejs +++ b/views/admin/policies/partials/editor-script.ejs @@ -524,52 +524,113 @@ function renderIconField(schema, col, parent, key) { const selected = parent[key] || ""; const wrapper = document.createElement("div"); - wrapper.className = "border rounded-3 bg-white p-3"; + wrapper.className = "cms-icon-combobox"; + const trigger = document.createElement("button"); + trigger.type = "button"; + trigger.className = "cms-icon-dropdown-trigger"; - const top = document.createElement("div"); - top.className = "d-flex gap-2 align-items-center mb-3"; - - const input = document.createElement("input"); - input.type = "text"; - input.className = "form-control"; - input.value = selected; - input.placeholder = "fa-shield-check"; - input.setAttribute("list", "cms-icon-options"); - top.appendChild(input); + const triggerValue = document.createElement("div"); + triggerValue.className = "cms-icon-dropdown-value"; const preview = document.createElement("div"); - preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center"; - preview.style.width = "52px"; - preview.innerHTML = selected - ? `` - : '--'; - top.appendChild(preview); - wrapper.appendChild(top); + preview.className = "cms-icon-preview"; + triggerValue.appendChild(preview); - const grid = document.createElement("div"); - grid.className = "d-flex flex-wrap gap-2"; - (schema.options || []).forEach((option) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`; - button.innerHTML = `${escapeHtml(option)}`; - button.addEventListener("click", function () { - parent[key] = option; - input.value = option; - preview.innerHTML = ``; - renderAllSections(); - }); - grid.appendChild(button); - }); - wrapper.appendChild(grid); + const triggerText = document.createElement("div"); + triggerText.className = "cms-icon-dropdown-text"; + triggerValue.appendChild(triggerText); + trigger.appendChild(triggerValue); - input.addEventListener("input", function () { - parent[key] = input.value; - preview.innerHTML = input.value - ? `` + const caret = document.createElement("i"); + caret.className = "fas fa-chevron-down cms-icon-dropdown-caret"; + trigger.appendChild(caret); + wrapper.appendChild(trigger); + + const panel = document.createElement("div"); + panel.className = "cms-icon-dropdown-panel d-none"; + + const searchInput = document.createElement("input"); + searchInput.type = "text"; + searchInput.className = "form-control"; + searchInput.placeholder = "Search icon name"; + panel.appendChild(searchInput); + + const optionsWrap = document.createElement("div"); + optionsWrap.className = "cms-icon-options"; + panel.appendChild(optionsWrap); + + const empty = document.createElement("div"); + empty.className = "cms-icon-option-empty d-none"; + empty.textContent = "No matching icons"; + panel.appendChild(empty); + wrapper.appendChild(panel); + + const setOpen = function (isOpen) { + wrapper.classList.toggle("is-open", isOpen); + panel.classList.toggle("d-none", !isOpen); + if (isOpen) { + searchInput.focus(); + searchInput.select(); + } + }; + + const updateTrigger = function (value) { + preview.innerHTML = value + ? `` : '--'; + triggerText.innerHTML = value + ? `${escapeHtml(value)}Selected icon` + : 'Select iconNo icon selected'; + }; + + const renderOptions = function (searchTerm = "") { + const normalized = String(searchTerm || "").trim().toLowerCase(); + const filteredOptions = (schema.options || []).filter((option) => + option.toLowerCase().includes(normalized), + ); + + optionsWrap.innerHTML = ""; + empty.classList.toggle("d-none", filteredOptions.length > 0); + + filteredOptions.forEach((option) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`; + button.innerHTML = `${escapeHtml(option)}${parent[key] === option ? '' : ""}`; + button.addEventListener("click", function () { + parent[key] = option; + searchInput.value = option; + updateTrigger(option); + renderOptions(option); + setOpen(false); + }); + optionsWrap.appendChild(button); + }); + }; + + trigger.addEventListener("click", function () { + const nextOpen = panel.classList.contains("d-none"); + setOpen(nextOpen); + if (nextOpen) { + renderOptions(searchInput.value || parent[key] || ""); + } }); + searchInput.addEventListener("input", function () { + renderOptions(searchInput.value); + }); + + wrapper.addEventListener("focusout", function () { + window.setTimeout(function () { + if (!wrapper.contains(document.activeElement)) { + setOpen(false); + } + }, 0); + }); + + searchInput.value = selected; + updateTrigger(selected); + renderOptions(selected); col.appendChild(wrapper); appendHelp(col, schema, parent[key]); } @@ -883,3 +944,4 @@ })(); + diff --git a/views/admin/policies/partials/sections-editor-script.ejs b/views/admin/policies/partials/sections-editor-script.ejs index 5eda695..65f089d 100644 --- a/views/admin/policies/partials/sections-editor-script.ejs +++ b/views/admin/policies/partials/sections-editor-script.ejs @@ -524,52 +524,113 @@ function renderIconField(schema, col, parent, key) { const selected = parent[key] || ""; const wrapper = document.createElement("div"); - wrapper.className = "border rounded-3 bg-white p-3"; + wrapper.className = "cms-icon-combobox"; + const trigger = document.createElement("button"); + trigger.type = "button"; + trigger.className = "cms-icon-dropdown-trigger"; - const top = document.createElement("div"); - top.className = "d-flex gap-2 align-items-center mb-3"; - - const input = document.createElement("input"); - input.type = "text"; - input.className = "form-control"; - input.value = selected; - input.placeholder = "fa-shield-check"; - input.setAttribute("list", "cms-icon-options"); - top.appendChild(input); + const triggerValue = document.createElement("div"); + triggerValue.className = "cms-icon-dropdown-value"; const preview = document.createElement("div"); - preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center"; - preview.style.width = "52px"; - preview.innerHTML = selected - ? `` - : '--'; - top.appendChild(preview); - wrapper.appendChild(top); + preview.className = "cms-icon-preview"; + triggerValue.appendChild(preview); - const grid = document.createElement("div"); - grid.className = "d-flex flex-wrap gap-2"; - (schema.options || []).forEach((option) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`; - button.innerHTML = `${escapeHtml(option)}`; - button.addEventListener("click", function () { - parent[key] = option; - input.value = option; - preview.innerHTML = ``; - renderAllSections(); - }); - grid.appendChild(button); - }); - wrapper.appendChild(grid); + const triggerText = document.createElement("div"); + triggerText.className = "cms-icon-dropdown-text"; + triggerValue.appendChild(triggerText); + trigger.appendChild(triggerValue); - input.addEventListener("input", function () { - parent[key] = input.value; - preview.innerHTML = input.value - ? `` + const caret = document.createElement("i"); + caret.className = "fas fa-chevron-down cms-icon-dropdown-caret"; + trigger.appendChild(caret); + wrapper.appendChild(trigger); + + const panel = document.createElement("div"); + panel.className = "cms-icon-dropdown-panel d-none"; + + const searchInput = document.createElement("input"); + searchInput.type = "text"; + searchInput.className = "form-control"; + searchInput.placeholder = "Search icon name"; + panel.appendChild(searchInput); + + const optionsWrap = document.createElement("div"); + optionsWrap.className = "cms-icon-options"; + panel.appendChild(optionsWrap); + + const empty = document.createElement("div"); + empty.className = "cms-icon-option-empty d-none"; + empty.textContent = "No matching icons"; + panel.appendChild(empty); + wrapper.appendChild(panel); + + const setOpen = function (isOpen) { + wrapper.classList.toggle("is-open", isOpen); + panel.classList.toggle("d-none", !isOpen); + if (isOpen) { + searchInput.focus(); + searchInput.select(); + } + }; + + const updateTrigger = function (value) { + preview.innerHTML = value + ? `` : '--'; + triggerText.innerHTML = value + ? `${escapeHtml(value)}Selected icon` + : 'Select iconNo icon selected'; + }; + + const renderOptions = function (searchTerm = "") { + const normalized = String(searchTerm || "").trim().toLowerCase(); + const filteredOptions = (schema.options || []).filter((option) => + option.toLowerCase().includes(normalized), + ); + + optionsWrap.innerHTML = ""; + empty.classList.toggle("d-none", filteredOptions.length > 0); + + filteredOptions.forEach((option) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`; + button.innerHTML = `${escapeHtml(option)}${parent[key] === option ? '' : ""}`; + button.addEventListener("click", function () { + parent[key] = option; + searchInput.value = option; + updateTrigger(option); + renderOptions(option); + setOpen(false); + }); + optionsWrap.appendChild(button); + }); + }; + + trigger.addEventListener("click", function () { + const nextOpen = panel.classList.contains("d-none"); + setOpen(nextOpen); + if (nextOpen) { + renderOptions(searchInput.value || parent[key] || ""); + } }); + searchInput.addEventListener("input", function () { + renderOptions(searchInput.value); + }); + + wrapper.addEventListener("focusout", function () { + window.setTimeout(function () { + if (!wrapper.contains(document.activeElement)) { + setOpen(false); + } + }, 0); + }); + + searchInput.value = selected; + updateTrigger(selected); + renderOptions(selected); col.appendChild(wrapper); appendHelp(col, schema, parent[key]); } @@ -883,3 +944,4 @@ })(); + diff --git a/views/layouts/main.ejs b/views/layouts/main.ejs index a3d544c..f16277d 100644 --- a/views/layouts/main.ejs +++ b/views/layouts/main.ejs @@ -477,6 +477,150 @@ margin-left: auto; } + .cms-icon-combobox { + position: relative; + } + + .cms-icon-dropdown-trigger { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 10px; + background: #fff; + padding: 0.75rem; + color: #334155; + text-align: left; + } + + .cms-icon-dropdown-trigger:hover { + border-color: rgba(184, 183, 106, 0.55); + background: rgba(248, 250, 252, 0.96); + } + + .cms-icon-combobox.is-open .cms-icon-dropdown-trigger { + border-color: rgba(184, 183, 106, 0.65); + box-shadow: 0 0 0 0.2rem rgba(184, 183, 106, 0.12); + } + + .cms-icon-dropdown-value { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; + flex: 1; + } + + .cms-icon-dropdown-text { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; + } + + .cms-icon-dropdown-text strong, + .cms-icon-dropdown-text span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .cms-icon-dropdown-caret { + color: #94a3b8; + transition: transform 0.18s ease; + } + + .cms-icon-combobox.is-open .cms-icon-dropdown-caret { + transform: rotate(180deg); + } + + .cms-icon-preview { + width: 44px; + min-width: 44px; + height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 8px; + background: #fff; + color: #334155; + } + + .cms-icon-dropdown-panel { + position: absolute; + top: calc(100% + 0.5rem); + left: 0; + right: 0; + z-index: 30; + background: #fff; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 10px; + padding: 0.75rem; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12); + } + + .cms-icon-dropdown-panel .form-control { + margin-bottom: 0.75rem; + } + + .cms-icon-options { + max-height: 220px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 0.375rem; + } + + .cms-icon-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + width: 100%; + border: 1px solid rgba(148, 163, 184, 0.18); + background: #fff; + color: #334155; + border-radius: 8px; + padding: 0.55rem 0.7rem; + text-align: left; + font-size: 0.875rem; + } + + .cms-icon-option-main { + min-width: 0; + display: flex; + align-items: center; + gap: 0.625rem; + } + + .cms-icon-option-main span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .cms-icon-option:hover { + border-color: rgba(184, 183, 106, 0.55); + background: rgba(248, 250, 252, 0.96); + color: #0f172a; + } + + .cms-icon-option.is-active { + border-color: rgba(184, 183, 106, 0.65); + background: rgba(184, 183, 106, 0.12); + color: #0f172a; + } + + .cms-icon-option-empty { + color: #94a3b8; + font-size: 0.75rem; + padding: 0.5rem 0.25rem 0; + } + main { flex: 1; z-index: 1;