forked from UKSOURCE/cms.hailearning.edu.vn
feat(contact-button): add floating contact widget admin management
Add CMS support for floating contact widget with Facebook/Zalo quick actions. Includes mongoose schema, admin UI tab, image upload with sharp resize presets, deferred form submission with draft persistence, and upload middleware error handling.
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
|
||||
const {
|
||||
addBaseUrlToImages,
|
||||
getFullImageUrl,
|
||||
} = require("../utils/imageHelper");
|
||||
const Home = require("../models/home");
|
||||
const Blog = require("../models/blog");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
@@ -8,6 +11,137 @@ const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
// Các hàm hỗ trợ
|
||||
const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 });
|
||||
const getHomeData = async () => (await getHomeDoc())?.toObject() || {};
|
||||
const getOrCreateHomeDoc = async () => {
|
||||
let doc = await getHomeDoc();
|
||||
|
||||
if (!doc) {
|
||||
doc = new Home(getDefaultHomeData());
|
||||
}
|
||||
|
||||
return doc;
|
||||
};
|
||||
|
||||
const normalizeStoredImagePath = (imagePath) => {
|
||||
if (!imagePath || typeof imagePath !== "string") return "";
|
||||
|
||||
const raw = imagePath.trim();
|
||||
if (!raw) return "";
|
||||
|
||||
const knownPrefixes = ["/uploads/", "/assets/", "/img/"];
|
||||
|
||||
for (const prefix of knownPrefixes) {
|
||||
const index = raw.indexOf(prefix);
|
||||
if (index >= 0) {
|
||||
return raw.slice(index);
|
||||
}
|
||||
}
|
||||
|
||||
if (raw.startsWith("/")) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return `/${raw}`;
|
||||
};
|
||||
|
||||
const getDefaultFloatingContactData = () => ({
|
||||
enabled: true,
|
||||
position: "bottom-right",
|
||||
panelTitle: "Anh chị cần tư vấn hay hỗ trợ gì thêm không ạ?",
|
||||
brand: {
|
||||
imageSrc: "/assets/img/logo/black-logo.svg",
|
||||
imageAlt: "HAI Learning",
|
||||
},
|
||||
trigger: {
|
||||
imageSrc: "",
|
||||
icon: "fa-comments",
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: "facebook",
|
||||
platform: "facebook",
|
||||
enabled: true,
|
||||
label: "Nhắn tin qua Facebook",
|
||||
subtitle: "facebook.com/hailearning.edu.vn",
|
||||
href: "https://www.facebook.com/hailearning.edu.vn/",
|
||||
iconImage: "/uploads/home/floating-contact/Facebook_Logo_Primary.webp",
|
||||
iconType: "iconClass",
|
||||
iconClass: "fa-brands fa-facebook-messenger",
|
||||
iconText: "",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: "zalo",
|
||||
platform: "zalo",
|
||||
enabled: true,
|
||||
label: "Nhắn tin qua Zalo",
|
||||
subtitle: "zalo.me/84961834040",
|
||||
href: "https://zalo.me/84961834040",
|
||||
iconImage: "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp",
|
||||
iconType: "iconText",
|
||||
iconClass: "",
|
||||
iconText: "Zalo",
|
||||
order: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const normalizeFloatingContactData = (payload = {}) => {
|
||||
const defaults = getDefaultFloatingContactData();
|
||||
const brand = payload.brand || {};
|
||||
const trigger = payload.trigger || {};
|
||||
const hasProvidedActions = Array.isArray(payload.actions);
|
||||
const rawActions = hasProvidedActions ? payload.actions : [];
|
||||
|
||||
const actions = rawActions
|
||||
.map((action, index) => ({
|
||||
id: String(action.id || `${action.platform || "action"}-${index + 1}`),
|
||||
platform: String(action.platform || "").trim(),
|
||||
enabled: action.enabled !== false,
|
||||
label: String(action.label || "").trim(),
|
||||
subtitle: String(action.subtitle || "").trim(),
|
||||
href: String(action.href || "").trim(),
|
||||
iconImage: normalizeStoredImagePath(String(action.iconImage || "").trim()),
|
||||
iconType: action.iconType === "iconText" ? "iconText" : "iconClass",
|
||||
iconClass: String(action.iconClass || "").trim(),
|
||||
iconText: String(action.iconText || "").trim(),
|
||||
order: Number.isFinite(Number(action.order)) ? Number(action.order) : index + 1,
|
||||
}))
|
||||
.filter((action) => {
|
||||
return (
|
||||
action.platform ||
|
||||
action.label ||
|
||||
action.subtitle ||
|
||||
action.href ||
|
||||
action.iconImage ||
|
||||
action.iconClass ||
|
||||
action.iconText
|
||||
);
|
||||
})
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((action, index) => ({
|
||||
...action,
|
||||
order: index + 1,
|
||||
}));
|
||||
|
||||
return {
|
||||
enabled: payload.enabled !== false,
|
||||
position: payload.position || defaults.position,
|
||||
panelTitle: String(payload.panelTitle || defaults.panelTitle).trim(),
|
||||
brand: {
|
||||
imageSrc: normalizeStoredImagePath(
|
||||
String(brand.imageSrc || defaults.brand.imageSrc).trim(),
|
||||
),
|
||||
imageAlt: String(brand.imageAlt || defaults.brand.imageAlt).trim(),
|
||||
},
|
||||
trigger: {
|
||||
imageSrc: normalizeStoredImagePath(
|
||||
String(trigger.imageSrc || "").trim(),
|
||||
),
|
||||
icon: String(trigger.icon || defaults.trigger.icon).trim() || defaults.trigger.icon,
|
||||
},
|
||||
actions: hasProvidedActions ? actions : defaults.actions,
|
||||
};
|
||||
};
|
||||
|
||||
const getDefaultHomeData = () => ({
|
||||
hero: {
|
||||
@@ -64,6 +198,7 @@ const getDefaultHomeData = () => ({
|
||||
items: [],
|
||||
selectedBlogIds: [], // Array of manually selected blog IDs
|
||||
},
|
||||
floatingContact: getDefaultFloatingContactData(),
|
||||
});
|
||||
|
||||
// Admin: Xem trang quản lý
|
||||
@@ -77,9 +212,10 @@ exports.index = async (req, res) => {
|
||||
sections.forEach((s) => {
|
||||
data[s] = data[s] || defaults[s];
|
||||
});
|
||||
data.floatingContact = normalizeFloatingContactData(data.floatingContact);
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
|
||||
const backendUrl = `${req.protocol}://${req.get("host")}`;
|
||||
|
||||
// Lấy tất cả blog để chọn trong CMS
|
||||
const allBlogs = await Blog.find({ status: "published" })
|
||||
@@ -118,6 +254,7 @@ exports.update = async (req, res) => {
|
||||
"achievements",
|
||||
"partners",
|
||||
"blogPreview",
|
||||
"floatingContact",
|
||||
];
|
||||
|
||||
let doc = await getHomeDoc();
|
||||
@@ -135,7 +272,10 @@ exports.update = async (req, res) => {
|
||||
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[section] =
|
||||
section === "floatingContact"
|
||||
? normalizeFloatingContactData(payload)
|
||||
: payload;
|
||||
doc.markModified(section);
|
||||
hasChanges = true;
|
||||
updatedSections.push(section);
|
||||
@@ -176,6 +316,49 @@ exports.update = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateFloatingContact = async (req, res) => {
|
||||
try {
|
||||
const payload =
|
||||
typeof req.body?.floatingContact === "string"
|
||||
? JSON.parse(req.body.floatingContact)
|
||||
: req.body?.floatingContact || req.body;
|
||||
|
||||
const doc = await getOrCreateHomeDoc();
|
||||
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||
|
||||
doc.floatingContact = normalizeFloatingContactData(payload);
|
||||
doc.markModified("floatingContact");
|
||||
await doc.save();
|
||||
|
||||
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Home",
|
||||
documentId: doc._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_HOME,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "Floating contact updated successfully",
|
||||
floatingContact: doc.floatingContact,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Floating contact update error:", err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: err.message || "Failed to update floating contact",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Public API// API lấy danh sách blog cho CMS
|
||||
exports.apiGetBlogs = async (req, res) => {
|
||||
try {
|
||||
@@ -191,8 +374,7 @@ exports.apiGetBlogs = async (req, res) => {
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
let data = await getHomeData();
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
|
||||
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
||||
|
||||
// === Xử lý Blog Preview động ===
|
||||
const blogPreview = data.blogPreview || {};
|
||||
@@ -238,6 +420,7 @@ exports.api = async (req, res) => {
|
||||
}));
|
||||
|
||||
data.blogPreview = blogPreview;
|
||||
data.floatingContact = normalizeFloatingContactData(data.floatingContact);
|
||||
// ===============================
|
||||
|
||||
const processed = addBaseUrlToImages(data, baseUrl);
|
||||
|
||||
@@ -1,7 +1,98 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const sharp = require('sharp');
|
||||
const jsonHelper = require('../utils/jsonHelper');
|
||||
|
||||
const imageResizePresets = {
|
||||
floatingContactBrandImage: { width: 104, height: 104, quality: 88 },
|
||||
floatingContactTriggerIcon: { width: 96, height: 96, quality: 88 },
|
||||
floatingContactActionIcon: { width: 84, height: 84, quality: 88 },
|
||||
};
|
||||
|
||||
const isSvgFile = (filePath) => path.extname(filePath).toLowerCase() === '.svg';
|
||||
|
||||
function scheduleTemporaryFileCleanup(filePath, attemptsLeft = 5, delayMs = 250) {
|
||||
if (!filePath || attemptsLeft <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
scheduleTemporaryFileCleanup(filePath, attemptsLeft - 1, delayMs * 2);
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function getFinalUploadTarget(file, req, forceWebp = false) {
|
||||
const parsedPath = path.parse(file.path);
|
||||
const requestedFileName =
|
||||
req.uploadFinalFileName ||
|
||||
file.filename.replace('.__upload__', '');
|
||||
const parsedRequestedFileName = path.parse(requestedFileName);
|
||||
const finalFileName = forceWebp
|
||||
? `${parsedRequestedFileName.name}.webp`
|
||||
: requestedFileName;
|
||||
const finalDirectory = req.uploadFinalDirectory || parsedPath.dir;
|
||||
|
||||
return {
|
||||
finalFileName,
|
||||
finalPath: path.join(finalDirectory, finalFileName),
|
||||
};
|
||||
}
|
||||
|
||||
async function finalizeUploadedImage(file, req, resizePreset) {
|
||||
const preset = imageResizePresets[resizePreset];
|
||||
|
||||
if (!file) {
|
||||
return file;
|
||||
}
|
||||
|
||||
if (!preset || isSvgFile(file.path)) {
|
||||
const { finalFileName, finalPath } = getFinalUploadTarget(file, req);
|
||||
|
||||
if (path.resolve(file.path) !== path.resolve(finalPath)) {
|
||||
fs.renameSync(file.path, finalPath);
|
||||
}
|
||||
|
||||
return {
|
||||
...file,
|
||||
filename: finalFileName,
|
||||
path: finalPath,
|
||||
};
|
||||
}
|
||||
|
||||
const { finalFileName, finalPath } = getFinalUploadTarget(file, req, true);
|
||||
|
||||
await sharp(file.path)
|
||||
.resize(preset.width, preset.height, {
|
||||
fit: 'contain',
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.webp({ quality: preset.quality })
|
||||
.toFile(finalPath);
|
||||
|
||||
if (fs.existsSync(file.path)) {
|
||||
try {
|
||||
fs.unlinkSync(file.path);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Unable to remove original uploaded image after optimization:', cleanupError.message);
|
||||
scheduleTemporaryFileCleanup(file.path);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...file,
|
||||
filename: finalFileName,
|
||||
path: finalPath,
|
||||
mimetype: 'image/webp',
|
||||
};
|
||||
}
|
||||
|
||||
// Controller xử lý upload ảnh
|
||||
const uploadController = {
|
||||
// Upload ảnh và trả về đường dẫn
|
||||
@@ -13,15 +104,14 @@ const uploadController = {
|
||||
|
||||
// Lấy loại ảnh từ query params
|
||||
const imageType = req.query.imageType || 'general';
|
||||
const resizePreset = req.query.resizePreset || '';
|
||||
req.file = await finalizeUploadedImage(req.file, req, resizePreset);
|
||||
|
||||
// Tạo đường dẫn tương đối để lưu vào database
|
||||
const relativePath = `/uploads/${imageType}/${req.file.filename}`;
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const fullUrl = `${baseUrl}${relativePath}`;
|
||||
|
||||
// Kiểm tra nếu file đã tồn tại từ trước
|
||||
const fileAlreadyExists = req.fileAlreadyExists || false;
|
||||
|
||||
// Nếu client yêu cầu cập nhật trực tiếp file JSON (ví dụ activities.json),
|
||||
// thì đồng bộ camps.image và camps.camp-detail.hero.bgImage
|
||||
try {
|
||||
@@ -60,8 +150,9 @@ const uploadController = {
|
||||
success: true,
|
||||
path: relativePath,
|
||||
url: fullUrl,
|
||||
reused: fileAlreadyExists,
|
||||
message: fileAlreadyExists ? 'Using existing file' : 'File uploaded successfully'
|
||||
resizePreset: resizePreset || null,
|
||||
reused: false,
|
||||
message: 'File uploaded successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
@@ -225,4 +316,4 @@ const uploadController = {
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = uploadController;
|
||||
module.exports = uploadController;
|
||||
|
||||
Reference in New Issue
Block a user