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 = ` +
/contact./contact.