From c6a2d4a55d08a39b99480f2d1c0c0ff75b737e21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=E1=BB=91ng=20Th=C3=A0nh=20=C4=90=E1=BA=A1t?= <84076965+tongthanhdat009@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:32:51 +0700 Subject: [PATCH] feat: enhance about us and footer CMS admin panels - Improve aboutUs controller with better field handling - Update footer controller with expanded content management - Refine about admin view templates - Update appointment and footer admin views - Add about contract repair migration script - Update about.json seed data --- controllers/aboutUsController.js | 235 +++++++++++++++--- controllers/footerController.js | 100 +++++++- data/about.json | 62 +++-- public/js/admin-form-helpers.js | 10 +- ...2026_04_10_210000_repair_about_contract.js | 169 +++++++++++++ views/admin/aboutUs/index.ejs | 58 +++-- views/admin/appointment/index.ejs | 10 +- views/admin/footer/index.ejs | 4 +- 8 files changed, 534 insertions(+), 114 deletions(-) create mode 100644 scripts/2026_04_10_210000_repair_about_contract.js diff --git a/controllers/aboutUsController.js b/controllers/aboutUsController.js index 42284c0..9a4211a 100644 --- a/controllers/aboutUsController.js +++ b/controllers/aboutUsController.js @@ -1,3 +1,4 @@ +const mongoose = require("mongoose"); const { addBaseUrlToImages } = require("../utils/imageHelper"); const AboutUs = require("../models/aboutUs"); const Blog = require("../models/blog"); @@ -13,6 +14,157 @@ const writeAuditLog = require("../audit/writeAuditLog"); const diffObject = require("../audit/diffObject"); const AUDIT_ACTIONS = require("../constants/auditAction"); +const ABOUT_NEWS_FALLBACK_THUMBNAIL = "/uploads/blog/7281.jpg"; +const ABOUT_NEWS_FALLBACK_AVATAR = "/assets/img/home-1/news/client.png"; +const ABOUT_NEWS_PLACEHOLDER_THUMBNAIL = + "img/inner-page/news-details/details-1.jpg"; +const ABOUT_MISSION_ITEM_ICON = "/assets/img/home-1/icon/01.svg"; + +const normalizePath = (value) => { + if (!value || typeof value !== "string") return ""; + if (value.startsWith("http://") || value.startsWith("https://")) return value; + if (value.startsWith("/")) return value; + return `/${value}`; +}; + +const isPlaceholderNewsThumbnail = (value) => { + if (!value || typeof value !== "string") return true; + + const normalized = value.trim().toLowerCase(); + return ( + normalized === ABOUT_NEWS_PLACEHOLDER_THUMBNAIL || + normalized === `/${ABOUT_NEWS_PLACEHOLDER_THUMBNAIL}` || + normalized.endsWith("/inner-page/news-details/details-1.jpg") || + normalized.endsWith("news-details/details-1.jpg") + ); +}; + +const sanitizeAboutSection = (section = {}, allowedKeys = []) => { + const sanitized = {}; + + allowedKeys.forEach((key) => { + if (section[key] !== undefined) { + sanitized[key] = section[key]; + } + }); + + return sanitized; +}; + +const sanitizeMissionItems = (items) => { + if (!Array.isArray(items)) return []; + + return items + .map((item = {}) => ({ + icon: ABOUT_MISSION_ITEM_ICON, + label: + typeof item.label === "string" + ? item.label + : typeof item.title === "string" + ? item.title + : "", + description: typeof item.description === "string" ? item.description : "", + })) + .filter((item) => item.label || item.description); +}; + +const buildCanonicalAboutData = (data) => { + const source = data || {}; + + return { + hero: sanitizeAboutSection(source.hero, [ + "title", + "breadcrumb", + "backgroundImage", + ]), + intro: sanitizeAboutSection(source.intro, [ + "subheading", + "heading", + "description", + "image", + ]), + mission: { + ...sanitizeAboutSection(source.mission, [ + "subheading", + "heading", + "description", + "features", + "ctaButton", + ]), + items: sanitizeMissionItems(source.mission?.items), + images: sanitizeAboutSection(source.mission?.images, [ + "main", + "secondary", + ]), + }, + features: { + ...sanitizeAboutSection(source.features, [ + "backgroundImage", + "subheading", + "heading", + "description", + "image", + "items", + "ctaButton", + ]), + }, + news: { + ...sanitizeAboutSection(source.news, [ + "subheading", + "heading", + "ctaButton", + "selectedBlogIds", + ]), + items: Array.isArray(source.news?.items) ? source.news.items : [], + }, + }; +}; + +const resolveBlogImage = (value, fallback = ABOUT_NEWS_FALLBACK_THUMBNAIL) => { + if (!value || isPlaceholderNewsThumbnail(value)) { + return fallback; + } + + return normalizePath(value); +}; + +const toObjectId = (value) => { + const stringValue = value && typeof value.toString === "function" ? value.toString() : ""; + return mongoose.isValidObjectId(stringValue) + ? new mongoose.Types.ObjectId(stringValue) + : null; +}; + +const resolveNewsItem = (blog, index = 0) => { + const fallbackThumbs = [ + ABOUT_NEWS_FALLBACK_THUMBNAIL, + "/uploads/about/news-1.jpg", + "/uploads/about/news-3.jpg", + ]; + + return { + title: blog.title, + category: blog.category && blog.category[0] ? blog.category[0] : "Visa", + date: + blog.publishedAt || + new Date(blog.createdAt).toLocaleDateString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + }), + comments: blog.commentsCount || 0, + author: { + name: blog.author || "Admin", + avatar: ABOUT_NEWS_FALLBACK_AVATAR, + }, + link: `/blog/${blog.slug}`, + thumbnail: resolveBlogImage( + blog.featuredImage, + fallbackThumbs[index % fallbackThumbs.length], + ), + }; +}; + const handleLengthValidation = (validation, req, res, options = {}) => { const message = summarizeLengthErrors(validation, 3) || "One or more fields exceed the allowed length."; @@ -41,26 +193,49 @@ exports.getAbout = async (req, res) => { res.setHeader("Expires", "0"); const data = await AboutUs.getSingle(); - const rawData = data.toObject(); + const rawData = buildCanonicalAboutData(data.toObject()); // === Dynamic Blog News Section === const news = rawData.news || {}; + const selectedBlogIds = Array.isArray(news.selectedBlogIds) + ? news.selectedBlogIds.map((id) => id.toString()) + : []; + const selectedObjectIds = selectedBlogIds + .map((id) => toObjectId(id)) + .filter(Boolean); let blogs = []; // Nếu có chọn blog cụ thể - if (news.selectedBlogIds && news.selectedBlogIds.length > 0) { - blogs = await Blog.find({ - _id: { $in: news.selectedBlogIds }, + if (selectedBlogIds.length > 0) { + const selectedQuery = await Blog.find({ + _id: { $in: selectedObjectIds }, status: "published", }).lean(); // Sắp xếp theo thứ tự đã chọn trong selectedBlogIds - blogs.sort((a, b) => { + selectedQuery.sort((a, b) => { return ( - news.selectedBlogIds.indexOf(a._id.toString()) - - news.selectedBlogIds.indexOf(b._id.toString()) + selectedBlogIds.indexOf(a._id.toString()) - + selectedBlogIds.indexOf(b._id.toString()) ); }); + + blogs = selectedQuery.slice(0, 3); + + if (blogs.length < 3) { + const missingCount = 3 - blogs.length; + const extraBlogs = await Blog.find({ + status: "published", + _id: { + $nin: [...blogs.map((blog) => blog._id), ...selectedObjectIds], + }, + }) + .sort({ createdAt: -1 }) + .limit(missingCount) + .lean(); + + blogs = [...blogs, ...extraBlogs]; + } } // Nếu không chọn hoặc chọn nhưng không đủ, lấy thêm 3 bài mới nhất @@ -72,24 +247,7 @@ exports.getAbout = async (req, res) => { } // Map dữ liệu blog sang format mà frontend mong đợi - news.items = blogs.map((blog) => ({ - title: blog.title, - category: blog.category && blog.category[0] ? blog.category[0] : "Visa", - date: - blog.publishedAt || - new Date(blog.createdAt).toLocaleDateString("en-GB", { - day: "numeric", - month: "long", - year: "numeric", - }), - comments: blog.commentsCount || 0, - author: { - name: blog.author || "Admin", - avatar: "/assets/img/home-1/news/client.png", // Default avatar - }, - link: `/blog/${blog.slug}`, - thumbnail: blog.featuredImage, - })); + news.items = blogs.slice(0, 3).map((blog, index) => resolveNewsItem(blog, index)); rawData.news = news; // =============================== @@ -133,6 +291,8 @@ exports.updateAbout = async (req, res) => { // ✅ Capture BEFORE state const beforeData = JSON.parse(JSON.stringify(doc.toObject())); + updateData = buildCanonicalAboutData(updateData); + const validation = validateLengthRules(updateData, ABOUT_US_LENGTH_RULES); if (!validation.valid) { return handleLengthValidation(validation, req, res, { json: true }); @@ -165,16 +325,18 @@ exports.updateAbout = async (req, res) => { } // Fetch fresh data for syncing and returning - const finalData = await AboutUs.findOne() + const finalData = buildCanonicalAboutData( + await AboutUs.findOne() .select("-_id -__v -createdAt -updatedAt") - .lean(); + .lean(), + ); // Update about.json file to keep it in sync jsonHelper.writeJsonFile("about", finalData); res.json({ success: true, - message: "About Us updated successfully", + message: "About page updated successfully", data: finalData, }); } catch (error) { @@ -192,7 +354,7 @@ exports.updateAbout = async (req, res) => { exports.index = async (req, res) => { try { const data = await AboutUs.getSingle(); - const rawData = data.toObject(); + const rawData = buildCanonicalAboutData(data.toObject()); // Lấy tất cả blog để chọn trong CMS const allBlogs = await Blog.find({ status: "published" }) @@ -202,7 +364,7 @@ exports.index = async (req, res) => { const activeTab = req.query.activeTab || "hero"; res.render("admin/aboutUs/index", { layout: "layouts/main", - title: "About Us Management", + title: "About Page Management", data: rawData, allBlogs, activeTab, @@ -213,7 +375,7 @@ exports.index = async (req, res) => { }); } catch (err) { console.error("Error in about index:", err); - req.flash("error_msg", "Error loading About Us page"); + req.flash("error_msg", "Error loading About page"); res.redirect("/admin/dashboard"); } }; @@ -234,6 +396,7 @@ exports.update = async (req, res) => { } const doc = await AboutUs.getSingle(); + updateData = buildCanonicalAboutData(updateData); // ✅ Capture BEFORE state const beforeData = JSON.parse(JSON.stringify(doc.toObject())); @@ -265,17 +428,19 @@ exports.update = async (req, res) => { }); } - const finalData = await AboutUs.findOne() + const finalData = buildCanonicalAboutData( + await AboutUs.findOne() .select("-_id -__v -createdAt -updatedAt") - .lean(); + .lean(), + ); jsonHelper.writeJsonFile("about", finalData); - req.flash("success_msg", "About Us updated successfully"); + req.flash("success_msg", "About page updated successfully"); const activeTab = req.query.activeTab || "hero"; res.redirect(`/admin/about-us?activeTab=${activeTab}`); } catch (err) { console.error("Update error:", err); - req.flash("error_msg", "Error updating About Us: " + err.message); + req.flash("error_msg", "Error updating About page: " + err.message); res.redirect("/admin/about-us"); } }; diff --git a/controllers/footerController.js b/controllers/footerController.js index 3f0212e..2357d37 100644 --- a/controllers/footerController.js +++ b/controllers/footerController.js @@ -4,6 +4,77 @@ const writeAuditLog = require("../audit/writeAuditLog"); const diffObject = require("../audit/diffObject"); const AUDIT_ACTIONS = require("../constants/auditAction"); +const isPlainObject = (value) => + value && typeof value === "object" && !Array.isArray(value); + +const normalizeFooterImagePath = (value) => { + if (!value || typeof value !== "string") return ""; + + const trimmed = value.trim(); + if (!trimmed) return ""; + + if (trimmed.startsWith("/uploads/footer/")) { + return trimmed; + } + + if (trimmed.startsWith("uploads/footer/")) { + return `/${trimmed}`; + } + + if (/^https?:\/\//i.test(trimmed)) { + try { + const parsed = new URL(trimmed); + if (parsed.pathname.startsWith("/uploads/footer/")) { + return parsed.pathname; + } + } catch (error) { + console.warn("Failed to parse footer image URL:", trimmed, error.message); + } + } + + return trimmed; +}; + +const sanitizeFooterData = (data = {}) => { + const sanitized = JSON.parse(JSON.stringify(data || {})); + + if (!sanitized.top) { + return sanitized; + } + + if (typeof sanitized.top.bgImage === "string") { + sanitized.top.bgImage = normalizeFooterImagePath(sanitized.top.bgImage); + } + + if (sanitized.top.logo && typeof sanitized.top.logo.src === "string") { + sanitized.top.logo.src = normalizeFooterImagePath(sanitized.top.logo.src); + } + + return sanitized; +}; + +const mergeFooterData = (currentValue, incomingValue) => { + if (incomingValue === undefined) { + return currentValue; + } + + if (Array.isArray(incomingValue)) { + return incomingValue; + } + + if (isPlainObject(currentValue) && isPlainObject(incomingValue)) { + const merged = { ...currentValue }; + + Object.keys(incomingValue).forEach((key) => { + merged[key] = mergeFooterData(currentValue[key], incomingValue[key]); + }); + + return merged; + } + + return incomingValue; +}; + // GET /api/footer - Public API cho website và CMS load dữ liệu exports.getFooter = async (req, res) => { try { @@ -42,6 +113,8 @@ exports.updateFooter = async (req, res) => { } } + updateData = sanitizeFooterData(updateData); + // Lấy footer hiện tại hoặc tạo mới (giống Header logic) let footer = await Footer.findOne(); @@ -52,8 +125,9 @@ exports.updateFooter = async (req, res) => { console.log("✓ Footer created:", footer._id); } else { console.log("✓ Found existing footer:", footer._id); - // Merge với dữ liệu cũ thay vì overwrite (giống Header) - Object.assign(footer, updateData); + const mergedData = mergeFooterData(footer.toObject(), updateData); + // Use set() so nested footer fields persist correctly in MongoDB + footer.set(mergedData); await footer.save(); console.log("✓ Footer updated successfully"); } @@ -80,11 +154,10 @@ exports.updateFooter = async (req, res) => { exports.index = async (req, res) => { try { const data = await Footer.getSingle(); - const processedData = addBaseUrlToImages(data.toObject()); res.render("admin/footer/index", { title: "Footer Management", - data: processedData, + data: data.toObject(), }); } catch (error) { console.error("Error in footer index:", error); @@ -114,6 +187,8 @@ exports.update = async (req, res) => { } } + updateData = sanitizeFooterData(updateData); + // Lấy footer hiện tại hoặc tạo mới (giống Header) let footer = await Footer.findOne(); @@ -130,8 +205,9 @@ exports.update = async (req, res) => { req.flash("success_msg", "Footer created successfully"); } else { console.log("✓ Found existing footer:", footer._id); - // Merge với dữ liệu cũ (giống Header) - Object.assign(footer, updateData); + const mergedData = mergeFooterData(footer.toObject(), updateData); + // Use set() so nested footer fields persist correctly in MongoDB + footer.set(mergedData); await footer.save(); // ✅ Capture AFTER state @@ -166,4 +242,14 @@ exports.update = async (req, res) => { // Legacy API endpoints (giữ lại cho tương thích) exports.api = exports.getFooter; -exports.getFooterData = exports.getFooter; +exports.getFooterData = async (req, res) => { + try { + const footer = await Footer.getSingle(); + res.json(footer.toObject()); + } catch (error) { + console.error("Error getting footer data for admin:", error); + res.status(500).json({ + error: "Failed to get footer data", + }); + } +}; diff --git a/data/about.json b/data/about.json index 246a6c7..22c3e43 100644 --- a/data/about.json +++ b/data/about.json @@ -5,25 +5,25 @@ "Home", "About Us" ], - "backgroundImage": "/uploads/about/breadcrumb.jpg" + "backgroundImage": "/uploads/about/7281.jpg" }, "intro": { "subheading": "Company Intro", "heading": "Building Pathways to Your Immigration Success", "description": "We provide expert guidance, personalized solutions, and transparent processes to help you achieve your immigration goals. Our dedicated team ensures a smooth journey, building pathways to your international success.", - "image": "/uploads/about/businessman.jpg" + "image": "/uploads/about/7281.jpg" }, "mission": { - "subheading": "About Our Consultancy", - "heading": "Turning Study Abroad Dreams Into Reality", + "subheading": "About Our ConsultancyAbout Our Consultan", + "heading": "Turning Study Abroad Dreams Into RealityTurning Study Abroad Dreams Into", "description": "We guide students with expert visa consulting, ensuring a smooth process from application to approval, turning study abroad aspirations into life-changing opportunities for a brighter future.", - "images": { - "main": "/uploads/about/375x419.jpg", - "secondary": "/uploads/about/375x419.jpg", - "bgShape": "/assets/img/home-1/about/Vector.png", - "planeShape": "/assets/img/home-1/about/plane.png", - "topShape": "/assets/img/home-1/about/shape.png", - "globeShape": "/assets/img/home-1/about/globe.png" + "features": [ + "Fastest Visa form processing with skilled immigration agents", + "Partnership with International Educational Institutions" + ], + "ctaButton": { + "label": "Get StartedGet StartedGet Starte", + "href": "/about" }, "items": [ { @@ -37,40 +37,36 @@ "description": "Expanding Opportunities Worldwide" } ], - "features": [ - "Fastest Visa form processing with skilled immigration agents", - "Partnership with International Educational Institutions" - ], - "ctaButton": { - "label": "Get Started", - "href": "/about" + "images": { + "main": "/uploads/about/7281.jpg", + "secondary": "/uploads/about/7281.jpg" } }, "features": { - "backgroundImage": "/assets/img/home-2/feature/bg-shape.png", - "subheading": "Your Travel Made Easy", - "heading": "Smooth Visa Journey Guaranteed", - "description": "We provide expert guidance for every visa application, ensuring smooth processing, personalized support, and reliable assistance", - "image": "/uploads/about/686x906.jpg", + "backgroundImage": "/uploads/about/7281.jpg", + "subheading": "Your Travel Made EasyYour Travel Made Ea", + "heading": "Smooth Visa Journey GuaranteedSmooth Visa Journey GuaranteedSmooth Visa", + "description": "We provide expert guidance for every visa application, ensuring smooth processing, personalized support, and reliable assistanceWe provide expert guidance for every visa application, ensuring smooth processing, personalized support, and reliable assistanceWe p", + "image": "/uploads/about/7281.jpg", "items": [ { - "icon": "/assets/img/home-2/icon/01.png", + "icon": "/uploads/about/7281.jpg", "title": "Expert Consultants", - "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors." + "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa" }, { - "icon": "/assets/img/home-2/icon/01.png", + "icon": "/uploads/about/7281.jpg", "title": "Personalized Support", - "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors." + "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa" }, { - "icon": "/assets/img/home-2/icon/01.png", + "icon": "/uploads/about/7281.jpg", "title": "Transparent Process", - "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors." + "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa" } ], "ctaButton": { - "label": "Get Started Today", + "label": "Get Started TodayGet Started Tod", "href": "/contact" } }, @@ -82,9 +78,9 @@ "href": "/blog" }, "selectedBlogIds": [ - "69857d6c6d04fed459107944", - "69857d6c6d04fed459107942", - "69857d6c6d04fed459107940" + "69d660e9f399cedf10d3b216", + "69d660e9f399cedf10d3b214", + "69d660e9f399cedf10d3b212" ], "items": [] } diff --git a/public/js/admin-form-helpers.js b/public/js/admin-form-helpers.js index fc28b53..1ebe328 100644 --- a/public/js/admin-form-helpers.js +++ b/public/js/admin-form-helpers.js @@ -455,7 +455,7 @@ function getCounterHost(input) { return ( input.closest(".input-group") || - input.closest(".col-md-12, .col-md-9, .col-md-6, .col-md-4, .col-md-3, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12") || + input.closest(".col, [class^='col-'], [class*=' col-']") || input.parentElement || input ); @@ -486,7 +486,13 @@ counter.className = "form-text admin-field-counter"; counter.dataset.counterFor = counterToken; counter.setAttribute("aria-live", "polite"); - host.insertAdjacentElement("afterend", counter); + + if (host.classList?.contains("input-group")) { + host.insertAdjacentElement("afterend", counter); + } else { + host.appendChild(counter); + } + return counter; } diff --git a/scripts/2026_04_10_210000_repair_about_contract.js b/scripts/2026_04_10_210000_repair_about_contract.js new file mode 100644 index 0000000..69b66d5 --- /dev/null +++ b/scripts/2026_04_10_210000_repair_about_contract.js @@ -0,0 +1,169 @@ +const mongoose = require("mongoose"); +const connectDB = require("../config/database"); +const AboutUs = require("../models/aboutUs"); +const jsonHelper = require("../utils/jsonHelper"); + +const GENERIC_PLACEHOLDER = "7281.jpg"; + +const ABOUT_DEFAULTS = { + heroBackground: "/uploads/about/breadcrumb.jpg", + featuresBackground: "/assets/img/home-3/choose-us/pricing-bg.jpg", + featuresImage: "/uploads/about/businessman.jpg", + missionIcons: [ + "/assets/img/home-3/choose-us/icon-1.png", + "/assets/img/home-3/choose-us/icon-2.png", + "/assets/img/home-3/choose-us/icon-3.png", + ], + featureIcons: [ + "/assets/img/home-3/choose-us/icon-1.png", + "/assets/img/home-3/choose-us/icon-2.png", + "/assets/img/home-3/choose-us/icon-3.png", + ], +}; + +const trimString = (value) => + typeof value === "string" ? value.trim() : ""; + +const normalizePath = (value) => { + const trimmed = trimString(value); + + if (!trimmed) { + return ""; + } + + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + return trimmed; + } + + return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; +}; + +const isPlaceholderLike = (value) => { + const normalized = normalizePath(value).toLowerCase(); + return !normalized || normalized.endsWith(`/${GENERIC_PLACEHOLDER}`); +}; + +const normalizeButton = (value = {}) => ({ + label: trimString(value?.label), + href: trimString(value?.href), +}); + +const repairIconItems = (items, fallbacks) => + Array.isArray(items) + ? items.map((item, index) => ({ + ...item, + icon: isPlaceholderLike(item?.icon) + ? fallbacks[index % fallbacks.length] + : normalizePath(item?.icon), + })) + : []; + +const buildCanonicalAboutData = (source = {}) => ({ + hero: { + title: trimString(source?.hero?.title), + breadcrumb: Array.isArray(source?.hero?.breadcrumb) + ? source.hero.breadcrumb.map(trimString).filter(Boolean) + : [], + backgroundImage: isPlaceholderLike(source?.hero?.backgroundImage) + ? ABOUT_DEFAULTS.heroBackground + : normalizePath(source?.hero?.backgroundImage), + }, + intro: { + subheading: trimString(source?.intro?.subheading), + heading: trimString(source?.intro?.heading), + description: trimString(source?.intro?.description), + image: normalizePath(source?.intro?.image), + }, + mission: { + subheading: trimString(source?.mission?.subheading), + heading: trimString(source?.mission?.heading), + description: trimString(source?.mission?.description), + images: { + main: normalizePath(source?.mission?.images?.main), + secondary: normalizePath(source?.mission?.images?.secondary), + }, + items: repairIconItems(source?.mission?.items, ABOUT_DEFAULTS.missionIcons) + .map((item) => ({ + icon: item.icon, + label: trimString(item?.label), + description: trimString(item?.description), + })) + .filter((item) => item.icon || item.label || item.description), + features: Array.isArray(source?.mission?.features) + ? source.mission.features.map(trimString).filter(Boolean) + : [], + ctaButton: normalizeButton(source?.mission?.ctaButton), + }, + features: { + backgroundImage: isPlaceholderLike(source?.features?.backgroundImage) + ? ABOUT_DEFAULTS.featuresBackground + : normalizePath(source?.features?.backgroundImage), + subheading: trimString(source?.features?.subheading), + heading: trimString(source?.features?.heading), + description: trimString(source?.features?.description), + image: isPlaceholderLike(source?.features?.image) + ? ABOUT_DEFAULTS.featuresImage + : normalizePath(source?.features?.image), + items: repairIconItems(source?.features?.items, ABOUT_DEFAULTS.featureIcons) + .map((item) => ({ + icon: item.icon, + title: trimString(item?.title), + description: trimString(item?.description), + })) + .filter((item) => item.icon || item.title || item.description), + ctaButton: normalizeButton(source?.features?.ctaButton), + }, + news: { + subheading: trimString(source?.news?.subheading), + heading: trimString(source?.news?.heading), + ctaButton: normalizeButton(source?.news?.ctaButton), + selectedBlogIds: Array.isArray(source?.news?.selectedBlogIds) + ? source.news.selectedBlogIds.filter(Boolean) + : [], + items: [], + }, +}); + +async function up() { + await connectDB(); + + try { + const doc = await AboutUs.getSingle(); + const repaired = buildCanonicalAboutData(doc.toObject()); + + doc.set(repaired); + await doc.save(); + + jsonHelper.writeJsonFile("about", repaired); + + console.log("✓ Repaired About CMS contract"); + console.log(` - Database: ${mongoose.connection.db.databaseName}`); + console.log(" - Canonicalized About singleton fields"); + console.log(" - Backfilled hero/features/images/icons when placeholder-like"); + } catch (error) { + console.error("✗ Failed to repair About contract:", error); + throw error; + } finally { + await mongoose.disconnect(); + } +} + +async function down() { + console.log( + "ℹ Rollback skipped for 2026_04_10_210000_repair_about_contract because the migration normalizes live content in place.", + ); +} + +if (require.main === module) { + up() + .then(() => { + console.log("\n✓ Migration script completed"); + process.exit(0); + }) + .catch((error) => { + console.error("\n✗ Migration script failed:", error); + process.exit(1); + }); +} + +module.exports = { up, down }; diff --git a/views/admin/aboutUs/index.ejs b/views/admin/aboutUs/index.ejs index 556b9cd..ff2f4a5 100644 --- a/views/admin/aboutUs/index.ejs +++ b/views/admin/aboutUs/index.ejs @@ -4,11 +4,11 @@
Edit content displayed on About Us page
+Edit content displayed on the About page
@@ -89,6 +89,7 @@ Upload + Recommended: 1920x640px wide banner (3:1). Keep the focal subject centered because the image is cropped responsively. <% if (data.hero?.backgroundImage) { %>
+