forked from UKSOURCE/cms.hailearning.edu.vn
fea/nhat-dat-11042026-merge #1
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@ pids
|
|||||||
#cursor
|
#cursor
|
||||||
.cursor
|
.cursor
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
/.omc
|
||||||
|
CLAUDE.md
|
||||||
|
/.claude
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
BIN
public/uploads/home/floating-contact/Facebook_Logo_Primary.webp
Normal file
BIN
public/uploads/home/floating-contact/Facebook_Logo_Primary.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
public/uploads/home/floating-contact/Icon_of_Zalo.svg.webp
Normal file
BIN
public/uploads/home/floating-contact/Icon_of_Zalo.svg.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
public/uploads/home/floating-contact/logo-hai-learning.webp
Normal file
BIN
public/uploads/home/floating-contact/logo-hai-learning.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
@@ -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(
|
||||||
|
|||||||
115
scripts/2026_04_07_110000_add_home_floating_contact.js
Normal file
115
scripts/2026_04_07_110000_add_home_floating_contact.js
Normal 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();
|
||||||
@@ -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 };
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -94,9 +100,10 @@
|
|||||||
<%- include('sections/testimonials') %>
|
<%- include('sections/testimonials') %>
|
||||||
<%- include('sections/videoGallery') %>
|
<%- include('sections/videoGallery') %>
|
||||||
<%- include('sections/faq') %>
|
<%- include('sections/faq') %>
|
||||||
<%- 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,28 +144,46 @@
|
|||||||
* <\/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...");
|
||||||
|
|
||||||
// Tự động thu gom dữ liệu từ các section đã đăng ký
|
try {
|
||||||
Object.keys(window.homeScrapers).forEach(section => {
|
await flushPendingImageUploads();
|
||||||
const input = document.getElementById(section + 'Json');
|
|
||||||
if (input) {
|
|
||||||
try {
|
|
||||||
const data = window.homeScrapers[section]();
|
|
||||||
console.log(`- Collected data for [${section}]:`, data);
|
|
||||||
input.value = JSON.stringify(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error scraping section [${section}]:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Để form tự submit tự nhiên sau khi đã điền xong các hidden inputs
|
// Tự động thu gom dữ liệu từ các section đã đăng ký
|
||||||
|
Object.keys(window.homeScrapers).forEach(section => {
|
||||||
|
const input = document.getElementById(section + 'Json');
|
||||||
|
if (input) {
|
||||||
|
try {
|
||||||
|
const data = window.homeScrapers[section]();
|
||||||
|
console.log(`- Collected data for [${section}]:`, data);
|
||||||
|
input.value = JSON.stringify(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error scraping section [${section}]:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/&/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();
|
const input = document.getElementById(targetInput);
|
||||||
formData.append("image", file);
|
if (!input) {
|
||||||
const response = await fetch(`/admin/upload/image?imageType=${imageType}`, { method: "POST", body: formData });
|
throw new Error("Target image field not found");
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success && result.path) {
|
|
||||||
const input = document.getElementById(targetInput);
|
|
||||||
if (input) {
|
|
||||||
input.value = result.path;
|
|
||||||
// Cập nhật preview nếu có img ngay sau input group
|
|
||||||
const previewImg = input.closest('.input-group')?.nextElementSibling?.querySelector('img');
|
|
||||||
if (previewImg) {
|
|
||||||
previewImg.src = result.path;
|
|
||||||
previewImg.classList.remove('d-none');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showToast("Success", "Image uploaded successfully", "success");
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error || "Upload failed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
revokePendingPreview(targetInput);
|
||||||
|
const previewUrl = URL.createObjectURL(file);
|
||||||
|
pendingPreviewUrls.set(targetInput, previewUrl);
|
||||||
|
|
||||||
|
const previewImg = findImagePreview(input);
|
||||||
|
if (previewImg) {
|
||||||
|
previewImg.src = previewUrl;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingImageUploads.set(targetInput, { file, imageType, resizePreset });
|
||||||
|
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
|
|||||||
510
views/admin/home/sections/floatingContact.ejs
Normal file
510
views/admin/home/sections/floatingContact.ejs
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
|
||||||
|
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>
|
||||||
Reference in New Issue
Block a user