forked from UKSOURCE/cms.hailearning.edu.vn
- Improve aboutUs controller with better field handling - Update footer controller with expanded content management - Refine about admin view templates - Update appointment and footer admin views - Add about contract repair migration script - Update about.json seed data
874 lines
30 KiB
JavaScript
874 lines
30 KiB
JavaScript
;(function (window, document) {
|
|
"use strict";
|
|
|
|
const COUNTER_SELECTOR = ".admin-field-counter";
|
|
const GUIDANCE_SELECTOR = ".admin-upload-guidance";
|
|
const COUNTER_BOUND_KEY = "adminCounterBound";
|
|
const AUTO_GUIDANCE_ATTR = "data-admin-upload-guidance";
|
|
const OBSERVER_BOUND_KEY = "__adminFormHelpersObserver";
|
|
let generatedFieldToken = 0;
|
|
|
|
const FIELD_RULES = {
|
|
"/admin/about-us": [
|
|
{ selector: "#heroTitle", maxLength: 72 },
|
|
{ selector: "#heroBreadcrumb", maxLength: 120 },
|
|
{ selector: "#heroBackgroundImage", maxLength: 255 },
|
|
{ selector: "#introSubheading", maxLength: 40 },
|
|
{ selector: "#introHeading", maxLength: 72 },
|
|
{ selector: "#introDescription", maxLength: 260 },
|
|
{ selector: "#introImage", maxLength: 255 },
|
|
{ selector: "#missionSubheading", maxLength: 40 },
|
|
{ selector: "#missionHeading", maxLength: 72 },
|
|
{ selector: "#missionDescription", maxLength: 260 },
|
|
{ selector: "#missionCtaLabel", maxLength: 32 },
|
|
{ selector: "#missionCtaHref", maxLength: 255 },
|
|
{ selector: "[id^='missionImg_']", maxLength: 255 },
|
|
{ selector: "#featuresSubheading", maxLength: 40 },
|
|
{ selector: "#featuresHeading", maxLength: 72 },
|
|
{ selector: "#featuresDescription", maxLength: 260 },
|
|
{ selector: "#featuresBgImage", maxLength: 255 },
|
|
{ selector: "#featuresImage", maxLength: 255 },
|
|
{ selector: "#featuresCtaLabel", maxLength: 32 },
|
|
{ selector: "#featuresCtaHref", maxLength: 255 },
|
|
{ selector: "[id^='missionItemLabel_']", maxLength: 48 },
|
|
{ selector: "[id^='missionItemDescription_']", maxLength: 160 },
|
|
{ selector: "[id^='missionItemIcon_']", maxLength: 255 },
|
|
{ selector: "[id^='missionFeature_']", maxLength: 96 },
|
|
{ selector: "[id^='featureItemTitle_']", maxLength: 48 },
|
|
{ selector: "[id^='featureItemDescription_']", maxLength: 160 },
|
|
{ selector: "[id^='featureItemIcon_']", maxLength: 255 },
|
|
{ selector: "#newsSubheading", maxLength: 40 },
|
|
{ selector: "#newsHeading", maxLength: 72 },
|
|
{ selector: "#newsCtaLabel", maxLength: 32 },
|
|
{ selector: "#newsCtaHref", maxLength: 255 },
|
|
],
|
|
"/admin/booking": [
|
|
{ selector: "#heroBackgroundImage", maxLength: 255 },
|
|
{ selector: "#heroTitle", maxLength: 72 },
|
|
{ selector: "#searchBarLocationLabel", maxLength: 32 },
|
|
{ selector: "#searchBarHolidaySeasonLabel", maxLength: 32 },
|
|
{ selector: "#searchBarSearchButtonText", maxLength: 24 },
|
|
{ selector: "input[name^='locationValue_']", maxLength: 32 },
|
|
{ selector: "input[name^='locationLabel_']", maxLength: 48 },
|
|
{ selector: "input[name^='holidayValue_']", maxLength: 32 },
|
|
{ selector: "input[name^='holidayLabel_']", maxLength: 48 },
|
|
{ selector: "#filterPanelTitle", maxLength: 48 },
|
|
{ selector: "#filterPanelPriceTitle", maxLength: 40 },
|
|
{ selector: "#filterPanelPriceLabel", maxLength: 32 },
|
|
{ selector: "#filterPanelPricePlaceholder", maxLength: 32 },
|
|
{ selector: "#filterPanelActivitiesTitle", maxLength: 40 },
|
|
{ selector: "#filterPanelAgeTitle", maxLength: 40 },
|
|
{ selector: "#filterPanelAgeSelectPlaceholder", maxLength: 32 },
|
|
{ selector: "#filterPanelRatingTitle", maxLength: 40 },
|
|
{ selector: "#filterPanelResetButtonText", maxLength: 24 },
|
|
{ selector: "input[name^='ratingValue_']", maxLength: 8 },
|
|
{ selector: "input[name^='ratingLabel_']", maxLength: 32 },
|
|
{ selector: "input[name^='programValue_']", maxLength: 32 },
|
|
{ selector: "input[name^='programLabel_']", maxLength: 48 },
|
|
{ selector: "input[name^='campName_']", maxLength: 72 },
|
|
{ selector: "input[name^='campPriceText_']", maxLength: 32 },
|
|
{ selector: "input[name^='campProgram_']", maxLength: 32 },
|
|
{ selector: "input[name^='campImage_']", maxLength: 255 },
|
|
{ selector: "input[name^='campLink_']", maxLength: 255 },
|
|
{ selector: "input[name^='discountName_']", maxLength: 48 },
|
|
{ selector: "textarea[name^='discountDescription_']", maxLength: 180 },
|
|
{ selector: "input[name^='voucherCode_']", maxLength: 24 },
|
|
{ selector: "input[name^='voucherDescription_']", maxLength: 120 },
|
|
{ selector: "[name='formTitle']", maxLength: 64 },
|
|
{ selector: "[name='formSubtitle']", maxLength: 48 },
|
|
{ selector: "[name^='stepTitle_']", maxLength: 64 },
|
|
{ selector: "[name^='sectionTitle_']", maxLength: 64 },
|
|
{ selector: "[name^='fieldLabel_']", maxLength: 48 },
|
|
{ selector: "[name^='fieldName_']", maxLength: 32 },
|
|
{ selector: "[name^='fieldPlaceholder_']", maxLength: 72 },
|
|
{ selector: "[name^='validationMessage_']", maxLength: 120 },
|
|
],
|
|
"/admin/pricing": [
|
|
{ selector: "#heroBackgroundImage", maxLength: 255 },
|
|
{ selector: "#heroTitle", maxLength: 72 },
|
|
{ selector: "#pricingSectionSubtitle", maxLength: 40 },
|
|
{ selector: "#pricingSectionHeading", maxLength: 72 },
|
|
{ selector: "#pricingSectionDescription", maxLength: 220 },
|
|
{ selector: ".plan-name", maxLength: 40 },
|
|
{ selector: ".plan-price", maxLength: 16 },
|
|
{ selector: ".plan-currency", maxLength: 8 },
|
|
{ selector: ".plan-period", maxLength: 12 },
|
|
{ selector: ".plan-button-text", maxLength: 32 },
|
|
{ selector: ".plan-button-link", maxLength: 255 },
|
|
{ selector: ".plan-button-icon", maxLength: 64 },
|
|
{ selector: ".plan-features", maxLength: 320 },
|
|
{ selector: "#testimonialsSubtitle", maxLength: 40 },
|
|
{ selector: "#testimonialsHeading", maxLength: 72 },
|
|
{ selector: "#testimonialsButtonText", maxLength: 32 },
|
|
{ selector: "#testimonialsButtonLink", maxLength: 255 },
|
|
{ selector: "#testimonialsButtonIcon", maxLength: 64 },
|
|
{ selector: "#testimonialsImage", maxLength: 255 },
|
|
{ selector: ".testimonial-name", maxLength: 48 },
|
|
{ selector: ".testimonial-role", maxLength: 48 },
|
|
{ selector: ".testimonial-content", maxLength: 220 },
|
|
],
|
|
"/admin/visa": [
|
|
{ selector: "input[name='name']", maxLength: 40 },
|
|
{ selector: "input[name='icon']", maxLength: 255 },
|
|
{ selector: "input[name='services[]']", maxLength: 56 },
|
|
{ selector: "input[name='detail_title']", maxLength: 72 },
|
|
{ selector: "input[name='mainImage']", maxLength: 255 },
|
|
{ selector: "textarea[name='description']", maxLength: 360 },
|
|
{ selector: "textarea[name='additionalInfo']", maxLength: 360 },
|
|
{ selector: "input[name='tagline']", maxLength: 72 },
|
|
{ selector: "input[name^='visa_title_']", maxLength: 56 },
|
|
{ selector: "textarea[name^='visa_desc_']", maxLength: 220 },
|
|
{ selector: "input[name='process_title']", maxLength: 72 },
|
|
{ selector: "input[name='step_title[]']", maxLength: 56 },
|
|
{ selector: "textarea[name='step_desc[]']", maxLength: 180 },
|
|
{ selector: "input[name='bannerImageGallery']", maxLength: 255 },
|
|
{ selector: "input[name='category_title[]']", maxLength: 56 },
|
|
{ selector: "textarea[name='category_desc[]']", maxLength: 180 },
|
|
{ selector: "input[name='related_title[]']", maxLength: 56 },
|
|
{ selector: "textarea[name='related_desc[]']", maxLength: 180 },
|
|
{ selector: "input[name='related_file[]']", maxLength: 255 },
|
|
{ selector: "input[name='contact_title']", maxLength: 72 },
|
|
{ selector: "input[name='contact_phone']", maxLength: 32 },
|
|
{ selector: "input[name='contact_email']", maxLength: 120 },
|
|
{ selector: "input[name='contact_address']", maxLength: 160 },
|
|
{ selector: "input[name='contact_image']", maxLength: 255 },
|
|
],
|
|
"/admin/activity/*": [
|
|
{ selector: "input[name='heroTitle']", maxLength: 72 },
|
|
{ selector: "input[name='heroBannerImage']", maxLength: 255 },
|
|
{ selector: "input[name='name']", maxLength: 72 },
|
|
{ selector: "input[name='priceText']", maxLength: 32 },
|
|
{ selector: "input[name='link']", maxLength: 255 },
|
|
{ selector: "input[name='program']", maxLength: 32 },
|
|
{ selector: "#customLocations", maxLength: 120 },
|
|
{ selector: "input[name='image']", maxLength: 255 },
|
|
{ selector: "input[name='campDetailHeroTitle']", maxLength: 72 },
|
|
{ selector: "input[name='campDetailHeroBgImage']", maxLength: 255 },
|
|
{ selector: "input[name='campDetailBasicInfoLocation']", maxLength: 48 },
|
|
{ selector: "textarea[name='campDetailBasicInfoAgeRange']", maxLength: 120 },
|
|
{ selector: "input[name='campDetailBasicInfoAccommodationType']", maxLength: 72 },
|
|
{ selector: "input[name='campDetailBasicInfoCareLevel']", maxLength: 72 },
|
|
{ selector: "input[name='campDetailBasicInfoLanguages']", maxLength: 72 },
|
|
],
|
|
"/admin/activity": [],
|
|
};
|
|
|
|
const GUIDANCE_RULES = {
|
|
"/admin/about-us": [
|
|
{
|
|
selector: "#heroBackgroundImage",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Displayed as a wide page hero.",
|
|
"Recommended upload: at least 1920x700px.",
|
|
],
|
|
},
|
|
{
|
|
selector: "#introImage",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Displayed around 596x787px on desktop.",
|
|
"Recommended upload: at least 1200x1600px.",
|
|
],
|
|
},
|
|
{
|
|
selector: "#featuresImage",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Displayed around 375x419px on desktop.",
|
|
"Recommended upload: at least 750x840px.",
|
|
],
|
|
},
|
|
],
|
|
"/admin/booking": [
|
|
{
|
|
selector: "#heroBackgroundImage",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Booking page hero background.",
|
|
"Recommended upload: at least 1920x700px.",
|
|
],
|
|
},
|
|
{
|
|
selector: "input[name^='campImage_']",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Used in booking camp cards.",
|
|
"Recommended upload: a landscape image at 704x432px or larger.",
|
|
],
|
|
},
|
|
],
|
|
"/admin/pricing": [
|
|
{
|
|
selector: "#heroBackgroundImage",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Pricing page hero background.",
|
|
"Recommended upload: at least 1920x700px.",
|
|
],
|
|
},
|
|
],
|
|
"/admin/visa": [
|
|
{
|
|
selector: "input[name='icon']",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Displayed as a small country flag or icon.",
|
|
"Prefer SVG; otherwise use a square image at 96x96px or larger.",
|
|
],
|
|
},
|
|
{
|
|
selector: "input[name='mainImage'], input[name='bannerImageGallery'], input[name='contact_image'], input[name='related_file[]']",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Used in visa detail content blocks.",
|
|
"Recommended upload: at least 1000x750px for primary imagery and 800x600px for supporting images.",
|
|
],
|
|
},
|
|
],
|
|
"/admin/activity/*": [
|
|
{
|
|
selector: "input[name='heroBannerImage'], input[name='campDetailHeroBgImage']",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Activity page hero-style image.",
|
|
"Recommended upload: at least 1920x700px.",
|
|
],
|
|
},
|
|
{
|
|
selector: "input[name='image']",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Used in activity listing cards.",
|
|
"Recommended upload: a landscape image at 704x432px or larger.",
|
|
],
|
|
},
|
|
],
|
|
"/admin/home": [
|
|
{
|
|
selector: "#whyChooseUsMainImage",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Displayed around 318x347px on desktop.",
|
|
"Recommended upload: at least 750x820px.",
|
|
],
|
|
},
|
|
{
|
|
selector: "#whyChooseUsSecondaryImage",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Displayed around 363x380px on desktop.",
|
|
"Recommended upload: at least 760x800px.",
|
|
],
|
|
},
|
|
{
|
|
selector: "#testimonialsVideoThumbnail",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Displayed around 416x370px on desktop.",
|
|
"Recommended upload: at least 832x740px.",
|
|
],
|
|
},
|
|
{
|
|
selector: "[id^='testimonialsAvatar_']",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Displayed around 48x48px.",
|
|
"Recommended upload: 96x96px or 128x128px square.",
|
|
],
|
|
},
|
|
{
|
|
selector: "#visaCountriesFlag_0",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Displayed around 840x830px on desktop.",
|
|
"Recommended upload: at least 1000x1000px.",
|
|
],
|
|
},
|
|
],
|
|
"*": [
|
|
{
|
|
selector: ".btn-upload-image",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Use a clear, high-resolution image that matches the visible frame.",
|
|
"Prefer SVG for logos or icons and at least 2x the displayed size for raster uploads.",
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
function toScope(scope) {
|
|
return scope && scope.querySelectorAll ? scope : document;
|
|
}
|
|
|
|
function normalizeText(value) {
|
|
return String(value ?? "").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function buildDescriptor(input) {
|
|
return [
|
|
input?.id,
|
|
input?.name,
|
|
input?.placeholder,
|
|
input?.closest(".col-md-12, .col-md-9, .col-md-6, .col-md-5, .col-md-4, .col-md-3, .col-md-2, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12")?.querySelector("label")?.textContent,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
.replace(/[_[\].-]+/g, " ")
|
|
.toLowerCase();
|
|
}
|
|
|
|
function isUploadDescriptor(descriptor) {
|
|
return /(?:^|[^a-z])(image|icon|logo|background|banner|thumbnail|avatar|flag|path|src|file)(?:[^a-z]|$)/.test(descriptor);
|
|
}
|
|
|
|
function resolveUploadTarget(targetId, anchor) {
|
|
if (targetId) {
|
|
const byId = document.getElementById(targetId);
|
|
if (byId) {
|
|
return byId;
|
|
}
|
|
|
|
if (window.CSS && typeof window.CSS.escape === "function") {
|
|
const byName = document.querySelector(`[name="${window.CSS.escape(targetId)}"]`);
|
|
if (byName) {
|
|
return byName;
|
|
}
|
|
}
|
|
}
|
|
|
|
return anchor || null;
|
|
}
|
|
|
|
function hasManualUploadHint(target) {
|
|
const anchor = resolveGuidanceAnchor(target);
|
|
if (!anchor) {
|
|
return false;
|
|
}
|
|
|
|
const host =
|
|
anchor.closest(".form-group, .mb-3, .col-md-12, .col-md-9, .col-md-6, .col-md-5, .col-md-4, .col-md-3, .col-md-2, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12") ||
|
|
anchor.parentElement;
|
|
if (!host) {
|
|
return false;
|
|
}
|
|
|
|
const candidates = [
|
|
...host.querySelectorAll("small.text-muted, small.form-text, .form-text, .text-muted"),
|
|
...Array.from(host.nextElementSibling ? host.nextElementSibling.querySelectorAll?.("small.text-muted, small.form-text, .form-text, .text-muted") || [] : []),
|
|
];
|
|
|
|
return candidates.some((node) => {
|
|
if (node.classList?.contains("admin-field-counter") || node.classList?.contains("admin-upload-guidance")) {
|
|
return false;
|
|
}
|
|
|
|
const text = normalizeText(node.textContent || "");
|
|
return /recommended|min(imum)? upload|upload|svg|png|webp|render|displayed|size|preview|icon/i.test(text);
|
|
});
|
|
}
|
|
|
|
function getFieldLimit(input) {
|
|
const dataMax = Number(input?.dataset?.maxlength);
|
|
if (Number.isFinite(dataMax) && dataMax > 0) {
|
|
return dataMax;
|
|
}
|
|
|
|
const attrMax = Number(input?.getAttribute("maxlength"));
|
|
if (Number.isFinite(attrMax) && attrMax > 0) {
|
|
return attrMax;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getWordLimit(input) {
|
|
const dataMax = Number(input?.dataset?.maxwords);
|
|
if (Number.isFinite(dataMax) && dataMax > 0) {
|
|
return dataMax;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getFieldToken(input) {
|
|
if (!input) {
|
|
return "";
|
|
}
|
|
|
|
if (input.id) {
|
|
return `id:${input.id}`;
|
|
}
|
|
|
|
if (input.name) {
|
|
const indexWithinNameGroup = Array.from(document.querySelectorAll(`[name="${CSS.escape(input.name)}"]`)).indexOf(input);
|
|
return `name:${input.name}:${Math.max(indexWithinNameGroup, 0)}`;
|
|
}
|
|
|
|
if (!input.dataset.adminFieldToken) {
|
|
generatedFieldToken += 1;
|
|
input.dataset.adminFieldToken = `generated:${generatedFieldToken}`;
|
|
}
|
|
|
|
return input.dataset.adminFieldToken;
|
|
}
|
|
|
|
function isDragDropField(element) {
|
|
if (!element || !element.closest) {
|
|
return false;
|
|
}
|
|
|
|
return Boolean(
|
|
element.closest(
|
|
".social-link-item, .floating-contact-action-item, .menu-links-sortable, .social-links-sortable, .sortable-container, .sortable-list, .sortable-item, [data-top-menu-index], [data-top-social-index], [data-bottom-menu-index], [data-bottom-social-index], [draggable='true']",
|
|
),
|
|
);
|
|
}
|
|
|
|
function refreshCountersWithin(scope) {
|
|
if (!scope || !scope.querySelectorAll) {
|
|
return;
|
|
}
|
|
|
|
scope
|
|
.querySelectorAll("input[data-maxlength], textarea[data-maxlength], input[data-maxwords], textarea[data-maxwords], input[maxlength], textarea[maxlength]")
|
|
.forEach((field) => {
|
|
if (field.dataset[COUNTER_BOUND_KEY] === "true") {
|
|
updateCounter(field);
|
|
}
|
|
});
|
|
}
|
|
|
|
function getCounterRefreshScope(input) {
|
|
if (!isDragDropField(input)) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
input.closest(".floating-contact-action-item, .social-link-item, [data-top-menu-index], [data-top-social-index], [data-bottom-menu-index], [data-bottom-social-index]") ||
|
|
input.closest(".menu-links-sortable, .social-links-sortable, .sortable-container, .sortable-list")
|
|
);
|
|
}
|
|
|
|
function getCounterHost(input) {
|
|
return (
|
|
input.closest(".input-group") ||
|
|
input.closest(".col, [class^='col-'], [class*=' col-']") ||
|
|
input.parentElement ||
|
|
input
|
|
);
|
|
}
|
|
|
|
function ensureCounterElement(input) {
|
|
const counterToken = getFieldToken(input);
|
|
const existingAnywhere = counterToken ? document.querySelector(`[data-counter-for="${counterToken}"]`) : null;
|
|
if (existingAnywhere) {
|
|
return existingAnywhere;
|
|
}
|
|
|
|
const host = getCounterHost(input);
|
|
const nextSibling = host.nextElementSibling;
|
|
const matchingNextSibling =
|
|
nextSibling &&
|
|
nextSibling.matches(COUNTER_SELECTOR) &&
|
|
nextSibling.dataset.counterFor === counterToken
|
|
? nextSibling
|
|
: null;
|
|
const existing = matchingNextSibling || host.querySelector(`${COUNTER_SELECTOR}[data-counter-for="${counterToken}"]`);
|
|
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const counter = document.createElement("small");
|
|
counter.className = "form-text admin-field-counter";
|
|
counter.dataset.counterFor = counterToken;
|
|
counter.setAttribute("aria-live", "polite");
|
|
|
|
if (host.classList?.contains("input-group")) {
|
|
host.insertAdjacentElement("afterend", counter);
|
|
} else {
|
|
host.appendChild(counter);
|
|
}
|
|
|
|
return counter;
|
|
}
|
|
|
|
function matchesPathKey(key, pathname) {
|
|
if (key === "*") {
|
|
return true;
|
|
}
|
|
|
|
if (key.endsWith("*")) {
|
|
return pathname.startsWith(key.slice(0, -1));
|
|
}
|
|
|
|
return key === pathname;
|
|
}
|
|
|
|
function getPathRules(registry) {
|
|
const pathname = window.location.pathname;
|
|
return Object.keys(registry).reduce((rules, key) => {
|
|
if (!matchesPathKey(key, pathname)) {
|
|
return rules;
|
|
}
|
|
|
|
return rules.concat(registry[key] || []);
|
|
}, []);
|
|
}
|
|
|
|
function findTargets(root, selector) {
|
|
const targets = [];
|
|
if (root.matches && root.matches(selector)) {
|
|
targets.push(root);
|
|
}
|
|
if (root.querySelectorAll) {
|
|
targets.push(...root.querySelectorAll(selector));
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
function applyFieldRules(scope) {
|
|
const root = toScope(scope);
|
|
getPathRules(FIELD_RULES).forEach((rule) => {
|
|
findTargets(root, rule.selector).forEach((input) => {
|
|
if (rule.maxLength && !input.dataset.maxlength) {
|
|
input.dataset.maxlength = String(rule.maxLength);
|
|
input.setAttribute("maxlength", String(rule.maxLength));
|
|
}
|
|
if (rule.maxWords && !input.dataset.maxwords) {
|
|
input.dataset.maxwords = String(rule.maxWords);
|
|
}
|
|
});
|
|
});
|
|
|
|
root.querySelectorAll("input, textarea").forEach((input) => {
|
|
if (
|
|
input.disabled ||
|
|
input.type === "hidden" ||
|
|
input.type === "file" ||
|
|
input.dataset.maxlength ||
|
|
input.dataset.maxwords ||
|
|
input.getAttribute("maxlength")
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const type = (input.getAttribute("type") || "").toLowerCase();
|
|
if (type && !["text", "email", "tel", "search", "url"].includes(type) && input.tagName !== "TEXTAREA") {
|
|
return;
|
|
}
|
|
|
|
const descriptor = buildDescriptor(input);
|
|
|
|
if (/json|editor|html|content-block|blocks/.test(descriptor)) {
|
|
return;
|
|
}
|
|
|
|
let inferredMaxLength = 72;
|
|
if (input.tagName === "TEXTAREA") {
|
|
inferredMaxLength = /description|content|overview|additional info|quote|note|message|summary/.test(descriptor) ? 500 : 220;
|
|
} else if (type === "email" || /email/.test(descriptor)) {
|
|
inferredMaxLength = 120;
|
|
} else if (type === "tel" || /phone|tel|mobile|whatsapp|zalo/.test(descriptor)) {
|
|
inferredMaxLength = 32;
|
|
} else if (/url|href|link/.test(descriptor) || isUploadDescriptor(descriptor)) {
|
|
inferredMaxLength = 255;
|
|
} else if (/slug|code|id/.test(descriptor)) {
|
|
inferredMaxLength = 32;
|
|
} else if (/title|heading|name|label|subtitle|platform/.test(descriptor)) {
|
|
inferredMaxLength = 72;
|
|
}
|
|
|
|
input.dataset.maxlength = String(inferredMaxLength);
|
|
input.setAttribute("maxlength", String(inferredMaxLength));
|
|
});
|
|
}
|
|
|
|
function updateCounter(input) {
|
|
const counter = ensureCounterElement(input);
|
|
const maxLength = getFieldLimit(input);
|
|
const maxWords = getWordLimit(input);
|
|
const currentValue = normalizeText(input.value || "");
|
|
const currentLength = currentValue.length;
|
|
|
|
if (maxWords) {
|
|
const words = currentValue ? currentValue.split(" ") : [];
|
|
const currentWords = words.filter(Boolean).length;
|
|
|
|
if (maxLength && currentLength > maxLength) {
|
|
input.value = currentValue.slice(0, maxLength);
|
|
}
|
|
|
|
if (maxLength) {
|
|
counter.textContent = `${currentWords}/${maxWords} words, ${Math.min(currentLength, maxLength)}/${maxLength} characters`;
|
|
counter.classList.toggle("is-danger", currentWords >= maxWords || currentLength >= maxLength);
|
|
} else {
|
|
counter.textContent = `${currentWords}/${maxWords} words`;
|
|
counter.classList.toggle("is-danger", currentWords >= maxWords);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!maxLength) {
|
|
counter.textContent = "";
|
|
return;
|
|
}
|
|
|
|
if (currentLength > maxLength) {
|
|
input.value = currentValue.slice(0, maxLength);
|
|
}
|
|
|
|
counter.textContent = `${Math.min(currentLength, maxLength)}/${maxLength} characters`;
|
|
counter.classList.toggle("is-danger", currentLength >= maxLength);
|
|
}
|
|
|
|
function bindCounter(input) {
|
|
if (!input || input.dataset[COUNTER_BOUND_KEY] === "true") {
|
|
return;
|
|
}
|
|
|
|
input.dataset[COUNTER_BOUND_KEY] = "true";
|
|
const syncCounter = () => {
|
|
updateCounter(input);
|
|
const refreshScope = getCounterRefreshScope(input);
|
|
if (refreshScope) {
|
|
refreshCountersWithin(refreshScope);
|
|
}
|
|
};
|
|
|
|
syncCounter();
|
|
input.addEventListener("input", syncCounter);
|
|
input.addEventListener("change", syncCounter);
|
|
input.addEventListener("blur", syncCounter);
|
|
input.addEventListener("focus", syncCounter);
|
|
}
|
|
|
|
function buildGuidanceLines(options = {}) {
|
|
const title = normalizeText(options.title) || "Upload guidance";
|
|
const lines = Array.isArray(options.lines) ? options.lines.map(normalizeText).filter(Boolean) : [];
|
|
|
|
if (!lines.length) {
|
|
lines.push("Use a clear, high-resolution image that matches the visible frame.");
|
|
lines.push("Prefer a file that is at least 2x the displayed size for crisp rendering.");
|
|
lines.push("Keep the original aspect ratio unless the page explicitly asks for a crop.");
|
|
}
|
|
|
|
return { title, lines };
|
|
}
|
|
|
|
function resolveGuidanceAnchor(target) {
|
|
if (!target) {
|
|
return null;
|
|
}
|
|
|
|
if (typeof target === "string") {
|
|
return document.querySelector(target);
|
|
}
|
|
|
|
if (target instanceof Element) {
|
|
return target;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function renderUploadGuidance(target, options = {}) {
|
|
const anchor = resolveGuidanceAnchor(target);
|
|
if (!anchor) {
|
|
return null;
|
|
}
|
|
|
|
const host = anchor.closest(".form-group, .mb-3, .col-md-12, .col-md-9, .col-md-6, .col-md-4, .col-md-3, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12") || anchor.parentElement;
|
|
if (!host) {
|
|
return null;
|
|
}
|
|
|
|
const matchingAnchorSibling =
|
|
anchor.nextElementSibling &&
|
|
anchor.nextElementSibling.matches(GUIDANCE_SELECTOR) &&
|
|
anchor.nextElementSibling.dataset.guidanceFor === (options.for || "")
|
|
? anchor.nextElementSibling
|
|
: null;
|
|
const matchingHostSibling =
|
|
host.nextElementSibling &&
|
|
host.nextElementSibling.matches(GUIDANCE_SELECTOR) &&
|
|
host.nextElementSibling.dataset.guidanceFor === (options.for || "")
|
|
? host.nextElementSibling
|
|
: null;
|
|
const existing =
|
|
matchingAnchorSibling ||
|
|
matchingHostSibling ||
|
|
host.querySelector(`${GUIDANCE_SELECTOR}[data-guidance-for="${options.for || ""}"]`);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const payload = buildGuidanceLines(options);
|
|
const note = document.createElement("div");
|
|
note.className = "admin-upload-guidance";
|
|
note.dataset.guidanceFor = options.for || "";
|
|
note.setAttribute("role", "note");
|
|
note.innerHTML = `
|
|
<div class="admin-upload-guidance__title">${payload.title}</div>
|
|
<ul class="admin-upload-guidance__list">
|
|
${payload.lines.map((line) => `<li>${line}</li>`).join("")}
|
|
</ul>
|
|
`;
|
|
|
|
const insertionTarget = host !== anchor ? host : anchor;
|
|
insertionTarget.insertAdjacentElement("afterend", note);
|
|
return note;
|
|
}
|
|
|
|
function autoWireGuidance(scope) {
|
|
const root = toScope(scope);
|
|
root.querySelectorAll(`[${AUTO_GUIDANCE_ATTR}]`).forEach((anchor) => {
|
|
const guidanceValue = anchor.getAttribute(AUTO_GUIDANCE_ATTR);
|
|
if (guidanceValue === "false") {
|
|
return;
|
|
}
|
|
|
|
if (isDragDropField(anchor)) {
|
|
return;
|
|
}
|
|
|
|
if (hasManualUploadHint(anchor)) {
|
|
return;
|
|
}
|
|
|
|
renderUploadGuidance(anchor, {
|
|
for: anchor.id || anchor.dataset.targetInput || "",
|
|
title: anchor.dataset.adminUploadGuidanceTitle || "Upload guidance",
|
|
lines: anchor.dataset.adminUploadGuidance
|
|
? anchor.dataset.adminUploadGuidance.split("|").map((part) => part.trim())
|
|
: undefined,
|
|
});
|
|
});
|
|
}
|
|
|
|
function applyGuidanceRules(scope) {
|
|
const root = toScope(scope);
|
|
getPathRules(GUIDANCE_RULES).forEach((rule) => {
|
|
findTargets(root, rule.selector).forEach((anchor) => {
|
|
if (isDragDropField(anchor)) {
|
|
return;
|
|
}
|
|
|
|
if (hasManualUploadHint(anchor)) {
|
|
return;
|
|
}
|
|
|
|
if (anchor.matches(".btn-upload-image")) {
|
|
const targetId = anchor.dataset.targetInput;
|
|
const target = resolveUploadTarget(targetId, anchor);
|
|
renderUploadGuidance(target || anchor, {
|
|
for: targetId || anchor.id || "",
|
|
title: rule.title,
|
|
lines: rule.lines,
|
|
});
|
|
return;
|
|
}
|
|
|
|
renderUploadGuidance(anchor, {
|
|
for: anchor.id || anchor.name || "",
|
|
title: rule.title,
|
|
lines: rule.lines,
|
|
});
|
|
});
|
|
});
|
|
|
|
root.querySelectorAll("input[type='text'], textarea").forEach((input) => {
|
|
if (isDragDropField(input)) {
|
|
return;
|
|
}
|
|
|
|
const descriptor = buildDescriptor(input);
|
|
|
|
if (!isUploadDescriptor(descriptor)) {
|
|
return;
|
|
}
|
|
|
|
if (hasManualUploadHint(input)) {
|
|
return;
|
|
}
|
|
|
|
const host =
|
|
input.parentElement?.querySelector(".admin-upload-guidance") ||
|
|
input.closest(".col-md-12, .col-md-6, .col-md-4, .col-lg-12, .col-lg-6, .col-lg-4, .col-12")?.querySelector(".admin-upload-guidance");
|
|
if (host) {
|
|
return;
|
|
}
|
|
|
|
renderUploadGuidance(input, {
|
|
for: input.id || input.name || "",
|
|
title: "Upload guidance",
|
|
lines: [
|
|
"Use a clear, high-resolution image sized for the frontend frame.",
|
|
"Prefer SVG for logos or icons and at least 2x the displayed size for raster uploads.",
|
|
],
|
|
});
|
|
});
|
|
}
|
|
|
|
function observeMutations() {
|
|
if (document.body[OBSERVER_BOUND_KEY]) {
|
|
return;
|
|
}
|
|
|
|
const observer = new MutationObserver((mutations) => {
|
|
mutations.forEach((mutation) => {
|
|
mutation.addedNodes.forEach((node) => {
|
|
if (!node || node.nodeType !== 1) {
|
|
return;
|
|
}
|
|
init(node);
|
|
});
|
|
});
|
|
});
|
|
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
document.body[OBSERVER_BOUND_KEY] = true;
|
|
}
|
|
|
|
function init(scope = document) {
|
|
const root = toScope(scope);
|
|
|
|
applyFieldRules(root);
|
|
root.querySelectorAll("input[data-maxlength], textarea[data-maxlength], input[data-maxwords], textarea[data-maxwords], input[maxlength], textarea[maxlength]").forEach(bindCounter);
|
|
autoWireGuidance(root);
|
|
applyGuidanceRules(root);
|
|
}
|
|
|
|
function refresh(scope = document) {
|
|
init(scope);
|
|
}
|
|
|
|
function shouldAutoInit() {
|
|
return document.body && document.body.dataset.adminHelpers === "true";
|
|
}
|
|
|
|
const api = {
|
|
init,
|
|
refresh,
|
|
renderUploadGuidance,
|
|
updateCounter,
|
|
};
|
|
|
|
window.AdminFormHelpers = api;
|
|
|
|
if (shouldAutoInit()) {
|
|
if (document.readyState === "loading") {
|
|
window.addEventListener("load", () => {
|
|
init(document);
|
|
observeMutations();
|
|
}, { once: true });
|
|
} else {
|
|
init(document);
|
|
observeMutations();
|
|
}
|
|
}
|
|
})(window, document);
|