feat(cms): add management for partnerships, history, accreditation, admissions, and policies pages

Implement a singleton page content system to manage static informational pages. This includes:
- New controllers, models, and data files for Partnerships, History, Accreditation, Admissions, and Policies.
- Admin routes and views for updating page content.
- Public API endpoints for fetching page data.
- Migration scripts for initializing page data.
- Updated admin navigation layout to include a dropdown for "About" sections.
- Audit action constants for tracking updates to these pages.
This commit is contained in:
Tống Thành Đạt
2026-04-20 14:08:38 +07:00
parent fab72e86a6
commit 8b6bf5fe6e
48 changed files with 2357 additions and 4 deletions
+703
View File
@@ -0,0 +1,703 @@
const ICON_OPTIONS = [
"fa-scale-balanced",
"fa-shield-check",
"fa-magnifying-glass",
"fa-graduation-cap",
"fa-check-double",
"fa-building-columns",
"fa-award",
"fa-trophy",
"fa-arrow-down",
"fa-arrow-right",
"fa-check-circle",
"fa-exchange-alt",
"fa-bolt",
"fa-book-open",
"fa-credit-card",
];
const text = (key, label, options = {}) => ({
key,
label,
type: "text",
...options,
});
const textarea = (key, label, options = {}) => ({
key,
label,
type: "textarea",
rows: options.rows || 4,
...options,
});
const image = (key, label, options = {}) => ({
key,
label,
type: "image",
...options,
});
const icon = (key, label, options = {}) => ({
key,
label,
type: "icon",
options: options.options || ICON_OPTIONS,
...options,
});
const checkbox = (key, label, options = {}) => ({
key,
label,
type: "checkbox",
...options,
});
const url = (key, label, options = {}) => ({
key,
label,
type: "url",
...options,
});
const object = (key, label, fields, options = {}) => ({
key,
label,
type: "object",
fields,
...options,
});
const stringList = (key, label, options = {}) => ({
key,
label,
type: "array",
itemLabel: options.itemLabel || "Item",
itemSchema: {
type: "primitive",
fieldType: options.fieldType || "text",
label: options.itemLabel || "Item",
maxLength: options.maxLength,
placeholder: options.placeholder,
helpText: options.itemHelpText,
},
...options,
});
const objectList = (key, label, fields, options = {}) => ({
key,
label,
type: "array",
itemLabel: options.itemLabel || "Item",
itemSchema: {
type: "object",
fields,
},
...options,
});
const variantList = (key, label, variants, options = {}) => ({
key,
label,
type: "array",
itemLabel: options.itemLabel || "Item",
itemSchema: {
type: "variant",
discriminator: "type",
options: Object.keys(variants).map((value) => ({
value,
label: variants[value].label,
})),
variants,
},
...options,
});
const linkFields = (prefix = "Link") => [
text("label", `${prefix} Label`, { maxLength: 60 }),
url("href", `${prefix} URL`, { maxLength: 255 }),
];
module.exports = {
partnerships: {
key: "partnerships",
title: "Partnerships Management",
subtitle: "Edit content displayed on the partnerships page",
routeBase: "/admin/partnerships",
apiPath: "/api/partnerships",
previewPath: "/about/partnerships",
dataFile: "partnerships",
imageType: "partnerships",
tabs: [
{
key: "hero",
label: "Hero",
icon: "fas fa-image",
schema: object("hero", "Hero", [
text("badge", "Badge", { maxLength: 40 }),
text("title", "Title", { maxLength: 90 }),
textarea("description", "Description", { maxLength: 220, rows: 4 }),
text("linkLabel", "Link Label", { maxLength: 40 }),
image("image", "Hero Image", {
imageHint: "Recommended 720x630 px",
helpText: "Upload a landscape hero image for the right panel.",
}),
text("imageAlt", "Hero Image Alt Text", { maxLength: 120 }),
]),
},
{
key: "directory",
label: "Directory",
icon: "fas fa-handshake",
schema: object("directory", "Directory", [
text("heading", "Heading", { maxLength: 70 }),
textarea("description", "Description", { maxLength: 180, rows: 3 }),
stringList("tabs", "Tabs", {
itemLabel: "Tab",
maxLength: 30,
placeholder: "Industry",
}),
text("loadMoreLabel", "Load More Label", { maxLength: 40 }),
objectList(
"partners",
"Partners",
[
text("id", "Partner ID", {
maxLength: 50,
helpText: "Stable ID used by the frontend filtering state.",
}),
text("name", "Name", { maxLength: 90 }),
text("category", "Category", { maxLength: 30 }),
textarea("summary", "Summary", {
maxLength: 130,
rows: 3,
helpText:
"The card preview uses a 130 character cap in the frontend.",
}),
image("logo", "Logo", {
imageHint: "Recommended 105x80 px minimum visible ratio",
helpText: "Use a clean logo with transparent or simple background.",
}),
text("logoAlt", "Logo Alt Text", { maxLength: 120 }),
textarea("about", "About", { maxLength: 600, rows: 5 }),
text("collabType", "Collaboration Type", { maxLength: 40 }),
textarea("benefits", "Benefits", { maxLength: 240, rows: 4 }),
],
{ itemLabel: "Partner" },
),
]),
},
{
key: "cta",
label: "CTA",
icon: "fas fa-bullhorn",
schema: object("cta", "CTA", [
text("heading", "Heading", { maxLength: 80 }),
textarea("description", "Description", { maxLength: 220, rows: 4 }),
text("buttonLabel", "Button Label", { maxLength: 40 }),
]),
},
{
key: "inquiryForm",
label: "Inquiry Form",
icon: "fas fa-envelope",
schema: object("inquiryForm", "Inquiry Form", [
text("title", "Title", { maxLength: 60 }),
object("fields", "Fields", [
object("firstName", "First Name Field", [
text("label", "Label", { maxLength: 30 }),
text("placeholder", "Placeholder", { maxLength: 40 }),
]),
object("lastName", "Last Name Field", [
text("label", "Label", { maxLength: 30 }),
text("placeholder", "Placeholder", { maxLength: 40 }),
]),
object("organization", "Organization Field", [
text("label", "Label", { maxLength: 40 }),
text("placeholder", "Placeholder", { maxLength: 60 }),
]),
object("partnershipType", "Partnership Type Field", [
text("label", "Label", { maxLength: 40 }),
stringList("options", "Options", {
itemLabel: "Option",
maxLength: 50,
}),
]),
object("message", "Message Field", [
text("label", "Label", { maxLength: 30 }),
text("placeholder", "Placeholder", { maxLength: 80 }),
]),
]),
text("submitLabel", "Submit Label", { maxLength: 40 }),
]),
},
],
},
history: {
key: "history",
title: "History Management",
subtitle: "Edit content displayed on the history page",
routeBase: "/admin/history",
apiPath: "/api/history",
previewPath: "/about/history",
dataFile: "history",
imageType: "history",
tabs: [
{
key: "highlight",
label: "Highlight",
icon: "fas fa-star",
schema: object("highlight", "Highlight Banner", [
icon("icon", "Icon"),
text("text", "Text", { maxLength: 110 }),
text("linkLabel", "Link Label", { maxLength: 30 }),
url("href", "Link URL", { maxLength: 255 }),
]),
},
{
key: "hero",
label: "Hero",
icon: "fas fa-image",
schema: object("hero", "Hero", [
text("badge", "Badge", { maxLength: 40 }),
text("title", "Title", { maxLength: 90 }),
textarea("description", "Description", { maxLength: 220, rows: 4 }),
]),
},
{
key: "filters",
label: "Filters",
icon: "fas fa-filter",
schema: object("filters", "Filters", [
text("yearLabel", "Year Label", { maxLength: 30 }),
text("categoryLabel", "Category Label", { maxLength: 30 }),
text("buttonLabel", "Button Label", { maxLength: 30 }),
stringList("yearOptions", "Year Options", {
itemLabel: "Year Option",
maxLength: 30,
}),
stringList("categoryOptions", "Category Options", {
itemLabel: "Category Option",
maxLength: 40,
}),
]),
},
{
key: "timeline",
label: "Timeline",
icon: "fas fa-clock-rotate-left",
schema: object("timeline", "Timeline", [
text("loadMoreLabel", "Load More Label", { maxLength: 40 }),
objectList(
"items",
"Milestones",
[
text("id", "Milestone ID", { maxLength: 60 }),
text("year", "Year", { maxLength: 10 }),
text("yearRange", "Year Range", { maxLength: 30 }),
text("category", "Category", { maxLength: 40 }),
text("categoryLabel", "Category Label", { maxLength: 30 }),
text("title", "Title", { maxLength: 90 }),
textarea("description", "Description", {
maxLength: 260,
rows: 4,
}),
image("image", "Milestone Image", {
imageHint: "Recommended 436x190 px",
helpText: "Wide image used inside the milestone card.",
}),
text("imageAlt", "Image Alt Text", { maxLength: 120 }),
objectList(
"stats",
"Stats",
[
text("value", "Value", { maxLength: 24 }),
text("label", "Label", { maxLength: 50 }),
],
{ itemLabel: "Stat" },
),
checkbox("featured", "Featured"),
],
{ itemLabel: "Milestone" },
),
]),
},
],
},
accreditation: {
key: "accreditation",
title: "Accreditation Management",
subtitle: "Edit content displayed on 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", "Icon"),
text("text", "Text", { maxLength: 120 }),
objectList(
"links",
"Links",
[
text("label", "Label", { maxLength: 40 }),
url("href", "URL", { maxLength: 255 }),
icon("icon", "Icon"),
],
{ itemLabel: "Link" },
),
]),
},
{
key: "hero",
label: "Hero",
icon: "fas fa-image",
schema: object("hero", "Hero", [
text("badge", "Badge", { maxLength: 40 }),
text("title", "Title", { maxLength: 60 }),
textarea("description", "Description", { maxLength: 420, rows: 5 }),
]),
},
{
key: "grid",
label: "Grid",
icon: "fas fa-table-cells-large",
schema: object("grid", "Grid", [
stringList("tabs", "Tabs", {
itemLabel: "Tab",
maxLength: 30,
}),
objectList(
"items",
"Items",
[
text("id", "Item ID", { maxLength: 50 }),
icon("icon", "Icon"),
image("image", "Image", {
imageHint: "Recommended 118x58 px minimum visible ratio",
helpText: "Logo or badge used at the top of the card.",
}),
text("status", "Status", { maxLength: 20 }),
text("category", "Category", { maxLength: 30 }),
text("title", "Title", {
maxLength: 17,
helpText: "Frontend title space is capped at 17 characters.",
}),
textarea("description", "Description", {
maxLength: 300,
rows: 5,
helpText:
"Frontend description preview is capped at 300 characters.",
}),
text("scopeLabel", "Scope Label", { maxLength: 20 }),
text("scope", "Scope", { maxLength: 60 }),
text("validUntilLabel", "Valid Until Label", { maxLength: 24 }),
text("validUntil", "Valid Until", { maxLength: 40 }),
text("buttonLabel", "Button Label", { maxLength: 30 }),
url("certificateHref", "Certificate URL", { maxLength: 255 }),
],
{ itemLabel: "Accreditation Item" },
),
]),
},
],
},
admissions: {
key: "admissions",
title: "Admissions Management",
subtitle: "Edit content displayed on the admissions page",
routeBase: "/admin/admissions",
apiPath: "/api/admissions",
previewPath: "/admissions",
dataFile: "admissions",
imageType: "admissions",
tabs: [
{
key: "hero",
label: "Hero",
icon: "fas fa-image",
schema: object("hero", "Hero", [
text("badge", "Badge", { maxLength: 40 }),
text("title", "Title", { maxLength: 80 }),
textarea("description", "Description", { maxLength: 240, rows: 4 }),
object("primaryCta", "Primary CTA", linkFields("Primary CTA")),
object("secondaryCta", "Secondary CTA", linkFields("Secondary CTA")),
image("image", "Hero Image", {
imageHint: "Recommended 720x646 px",
helpText: "Large image rendered in the right hero panel.",
}),
text("imageAlt", "Hero Image Alt Text", { maxLength: 120 }),
]),
},
{
key: "process",
label: "Process",
icon: "fas fa-list-ol",
schema: object("process", "Process", [
text("id", "Section ID", { maxLength: 40 }),
text("title", "Title", { maxLength: 60 }),
textarea("description", "Description", { maxLength: 180, rows: 3 }),
objectList(
"steps",
"Steps",
[
text("number", "Number", { maxLength: 8 }),
text("title", "Title", { maxLength: 50 }),
textarea("description", "Description", { maxLength: 180, rows: 3 }),
checkbox("active", "Active"),
],
{ itemLabel: "Step" },
),
]),
},
{
key: "eligibility",
label: "Eligibility",
icon: "fas fa-check-circle",
schema: object("eligibility", "Eligibility", [
text("id", "Section ID", { maxLength: 40 }),
text("title", "Title", { maxLength: 60 }),
objectList(
"cards",
"Cards",
[
text("title", "Title", { maxLength: 50 }),
icon("icon", "Icon"),
stringList("items", "Items", {
itemLabel: "Bullet Item",
maxLength: 120,
}),
],
{ itemLabel: "Card" },
),
]),
},
{
key: "tuition",
label: "Tuition",
icon: "fas fa-chart-column",
schema: object("tuition", "Tuition", [
text("id", "Section ID", { maxLength: 40 }),
text("title", "Title", { maxLength: 60 }),
text("chartTitle", "Chart Title", { maxLength: 60 }),
textarea("chartDescription", "Chart Description", {
maxLength: 140,
rows: 3,
}),
objectList(
"series",
"Chart Series",
[
text("label", "Label", { maxLength: 40 }),
{ key: "color", label: "Color", type: "color" },
stringList("values", "Values", {
itemLabel: "Point",
fieldType: "number",
}),
],
{ itemLabel: "Series" },
),
]),
},
{
key: "keyDates",
label: "Key Dates",
icon: "fas fa-calendar-days",
schema: object("keyDates", "Key Dates", [
text("id", "Section ID", { maxLength: 40 }),
text("title", "Title", { maxLength: 60 }),
stringList("columns", "Columns", {
itemLabel: "Column",
maxLength: 40,
}),
objectList(
"rows",
"Rows",
[
text("term", "Term", { maxLength: 40 }),
text("applicationDeadline", "Application Deadline", {
maxLength: 40,
}),
text("classesStart", "Classes Start", { maxLength: 40 }),
],
{ itemLabel: "Row" },
),
]),
},
{
key: "calculator",
label: "Calculator",
icon: "fas fa-calculator",
schema: object("calculator", "Calculator", [
text("title", "Title", { maxLength: 60 }),
textarea("description", "Description", { maxLength: 120, rows: 3 }),
stringList("modelOptions", "Model Options", {
itemLabel: "Option",
maxLength: 30,
}),
text("paceLabel", "Pace Label", { maxLength: 30 }),
text("minPaceLabel", "Min Pace Label", { maxLength: 20 }),
text("maxPaceLabel", "Max 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", { maxLength: 60 }),
object("cta", "CTA", linkFields("CTA")),
]),
},
{
key: "scholarships",
label: "Scholarships",
icon: "fas fa-award",
schema: object("scholarships", "Scholarships", [
text("title", "Title", { maxLength: 50 }),
icon("icon", "Icon"),
objectList(
"items",
"Scholarship Items",
[
text("title", "Title", { maxLength: 50 }),
text("amount", "Amount", { maxLength: 24 }),
textarea("description", "Description", { maxLength: 160, rows: 3 }),
],
{ itemLabel: "Scholarship Item" },
),
]),
},
],
},
policies: {
key: "policies",
title: "Policies Management",
subtitle: "Edit content displayed on the policies page",
routeBase: "/admin/policies",
apiPath: "/api/policies",
previewPath: "/policies",
dataFile: "policies",
imageType: "policies",
tabs: [
{
key: "hero",
label: "Hero",
icon: "fas fa-scale-balanced",
schema: object("hero", "Hero", [
text("badge", "Badge", { maxLength: 40 }),
icon("icon", "Icon", {
helpText: "Policies uses icon-only controls and does not require image upload.",
}),
text("titlePrefix", "Title Prefix", { maxLength: 50 }),
text("titleHighlight", "Title Highlight", { maxLength: 40 }),
textarea("description", "Description", { maxLength: 220, rows: 4 }),
text("lastUpdated", "Last Updated Label", { maxLength: 50 }),
]),
},
{
key: "sidebar",
label: "Sidebar",
icon: "fas fa-bars",
schema: object("sidebar", "Sidebar", [
text("heading", "Heading", { maxLength: 30 }),
text("helperText", "Helper Text", { maxLength: 60 }),
text("contactLabel", "Contact Label", { maxLength: 40 }),
url("contactHref", "Contact URL", { maxLength: 255 }),
]),
},
{
key: "policies",
label: "Policies",
icon: "fas fa-file-lines",
schema: objectList(
"policies",
"Policies",
[
text("id", "Policy ID", { maxLength: 40 }),
text("navLabel", "Navigation Label", { maxLength: 40 }),
text("title", "Title", { maxLength: 70 }),
text("effectiveDate", "Effective Date", { maxLength: 50 }),
textarea("intro", "Intro", { maxLength: 260, rows: 4 }),
variantList(
"sections",
"Sections",
{
text: {
label: "Text Section",
schema: object("section", "Text Section", [
text("heading", "Heading", { maxLength: 60 }),
objectList(
"paragraphs",
"Paragraphs",
[
textarea("text", "Text", {
maxLength: 500,
rows: 4,
}),
objectList(
"links",
"Links",
[
text("label", "Label", { maxLength: 50 }),
url("href", "URL", { maxLength: 255 }),
text("tabId", "Target Policy ID", {
maxLength: 40,
helpText:
"Use this when the link should switch to another policy tab.",
}),
],
{ itemLabel: "Link" },
),
],
{ itemLabel: "Paragraph" },
),
]),
},
list: {
label: "List Section",
schema: object("section", "List Section", [
text("heading", "Heading", { maxLength: 60 }),
textarea("intro", "Intro", { maxLength: 220, rows: 3 }),
stringList("items", "Items", {
itemLabel: "List Item",
maxLength: 180,
fieldType: "textarea",
}),
]),
},
cards: {
label: "Cards Section",
schema: object("section", "Cards Section", [
objectList(
"cards",
"Cards",
[
icon("icon", "Icon"),
text("title", "Title", { maxLength: 50 }),
textarea("description", "Description", {
maxLength: 220,
rows: 4,
}),
object("link", "Link", [
text("label", "Label", { maxLength: 50 }),
url("href", "URL", { maxLength: 255 }),
text("tabId", "Target Policy ID", { maxLength: 40 }),
]),
],
{ itemLabel: "Card" },
),
]),
},
},
{ itemLabel: "Section" },
),
],
{ itemLabel: "Policy" },
),
},
],
},
};