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

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ pids
#cursor #cursor
.cursor .cursor
package-lock.json package-lock.json
/.omc
CLAUDE.md
/.claude

View File

@@ -1,4 +1,7 @@
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper"); const {
addBaseUrlToImages,
getFullImageUrl,
} = require("../utils/imageHelper");
const Home = require("../models/home"); const Home = require("../models/home");
const Blog = require("../models/blog"); const Blog = require("../models/blog");
const writeAuditLog = require("../audit/writeAuditLog"); const writeAuditLog = require("../audit/writeAuditLog");
@@ -8,6 +11,137 @@ const AUDIT_ACTIONS = require("../constants/auditAction");
// Các hàm hỗ trợ // Các hàm hỗ trợ
const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 }); const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 });
const getHomeData = async () => (await getHomeDoc())?.toObject() || {}; 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 = () => ({ const getDefaultHomeData = () => ({
hero: { hero: {
@@ -64,6 +198,7 @@ const getDefaultHomeData = () => ({
items: [], items: [],
selectedBlogIds: [], // Array of manually selected blog IDs selectedBlogIds: [], // Array of manually selected blog IDs
}, },
floatingContact: getDefaultFloatingContactData(),
}); });
// Admin: Xem trang quản lý // Admin: Xem trang quản lý
@@ -77,9 +212,10 @@ exports.index = async (req, res) => {
sections.forEach((s) => { sections.forEach((s) => {
data[s] = data[s] || defaults[s]; data[s] = data[s] || defaults[s];
}); });
data.floatingContact = normalizeFloatingContactData(data.floatingContact);
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; 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 // Lấy tất cả blog để chọn trong CMS
const allBlogs = await Blog.find({ status: "published" }) const allBlogs = await Blog.find({ status: "published" })
@@ -118,6 +254,7 @@ exports.update = async (req, res) => {
"achievements", "achievements",
"partners", "partners",
"blogPreview", "blogPreview",
"floatingContact",
]; ];
let doc = await getHomeDoc(); let doc = await getHomeDoc();
@@ -135,7 +272,10 @@ exports.update = async (req, res) => {
try { try {
const payload = JSON.parse(req.body[section]); const payload = JSON.parse(req.body[section]);
// Gán trực tiếp vào doc, Mongoose sẽ tự check schema // 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); doc.markModified(section);
hasChanges = true; hasChanges = true;
updatedSections.push(section); 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 // Public API// API lấy danh sách blog cho CMS
exports.apiGetBlogs = async (req, res) => { exports.apiGetBlogs = async (req, res) => {
try { try {
@@ -191,8 +374,7 @@ exports.apiGetBlogs = async (req, res) => {
exports.api = async (req, res) => { exports.api = async (req, res) => {
try { try {
let data = await getHomeData(); let data = await getHomeData();
const baseUrl = const baseUrl = `${req.protocol}://${req.get("host")}`;
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
// === Xử lý Blog Preview động === // === Xử lý Blog Preview động ===
const blogPreview = data.blogPreview || {}; const blogPreview = data.blogPreview || {};
@@ -238,6 +420,7 @@ exports.api = async (req, res) => {
})); }));
data.blogPreview = blogPreview; data.blogPreview = blogPreview;
data.floatingContact = normalizeFloatingContactData(data.floatingContact);
// =============================== // ===============================
const processed = addBaseUrlToImages(data, baseUrl); const processed = addBaseUrlToImages(data, baseUrl);

View File

@@ -1,7 +1,98 @@
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const sharp = require('sharp');
const jsonHelper = require('../utils/jsonHelper'); 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 // Controller xử lý upload ảnh
const uploadController = { const uploadController = {
// Upload ảnh và trả về đường dẫn // Upload ảnh và trả về đường dẫn
@@ -13,15 +104,14 @@ const uploadController = {
// Lấy loại ảnh từ query params // Lấy loại ảnh từ query params
const imageType = req.query.imageType || 'general'; 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 // Tạo đường dẫn tương đối để lưu vào database
const relativePath = `/uploads/${imageType}/${req.file.filename}`; const relativePath = `/uploads/${imageType}/${req.file.filename}`;
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const fullUrl = `${baseUrl}${relativePath}`; 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), // 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 // thì đồng bộ camps.image và camps.camp-detail.hero.bgImage
try { try {
@@ -60,8 +150,9 @@ const uploadController = {
success: true, success: true,
path: relativePath, path: relativePath,
url: fullUrl, url: fullUrl,
reused: fileAlreadyExists, resizePreset: resizePreset || null,
message: fileAlreadyExists ? 'Using existing file' : 'File uploaded successfully' reused: false,
message: 'File uploaded successfully'
}); });
} catch (error) { } catch (error) {
console.error('Error uploading image:', error); console.error('Error uploading image:', error);

View File

@@ -11,6 +11,51 @@ const LinkSchema = new Schema(
{ _id: false }, { _id: false },
); );
const FloatingContactBrandSchema = new Schema(
{
imageSrc: { type: String, default: "" },
imageAlt: { type: String, default: "", maxlength: 60 },
},
{ _id: false },
);
const FloatingContactTriggerSchema = new Schema(
{
imageSrc: { type: String, default: "" },
icon: { type: String, default: "fa-comments" },
},
{ _id: false },
);
const FloatingContactActionSchema = new Schema(
{
id: { type: String, default: "" },
platform: { type: String, default: "" },
enabled: { type: Boolean, default: true },
label: { type: String, default: "", maxlength: 48 },
subtitle: { type: String, default: "", maxlength: 48 },
href: { type: String, default: "" },
iconImage: { type: String, default: "" },
iconType: { type: String, default: "iconClass" },
iconClass: { type: String, default: "" },
iconText: { type: String, default: "", maxlength: 12 },
order: { type: Number, default: 0 },
},
{ _id: false },
);
const FloatingContactSchema = new Schema(
{
enabled: { type: Boolean, default: true },
position: { type: String, default: "bottom-right" },
panelTitle: { type: String, default: "", maxlength: 72 },
brand: { type: FloatingContactBrandSchema, default: () => ({}) },
trigger: { type: FloatingContactTriggerSchema, default: () => ({}) },
actions: { type: [FloatingContactActionSchema], default: [] },
},
{ _id: false },
);
// Hero slide (for multiple hero items in slider) // Hero slide (for multiple hero items in slider)
const HeroSlideSchema = new Schema( const HeroSlideSchema = new Schema(
{ {
@@ -266,6 +311,7 @@ const HomeSchema = new Schema(
achievements: { type: AchievementsSchema, default: () => ({}) }, achievements: { type: AchievementsSchema, default: () => ({}) },
partners: { type: PartnersSchema, default: () => ({}) }, partners: { type: PartnersSchema, default: () => ({}) },
blogPreview: { type: BlogPreviewSchema, default: () => ({}) }, blogPreview: { type: BlogPreviewSchema, default: () => ({}) },
floatingContact: { type: FloatingContactSchema, default: () => ({}) },
}, },
{ {
timestamps: true, timestamps: true,

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -36,9 +36,34 @@ const videoGalleryController = require("../controllers/videoGalleryController");
// Dashboard // Dashboard
router.get("/dashboard", ensureAuthenticated, dashboardController.getDashboard); router.get("/dashboard", ensureAuthenticated, dashboardController.getDashboard);
const runUploadMiddleware = (middleware) => (req, res, next) => {
middleware(req, res, (error) => {
if (!error) {
return next();
}
console.error("Upload middleware error:", error);
const status =
error.code === "LIMIT_FILE_SIZE"
? 413
: error.statusCode || error.status || 400;
return res.status(status).json({
success: false,
error: error.message || "Upload failed",
});
});
};
// Home // Home
router.get("/home", ensureAuthenticated, homeController.index); router.get("/home", ensureAuthenticated, homeController.index);
router.post("/home/update", ensureAuthenticated, homeController.update); router.post("/home/update", ensureAuthenticated, homeController.update);
router.post(
"/home/floating-contact/update",
ensureAuthenticated,
homeController.updateFloatingContact,
);
router.get("/home/api/blogs", ensureAuthenticated, homeController.apiGetBlogs); router.get("/home/api/blogs", ensureAuthenticated, homeController.apiGetBlogs);
// Middleware chuẩn hóa code // Middleware chuẩn hóa code
@@ -72,13 +97,13 @@ router.get("/upload", ensureAuthenticated, (req, res) => {
router.post( router.post(
"/upload/image", "/upload/image",
ensureAuthenticated, ensureAuthenticated,
upload.single("image"), runUploadMiddleware(upload.single("image")),
uploadController.uploadImage, uploadController.uploadImage,
); );
router.post( router.post(
"/upload/video", "/upload/video",
ensureAuthenticated, ensureAuthenticated,
uploadVideo.single("video"), runUploadMiddleware(uploadVideo.single("video")),
uploadController.uploadVideo, uploadController.uploadVideo,
); );
router.post( router.post(

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

View File

@@ -27,6 +27,7 @@
<input type="hidden" name="achievements" id="achievementsJson" /> <input type="hidden" name="achievements" id="achievementsJson" />
<input type="hidden" name="partners" id="partnersJson" /> <input type="hidden" name="partners" id="partnersJson" />
<input type="hidden" name="blogPreview" id="blogPreviewJson" /> <input type="hidden" name="blogPreview" id="blogPreviewJson" />
<input type="hidden" name="floatingContact" id="floatingContactJson" />
<!-- Navigation Tabs --> <!-- Navigation Tabs -->
<div class="card shadow-sm border-0 mb-4"> <div class="card shadow-sm border-0 mb-4">
@@ -82,6 +83,11 @@
<i class="fas fa-blog me-2"></i>Blog Preview <i class="fas fa-blog me-2"></i>Blog Preview
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#floatingcontact" role="tab">
<i class="fas fa-comment-dots me-2"></i>Floating Contact
</a>
</li>
</ul> </ul>
</div> </div>
@@ -97,6 +103,7 @@
<%- include('sections/achievements') %> <%- include('sections/achievements') %>
<%- include('sections/partners') %> <%- include('sections/partners') %>
<%- include('sections/blogPreview') %> <%- include('sections/blogPreview') %>
<%- include('sections/floatingContact') %>
</div> </div>
</div> </div>
</div> </div>
@@ -118,9 +125,14 @@
</div> </div>
<!-- Image upload input --> <!-- Image upload input -->
<input type="file" id="directImageUpload" style="display: none" /> <input
type="file"
id="directImageUpload"
style="display: none"
accept="image/*,.png,.jpg,.jpeg,.gif,.webp,.svg" />
<input type="hidden" id="currentImageType" name="imageType" /> <input type="hidden" id="currentImageType" name="imageType" />
<input type="hidden" id="currentTargetInput" name="targetInput" /> <input type="hidden" id="currentTargetInput" name="targetInput" />
<input type="hidden" id="currentResizePreset" name="resizePreset" />
<script> <script>
/** /**
@@ -132,13 +144,26 @@
* <\/script> * <\/script>
*/ */
window.homeScrapers = window.homeScrapers || {}; window.homeScrapers = window.homeScrapers || {};
const pendingImageUploads = new Map();
const pendingPreviewUrls = new Map();
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const form = document.querySelector("form"); const form = document.querySelector("form");
if (form) { if (form) {
form.addEventListener("submit", function (e) { form.addEventListener("submit", async function (e) {
if (form.dataset.submitting === "true") {
return;
}
e.preventDefault();
if (typeof form.reportValidity === "function" && !form.reportValidity()) {
return;
}
console.log("Form submitting, collecting data from scrapers..."); console.log("Form submitting, collecting data from scrapers...");
try {
await flushPendingImageUploads();
// Tự động thu gom dữ liệu từ các section đã đăng ký // Tự động thu gom dữ liệu từ các section đã đăng ký
Object.keys(window.homeScrapers).forEach(section => { Object.keys(window.homeScrapers).forEach(section => {
const input = document.getElementById(section + 'Json'); const input = document.getElementById(section + 'Json');
@@ -153,7 +178,12 @@
} }
}); });
// Để form tự submit tự nhiên sau khi đã điền xong các hidden inputs form.dataset.submitting = "true";
form.submit();
} catch (error) {
console.error("Error during deferred image uploads:", error);
showToast("Error", error.message || "Failed to upload pending images", "error");
}
}); });
} }
@@ -163,12 +193,141 @@
// --- UTILITIES (Dùng chung) --- // --- UTILITIES (Dùng chung) ---
function extractHtmlErrorMessage(html) {
if (!html) {
return "";
}
const preMatch = html.match(/<pre>([\s\S]*?)<\/pre>/i);
const titleMatch = html.match(/<title>([\s\S]*?)<\/title>/i);
const rawMessage = (preMatch && preMatch[1]) || (titleMatch && titleMatch[1]) || html;
const decoded = rawMessage
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&");
return decoded.replace(/\s+/g, " ").trim();
}
function findImagePreview(input) {
if (!input) {
return null;
}
return (
input.closest(".input-group")?.nextElementSibling?.querySelector("img") ||
input.parentElement?.nextElementSibling?.querySelector("img") ||
null
);
}
function revokePendingPreview(targetInput) {
const previewUrl = pendingPreviewUrls.get(targetInput);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
pendingPreviewUrls.delete(targetInput);
}
}
function isFloatingContactTargetInput(targetInput) {
return typeof targetInput === "string" && targetInput.startsWith("floatingContact");
}
async function persistFloatingContactDraft() {
const scraper = window.homeScrapers && window.homeScrapers.floatingContact;
if (typeof scraper !== "function") {
throw new Error("Floating contact scraper is not available");
}
const response = await fetch("/admin/home/floating-contact/update", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
floatingContact: scraper(),
}),
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.success) {
throw new Error(result?.error || "Failed to save floating contact changes");
}
return result;
}
async function uploadPendingImage(targetInput, uploadConfig) {
const input = document.getElementById(targetInput);
if (!input || !uploadConfig?.file) {
pendingImageUploads.delete(targetInput);
revokePendingPreview(targetInput);
return;
}
const formData = new FormData();
formData.append("image", uploadConfig.file);
const query = new URLSearchParams({ imageType: uploadConfig.imageType });
if (uploadConfig.resizePreset) {
query.set("resizePreset", uploadConfig.resizePreset);
}
const response = await fetch(`/admin/upload/image?${query.toString()}`, {
method: "POST",
body: formData,
});
const rawResponse = await response.text();
let result = null;
try {
result = rawResponse ? JSON.parse(rawResponse) : null;
} catch (parseError) {
result = null;
}
if (!response.ok) {
throw new Error(
result?.error ||
extractHtmlErrorMessage(rawResponse) ||
`Upload failed with status ${response.status}`,
);
}
if (!result?.success || !result.path) {
throw new Error(result?.error || "Upload failed");
}
input.value = result.path;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
const previewImg = findImagePreview(input);
if (previewImg) {
previewImg.src = new URL(result.path, window.location.origin).toString();
previewImg.classList.remove("d-none");
}
pendingImageUploads.delete(targetInput);
revokePendingPreview(targetInput);
}
async function flushPendingImageUploads() {
for (const [targetInput, uploadConfig] of pendingImageUploads.entries()) {
await uploadPendingImage(targetInput, uploadConfig);
}
}
function initImageUploads() { function initImageUploads() {
document.addEventListener("click", function (e) { document.addEventListener("click", function (e) {
const btn = e.target.closest(".btn-upload-image"); const btn = e.target.closest(".btn-upload-image");
if (btn) { if (btn) {
document.getElementById("currentImageType").value = btn.dataset.imageType; document.getElementById("currentImageType").value = btn.dataset.imageType;
document.getElementById("currentTargetInput").value = btn.dataset.targetInput; document.getElementById("currentTargetInput").value = btn.dataset.targetInput;
document.getElementById("currentResizePreset").value = btn.dataset.resizePreset || "";
document.getElementById("directImageUpload").click(); document.getElementById("directImageUpload").click();
} }
}); });
@@ -184,28 +343,43 @@
const file = this.files[0]; const file = this.files[0];
const imageType = document.getElementById("currentImageType").value; const imageType = document.getElementById("currentImageType").value;
const targetInput = document.getElementById("currentTargetInput").value; const targetInput = document.getElementById("currentTargetInput").value;
const resizePreset = document.getElementById("currentResizePreset").value;
const allowedExtensions = /\.(jpe?g|png|gif|webp|svg)$/i;
if (!(file.type.startsWith("image/") || allowedExtensions.test(file.name))) {
showToast("Error", "Only image files are allowed", "error");
this.value = "";
return;
}
try { try {
const formData = new FormData();
formData.append("image", file);
const response = await fetch(`/admin/upload/image?imageType=${imageType}`, { method: "POST", body: formData });
const result = await response.json();
if (result.success && result.path) {
const input = document.getElementById(targetInput); const input = document.getElementById(targetInput);
if (input) { if (!input) {
input.value = result.path; throw new Error("Target image field not found");
// Cập nhật preview nếu có img ngay sau input group }
const previewImg = input.closest('.input-group')?.nextElementSibling?.querySelector('img');
revokePendingPreview(targetInput);
const previewUrl = URL.createObjectURL(file);
pendingPreviewUrls.set(targetInput, previewUrl);
const previewImg = findImagePreview(input);
if (previewImg) { if (previewImg) {
previewImg.src = result.path; previewImg.src = previewUrl;
previewImg.classList.remove('d-none'); previewImg.classList.remove("d-none");
} }
if (isFloatingContactTargetInput(targetInput)) {
pendingImageUploads.delete(targetInput);
await uploadPendingImage(targetInput, { file, imageType, resizePreset });
await persistFloatingContactDraft();
showToast("Success", "Image uploaded and saved immediately.", "success");
this.value = "";
return;
} }
showToast("Success", "Image uploaded successfully", "success");
} else { pendingImageUploads.set(targetInput, { file, imageType, resizePreset });
throw new Error(result.error || "Upload failed");
} showToast("Ready", "Image selected. It will be uploaded when you save changes.", "info");
} catch (error) { } catch (error) {
showToast("Error", "Upload failed: " + error.message, "error"); showToast("Error", "Upload failed: " + error.message, "error");
} }

View File

@@ -0,0 +1,510 @@
<!-- Floating Contact Tab -->
<div class="tab-pane fade" id="floatingcontact" role="tabpanel">
<div class="row g-4">
<div class="col-md-12">
<div class="card border shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<i class="fas fa-sliders-h me-2"></i>Widget Settings
</h6>
<span class="badge bg-light text-dark border">Homepage floating contact widget</span>
</div>
<div class="card-body">
<div class="row g-4 align-items-start">
<div class="col-12">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 p-3 border rounded-3 bg-light-subtle">
<div>
<div class="fw-semibold">Widget visibility</div>
<small class="text-muted">Enable or disable the floating contact widget on the homepage.</small>
</div>
<div class="form-check form-switch m-0">
<input class="form-check-input" type="checkbox" id="floatingContactEnabled"
<%= data.floatingContact?.enabled !== false ? 'checked' : '' %> />
<label class="form-check-label fw-medium" for="floatingContactEnabled">
Enable floating widget
</label>
</div>
</div>
</div>
<div class="col-lg-8">
<label class="form-label fw-medium">Panel Title</label>
<input type="text" class="form-control" id="floatingContactPanelTitle"
value="<%= data.floatingContact?.panelTitle || '' %>"
placeholder="How can we help you today?"
maxlength="72"
data-maxlength="72" />
<small class="text-muted">Maximum 72 characters to keep the header from breaking the widget layout.</small>
</div>
<div class="col-lg-4">
<label class="form-label fw-medium">Brand Alt Text</label>
<input type="text" class="form-control" id="floatingContactBrandAlt"
value="<%= data.floatingContact?.brand?.imageAlt || 'HAI Learning' %>" placeholder="HAI Learning"
maxlength="60"
data-maxlength="60" />
<small class="text-muted d-block mt-2">Used for accessibility and fallback image descriptions.</small>
</div>
<div class="col-lg-6">
<label class="form-label fw-medium">Brand Image</label>
<div class="input-group mb-2">
<input type="text" class="form-control" id="floatingContactBrandImage"
value="<%= data.floatingContact?.brand?.imageSrc || '' %>" placeholder="/assets/img/logo/black-logo.svg" />
<button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="floatingContactBrandImage" data-image-type="home/floating-contact"
data-resize-preset="floatingContactBrandImage">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
<div class="mt-2">
<img
src="<%= data.floatingContact?.brand?.imageSrc ? getFullImageUrl(data.floatingContact.brand.imageSrc, backendUrl) : '' %>"
class="img-thumbnail <%= data.floatingContact?.brand?.imageSrc ? '' : 'd-none' %>"
id="floatingContactBrandPreview"
style="height: 120px; width: 120px; object-fit: contain; background: #fff;"
alt="Brand preview" />
</div>
<small class="text-muted d-block mt-2">Raster logo uploads are normalized to 104x104 WebP to match the homepage widget.</small>
</div>
<div class="col-lg-6">
<label class="form-label fw-medium">Fallback Trigger Image</label>
<div class="input-group mb-2">
<input
type="text"
class="form-control"
id="floatingContactTriggerImage"
value="<%= data.floatingContact?.trigger?.imageSrc || '' %>"
placeholder="/uploads/home/floating-contact/floating-trigger-icon.webp" />
<button
type="button"
class="btn btn-outline-primary btn-upload-image"
data-target-input="floatingContactTriggerImage"
data-image-type="home/floating-contact"
data-resize-preset="floatingContactTriggerIcon">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
<div class="mt-2">
<img
src="<%= data.floatingContact?.trigger?.imageSrc ? getFullImageUrl(data.floatingContact.trigger.imageSrc, backendUrl) : '' %>"
class="img-thumbnail <%= data.floatingContact?.trigger?.imageSrc ? '' : 'd-none' %>"
id="floatingContactTriggerPreview"
style="height: 120px; width: 120px; object-fit: contain; background: #fff;"
alt="Trigger icon preview" />
</div>
<input type="hidden" id="floatingContactTriggerIconFallback"
value="<%= data.floatingContact?.trigger?.icon || 'fa-comments' %>" />
<small class="text-muted d-block mt-2">Shown only when the trigger slideshow cannot use the brand image and action icons. Raster uploads are normalized to 96x96 WebP.</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-12">
<div class="card border shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-0">
<i class="fas fa-list me-2"></i>Contact Actions
</h6>
<small class="text-muted">Add, remove, and drag to reorder floating contact actions.</small>
</div>
<button type="button" class="btn btn-sm btn-primary" id="addFloatingContactActionBtn">
<i class="fas fa-plus me-1"></i>Add Action
</button>
</div>
<div class="card-body">
<div id="floatingContactActionsContainer" class="d-grid gap-3"></div>
</div>
</div>
</div>
</div>
</div>
<div
id="floatingContactConfig"
data-cms-base-url="<%= backendUrl.replace(/\/$/, '') %>"
hidden
></div>
<script id="floatingContactInitialData" type="application/json"><%- JSON.stringify(data.floatingContact || {}) %></script>
<script>
window.homeScrapers = window.homeScrapers || {};
const floatingContactConfig = document.getElementById("floatingContactConfig");
const floatingContactCmsBaseUrl = floatingContactConfig?.dataset.cmsBaseUrl || "";
const normalizeFloatingContactPublicPath = (value) => {
const raw = (value || "").trim();
if (!raw) {
return "";
}
const knownPrefixes = ["/uploads/", "/assets/", "/img/"];
for (const prefix of knownPrefixes) {
const absolutePrefix = `${floatingContactCmsBaseUrl}${prefix}`;
if (raw.startsWith(absolutePrefix)) {
return raw.slice(floatingContactCmsBaseUrl.length);
}
}
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 resolveFloatingContactImageUrl = (value) => {
const normalized = normalizeFloatingContactPublicPath(value);
return normalized ? `${floatingContactCmsBaseUrl}${normalized}` : "";
};
const escapeFloatingContactHtml = (value) =>
String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
document.addEventListener("DOMContentLoaded", function () {
const initialDataElement = document.getElementById("floatingContactInitialData");
const initialData = initialDataElement?.textContent
? JSON.parse(initialDataElement.textContent)
: {};
const container = document.getElementById("floatingContactActionsContainer");
const addBtn = document.getElementById("addFloatingContactActionBtn");
const brandInput = document.getElementById("floatingContactBrandImage");
const brandPreview = document.getElementById("floatingContactBrandPreview");
const triggerInput = document.getElementById("floatingContactTriggerImage");
const triggerPreview = document.getElementById("floatingContactTriggerPreview");
if (!container || !addBtn) {
return;
}
const createActionId = () => `floating-action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const getActionDefaults = (platform) => {
if (platform === "facebook") {
return {
platform: "facebook",
enabled: true,
label: "Message us on Facebook",
subtitle: "facebook.com/hailearning.edu.vn",
href: "https://www.facebook.com/hailearning.edu.vn/",
iconImage: "",
iconType: "iconClass",
iconClass: "fa-brands fa-facebook-messenger",
iconText: "",
};
}
if (platform === "zalo") {
return {
platform: "zalo",
enabled: true,
label: "Message us on Zalo",
subtitle: "zalo.me/84961834040",
href: "https://zalo.me/84961834040",
iconImage: "",
iconType: "iconText",
iconClass: "",
iconText: "Zalo",
};
}
return {
platform: "custom",
enabled: true,
label: "",
subtitle: "",
href: "",
iconImage: "",
iconType: "iconClass",
iconClass: "fa-solid fa-comment-dots",
iconText: "",
};
};
const bindImageField = (input, preview) => {
if (!input || !preview) {
return;
}
input.value = normalizeFloatingContactPublicPath(input.value);
if (input.value) {
preview.src = resolveFloatingContactImageUrl(input.value);
preview.classList.remove("d-none");
}
if (input.dataset.previewBound === "true") {
return;
}
input.addEventListener("input", () => {
const value = normalizeFloatingContactPublicPath(input.value);
input.value = value;
if (!value) {
preview.classList.add("d-none");
preview.removeAttribute("src");
return;
}
preview.src = resolveFloatingContactImageUrl(value);
preview.classList.remove("d-none");
});
input.dataset.previewBound = "true";
};
const createActionCard = (action = {}) => {
const defaults = {
id: createActionId(),
...getActionDefaults(action.platform || "custom"),
...action,
};
const normalizedIconImage = normalizeFloatingContactPublicPath(defaults.iconImage || "");
const iconInputId = `floatingContactActionIconImage-${defaults.id}`;
const iconPreviewId = `${iconInputId}-preview`;
const wrapper = document.createElement("div");
wrapper.className = "card border floating-contact-action-item";
wrapper.innerHTML = `
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary floating-contact-action-handle" title="Drag to reorder">
<i class="fas fa-grip-vertical"></i>
</button>
<strong class="floating-contact-action-title">Action</strong>
</div>
<button type="button" class="btn btn-sm btn-outline-danger floating-contact-remove-action">
<i class="fas fa-trash-alt me-1"></i>Remove
</button>
</div>
<div class="card-body">
<input type="hidden" class="floating-contact-action-id" value="${escapeFloatingContactHtml(defaults.id)}" />
<input type="hidden" class="floating-contact-action-legacy-icon-type" value="${escapeFloatingContactHtml(defaults.iconType)}" />
<input type="hidden" class="floating-contact-action-legacy-icon-class" value="${escapeFloatingContactHtml(defaults.iconClass)}" />
<input type="hidden" class="floating-contact-action-legacy-icon-text" value="${escapeFloatingContactHtml(defaults.iconText)}" />
<div class="row g-3 align-items-start">
<div class="col-lg-3 col-md-6">
<label class="form-label fw-medium">Platform</label>
<select class="form-select floating-contact-action-platform">
<option value="facebook" ${defaults.platform === "facebook" ? "selected" : ""}>Facebook</option>
<option value="zalo" ${defaults.platform === "zalo" ? "selected" : ""}>Zalo</option>
<option value="custom" ${defaults.platform === "custom" ? "selected" : ""}>Custom</option>
</select>
</div>
<div class="col-lg-5 col-md-6">
<label class="form-label fw-medium">Label</label>
<input type="text" class="form-control floating-contact-action-label" value="${escapeFloatingContactHtml(defaults.label)}" placeholder="Message us on Facebook" maxlength="48" data-maxlength="48" />
</div>
<div class="col-lg-4 col-md-8">
<label class="form-label fw-medium">Subtitle</label>
<input type="text" class="form-control floating-contact-action-subtitle" value="${escapeFloatingContactHtml(defaults.subtitle)}" placeholder="facebook.com/hailearning.edu.vn" maxlength="48" data-maxlength="48" />
</div>
<div class="col-lg-3 col-md-4">
<label class="form-label fw-medium d-block">Status</label>
<div class="form-check form-switch mt-2">
<input class="form-check-input floating-contact-action-enabled" type="checkbox" ${defaults.enabled !== false ? "checked" : ""}>
<label class="form-check-label fw-medium">Enabled</label>
</div>
</div>
<div class="col-lg-9 col-md-8">
<label class="form-label fw-medium">Link</label>
<input type="text" class="form-control floating-contact-action-href" value="${escapeFloatingContactHtml(defaults.href)}" placeholder="https://example.com" />
</div>
<div class="col-12">
<label class="form-label fw-medium">Icon Image</label>
<div class="input-group mb-2">
<input type="text" class="form-control floating-contact-action-icon-image" id="${escapeFloatingContactHtml(iconInputId)}" value="${escapeFloatingContactHtml(normalizedIconImage)}" placeholder="/uploads/home/floating-contact/floating-action-icon.webp" />
<button
type="button"
class="btn btn-outline-primary btn-upload-image"
data-target-input="${escapeFloatingContactHtml(iconInputId)}"
data-image-type="home/floating-contact"
data-resize-preset="floatingContactActionIcon">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
<div class="mt-2">
<img
src="${normalizedIconImage ? escapeFloatingContactHtml(resolveFloatingContactImageUrl(normalizedIconImage)) : ""}"
class="img-thumbnail floating-contact-action-icon-preview ${normalizedIconImage ? "" : "d-none"}"
id="${escapeFloatingContactHtml(iconPreviewId)}"
style="height: 120px; width: 120px; object-fit: contain; background: #fff;"
alt="Action icon preview" />
</div>
<small class="text-muted d-block mt-2">Raster uploads are normalized to 84x84 WebP. SVG files remain unchanged.</small>
</div>
</div>
</div>
`;
return wrapper;
};
const updateActionCardTitles = () => {
container.querySelectorAll(".floating-contact-action-item").forEach((item, index) => {
const title = item.querySelector(".floating-contact-action-title");
if (title) {
title.textContent = `Action ${index + 1}`;
}
});
};
const ensureLengthHint = (input) => {
if (!input || !input.dataset.maxlength) {
return;
}
let hint = input.parentElement?.querySelector(".floating-contact-length-hint");
if (!hint) {
hint = document.createElement("small");
hint.className = "text-muted d-block mt-1 floating-contact-length-hint";
input.parentElement?.appendChild(hint);
}
const max = Number(input.dataset.maxlength);
const length = input.value.length;
hint.textContent = `${length}/${max} characters`;
hint.classList.toggle("text-danger", length >= max);
};
const bindLengthHints = (scope = document) => {
scope.querySelectorAll("input[data-maxlength]").forEach((input) => {
ensureLengthHint(input);
if (input.dataset.lengthBound === "true") {
return;
}
input.addEventListener("input", () => ensureLengthHint(input));
input.dataset.lengthBound = "true";
});
};
const bindActionCard = (card) => {
bindLengthHints(card);
bindImageField(
card.querySelector(".floating-contact-action-icon-image"),
card.querySelector(".floating-contact-action-icon-preview"),
);
};
const maybeApplyPlatformDefaults = (item) => {
const platform = item.querySelector(".floating-contact-action-platform")?.value || "custom";
const defaults = getActionDefaults(platform);
item.querySelector(".floating-contact-action-label").value =
item.querySelector(".floating-contact-action-label").value || defaults.label;
item.querySelector(".floating-contact-action-subtitle").value =
item.querySelector(".floating-contact-action-subtitle").value || defaults.subtitle;
item.querySelector(".floating-contact-action-href").value =
item.querySelector(".floating-contact-action-href").value || defaults.href;
item.querySelector(".floating-contact-action-legacy-icon-type").value = defaults.iconType;
item.querySelector(".floating-contact-action-legacy-icon-class").value = defaults.iconClass;
item.querySelector(".floating-contact-action-legacy-icon-text").value = defaults.iconText;
};
const renderInitialActions = () => {
const actions = Array.isArray(initialData.actions) && initialData.actions.length > 0
? initialData.actions
: [getActionDefaults("facebook"), getActionDefaults("zalo")];
actions.forEach((action) => {
const card = createActionCard(action);
container.appendChild(card);
bindActionCard(card);
});
updateActionCardTitles();
};
addBtn.addEventListener("click", () => {
const card = createActionCard(getActionDefaults("custom"));
container.appendChild(card);
bindActionCard(card);
updateActionCardTitles();
});
container.addEventListener("click", (event) => {
const removeBtn = event.target.closest(".floating-contact-remove-action");
if (!removeBtn) {
return;
}
const card = removeBtn.closest(".floating-contact-action-item");
if (!card) {
return;
}
card.remove();
updateActionCardTitles();
});
container.addEventListener("change", (event) => {
const card = event.target.closest(".floating-contact-action-item");
if (!card) {
return;
}
if (event.target.matches(".floating-contact-action-platform")) {
maybeApplyPlatformDefaults(card);
}
});
bindImageField(brandInput, brandPreview);
bindImageField(triggerInput, triggerPreview);
if (window.Sortable) {
window.Sortable.create(container, {
animation: 150,
handle: ".floating-contact-action-handle",
onEnd: updateActionCardTitles,
});
}
renderInitialActions();
bindLengthHints(document);
});
window.homeScrapers.floatingContact = () => {
const actions = Array.from(document.querySelectorAll(".floating-contact-action-item")).map((item, index) => ({
id: item.querySelector(".floating-contact-action-id")?.value || "",
platform: item.querySelector(".floating-contact-action-platform")?.value || "custom",
enabled: !!item.querySelector(".floating-contact-action-enabled")?.checked,
label: item.querySelector(".floating-contact-action-label")?.value?.trim() || "",
subtitle: item.querySelector(".floating-contact-action-subtitle")?.value?.trim() || "",
href: item.querySelector(".floating-contact-action-href")?.value?.trim() || "",
iconImage: normalizeFloatingContactPublicPath(item.querySelector(".floating-contact-action-icon-image")?.value?.trim() || ""),
iconType: item.querySelector(".floating-contact-action-legacy-icon-type")?.value || "iconClass",
iconClass: item.querySelector(".floating-contact-action-legacy-icon-class")?.value?.trim() || "",
iconText: item.querySelector(".floating-contact-action-legacy-icon-text")?.value?.trim() || "",
order: index + 1,
}));
return {
enabled: !!document.getElementById("floatingContactEnabled")?.checked,
panelTitle: document.getElementById("floatingContactPanelTitle")?.value?.trim() || "",
brand: {
imageSrc: normalizeFloatingContactPublicPath(document.getElementById("floatingContactBrandImage")?.value?.trim() || ""),
imageAlt: document.getElementById("floatingContactBrandAlt")?.value?.trim() || "",
},
trigger: {
imageSrc: normalizeFloatingContactPublicPath(document.getElementById("floatingContactTriggerImage")?.value?.trim() || ""),
icon: document.getElementById("floatingContactTriggerIconFallback")?.value?.trim() || "fa-comments",
},
actions,
};
};
</script>