forked from UKSOURCE/cms.hailearning.edu.vn
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:
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,72 +5,68 @@
|
||||
"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"
|
||||
},
|
||||
"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": [
|
||||
"Fastest Visa form processing with skilled immigration agents",
|
||||
"Partnership with International Educational Institutions"
|
||||
],
|
||||
"ctaButton": {
|
||||
"label": "Get Started",
|
||||
"label": "Get StartedGet StartedGet Starte",
|
||||
"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": {
|
||||
"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": []
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
if (host.classList?.contains("input-group")) {
|
||||
host.insertAdjacentElement("afterend", counter);
|
||||
} else {
|
||||
host.appendChild(counter);
|
||||
}
|
||||
|
||||
return counter;
|
||||
}
|
||||
|
||||
|
||||
169
scripts/2026_04_10_210000_repair_about_contract.js
Normal file
169
scripts/2026_04_10_210000_repair_about_contract.js
Normal 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 };
|
||||
@@ -4,11 +4,11 @@
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark);">
|
||||
<%= title %>
|
||||
</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>
|
||||
<a href="<%= frontendUrl %>/about-us/" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View About Us Page
|
||||
<a href="<%= frontendUrl %>/about" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View About Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,6 +89,7 @@
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</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) { %>
|
||||
<img src="<%= data.hero.backgroundImage %>"
|
||||
class="img-thumbnail uploaded-preview mt-2"
|
||||
@@ -134,6 +135,7 @@
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</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) { %>
|
||||
<img src="<%= data.intro.image %>"
|
||||
class="img-thumbnail uploaded-preview mt-2"
|
||||
@@ -180,15 +182,20 @@
|
||||
|
||||
<h6 class="mt-4 mb-3">Images</h6>
|
||||
<div class="row g-3">
|
||||
<% ['main', 'secondary', 'bgShape', 'planeShape', 'topShape', 'globeShape'].forEach(imgKey => { %>
|
||||
<% ['main', 'secondary'].forEach(imgKey => { %>
|
||||
<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">
|
||||
<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">
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
</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>
|
||||
@@ -196,11 +203,12 @@
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<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()">
|
||||
<i class="fas fa-plus me-1"></i>Add
|
||||
</button>
|
||||
</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>
|
||||
<div class="col-md-6">
|
||||
@@ -245,6 +253,7 @@
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
</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 class="col-md-6">
|
||||
<label class="form-label">Side Image</label>
|
||||
@@ -254,6 +263,7 @@
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
</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 class="col-md-6">
|
||||
<label class="form-label">CTA Button Label</label>
|
||||
@@ -272,6 +282,7 @@
|
||||
<i class="fas fa-plus me-1"></i>Add Item
|
||||
</button>
|
||||
</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>
|
||||
</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)">
|
||||
</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">
|
||||
<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 %>
|
||||
@@ -403,7 +414,7 @@
|
||||
|
||||
const result = await response.json();
|
||||
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
|
||||
// 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('missionCtaHref').value = mission.ctaButton?.href || '';
|
||||
|
||||
['main', 'secondary', 'bgShape', 'planeShape', 'topShape', 'globeShape'].forEach(k => {
|
||||
['main', 'secondary'].forEach(k => {
|
||||
const el = document.getElementById('missionImg_' + k);
|
||||
const val = mission.images?.[k] || '';
|
||||
if (el) {
|
||||
@@ -706,18 +717,10 @@
|
||||
<div class="card mb-2 mission-item">
|
||||
<div class="card-body p-2">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<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">
|
||||
<div class="col-md-5">
|
||||
<input type="text" class="form-control form-control-sm" name="missionItemLabel_${idx}" placeholder="Label">
|
||||
</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">
|
||||
</div>
|
||||
</div>
|
||||
@@ -730,12 +733,11 @@
|
||||
function populateMissionItems(items) {
|
||||
const container = document.getElementById('missionItemsContainer');
|
||||
container.innerHTML = '';
|
||||
items.forEach((item, i) => {
|
||||
items.forEach((item) => {
|
||||
addMissionItem();
|
||||
const last = container.lastElementChild;
|
||||
last.querySelector(`[name="missionItemIcon_${i}"]`).value = item.icon || '';
|
||||
last.querySelector(`[name="missionItemLabel_${i}"]`).value = item.label || '';
|
||||
last.querySelector(`[name="missionItemDesc_${i}"]`).value = item.description || '';
|
||||
last.querySelector(`[name^="missionItemLabel_"]`).value = item.label || item.title || '';
|
||||
last.querySelector(`[name^="missionItemDesc_"]`).value = item.description || '';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -890,14 +892,10 @@
|
||||
description: document.getElementById('missionDescription').value.trim(),
|
||||
images: {
|
||||
main: document.getElementById('missionImg_main').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()
|
||||
secondary: document.getElementById('missionImg_secondary').value.trim()
|
||||
},
|
||||
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(),
|
||||
description: item.querySelector('[name^="missionItemDesc_"]').value.trim()
|
||||
})).filter(i => i.label !== ''),
|
||||
|
||||
@@ -767,16 +767,16 @@
|
||||
formData.append('image', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/upload/image', {
|
||||
const response = await fetch(`/admin/upload/image?imageType=${encodeURIComponent(imageType || 'appointment')}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success && result.imagePath) {
|
||||
document.getElementById(targetInput).value = result.imagePath;
|
||||
if (result.success && result.path) {
|
||||
document.getElementById(targetInput).value = result.path;
|
||||
if (targetInput === 'heroBackgroundImage') {
|
||||
updateHeroImagePreview(result.imagePath);
|
||||
updateHeroImagePreview(result.path);
|
||||
}
|
||||
} else {
|
||||
alert('Upload failed: ' + (result.error || 'Unknown error'));
|
||||
|
||||
@@ -634,8 +634,8 @@
|
||||
|
||||
async function loadFooterData() {
|
||||
try {
|
||||
console.log("Fetching footer data from /api/footer...");
|
||||
const response = await fetch("/api/footer");
|
||||
console.log("Fetching footer data from /admin/footer/data...");
|
||||
const response = await fetch("/admin/footer/data");
|
||||
console.log("Response status:", response.status);
|
||||
console.log("Response ok:", response.ok);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user