feat(contact-button): add floating contact widget admin management

Add CMS support for floating contact widget with Facebook/Zalo quick
actions. Includes mongoose schema, admin UI tab, image upload with
sharp resize presets, deferred form submission with draft persistence,
and upload middleware error handling.
This commit is contained in:
Tống Thành Đạt
2026-04-07 19:36:20 +07:00
parent e86e5d2c46
commit ffe2f12bb3
12 changed files with 1328 additions and 52 deletions

View File

@@ -27,6 +27,7 @@
<input type="hidden" name="achievements" id="achievementsJson" />
<input type="hidden" name="partners" id="partnersJson" />
<input type="hidden" name="blogPreview" id="blogPreviewJson" />
<input type="hidden" name="floatingContact" id="floatingContactJson" />
<!-- Navigation Tabs -->
<div class="card shadow-sm border-0 mb-4">
@@ -82,6 +83,11 @@
<i class="fas fa-blog me-2"></i>Blog Preview
</a>
</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>
</div>
@@ -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') %>
</div>
</div>
</div>
@@ -118,9 +125,14 @@
</div>
<!-- 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="currentTargetInput" name="targetInput" />
<input type="hidden" id="currentResizePreset" name="resizePreset" />
<script>
/**
@@ -132,28 +144,46 @@
* <\/script>
*/
window.homeScrapers = window.homeScrapers || {};
const pendingImageUploads = new Map();
const pendingPreviewUrls = new Map();
document.addEventListener("DOMContentLoaded", function () {
const form = document.querySelector("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...");
// 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);
}
}
});
try {
await flushPendingImageUploads();
// Để 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) ---
function extractHtmlErrorMessage(html) {
if (!html) {
return "";
}
const preMatch = html.match(/<pre>([\s\S]*?)<\/pre>/i);
const titleMatch = html.match(/<title>([\s\S]*?)<\/title>/i);
const rawMessage = (preMatch && preMatch[1]) || (titleMatch && titleMatch[1]) || html;
const decoded = rawMessage
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&");
return decoded.replace(/\s+/g, " ").trim();
}
function findImagePreview(input) {
if (!input) {
return null;
}
return (
input.closest(".input-group")?.nextElementSibling?.querySelector("img") ||
input.parentElement?.nextElementSibling?.querySelector("img") ||
null
);
}
function revokePendingPreview(targetInput) {
const previewUrl = pendingPreviewUrls.get(targetInput);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
pendingPreviewUrls.delete(targetInput);
}
}
function isFloatingContactTargetInput(targetInput) {
return typeof targetInput === "string" && targetInput.startsWith("floatingContact");
}
async function persistFloatingContactDraft() {
const scraper = window.homeScrapers && window.homeScrapers.floatingContact;
if (typeof scraper !== "function") {
throw new Error("Floating contact scraper is not available");
}
const response = await fetch("/admin/home/floating-contact/update", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
floatingContact: scraper(),
}),
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.success) {
throw new Error(result?.error || "Failed to save floating contact changes");
}
return result;
}
async function uploadPendingImage(targetInput, uploadConfig) {
const input = document.getElementById(targetInput);
if (!input || !uploadConfig?.file) {
pendingImageUploads.delete(targetInput);
revokePendingPreview(targetInput);
return;
}
const formData = new FormData();
formData.append("image", uploadConfig.file);
const query = new URLSearchParams({ imageType: uploadConfig.imageType });
if (uploadConfig.resizePreset) {
query.set("resizePreset", uploadConfig.resizePreset);
}
const response = await fetch(`/admin/upload/image?${query.toString()}`, {
method: "POST",
body: formData,
});
const rawResponse = await response.text();
let result = null;
try {
result = rawResponse ? JSON.parse(rawResponse) : null;
} catch (parseError) {
result = null;
}
if (!response.ok) {
throw new Error(
result?.error ||
extractHtmlErrorMessage(rawResponse) ||
`Upload failed with status ${response.status}`,
);
}
if (!result?.success || !result.path) {
throw new Error(result?.error || "Upload failed");
}
input.value = result.path;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
const previewImg = findImagePreview(input);
if (previewImg) {
previewImg.src = new URL(result.path, window.location.origin).toString();
previewImg.classList.remove("d-none");
}
pendingImageUploads.delete(targetInput);
revokePendingPreview(targetInput);
}
async function flushPendingImageUploads() {
for (const [targetInput, uploadConfig] of pendingImageUploads.entries()) {
await uploadPendingImage(targetInput, uploadConfig);
}
}
function initImageUploads() {
document.addEventListener("click", function (e) {
const btn = e.target.closest(".btn-upload-image");
if (btn) {
document.getElementById("currentImageType").value = btn.dataset.imageType;
document.getElementById("currentTargetInput").value = btn.dataset.targetInput;
document.getElementById("currentResizePreset").value = btn.dataset.resizePreset || "";
document.getElementById("directImageUpload").click();
}
});
@@ -184,28 +343,43 @@
const file = this.files[0];
const imageType = document.getElementById("currentImageType").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 {
const formData = new FormData();
formData.append("image", file);
const response = await fetch(`/admin/upload/image?imageType=${imageType}`, { method: "POST", body: formData });
const result = await response.json();
if (result.success && result.path) {
const input = document.getElementById(targetInput);
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");
const input = document.getElementById(targetInput);
if (!input) {
throw new Error("Target image field not found");
}
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) {
showToast("Error", "Upload failed: " + error.message, "error");
}
@@ -228,4 +402,4 @@
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
toast.addEventListener("hidden.bs.toast", () => toast.remove());
}
</script>
</script>

View File

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