diff --git a/.env.example b/.env.example index ad8f4a5..797edcb 100644 Binary files a/.env.example and b/.env.example differ diff --git a/.gitignore b/.gitignore index cb92d84..d701744 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,7 @@ pids #cursor .cursor -package-lock.json \ No newline at end of file +package-lock.json +/.omc +CLAUDE.md +/.claude diff --git a/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..9a4211a 100644 --- a/controllers/aboutUsController.js +++ b/controllers/aboutUsController.js @@ -1,11 +1,186 @@ +const mongoose = require("mongoose"); 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 ABOUT_NEWS_FALLBACK_THUMBNAIL = "/uploads/blog/7281.jpg"; +const ABOUT_NEWS_FALLBACK_AVATAR = "/assets/img/home-1/news/client.png"; +const ABOUT_NEWS_PLACEHOLDER_THUMBNAIL = + "img/inner-page/news-details/details-1.jpg"; +const ABOUT_MISSION_ITEM_ICON = "/assets/img/home-1/icon/01.svg"; + +const normalizePath = (value) => { + if (!value || typeof value !== "string") return ""; + if (value.startsWith("http://") || value.startsWith("https://")) return value; + if (value.startsWith("/")) return value; + return `/${value}`; +}; + +const isPlaceholderNewsThumbnail = (value) => { + if (!value || typeof value !== "string") return true; + + const normalized = value.trim().toLowerCase(); + return ( + normalized === ABOUT_NEWS_PLACEHOLDER_THUMBNAIL || + normalized === `/${ABOUT_NEWS_PLACEHOLDER_THUMBNAIL}` || + normalized.endsWith("/inner-page/news-details/details-1.jpg") || + normalized.endsWith("news-details/details-1.jpg") + ); +}; + +const sanitizeAboutSection = (section = {}, allowedKeys = []) => { + const sanitized = {}; + + allowedKeys.forEach((key) => { + if (section[key] !== undefined) { + sanitized[key] = section[key]; + } + }); + + return sanitized; +}; + +const sanitizeMissionItems = (items) => { + if (!Array.isArray(items)) return []; + + return items + .map((item = {}) => ({ + icon: ABOUT_MISSION_ITEM_ICON, + label: + typeof item.label === "string" + ? item.label + : typeof item.title === "string" + ? item.title + : "", + description: typeof item.description === "string" ? item.description : "", + })) + .filter((item) => item.label || item.description); +}; + +const buildCanonicalAboutData = (data) => { + const source = data || {}; + + return { + hero: sanitizeAboutSection(source.hero, [ + "title", + "breadcrumb", + "backgroundImage", + ]), + intro: sanitizeAboutSection(source.intro, [ + "subheading", + "heading", + "description", + "image", + ]), + mission: { + ...sanitizeAboutSection(source.mission, [ + "subheading", + "heading", + "description", + "features", + "ctaButton", + ]), + items: sanitizeMissionItems(source.mission?.items), + images: sanitizeAboutSection(source.mission?.images, [ + "main", + "secondary", + ]), + }, + features: { + ...sanitizeAboutSection(source.features, [ + "backgroundImage", + "subheading", + "heading", + "description", + "image", + "items", + "ctaButton", + ]), + }, + news: { + ...sanitizeAboutSection(source.news, [ + "subheading", + "heading", + "ctaButton", + "selectedBlogIds", + ]), + items: Array.isArray(source.news?.items) ? source.news.items : [], + }, + }; +}; + +const resolveBlogImage = (value, fallback = ABOUT_NEWS_FALLBACK_THUMBNAIL) => { + if (!value || isPlaceholderNewsThumbnail(value)) { + return fallback; + } + + return normalizePath(value); +}; + +const toObjectId = (value) => { + const stringValue = value && typeof value.toString === "function" ? value.toString() : ""; + return mongoose.isValidObjectId(stringValue) + ? new mongoose.Types.ObjectId(stringValue) + : null; +}; + +const resolveNewsItem = (blog, index = 0) => { + const fallbackThumbs = [ + ABOUT_NEWS_FALLBACK_THUMBNAIL, + "/uploads/about/news-1.jpg", + "/uploads/about/news-3.jpg", + ]; + + return { + title: blog.title, + category: blog.category && blog.category[0] ? blog.category[0] : "Visa", + date: + blog.publishedAt || + new Date(blog.createdAt).toLocaleDateString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + }), + comments: blog.commentsCount || 0, + author: { + name: blog.author || "Admin", + avatar: ABOUT_NEWS_FALLBACK_AVATAR, + }, + link: `/blog/${blog.slug}`, + thumbnail: resolveBlogImage( + blog.featuredImage, + fallbackThumbs[index % fallbackThumbs.length], + ), + }; +}; + +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) @@ -18,26 +193,49 @@ exports.getAbout = async (req, res) => { res.setHeader("Expires", "0"); const data = await AboutUs.getSingle(); - const rawData = data.toObject(); + const rawData = buildCanonicalAboutData(data.toObject()); // === Dynamic Blog News Section === const news = rawData.news || {}; + const selectedBlogIds = Array.isArray(news.selectedBlogIds) + ? news.selectedBlogIds.map((id) => id.toString()) + : []; + const selectedObjectIds = selectedBlogIds + .map((id) => toObjectId(id)) + .filter(Boolean); let blogs = []; // Nếu có chọn blog cụ thể - if (news.selectedBlogIds && news.selectedBlogIds.length > 0) { - blogs = await Blog.find({ - _id: { $in: news.selectedBlogIds }, + if (selectedBlogIds.length > 0) { + const selectedQuery = await Blog.find({ + _id: { $in: selectedObjectIds }, status: "published", }).lean(); // Sắp xếp theo thứ tự đã chọn trong selectedBlogIds - blogs.sort((a, b) => { + selectedQuery.sort((a, b) => { return ( - news.selectedBlogIds.indexOf(a._id.toString()) - - news.selectedBlogIds.indexOf(b._id.toString()) + selectedBlogIds.indexOf(a._id.toString()) - + selectedBlogIds.indexOf(b._id.toString()) ); }); + + blogs = selectedQuery.slice(0, 3); + + if (blogs.length < 3) { + const missingCount = 3 - blogs.length; + const extraBlogs = await Blog.find({ + status: "published", + _id: { + $nin: [...blogs.map((blog) => blog._id), ...selectedObjectIds], + }, + }) + .sort({ createdAt: -1 }) + .limit(missingCount) + .lean(); + + blogs = [...blogs, ...extraBlogs]; + } } // Nếu không chọn hoặc chọn nhưng không đủ, lấy thêm 3 bài mới nhất @@ -49,24 +247,7 @@ exports.getAbout = async (req, res) => { } // Map dữ liệu blog sang format mà frontend mong đợi - news.items = blogs.map((blog) => ({ - title: blog.title, - category: blog.category && blog.category[0] ? blog.category[0] : "Visa", - date: - blog.publishedAt || - new Date(blog.createdAt).toLocaleDateString("en-GB", { - day: "numeric", - month: "long", - year: "numeric", - }), - comments: blog.commentsCount || 0, - author: { - name: blog.author || "Admin", - avatar: "/assets/img/home-1/news/client.png", // Default avatar - }, - link: `/blog/${blog.slug}`, - thumbnail: blog.featuredImage, - })); + news.items = blogs.slice(0, 3).map((blog, index) => resolveNewsItem(blog, index)); rawData.news = news; // =============================== @@ -110,6 +291,13 @@ exports.updateAbout = async (req, res) => { // ✅ Capture BEFORE state const beforeData = JSON.parse(JSON.stringify(doc.toObject())); + updateData = buildCanonicalAboutData(updateData); + + 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(); @@ -137,16 +325,18 @@ exports.updateAbout = async (req, res) => { } // Fetch fresh data for syncing and returning - const finalData = await AboutUs.findOne() + const finalData = buildCanonicalAboutData( + await AboutUs.findOne() .select("-_id -__v -createdAt -updatedAt") - .lean(); + .lean(), + ); // Update about.json file to keep it in sync jsonHelper.writeJsonFile("about", finalData); res.json({ success: true, - message: "About Us updated successfully", + message: "About page updated successfully", data: finalData, }); } catch (error) { @@ -164,7 +354,7 @@ exports.updateAbout = async (req, res) => { exports.index = async (req, res) => { try { const data = await AboutUs.getSingle(); - const rawData = data.toObject(); + const rawData = buildCanonicalAboutData(data.toObject()); // Lấy tất cả blog để chọn trong CMS const allBlogs = await Blog.find({ status: "published" }) @@ -174,7 +364,7 @@ exports.index = async (req, res) => { const activeTab = req.query.activeTab || "hero"; res.render("admin/aboutUs/index", { layout: "layouts/main", - title: "About Us Management", + title: "About Page Management", data: rawData, allBlogs, activeTab, @@ -185,7 +375,7 @@ exports.index = async (req, res) => { }); } catch (err) { console.error("Error in about index:", err); - req.flash("error_msg", "Error loading About Us page"); + req.flash("error_msg", "Error loading About page"); res.redirect("/admin/dashboard"); } }; @@ -206,10 +396,18 @@ exports.update = async (req, res) => { } const doc = await AboutUs.getSingle(); + updateData = buildCanonicalAboutData(updateData); // ✅ 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(); @@ -230,17 +428,19 @@ exports.update = async (req, res) => { }); } - const finalData = await AboutUs.findOne() + const finalData = buildCanonicalAboutData( + await AboutUs.findOne() .select("-_id -__v -createdAt -updatedAt") - .lean(); + .lean(), + ); jsonHelper.writeJsonFile("about", finalData); - req.flash("success_msg", "About Us updated successfully"); + req.flash("success_msg", "About page updated successfully"); const activeTab = req.query.activeTab || "hero"; res.redirect(`/admin/about-us?activeTab=${activeTab}`); } catch (err) { console.error("Update error:", err); - req.flash("error_msg", "Error updating About Us: " + err.message); + req.flash("error_msg", "Error updating About page: " + err.message); res.redirect("/admin/about-us"); } }; 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/footerController.js b/controllers/footerController.js index 3f0212e..2357d37 100644 --- a/controllers/footerController.js +++ b/controllers/footerController.js @@ -4,6 +4,77 @@ const writeAuditLog = require("../audit/writeAuditLog"); const diffObject = require("../audit/diffObject"); const AUDIT_ACTIONS = require("../constants/auditAction"); +const isPlainObject = (value) => + value && typeof value === "object" && !Array.isArray(value); + +const normalizeFooterImagePath = (value) => { + if (!value || typeof value !== "string") return ""; + + const trimmed = value.trim(); + if (!trimmed) return ""; + + if (trimmed.startsWith("/uploads/footer/")) { + return trimmed; + } + + if (trimmed.startsWith("uploads/footer/")) { + return `/${trimmed}`; + } + + if (/^https?:\/\//i.test(trimmed)) { + try { + const parsed = new URL(trimmed); + if (parsed.pathname.startsWith("/uploads/footer/")) { + return parsed.pathname; + } + } catch (error) { + console.warn("Failed to parse footer image URL:", trimmed, error.message); + } + } + + return trimmed; +}; + +const sanitizeFooterData = (data = {}) => { + const sanitized = JSON.parse(JSON.stringify(data || {})); + + if (!sanitized.top) { + return sanitized; + } + + if (typeof sanitized.top.bgImage === "string") { + sanitized.top.bgImage = normalizeFooterImagePath(sanitized.top.bgImage); + } + + if (sanitized.top.logo && typeof sanitized.top.logo.src === "string") { + sanitized.top.logo.src = normalizeFooterImagePath(sanitized.top.logo.src); + } + + return sanitized; +}; + +const mergeFooterData = (currentValue, incomingValue) => { + if (incomingValue === undefined) { + return currentValue; + } + + if (Array.isArray(incomingValue)) { + return incomingValue; + } + + if (isPlainObject(currentValue) && isPlainObject(incomingValue)) { + const merged = { ...currentValue }; + + Object.keys(incomingValue).forEach((key) => { + merged[key] = mergeFooterData(currentValue[key], incomingValue[key]); + }); + + return merged; + } + + return incomingValue; +}; + // GET /api/footer - Public API cho website và CMS load dữ liệu exports.getFooter = async (req, res) => { try { @@ -42,6 +113,8 @@ exports.updateFooter = async (req, res) => { } } + updateData = sanitizeFooterData(updateData); + // Lấy footer hiện tại hoặc tạo mới (giống Header logic) let footer = await Footer.findOne(); @@ -52,8 +125,9 @@ exports.updateFooter = async (req, res) => { console.log("✓ Footer created:", footer._id); } else { console.log("✓ Found existing footer:", footer._id); - // Merge với dữ liệu cũ thay vì overwrite (giống Header) - Object.assign(footer, updateData); + const mergedData = mergeFooterData(footer.toObject(), updateData); + // Use set() so nested footer fields persist correctly in MongoDB + footer.set(mergedData); await footer.save(); console.log("✓ Footer updated successfully"); } @@ -80,11 +154,10 @@ exports.updateFooter = async (req, res) => { exports.index = async (req, res) => { try { const data = await Footer.getSingle(); - const processedData = addBaseUrlToImages(data.toObject()); res.render("admin/footer/index", { title: "Footer Management", - data: processedData, + data: data.toObject(), }); } catch (error) { console.error("Error in footer index:", error); @@ -114,6 +187,8 @@ exports.update = async (req, res) => { } } + updateData = sanitizeFooterData(updateData); + // Lấy footer hiện tại hoặc tạo mới (giống Header) let footer = await Footer.findOne(); @@ -130,8 +205,9 @@ exports.update = async (req, res) => { req.flash("success_msg", "Footer created successfully"); } else { console.log("✓ Found existing footer:", footer._id); - // Merge với dữ liệu cũ (giống Header) - Object.assign(footer, updateData); + const mergedData = mergeFooterData(footer.toObject(), updateData); + // Use set() so nested footer fields persist correctly in MongoDB + footer.set(mergedData); await footer.save(); // ✅ Capture AFTER state @@ -166,4 +242,14 @@ exports.update = async (req, res) => { // Legacy API endpoints (giữ lại cho tương thích) exports.api = exports.getFooter; -exports.getFooterData = exports.getFooter; +exports.getFooterData = async (req, res) => { + try { + const footer = await Footer.getSingle(); + res.json(footer.toObject()); + } catch (error) { + console.error("Error getting footer data for admin:", error); + res.status(500).json({ + error: "Failed to get footer data", + }); + } +}; 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/headerController.js b/controllers/headerController.js index a237ffb..4d74f5c 100644 --- a/controllers/headerController.js +++ b/controllers/headerController.js @@ -42,6 +42,7 @@ exports.index = async (req, res) => { socialLinks: header.top?.socialLinks || [], }, logo: header.logo?.light || "", + header: header, } : { topbar: { @@ -53,6 +54,7 @@ exports.index = async (req, res) => { socialLinks: [], }, logo: "", + header: null, }; const activeTab = req.query.tab || "topbar"; @@ -172,40 +174,28 @@ exports.update = async (req, res) => { location: parsedData.contactInfo?.location || "", socialLinks: parsedData.socialLinks || [], }; + } catch (parseErr) { + console.error("✗ Error parsing topbarJson:", parseErr); + } + } - if (logo) { - updateData.logo = logoData; - } - - console.log( - "Preparing to update header with data:", - JSON.stringify(updateData, null, 2), - ); - - const updatedHeader = await Header.findByIdAndUpdate( - headerId, - updateData, - { new: true, runValidators: true }, - ); - - if (!updatedHeader) { - console.error("✗ Header not found with ID:", headerId); - return res.status(404).json({ - success: false, - message: "Header not found", - }); - } - res.json({ - success: true, - message: "Header updated successfully", - data: updatedHeader, - }); - } catch (error) { - console.error("✗ Error updating header:", error); - res.status(400).json({ - success: false, - message: error.message, - }); + // Nếu có offcanvasJson, parse nó + const { offcanvasJson } = req.body; + if (offcanvasJson && typeof offcanvasJson === "string") { + try { + const parsedOffcanvas = JSON.parse(offcanvasJson); + console.log("✓ Parsed offcanvasJson successfully:", parsedOffcanvas); + offcanvas = { + description: parsedOffcanvas.description || "", + contactInfo: { + address: parsedOffcanvas.contactInfo?.address || "", + email: parsedOffcanvas.contactInfo?.email || "", + workingHours: parsedOffcanvas.contactInfo?.workingHours || "", + phone: parsedOffcanvas.contactInfo?.phone || "", + }, + }; + } catch (parseErr) { + console.error("✗ Error parsing offcanvasJson:", parseErr); } } diff --git a/controllers/headerMenuController.js b/controllers/headerMenuController.js index 0c1ddfb..07074e5 100644 --- a/controllers/headerMenuController.js +++ b/controllers/headerMenuController.js @@ -1,6 +1,36 @@ const HeaderMenu = require("../models/headerMenu"); const slugify = require("slugify"); +const parseBooleanFlag = (value) => { + if (typeof value === "boolean") { + return value; + } + + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + return ["true", "1", "on", "yes"].includes(normalized); + } + + return false; +}; + +const normalizeInternalUrl = (url = "") => { + if (typeof url !== "string") { + return null; + } + + const trimmed = url.trim(); + if (!trimmed || !trimmed.startsWith("/")) { + return null; + } + + if (trimmed === "/") { + return "/"; + } + + return trimmed.replace(/\/+$/, ""); +}; + /** * Helper: Build tree structure from flat array */ @@ -19,8 +49,10 @@ const buildMenuTree = (items, parentId = null, isPublic = false) => { cleanItem = { id: item._id, title: item.title, - url: item.url, + url: item.is_maintainance ? "/maintenance" : item.url, + originalUrl: item.url, type: item.type, + is_maintainance: Boolean(item.is_maintainance), }; } @@ -59,7 +91,7 @@ exports.store = async (req, res) => { try { console.log("=== BACKEND: store hit ==="); console.log("Body:", req.body); - const { title, url, parentId, order, status, type } = req.body; + const { title, url, parentId, order, status, type, is_maintainance } = req.body; const slug = slugify(title, { lower: true, strict: true }); const newItem = new HeaderMenu({ @@ -70,6 +102,7 @@ exports.store = async (req, res) => { order: order || 0, status: status || "active", type: type || "internal", + is_maintainance: parseBooleanFlag(is_maintainance), }); const savedItem = await newItem.save(); @@ -101,7 +134,7 @@ exports.update = async (req, res) => { const { id } = req.params; console.log("=== BACKEND: update hit ===", { id }); console.log("Body:", req.body); - const { title, url, parentId, order, status, type } = req.body; + const { title, url, parentId, order, status, type, is_maintainance } = req.body; const updateData = { url, @@ -109,6 +142,7 @@ exports.update = async (req, res) => { order, status, type, + is_maintainance: parseBooleanFlag(is_maintainance), }; if (title) { @@ -203,3 +237,33 @@ exports.api = async (req, res) => { res.status(500).json({ success: false, message: error.message }); } }; + +exports.maintenanceStatus = async (req, res) => { + try { + const items = await HeaderMenu.find({ + status: "active", + is_maintainance: true, + }) + .select("title url slug") + .sort({ order: 1 }) + .lean(); + + const urls = [...new Set(items.map((item) => normalizeInternalUrl(item.url)).filter(Boolean))]; + + res.json({ + success: true, + data: { + enabled: items.length > 0, + urls, + items: items.map((item) => ({ + id: String(item._id), + title: item.title, + slug: item.slug, + url: item.url, + })), + }, + }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } +}; diff --git a/controllers/homeController.js b/controllers/homeController.js index e070c6c..5f8bc22 100644 --- a/controllers/homeController.js +++ b/controllers/homeController.js @@ -1,4 +1,7 @@ -const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper"); +const { + addBaseUrlToImages, + getFullImageUrl, +} = require("../utils/imageHelper"); const Home = require("../models/home"); const Blog = require("../models/blog"); const writeAuditLog = require("../audit/writeAuditLog"); @@ -7,7 +10,210 @@ const AUDIT_ACTIONS = require("../constants/auditAction"); // Các hàm hỗ trợ const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 }); +const getAllHomeDocs = async () => Home.find().sort({ updatedAt: -1 }); const getHomeData = async () => (await getHomeDoc())?.toObject() || {}; +const getOrCreateHomeDoc = async () => { + let doc = await getHomeDoc(); + + if (!doc) { + doc = new Home(getDefaultHomeData()); + } + + return doc; +}; + +const hasMeaningfulValue = (value) => + typeof value === "string" && value.trim().length > 0; + +const getMeaningfulHeroSlides = (hero = {}) => { + if (!Array.isArray(hero.slides)) return []; + + return hero.slides.filter((slide = {}) => { + return ( + hasMeaningfulValue(slide.title) || + hasMeaningfulValue(slide.subtitle) || + hasMeaningfulValue(slide.description) || + hasMeaningfulValue(slide.heroImage) || + hasMeaningfulValue(slide.videoUrl) || + hasMeaningfulValue(slide.primaryButton?.label) || + hasMeaningfulValue(slide.primaryButton?.href) || + hasMeaningfulValue(slide.secondaryButton?.label) || + hasMeaningfulValue(slide.secondaryButton?.href) + ); + }); +}; + +const scoreHeroSlides = (slides = []) => + slides.reduce((score, slide = {}) => { + return ( + score + + (slide.title || "").trim().length * 3 + + (slide.subtitle || "").trim().length + + (slide.description || "").trim().length * 2 + + (slide.primaryButton?.label || "").trim().length + + (slide.primaryButton?.href || "").trim().length + + (slide.secondaryButton?.label || "").trim().length + + (slide.secondaryButton?.href || "").trim().length + + (hasMeaningfulValue(slide.heroImage) ? 20 : 0) + + (hasMeaningfulValue(slide.videoUrl) ? 12 : 0) + ); + }, 0); + +const scoreHeroData = (hero = {}) => { + const slides = getMeaningfulHeroSlides(hero); + + if (slides.length > 0) { + return scoreHeroSlides(slides) + slides.length * 30; + } + + return ( + (hero.title || "").trim().length * 3 + + (hero.subtitle || "").trim().length + + (hero.description || "").trim().length * 2 + + (hero.primaryButton?.label || "").trim().length + + (hero.primaryButton?.href || "").trim().length + + (hero.secondaryButton?.label || "").trim().length + + (hero.secondaryButton?.href || "").trim().length + + (hasMeaningfulValue(hero.heroImage) ? 20 : 0) + + (hasMeaningfulValue(hero.videoUrl) ? 12 : 0) + ); +}; + +const getPreferredHeroData = (docs = []) => { + const heroes = docs + .map((doc) => doc?.hero) + .filter(Boolean); + + if (!heroes.length) return {}; + + return heroes.reduce((bestHero, currentHero) => { + return scoreHeroData(currentHero) > scoreHeroData(bestHero) + ? currentHero + : bestHero; + }, heroes[0]); +}; + +const normalizeStoredImagePath = (imagePath) => { + if (!imagePath || typeof imagePath !== "string") return ""; + + const raw = imagePath.trim(); + if (!raw) return ""; + + const knownPrefixes = ["/uploads/", "/assets/", "/img/"]; + + for (const prefix of knownPrefixes) { + const index = raw.indexOf(prefix); + if (index >= 0) { + return raw.slice(index); + } + } + + if (raw.startsWith("/")) { + return raw; + } + + return `/${raw}`; +}; + +const getDefaultFloatingContactData = () => ({ + enabled: true, + position: "bottom-right", + panelTitle: "Do you need any additional advice or support?", + brand: { + imageSrc: "/assets/img/logo/black-logo.svg", + imageAlt: "HAI Learning", + }, + trigger: { + imageSrc: "", + icon: "fa-comments", + }, + actions: [ + { + id: "facebook", + platform: "facebook", + enabled: true, + 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", + iconType: "iconClass", + iconClass: "fa-brands fa-facebook-messenger", + iconText: "", + order: 1, + }, + { + id: "zalo", + platform: "zalo", + enabled: true, + label: "Message via Zalo", + subtitle: "zalo.me/84961834040", + href: "https://zalo.me/84961834040", + iconImage: "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp", + iconType: "iconText", + iconClass: "", + iconText: "Zalo", + order: 2, + }, + ], +}); + +const normalizeFloatingContactData = (payload = {}) => { + const defaults = getDefaultFloatingContactData(); + const brand = payload.brand || {}; + const trigger = payload.trigger || {}; + const hasProvidedActions = Array.isArray(payload.actions); + const rawActions = hasProvidedActions ? payload.actions : []; + + const actions = rawActions + .map((action, index) => ({ + id: String(action.id || `${action.platform || "action"}-${index + 1}`), + platform: String(action.platform || "").trim(), + enabled: action.enabled !== false, + label: String(action.label || "").trim(), + subtitle: String(action.subtitle || "").trim(), + href: String(action.href || "").trim(), + iconImage: normalizeStoredImagePath(String(action.iconImage || "").trim()), + iconType: action.iconType === "iconText" ? "iconText" : "iconClass", + iconClass: String(action.iconClass || "").trim(), + iconText: String(action.iconText || "").trim(), + order: Number.isFinite(Number(action.order)) ? Number(action.order) : index + 1, + })) + .filter((action) => { + return ( + action.platform || + action.label || + action.subtitle || + action.href || + action.iconImage || + action.iconClass || + action.iconText + ); + }) + .sort((a, b) => a.order - b.order) + .map((action, index) => ({ + ...action, + order: index + 1, + })); + + return { + enabled: payload.enabled !== false, + position: payload.position || defaults.position, + panelTitle: String(payload.panelTitle || defaults.panelTitle).trim(), + brand: { + imageSrc: normalizeStoredImagePath( + String(brand.imageSrc || defaults.brand.imageSrc).trim(), + ), + imageAlt: String(brand.imageAlt || defaults.brand.imageAlt).trim(), + }, + trigger: { + imageSrc: normalizeStoredImagePath( + String(trigger.imageSrc || "").trim(), + ), + icon: String(trigger.icon || defaults.trigger.icon).trim() || defaults.trigger.icon, + }, + actions: hasProvidedActions ? actions : defaults.actions, + }; +}; const getDefaultHomeData = () => ({ hero: { @@ -64,6 +270,7 @@ const getDefaultHomeData = () => ({ items: [], selectedBlogIds: [], // Array of manually selected blog IDs }, + floatingContact: getDefaultFloatingContactData(), }); // Admin: Xem trang quản lý @@ -77,9 +284,10 @@ exports.index = async (req, res) => { sections.forEach((s) => { data[s] = data[s] || defaults[s]; }); + data.floatingContact = normalizeFloatingContactData(data.floatingContact); const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - const backendUrl = process.env.BACKEND_URL || "http://localhost:3001"; + const backendUrl = `${req.protocol}://${req.get("host")}`; // Lấy tất cả blog để chọn trong CMS const allBlogs = await Blog.find({ status: "published" }) @@ -118,6 +326,7 @@ exports.update = async (req, res) => { "achievements", "partners", "blogPreview", + "floatingContact", ]; let doc = await getHomeDoc(); @@ -135,7 +344,10 @@ exports.update = async (req, res) => { try { const payload = JSON.parse(req.body[section]); // Gán trực tiếp vào doc, Mongoose sẽ tự check schema - doc[section] = payload; + doc[section] = + section === "floatingContact" + ? normalizeFloatingContactData(payload) + : payload; doc.markModified(section); hasChanges = true; updatedSections.push(section); @@ -176,6 +388,49 @@ exports.update = async (req, res) => { } }; +exports.updateFloatingContact = async (req, res) => { + try { + const payload = + typeof req.body?.floatingContact === "string" + ? JSON.parse(req.body.floatingContact) + : req.body?.floatingContact || req.body; + + const doc = await getOrCreateHomeDoc(); + const beforeData = JSON.parse(JSON.stringify(doc.toObject())); + + doc.floatingContact = normalizeFloatingContactData(payload); + doc.markModified("floatingContact"); + await doc.save(); + + const afterData = JSON.parse(JSON.stringify(doc.toObject())); + const changes = diffObject(beforeData, afterData); + + if (changes.length > 0) { + await writeAuditLog({ + model: "Home", + documentId: doc._id, + action: AUDIT_ACTIONS.UPDATE_HOME, + before: beforeData, + after: afterData, + changes, + req, + }); + } + + return res.status(200).json({ + success: true, + message: "Floating contact updated successfully", + floatingContact: doc.floatingContact, + }); + } catch (err) { + console.error("Floating contact update error:", err); + return res.status(500).json({ + success: false, + error: err.message || "Failed to update floating contact", + }); + } +}; + // Public API// API lấy danh sách blog cho CMS exports.apiGetBlogs = async (req, res) => { try { @@ -190,9 +445,10 @@ exports.apiGetBlogs = async (req, res) => { }; exports.api = async (req, res) => { try { - let data = await getHomeData(); - const baseUrl = - process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`; + // Chỉ dùng doc mới nhất, không merge nhiều docs + const doc = await getHomeDoc(); + let data = doc?.toObject() || {}; + const baseUrl = `${req.protocol}://${req.get("host")}`; // === Xử lý Blog Preview động === const blogPreview = data.blogPreview || {}; @@ -238,6 +494,7 @@ exports.api = async (req, res) => { })); data.blogPreview = blogPreview; + data.floatingContact = normalizeFloatingContactData(data.floatingContact); // =============================== const processed = addBaseUrlToImages(data, baseUrl); 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/serviceController.js b/controllers/serviceController.js index 9c25c63..9cf24a0 100644 --- a/controllers/serviceController.js +++ b/controllers/serviceController.js @@ -1,5 +1,6 @@ const { getServiceData } = require("../services/service.service"); const Service = require("../models/service"); +const syncServiceMenu = require("../services/syncServiceMenu"); const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper"); const writeAuditLog = require("../audit/writeAuditLog"); const diffObject = require("../audit/diffObject"); @@ -98,6 +99,8 @@ exports.updateService = async (req, res) => { changes, req, }); + // Sync header menu children to reflect updated service name/slug + await syncServiceMenu(updatedData.services?.items || []); req.flash("success_msg", "Service updated successfully"); res.redirect("/admin/service"); } catch (err) { @@ -168,6 +171,9 @@ exports.update = async (req, res) => { await Service.create(updatedData); } + // Sync header menu children to reflect current service list + await syncServiceMenu(updatedData.services?.items || []); + req.flash("success_msg", "Service updated successfully"); res.redirect("/admin/service"); } catch (err) { 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/controllers/uploadController.js b/controllers/uploadController.js index ef6f4c8..ff8042f 100644 --- a/controllers/uploadController.js +++ b/controllers/uploadController.js @@ -1,7 +1,102 @@ const path = require('path'); const fs = require('fs'); +const sharp = require('sharp'); const jsonHelper = require('../utils/jsonHelper'); +const imageResizePresets = { + floatingContactBrandImage: { width: 104, height: 104, quality: 88 }, + floatingContactTriggerIcon: { width: 96, height: 96, quality: 88 }, + floatingContactActionIcon: { width: 84, height: 84, quality: 88 }, +}; + +const isSvgFile = (filePath) => path.extname(filePath).toLowerCase() === '.svg'; + +function scheduleTemporaryFileCleanup(filePath, attemptsLeft = 5, delayMs = 250) { + if (!filePath || attemptsLeft <= 0) { + return; + } + + setTimeout(() => { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch (cleanupError) { + scheduleTemporaryFileCleanup(filePath, attemptsLeft - 1, delayMs * 2); + } + }, delayMs); +} + +function getFinalUploadTarget(file, req, forceWebp = false) { + const parsedPath = path.parse(file.path); + const requestedFileName = + req.uploadFinalFileName || + file.filename.replace('.__upload__', ''); + const parsedRequestedFileName = path.parse(requestedFileName); + const finalFileName = forceWebp + ? `${parsedRequestedFileName.name}.webp` + : requestedFileName; + const finalDirectory = req.uploadFinalDirectory || parsedPath.dir; + + return { + finalFileName, + finalPath: path.join(finalDirectory, finalFileName), + }; +} + +async function finalizeUploadedImage(file, req, resizePreset) { + const preset = imageResizePresets[resizePreset]; + + if (!file) { + return file; + } + + if (!preset || isSvgFile(file.path)) { + const { finalFileName, finalPath } = getFinalUploadTarget(file, req); + + if (path.resolve(file.path) !== path.resolve(finalPath)) { + fs.renameSync(file.path, finalPath); + } + + return { + ...file, + filename: finalFileName, + path: finalPath, + }; + } + + const { finalFileName, finalPath } = getFinalUploadTarget(file, req, true); + const finalPathMatchesInput = path.resolve(file.path) === path.resolve(finalPath); + + const sourceBuffer = fs.readFileSync(file.path); + const optimizedBuffer = await sharp(sourceBuffer) + .resize(preset.width, preset.height, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + withoutEnlargement: true, + }) + .webp({ quality: preset.quality }) + .toBuffer(); + + fs.writeFileSync(finalPath, optimizedBuffer); + + if (!finalPathMatchesInput && fs.existsSync(file.path)) { + try { + fs.unlinkSync(file.path); + } catch (cleanupError) { + console.warn('Unable to remove original uploaded image after optimization:', cleanupError.message); + scheduleTemporaryFileCleanup(file.path); + } + } + + return { + ...file, + filename: finalFileName, + path: finalPath, + mimetype: 'image/webp', + }; +} + // Controller xử lý upload ảnh const uploadController = { // Upload ảnh và trả về đường dẫn @@ -13,15 +108,14 @@ const uploadController = { // Lấy loại ảnh từ query params const imageType = req.query.imageType || 'general'; + const resizePreset = req.query.resizePreset || ''; + req.file = await finalizeUploadedImage(req.file, req, resizePreset); // Tạo đường dẫn tương đối để lưu vào database const relativePath = `/uploads/${imageType}/${req.file.filename}`; const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; const fullUrl = `${baseUrl}${relativePath}`; - // Kiểm tra nếu file đã tồn tại từ trước - const fileAlreadyExists = req.fileAlreadyExists || false; - // Nếu client yêu cầu cập nhật trực tiếp file JSON (ví dụ activities.json), // thì đồng bộ camps.image và camps.camp-detail.hero.bgImage try { @@ -60,8 +154,9 @@ const uploadController = { success: true, path: relativePath, url: fullUrl, - reused: fileAlreadyExists, - message: fileAlreadyExists ? 'Using existing file' : 'File uploaded successfully' + resizePreset: resizePreset || null, + reused: false, + message: 'File uploaded successfully' }); } catch (error) { console.error('Error uploading image:', error); @@ -225,4 +320,4 @@ const uploadController = { } }; -module.exports = uploadController; \ No newline at end of file +module.exports = uploadController; diff --git a/data/about.json b/data/about.json index 246a6c7..22c3e43 100644 --- a/data/about.json +++ b/data/about.json @@ -5,25 +5,25 @@ "Home", "About Us" ], - "backgroundImage": "/uploads/about/breadcrumb.jpg" + "backgroundImage": "/uploads/about/7281.jpg" }, "intro": { "subheading": "Company Intro", "heading": "Building Pathways to Your Immigration Success", "description": "We provide expert guidance, personalized solutions, and transparent processes to help you achieve your immigration goals. Our dedicated team ensures a smooth journey, building pathways to your international success.", - "image": "/uploads/about/businessman.jpg" + "image": "/uploads/about/7281.jpg" }, "mission": { - "subheading": "About Our Consultancy", - "heading": "Turning Study Abroad Dreams Into Reality", + "subheading": "About Our ConsultancyAbout Our Consultan", + "heading": "Turning Study Abroad Dreams Into RealityTurning Study Abroad Dreams Into", "description": "We guide students with expert visa consulting, ensuring a smooth process from application to approval, turning study abroad aspirations into life-changing opportunities for a brighter future.", - "images": { - "main": "/uploads/about/375x419.jpg", - "secondary": "/uploads/about/375x419.jpg", - "bgShape": "/assets/img/home-1/about/Vector.png", - "planeShape": "/assets/img/home-1/about/plane.png", - "topShape": "/assets/img/home-1/about/shape.png", - "globeShape": "/assets/img/home-1/about/globe.png" + "features": [ + "Fastest Visa form processing with skilled immigration agents", + "Partnership with International Educational Institutions" + ], + "ctaButton": { + "label": "Get StartedGet StartedGet Starte", + "href": "/about" }, "items": [ { @@ -37,40 +37,36 @@ "description": "Expanding Opportunities Worldwide" } ], - "features": [ - "Fastest Visa form processing with skilled immigration agents", - "Partnership with International Educational Institutions" - ], - "ctaButton": { - "label": "Get Started", - "href": "/about" + "images": { + "main": "/uploads/about/7281.jpg", + "secondary": "/uploads/about/7281.jpg" } }, "features": { - "backgroundImage": "/assets/img/home-2/feature/bg-shape.png", - "subheading": "Your Travel Made Easy", - "heading": "Smooth Visa Journey Guaranteed", - "description": "We provide expert guidance for every visa application, ensuring smooth processing, personalized support, and reliable assistance", - "image": "/uploads/about/686x906.jpg", + "backgroundImage": "/uploads/about/7281.jpg", + "subheading": "Your Travel Made EasyYour Travel Made Ea", + "heading": "Smooth Visa Journey GuaranteedSmooth Visa Journey GuaranteedSmooth Visa", + "description": "We provide expert guidance for every visa application, ensuring smooth processing, personalized support, and reliable assistanceWe provide expert guidance for every visa application, ensuring smooth processing, personalized support, and reliable assistanceWe p", + "image": "/uploads/about/7281.jpg", "items": [ { - "icon": "/assets/img/home-2/icon/01.png", + "icon": "/uploads/about/7281.jpg", "title": "Expert Consultants", - "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors." + "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa" }, { - "icon": "/assets/img/home-2/icon/01.png", + "icon": "/uploads/about/7281.jpg", "title": "Personalized Support", - "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors." + "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa" }, { - "icon": "/assets/img/home-2/icon/01.png", + "icon": "/uploads/about/7281.jpg", "title": "Transparent Process", - "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors." + "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa" } ], "ctaButton": { - "label": "Get Started Today", + "label": "Get Started TodayGet Started Tod", "href": "/contact" } }, @@ -82,9 +78,9 @@ "href": "/blog" }, "selectedBlogIds": [ - "69857d6c6d04fed459107944", - "69857d6c6d04fed459107942", - "69857d6c6d04fed459107940" + "69d660e9f399cedf10d3b216", + "69d660e9f399cedf10d3b214", + "69d660e9f399cedf10d3b212" ], "items": [] } diff --git a/middleware/upload.js b/middleware/upload.js index 7808000..f9942a7 100644 --- a/middleware/upload.js +++ b/middleware/upload.js @@ -34,6 +34,14 @@ const storage = multer.diskStorage({ // Lấy tên file gốc (sanitize để tránh ký tự đặc biệt) const originalName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); + const resizePreset = req.query.resizePreset || ''; + if (resizePreset) { + const parsedOriginalName = path.parse(originalName); + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + req.uploadFinalFileName = originalName; + return cb(null, `${parsedOriginalName.name}.__upload__${uniqueSuffix}${parsedOriginalName.ext}`); + } + const fullPath = path.join(uploadPath, originalName); // Kiểm tra nếu file đã tồn tại @@ -159,4 +167,4 @@ module.exports = { upload, uploadVideo, convertToWebp -}; \ 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/contact.js b/models/contact.js index 26df236..26c9d3f 100644 --- a/models/contact.js +++ b/models/contact.js @@ -7,11 +7,13 @@ const heroSchema = new mongoose.Schema( type: String, required: true, trim: true, + maxlength: 40, }, backgroundImage: { type: String, trim: true, default: "", + maxlength: 255, }, overlayColor: { type: String, @@ -62,9 +64,10 @@ const contactCardSchema = new mongoose.Schema( type: String, required: true, trim: true, + maxlength: 40, }, content: { - type: [String], + type: [{ type: String, maxlength: 96 }], default: [], }, iconType: { @@ -72,6 +75,7 @@ const contactCardSchema = new mongoose.Schema( required: false, trim: true, default: "", + maxlength: 255, }, iconSource: { type: String, @@ -139,16 +143,19 @@ const mapSchema = new mongoose.Schema( type: String, required: true, trim: true, + maxlength: 120, }, markerTitle: { type: String, trim: true, default: "", + maxlength: 48, }, embedUrl: { type: String, trim: true, default: "", + maxlength: 1000, }, tileLayer: { type: tileLayerSchema, @@ -165,11 +172,13 @@ const formFieldSchema = new mongoose.Schema( type: String, required: true, trim: true, + maxlength: 32, }, label: { type: String, trim: true, default: "", + maxlength: 32, }, type: { type: String, @@ -181,6 +190,7 @@ const formFieldSchema = new mongoose.Schema( type: String, trim: true, default: "", + maxlength: 72, }, required: { type: Boolean, @@ -195,6 +205,7 @@ const formFieldSchema = new mongoose.Schema( type: String, trim: true, default: "", + maxlength: 48, }, }, { _id: false } @@ -207,6 +218,7 @@ const submitButtonSchema = new mongoose.Schema( type: String, required: true, trim: true, + maxlength: 24, }, icon: { type: String, @@ -229,16 +241,19 @@ const formSchema = new mongoose.Schema( type: String, trim: true, default: "", + maxlength: 32, }, heading: { type: String, trim: true, default: "", + maxlength: 48, }, description: { type: String, trim: true, default: "", + maxlength: 160, }, fields: { type: [formFieldSchema], diff --git a/models/headerMenu.js b/models/headerMenu.js index 13c8d3c..613030f 100644 --- a/models/headerMenu.js +++ b/models/headerMenu.js @@ -31,6 +31,10 @@ const HeaderMenuSchema = new mongoose.Schema({ enum: ['active', 'inactive'], default: 'active' }, + is_maintainance: { + type: Boolean, + default: false + }, type: { type: String, enum: ['internal', 'external'], @@ -43,6 +47,7 @@ const HeaderMenuSchema = new mongoose.Schema({ // Indexes for optimization HeaderMenuSchema.index({ order: 1 }); HeaderMenuSchema.index({ status: 1 }); +HeaderMenuSchema.index({ is_maintainance: 1 }); HeaderMenuSchema.index({ parentId: 1, order: 1 }); module.exports = mongoose.model('HeaderMenu', HeaderMenuSchema); diff --git a/models/home.js b/models/home.js index 09d3d52..6c310b6 100644 --- a/models/home.js +++ b/models/home.js @@ -5,8 +5,53 @@ const { Schema } = mongoose; // Reusable small schemas const LinkSchema = new Schema( { - label: { type: String, default: "" }, - href: { type: String, default: "" }, + label: { type: String, default: "", maxlength: 32 }, + href: { type: String, default: "", maxlength: 255 }, + }, + { _id: false }, +); + +const FloatingContactBrandSchema = new Schema( + { + imageSrc: { type: String, default: "", maxlength: 255 }, + imageAlt: { type: String, default: "", maxlength: 60 }, + }, + { _id: false }, +); + +const FloatingContactTriggerSchema = new Schema( + { + imageSrc: { type: String, default: "", maxlength: 255 }, + icon: { type: String, default: "fa-comments", maxlength: 64 }, + }, + { _id: false }, +); + +const FloatingContactActionSchema = new Schema( + { + id: { type: String, default: "" }, + platform: { type: String, default: "" }, + enabled: { type: Boolean, default: true }, + label: { type: String, default: "", maxlength: 48 }, + subtitle: { type: String, default: "", maxlength: 48 }, + href: { type: String, default: "", maxlength: 255 }, + iconImage: { type: String, default: "", maxlength: 255 }, + iconType: { type: String, default: "iconClass" }, + iconClass: { type: String, default: "", maxlength: 120 }, + iconText: { type: String, default: "", maxlength: 12 }, + order: { type: Number, default: 0 }, + }, + { _id: false }, +); + +const FloatingContactSchema = new Schema( + { + enabled: { type: Boolean, default: true }, + position: { type: String, default: "bottom-right" }, + panelTitle: { type: String, default: "", maxlength: 72 }, + brand: { type: FloatingContactBrandSchema, default: () => ({}) }, + trigger: { type: FloatingContactTriggerSchema, default: () => ({}) }, + actions: { type: [FloatingContactActionSchema], default: [] }, }, { _id: false }, ); @@ -14,48 +59,54 @@ const LinkSchema = new Schema( // Hero slide (for multiple hero items in slider) const HeroSlideSchema = new Schema( { - title: { type: String, default: "" }, - subtitle: { type: String, default: "" }, - description: { type: String, default: "" }, + title: { type: String, default: "", maxlength: 72 }, + subtitle: { type: String, default: "", maxlength: 48 }, + description: { type: String, default: "", maxlength: 220 }, primaryButton: { type: LinkSchema, default: () => ({}) }, secondaryButton: { type: LinkSchema, default: () => ({}) }, - heroImage: { type: String, default: "" }, - videoUrl: { type: String, default: "" }, + heroImage: { type: String, default: "", maxlength: 255 }, + videoUrl: { type: String, default: "", maxlength: 255 }, }, { _id: false }, ); const HeroSchema = new Schema( { + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + // Background for whole hero section - backgroundImage: { type: String, default: "" }, + backgroundImage: { type: String, default: "", maxlength: 255 }, // Multiple slides slides: { type: [HeroSlideSchema], default: [] }, // Legacy single-slide fields (backward compatible) - title: { type: String, default: "" }, - subtitle: { type: String, default: "" }, - description: { type: String, default: "" }, + title: { type: String, default: "", maxlength: 72 }, + subtitle: { type: String, default: "", maxlength: 48 }, + description: { type: String, default: "", maxlength: 220 }, primaryButton: { type: LinkSchema, default: () => ({}) }, secondaryButton: { type: LinkSchema, default: () => ({}) }, - heroImage: { type: String, default: "" }, - videoUrl: { type: String, default: "" }, + heroImage: { type: String, default: "", maxlength: 255 }, + videoUrl: { type: String, default: "", maxlength: 255 }, }, { _id: false }, ); const WhyChooseUsItemSchema = new Schema( { - icon: { type: String, default: "" }, - title: { type: String, default: "" }, - description: { type: String, default: "" }, + icon: { type: String, default: "", maxlength: 255 }, + title: { type: String, default: "", maxlength: 40 }, + description: { type: String, default: "", maxlength: 72 }, }, { _id: false }, ); const WhyChooseUsSchema = new Schema( { + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + heading: { type: String, default: "" }, subheading: { type: String, default: "" }, description: { type: String, default: "" }, @@ -63,7 +114,7 @@ const WhyChooseUsSchema = new Schema( mainImage: { type: String, default: "" }, secondaryImage: { type: String, default: "" }, items: { type: [WhyChooseUsItemSchema], default: [] }, - features: { type: [String], default: [] }, + features: { type: [{ type: String, maxlength: 96 }], default: [] }, ctaButton: { type: LinkSchema, default: () => ({}) }, }, { _id: false }, @@ -71,16 +122,19 @@ const WhyChooseUsSchema = new Schema( const VisaSolutionItemSchema = new Schema( { - number: { type: String, default: "" }, - title: { type: String, default: "" }, - description: { type: String, default: "" }, - link: { type: String, default: "" }, + number: { type: String, default: "", maxlength: 4 }, + title: { type: String, default: "", maxlength: 56 }, + description: { type: String, default: "", maxlength: 180 }, + link: { type: String, default: "", maxlength: 255 }, }, { _id: false }, ); const VisaSolutionsSchema = new Schema( { + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + heading: { type: String, default: "" }, subheading: { type: String, default: "" }, items: { type: [VisaSolutionItemSchema], default: [] }, @@ -90,17 +144,20 @@ const VisaSolutionsSchema = new Schema( const VisaCountrySchema = new Schema( { - name: { type: String, default: "" }, - code: { type: String, default: "" }, - flag: { type: String, default: "" }, - link: { type: String, default: "" }, - visaTypes: { type: [String], default: [] }, + name: { type: String, default: "", maxlength: 40 }, + code: { type: String, default: "", maxlength: 12 }, + flag: { type: String, default: "", maxlength: 255 }, + link: { type: String, default: "", maxlength: 255 }, + visaTypes: { type: [{ type: String, maxlength: 48 }], default: [] }, }, { _id: false }, ); const VisaCountriesSchema = new Schema( { + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + heading: { type: String, default: "" }, subheading: { type: String, default: "" }, description: { type: String, default: "" }, @@ -112,18 +169,21 @@ const VisaCountriesSchema = new Schema( const TestimonialSchema = new Schema( { - name: { type: String, default: "" }, - role: { type: String, default: "" }, - country: { type: String, default: "" }, + name: { type: String, default: "", maxlength: 48 }, + role: { type: String, default: "", maxlength: 48 }, + country: { type: String, default: "", maxlength: 48 }, rating: { type: Number, default: 5 }, - comment: { type: String, default: "" }, - avatar: { type: String, default: "" }, + comment: { type: String, default: "", maxlength: 280 }, + avatar: { type: String, default: "", maxlength: 255 }, }, { _id: false }, ); const TestimonialsSchema = new Schema( { + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + heading: { type: String, default: "" }, subheading: { type: String, default: "" }, videoUrl: { type: String, default: "" }, @@ -135,6 +195,9 @@ const TestimonialsSchema = new Schema( const VideoGallerySchema = new Schema( { + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + heading: { type: String, default: "" }, videoUrl: { type: String, default: "" }, thumbnail: { type: String, default: "" }, @@ -144,14 +207,17 @@ const VideoGallerySchema = new Schema( const FaqItemSchema = new Schema( { - question: { type: String, default: "" }, - answer: { type: String, default: "" }, + question: { type: String, default: "", maxlength: 120 }, + answer: { type: String, default: "", maxlength: 320 }, }, { _id: false }, ); const FaqSchema = new Schema( { + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + heading: { type: String, default: "" }, subheading: { type: String, default: "" }, description: { type: String, default: "" }, @@ -163,16 +229,19 @@ const FaqSchema = new Schema( const AchievementItemSchema = new Schema( { - value: { type: String, default: "" }, - suffix: { type: String, default: "" }, - label: { type: String, default: "" }, - description: { type: String, default: "" }, + value: { type: String, default: "", maxlength: 6 }, + suffix: { type: String, default: "", maxlength: 4 }, + label: { type: String, default: "", maxlength: 40 }, + description: { type: String, default: "", maxlength: 120 }, }, { _id: false }, ); const AchievementsSchema = new Schema( { + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + heading: { type: String, default: "" }, subheading: { type: String, default: "" }, items: { type: [AchievementItemSchema], default: [] }, @@ -182,9 +251,9 @@ const AchievementsSchema = new Schema( const VisaConsultancyItemSchema = new Schema( { - name: { type: String, default: "" }, - icon: { type: String, default: "" }, - year: { type: String, default: "" }, + name: { type: String, default: "", maxlength: 48 }, + icon: { type: String, default: "", maxlength: 255 }, + year: { type: String, default: "", maxlength: 8 }, }, { _id: false }, ); @@ -198,7 +267,7 @@ const VisaConsultancySchema = new Schema( const BrandItemSchema = new Schema( { - logo: { type: String, default: "" }, + logo: { type: String, default: "", maxlength: 255 }, }, { _id: false }, ); @@ -212,6 +281,9 @@ const BrandsSchema = new Schema( const PartnersSchema = new Schema( { + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + visaConsultancy: { type: VisaConsultancySchema, default: () => ({}) }, brands: { type: BrandsSchema, default: () => ({}) }, }, @@ -220,16 +292,16 @@ const PartnersSchema = new Schema( const BlogPreviewItemSchema = new Schema( { - title: { type: String, default: "" }, - excerpt: { type: String, default: "" }, - category: { type: String, default: "" }, + title: { type: String, default: "", maxlength: 120 }, + excerpt: { type: String, default: "", maxlength: 280 }, + category: { type: String, default: "", maxlength: 48 }, date: { type: String, default: "" }, // keep as string for easy JSON compatibility (e.g. "2025-08-20") author: { - name: { type: String, default: "" }, + name: { type: String, default: "", maxlength: 48 }, avatar: { type: String, default: "" }, }, comments: { type: Number, default: 0 }, - link: { type: String, default: "" }, + link: { type: String, default: "", maxlength: 255 }, thumbnail: { type: String, default: "" }, }, { _id: false }, @@ -237,6 +309,9 @@ const BlogPreviewItemSchema = new Schema( const BlogPreviewSchema = new Schema( { + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + heading: { type: String, default: "" }, subheading: { type: String, default: "" }, ctaButton: { type: LinkSchema, default: () => ({}) }, @@ -266,6 +341,7 @@ const HomeSchema = new Schema( achievements: { type: AchievementsSchema, default: () => ({}) }, partners: { type: PartnersSchema, default: () => ({}) }, blogPreview: { type: BlogPreviewSchema, default: () => ({}) }, + floatingContact: { type: FloatingContactSchema, default: () => ({}) }, }, { timestamps: true, @@ -273,5 +349,8 @@ const HomeSchema = new Schema( }, ); +// Đảm bảo chỉ có 1 document duy nhất (singleton pattern) +HomeSchema.index({ createdAt: 1 }, { unique: false }); + module.exports = mongoose.model("Home", HomeSchema); 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/package.json b/package.json index e180adf..9cec226 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "migrate-fresh": "node scripts/migrate-fresh.js", "migrate-status": "node scripts/migrate-status.js", "migrate-rollback": "node scripts/migrate-rollback.js", - "make-migration": "node scripts/make-migration.js" + "make-migration": "node scripts/make-migration.js", + "db:seed": "node scripts/seedDatabase.js" }, "keywords": [ "cms", diff --git a/public/js/admin-form-helpers.js b/public/js/admin-form-helpers.js new file mode 100644 index 0000000..1ebe328 --- /dev/null +++ b/public/js/admin-form-helpers.js @@ -0,0 +1,873 @@ +;(function (window, document) { + "use strict"; + + const COUNTER_SELECTOR = ".admin-field-counter"; + const GUIDANCE_SELECTOR = ".admin-upload-guidance"; + const COUNTER_BOUND_KEY = "adminCounterBound"; + const AUTO_GUIDANCE_ATTR = "data-admin-upload-guidance"; + const OBSERVER_BOUND_KEY = "__adminFormHelpersObserver"; + let generatedFieldToken = 0; + + const FIELD_RULES = { + "/admin/about-us": [ + { selector: "#heroTitle", maxLength: 72 }, + { selector: "#heroBreadcrumb", maxLength: 120 }, + { selector: "#heroBackgroundImage", maxLength: 255 }, + { selector: "#introSubheading", maxLength: 40 }, + { selector: "#introHeading", maxLength: 72 }, + { selector: "#introDescription", maxLength: 260 }, + { selector: "#introImage", maxLength: 255 }, + { selector: "#missionSubheading", maxLength: 40 }, + { selector: "#missionHeading", maxLength: 72 }, + { selector: "#missionDescription", maxLength: 260 }, + { selector: "#missionCtaLabel", maxLength: 32 }, + { selector: "#missionCtaHref", maxLength: 255 }, + { selector: "[id^='missionImg_']", maxLength: 255 }, + { selector: "#featuresSubheading", maxLength: 40 }, + { selector: "#featuresHeading", maxLength: 72 }, + { selector: "#featuresDescription", maxLength: 260 }, + { selector: "#featuresBgImage", maxLength: 255 }, + { selector: "#featuresImage", maxLength: 255 }, + { selector: "#featuresCtaLabel", maxLength: 32 }, + { selector: "#featuresCtaHref", maxLength: 255 }, + { selector: "[id^='missionItemLabel_']", maxLength: 48 }, + { selector: "[id^='missionItemDescription_']", maxLength: 160 }, + { selector: "[id^='missionItemIcon_']", maxLength: 255 }, + { selector: "[id^='missionFeature_']", maxLength: 96 }, + { selector: "[id^='featureItemTitle_']", maxLength: 48 }, + { selector: "[id^='featureItemDescription_']", maxLength: 160 }, + { selector: "[id^='featureItemIcon_']", maxLength: 255 }, + { selector: "#newsSubheading", maxLength: 40 }, + { selector: "#newsHeading", maxLength: 72 }, + { selector: "#newsCtaLabel", maxLength: 32 }, + { selector: "#newsCtaHref", maxLength: 255 }, + ], + "/admin/booking": [ + { selector: "#heroBackgroundImage", maxLength: 255 }, + { selector: "#heroTitle", maxLength: 72 }, + { selector: "#searchBarLocationLabel", maxLength: 32 }, + { selector: "#searchBarHolidaySeasonLabel", maxLength: 32 }, + { selector: "#searchBarSearchButtonText", maxLength: 24 }, + { selector: "input[name^='locationValue_']", maxLength: 32 }, + { selector: "input[name^='locationLabel_']", maxLength: 48 }, + { selector: "input[name^='holidayValue_']", maxLength: 32 }, + { selector: "input[name^='holidayLabel_']", maxLength: 48 }, + { selector: "#filterPanelTitle", maxLength: 48 }, + { selector: "#filterPanelPriceTitle", maxLength: 40 }, + { selector: "#filterPanelPriceLabel", maxLength: 32 }, + { selector: "#filterPanelPricePlaceholder", maxLength: 32 }, + { selector: "#filterPanelActivitiesTitle", maxLength: 40 }, + { selector: "#filterPanelAgeTitle", maxLength: 40 }, + { selector: "#filterPanelAgeSelectPlaceholder", maxLength: 32 }, + { selector: "#filterPanelRatingTitle", maxLength: 40 }, + { selector: "#filterPanelResetButtonText", maxLength: 24 }, + { selector: "input[name^='ratingValue_']", maxLength: 8 }, + { selector: "input[name^='ratingLabel_']", maxLength: 32 }, + { selector: "input[name^='programValue_']", maxLength: 32 }, + { selector: "input[name^='programLabel_']", maxLength: 48 }, + { selector: "input[name^='campName_']", maxLength: 72 }, + { selector: "input[name^='campPriceText_']", maxLength: 32 }, + { selector: "input[name^='campProgram_']", maxLength: 32 }, + { selector: "input[name^='campImage_']", maxLength: 255 }, + { selector: "input[name^='campLink_']", maxLength: 255 }, + { selector: "input[name^='discountName_']", maxLength: 48 }, + { selector: "textarea[name^='discountDescription_']", maxLength: 180 }, + { selector: "input[name^='voucherCode_']", maxLength: 24 }, + { selector: "input[name^='voucherDescription_']", maxLength: 120 }, + { selector: "[name='formTitle']", maxLength: 64 }, + { selector: "[name='formSubtitle']", maxLength: 48 }, + { selector: "[name^='stepTitle_']", maxLength: 64 }, + { selector: "[name^='sectionTitle_']", maxLength: 64 }, + { selector: "[name^='fieldLabel_']", maxLength: 48 }, + { selector: "[name^='fieldName_']", maxLength: 32 }, + { selector: "[name^='fieldPlaceholder_']", maxLength: 72 }, + { selector: "[name^='validationMessage_']", maxLength: 120 }, + ], + "/admin/pricing": [ + { selector: "#heroBackgroundImage", maxLength: 255 }, + { selector: "#heroTitle", maxLength: 72 }, + { selector: "#pricingSectionSubtitle", maxLength: 40 }, + { selector: "#pricingSectionHeading", maxLength: 72 }, + { selector: "#pricingSectionDescription", maxLength: 220 }, + { selector: ".plan-name", maxLength: 40 }, + { selector: ".plan-price", maxLength: 16 }, + { selector: ".plan-currency", maxLength: 8 }, + { selector: ".plan-period", maxLength: 12 }, + { selector: ".plan-button-text", maxLength: 32 }, + { selector: ".plan-button-link", maxLength: 255 }, + { selector: ".plan-button-icon", maxLength: 64 }, + { selector: ".plan-features", maxLength: 320 }, + { selector: "#testimonialsSubtitle", maxLength: 40 }, + { selector: "#testimonialsHeading", maxLength: 72 }, + { selector: "#testimonialsButtonText", maxLength: 32 }, + { selector: "#testimonialsButtonLink", maxLength: 255 }, + { selector: "#testimonialsButtonIcon", maxLength: 64 }, + { selector: "#testimonialsImage", maxLength: 255 }, + { selector: ".testimonial-name", maxLength: 48 }, + { selector: ".testimonial-role", maxLength: 48 }, + { selector: ".testimonial-content", maxLength: 220 }, + ], + "/admin/visa": [ + { selector: "input[name='name']", maxLength: 40 }, + { selector: "input[name='icon']", maxLength: 255 }, + { selector: "input[name='services[]']", maxLength: 56 }, + { selector: "input[name='detail_title']", maxLength: 72 }, + { selector: "input[name='mainImage']", maxLength: 255 }, + { selector: "textarea[name='description']", maxLength: 360 }, + { selector: "textarea[name='additionalInfo']", maxLength: 360 }, + { selector: "input[name='tagline']", maxLength: 72 }, + { selector: "input[name^='visa_title_']", maxLength: 56 }, + { selector: "textarea[name^='visa_desc_']", maxLength: 220 }, + { selector: "input[name='process_title']", maxLength: 72 }, + { selector: "input[name='step_title[]']", maxLength: 56 }, + { selector: "textarea[name='step_desc[]']", maxLength: 180 }, + { selector: "input[name='bannerImageGallery']", maxLength: 255 }, + { selector: "input[name='category_title[]']", maxLength: 56 }, + { selector: "textarea[name='category_desc[]']", maxLength: 180 }, + { selector: "input[name='related_title[]']", maxLength: 56 }, + { selector: "textarea[name='related_desc[]']", maxLength: 180 }, + { selector: "input[name='related_file[]']", maxLength: 255 }, + { selector: "input[name='contact_title']", maxLength: 72 }, + { selector: "input[name='contact_phone']", maxLength: 32 }, + { selector: "input[name='contact_email']", maxLength: 120 }, + { selector: "input[name='contact_address']", maxLength: 160 }, + { selector: "input[name='contact_image']", maxLength: 255 }, + ], + "/admin/activity/*": [ + { selector: "input[name='heroTitle']", maxLength: 72 }, + { selector: "input[name='heroBannerImage']", maxLength: 255 }, + { selector: "input[name='name']", maxLength: 72 }, + { selector: "input[name='priceText']", maxLength: 32 }, + { selector: "input[name='link']", maxLength: 255 }, + { selector: "input[name='program']", maxLength: 32 }, + { selector: "#customLocations", maxLength: 120 }, + { selector: "input[name='image']", maxLength: 255 }, + { selector: "input[name='campDetailHeroTitle']", maxLength: 72 }, + { selector: "input[name='campDetailHeroBgImage']", maxLength: 255 }, + { selector: "input[name='campDetailBasicInfoLocation']", maxLength: 48 }, + { selector: "textarea[name='campDetailBasicInfoAgeRange']", maxLength: 120 }, + { selector: "input[name='campDetailBasicInfoAccommodationType']", maxLength: 72 }, + { selector: "input[name='campDetailBasicInfoCareLevel']", maxLength: 72 }, + { selector: "input[name='campDetailBasicInfoLanguages']", maxLength: 72 }, + ], + "/admin/activity": [], + }; + + const GUIDANCE_RULES = { + "/admin/about-us": [ + { + selector: "#heroBackgroundImage", + title: "Upload guidance", + lines: [ + "Displayed as a wide page hero.", + "Recommended upload: at least 1920x700px.", + ], + }, + { + selector: "#introImage", + title: "Upload guidance", + lines: [ + "Displayed around 596x787px on desktop.", + "Recommended upload: at least 1200x1600px.", + ], + }, + { + selector: "#featuresImage", + title: "Upload guidance", + lines: [ + "Displayed around 375x419px on desktop.", + "Recommended upload: at least 750x840px.", + ], + }, + ], + "/admin/booking": [ + { + selector: "#heroBackgroundImage", + title: "Upload guidance", + lines: [ + "Booking page hero background.", + "Recommended upload: at least 1920x700px.", + ], + }, + { + selector: "input[name^='campImage_']", + title: "Upload guidance", + lines: [ + "Used in booking camp cards.", + "Recommended upload: a landscape image at 704x432px or larger.", + ], + }, + ], + "/admin/pricing": [ + { + selector: "#heroBackgroundImage", + title: "Upload guidance", + lines: [ + "Pricing page hero background.", + "Recommended upload: at least 1920x700px.", + ], + }, + ], + "/admin/visa": [ + { + selector: "input[name='icon']", + title: "Upload guidance", + lines: [ + "Displayed as a small country flag or icon.", + "Prefer SVG; otherwise use a square image at 96x96px or larger.", + ], + }, + { + selector: "input[name='mainImage'], input[name='bannerImageGallery'], input[name='contact_image'], input[name='related_file[]']", + title: "Upload guidance", + lines: [ + "Used in visa detail content blocks.", + "Recommended upload: at least 1000x750px for primary imagery and 800x600px for supporting images.", + ], + }, + ], + "/admin/activity/*": [ + { + selector: "input[name='heroBannerImage'], input[name='campDetailHeroBgImage']", + title: "Upload guidance", + lines: [ + "Activity page hero-style image.", + "Recommended upload: at least 1920x700px.", + ], + }, + { + selector: "input[name='image']", + title: "Upload guidance", + lines: [ + "Used in activity listing cards.", + "Recommended upload: a landscape image at 704x432px or larger.", + ], + }, + ], + "/admin/home": [ + { + selector: "#whyChooseUsMainImage", + title: "Upload guidance", + lines: [ + "Displayed around 318x347px on desktop.", + "Recommended upload: at least 750x820px.", + ], + }, + { + selector: "#whyChooseUsSecondaryImage", + title: "Upload guidance", + lines: [ + "Displayed around 363x380px on desktop.", + "Recommended upload: at least 760x800px.", + ], + }, + { + selector: "#testimonialsVideoThumbnail", + title: "Upload guidance", + lines: [ + "Displayed around 416x370px on desktop.", + "Recommended upload: at least 832x740px.", + ], + }, + { + selector: "[id^='testimonialsAvatar_']", + title: "Upload guidance", + lines: [ + "Displayed around 48x48px.", + "Recommended upload: 96x96px or 128x128px square.", + ], + }, + { + selector: "#visaCountriesFlag_0", + title: "Upload guidance", + lines: [ + "Displayed around 840x830px on desktop.", + "Recommended upload: at least 1000x1000px.", + ], + }, + ], + "*": [ + { + selector: ".btn-upload-image", + title: "Upload guidance", + lines: [ + "Use a clear, high-resolution image that matches the visible frame.", + "Prefer SVG for logos or icons and at least 2x the displayed size for raster uploads.", + ], + }, + ], + }; + + function toScope(scope) { + return scope && scope.querySelectorAll ? scope : document; + } + + function normalizeText(value) { + return String(value ?? "").replace(/\s+/g, " ").trim(); + } + + function buildDescriptor(input) { + return [ + input?.id, + input?.name, + input?.placeholder, + input?.closest(".col-md-12, .col-md-9, .col-md-6, .col-md-5, .col-md-4, .col-md-3, .col-md-2, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12")?.querySelector("label")?.textContent, + ] + .filter(Boolean) + .join(" ") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[_[\].-]+/g, " ") + .toLowerCase(); + } + + function isUploadDescriptor(descriptor) { + return /(?:^|[^a-z])(image|icon|logo|background|banner|thumbnail|avatar|flag|path|src|file)(?:[^a-z]|$)/.test(descriptor); + } + + function resolveUploadTarget(targetId, anchor) { + if (targetId) { + const byId = document.getElementById(targetId); + if (byId) { + return byId; + } + + if (window.CSS && typeof window.CSS.escape === "function") { + const byName = document.querySelector(`[name="${window.CSS.escape(targetId)}"]`); + if (byName) { + return byName; + } + } + } + + return anchor || null; + } + + function hasManualUploadHint(target) { + const anchor = resolveGuidanceAnchor(target); + if (!anchor) { + return false; + } + + const host = + anchor.closest(".form-group, .mb-3, .col-md-12, .col-md-9, .col-md-6, .col-md-5, .col-md-4, .col-md-3, .col-md-2, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12") || + anchor.parentElement; + if (!host) { + return false; + } + + const candidates = [ + ...host.querySelectorAll("small.text-muted, small.form-text, .form-text, .text-muted"), + ...Array.from(host.nextElementSibling ? host.nextElementSibling.querySelectorAll?.("small.text-muted, small.form-text, .form-text, .text-muted") || [] : []), + ]; + + return candidates.some((node) => { + if (node.classList?.contains("admin-field-counter") || node.classList?.contains("admin-upload-guidance")) { + return false; + } + + const text = normalizeText(node.textContent || ""); + return /recommended|min(imum)? upload|upload|svg|png|webp|render|displayed|size|preview|icon/i.test(text); + }); + } + + function getFieldLimit(input) { + const dataMax = Number(input?.dataset?.maxlength); + if (Number.isFinite(dataMax) && dataMax > 0) { + return dataMax; + } + + const attrMax = Number(input?.getAttribute("maxlength")); + if (Number.isFinite(attrMax) && attrMax > 0) { + return attrMax; + } + + return null; + } + + function getWordLimit(input) { + const dataMax = Number(input?.dataset?.maxwords); + if (Number.isFinite(dataMax) && dataMax > 0) { + return dataMax; + } + + return null; + } + + function getFieldToken(input) { + if (!input) { + return ""; + } + + if (input.id) { + return `id:${input.id}`; + } + + if (input.name) { + const indexWithinNameGroup = Array.from(document.querySelectorAll(`[name="${CSS.escape(input.name)}"]`)).indexOf(input); + return `name:${input.name}:${Math.max(indexWithinNameGroup, 0)}`; + } + + if (!input.dataset.adminFieldToken) { + generatedFieldToken += 1; + input.dataset.adminFieldToken = `generated:${generatedFieldToken}`; + } + + return input.dataset.adminFieldToken; + } + + function isDragDropField(element) { + if (!element || !element.closest) { + return false; + } + + return Boolean( + element.closest( + ".social-link-item, .floating-contact-action-item, .menu-links-sortable, .social-links-sortable, .sortable-container, .sortable-list, .sortable-item, [data-top-menu-index], [data-top-social-index], [data-bottom-menu-index], [data-bottom-social-index], [draggable='true']", + ), + ); + } + + function refreshCountersWithin(scope) { + if (!scope || !scope.querySelectorAll) { + return; + } + + scope + .querySelectorAll("input[data-maxlength], textarea[data-maxlength], input[data-maxwords], textarea[data-maxwords], input[maxlength], textarea[maxlength]") + .forEach((field) => { + if (field.dataset[COUNTER_BOUND_KEY] === "true") { + updateCounter(field); + } + }); + } + + function getCounterRefreshScope(input) { + if (!isDragDropField(input)) { + return null; + } + + return ( + input.closest(".floating-contact-action-item, .social-link-item, [data-top-menu-index], [data-top-social-index], [data-bottom-menu-index], [data-bottom-social-index]") || + input.closest(".menu-links-sortable, .social-links-sortable, .sortable-container, .sortable-list") + ); + } + + function getCounterHost(input) { + return ( + input.closest(".input-group") || + input.closest(".col, [class^='col-'], [class*=' col-']") || + input.parentElement || + input + ); + } + + function ensureCounterElement(input) { + const counterToken = getFieldToken(input); + const existingAnywhere = counterToken ? document.querySelector(`[data-counter-for="${counterToken}"]`) : null; + if (existingAnywhere) { + return existingAnywhere; + } + + const host = getCounterHost(input); + const nextSibling = host.nextElementSibling; + const matchingNextSibling = + nextSibling && + nextSibling.matches(COUNTER_SELECTOR) && + nextSibling.dataset.counterFor === counterToken + ? nextSibling + : null; + const existing = matchingNextSibling || host.querySelector(`${COUNTER_SELECTOR}[data-counter-for="${counterToken}"]`); + + if (existing) { + return existing; + } + + const counter = document.createElement("small"); + counter.className = "form-text admin-field-counter"; + counter.dataset.counterFor = counterToken; + counter.setAttribute("aria-live", "polite"); + + if (host.classList?.contains("input-group")) { + host.insertAdjacentElement("afterend", counter); + } else { + host.appendChild(counter); + } + + return counter; + } + + function matchesPathKey(key, pathname) { + if (key === "*") { + return true; + } + + if (key.endsWith("*")) { + return pathname.startsWith(key.slice(0, -1)); + } + + return key === pathname; + } + + function getPathRules(registry) { + const pathname = window.location.pathname; + return Object.keys(registry).reduce((rules, key) => { + if (!matchesPathKey(key, pathname)) { + return rules; + } + + return rules.concat(registry[key] || []); + }, []); + } + + function findTargets(root, selector) { + const targets = []; + if (root.matches && root.matches(selector)) { + targets.push(root); + } + if (root.querySelectorAll) { + targets.push(...root.querySelectorAll(selector)); + } + return targets; + } + + function applyFieldRules(scope) { + const root = toScope(scope); + getPathRules(FIELD_RULES).forEach((rule) => { + findTargets(root, rule.selector).forEach((input) => { + if (rule.maxLength && !input.dataset.maxlength) { + input.dataset.maxlength = String(rule.maxLength); + input.setAttribute("maxlength", String(rule.maxLength)); + } + if (rule.maxWords && !input.dataset.maxwords) { + input.dataset.maxwords = String(rule.maxWords); + } + }); + }); + + root.querySelectorAll("input, textarea").forEach((input) => { + if ( + input.disabled || + input.type === "hidden" || + input.type === "file" || + input.dataset.maxlength || + input.dataset.maxwords || + input.getAttribute("maxlength") + ) { + return; + } + + const type = (input.getAttribute("type") || "").toLowerCase(); + if (type && !["text", "email", "tel", "search", "url"].includes(type) && input.tagName !== "TEXTAREA") { + return; + } + + const descriptor = buildDescriptor(input); + + if (/json|editor|html|content-block|blocks/.test(descriptor)) { + return; + } + + let inferredMaxLength = 72; + if (input.tagName === "TEXTAREA") { + inferredMaxLength = /description|content|overview|additional info|quote|note|message|summary/.test(descriptor) ? 500 : 220; + } else if (type === "email" || /email/.test(descriptor)) { + inferredMaxLength = 120; + } else if (type === "tel" || /phone|tel|mobile|whatsapp|zalo/.test(descriptor)) { + inferredMaxLength = 32; + } else if (/url|href|link/.test(descriptor) || isUploadDescriptor(descriptor)) { + inferredMaxLength = 255; + } else if (/slug|code|id/.test(descriptor)) { + inferredMaxLength = 32; + } else if (/title|heading|name|label|subtitle|platform/.test(descriptor)) { + inferredMaxLength = 72; + } + + input.dataset.maxlength = String(inferredMaxLength); + input.setAttribute("maxlength", String(inferredMaxLength)); + }); + } + + function updateCounter(input) { + const counter = ensureCounterElement(input); + const maxLength = getFieldLimit(input); + const maxWords = getWordLimit(input); + const currentValue = normalizeText(input.value || ""); + const currentLength = currentValue.length; + + if (maxWords) { + const words = currentValue ? currentValue.split(" ") : []; + const currentWords = words.filter(Boolean).length; + + if (maxLength && currentLength > maxLength) { + input.value = currentValue.slice(0, maxLength); + } + + if (maxLength) { + counter.textContent = `${currentWords}/${maxWords} words, ${Math.min(currentLength, maxLength)}/${maxLength} characters`; + counter.classList.toggle("is-danger", currentWords >= maxWords || currentLength >= maxLength); + } else { + counter.textContent = `${currentWords}/${maxWords} words`; + counter.classList.toggle("is-danger", currentWords >= maxWords); + } + return; + } + + if (!maxLength) { + counter.textContent = ""; + return; + } + + if (currentLength > maxLength) { + input.value = currentValue.slice(0, maxLength); + } + + counter.textContent = `${Math.min(currentLength, maxLength)}/${maxLength} characters`; + counter.classList.toggle("is-danger", currentLength >= maxLength); + } + + function bindCounter(input) { + if (!input || input.dataset[COUNTER_BOUND_KEY] === "true") { + return; + } + + input.dataset[COUNTER_BOUND_KEY] = "true"; + const syncCounter = () => { + updateCounter(input); + const refreshScope = getCounterRefreshScope(input); + if (refreshScope) { + refreshCountersWithin(refreshScope); + } + }; + + syncCounter(); + input.addEventListener("input", syncCounter); + input.addEventListener("change", syncCounter); + input.addEventListener("blur", syncCounter); + input.addEventListener("focus", syncCounter); + } + + function buildGuidanceLines(options = {}) { + const title = normalizeText(options.title) || "Upload guidance"; + const lines = Array.isArray(options.lines) ? options.lines.map(normalizeText).filter(Boolean) : []; + + if (!lines.length) { + lines.push("Use a clear, high-resolution image that matches the visible frame."); + lines.push("Prefer a file that is at least 2x the displayed size for crisp rendering."); + lines.push("Keep the original aspect ratio unless the page explicitly asks for a crop."); + } + + return { title, lines }; + } + + function resolveGuidanceAnchor(target) { + if (!target) { + return null; + } + + if (typeof target === "string") { + return document.querySelector(target); + } + + if (target instanceof Element) { + return target; + } + + return null; + } + + function renderUploadGuidance(target, options = {}) { + const anchor = resolveGuidanceAnchor(target); + if (!anchor) { + return null; + } + + const host = anchor.closest(".form-group, .mb-3, .col-md-12, .col-md-9, .col-md-6, .col-md-4, .col-md-3, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12") || anchor.parentElement; + if (!host) { + return null; + } + + const matchingAnchorSibling = + anchor.nextElementSibling && + anchor.nextElementSibling.matches(GUIDANCE_SELECTOR) && + anchor.nextElementSibling.dataset.guidanceFor === (options.for || "") + ? anchor.nextElementSibling + : null; + const matchingHostSibling = + host.nextElementSibling && + host.nextElementSibling.matches(GUIDANCE_SELECTOR) && + host.nextElementSibling.dataset.guidanceFor === (options.for || "") + ? host.nextElementSibling + : null; + const existing = + matchingAnchorSibling || + matchingHostSibling || + host.querySelector(`${GUIDANCE_SELECTOR}[data-guidance-for="${options.for || ""}"]`); + if (existing) { + return existing; + } + + const payload = buildGuidanceLines(options); + const note = document.createElement("div"); + note.className = "admin-upload-guidance"; + note.dataset.guidanceFor = options.for || ""; + note.setAttribute("role", "note"); + note.innerHTML = ` +
Edit content displayed on About Us page
+Edit content displayed on the About page
@@ -41,14 +41,9 @@ aria-selected="false">Intro
+
Edit content displayed on Contact Us page
@@ -66,7 +66,8 @@