diff --git a/.gitignore b/.gitignore index cb92d84..d701744 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,7 @@ pids #cursor .cursor -package-lock.json \ No newline at end of file +package-lock.json +/.omc +CLAUDE.md +/.claude diff --git a/controllers/homeController.js b/controllers/homeController.js index e070c6c..2040cd9 100644 --- a/controllers/homeController.js +++ b/controllers/homeController.js @@ -1,4 +1,7 @@ -const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper"); +const { + addBaseUrlToImages, + getFullImageUrl, +} = require("../utils/imageHelper"); const Home = require("../models/home"); const Blog = require("../models/blog"); const writeAuditLog = require("../audit/writeAuditLog"); @@ -8,6 +11,137 @@ const AUDIT_ACTIONS = require("../constants/auditAction"); // Các hàm hỗ trợ const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 }); const getHomeData = async () => (await getHomeDoc())?.toObject() || {}; +const getOrCreateHomeDoc = async () => { + let doc = await getHomeDoc(); + + if (!doc) { + doc = new Home(getDefaultHomeData()); + } + + return doc; +}; + +const normalizeStoredImagePath = (imagePath) => { + if (!imagePath || typeof imagePath !== "string") return ""; + + const raw = imagePath.trim(); + if (!raw) return ""; + + const knownPrefixes = ["/uploads/", "/assets/", "/img/"]; + + for (const prefix of knownPrefixes) { + const index = raw.indexOf(prefix); + if (index >= 0) { + return raw.slice(index); + } + } + + if (raw.startsWith("/")) { + return raw; + } + + return `/${raw}`; +}; + +const getDefaultFloatingContactData = () => ({ + enabled: true, + position: "bottom-right", + panelTitle: "Anh chị cần tư vấn hay hỗ trợ gì thêm không ạ?", + brand: { + imageSrc: "/assets/img/logo/black-logo.svg", + imageAlt: "HAI Learning", + }, + trigger: { + imageSrc: "", + icon: "fa-comments", + }, + actions: [ + { + id: "facebook", + platform: "facebook", + enabled: true, + label: "Nhắn tin qua Facebook", + subtitle: "facebook.com/hailearning.edu.vn", + href: "https://www.facebook.com/hailearning.edu.vn/", + iconImage: "/uploads/home/floating-contact/Facebook_Logo_Primary.webp", + iconType: "iconClass", + iconClass: "fa-brands fa-facebook-messenger", + iconText: "", + order: 1, + }, + { + id: "zalo", + platform: "zalo", + enabled: true, + label: "Nhắn tin qua Zalo", + subtitle: "zalo.me/84961834040", + href: "https://zalo.me/84961834040", + iconImage: "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp", + iconType: "iconText", + iconClass: "", + iconText: "Zalo", + order: 2, + }, + ], +}); + +const normalizeFloatingContactData = (payload = {}) => { + const defaults = getDefaultFloatingContactData(); + const brand = payload.brand || {}; + const trigger = payload.trigger || {}; + const hasProvidedActions = Array.isArray(payload.actions); + const rawActions = hasProvidedActions ? payload.actions : []; + + const actions = rawActions + .map((action, index) => ({ + id: String(action.id || `${action.platform || "action"}-${index + 1}`), + platform: String(action.platform || "").trim(), + enabled: action.enabled !== false, + label: String(action.label || "").trim(), + subtitle: String(action.subtitle || "").trim(), + href: String(action.href || "").trim(), + iconImage: normalizeStoredImagePath(String(action.iconImage || "").trim()), + iconType: action.iconType === "iconText" ? "iconText" : "iconClass", + iconClass: String(action.iconClass || "").trim(), + iconText: String(action.iconText || "").trim(), + order: Number.isFinite(Number(action.order)) ? Number(action.order) : index + 1, + })) + .filter((action) => { + return ( + action.platform || + action.label || + action.subtitle || + action.href || + action.iconImage || + action.iconClass || + action.iconText + ); + }) + .sort((a, b) => a.order - b.order) + .map((action, index) => ({ + ...action, + order: index + 1, + })); + + return { + enabled: payload.enabled !== false, + position: payload.position || defaults.position, + panelTitle: String(payload.panelTitle || defaults.panelTitle).trim(), + brand: { + imageSrc: normalizeStoredImagePath( + String(brand.imageSrc || defaults.brand.imageSrc).trim(), + ), + imageAlt: String(brand.imageAlt || defaults.brand.imageAlt).trim(), + }, + trigger: { + imageSrc: normalizeStoredImagePath( + String(trigger.imageSrc || "").trim(), + ), + icon: String(trigger.icon || defaults.trigger.icon).trim() || defaults.trigger.icon, + }, + actions: hasProvidedActions ? actions : defaults.actions, + }; +}; const getDefaultHomeData = () => ({ hero: { @@ -64,6 +198,7 @@ const getDefaultHomeData = () => ({ items: [], selectedBlogIds: [], // Array of manually selected blog IDs }, + floatingContact: getDefaultFloatingContactData(), }); // Admin: Xem trang quản lý @@ -77,9 +212,10 @@ exports.index = async (req, res) => { sections.forEach((s) => { data[s] = data[s] || defaults[s]; }); + data.floatingContact = normalizeFloatingContactData(data.floatingContact); const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - const backendUrl = process.env.BACKEND_URL || "http://localhost:3001"; + const backendUrl = `${req.protocol}://${req.get("host")}`; // Lấy tất cả blog để chọn trong CMS const allBlogs = await Blog.find({ status: "published" }) @@ -118,6 +254,7 @@ exports.update = async (req, res) => { "achievements", "partners", "blogPreview", + "floatingContact", ]; let doc = await getHomeDoc(); @@ -135,7 +272,10 @@ exports.update = async (req, res) => { try { const payload = JSON.parse(req.body[section]); // Gán trực tiếp vào doc, Mongoose sẽ tự check schema - doc[section] = payload; + doc[section] = + section === "floatingContact" + ? normalizeFloatingContactData(payload) + : payload; doc.markModified(section); hasChanges = true; updatedSections.push(section); @@ -176,6 +316,49 @@ exports.update = async (req, res) => { } }; +exports.updateFloatingContact = async (req, res) => { + try { + const payload = + typeof req.body?.floatingContact === "string" + ? JSON.parse(req.body.floatingContact) + : req.body?.floatingContact || req.body; + + const doc = await getOrCreateHomeDoc(); + const beforeData = JSON.parse(JSON.stringify(doc.toObject())); + + doc.floatingContact = normalizeFloatingContactData(payload); + doc.markModified("floatingContact"); + await doc.save(); + + const afterData = JSON.parse(JSON.stringify(doc.toObject())); + const changes = diffObject(beforeData, afterData); + + if (changes.length > 0) { + await writeAuditLog({ + model: "Home", + documentId: doc._id, + action: AUDIT_ACTIONS.UPDATE_HOME, + before: beforeData, + after: afterData, + changes, + req, + }); + } + + return res.status(200).json({ + success: true, + message: "Floating contact updated successfully", + floatingContact: doc.floatingContact, + }); + } catch (err) { + console.error("Floating contact update error:", err); + return res.status(500).json({ + success: false, + error: err.message || "Failed to update floating contact", + }); + } +}; + // Public API// API lấy danh sách blog cho CMS exports.apiGetBlogs = async (req, res) => { try { @@ -191,8 +374,7 @@ exports.apiGetBlogs = async (req, res) => { exports.api = async (req, res) => { try { let data = await getHomeData(); - const baseUrl = - process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`; + const baseUrl = `${req.protocol}://${req.get("host")}`; // === Xử lý Blog Preview động === const blogPreview = data.blogPreview || {}; @@ -238,6 +420,7 @@ exports.api = async (req, res) => { })); data.blogPreview = blogPreview; + data.floatingContact = normalizeFloatingContactData(data.floatingContact); // =============================== const processed = addBaseUrlToImages(data, baseUrl); diff --git a/controllers/uploadController.js b/controllers/uploadController.js index ef6f4c8..7d4f80c 100644 --- a/controllers/uploadController.js +++ b/controllers/uploadController.js @@ -1,7 +1,98 @@ const path = require('path'); const fs = require('fs'); +const sharp = require('sharp'); const jsonHelper = require('../utils/jsonHelper'); +const imageResizePresets = { + floatingContactBrandImage: { width: 104, height: 104, quality: 88 }, + floatingContactTriggerIcon: { width: 96, height: 96, quality: 88 }, + floatingContactActionIcon: { width: 84, height: 84, quality: 88 }, +}; + +const isSvgFile = (filePath) => path.extname(filePath).toLowerCase() === '.svg'; + +function scheduleTemporaryFileCleanup(filePath, attemptsLeft = 5, delayMs = 250) { + if (!filePath || attemptsLeft <= 0) { + return; + } + + setTimeout(() => { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch (cleanupError) { + scheduleTemporaryFileCleanup(filePath, attemptsLeft - 1, delayMs * 2); + } + }, delayMs); +} + +function getFinalUploadTarget(file, req, forceWebp = false) { + const parsedPath = path.parse(file.path); + const requestedFileName = + req.uploadFinalFileName || + file.filename.replace('.__upload__', ''); + const parsedRequestedFileName = path.parse(requestedFileName); + const finalFileName = forceWebp + ? `${parsedRequestedFileName.name}.webp` + : requestedFileName; + const finalDirectory = req.uploadFinalDirectory || parsedPath.dir; + + return { + finalFileName, + finalPath: path.join(finalDirectory, finalFileName), + }; +} + +async function finalizeUploadedImage(file, req, resizePreset) { + const preset = imageResizePresets[resizePreset]; + + if (!file) { + return file; + } + + if (!preset || isSvgFile(file.path)) { + const { finalFileName, finalPath } = getFinalUploadTarget(file, req); + + if (path.resolve(file.path) !== path.resolve(finalPath)) { + fs.renameSync(file.path, finalPath); + } + + return { + ...file, + filename: finalFileName, + path: finalPath, + }; + } + + const { finalFileName, finalPath } = getFinalUploadTarget(file, req, true); + + await sharp(file.path) + .resize(preset.width, preset.height, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + withoutEnlargement: true, + }) + .webp({ quality: preset.quality }) + .toFile(finalPath); + + if (fs.existsSync(file.path)) { + try { + fs.unlinkSync(file.path); + } catch (cleanupError) { + console.warn('Unable to remove original uploaded image after optimization:', cleanupError.message); + scheduleTemporaryFileCleanup(file.path); + } + } + + return { + ...file, + filename: finalFileName, + path: finalPath, + mimetype: 'image/webp', + }; +} + // Controller xử lý upload ảnh const uploadController = { // Upload ảnh và trả về đường dẫn @@ -13,15 +104,14 @@ const uploadController = { // Lấy loại ảnh từ query params const imageType = req.query.imageType || 'general'; + const resizePreset = req.query.resizePreset || ''; + req.file = await finalizeUploadedImage(req.file, req, resizePreset); // Tạo đường dẫn tương đối để lưu vào database const relativePath = `/uploads/${imageType}/${req.file.filename}`; const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; const fullUrl = `${baseUrl}${relativePath}`; - // Kiểm tra nếu file đã tồn tại từ trước - const fileAlreadyExists = req.fileAlreadyExists || false; - // Nếu client yêu cầu cập nhật trực tiếp file JSON (ví dụ activities.json), // thì đồng bộ camps.image và camps.camp-detail.hero.bgImage try { @@ -60,8 +150,9 @@ const uploadController = { success: true, path: relativePath, url: fullUrl, - reused: fileAlreadyExists, - message: fileAlreadyExists ? 'Using existing file' : 'File uploaded successfully' + resizePreset: resizePreset || null, + reused: false, + message: 'File uploaded successfully' }); } catch (error) { console.error('Error uploading image:', error); @@ -225,4 +316,4 @@ const uploadController = { } }; -module.exports = uploadController; \ No newline at end of file +module.exports = uploadController; diff --git a/models/home.js b/models/home.js index 09d3d52..d2bc0ee 100644 --- a/models/home.js +++ b/models/home.js @@ -11,6 +11,51 @@ const LinkSchema = new Schema( { _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) const HeroSlideSchema = new Schema( { @@ -266,6 +311,7 @@ const HomeSchema = new Schema( achievements: { type: AchievementsSchema, default: () => ({}) }, partners: { type: PartnersSchema, default: () => ({}) }, blogPreview: { type: BlogPreviewSchema, default: () => ({}) }, + floatingContact: { type: FloatingContactSchema, default: () => ({}) }, }, { timestamps: true, diff --git a/public/uploads/home/floating-contact/Facebook_Logo_Primary.webp b/public/uploads/home/floating-contact/Facebook_Logo_Primary.webp new file mode 100644 index 0000000..6ec1ee5 Binary files /dev/null and b/public/uploads/home/floating-contact/Facebook_Logo_Primary.webp differ diff --git a/public/uploads/home/floating-contact/Icon_of_Zalo.svg.webp b/public/uploads/home/floating-contact/Icon_of_Zalo.svg.webp new file mode 100644 index 0000000..44aa48e Binary files /dev/null and b/public/uploads/home/floating-contact/Icon_of_Zalo.svg.webp differ diff --git a/public/uploads/home/floating-contact/logo-hai-learning.webp b/public/uploads/home/floating-contact/logo-hai-learning.webp new file mode 100644 index 0000000..f75d5b1 Binary files /dev/null and b/public/uploads/home/floating-contact/logo-hai-learning.webp differ diff --git a/routes/admin.js b/routes/admin.js index 5dce403..cefebab 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -36,9 +36,34 @@ const videoGalleryController = require("../controllers/videoGalleryController"); // Dashboard 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 router.get("/home", ensureAuthenticated, homeController.index); router.post("/home/update", ensureAuthenticated, homeController.update); +router.post( + "/home/floating-contact/update", + ensureAuthenticated, + homeController.updateFloatingContact, +); router.get("/home/api/blogs", ensureAuthenticated, homeController.apiGetBlogs); // Middleware chuẩn hóa code @@ -72,13 +97,13 @@ router.get("/upload", ensureAuthenticated, (req, res) => { router.post( "/upload/image", ensureAuthenticated, - upload.single("image"), + runUploadMiddleware(upload.single("image")), uploadController.uploadImage, ); router.post( "/upload/video", ensureAuthenticated, - uploadVideo.single("video"), + runUploadMiddleware(uploadVideo.single("video")), uploadController.uploadVideo, ); router.post( diff --git a/scripts/2026_04_07_110000_add_home_floating_contact.js b/scripts/2026_04_07_110000_add_home_floating_contact.js new file mode 100644 index 0000000..517194a --- /dev/null +++ b/scripts/2026_04_07_110000_add_home_floating_contact.js @@ -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(); diff --git a/scripts/2026_04_07_164500_add_home_floating_contact_icon_images.js b/scripts/2026_04_07_164500_add_home_floating_contact_icon_images.js new file mode 100644 index 0000000..377e256 --- /dev/null +++ b/scripts/2026_04_07_164500_add_home_floating_contact_icon_images.js @@ -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 }; diff --git a/views/admin/home/index.ejs b/views/admin/home/index.ejs index f094b65..727d9c3 100644 --- a/views/admin/home/index.ejs +++ b/views/admin/home/index.ejs @@ -27,6 +27,7 @@ +
@@ -82,6 +83,11 @@ Blog Preview +
@@ -94,9 +100,10 @@ <%- include('sections/testimonials') %> <%- include('sections/videoGallery') %> <%- include('sections/faq') %> - <%- include('sections/achievements') %> - <%- include('sections/partners') %> + <%- include('sections/achievements') %> + <%- include('sections/partners') %> <%- include('sections/blogPreview') %> + <%- include('sections/floatingContact') %> @@ -118,9 +125,14 @@ - + + \ No newline at end of file + diff --git a/views/admin/home/sections/floatingContact.ejs b/views/admin/home/sections/floatingContact.ejs new file mode 100644 index 0000000..a471013 --- /dev/null +++ b/views/admin/home/sections/floatingContact.ejs @@ -0,0 +1,510 @@ + +
+
+
+
+
+
+ Widget Settings +
+ Homepage floating contact widget +
+
+
+
+
+
+
Widget visibility
+ Enable or disable the floating contact widget on the homepage. +
+
+ /> + +
+
+
+
+ + + Maximum 72 characters to keep the header from breaking the widget layout. +
+
+ + + Used for accessibility and fallback image descriptions. +
+ +
+ +
+ + +
+
+ Brand preview +
+ Raster logo uploads are normalized to 104x104 WebP to match the homepage widget. +
+
+ +
+ + +
+
+ Trigger icon preview +
+ + Shown only when the trigger slideshow cannot use the brand image and action icons. Raster uploads are normalized to 96x96 WebP. +
+
+
+
+
+ +
+
+
+
+
+ Contact Actions +
+ Add, remove, and drag to reorder floating contact actions. +
+ +
+
+
+
+
+
+
+
+ + + +