Merge branch 'main' of https://gits.techvanguard.vn/UKSOURCE/cms.hailearning.edu.vn into hoanganh-03022026-appointment-contact-pricing

This commit is contained in:
LNHA
2026-02-05 16:05:11 +07:00
17 changed files with 1681 additions and 2887 deletions

View File

@@ -1,138 +1,56 @@
const { addBaseUrlToImages } = require("../utils/imageHelper");
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
const Home = require("../models/home");
const Blog = require("../models/blog");
// -------------------- Helpers --------------------
// Các hàm hỗ trợ
const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 });
const getHomeData = async () => (await getHomeDoc())?.toObject() || {};
const getHomeDoc = async () => {
// Keep newest document as the source of truth
return await Home.findOne().sort({ updatedAt: -1 });
};
const getHomeData = async () => {
const doc = await Home.findOne().sort({ updatedAt: -1 }).lean();
return doc || {};
};
/**
* Default structure used by the current CMS Home admin UI (`views/admin/home/index.ejs`).
* This is intentionally permissive; the Home model itself also supports the Next.js
* structure from `hailearning.edu.vn/app/home.json`.
*/
const getDefaultHomeData = () => ({
hero: {
title: "",
description: "",
backgroundImage: "",
button: { label: "Book Your Adventure", href: "/booking" },
contactBox: {
welcomeText: "",
phone: { label: "Call us", number: "", href: "" },
email: { label: "Email", address: "", href: "" },
workingHours: { label: "Working Hours", hours: "" },
},
},
about: {
title: "",
subtitle: "",
description: "",
images: { mainImage1: "", mainImage2: "", avatars: [] },
features: [],
quote: "",
button: { label: "", href: "" },
stats: { customerCount: 0, customerLabel: "" },
},
missionVision: {
title: "",
subtitle: "",
backgroundImage: "",
cards: [],
},
whyChooseUs: {
title: "",
subtitle: "",
description: "",
button: { label: "", href: "" },
features: [],
tags: [],
cta: { text: "", linkText: "", linkHref: "" },
},
activities: { cards: [] },
faq: {
title: "",
subtitle: "",
description: "",
image: "",
contact: { title: "", info: "" },
questions: [],
},
partners: {
title: "",
subtitle: "",
backgroundImage: "",
logos: [],
cta: { badge: "", text: "", linkText: "", linkHref: "" },
},
programs: {
title: "",
subtitle: "",
button: { label: "", href: "" },
card: {
pricePrefix: "from",
priceSuffix: "USD",
buttonLabel: "Camp Detail",
buttonHref: "/camp-profiles",
},
hero: { title: "", subtitle: "", description: "", backgroundImage: "", videoUrl: "", primaryButton: {}, secondaryButton: {} },
whyChooseUs: { heading: "", subheading: "", description: "", items: [], features: [], ctaButton: {} },
visaSolutions: { heading: "", subheading: "", items: [] },
visaCountries: { heading: "", subheading: "", description: "", countries: [], ctaButton: {} },
testimonials: { heading: "", subheading: "", videoUrl: "", videoThumbnail: "", items: [] },
videoGallery: { heading: "", videoUrl: "", thumbnail: "" },
faq: { heading: "", subheading: "", description: "", ctaButton: {}, items: [] },
achievements: { heading: "", subheading: "", items: [] },
partners: { visaConsultancy: { items: [] }, brands: { items: [] } },
blogPreview: {
heading: "Latest Insights & Updates",
subheading: "Visa Tips & Guides",
ctaButton: { label: "View All Articles", href: "/blog" },
items: [],
},
newsletter: {
title: "",
subtitle: "",
description: "",
image: "",
decorativeImage: "",
button: { label: "", placeholder: "", href: "" },
},
latestPosts: {
title: "",
subtitle: "",
searchPlaceholder: "",
sidebarTitle: "",
blogPosts: [],
sidebarPosts: [],
featuredCard: { image: "", title: "", description: "" },
selectedBlogIds: [] // Array of manually selected blog IDs
},
});
// -------------------- Admin --------------------
// Admin: Xem trang quản lý
exports.index = async (req, res) => {
try {
let data = await getHomeData();
if (!data || Object.keys(data).length === 0) {
data = getDefaultHomeData();
} else {
// Merge minimal defaults to keep the view safe
const defaults = getDefaultHomeData();
data.hero = data.hero || defaults.hero;
data.about = data.about || defaults.about;
data.missionVision = data.missionVision || defaults.missionVision;
data.whyChooseUs = data.whyChooseUs || defaults.whyChooseUs;
data.activities = data.activities || defaults.activities;
data.faq = data.faq || defaults.faq;
data.partners = data.partners || defaults.partners;
data.programs = data.programs || defaults.programs;
data.newsletter = data.newsletter || defaults.newsletter;
data.latestPosts = data.latestPosts || defaults.latestPosts;
}
const defaults = getDefaultHomeData();
// Merge dữ liệu mặc định cho tất cả các phần
const sections = Object.keys(defaults);
sections.forEach(s => {
data[s] = data[s] || defaults[s];
});
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
// Lấy tất cả blog để chọn trong CMS
const allBlogs = await Blog.find({ status: "published" }).sort({ createdAt: -1 }).lean();
return res.render("admin/home/index", {
layout: "layouts/main",
title: "Home Management",
data,
allBlogs,
frontendUrl,
backendUrl,
getFullImageUrl,
currentPath: req.path,
user: req.session.user,
});
@@ -143,73 +61,32 @@ exports.index = async (req, res) => {
}
};
// Admin: Cập nhật dữ liệu (tập trung vào achievements, partners; các phần khác giữ nguyên nếu không có dữ liệu mới)
exports.update = async (req, res) => {
try {
const currentDoc = await getHomeDoc();
const currentData = currentDoc ? currentDoc.toObject() : {};
const updatedData = { ...currentData };
// Quick fields (Hero) from classic form fields
if (req.body.heroTitle || req.body.heroDescription || req.body.heroBackgroundImage) {
updatedData.hero = {
title: req.body.heroTitle || "",
description: req.body.heroDescription || "",
backgroundImage: req.body.heroBackgroundImage || "",
button: {
label: req.body.heroButtonLabel || "Book Your Adventure",
href: req.body.heroButtonHref || "/booking",
},
contactBox: {
welcomeText: req.body.heroContactWelcome || "",
phone: {
label: "Call us",
number: req.body.heroContactPhone || "",
href: req.body.heroContactPhone ? `tel:${req.body.heroContactPhone}` : "",
},
email: {
label: "Email",
address: req.body.heroContactEmail || "",
href: req.body.heroContactEmail ? `mailto:${req.body.heroContactEmail}` : "",
},
workingHours: {
label: "Working Hours",
hours: req.body.heroContactHours || "",
},
},
};
}
// Handle sections sent as JSON payloads
const sections = [
"hero",
"about",
"missionVision",
"whyChooseUs",
"activities",
"faq",
"partners",
"programs",
"newsletter",
"latestPosts",
"hero", "whyChooseUs", "visaSolutions", "visaCountries",
"testimonials", "videoGallery", "faq", "achievements",
"partners", "blogPreview"
];
let doc = await getHomeDoc();
if (!doc) {
doc = new Home({});
}
let hasChanges = false;
for (const section of sections) {
if (!req.body[section]) continue;
try {
const newSectionData = JSON.parse(req.body[section]);
const currentSectionData = currentData?.[section];
if (JSON.stringify(newSectionData) !== JSON.stringify(currentSectionData)) {
updatedData[section] = newSectionData;
if (req.body[section]) {
try {
const payload = JSON.parse(req.body[section]);
// Gán trực tiếp vào doc, Mongoose sẽ tự check schema
doc[section] = payload;
doc.markModified(section);
hasChanges = true;
} catch (e) {
console.error(`Invalid JSON for ${section}:`, e);
}
} catch (e) {
console.error(`Error processing section "${section}":`, e);
req.flash("error_msg", `Invalid JSON for section "${section}": ${e.message}`);
return req.session.save(() => res.redirect("/admin/home"));
}
}
@@ -218,29 +95,75 @@ exports.update = async (req, res) => {
return req.session.save(() => res.redirect("/admin/home"));
}
if (currentDoc?._id) {
await Home.findByIdAndUpdate(currentDoc._id, updatedData, { new: true });
} else {
await Home.create(updatedData);
}
req.flash("success_msg", "Home data updated successfully");
await doc.save();
req.flash("success_msg", "Home page configuration has been updated!");
return req.session.save(() => res.redirect("/admin/home"));
} catch (err) {
console.error("Home update error:", err);
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
req.flash("error_msg", `Update error: ${err.message}`);
return req.session.save(() => res.redirect("/admin/home"));
}
};
// -------------------- Public API --------------------
// Public API// API lấy danh sách blog cho CMS
exports.apiGetBlogs = async (req, res) => {
try {
const blogs = await Blog.find({ status: "published" }).sort({ createdAt: -1 }).select("title slug featuredImage author publishedAt").lean();
res.json(blogs);
} catch (err) {
res.status(500).json({ error: err.message });
}
};
exports.api = async (req, res) => {
try {
const homeData = await getHomeData();
let data = await getHomeData();
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(homeData, baseUrl);
return res.json(processedData);
// === Xử lý Blog Preview động ===
const blogPreview = data.blogPreview || {};
let blogs = [];
// Nếu có chọn blog cụ thể
if (blogPreview.selectedBlogIds && blogPreview.selectedBlogIds.length > 0) {
blogs = await Blog.find({
_id: { $in: blogPreview.selectedBlogIds },
status: "published"
}).lean();
// Sắp xếp theo thứ tự đã chọn trong selectedBlogIds
blogs.sort((a, b) => {
return blogPreview.selectedBlogIds.indexOf(a._id.toString()) - blogPreview.selectedBlogIds.indexOf(b._id.toString());
});
}
// 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 (hoặc bù vào)
if (blogs.length === 0) {
blogs = await Blog.find({ status: "published" })
.sort({ createdAt: -1 })
.limit(3)
.lean();
}
// Map dữ liệu blog sang format mà frontend mong đợi
blogPreview.items = blogs.map(blog => ({
title: blog.title,
excerpt: blog.excerpt,
category: blog.category && blog.category[0] ? blog.category[0] : "Visa",
date: blog.publishedAt || blog.createdAt,
author: {
name: blog.author || "Admin",
avatar: "" // Frontend đang tự xử lý hoặc dùng logo hệ thống
},
comments: blog.commentsCount || 0,
link: `/blog/${blog.slug}`,
thumbnail: blog.featuredImage
}));
data.blogPreview = blogPreview;
// ===============================
const processed = addBaseUrlToImages(data, baseUrl);
return res.json(processed);
} catch (err) {
console.error("Home API error:", err);
return res.status(500).json({ error: "Error loading home data" });