From 51c6303437e44c15ff3a87e149b1cfa659ee072b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=E1=BB=91ng=20Th=C3=A0nh=20=C4=90=E1=BA=A1t?= <84076965+tongthanhdat009@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:55:15 +0700 Subject: [PATCH] feat: standardize admin form limits and guidance --- assets/css/components/form.css | 41 + constants/contentLengthRules.js | 191 ++++ controllers/aboutUsController.js | 35 + controllers/activityController.js | 53 ++ controllers/bookingController.js | 19 +- controllers/formController.js | 10 +- controllers/homeController.js | 6 +- controllers/pageController.js | 8 +- controllers/pricingController.js | 25 + controllers/settingController.js | 6 +- models/aboutUs.js | 86 +- models/activity.js | 63 +- models/booking.js | 56 +- models/pricing.js | 26 +- public/js/admin-form-helpers.js | 867 ++++++++++++++++++ ..._04_07_110000_add_home_floating_contact.js | 6 +- utils/lengthValidation.js | 158 ++++ views/admin/activity/form.ejs | 14 +- views/admin/blog/create.ejs | 14 +- views/admin/blog/edit.ejs | 16 +- views/admin/booking/index.ejs | 10 +- views/admin/contact/index.ejs | 63 +- views/admin/header/index.ejs | 12 +- views/admin/home/index.ejs | 89 +- views/admin/home/sections/floatingContact.ejs | 4 +- views/admin/home/sections/hero.ejs | 25 +- views/admin/home/sections/partners.ejs | 10 +- views/admin/home/sections/testimonials.ejs | 4 +- views/admin/home/sections/visaCountries.ejs | 2 +- views/admin/home/sections/whyChooseUs.ejs | 6 +- views/admin/level/index.ejs | 2 +- views/admin/visa/index.ejs | 112 ++- views/layouts/admin.ejs | 7 +- views/layouts/main.ejs | 7 +- 34 files changed, 1692 insertions(+), 361 deletions(-) create mode 100644 constants/contentLengthRules.js create mode 100644 public/js/admin-form-helpers.js create mode 100644 utils/lengthValidation.js diff --git a/assets/css/components/form.css b/assets/css/components/form.css index 069efd1..490dbe8 100644 --- a/assets/css/components/form.css +++ b/assets/css/components/form.css @@ -37,6 +37,47 @@ margin-top: var(--spacing-1); } +.admin-field-counter { + display: block; + margin-top: var(--spacing-1); + color: var(--text-muted); + font-size: var(--font-size-xs); + line-height: 1.4; + text-align: left; + width: 100%; +} + +.admin-field-counter.is-danger { + color: #b42318; + font-weight: var(--font-weight-medium); +} + +.admin-upload-guidance { + margin-top: var(--spacing-2); + padding: 0.85rem 1rem; + border: 1px solid rgba(184, 183, 106, 0.22); + border-radius: var(--border-radius); + background: linear-gradient(180deg, rgba(184, 183, 106, 0.08), rgba(184, 183, 106, 0.03)); + color: var(--text-main); +} + +.admin-upload-guidance__title { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + margin-bottom: 0.35rem; +} + +.admin-upload-guidance__list { + margin: 0; + padding-left: 1.15rem; + color: var(--text-muted); + font-size: var(--font-size-xs); +} + +.admin-upload-guidance__list li + li { + margin-top: 0.25rem; +} + /* Validation Styles */ .invalid-feedback { font-size: var(--font-size-xs); diff --git a/constants/contentLengthRules.js b/constants/contentLengthRules.js new file mode 100644 index 0000000..cf50b2a --- /dev/null +++ b/constants/contentLengthRules.js @@ -0,0 +1,191 @@ +const ABOUT_US_LENGTH_RULES = [ + { path: "hero.title", label: "Hero title", maxLength: 80 }, + { path: "hero.breadcrumb.*", label: "Hero breadcrumb", maxLength: 80 }, + { path: "hero.backgroundImage", label: "Hero background image", maxLength: 255 }, + { path: "intro.subheading", label: "Intro subheading", maxLength: 80 }, + { path: "intro.heading", label: "Intro heading", maxLength: 120 }, + { path: "intro.description", label: "Intro description", maxLength: 1000 }, + { path: "intro.image", label: "Intro image", maxLength: 255 }, + { path: "mission.subheading", label: "Mission subheading", maxLength: 80 }, + { path: "mission.heading", label: "Mission heading", maxLength: 120 }, + { path: "mission.description", label: "Mission description", maxLength: 1000 }, + { path: "mission.images.main", label: "Mission main image", maxLength: 255 }, + { path: "mission.images.secondary", label: "Mission secondary image", maxLength: 255 }, + { path: "mission.images.bgShape", label: "Mission background shape", maxLength: 255 }, + { path: "mission.images.planeShape", label: "Mission plane shape", maxLength: 255 }, + { path: "mission.images.topShape", label: "Mission top shape", maxLength: 255 }, + { path: "mission.images.globeShape", label: "Mission globe shape", maxLength: 255 }, + { path: "mission.items.*.icon", label: "Mission item icon", maxLength: 255 }, + { path: "mission.items.*.label", label: "Mission item label", maxLength: 80 }, + { path: "mission.items.*.description", label: "Mission item description", maxLength: 240 }, + { path: "mission.features.*", label: "Mission feature", maxLength: 80 }, + { path: "mission.ctaButton.label", label: "Mission CTA label", maxLength: 64 }, + { path: "mission.ctaButton.href", label: "Mission CTA link", maxLength: 255 }, + { path: "features.backgroundImage", label: "Features background image", maxLength: 255 }, + { path: "features.subheading", label: "Features subheading", maxLength: 80 }, + { path: "features.heading", label: "Features heading", maxLength: 120 }, + { path: "features.description", label: "Features description", maxLength: 1000 }, + { path: "features.image", label: "Features image", maxLength: 255 }, + { path: "features.items.*.icon", label: "Feature item icon", maxLength: 255 }, + { path: "features.items.*.title", label: "Feature item title", maxLength: 80 }, + { path: "features.items.*.description", label: "Feature item description", maxLength: 240 }, + { path: "features.ctaButton.label", label: "Features CTA label", maxLength: 64 }, + { path: "features.ctaButton.href", label: "Features CTA link", maxLength: 255 }, + { path: "news.subheading", label: "News subheading", maxLength: 80 }, + { path: "news.heading", label: "News heading", maxLength: 120 }, + { path: "news.ctaButton.label", label: "News CTA label", maxLength: 64 }, + { path: "news.ctaButton.href", label: "News CTA link", maxLength: 255 }, + { path: "news.selectedBlogIds.*", label: "Selected blog ID", maxLength: 64 }, + { path: "news.items.*.title", label: "News item title", maxLength: 120 }, + { path: "news.items.*.category", label: "News item category", maxLength: 48 }, + { path: "news.items.*.date", label: "News item date", maxLength: 32 }, + { path: "news.items.*.author.name", label: "News item author", maxLength: 48 }, + { path: "news.items.*.author.avatar", label: "News item author avatar", maxLength: 255 }, + { path: "news.items.*.link", label: "News item link", maxLength: 255 }, + { path: "news.items.*.thumbnail", label: "News item thumbnail", maxLength: 255 }, +]; + +const PRICING_LENGTH_RULES = [ + { path: "hero.title", label: "Hero title", maxLength: 60 }, + { path: "hero.backgroundImage", label: "Hero background image", maxLength: 255 }, + { path: "hero.shapeImage", label: "Hero shape image", maxLength: 255 }, + { path: "hero.breadcrumb.*.text", label: "Hero breadcrumb text", maxLength: 40 }, + { path: "hero.breadcrumb.*.link", label: "Hero breadcrumb link", maxLength: 255 }, + { path: "pricingSection.subtitle", label: "Pricing section subtitle", maxLength: 64 }, + { path: "pricingSection.heading", label: "Pricing section heading", maxLength: 120 }, + { path: "pricingSection.description", label: "Pricing section description", maxLength: 500 }, + { path: "plans.monthly.*.name", label: "Monthly plan name", maxLength: 64 }, + { path: "plans.monthly.*.price", label: "Monthly plan price", maxLength: 32 }, + { path: "plans.monthly.*.period", label: "Monthly plan period", maxLength: 8 }, + { path: "plans.monthly.*.currency", label: "Monthly plan currency", maxLength: 8 }, + { path: "plans.monthly.*.buttonText", label: "Monthly plan button text", maxLength: 64 }, + { path: "plans.monthly.*.buttonLink", label: "Monthly plan button link", maxLength: 255 }, + { path: "plans.monthly.*.buttonIcon", label: "Monthly plan button icon", maxLength: 64 }, + { path: "plans.monthly.*.features.*", label: "Monthly plan feature", maxLength: 96 }, + { path: "plans.yearly.*.name", label: "Yearly plan name", maxLength: 64 }, + { path: "plans.yearly.*.price", label: "Yearly plan price", maxLength: 32 }, + { path: "plans.yearly.*.period", label: "Yearly plan period", maxLength: 8 }, + { path: "plans.yearly.*.currency", label: "Yearly plan currency", maxLength: 8 }, + { path: "plans.yearly.*.buttonText", label: "Yearly plan button text", maxLength: 64 }, + { path: "plans.yearly.*.buttonLink", label: "Yearly plan button link", maxLength: 255 }, + { path: "plans.yearly.*.buttonIcon", label: "Yearly plan button icon", maxLength: 64 }, + { path: "plans.yearly.*.features.*", label: "Yearly plan feature", maxLength: 96 }, + { path: "testimonials.subtitle", label: "Testimonials subtitle", maxLength: 64 }, + { path: "testimonials.heading", label: "Testimonials heading", maxLength: 120 }, + { path: "testimonials.buttonText", label: "Testimonials button text", maxLength: 64 }, + { path: "testimonials.buttonLink", label: "Testimonials button link", maxLength: 255 }, + { path: "testimonials.buttonIcon", label: "Testimonials button icon", maxLength: 64 }, + { path: "testimonials.image", label: "Testimonials image", maxLength: 255 }, + { path: "testimonials.items.*.name", label: "Testimonial name", maxLength: 64 }, + { path: "testimonials.items.*.role", label: "Testimonial role", maxLength: 64 }, + { path: "testimonials.items.*.content", label: "Testimonial content", maxLength: 400 }, +]; + +const BOOKING_LENGTH_RULES = [ + { path: "hero.title", label: "Hero title", maxLength: 80 }, + { path: "hero.backgroundImage", label: "Hero background image", maxLength: 255 }, + { path: "searchBar.locationLabel", label: "Search bar location label", maxLength: 64 }, + { path: "searchBar.holidaySeasonLabel", label: "Search bar holiday label", maxLength: 64 }, + { path: "searchBar.searchButtonText", label: "Search bar button text", maxLength: 64 }, + { path: "filterPanel.title", label: "Filter panel title", maxLength: 80 }, + { path: "filterPanel.priceTitle", label: "Filter panel price title", maxLength: 64 }, + { path: "filterPanel.priceLabel", label: "Filter panel price label", maxLength: 64 }, + { path: "filterPanel.pricePlaceholder", label: "Filter panel price placeholder", maxLength: 64 }, + { path: "filterPanel.ageTitle", label: "Filter panel age title", maxLength: 64 }, + { path: "filterPanel.ageSelectPlaceholder", label: "Filter panel age placeholder", maxLength: 64 }, + { path: "filterPanel.activitiesTitle", label: "Filter panel activities title", maxLength: 64 }, + { path: "filterPanel.ratingTitle", label: "Filter panel rating title", maxLength: 64 }, + { path: "filterPanel.resetButtonText", label: "Filter panel reset button text", maxLength: 64 }, + { path: "filterPanel.ratingOptions.*.value", label: "Filter rating option value", maxLength: 48 }, + { path: "filterPanel.ratingOptions.*.label", label: "Filter rating option label", maxLength: 64 }, + { path: "programs.*.value", label: "Program value", maxLength: 64 }, + { path: "programs.*.label", label: "Program label", maxLength: 64 }, + { path: "holidays.*.value", label: "Holiday value", maxLength: 64 }, + { path: "holidays.*.label", label: "Holiday label", maxLength: 64 }, + { path: "locations.*.value", label: "Location value", maxLength: 64 }, + { path: "locations.*.label", label: "Location label", maxLength: 64 }, + { path: "camps.*.name", label: "Camp name", maxLength: 120 }, + { path: "camps.*.priceText", label: "Camp price text", maxLength: 32 }, + { path: "camps.*.image", label: "Camp image", maxLength: 255 }, + { path: "camps.*.link", label: "Camp link", maxLength: 255 }, + { path: "camps.*.program", label: "Camp program", maxLength: 80 }, + { path: "configuration.discounts.*.id", label: "Discount ID", maxLength: 64 }, + { path: "configuration.discounts.*.name", label: "Discount name", maxLength: 64 }, + { path: "configuration.discounts.*.description", label: "Discount description", maxLength: 220 }, + { path: "configuration.vouchers.*.validCodes", label: "Voucher code", maxLength: 64 }, + { path: "formSteps.*.title", label: "Booking step title", maxLength: 80 }, + { path: "formSteps.*.sections.*.id", label: "Booking section ID", maxLength: 48 }, + { path: "formSteps.*.sections.*.fields.*.name", label: "Booking field name", maxLength: 32 }, + { path: "formSteps.*.sections.*.fields.*.label", label: "Booking field label", maxLength: 48 }, + { path: "formSteps.*.sections.*.fields.*.placeholder", label: "Booking field placeholder", maxLength: 72 }, + { path: "formSteps.*.sections.*.fields.*.programmeName", label: "Booking field programme name", maxLength: 48 }, +]; + +const ACTIVITY_LENGTH_RULES = [ + { path: "hero.titleActivities", label: "Activity hero title", maxLength: 80 }, + { path: "hero.titleBooking", label: "Booking hero title", maxLength: 80 }, + { path: "hero.bannerImageActivities", label: "Activity hero banner image", maxLength: 255 }, + { path: "hero.bannerImageBooking", label: "Booking hero banner image", maxLength: 255 }, + { path: "name", label: "Activity name", maxLength: 120 }, + { path: "priceText", label: "Activity price text", maxLength: 32 }, + { path: "image", label: "Activity image", maxLength: 255 }, + { path: "link", label: "Activity link", maxLength: 255 }, + { path: "program", label: "Activity program", maxLength: 80 }, + { path: "campDetail.hero.title", label: "Camp detail hero title", maxLength: 120 }, + { path: "campDetail.hero.bgImage", label: "Camp detail hero image", maxLength: 255 }, + { path: "campDetail.basicInfo.location", label: "Camp location", maxLength: 120 }, + { path: "campDetail.basicInfo.ageRange", label: "Camp age range", maxLength: 120 }, + { path: "campDetail.basicInfo.accommodationType", label: "Camp accommodation type", maxLength: 120 }, + { path: "campDetail.basicInfo.careLevel", label: "Camp care level", maxLength: 120 }, + { path: "campDetail.basicInfo.languages", label: "Camp languages", maxLength: 120 }, + { path: "campDetail.sidebar.contact.phone", label: "Camp contact phone", maxLength: 32 }, + { path: "campDetail.sidebar.contact.email", label: "Camp contact email", maxLength: 120 }, + { path: "campDetail.sidebar.menuItems.*.name", label: "Sidebar menu item name", maxLength: 64 }, + { path: "campDetail.sidebar.menuItems.*.href", label: "Sidebar menu item link", maxLength: 255 }, + { path: "campDetail.sidebar.upcomingTours.*.title", label: "Upcoming tour title", maxLength: 120 }, + { path: "campDetail.sidebar.upcomingTours.*.location", label: "Upcoming tour location", maxLength: 80 }, + { path: "campDetail.sidebar.upcomingTours.*.image", label: "Upcoming tour image", maxLength: 255 }, + { path: "campDetail.mainGallery.overlayInfo.location", label: "Gallery overlay location", maxLength: 120 }, + { path: "campDetail.mainGallery.overlayInfo.season", label: "Gallery overlay season", maxLength: 120 }, + { path: "campDetail.mainGallery.overlayInfo.languages", label: "Gallery overlay languages", maxLength: 120 }, + { path: "campDetail.mainGallery.slides.*.url", label: "Gallery slide image", maxLength: 255 }, + { path: "campDetail.mainGallery.slides.*.alt", label: "Gallery slide alt text", maxLength: 120 }, + { path: "campDetail.eventSchedule.startDate", label: "Event schedule start date", maxLength: 32 }, + { path: "campDetail.eventSchedule.duration", label: "Event schedule duration", maxLength: 32 }, + { path: "campDetail.eventSchedule.tickets", label: "Event schedule tickets", maxLength: 32 }, + { path: "campDetail.sections.overview.intro", label: "Overview intro", maxLength: 240 }, + { path: "campDetail.sections.overview.mainText", label: "Overview main text", maxLength: 1000 }, + { path: "campDetail.sections.overview.featureImage", label: "Overview feature image", maxLength: 255 }, + { path: "campDetail.sections.overview.features.*", label: "Overview feature", maxLength: 120 }, + { path: "campDetail.sections.location.title", label: "Location section title", maxLength: 120 }, + { path: "campDetail.sections.location.description", label: "Location section description", maxLength: 1000 }, + { path: "campDetail.sections.accommodation.title", label: "Accommodation section title", maxLength: 120 }, + { path: "campDetail.sections.accommodation.quote", label: "Accommodation quote", maxLength: 120 }, + { path: "campDetail.sections.accommodation.mainHeading", label: "Accommodation main heading", maxLength: 120 }, + { path: "campDetail.sections.accommodation.description", label: "Accommodation description", maxLength: 1000 }, + { path: "campDetail.sections.program.title", label: "Program section title", maxLength: 120 }, + { path: "campDetail.sections.program.heading", label: "Program section heading", maxLength: 120 }, + { path: "campDetail.sections.program.quote", label: "Program quote", maxLength: 120 }, + { path: "campDetail.sections.program.description", label: "Program description", maxLength: 1000 }, + { path: "campDetail.sections.meals.title", label: "Meals section title", maxLength: 120 }, + { path: "campDetail.sections.meals.description", label: "Meals section description", maxLength: 1000 }, + { path: "campDetail.sections.meals.footer", label: "Meals footer text", maxLength: 500 }, + { path: "campDetail.sections.insurance.title", label: "Insurance section title", maxLength: 120 }, + { path: "campDetail.sections.insurance.description", label: "Insurance description", maxLength: 1000 }, + { path: "campDetail.sections.insurance.package.title", label: "Insurance package title", maxLength: 120 }, + { path: "campDetail.sections.insurance.package.desc", label: "Insurance package description", maxLength: 1000 }, + { path: "campDetail.sections.insurance.cancellation.title", label: "Cancellation title", maxLength: 120 }, + { path: "campDetail.sections.insurance.cancellation.desc", label: "Cancellation description", maxLength: 1000 }, + { path: "filters.*.label", label: "Filter label", maxLength: 64 }, + { path: "filters.*.value", label: "Filter value", maxLength: 64 }, + { path: "filters.*.items.*.label", label: "Filter item label", maxLength: 64 }, + { path: "filters.*.items.*.value", label: "Filter item value", maxLength: 64 }, + { path: "bookingSessions.*.sessionId", label: "Session ID", maxLength: 80 }, +]; + +module.exports = { + ABOUT_US_LENGTH_RULES, + PRICING_LENGTH_RULES, + BOOKING_LENGTH_RULES, + ACTIVITY_LENGTH_RULES, +}; diff --git a/controllers/aboutUsController.js b/controllers/aboutUsController.js index b46b1ee..42284c0 100644 --- a/controllers/aboutUsController.js +++ b/controllers/aboutUsController.js @@ -2,10 +2,33 @@ const { addBaseUrlToImages } = require("../utils/imageHelper"); const AboutUs = require("../models/aboutUs"); const Blog = require("../models/blog"); const jsonHelper = require("../utils/jsonHelper"); +const { + validateLengthRules, + summarizeLengthErrors, +} = require("../utils/lengthValidation"); +const { + ABOUT_US_LENGTH_RULES, +} = require("../constants/contentLengthRules"); const writeAuditLog = require("../audit/writeAuditLog"); const diffObject = require("../audit/diffObject"); const AUDIT_ACTIONS = require("../constants/auditAction"); +const handleLengthValidation = (validation, req, res, options = {}) => { + const message = + summarizeLengthErrors(validation, 3) || "One or more fields exceed the allowed length."; + + if (options.json) { + return res.status(400).json({ + success: false, + error: message, + errors: validation.errors, + }); + } + + req.flash("error_msg", message); + return res.redirect(options.redirectTo || "/admin/about-us"); +}; + /** * GET /api/about * Lấy dữ liệu About Us (Public API cho website và CMS load dữ liệu) @@ -110,6 +133,11 @@ exports.updateAbout = async (req, res) => { // ✅ Capture BEFORE state const beforeData = JSON.parse(JSON.stringify(doc.toObject())); + const validation = validateLengthRules(updateData, ABOUT_US_LENGTH_RULES); + if (!validation.valid) { + return handleLengthValidation(validation, req, res, { json: true }); + } + // Use .set() for better handling of nested objects/arrays in Mongoose doc.set(updateData); await doc.save(); @@ -210,6 +238,13 @@ exports.update = async (req, res) => { // ✅ Capture BEFORE state const beforeData = JSON.parse(JSON.stringify(doc.toObject())); + const validation = validateLengthRules(updateData, ABOUT_US_LENGTH_RULES); + if (!validation.valid) { + return handleLengthValidation(validation, req, res, { + redirectTo: `/admin/about-us?activeTab=${req.query.activeTab || "hero"}`, + }); + } + doc.set(updateData); await doc.save(); diff --git a/controllers/activityController.js b/controllers/activityController.js index 532fffb..dc1f9f0 100644 --- a/controllers/activityController.js +++ b/controllers/activityController.js @@ -1,6 +1,22 @@ const {addBaseUrlToImages} = require("../utils/imageHelper"); const Activity = require("../models/activity"); const mongoose = require('mongoose'); +const { + validateLengthRules, + summarizeLengthErrors, +} = require("../utils/lengthValidation"); +const { + ACTIVITY_LENGTH_RULES, +} = require("../constants/contentLengthRules"); + +const getActivityLengthMessage = (validation) => + summarizeLengthErrors(validation, 3) || + "One or more fields exceed the allowed length."; + +const redirectWithLengthError = (req, res, path, validation) => { + req.flash("error_msg", getActivityLengthMessage(validation)); + return req.session.save(() => res.redirect(path)); +}; // -------------------- Public (API) exports -------------------- @@ -302,6 +318,15 @@ exports.updateFilters = async (req, res) => { try { // Provide minimal valid fields when inserting a new filters document so // schema validators (e.g., age validator) do not fail on upsert. + const filterLengthValidation = validateLengthRules( + { filters: sanitizedFilters }, + ACTIVITY_LENGTH_RULES, + ); + if (!filterLengthValidation.valid) { + req.flash("error_msg", getActivityLengthMessage(filterLengthValidation)); + return res.redirect("/admin/activity"); + } + const setOnInsert = { name: "_filters_doc", price: 0, @@ -353,6 +378,14 @@ exports.updateHero = async (req, res) => { bannerImageBooking: bannerImageBooking || '/uploads/banner/b9.jpg', }; + const heroLengthValidation = validateLengthRules( + { hero }, + ACTIVITY_LENGTH_RULES, + ); + if (!heroLengthValidation.valid) { + return redirectWithLengthError(req, res, "/admin/activity", heroLengthValidation); + } + // Update all activity docs to keep hero consistent await Activity.updateMany({ isFiltersDoc: { $ne: true } }, { $set: { hero } }); @@ -413,6 +446,16 @@ exports.create = async (req, res) => { try { const activityData = parseActivityFormData(req.body); + const lengthValidation = validateLengthRules(activityData, ACTIVITY_LENGTH_RULES); + if (!lengthValidation.valid) { + return redirectWithLengthError( + req, + res, + "/admin/activity/create", + lengthValidation, + ); + } + const newActivity = new Activity(activityData); await newActivity.save(); @@ -465,6 +508,16 @@ exports.update = async (req, res) => { // Force status to active on update (always set isActive true when editing) activityData.isActive = true; + const lengthValidation = validateLengthRules(activityData, ACTIVITY_LENGTH_RULES); + if (!lengthValidation.valid) { + return redirectWithLengthError( + req, + res, + `/admin/activity/${req.params.id}/edit`, + lengthValidation, + ); + } + await Activity.findByIdAndUpdate(req.params.id, activityData, {new: true}); req.flash("success_msg", "Activity updated successfully"); diff --git a/controllers/bookingController.js b/controllers/bookingController.js index ccca6fb..79a6412 100644 --- a/controllers/bookingController.js +++ b/controllers/bookingController.js @@ -2,6 +2,17 @@ const fs = require('fs'); const path = require('path'); const { addBaseUrlToImages } = require("../utils/imageHelper"); const Booking = require("../models/booking"); +const { + validateLengthRules, + summarizeLengthErrors, +} = require("../utils/lengthValidation"); +const { + BOOKING_LENGTH_RULES, +} = require("../constants/contentLengthRules"); + +const getBookingLengthMessage = (validation) => + summarizeLengthErrors(validation, 3) || + "One or more fields exceed the allowed length."; // -------------------- Public helpers -------------------- const getBookingData = async () => { @@ -398,6 +409,12 @@ exports.update = async (req, res) => { return req.session.save(() => res.redirect("/admin/booking")); } + const lengthValidation = validateLengthRules(updateData, BOOKING_LENGTH_RULES); + if (!lengthValidation.valid) { + req.flash("error_msg", getBookingLengthMessage(lengthValidation)); + return req.session.save(() => res.redirect("/admin/booking")); + } + // Validate data structure const validation = validateBookingData(updateData); if (!validation.isValid) { @@ -546,4 +563,4 @@ const getFinalBooking = (staticBooking, dbBooking) => { } return merged; -}; \ No newline at end of file +}; diff --git a/controllers/formController.js b/controllers/formController.js index b31ca98..2139fa2 100644 --- a/controllers/formController.js +++ b/controllers/formController.js @@ -7,13 +7,13 @@ const formController = { try { res.render('admin/form/index', { layout: 'layouts/admin', - title: 'Quản lý Form', + title: 'Form Management', user: req.session.user, }); } catch (error) { console.error('Error loading form management page:', error); res.status(500).render('error', { - message: 'Lỗi khi tải trang quản lý form', + message: 'Failed to load the form management page', error: error }); } @@ -29,16 +29,16 @@ const formController = { res.json({ success: true, - message: 'Cập nhật form thành công' + message: 'Form settings updated successfully' }); } catch (error) { console.error('Error updating form:', error); res.status(500).json({ success: false, - message: 'Lỗi khi cập nhật form' + message: 'Failed to update form settings' }); } } }; -module.exports = formController; \ No newline at end of file +module.exports = formController; diff --git a/controllers/homeController.js b/controllers/homeController.js index 9b8e2e7..043f51c 100644 --- a/controllers/homeController.js +++ b/controllers/homeController.js @@ -118,7 +118,7 @@ const normalizeStoredImagePath = (imagePath) => { const getDefaultFloatingContactData = () => ({ enabled: true, position: "bottom-right", - panelTitle: "Anh chị cần tư vấn hay hỗ trợ gì thêm không ạ?", + panelTitle: "Do you need any additional advice or support?", brand: { imageSrc: "/assets/img/logo/black-logo.svg", imageAlt: "HAI Learning", @@ -132,7 +132,7 @@ const getDefaultFloatingContactData = () => ({ id: "facebook", platform: "facebook", enabled: true, - label: "Nhắn tin qua Facebook", + label: "Message via Facebook", subtitle: "facebook.com/hailearning.edu.vn", href: "https://www.facebook.com/hailearning.edu.vn/", iconImage: "/uploads/home/floating-contact/Facebook_Logo_Primary.webp", @@ -145,7 +145,7 @@ const getDefaultFloatingContactData = () => ({ id: "zalo", platform: "zalo", enabled: true, - label: "Nhắn tin qua Zalo", + label: "Message via Zalo", subtitle: "zalo.me/84961834040", href: "https://zalo.me/84961834040", iconImage: "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp", diff --git a/controllers/pageController.js b/controllers/pageController.js index 03daf3a..e5d96d9 100644 --- a/controllers/pageController.js +++ b/controllers/pageController.js @@ -8,7 +8,7 @@ exports.getAllPages = async (req, res) => { const pages = content.pages || []; res.render('admin/pages/index', { - title: 'Quản lý trang', + title: 'Page Management', pages }); } catch (err) { @@ -21,7 +21,7 @@ exports.getAllPages = async (req, res) => { // Hiển thị form tạo trang mới exports.getAddPage = (req, res) => { res.render('admin/pages/add', { - title: 'Thêm trang mới' + title: 'Add New Page' }); }; @@ -95,7 +95,7 @@ exports.getEditPage = async (req, res) => { } res.render('admin/pages/edit', { - title: 'Chỉnh sửa trang', + title: 'Edit Page', page }); } catch (err) { @@ -225,4 +225,4 @@ exports.getPageBySlug = async (req, res) => { message: 'An error occurred while loading the page. Please try again later.' }); } -}; \ No newline at end of file +}; diff --git a/controllers/pricingController.js b/controllers/pricingController.js index d362c3c..fd9722a 100644 --- a/controllers/pricingController.js +++ b/controllers/pricingController.js @@ -1,8 +1,19 @@ const Pricing = require("../models/pricing"); +const { + validateLengthRules, + summarizeLengthErrors, +} = require("../utils/lengthValidation"); +const { + PRICING_LENGTH_RULES, +} = require("../constants/contentLengthRules"); const writeAuditLog = require("../audit/writeAuditLog"); const diffObject = require("../audit/diffObject"); const AUDIT_ACTIONS = require("../constants/auditAction"); +const getLengthValidationMessage = (validation) => + summarizeLengthErrors(validation, 3) || + "One or more fields exceed the allowed length."; + // ==================== CMS ADMIN FUNCTIONS ==================== // Render admin page for pricing management @@ -86,6 +97,20 @@ exports.update = async (req, res) => { ? JSON.parse(testimonials) : testimonials; + const validation = validateLengthRules( + { + hero: heroData, + pricingSection: pricingSectionData, + plans: plansData, + testimonials: testimonialsData, + }, + PRICING_LENGTH_RULES, + ); + if (!validation.valid) { + req.flash("error", getLengthValidationMessage(validation)); + return res.redirect("/admin/pricing"); + } + let pricing = await Pricing.findOne({ name: "default" }); // ✅ Capture BEFORE state diff --git a/controllers/settingController.js b/controllers/settingController.js index 71a6561..d23d8f3 100644 --- a/controllers/settingController.js +++ b/controllers/settingController.js @@ -7,11 +7,11 @@ exports.getSettings = async (req, res) => { const content = readJsonFile('content'); const settings = content.settings || { siteName: 'CMS-SIMS', - description: 'Hệ thống quản lý nội dung đơn giản' + description: 'Simple content management system' }; res.render('admin/settings', { - title: 'Cài đặt hệ thống', + title: 'System Settings', settings }); } catch (err) { @@ -53,4 +53,4 @@ exports.updateSettings = async (req, res) => { req.flash('error_msg', 'Error updating settings'); res.redirect('/admin/settings'); } -}; \ No newline at end of file +}; diff --git a/models/aboutUs.js b/models/aboutUs.js index d72dc07..e7e5ca0 100644 --- a/models/aboutUs.js +++ b/models/aboutUs.js @@ -3,87 +3,87 @@ const mongoose = require("mongoose"); const aboutUsSchema = new mongoose.Schema( { hero: { - title: String, - breadcrumb: [String], - backgroundImage: String, + title: { type: String, trim: true, maxlength: 80 }, + breadcrumb: [{ type: String, trim: true, maxlength: 80 }], + backgroundImage: { type: String, trim: true, maxlength: 255 }, }, intro: { - subheading: String, - heading: String, - description: String, - image: String, + subheading: { type: String, trim: true, maxlength: 80 }, + heading: { type: String, trim: true, maxlength: 120 }, + description: { type: String, trim: true, maxlength: 1000 }, + image: { type: String, trim: true, maxlength: 255 }, }, mission: { - subheading: String, - heading: String, - description: String, + subheading: { type: String, trim: true, maxlength: 80 }, + heading: { type: String, trim: true, maxlength: 120 }, + description: { type: String, trim: true, maxlength: 1000 }, images: { - main: String, - secondary: String, - bgShape: String, - planeShape: String, - topShape: String, - globeShape: String, + main: { type: String, trim: true, maxlength: 255 }, + secondary: { type: String, trim: true, maxlength: 255 }, + bgShape: { type: String, trim: true, maxlength: 255 }, + planeShape: { type: String, trim: true, maxlength: 255 }, + topShape: { type: String, trim: true, maxlength: 255 }, + globeShape: { type: String, trim: true, maxlength: 255 }, }, items: [ new mongoose.Schema( { - icon: String, - label: String, - description: String, + icon: { type: String, trim: true, maxlength: 255 }, + label: { type: String, trim: true, maxlength: 80 }, + description: { type: String, trim: true, maxlength: 240 }, }, { _id: false }, ), ], - features: [String], + features: [{ type: String, trim: true, maxlength: 80 }], ctaButton: { - label: String, - href: String, + label: { type: String, trim: true, maxlength: 64 }, + href: { type: String, trim: true, maxlength: 255 }, }, }, features: { - backgroundImage: String, - subheading: String, - heading: String, - description: String, - image: String, + backgroundImage: { type: String, trim: true, maxlength: 255 }, + subheading: { type: String, trim: true, maxlength: 80 }, + heading: { type: String, trim: true, maxlength: 120 }, + description: { type: String, trim: true, maxlength: 1000 }, + image: { type: String, trim: true, maxlength: 255 }, items: [ new mongoose.Schema( { - icon: String, - title: String, - description: String, + icon: { type: String, trim: true, maxlength: 255 }, + title: { type: String, trim: true, maxlength: 80 }, + description: { type: String, trim: true, maxlength: 240 }, }, { _id: false }, ), ], ctaButton: { - label: String, - href: String, + label: { type: String, trim: true, maxlength: 64 }, + href: { type: String, trim: true, maxlength: 255 }, }, }, news: { - subheading: String, - heading: String, + subheading: { type: String, trim: true, maxlength: 80 }, + heading: { type: String, trim: true, maxlength: 120 }, ctaButton: { - label: String, - href: String, + label: { type: String, trim: true, maxlength: 64 }, + href: { type: String, trim: true, maxlength: 255 }, }, selectedBlogIds: [{ type: mongoose.Schema.Types.ObjectId, ref: "Blog" }], // Deprecated: items field kept for backward compatibility during migration items: [ new mongoose.Schema( { - title: String, - category: String, - date: String, + title: { type: String, trim: true, maxlength: 120 }, + category: { type: String, trim: true, maxlength: 48 }, + date: { type: String, trim: true, maxlength: 32 }, comments: Number, author: { - name: String, - avatar: String, + name: { type: String, trim: true, maxlength: 48 }, + avatar: { type: String, trim: true, maxlength: 255 }, }, - link: String, - thumbnail: String, + link: { type: String, trim: true, maxlength: 255 }, + thumbnail: { type: String, trim: true, maxlength: 255 }, }, { _id: false }, ), diff --git a/models/activity.js b/models/activity.js index 2c62e7e..312d005 100644 --- a/models/activity.js +++ b/models/activity.js @@ -7,28 +7,33 @@ const activitySchema = new mongoose.Schema( titleActivities: { type: String, trim: true, - default: '' + default: "", + maxlength: 80, }, titleBooking: { type: String, trim: true, - default: '' + default: "", + maxlength: 80, }, bannerImageActivities: { type: String, trim: true, - default: '' + default: "", + maxlength: 255, }, bannerImageBooking: { type: String, trim: true, - default: '' + default: "", + maxlength: 255, }, }, name: { type: String, required: true, trim: true, + maxlength: 120, }, price: { type: Number, @@ -38,6 +43,7 @@ const activitySchema = new mongoose.Schema( priceText: { type: String, trim: true, + maxlength: 32, }, season: [ { @@ -58,25 +64,28 @@ const activitySchema = new mongoose.Schema( { type: String, trim: true, + maxlength: 80, }, ], image: { type: String, trim: true, + maxlength: 255, }, link: { type: String, trim: true, + maxlength: 255, }, // Global filters document (single document in Activity collection) filters: [ { - label: { type: String, required: true, trim: true }, - value: { type: String, required: true, trim: true }, + label: { type: String, required: true, trim: true, maxlength: 64 }, + value: { type: String, required: true, trim: true, maxlength: 64 }, items: [ { - value: { type: String, required: true }, - label: { type: String, required: true }, + value: { type: String, required: true, maxlength: 64 }, + label: { type: String, required: true, maxlength: 64 }, }, ], order: { type: Number, default: 0 }, @@ -85,6 +94,7 @@ const activitySchema = new mongoose.Schema( program: { type: String, trim: true, + maxlength: 80, }, rating: { type: Number, @@ -113,7 +123,7 @@ const activitySchema = new mongoose.Schema( // Booking sessions - các đợt booking với thông số riêng bookingSessions: [ { - sessionId: { type: String, required: true }, + sessionId: { type: String, required: true, maxlength: 80 }, startDate: { type: Date, required: true }, endDate: { type: Date, required: true }, overnightStays: { type: Number, required: true, default: 14 }, @@ -127,11 +137,11 @@ const activitySchema = new mongoose.Schema( // Danh sách booking cho session này bookingList: [ { - address: { type: String, required: true }, + address: { type: String, required: true, maxlength: 255 }, agreeNewsletter: { type: Boolean, default: false }, agreeTerms: { type: Boolean, required: true }, - city: { type: String, required: true }, - country: { type: String, required: true }, + city: { type: String, required: true, maxlength: 80 }, + country: { type: String, required: true, maxlength: 80 }, dietaryRestrictions: { type: String, enum: ['none', 'vegetarian', 'vegan', 'halal', 'kosher', 'gluten-free', 'other'], @@ -141,26 +151,27 @@ const activitySchema = new mongoose.Schema( type: String, required: true, lowercase: true, - trim: true + trim: true, + maxlength: 120 }, - emergencyContact: { type: String, required: true }, - emergencyPhone: { type: String, required: true }, - medicalConditions: { type: String, default: '' }, + emergencyContact: { type: String, required: true, maxlength: 80 }, + emergencyPhone: { type: String, required: true, maxlength: 40 }, + medicalConditions: { type: String, default: '', maxlength: 500 }, numberOfParticipants: { type: Number, required: true, min: 1 }, - parentFirstName: { type: String, required: true, trim: true }, - parentLastName: { type: String, required: true, trim: true }, + parentFirstName: { type: String, required: true, trim: true, maxlength: 80 }, + parentLastName: { type: String, required: true, trim: true, maxlength: 80 }, participantBirthDate: { type: Date, required: true }, - participantFirstName: { type: String, required: true, trim: true }, + participantFirstName: { type: String, required: true, trim: true, maxlength: 80 }, participantGender: { type: String, enum: ['male', 'female', 'other'], required: true }, - participantLastName: { type: String, required: true, trim: true }, - phone: { type: String, required: true }, - postalCode: { type: String, required: true }, - sessionDate: { type: String, required: true }, // sessionId reference - specialRequests: { type: String, default: '' }, + participantLastName: { type: String, required: true, trim: true, maxlength: 80 }, + phone: { type: String, required: true, maxlength: 40 }, + postalCode: { type: String, required: true, maxlength: 20 }, + sessionDate: { type: String, required: true, maxlength: 80 }, // sessionId reference + specialRequests: { type: String, default: '', maxlength: 500 }, // Thêm các trường quản lý bookingStatus: { type: String, @@ -175,8 +186,8 @@ const activitySchema = new mongoose.Schema( totalAmount: { type: Number, default: 0 }, paidAmount: { type: Number, default: 0 }, bookingDate: { type: Date, default: Date.now }, - confirmationCode: { type: String, unique: true }, - adminNotes: { type: String, default: '' } + confirmationCode: { type: String, unique: true, maxlength: 32 }, + adminNotes: { type: String, default: '', maxlength: 1000 } } ] } diff --git a/models/booking.js b/models/booking.js index a447c35..637779b 100644 --- a/models/booking.js +++ b/models/booking.js @@ -11,70 +11,70 @@ if (mongoose.connection.models.Booking) { const bookingSchema = new mongoose.Schema( { hero: { - title: String, - backgroundImage: String, + title: { type: String, trim: true, maxlength: 80 }, + backgroundImage: { type: String, trim: true, maxlength: 255 }, }, searchBar: { - locationLabel: String, - holidaySeasonLabel: String, - searchButtonText: String, + locationLabel: { type: String, trim: true, maxlength: 64 }, + holidaySeasonLabel: { type: String, trim: true, maxlength: 64 }, + searchButtonText: { type: String, trim: true, maxlength: 64 }, }, filterPanel: { - title: String, - priceTitle: String, - priceLabel: String, - pricePlaceholder: String, + title: { type: String, trim: true, maxlength: 80 }, + priceTitle: { type: String, trim: true, maxlength: 64 }, + priceLabel: { type: String, trim: true, maxlength: 64 }, + pricePlaceholder: { type: String, trim: true, maxlength: 64 }, priceMin: Number, priceMax: Number, - activitiesTitle: String, - ageTitle: String, - ageSelectPlaceholder: String, + activitiesTitle: { type: String, trim: true, maxlength: 64 }, + ageTitle: { type: String, trim: true, maxlength: 64 }, + ageSelectPlaceholder: { type: String, trim: true, maxlength: 64 }, ageMin: Number, ageMax: Number, - ratingTitle: String, + ratingTitle: { type: String, trim: true, maxlength: 64 }, ratingOptions: [ { - value: String, - label: String, + value: { type: String, trim: true, maxlength: 48 }, + label: { type: String, trim: true, maxlength: 64 }, }, ], - resetButtonText: String, + resetButtonText: { type: String, trim: true, maxlength: 64 }, }, programs: [ { - value: String, - label: String, + value: { type: String, trim: true, maxlength: 64 }, + label: { type: String, trim: true, maxlength: 64 }, }, ], holidays: [ { - value: String, - label: String, + value: { type: String, trim: true, maxlength: 64 }, + label: { type: String, trim: true, maxlength: 64 }, }, ], locations: [ { - value: String, - label: String, + value: { type: String, trim: true, maxlength: 64 }, + label: { type: String, trim: true, maxlength: 64 }, }, ], camps: [ { - name: String, + name: { type: String, trim: true, maxlength: 120 }, price: Number, - priceText: String, + priceText: { type: String, trim: true, maxlength: 32 }, season: [String], age: [Number], locations: [String], - image: String, - link: String, - program: String, + image: { type: String, trim: true, maxlength: 255 }, + link: { type: String, trim: true, maxlength: 255 }, + program: { type: String, trim: true, maxlength: 80 }, rating: Number, }, ], @@ -103,4 +103,4 @@ const bookingSchema = new mongoose.Schema( } ); -module.exports = mongoose.model("Booking", bookingSchema); \ No newline at end of file +module.exports = mongoose.model("Booking", bookingSchema); diff --git a/models/pricing.js b/models/pricing.js index 4f9e931..49b52b0 100644 --- a/models/pricing.js +++ b/models/pricing.js @@ -15,11 +15,13 @@ const breadcrumbItemSchema = new mongoose.Schema( type: String, trim: true, default: "", + maxlength: 40, }, link: { type: String, trim: true, default: "", + maxlength: 255, }, }, { _id: false } @@ -32,16 +34,19 @@ const heroSchema = new mongoose.Schema( type: String, trim: true, default: "Pricing Plan", + maxlength: 60, }, backgroundImage: { type: String, trim: true, default: "/assets/img/inner-page/breadcrumb.jpg", + maxlength: 255, }, shapeImage: { type: String, trim: true, default: "/assets/img/inner-page/shape.png", + maxlength: 255, }, breadcrumb: { type: [breadcrumbItemSchema], @@ -58,16 +63,19 @@ const pricingSectionSchema = new mongoose.Schema( type: String, trim: true, default: "pricing plan", + maxlength: 64, }, heading: { type: String, trim: true, default: "Flexible Plans to Suit Every Traveler", + maxlength: 120, }, description: { type: String, trim: true, default: "", + maxlength: 500, }, }, { _id: false } @@ -80,36 +88,43 @@ const planSchema = new mongoose.Schema( type: String, trim: true, required: true, + maxlength: 64, }, price: { type: String, trim: true, default: "0", + maxlength: 32, }, period: { type: String, trim: true, default: "mo", + maxlength: 8, }, currency: { type: String, trim: true, default: "$", + maxlength: 8, }, buttonText: { type: String, trim: true, default: "Get Started Today", + maxlength: 64, }, buttonLink: { type: String, trim: true, default: "/pricing", + maxlength: 255, }, buttonIcon: { type: String, trim: true, default: "fa-solid fa-arrow-right", + maxlength: 64, }, style: { type: String, @@ -118,7 +133,7 @@ const planSchema = new mongoose.Schema( default: "default", }, features: { - type: [String], + type: [{ type: String, maxlength: 96 }], default: [], }, }, @@ -147,11 +162,13 @@ const testimonialItemSchema = new mongoose.Schema( type: String, trim: true, default: "", + maxlength: 64, }, role: { type: String, trim: true, default: "", + maxlength: 64, }, rating: { type: Number, @@ -163,6 +180,7 @@ const testimonialItemSchema = new mongoose.Schema( type: String, trim: true, default: "", + maxlength: 400, }, }, { _id: false } @@ -175,31 +193,37 @@ const testimonialsSchema = new mongoose.Schema( type: String, trim: true, default: "What Our Clients Say", + maxlength: 64, }, heading: { type: String, trim: true, default: "Immigration Success Stories", + maxlength: 120, }, buttonText: { type: String, trim: true, default: "View All Review", + maxlength: 64, }, buttonLink: { type: String, trim: true, default: "/contact", + maxlength: 255, }, buttonIcon: { type: String, trim: true, default: "fa-solid fa-arrow-right", + maxlength: 64, }, image: { type: String, trim: true, default: "", + maxlength: 255, }, items: { type: [testimonialItemSchema], diff --git a/public/js/admin-form-helpers.js b/public/js/admin-form-helpers.js new file mode 100644 index 0000000..fc28b53 --- /dev/null +++ b/public/js/admin-form-helpers.js @@ -0,0 +1,867 @@ +;(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-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") || + 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"); + host.insertAdjacentElement("afterend", 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 = ` +
${payload.title}
+ + `; + + 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); diff --git a/scripts/2026_04_07_110000_add_home_floating_contact.js b/scripts/2026_04_07_110000_add_home_floating_contact.js index 517194a..145e329 100644 --- a/scripts/2026_04_07_110000_add_home_floating_contact.js +++ b/scripts/2026_04_07_110000_add_home_floating_contact.js @@ -2,7 +2,7 @@ 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_PANEL_TITLE = "Do you need any additional advice or support?"; 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"; @@ -60,7 +60,7 @@ async function migrate() { id: "facebook", platform: "facebook", enabled: true, - label: "Nhắn tin qua Facebook", + label: "Message via Facebook", subtitle: "facebook.com/hailearning.edu.vn", href: DEFAULT_FACEBOOK_URL, iconImage: DEFAULT_FACEBOOK_ICON, @@ -73,7 +73,7 @@ async function migrate() { id: "zalo", platform: "zalo", enabled: true, - label: "Nhắn tin qua Zalo", + label: "Message via Zalo", subtitle: `zalo.me/${zaloPhone}`, href: zaloUrl, iconImage: DEFAULT_ZALO_ICON, diff --git a/utils/lengthValidation.js b/utils/lengthValidation.js new file mode 100644 index 0000000..60e83c0 --- /dev/null +++ b/utils/lengthValidation.js @@ -0,0 +1,158 @@ +const DEFAULT_LABEL = "Field"; + +const normalizePath = (path) => { + return String(path || "") + .replace(/\[(\d+)\]/g, ".$1") + .replace(/\[\*\]/g, ".*") + .replace(/\[\]/g, ".*") + .split(".") + .filter(Boolean); +}; + +const toLabel = (path) => { + if (!path) { + return DEFAULT_LABEL; + } + + return String(path) + .replace(/\[\d+\]/g, "") + .replace(/\.\*/g, "") + .replace(/[._-]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .replace(/^./, (ch) => ch.toUpperCase()); +}; + +const collectMatches = (value, segments, currentPath = [], results = []) => { + if (segments.length === 0) { + results.push({ + path: currentPath.join("."), + value, + }); + return results; + } + + const [segment, ...rest] = segments; + + if (segment === "*") { + if (!Array.isArray(value)) { + return results; + } + + value.forEach((item, index) => { + collectMatches(item, rest, [...currentPath, String(index)], results); + }); + + return results; + } + + if (value === null || value === undefined) { + return results; + } + + if (typeof value !== "object") { + return results; + } + + if (!Object.prototype.hasOwnProperty.call(value, segment)) { + return results; + } + + return collectMatches(value[segment], rest, [...currentPath, segment], results); +}; + +const countWords = (value) => { + const normalized = String(value || "") + .replace(/\s+/g, " ") + .trim(); + + if (!normalized) { + return 0; + } + + return normalized.split(" ").length; +}; + +const buildErrorMessage = (label, path, maxLength, maxWords) => { + const target = path ? `${label} (${path})` : label; + + if (maxLength && maxWords) { + return `${target} must not exceed ${maxLength} characters or ${maxWords} words.`; + } + + if (maxLength) { + return `${target} must not exceed ${maxLength} characters.`; + } + + return `${target} must not exceed ${maxWords} words.`; +}; + +const validateLengthRules = (payload, rules = []) => { + const errors = []; + + for (const rule of rules) { + const paths = Array.isArray(rule.paths) + ? rule.paths + : [rule.path].filter(Boolean); + + for (const path of paths) { + const segments = normalizePath(path); + const matches = collectMatches(payload, segments); + + for (const match of matches) { + if (typeof match.value !== "string") { + continue; + } + + const normalized = match.value.trim(); + if (!normalized && rule.allowEmpty !== false) { + continue; + } + + const actualLength = normalized.length; + const actualWords = countWords(normalized); + const maxLength = Number(rule.maxLength); + const maxWords = Number(rule.maxWords); + const exceedsLength = Number.isFinite(maxLength) && maxLength > 0 && actualLength > maxLength; + const exceedsWords = Number.isFinite(maxWords) && maxWords > 0 && actualWords > maxWords; + + if (!exceedsLength && !exceedsWords) { + continue; + } + + const label = rule.label || toLabel(path); + errors.push({ + path: match.path, + label, + message: buildErrorMessage(label, match.path, exceedsLength ? maxLength : null, exceedsWords ? maxWords : null), + maxLength: Number.isFinite(maxLength) ? maxLength : undefined, + maxWords: Number.isFinite(maxWords) ? maxWords : undefined, + actualLength, + actualWords, + }); + } + } + } + + return { + valid: errors.length === 0, + errors, + }; +}; + +const summarizeLengthErrors = (validation, limit = 1) => { + if (!validation || !Array.isArray(validation.errors) || validation.errors.length === 0) { + return ""; + } + + return validation.errors + .slice(0, limit) + .map((error) => error.message) + .join(" "); +}; + +module.exports = { + validateLengthRules, + summarizeLengthErrors, + toLabel, +}; diff --git a/views/admin/activity/form.ejs b/views/admin/activity/form.ejs index 3b75cf6..a2fba55 100644 --- a/views/admin/activity/form.ejs +++ b/views/admin/activity/form.ejs @@ -41,7 +41,9 @@
+ placeholder="/templates/yootheme/activities/activity-banner.jpg" + maxlength="255" data-maxlength="255" + data-admin-upload-guidance="Activity page hero-style image.|Recommended upload: at least 1920x700px."> @@ -235,7 +237,9 @@ + placeholder="e.g., yootheme/banner/b14.jpg" + maxlength="255" data-maxlength="255" + data-admin-upload-guidance="Used in activity listing cards.|Recommended upload: a landscape image at 704x432px or larger."> Path to the main activity image (used in listings)
@@ -271,7 +275,9 @@
+ placeholder="e.g., yootheme/banner/b1.jpg" + maxlength="255" data-maxlength="255" + data-admin-upload-guidance="Activity page hero-style image.|Recommended upload: at least 1920x700px.">
@@ -470,14 +468,6 @@ console.error('Error initializing contentAfterQuote editor:', error); } - // Excerpt character counter - const excerptInput = document.getElementById('excerpt'); - const excerptCount = document.getElementById('excerptCount'); - - excerptInput.addEventListener('input', function () { - excerptCount.textContent = this.value.length; - }); - // Image upload handler document.querySelectorAll('.btn-upload-image').forEach(button => { button.addEventListener('click', function () { @@ -1062,4 +1052,4 @@ } }); }); - \ No newline at end of file + diff --git a/views/admin/blog/edit.ejs b/views/admin/blog/edit.ejs index f9528c8..83214b6 100644 --- a/views/admin/blog/edit.ejs +++ b/views/admin/blog/edit.ejs @@ -200,11 +200,7 @@ -
- - <%= (blog.excerpt || '' ).length %> - /500 characters -
+
Maximum 500 characters.
@@ -888,14 +884,6 @@ console.error('Error initializing contentAfterQuote editor:', error); } - // Excerpt character counter - const excerptInput = document.getElementById('excerpt'); - const excerptCount = document.getElementById('excerptCount'); - - excerptInput.addEventListener('input', function () { - excerptCount.textContent = this.value.length; - }); - // Image upload handler document.querySelectorAll('.btn-upload-image').forEach(button => { button.addEventListener('click', function () { @@ -1773,4 +1761,4 @@ } }); }); - \ No newline at end of file + diff --git a/views/admin/booking/index.ejs b/views/admin/booking/index.ejs index 0c86370..192b5cb 100644 --- a/views/admin/booking/index.ejs +++ b/views/admin/booking/index.ejs @@ -2000,13 +2000,13 @@ } - if (window.toastManager) window.toastManager.success('Upload thành công'); + if (window.toastManager) window.toastManager.success('Upload completed successfully'); } else { - if (window.toastManager) window.toastManager.success('Upload thành công'); + if (window.toastManager) window.toastManager.success('Upload completed successfully'); } } catch (err) { console.error('Upload error', err); - if (window.toastManager) window.toastManager.error('Lỗi upload: ' + (err.message || + if (window.toastManager) window.toastManager.error('Upload error: ' + (err.message || err)); } }); @@ -2015,7 +2015,7 @@ fileInput.click(); } catch (err) { console.error('openImageUploader error', err); - if (window.toastManager) window.toastManager.error('Lỗi: ' + (err.message || err)); + if (window.toastManager) window.toastManager.error('Error: ' + (err.message || err)); } } @@ -2031,4 +2031,4 @@ } openImageUploader(targetInput, imageType); }); - \ No newline at end of file + diff --git a/views/admin/contact/index.ejs b/views/admin/contact/index.ejs index 6dce90c..4f37814 100644 --- a/views/admin/contact/index.ejs +++ b/views/admin/contact/index.ejs @@ -75,7 +75,7 @@ Upload - The contact hero currently renders at about 1496x544px on desktop. Recommended minimum upload: 1920x700px. + Recommended minimum upload: 1920x700px.
@@ -181,7 +181,6 @@ name="cardTitle_<%= index %>" value="<%= card.title || '' %>" maxlength="40" data-maxlength="40"> - Recommended maximum: 40 characters.
@@ -1210,63 +1209,12 @@ }); } - function ensureContactCounter(input) { - if (!input || !input.dataset.maxlength) { - return null; - } - - if (!input.id) { - input.id = `contactField_${Math.random().toString(36).slice(2, 10)}`; - } - - const field = input.closest('.col-md-12, .col-md-7, .col-md-6, .col-md-5, .col-md-4, .col-md-3, .col-md-2, .col-12') || input.parentElement; - const anchor = input.closest('.input-group') || input; - const parent = anchor?.parentElement || field; - if (!field || !anchor || !parent) { - return null; - } - - let hint = field.querySelector(`[data-counter-for="${input.id}"]`); - if (!hint) { - hint = document.createElement('small'); - hint.className = 'form-text contact-limit-counter text-secondary'; - hint.dataset.counterFor = input.id; - } - - if (hint.previousElementSibling !== anchor) { - parent.insertBefore(hint, anchor.nextSibling); - } - - return hint; - } - - function updateContactCounter(input) { - const hint = ensureContactCounter(input); - if (!hint) { - return; - } - - const max = Number(input.dataset.maxlength); - if (Number.isFinite(max) && max > 0 && (input.value || '').length > max) { - input.value = (input.value || '').slice(0, max); - } - - const length = (input.value || '').length; - hint.textContent = `${length}/${max} characters`; - hint.classList.toggle('text-danger', length >= max); - } - function initContactCharacterCounters(scope = document) { - scope.querySelectorAll('input[data-maxlength], textarea[data-maxlength]').forEach((input) => { - updateContactCounter(input); + scope.querySelectorAll('.contact-limit-counter').forEach((node) => node.remove()); - if (input.dataset.counterBound === 'true') { - return; - } - - input.dataset.counterBound = 'true'; - input.addEventListener('input', () => updateContactCounter(input)); - }); + if (window.AdminFormHelpers) { + window.AdminFormHelpers.refresh(scope); + } } function updateAllJsonInputs(data) { @@ -1327,7 +1275,6 @@
- Recommended maximum: 40 characters.
diff --git a/views/admin/header/index.ejs b/views/admin/header/index.ejs index 66ec410..c4252c3 100644 --- a/views/admin/header/index.ejs +++ b/views/admin/header/index.ejs @@ -404,7 +404,7 @@ } } catch (error) { console.error('=== TRACE: Unified Save ERROR ===', error); - showNotification('Lỗi: ' + error.message, 'error'); + showNotification('Error: ' + error.message, 'error'); } finally { submitBtn.innerHTML = originalText; submitBtn.disabled = false; @@ -709,19 +709,19 @@ const icon = document.getElementById('newSocialIcon').value.trim(); if (!platform) { - alert('Vui lòng nhập tên nền tảng'); + alert('Please enter a platform name'); return; } if (!url) { - alert('Vui lòng nhập URL'); + alert('Please enter a URL'); return; } // Check if platform already exists const existingPlatforms = Array.from(document.querySelectorAll('.social-platform')).map(el => el.value); if (existingPlatforms.includes(platform)) { - alert(`${platform} đã tồn tại`); + alert(`${platform} already exists`); return; } @@ -780,12 +780,12 @@ const newIcon = document.getElementById('editSocialIcon').value.trim(); if (!newPlatform) { - alert('Vui lòng nhập tên nền tảng'); + alert('Please enter a platform name'); return; } if (!newUrl) { - alert('Vui lòng nhập URL'); + alert('Please enter a URL'); return; } diff --git a/views/admin/home/index.ejs b/views/admin/home/index.ejs index f42a535..5865cdc 100644 --- a/views/admin/home/index.ejs +++ b/views/admin/home/index.ejs @@ -446,92 +446,11 @@ toast.addEventListener("hidden.bs.toast", () => toast.remove()); } - function ensureCharacterHint(input) { - if (!input || (!input.dataset.maxlength && !input.dataset.maxwords)) { - return null; - } - - if (!input.id) { - input.id = `homeField_${Math.random().toString(36).slice(2, 10)}`; - } - - const field = input.closest(".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") || input.parentElement; - const anchor = input.closest(".input-group") || input; - const parent = anchor?.parentElement || field; - if (!field || !anchor || !parent) { - return null; - } - - let hint = field.querySelector(`[data-counter-for="${input.id}"]`); - if (!hint) { - hint = document.createElement("small"); - hint.className = "form-text home-limit-counter text-secondary"; - hint.dataset.counterFor = input.id; - } - - if (hint.previousElementSibling !== anchor) { - parent.insertBefore(hint, anchor.nextSibling); - } - - return hint; - } - - function updateCharacterHint(input) { - const hint = ensureCharacterHint(input); - if (!hint) { - return; - } - - const hasWordLimit = Boolean(input.dataset.maxwords); - const hasCharLimit = Boolean(input.dataset.maxlength); - - if (hasWordLimit) { - const maxWords = Number(input.dataset.maxwords); - const normalized = (input.value || "").replace(/\s+/g, " ").trim(); - const words = normalized ? normalized.split(" ") : []; - - if (Number.isFinite(maxWords) && maxWords > 0 && words.length > maxWords) { - input.value = words.slice(0, maxWords).join(" "); - } else if (normalized !== input.value) { - input.value = normalized; - } - } - - if (hasCharLimit) { - const max = Number(input.dataset.maxlength); - if (Number.isFinite(max) && max > 0 && (input.value || "").length > max) { - input.value = (input.value || "").slice(0, max); - } - } - - if (hasWordLimit) { - const maxWords = Number(input.dataset.maxwords); - const currentWords = input.value ? input.value.split(" ").filter(Boolean).length : 0; - const currentLength = (input.value || "").length; - const maxLength = Number(input.dataset.maxlength); - hint.textContent = hasCharLimit - ? `${currentWords}/${maxWords} words, ${currentLength}/${maxLength} characters` - : `${currentWords}/${maxWords} words`; - hint.classList.toggle("text-danger", currentWords >= maxWords || (hasCharLimit && currentLength >= maxLength)); - return; - } - - const max = Number(input.dataset.maxlength); - const length = (input.value || "").length; - hint.textContent = `${length}/${max} characters`; - hint.classList.toggle("text-danger", length >= max); - } - function initHomeCharacterCounters(scope = document) { - scope.querySelectorAll("input[data-maxlength], textarea[data-maxlength], input[data-maxwords], textarea[data-maxwords]").forEach((input) => { - updateCharacterHint(input); + scope.querySelectorAll(".home-limit-counter").forEach((node) => node.remove()); - if (input.dataset.counterBound === "true") { - return; - } - - input.dataset.counterBound = "true"; - input.addEventListener("input", () => updateCharacterHint(input)); - }); + if (window.AdminFormHelpers) { + window.AdminFormHelpers.refresh(scope); + } } diff --git a/views/admin/home/sections/floatingContact.ejs b/views/admin/home/sections/floatingContact.ejs index 8da4ae4..d0ff814 100644 --- a/views/admin/home/sections/floatingContact.ejs +++ b/views/admin/home/sections/floatingContact.ejs @@ -64,7 +64,7 @@ style="height: 120px; width: 120px; object-fit: contain; background: #fff;" alt="Brand preview" />
- Hiển thị thực tế khoảng 35x35px. Raster uploads are normalized to 104x104 WebP to match the homepage widget. + Displayed at roughly 35x35px. Raster uploads are normalized to 104x104 WebP to match the homepage widget.
@@ -95,7 +95,7 @@
- Hiển thị thực tế khoảng 26x26px. Shown only when the trigger slideshow cannot use the brand image and action icons. Raster uploads are normalized to 96x96 WebP. + Displayed at roughly 26x26px. Shown only when the trigger slideshow cannot use the brand image and action icons. Raster uploads are normalized to 96x96 WebP.
diff --git a/views/admin/home/sections/hero.ejs b/views/admin/home/sections/hero.ejs index 118ec9e..64922c2 100644 --- a/views/admin/home/sections/hero.ejs +++ b/views/admin/home/sections/hero.ejs @@ -9,18 +9,11 @@
-
-
Current homepage hero behavior
-
- Mỗi slide dùng ảnh riêng làm nền full-width. Title, description và 2 button chỉ là lớp overlay. - Trường video hiện không còn được hiển thị ở frontend. -
-
- Tùy chọn dự phòng. Khung hero desktop hiện hiển thị khoảng 1512x544px, nên nên upload ảnh ngang ít nhất 1920x700px. + Optional fallback. The hero desktop frame currently displays approximately 1512x544px, so upload a landscape image of at least 1920x700px.
Recommended content structure
    -
  • Dùng ảnh slide tỉ lệ ngang, ưu tiên ảnh lớn để fit container.
  • -
  • Title ngắn 2-4 dòng để không tràn trên mobile.
  • -
  • Description giữ ở mức 1-3 câu ngắn.
  • -
  • Hai nút nên dùng link nội bộ như /contact.
  • +
  • Use landscape slide images; prefer large images to fit the container.
  • +
  • Keep titles to 2-4 lines to avoid overflow on mobile.
  • +
  • Limit descriptions to 1-3 short sentences.
  • +
  • Both buttons should use internal links like /contact.
@@ -101,7 +94,7 @@
- Hiện không render ngoài frontend, chỉ giữ để tương thích dữ liệu cũ. + Currently not rendered on the frontend; kept for backward compatibility with existing data. @@ -113,7 +106,7 @@
- Ảnh này phủ toàn bộ hero. Khung desktop hiện khoảng 1512x544px, khuyến nghị upload 1920x700px hoặc lớn hơn. + Recommended upload size is 1920x700px or larger.
- Frontend hiện không render video trong hero. Giữ lại chỉ để tránh mất dữ liệu cũ. + The frontend currently does not render video in the hero. Kept only to preserve existing data.
diff --git a/views/admin/home/sections/partners.ejs b/views/admin/home/sections/partners.ejs index 271a5c6..0ee0be8 100644 --- a/views/admin/home/sections/partners.ejs +++ b/views/admin/home/sections/partners.ejs @@ -10,7 +10,7 @@
- Award icons trên homepage hiển thị khoảng 124x124px. Khuyến nghị upload ảnh vuông hoặc logo nền trong suốt tối thiểu 248x248px. + Award icons on the homepage display at roughly 124x124px. Recommended uploads are square images or transparent logos at least 248x248px.
<% for(let i=0; i<4; i++) { const item = (data.partners?.visaConsultancy?.items && data.partners.visaConsultancy.items[i]) || {}; @@ -31,7 +31,7 @@
- Khuyến nghị ảnh vuông hoặc logo nền trong suốt 248x248px để hiển thị sắc nét. + Recommended: a 248x248px square image or transparent logo for crisp rendering.
- Brand logo trong slider hiện hiển thị khoảng 159x48px. Khuyến nghị dùng SVG hoặc logo ngang nền trong suốt tối thiểu 320x96px. + Brand logos in the slider display at roughly 159x48px. Recommended assets are SVGs or horizontal transparent logos at least 320x96px.
<% (data.partners?.brands?.items || []).forEach(function(item, index) { %>
@@ -83,7 +83,7 @@
- Khuyến nghị SVG hoặc ảnh ngang 320x96px để logo không bị mờ trong slider. + Recommended: SVG or a 320x96px horizontal image so the logo stays sharp in the slider.
@@ -143,7 +143,7 @@
- Khuyến nghị SVG hoặc ảnh ngang 320x96px để logo không bị mờ trong slider. + Recommended: SVG or a 320x96px horizontal image so the logo stays sharp in the slider.
diff --git a/views/admin/home/sections/testimonials.ejs b/views/admin/home/sections/testimonials.ejs index 7bf9ab4..e30c228 100644 --- a/views/admin/home/sections/testimonials.ejs +++ b/views/admin/home/sections/testimonials.ejs @@ -31,7 +31,7 @@
- Khung thumbnail desktop hiện khoảng 416x370px. Khuyến nghị upload tối thiểu 832x740px. + The desktop thumbnail frame is approximately 416x370px. Recommended upload size is at least 832x740px.
@@ -100,7 +100,7 @@
- Avatar hiển thị ở khoảng 48x48px. Khuyến nghị ảnh vuông 96x96px hoặc 128x128px. + Avatars display at roughly 48x48px. Recommended square image sizes are 96x96px or 128x128px.
diff --git a/views/admin/home/sections/visaCountries.ejs b/views/admin/home/sections/visaCountries.ejs index eebeacf..53860c3 100644 --- a/views/admin/home/sections/visaCountries.ejs +++ b/views/admin/home/sections/visaCountries.ejs @@ -63,7 +63,7 @@
- Khung ảnh desktop hiện khoảng 840x830px. Khuyến nghị ảnh gần vuông, tối thiểu 1000x1000px. + The desktop image frame is approximately 840x830px. Recommended assets are near-square images at least 1000x1000px.
- Khung desktop hiện khoảng 318x347px. Khuyến nghị upload ít nhất 750x820px, tỉ lệ dọc khoảng 0.91:1. + The desktop frame is approximately 318x347px. Recommended upload size is at least 750x820px, with a portrait ratio near 0.91:1.
- Khung desktop hiện khoảng 363x380px. Khuyến nghị upload ít nhất 760x800px, tỉ lệ dọc khoảng 0.95:1. + The desktop frame is approximately 363x380px. Recommended upload size is at least 760x800px, with a portrait ratio near 0.95:1.
- Icon hiển thị ở khoảng 24x24px. Khuyến nghị dùng SVG hoặc ảnh vuông 48x48px. + Icons display at roughly 24x24px. Recommended assets are SVGs or square images at 48x48px.
diff --git a/views/admin/level/index.ejs b/views/admin/level/index.ejs index 86068cf..27a172c 100644 --- a/views/admin/level/index.ejs +++ b/views/admin/level/index.ejs @@ -529,7 +529,7 @@ document.addEventListener('DOMContentLoaded', function() { img.alt = 'Image preview'; if (window.toastManager) { - window.toastManager.success('Tải ảnh thành công'); + window.toastManager.success('Image uploaded successfully'); } } } else { diff --git a/views/admin/visa/index.ejs b/views/admin/visa/index.ejs index efefb65..27f7087 100644 --- a/views/admin/visa/index.ejs +++ b/views/admin/visa/index.ejs @@ -107,7 +107,7 @@
- + @@ -146,7 +146,7 @@
- + @@ -236,7 +236,7 @@
- + @@ -330,7 +330,7 @@
- + @@ -599,17 +599,75 @@ setupImageUploadHandlers(); }); - function showListView() { - document.getElementById("tableContainer").style.display = "block"; - document.getElementById("formContainer").style.display = "none"; - document.getElementById("viewContainer").style.display = "none"; - } - - function showFormView() { - document.getElementById("tableContainer").style.display = "none"; - document.getElementById("formContainer").style.display = "block"; - document.getElementById("viewContainer").style.display = "none"; - } + function showListView() { + document.getElementById("tableContainer").style.display = "block"; + document.getElementById("formContainer").style.display = "none"; + document.getElementById("viewContainer").style.display = "none"; + } + + function refreshVisaAdminHelpers() { + if (!window.AdminFormHelpers) { + return; + } + + const formContainer = document.getElementById("formContainer"); + window.AdminFormHelpers.refresh(formContainer); + + const guidanceTargets = [ + { + selector: "#icon_input", + lines: [ + "Displayed as a small country flag or icon.", + "Prefer SVG; otherwise use a square image at 96x96px or larger." + ] + }, + { + selector: "#mainImage_detail", + lines: [ + "Used in visa detail content blocks.", + "Recommended upload: at least 1000x750px for primary imagery." + ] + }, + { + selector: "input[name='bannerImageGallery']", + lines: [ + "Gallery image used in visa detail content.", + "Recommended upload: at least 1000x750px." + ] + }, + { + selector: "input[name='related_icon[]']", + lines: [ + "Supporting service image.", + "Recommended upload: at least 800x600px." + ] + }, + { + selector: "#contact_image_input", + lines: [ + "Contact-side supporting image.", + "Recommended upload: at least 800x600px." + ] + } + ]; + + guidanceTargets.forEach(({ selector, lines }) => { + document.querySelectorAll(selector).forEach((input) => { + window.AdminFormHelpers.renderUploadGuidance(input, { + for: input.id || input.name || "", + title: "Upload guidance", + lines + }); + }); + }); + } + + function showFormView() { + document.getElementById("tableContainer").style.display = "none"; + document.getElementById("formContainer").style.display = "block"; + document.getElementById("viewContainer").style.display = "none"; + refreshVisaAdminHelpers(); + } function showViewView() { document.getElementById("tableContainer").style.display = "none"; @@ -795,12 +853,13 @@ } } - - showFormView(); - - } catch (error) { - console.error("Error:", error); - showNotification('Cannot connect to server. Please try again.', 'error'); + + showFormView(); + refreshVisaAdminHelpers(); + + } catch (error) { + console.error("Error:", error); + showNotification('Cannot connect to server. Please try again.', 'error'); } } @@ -892,9 +951,10 @@ if (field) field.value = ""; }); - - showFormView(); - }); + + showFormView(); + refreshVisaAdminHelpers(); + }); document.getElementById("btnBackToList").addEventListener("click", showListView); document.getElementById("btnBackFromView").addEventListener("click", showListView); @@ -1031,4 +1091,4 @@ btnSave.innerHTML = 'Save'; } }); - \ No newline at end of file + diff --git a/views/layouts/admin.ejs b/views/layouts/admin.ejs index d566c7e..fec2383 100644 --- a/views/layouts/admin.ejs +++ b/views/layouts/admin.ejs @@ -12,6 +12,8 @@ + +