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

@@ -0,0 +1,115 @@
require("dotenv").config();
const connectDB = require("../config/database");
const DEFAULT_FACEBOOK_URL = "https://www.facebook.com/hailearning.edu.vn/";
const DEFAULT_PANEL_TITLE = "Anh chị cần tư vấn hay hỗ trợ gì thêm không ạ?";
const DEFAULT_BRAND_IMAGE = "/assets/img/logo/black-logo.svg";
const DEFAULT_FACEBOOK_ICON = "/uploads/home/floating-contact/Facebook_Logo_Primary.webp";
const DEFAULT_ZALO_ICON = "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp";
function normalizePhoneForZalo(value = "") {
const digits = String(value).replace(/\D/g, "");
if (!digits) {
return "";
}
if (digits.startsWith("84")) {
return digits;
}
if (digits.startsWith("0")) {
return `84${digits.slice(1)}`;
}
return digits;
}
async function migrate() {
const mongoose = require("mongoose");
let ownConn = false;
try {
const wasConnected = mongoose.connection.readyState === 1;
await connectDB();
if (!wasConnected) {
ownConn = true;
}
const Home = require("../models/home");
const Footer = require("../models/footer");
const footer = await Footer.findOne().sort({ updatedAt: -1 }).lean();
const footerPhone = footer?.top?.phone?.href || footer?.top?.phone?.display || "";
const zaloPhone = normalizePhoneForZalo(footerPhone) || "84961834040";
const zaloUrl = `https://zalo.me/${zaloPhone}`;
const defaultFloatingContact = {
enabled: true,
position: "bottom-right",
panelTitle: DEFAULT_PANEL_TITLE,
brand: {
imageSrc: DEFAULT_BRAND_IMAGE,
imageAlt: "HAI Learning",
},
trigger: {
icon: "fa-comments",
},
actions: [
{
id: "facebook",
platform: "facebook",
enabled: true,
label: "Nhắn tin qua Facebook",
subtitle: "facebook.com/hailearning.edu.vn",
href: DEFAULT_FACEBOOK_URL,
iconImage: DEFAULT_FACEBOOK_ICON,
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/${zaloPhone}`,
href: zaloUrl,
iconImage: DEFAULT_ZALO_ICON,
iconType: "iconText",
iconClass: "",
iconText: "Zalo",
order: 2,
},
],
};
const updateResult = await Home.updateMany(
{ floatingContact: { $exists: false } },
{ $set: { floatingContact: defaultFloatingContact } },
);
if (updateResult.matchedCount === 0) {
await Home.create({ floatingContact: defaultFloatingContact });
console.log("Created a Home document with default floatingContact data.");
} else {
console.log(
`Updated ${updateResult.modifiedCount} Home document(s) with floatingContact defaults.`,
);
}
if (ownConn && mongoose.connection.readyState === 1) {
await mongoose.disconnect();
}
process.exit(0);
} catch (error) {
console.error("Failed to add floatingContact to Home:", error);
if (ownConn && mongoose.connection.readyState === 1) {
await mongoose.disconnect();
}
process.exit(1);
}
}
migrate();

View File

@@ -0,0 +1,129 @@
const mongoose = require("mongoose");
const connectDB = require("../config/database");
const Home = require("../models/home");
const DEFAULT_ICON_BY_PLATFORM = {
facebook: "/uploads/home/floating-contact/Facebook_Logo_Primary.webp",
zalo: "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp",
};
async function up() {
await connectDB();
try {
const homes = await Home.find({
floatingContact: { $exists: true },
});
let updatedCount = 0;
for (const home of homes) {
const floatingContact = home.floatingContact || {};
let modified = false;
if (!floatingContact.trigger) {
floatingContact.trigger = {};
modified = true;
}
if (typeof floatingContact.trigger.imageSrc !== "string") {
floatingContact.trigger.imageSrc = "";
modified = true;
}
if (Array.isArray(floatingContact.actions)) {
floatingContact.actions = floatingContact.actions.map((action) => {
const defaultIcon =
DEFAULT_ICON_BY_PLATFORM[String(action?.platform || "").trim().toLowerCase()] || "";
if (typeof action?.iconImage !== "string") {
modified = true;
return {
...action,
iconImage: defaultIcon,
};
}
if (!action.iconImage.trim() && defaultIcon) {
modified = true;
return {
...action,
iconImage: defaultIcon,
};
}
return action;
});
}
if (modified) {
home.floatingContact = floatingContact;
home.markModified("floatingContact");
await home.save();
updatedCount += 1;
}
}
console.log(
`Added floatingContact trigger/action image fields to ${updatedCount} Home document(s).`,
);
} catch (error) {
console.error("Failed to add floatingContact icon image fields:", error);
throw error;
} finally {
await mongoose.disconnect();
}
}
async function down() {
await connectDB();
try {
const homes = await Home.find({
floatingContact: { $exists: true },
});
let updatedCount = 0;
for (const home of homes) {
const floatingContact = home.floatingContact || {};
let modified = false;
if (floatingContact.trigger && "imageSrc" in floatingContact.trigger) {
delete floatingContact.trigger.imageSrc;
modified = true;
}
if (Array.isArray(floatingContact.actions)) {
floatingContact.actions = floatingContact.actions.map((action) => {
if (!action || !("iconImage" in action)) {
return action;
}
modified = true;
const nextAction = { ...action };
delete nextAction.iconImage;
return nextAction;
});
}
if (modified) {
home.floatingContact = floatingContact;
home.markModified("floatingContact");
await home.save();
updatedCount += 1;
}
}
console.log(
`Removed floatingContact trigger/action image fields from ${updatedCount} Home document(s).`,
);
} catch (error) {
console.error("Failed to rollback floatingContact icon image fields:", error);
throw error;
} finally {
await mongoose.disconnect();
}
}
module.exports = { up, down };