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:
Tống Thành Đạt
2026-04-07 19:36:20 +07:00
parent e86e5d2c46
commit ffe2f12bb3
12 changed files with 1328 additions and 52 deletions

View File

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

View File

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