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
This commit is contained in:
Tống Thành Đạt
2026-04-10 22:32:51 +07:00
parent 51c6303437
commit c6a2d4a55d
8 changed files with 534 additions and 114 deletions

View File

@@ -1,3 +1,4 @@
const mongoose = require("mongoose");
const { addBaseUrlToImages } = require("../utils/imageHelper"); const { addBaseUrlToImages } = require("../utils/imageHelper");
const AboutUs = require("../models/aboutUs"); const AboutUs = require("../models/aboutUs");
const Blog = require("../models/blog"); const Blog = require("../models/blog");
@@ -13,6 +14,157 @@ const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject"); const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction"); 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 handleLengthValidation = (validation, req, res, options = {}) => {
const message = const message =
summarizeLengthErrors(validation, 3) || "One or more fields exceed the allowed length."; summarizeLengthErrors(validation, 3) || "One or more fields exceed the allowed length.";
@@ -41,26 +193,49 @@ exports.getAbout = async (req, res) => {
res.setHeader("Expires", "0"); res.setHeader("Expires", "0");
const data = await AboutUs.getSingle(); const data = await AboutUs.getSingle();
const rawData = data.toObject(); const rawData = buildCanonicalAboutData(data.toObject());
// === Dynamic Blog News Section === // === Dynamic Blog News Section ===
const news = rawData.news || {}; 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 = []; let blogs = [];
// Nếu có chọn blog cụ thể // Nếu có chọn blog cụ thể
if (news.selectedBlogIds && news.selectedBlogIds.length > 0) { if (selectedBlogIds.length > 0) {
blogs = await Blog.find({ const selectedQuery = await Blog.find({
_id: { $in: news.selectedBlogIds }, _id: { $in: selectedObjectIds },
status: "published", status: "published",
}).lean(); }).lean();
// Sắp xếp theo thứ tự đã chọn trong selectedBlogIds // Sắp xếp theo thứ tự đã chọn trong selectedBlogIds
blogs.sort((a, b) => { selectedQuery.sort((a, b) => {
return ( return (
news.selectedBlogIds.indexOf(a._id.toString()) - selectedBlogIds.indexOf(a._id.toString()) -
news.selectedBlogIds.indexOf(b._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 // 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 // Map dữ liệu blog sang format mà frontend mong đợi
news.items = blogs.map((blog) => ({ news.items = blogs.slice(0, 3).map((blog, index) => resolveNewsItem(blog, index));
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,
}));
rawData.news = news; rawData.news = news;
// =============================== // ===============================
@@ -133,6 +291,8 @@ exports.updateAbout = async (req, res) => {
// ✅ Capture BEFORE state // ✅ Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(doc.toObject())); const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
updateData = buildCanonicalAboutData(updateData);
const validation = validateLengthRules(updateData, ABOUT_US_LENGTH_RULES); const validation = validateLengthRules(updateData, ABOUT_US_LENGTH_RULES);
if (!validation.valid) { if (!validation.valid) {
return handleLengthValidation(validation, req, res, { json: true }); return handleLengthValidation(validation, req, res, { json: true });
@@ -165,16 +325,18 @@ exports.updateAbout = async (req, res) => {
} }
// Fetch fresh data for syncing and returning // Fetch fresh data for syncing and returning
const finalData = await AboutUs.findOne() const finalData = buildCanonicalAboutData(
await AboutUs.findOne()
.select("-_id -__v -createdAt -updatedAt") .select("-_id -__v -createdAt -updatedAt")
.lean(); .lean(),
);
// Update about.json file to keep it in sync // Update about.json file to keep it in sync
jsonHelper.writeJsonFile("about", finalData); jsonHelper.writeJsonFile("about", finalData);
res.json({ res.json({
success: true, success: true,
message: "About Us updated successfully", message: "About page updated successfully",
data: finalData, data: finalData,
}); });
} catch (error) { } catch (error) {
@@ -192,7 +354,7 @@ exports.updateAbout = async (req, res) => {
exports.index = async (req, res) => { exports.index = async (req, res) => {
try { try {
const data = await AboutUs.getSingle(); const data = await AboutUs.getSingle();
const rawData = data.toObject(); const rawData = buildCanonicalAboutData(data.toObject());
// Lấy tất cả blog để chọn trong CMS // Lấy tất cả blog để chọn trong CMS
const allBlogs = await Blog.find({ status: "published" }) const allBlogs = await Blog.find({ status: "published" })
@@ -202,7 +364,7 @@ exports.index = async (req, res) => {
const activeTab = req.query.activeTab || "hero"; const activeTab = req.query.activeTab || "hero";
res.render("admin/aboutUs/index", { res.render("admin/aboutUs/index", {
layout: "layouts/main", layout: "layouts/main",
title: "About Us Management", title: "About Page Management",
data: rawData, data: rawData,
allBlogs, allBlogs,
activeTab, activeTab,
@@ -213,7 +375,7 @@ exports.index = async (req, res) => {
}); });
} catch (err) { } catch (err) {
console.error("Error in about index:", 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"); res.redirect("/admin/dashboard");
} }
}; };
@@ -234,6 +396,7 @@ exports.update = async (req, res) => {
} }
const doc = await AboutUs.getSingle(); const doc = await AboutUs.getSingle();
updateData = buildCanonicalAboutData(updateData);
// ✅ Capture BEFORE state // ✅ Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(doc.toObject())); 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") .select("-_id -__v -createdAt -updatedAt")
.lean(); .lean(),
);
jsonHelper.writeJsonFile("about", finalData); 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"; const activeTab = req.query.activeTab || "hero";
res.redirect(`/admin/about-us?activeTab=${activeTab}`); res.redirect(`/admin/about-us?activeTab=${activeTab}`);
} catch (err) { } catch (err) {
console.error("Update error:", 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"); res.redirect("/admin/about-us");
} }
}; };

View File

@@ -4,6 +4,77 @@ const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject"); const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction"); 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 // GET /api/footer - Public API cho website và CMS load dữ liệu
exports.getFooter = async (req, res) => { exports.getFooter = async (req, res) => {
try { 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) // Lấy footer hiện tại hoặc tạo mới (giống Header logic)
let footer = await Footer.findOne(); let footer = await Footer.findOne();
@@ -52,8 +125,9 @@ exports.updateFooter = async (req, res) => {
console.log("✓ Footer created:", footer._id); console.log("✓ Footer created:", footer._id);
} else { } else {
console.log("✓ Found existing footer:", footer._id); console.log("✓ Found existing footer:", footer._id);
// Merge với dữ liệu cũ thay vì overwrite (giống Header) const mergedData = mergeFooterData(footer.toObject(), updateData);
Object.assign(footer, updateData); // Use set() so nested footer fields persist correctly in MongoDB
footer.set(mergedData);
await footer.save(); await footer.save();
console.log("✓ Footer updated successfully"); console.log("✓ Footer updated successfully");
} }
@@ -80,11 +154,10 @@ exports.updateFooter = async (req, res) => {
exports.index = async (req, res) => { exports.index = async (req, res) => {
try { try {
const data = await Footer.getSingle(); const data = await Footer.getSingle();
const processedData = addBaseUrlToImages(data.toObject());
res.render("admin/footer/index", { res.render("admin/footer/index", {
title: "Footer Management", title: "Footer Management",
data: processedData, data: data.toObject(),
}); });
} catch (error) { } catch (error) {
console.error("Error in footer index:", 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) // Lấy footer hiện tại hoặc tạo mới (giống Header)
let footer = await Footer.findOne(); let footer = await Footer.findOne();
@@ -130,8 +205,9 @@ exports.update = async (req, res) => {
req.flash("success_msg", "Footer created successfully"); req.flash("success_msg", "Footer created successfully");
} else { } else {
console.log("✓ Found existing footer:", footer._id); console.log("✓ Found existing footer:", footer._id);
// Merge với dữ liệu cũ (giống Header) const mergedData = mergeFooterData(footer.toObject(), updateData);
Object.assign(footer, updateData); // Use set() so nested footer fields persist correctly in MongoDB
footer.set(mergedData);
await footer.save(); await footer.save();
// ✅ Capture AFTER state // ✅ Capture AFTER state
@@ -166,4 +242,14 @@ exports.update = async (req, res) => {
// Legacy API endpoints (giữ lại cho tương thích) // Legacy API endpoints (giữ lại cho tương thích)
exports.api = exports.getFooter; 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",
});
}
};

View File

@@ -5,72 +5,68 @@
"Home", "Home",
"About Us" "About Us"
], ],
"backgroundImage": "/uploads/about/breadcrumb.jpg" "backgroundImage": "/uploads/about/7281.jpg"
}, },
"intro": { "intro": {
"subheading": "Company Intro", "subheading": "Company Intro",
"heading": "Building Pathways to Your Immigration Success", "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.", "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": { "mission": {
"subheading": "About Our Consultancy", "subheading": "About Our ConsultancyAbout Our Consultan",
"heading": "Turning Study Abroad Dreams Into Reality", "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.", "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"
},
"items": [
{
"icon": "/assets/img/home-1/icon/01.svg",
"label": "Global Reach",
"description": "Expanding Opportunities Worldwide"
},
{
"icon": "/assets/img/home-1/icon/01.svg",
"label": "Global Reach",
"description": "Expanding Opportunities Worldwide"
}
],
"features": [ "features": [
"Fastest Visa form processing with skilled immigration agents", "Fastest Visa form processing with skilled immigration agents",
"Partnership with International Educational Institutions" "Partnership with International Educational Institutions"
], ],
"ctaButton": { "ctaButton": {
"label": "Get Started", "label": "Get StartedGet StartedGet Starte",
"href": "/about" "href": "/about"
},
"items": [
{
"icon": "/assets/img/home-1/icon/01.svg",
"label": "Global Reach",
"description": "Expanding Opportunities Worldwide"
},
{
"icon": "/assets/img/home-1/icon/01.svg",
"label": "Global Reach",
"description": "Expanding Opportunities Worldwide"
}
],
"images": {
"main": "/uploads/about/7281.jpg",
"secondary": "/uploads/about/7281.jpg"
} }
}, },
"features": { "features": {
"backgroundImage": "/assets/img/home-2/feature/bg-shape.png", "backgroundImage": "/uploads/about/7281.jpg",
"subheading": "Your Travel Made Easy", "subheading": "Your Travel Made EasyYour Travel Made Ea",
"heading": "Smooth Visa Journey Guaranteed", "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 assistance", "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/686x906.jpg", "image": "/uploads/about/7281.jpg",
"items": [ "items": [
{ {
"icon": "/assets/img/home-2/icon/01.png", "icon": "/uploads/about/7281.jpg",
"title": "Expert Consultants", "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", "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", "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": { "ctaButton": {
"label": "Get Started Today", "label": "Get Started TodayGet Started Tod",
"href": "/contact" "href": "/contact"
} }
}, },
@@ -82,9 +78,9 @@
"href": "/blog" "href": "/blog"
}, },
"selectedBlogIds": [ "selectedBlogIds": [
"69857d6c6d04fed459107944", "69d660e9f399cedf10d3b216",
"69857d6c6d04fed459107942", "69d660e9f399cedf10d3b214",
"69857d6c6d04fed459107940" "69d660e9f399cedf10d3b212"
], ],
"items": [] "items": []
} }

View File

@@ -455,7 +455,7 @@
function getCounterHost(input) { function getCounterHost(input) {
return ( return (
input.closest(".input-group") || 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.parentElement ||
input input
); );
@@ -486,7 +486,13 @@
counter.className = "form-text admin-field-counter"; counter.className = "form-text admin-field-counter";
counter.dataset.counterFor = counterToken; counter.dataset.counterFor = counterToken;
counter.setAttribute("aria-live", "polite"); counter.setAttribute("aria-live", "polite");
if (host.classList?.contains("input-group")) {
host.insertAdjacentElement("afterend", counter); host.insertAdjacentElement("afterend", counter);
} else {
host.appendChild(counter);
}
return counter; return counter;
} }

View File

@@ -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 };

View File

@@ -4,11 +4,11 @@
<h1 class="h3 mb-0" style="color: var(--primary-dark);"> <h1 class="h3 mb-0" style="color: var(--primary-dark);">
<%= title %> <%= title %>
</h1> </h1>
<p class="text-muted mb-0">Edit content displayed on About Us page</p> <p class="text-muted mb-0">Edit content displayed on the About page</p>
</div> </div>
<div> <div>
<a href="<%= frontendUrl %>/about-us/" class="btn btn-outline-primary" target="_blank"> <a href="<%= frontendUrl %>/about" class="btn btn-outline-primary" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>View About Us Page <i class="fas fa-external-link-alt me-2"></i>View About Page
</a> </a>
</div> </div>
</div> </div>
@@ -89,6 +89,7 @@
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
</button> </button>
</div> </div>
<small class="text-muted d-block mt-1">Recommended: 1920x640px wide banner (3:1). Keep the focal subject centered because the image is cropped responsively.</small>
<% if (data.hero?.backgroundImage) { %> <% if (data.hero?.backgroundImage) { %>
<img src="<%= data.hero.backgroundImage %>" <img src="<%= data.hero.backgroundImage %>"
class="img-thumbnail uploaded-preview mt-2" class="img-thumbnail uploaded-preview mt-2"
@@ -134,6 +135,7 @@
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
</button> </button>
</div> </div>
<small class="text-muted d-block mt-1">Recommended: 1600x900px landscape image (16:9). This section uses a full-width cover frame.</small>
<% if (data.intro?.image) { %> <% if (data.intro?.image) { %>
<img src="<%= data.intro.image %>" <img src="<%= data.intro.image %>"
class="img-thumbnail uploaded-preview mt-2" class="img-thumbnail uploaded-preview mt-2"
@@ -180,15 +182,20 @@
<h6 class="mt-4 mb-3">Images</h6> <h6 class="mt-4 mb-3">Images</h6>
<div class="row g-3"> <div class="row g-3">
<% ['main', 'secondary', 'bgShape', 'planeShape', 'topShape', 'globeShape'].forEach(imgKey => { %> <% ['main', 'secondary'].forEach(imgKey => { %>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label"><%= imgKey.charAt(0).toUpperCase() + imgKey.slice(1) %></label> <label class="form-label"><%= imgKey.charAt(0).toUpperCase() + imgKey.slice(1) %> Image</label>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" id="missionImg_<%= imgKey %>" value="<%= data.mission?.images?.[imgKey] || '' %>"> <input type="text" class="form-control" id="missionImg_<%= imgKey %>" value="<%= data.mission?.images?.[imgKey] || '' %>">
<button class="btn btn-outline-primary btn-upload-image btn-sm" type="button" data-target-input="missionImg_<%= imgKey %>" data-image-type="about"> <button class="btn btn-outline-primary btn-upload-image btn-sm" type="button" data-target-input="missionImg_<%= imgKey %>" data-image-type="about">
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
</button> </button>
</div> </div>
<% if (imgKey === 'main') { %>
<small class="text-muted d-block mt-1">Recommended: 1200x1500px portrait image (4:5). This is the primary mission visual.</small>
<% } else { %>
<small class="text-muted d-block mt-1">Recommended: 1200x900px image (4:3). This image sits as the smaller overlapping card.</small>
<% } %>
</div> </div>
<% }) %> <% }) %>
</div> </div>
@@ -196,11 +203,12 @@
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-6"> <div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">Items (Icons & Labels)</label> <label class="form-label mb-0">Items</label>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="addMissionItem()"> <button type="button" class="btn btn-outline-primary btn-sm" onclick="addMissionItem()">
<i class="fas fa-plus me-1"></i>Add <i class="fas fa-plus me-1"></i>Add
</button> </button>
</div> </div>
<small class="text-muted d-block mb-2">Mission item icons are fixed by the frontend design and are not editable in CMS.</small>
<div id="missionItemsContainer"></div> <div id="missionItemsContainer"></div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
@@ -245,6 +253,7 @@
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
</button> </button>
</div> </div>
<small class="text-muted d-block mt-1">Recommended: 1920x1080px background image (16:9). Darker imagery works best because the section adds a dark overlay.</small>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Side Image</label> <label class="form-label">Side Image</label>
@@ -254,6 +263,7 @@
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
</button> </button>
</div> </div>
<small class="text-muted d-block mt-1">Recommended: 1200x1500px portrait image (4:5). The image fills a tall cover frame on desktop.</small>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">CTA Button Label</label> <label class="form-label">CTA Button Label</label>
@@ -272,6 +282,7 @@
<i class="fas fa-plus me-1"></i>Add Item <i class="fas fa-plus me-1"></i>Add Item
</button> </button>
</div> </div>
<small class="text-muted d-block mb-2">Custom icons render at about 28x28px inside a 60x60 card. Use SVG or a square PNG/WebP at 64x64px or 128x128px.</small>
<div id="featureItemsContainer"></div> <div id="featureItemsContainer"></div>
</div> </div>
</div> </div>
@@ -320,7 +331,7 @@
<input class="form-check-input about-blog-checkbox" type="checkbox" value="<%= blog._id %>" <%= isSelected ? 'checked' : '' %> onclick="event.stopPropagation(); handleAboutCheckboxChange(this)"> <input class="form-check-input about-blog-checkbox" type="checkbox" value="<%= blog._id %>" <%= isSelected ? 'checked' : '' %> onclick="event.stopPropagation(); handleAboutCheckboxChange(this)">
</div> </div>
</div> </div>
<img src="<%= blog.featuredImage ? (blog.featuredImage.startsWith('http') ? blog.featuredImage : backendUrl + blog.featuredImage) : '/assets/img/placeholder.jpg' %>" class="card-img-top" style="height: 210px; object-fit: cover;"> <img src="<%= blog.featuredImage ? (blog.featuredImage.startsWith('http') ? blog.featuredImage : (blog.featuredImage.startsWith('/') ? backendUrl + blog.featuredImage : backendUrl + '/' + blog.featuredImage)) : '/assets/img/placeholder.jpg' %>" class="card-img-top" style="height: 210px; object-fit: cover;">
<div class="card-body p-2"> <div class="card-body p-2">
<h6 class="card-title small fw-bold mb-1" title="<%= blog.title %>" style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2.6em; line-height: 1.3em;"> <h6 class="card-title small fw-bold mb-1" title="<%= blog.title %>" style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2.6em; line-height: 1.3em;">
<%= blog.title %> <%= blog.title %>
@@ -403,7 +414,7 @@
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
showToast('Success', 'About Us updated successfully', 'success'); showToast('Success', 'About page updated successfully', 'success');
// Update the local state with returned data from server // Update the local state with returned data from server
// This ensures the UI is in sync with what was actually saved // This ensures the UI is in sync with what was actually saved
@@ -635,7 +646,7 @@
document.getElementById('missionCtaLabel').value = mission.ctaButton?.label || ''; document.getElementById('missionCtaLabel').value = mission.ctaButton?.label || '';
document.getElementById('missionCtaHref').value = mission.ctaButton?.href || ''; document.getElementById('missionCtaHref').value = mission.ctaButton?.href || '';
['main', 'secondary', 'bgShape', 'planeShape', 'topShape', 'globeShape'].forEach(k => { ['main', 'secondary'].forEach(k => {
const el = document.getElementById('missionImg_' + k); const el = document.getElementById('missionImg_' + k);
const val = mission.images?.[k] || ''; const val = mission.images?.[k] || '';
if (el) { if (el) {
@@ -706,18 +717,10 @@
<div class="card mb-2 mission-item"> <div class="card mb-2 mission-item">
<div class="card-body p-2"> <div class="card-body p-2">
<div class="row g-2"> <div class="row g-2">
<div class="col-md-4"> <div class="col-md-5">
<div class="input-group input-group-sm">
<input type="text" class="form-control" name="missionItemIcon_${idx}" placeholder="Icon path">
<button class="btn btn-outline-primary btn-upload-image" type="button" data-target-input="missionItemIcon_${idx}" data-image-type="about">
<i class="fas fa-upload"></i>
</button>
</div>
</div>
<div class="col-md-4">
<input type="text" class="form-control form-control-sm" name="missionItemLabel_${idx}" placeholder="Label"> <input type="text" class="form-control form-control-sm" name="missionItemLabel_${idx}" placeholder="Label">
</div> </div>
<div class="col-md-4"> <div class="col-md-7">
<input type="text" class="form-control form-control-sm" name="missionItemDesc_${idx}" placeholder="Description"> <input type="text" class="form-control form-control-sm" name="missionItemDesc_${idx}" placeholder="Description">
</div> </div>
</div> </div>
@@ -730,12 +733,11 @@
function populateMissionItems(items) { function populateMissionItems(items) {
const container = document.getElementById('missionItemsContainer'); const container = document.getElementById('missionItemsContainer');
container.innerHTML = ''; container.innerHTML = '';
items.forEach((item, i) => { items.forEach((item) => {
addMissionItem(); addMissionItem();
const last = container.lastElementChild; const last = container.lastElementChild;
last.querySelector(`[name="missionItemIcon_${i}"]`).value = item.icon || ''; last.querySelector(`[name^="missionItemLabel_"]`).value = item.label || item.title || '';
last.querySelector(`[name="missionItemLabel_${i}"]`).value = item.label || ''; last.querySelector(`[name^="missionItemDesc_"]`).value = item.description || '';
last.querySelector(`[name="missionItemDesc_${i}"]`).value = item.description || '';
}); });
} }
@@ -890,14 +892,10 @@
description: document.getElementById('missionDescription').value.trim(), description: document.getElementById('missionDescription').value.trim(),
images: { images: {
main: document.getElementById('missionImg_main').value.trim(), main: document.getElementById('missionImg_main').value.trim(),
secondary: document.getElementById('missionImg_secondary').value.trim(), secondary: document.getElementById('missionImg_secondary').value.trim()
bgShape: document.getElementById('missionImg_bgShape').value.trim(),
planeShape: document.getElementById('missionImg_planeShape').value.trim(),
topShape: document.getElementById('missionImg_topShape').value.trim(),
globeShape: document.getElementById('missionImg_globeShape').value.trim()
}, },
items: Array.from(document.querySelectorAll('.mission-item')).map(item => ({ items: Array.from(document.querySelectorAll('.mission-item')).map(item => ({
icon: item.querySelector('[name^="missionItemIcon_"]').value.trim(), icon: '/assets/img/home-1/icon/01.svg',
label: item.querySelector('[name^="missionItemLabel_"]').value.trim(), label: item.querySelector('[name^="missionItemLabel_"]').value.trim(),
description: item.querySelector('[name^="missionItemDesc_"]').value.trim() description: item.querySelector('[name^="missionItemDesc_"]').value.trim()
})).filter(i => i.label !== ''), })).filter(i => i.label !== ''),

View File

@@ -767,16 +767,16 @@
formData.append('image', file); formData.append('image', file);
try { try {
const response = await fetch('/admin/upload/image', { const response = await fetch(`/admin/upload/image?imageType=${encodeURIComponent(imageType || 'appointment')}`, {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
const result = await response.json(); const result = await response.json();
if (result.success && result.imagePath) { if (result.success && result.path) {
document.getElementById(targetInput).value = result.imagePath; document.getElementById(targetInput).value = result.path;
if (targetInput === 'heroBackgroundImage') { if (targetInput === 'heroBackgroundImage') {
updateHeroImagePreview(result.imagePath); updateHeroImagePreview(result.path);
} }
} else { } else {
alert('Upload failed: ' + (result.error || 'Unknown error')); alert('Upload failed: ' + (result.error || 'Unknown error'));

View File

@@ -634,8 +634,8 @@
async function loadFooterData() { async function loadFooterData() {
try { try {
console.log("Fetching footer data from /api/footer..."); console.log("Fetching footer data from /admin/footer/data...");
const response = await fetch("/api/footer"); const response = await fetch("/admin/footer/data");
console.log("Response status:", response.status); console.log("Response status:", response.status);
console.log("Response ok:", response.ok); console.log("Response ok:", response.ok);