From ffe2f12bb393d4355e4dba9d5568ef155ef93ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=E1=BB=91ng=20Th=C3=A0nh=20=C4=90=E1=BA=A1t?= <84076965+tongthanhdat009@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:36:20 +0700 Subject: [PATCH 01/13] feat(contact-button): add floating contact widget admin management Add CMS support for floating contact widget with Facebook/Zalo quick actions. Includes mongoose schema, admin UI tab, image upload with sharp resize presets, deferred form submission with draft persistence, and upload middleware error handling. --- .gitignore | 5 +- controllers/homeController.js | 193 ++++++- controllers/uploadController.js | 103 +++- models/home.js | 46 ++ .../Facebook_Logo_Primary.webp | Bin 0 -> 37272 bytes .../floating-contact/Icon_of_Zalo.svg.webp | Bin 0 -> 20494 bytes .../floating-contact/logo-hai-learning.webp | Bin 0 -> 2772 bytes routes/admin.js | 29 +- ..._04_07_110000_add_home_floating_contact.js | 115 ++++ ...0_add_home_floating_contact_icon_images.js | 129 +++++ views/admin/home/index.ejs | 250 +++++++-- views/admin/home/sections/floatingContact.ejs | 510 ++++++++++++++++++ 12 files changed, 1328 insertions(+), 52 deletions(-) create mode 100644 public/uploads/home/floating-contact/Facebook_Logo_Primary.webp create mode 100644 public/uploads/home/floating-contact/Icon_of_Zalo.svg.webp create mode 100644 public/uploads/home/floating-contact/logo-hai-learning.webp create mode 100644 scripts/2026_04_07_110000_add_home_floating_contact.js create mode 100644 scripts/2026_04_07_164500_add_home_floating_contact_icon_images.js create mode 100644 views/admin/home/sections/floatingContact.ejs 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/controllers/homeController.js b/controllers/homeController.js index e070c6c..2040cd9 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"); @@ -8,6 +11,137 @@ const AUDIT_ACTIONS = require("../constants/auditAction"); // Các hàm hỗ trợ const getHomeDoc = async () => Home.findOne().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 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: "Anh chị cần tư vấn hay hỗ trợ gì thêm không ạ?", + brand: { + imageSrc: "/assets/img/logo/black-logo.svg", + imageAlt: "HAI Learning", + }, + trigger: { + imageSrc: "", + icon: "fa-comments", + }, + actions: [ + { + id: "facebook", + platform: "facebook", + enabled: true, + label: "Nhắn tin qua 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: "Nhắn tin qua 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 +198,7 @@ const getDefaultHomeData = () => ({ items: [], selectedBlogIds: [], // Array of manually selected blog IDs }, + floatingContact: getDefaultFloatingContactData(), }); // Admin: Xem trang quản lý @@ -77,9 +212,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 +254,7 @@ exports.update = async (req, res) => { "achievements", "partners", "blogPreview", + "floatingContact", ]; let doc = await getHomeDoc(); @@ -135,7 +272,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 +316,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 { @@ -191,8 +374,7 @@ 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")}`; + const baseUrl = `${req.protocol}://${req.get("host")}`; // === Xử lý Blog Preview động === const blogPreview = data.blogPreview || {}; @@ -238,6 +420,7 @@ exports.api = async (req, res) => { })); data.blogPreview = blogPreview; + data.floatingContact = normalizeFloatingContactData(data.floatingContact); // =============================== const processed = addBaseUrlToImages(data, baseUrl); diff --git a/controllers/uploadController.js b/controllers/uploadController.js index ef6f4c8..7d4f80c 100644 --- a/controllers/uploadController.js +++ b/controllers/uploadController.js @@ -1,7 +1,98 @@ 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); + + await sharp(file.path) + .resize(preset.width, preset.height, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + withoutEnlargement: true, + }) + .webp({ quality: preset.quality }) + .toFile(finalPath); + + if (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 +104,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 +150,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 +316,4 @@ const uploadController = { } }; -module.exports = uploadController; \ No newline at end of file +module.exports = uploadController; diff --git a/models/home.js b/models/home.js index 09d3d52..d2bc0ee 100644 --- a/models/home.js +++ b/models/home.js @@ -11,6 +11,51 @@ const LinkSchema = new Schema( { _id: false }, ); +const FloatingContactBrandSchema = new Schema( + { + imageSrc: { type: String, default: "" }, + imageAlt: { type: String, default: "", maxlength: 60 }, + }, + { _id: false }, +); + +const FloatingContactTriggerSchema = new Schema( + { + imageSrc: { type: String, default: "" }, + icon: { type: String, default: "fa-comments" }, + }, + { _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: "" }, + iconImage: { type: String, default: "" }, + iconType: { type: String, default: "iconClass" }, + iconClass: { type: String, default: "" }, + 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 }, +); + // Hero slide (for multiple hero items in slider) const HeroSlideSchema = new Schema( { @@ -266,6 +311,7 @@ const HomeSchema = new Schema( achievements: { type: AchievementsSchema, default: () => ({}) }, partners: { type: PartnersSchema, default: () => ({}) }, blogPreview: { type: BlogPreviewSchema, default: () => ({}) }, + floatingContact: { type: FloatingContactSchema, default: () => ({}) }, }, { timestamps: true, diff --git a/public/uploads/home/floating-contact/Facebook_Logo_Primary.webp b/public/uploads/home/floating-contact/Facebook_Logo_Primary.webp new file mode 100644 index 0000000000000000000000000000000000000000..6ec1ee52cadbed736ff43bd7a99575e4022e04d5 GIT binary patch literal 37272 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 literal 0 HcmV?d00001 diff --git a/public/uploads/home/floating-contact/Icon_of_Zalo.svg.webp b/public/uploads/home/floating-contact/Icon_of_Zalo.svg.webp new file mode 100644 index 0000000000000000000000000000000000000000..44aa48e390ef8401ed72c678cbc15b401f66124c GIT binary patch literal 20494 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 V@OYHPG+I@0g}?e&C$~SjG69y?&vgI* literal 0 HcmV?d00001 diff --git a/public/uploads/home/floating-contact/logo-hai-learning.webp b/public/uploads/home/floating-contact/logo-hai-learning.webp new file mode 100644 index 0000000000000000000000000000000000000000..f75d5b1ccff4efaf5d2e0c16e3f02f83d67368a8 GIT binary patch literal 2772 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 anit0aVxK6Pd&5rNi;XVV%YQ8Z0001meR5v_ literal 0 HcmV?d00001 diff --git a/routes/admin.js b/routes/admin.js index 5dce403..cefebab 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -36,9 +36,34 @@ const videoGalleryController = require("../controllers/videoGalleryController"); // Dashboard router.get("/dashboard", ensureAuthenticated, dashboardController.getDashboard); +const runUploadMiddleware = (middleware) => (req, res, next) => { + middleware(req, res, (error) => { + if (!error) { + return next(); + } + + console.error("Upload middleware error:", error); + + const status = + error.code === "LIMIT_FILE_SIZE" + ? 413 + : error.statusCode || error.status || 400; + + return res.status(status).json({ + success: false, + error: error.message || "Upload failed", + }); + }); +}; + // Home router.get("/home", ensureAuthenticated, homeController.index); router.post("/home/update", ensureAuthenticated, homeController.update); +router.post( + "/home/floating-contact/update", + ensureAuthenticated, + homeController.updateFloatingContact, +); router.get("/home/api/blogs", ensureAuthenticated, homeController.apiGetBlogs); // Middleware chuẩn hóa code @@ -72,13 +97,13 @@ router.get("/upload", ensureAuthenticated, (req, res) => { router.post( "/upload/image", ensureAuthenticated, - upload.single("image"), + runUploadMiddleware(upload.single("image")), uploadController.uploadImage, ); router.post( "/upload/video", ensureAuthenticated, - uploadVideo.single("video"), + runUploadMiddleware(uploadVideo.single("video")), uploadController.uploadVideo, ); router.post( diff --git a/scripts/2026_04_07_110000_add_home_floating_contact.js b/scripts/2026_04_07_110000_add_home_floating_contact.js new file mode 100644 index 0000000..517194a --- /dev/null +++ b/scripts/2026_04_07_110000_add_home_floating_contact.js @@ -0,0 +1,115 @@ +require("dotenv").config(); +const connectDB = require("../config/database"); + +const DEFAULT_FACEBOOK_URL = "https://www.facebook.com/hailearning.edu.vn/"; +const DEFAULT_PANEL_TITLE = "Anh chị cần tư vấn hay hỗ trợ gì thêm không ạ?"; +const DEFAULT_BRAND_IMAGE = "/assets/img/logo/black-logo.svg"; +const DEFAULT_FACEBOOK_ICON = "/uploads/home/floating-contact/Facebook_Logo_Primary.webp"; +const DEFAULT_ZALO_ICON = "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp"; + +function normalizePhoneForZalo(value = "") { + const digits = String(value).replace(/\D/g, ""); + + if (!digits) { + return ""; + } + + if (digits.startsWith("84")) { + return digits; + } + + if (digits.startsWith("0")) { + return `84${digits.slice(1)}`; + } + + return digits; +} + +async function migrate() { + const mongoose = require("mongoose"); + let ownConn = false; + + try { + const wasConnected = mongoose.connection.readyState === 1; + await connectDB(); + if (!wasConnected) { + ownConn = true; + } + + const Home = require("../models/home"); + const Footer = require("../models/footer"); + + const footer = await Footer.findOne().sort({ updatedAt: -1 }).lean(); + const footerPhone = footer?.top?.phone?.href || footer?.top?.phone?.display || ""; + const zaloPhone = normalizePhoneForZalo(footerPhone) || "84961834040"; + const zaloUrl = `https://zalo.me/${zaloPhone}`; + + const defaultFloatingContact = { + enabled: true, + position: "bottom-right", + panelTitle: DEFAULT_PANEL_TITLE, + brand: { + imageSrc: DEFAULT_BRAND_IMAGE, + imageAlt: "HAI Learning", + }, + trigger: { + icon: "fa-comments", + }, + actions: [ + { + id: "facebook", + platform: "facebook", + enabled: true, + label: "Nhắn tin qua Facebook", + subtitle: "facebook.com/hailearning.edu.vn", + href: DEFAULT_FACEBOOK_URL, + iconImage: DEFAULT_FACEBOOK_ICON, + iconType: "iconClass", + iconClass: "fa-brands fa-facebook-messenger", + iconText: "", + order: 1, + }, + { + id: "zalo", + platform: "zalo", + enabled: true, + label: "Nhắn tin qua Zalo", + subtitle: `zalo.me/${zaloPhone}`, + href: zaloUrl, + iconImage: DEFAULT_ZALO_ICON, + iconType: "iconText", + iconClass: "", + iconText: "Zalo", + order: 2, + }, + ], + }; + + const updateResult = await Home.updateMany( + { floatingContact: { $exists: false } }, + { $set: { floatingContact: defaultFloatingContact } }, + ); + + if (updateResult.matchedCount === 0) { + await Home.create({ floatingContact: defaultFloatingContact }); + console.log("Created a Home document with default floatingContact data."); + } else { + console.log( + `Updated ${updateResult.modifiedCount} Home document(s) with floatingContact defaults.`, + ); + } + + if (ownConn && mongoose.connection.readyState === 1) { + await mongoose.disconnect(); + } + process.exit(0); + } catch (error) { + console.error("Failed to add floatingContact to Home:", error); + if (ownConn && mongoose.connection.readyState === 1) { + await mongoose.disconnect(); + } + process.exit(1); + } +} + +migrate(); diff --git a/scripts/2026_04_07_164500_add_home_floating_contact_icon_images.js b/scripts/2026_04_07_164500_add_home_floating_contact_icon_images.js new file mode 100644 index 0000000..377e256 --- /dev/null +++ b/scripts/2026_04_07_164500_add_home_floating_contact_icon_images.js @@ -0,0 +1,129 @@ +const mongoose = require("mongoose"); +const connectDB = require("../config/database"); +const Home = require("../models/home"); + +const DEFAULT_ICON_BY_PLATFORM = { + facebook: "/uploads/home/floating-contact/Facebook_Logo_Primary.webp", + zalo: "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp", +}; + +async function up() { + await connectDB(); + + try { + const homes = await Home.find({ + floatingContact: { $exists: true }, + }); + + let updatedCount = 0; + + for (const home of homes) { + const floatingContact = home.floatingContact || {}; + let modified = false; + + if (!floatingContact.trigger) { + floatingContact.trigger = {}; + modified = true; + } + + if (typeof floatingContact.trigger.imageSrc !== "string") { + floatingContact.trigger.imageSrc = ""; + modified = true; + } + + if (Array.isArray(floatingContact.actions)) { + floatingContact.actions = floatingContact.actions.map((action) => { + const defaultIcon = + DEFAULT_ICON_BY_PLATFORM[String(action?.platform || "").trim().toLowerCase()] || ""; + + if (typeof action?.iconImage !== "string") { + modified = true; + return { + ...action, + iconImage: defaultIcon, + }; + } + + if (!action.iconImage.trim() && defaultIcon) { + modified = true; + return { + ...action, + iconImage: defaultIcon, + }; + } + + return action; + }); + } + + if (modified) { + home.floatingContact = floatingContact; + home.markModified("floatingContact"); + await home.save(); + updatedCount += 1; + } + } + + console.log( + `Added floatingContact trigger/action image fields to ${updatedCount} Home document(s).`, + ); + } catch (error) { + console.error("Failed to add floatingContact icon image fields:", error); + throw error; + } finally { + await mongoose.disconnect(); + } +} + +async function down() { + await connectDB(); + + try { + const homes = await Home.find({ + floatingContact: { $exists: true }, + }); + + let updatedCount = 0; + + for (const home of homes) { + const floatingContact = home.floatingContact || {}; + let modified = false; + + if (floatingContact.trigger && "imageSrc" in floatingContact.trigger) { + delete floatingContact.trigger.imageSrc; + modified = true; + } + + if (Array.isArray(floatingContact.actions)) { + floatingContact.actions = floatingContact.actions.map((action) => { + if (!action || !("iconImage" in action)) { + return action; + } + + modified = true; + const nextAction = { ...action }; + delete nextAction.iconImage; + return nextAction; + }); + } + + if (modified) { + home.floatingContact = floatingContact; + home.markModified("floatingContact"); + await home.save(); + updatedCount += 1; + } + } + + console.log( + `Removed floatingContact trigger/action image fields from ${updatedCount} Home document(s).`, + ); + } catch (error) { + console.error("Failed to rollback floatingContact icon image fields:", error); + throw error; + } finally { + await mongoose.disconnect(); + } +} + +module.exports = { up, down }; diff --git a/views/admin/home/index.ejs b/views/admin/home/index.ejs index f094b65..727d9c3 100644 --- a/views/admin/home/index.ejs +++ b/views/admin/home/index.ejs @@ -27,6 +27,7 @@ +
@@ -82,6 +83,11 @@ Blog Preview +
@@ -94,9 +100,10 @@ <%- include('sections/testimonials') %> <%- include('sections/videoGallery') %> <%- include('sections/faq') %> - <%- include('sections/achievements') %> - <%- include('sections/partners') %> + <%- include('sections/achievements') %> + <%- include('sections/partners') %> <%- include('sections/blogPreview') %> + <%- include('sections/floatingContact') %> @@ -118,9 +125,14 @@ - + + \ No newline at end of file + diff --git a/views/admin/home/sections/floatingContact.ejs b/views/admin/home/sections/floatingContact.ejs new file mode 100644 index 0000000..a471013 --- /dev/null +++ b/views/admin/home/sections/floatingContact.ejs @@ -0,0 +1,510 @@ + +
+
+
+
+
+
+ Widget Settings +
+ Homepage floating contact widget +
+
+
+
+
+
+
Widget visibility
+ Enable or disable the floating contact widget on the homepage. +
+
+ /> + +
+
+
+
+ + + Maximum 72 characters to keep the header from breaking the widget layout. +
+
+ + + Used for accessibility and fallback image descriptions. +
+ +
+ +
+ + +
+
+ Brand preview +
+ Raster logo uploads are normalized to 104x104 WebP to match the homepage widget. +
+
+ +
+ + +
+
+ Trigger icon preview +
+ + Shown only when the trigger slideshow cannot use the brand image and action icons. Raster uploads are normalized to 96x96 WebP. +
+
+
+
+
+ +
+
+
+
+
+ Contact Actions +
+ Add, remove, and drag to reorder floating contact actions. +
+ +
+
+
+
+
+
+
+
+ + + + From b6f1b92feb78347ce777c2fb17a58efbcbe55283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=E1=BB=91ng=20Th=C3=A0nh=20=C4=90=E1=BA=A1t?= <84076965+tongthanhdat009@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:57:28 +0700 Subject: [PATCH 02/13] feat(header-menu): add maintenance mode functionality and related UI elements --- controllers/headerMenuController.js | 70 ++++++++++++++++++- models/headerMenu.js | 5 ++ routes/index.js | 1 + ..._08_200000_add_header_menu_maintainance.js | 46 ++++++++++++ views/admin/header/index.ejs | 11 +++ views/admin/header/menu.ejs | 5 ++ 6 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 scripts/2026_04_08_200000_add_header_menu_maintainance.js 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/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/routes/index.js b/routes/index.js index 48e1d8c..39deb0d 100644 --- a/routes/index.js +++ b/routes/index.js @@ -52,6 +52,7 @@ router.get("/api/menu-tree", headerController.getMenuTreeAPI); // Header Menu New Module API router.get("/api/header-menu", headerMenuController.api); +router.get("/api/header-menu/maintenance", headerMenuController.maintenanceStatus); // Social Links API routes router.get("/api/social-links", socialLinkController.index); diff --git a/scripts/2026_04_08_200000_add_header_menu_maintainance.js b/scripts/2026_04_08_200000_add_header_menu_maintainance.js new file mode 100644 index 0000000..69c3e95 --- /dev/null +++ b/scripts/2026_04_08_200000_add_header_menu_maintainance.js @@ -0,0 +1,46 @@ +const mongoose = require("mongoose"); +const connectDB = require("../config/database"); + +async function up() { + await connectDB(); + + try { + const collection = mongoose.connection.db.collection("headermenus"); + const result = await collection.updateMany( + { is_maintainance: { $exists: false } }, + { $set: { is_maintainance: false } }, + ); + + console.log( + `Added is_maintainance=false to ${result.modifiedCount || 0} HeaderMenu document(s).`, + ); + } catch (error) { + console.error("Failed to add is_maintainance to HeaderMenu documents:", error); + throw error; + } finally { + await mongoose.disconnect(); + } +} + +async function down() { + await connectDB(); + + try { + const collection = mongoose.connection.db.collection("headermenus"); + const result = await collection.updateMany( + { is_maintainance: { $exists: true } }, + { $unset: { is_maintainance: "" } }, + ); + + console.log( + `Removed is_maintainance from ${result.modifiedCount || 0} HeaderMenu document(s).`, + ); + } catch (error) { + console.error("Failed to rollback is_maintainance on HeaderMenu documents:", error); + throw error; + } finally { + await mongoose.disconnect(); + } +} + +module.exports = { up, down }; diff --git a/views/admin/header/index.ejs b/views/admin/header/index.ejs index 03cdb93..3e868d3 100644 --- a/views/admin/header/index.ejs +++ b/views/admin/header/index.ejs @@ -1067,6 +1067,17 @@ +
+ + +
+ + +
+ Use this when the linked page should be temporarily unavailable to visitors. +
diff --git a/views/admin/header/menu.ejs b/views/admin/header/menu.ejs index 1aa6c0b..3429c5e 100644 --- a/views/admin/header/menu.ejs +++ b/views/admin/header/menu.ejs @@ -42,6 +42,9 @@ <% } else { %> Active <% } %> + <% if (item.is_maintainance) { %> + Maintenance + <% } %>
<%= item.url %> @@ -192,6 +195,7 @@ document.getElementById('formUrl').value = ''; document.getElementById('formOrder').value = '0'; document.getElementById('formStatus').value = 'active'; + document.getElementById('formMaintainance').checked = false; document.getElementById('typeInternal').checked = true; const modalElement = document.getElementById('modalAddMenu'); @@ -216,6 +220,7 @@ document.getElementById('formUrl').value = item.url; document.getElementById('formOrder').value = item.order; document.getElementById('formStatus').value = item.status; + document.getElementById('formMaintainance').checked = Boolean(item.is_maintainance); if (item.type === 'external') { document.getElementById('typeExternal').checked = true; From 5bae55f459deac75ad1932b08ee9d38a34e36a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20Nh=E1=BA=ADt?= Date: Thu, 9 Apr 2026 13:37:48 +0700 Subject: [PATCH 03/13] refactor: restructure code and implement active tab UI --- .env.example | Bin 618 -> 616 bytes controllers/headerController.js | 2 +- controllers/headerMenuController.js | 2 +- package.json | 3 +- scripts/migrate-header-menu.js | 2 +- scripts/seedDatabase.txt | 92 ++++++++++++++++++++++++++++ views/admin/aboutUs/index.ejs | 31 ++++++---- views/admin/home/sections/hero.ejs | 3 + 8 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 scripts/seedDatabase.txt diff --git a/.env.example b/.env.example index ad8f4a57d121c71c29c2d383ef1b81ccb4733468..6e9969b5d10bccc452cb2dae3cb5f057b31e2d94 100644 GIT binary patch delta 7 OcmaFG@`7bU1`_}c{{r;@ delta 10 RcmaFC@``0c1``t(0{|BE0|5X4 diff --git a/controllers/headerController.js b/controllers/headerController.js index 3376342..a237ffb 100644 --- a/controllers/headerController.js +++ b/controllers/headerController.js @@ -1,5 +1,5 @@ const Header = require("../models/header"); -const HeaderMenu = require("../models/HeaderMenu"); +const HeaderMenu = require("../models/headerMenu"); const writeAuditLog = require("../audit/writeAuditLog"); const diffObject = require("../audit/diffObject"); const AUDIT_ACTIONS = require("../constants/auditAction"); diff --git a/controllers/headerMenuController.js b/controllers/headerMenuController.js index 5423ed4..0c1ddfb 100644 --- a/controllers/headerMenuController.js +++ b/controllers/headerMenuController.js @@ -1,4 +1,4 @@ -const HeaderMenu = require("../models/HeaderMenu"); +const HeaderMenu = require("../models/headerMenu"); const slugify = require("slugify"); /** diff --git a/package.json b/package.json index 67a480f..a620fad 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/scripts/migrate-header-menu.js b/scripts/migrate-header-menu.js index 3f7cbba..cc5b2e2 100644 --- a/scripts/migrate-header-menu.js +++ b/scripts/migrate-header-menu.js @@ -2,7 +2,7 @@ const mongoose = require('mongoose'); const fs = require('fs'); const path = require('path'); const dotenv = require('dotenv'); -const HeaderMenu = require('../models/HeaderMenu'); +const HeaderMenu = require('../models/headerMenu'); dotenv.config(); diff --git a/scripts/seedDatabase.txt b/scripts/seedDatabase.txt new file mode 100644 index 0000000..c407b41 --- /dev/null +++ b/scripts/seedDatabase.txt @@ -0,0 +1,92 @@ +require("dotenv").config(); +const mongoose = require("mongoose"); +const fs = require("fs"); +const path = require("path"); + +// Import models +const About = require("../models/aboutUs"); +const Blog = require("../models/blog"); +const Service = require("../models/service"); +const Contact = require("../models/contact"); +const Footer = require("../models/footer"); +const Header = require("../models/header"); +const HeaderMenu = require("../models/headerMenu"); +const Home = require("../models/home"); +const FAQ = require("../models/faq"); +const Visa = require("../models/visa"); +const Appointment = require("../models/appointment"); +const Pricing = require("../models/pricing"); +const Activity = require("../models/activity"); + +// Data mapping +const dataMap = { + about: { model: About, file: "about.json" }, + blog: { model: Blog, file: "blog.json" }, + service: { model: Service, file: "service.json" }, + contact: { model: Contact, file: "contact.json" }, + footer: { model: Footer, file: "footer.json" }, + header: { model: Header, file: "header.json" }, + headerMenu: { model: HeaderMenu, file: "header-menu.json" }, + home: { model: Home, file: "home.json" }, + faq: { model: FAQ, file: "faq-data.json" }, + visa: { model: Visa, file: "visa.json" }, + appointment: { model: Appointment, file: "appointment.json" }, + pricing: { model: Pricing, file: "pricing.json" }, +}; + +const seedDatabase = async () => { + try { + // Kết nối MongoDB + if (!process.env.MONGODB_URI) { + throw new Error("MONGODB_URI is not defined in environment variables"); + } + + console.log("🔗 Connecting to MongoDB..."); + await mongoose.connect(process.env.MONGODB_URI); + console.log("✅ MongoDB Connected"); + + // Seed từng collection + for (const [key, config] of Object.entries(dataMap)) { + try { + const jsonPath = path.join(__dirname, "../data", config.file); + + if (!fs.existsSync(jsonPath)) { + console.warn(`⚠️ ${config.file} không tồn tại, bỏ qua...`); + continue; + } + + const rawData = fs.readFileSync(jsonPath, "utf8"); + const jsonData = JSON.parse(rawData); + + if (Array.isArray(jsonData)) { + // Nếu là array, xóa tất cả và thêm mới + await config.model.deleteMany({}); + await config.model.insertMany(jsonData); + console.log(`✅ Seeded ${key} (${jsonData.length} items)`); + } else { + // Nếu là object, dùng upsert + await config.model.findOneAndUpdate({}, jsonData, { + upsert: true, + new: true, + setDefaultsOnInsert: true + }); + console.log(`✅ Seeded ${key}`); + } + } catch (error) { + console.error(`❌ Error seeding ${key}:`, error.message); + } + } + + console.log("🎉 Database seeding completed successfully!"); + + } catch (error) { + console.error("❌ Seeding failed:", error.message); + process.exit(1); + } finally { + await mongoose.connection.close(); + console.log("👋 Database connection closed"); + process.exit(0); + } +}; + +seedDatabase(); diff --git a/views/admin/aboutUs/index.ejs b/views/admin/aboutUs/index.ejs index 556b9cd..69d6932 100644 --- a/views/admin/aboutUs/index.ejs +++ b/views/admin/aboutUs/index.ejs @@ -41,14 +41,9 @@ aria-selected="false">Intro -
@@ -106,7 +107,9 @@
+ value="<%= data.hero?.title || '' %>" + maxlength="40" data-maxlength="40"> + Keep the hero title short so the centered breadcrumb stays balanced on tablet and mobile.
@@ -176,7 +179,9 @@ + value="<%= card.title || '' %>" + maxlength="40" data-maxlength="40"> + Recommended maximum: 40 characters.
@@ -324,7 +329,8 @@ class="form-control card-icon-image-input" name="cardIconImage_<%= index %>" value="<%= imageIconValue %>" - placeholder="/uploads/icon.png"> + placeholder="/uploads/icon.png" + maxlength="255" data-maxlength="255">
@@ -357,9 +361,8 @@ line) - Enter each content - item on a new line + rows="3" maxlength="220" data-maxlength="220"><%= (card.content || []).join('\n') %> + Each line is shown inside a compact contact card. Keep it to 1-3 short lines.
- Upload a custom icon image for this contact card + Custom icons render at about 28x28px inside a 64x64 circle. Use SVG or a square PNG/WebP at 64x64px or 128x128px.
- - Enter each content item on a new line + + Each line is shown inside a compact contact card. Keep it to 1-3 short lines.
+ Brand logo trong slider hiện hiển thị khoảng 159x48px. Khuyến nghị dùng SVG hoặc logo ngang nền trong suốt tối thiểu 320x96px.
<% (data.partners?.brands?.items || []).forEach(function(item, index) { %>
@@ -74,12 +77,13 @@
- +
+ Khuyến nghị SVG hoặc ảnh ngang 320x96px để logo không bị mờ trong slider.
@@ -134,11 +138,12 @@
- +
+ Khuyến nghị SVG hoặc ảnh ngang 320x96px để logo không bị mờ trong slider.
@@ -146,5 +151,8 @@
`; container.appendChild(div); + if (typeof initHomeCharacterCounters === "function") { + initHomeCharacterCounters(div); + } } - \ No newline at end of file + diff --git a/views/admin/home/sections/testimonials.ejs b/views/admin/home/sections/testimonials.ejs index 650af05..7bf9ab4 100644 --- a/views/admin/home/sections/testimonials.ejs +++ b/views/admin/home/sections/testimonials.ejs @@ -14,23 +14,27 @@
+ value="<%= data.testimonials?.heading || '' %>" placeholder="e.g., Student Reviews & Testimonials" + maxlength="64" data-maxlength="64" />
+ value="<%= data.testimonials?.subheading || '' %>" placeholder="e.g., What Our Students Say" + maxlength="40" data-maxlength="40" />
+ value="<%= data.testimonials?.videoUrl || '' %>" placeholder="https://www.youtube.com/watch?v=..." + maxlength="255" data-maxlength="255" />
+ Khung thumbnail desktop hiện khoảng 416x370px. Khuyến nghị upload tối thiểu 832x740px.
+ value="<%= data.testimonials?.videoThumbnail || '' %>" maxlength="255" data-maxlength="255" />
@@ -52,18 +54,20 @@
+ placeholder="e.g., United Kingdom" maxlength="40" data-maxlength="40" />
+ placeholder="e.g., UK" maxlength="12" data-maxlength="12" />
+ Khung ảnh desktop hiện khoảng 840x830px. Khuyến nghị ảnh gần vuông, tối thiểu 1000x1000px.
+ value="<%= featured.flag || '' %>" placeholder="/assets/img/home-1/feature/shape.png" + maxlength="255" data-maxlength="255" />
@@ -100,12 +104,14 @@
+ value="<%= data.visaCountries?.ctaButton?.label || '' %>" placeholder="e.g., Get Started" + maxlength="32" data-maxlength="32" />
+ value="<%= data.visaCountries?.ctaButton?.href || '' %>" placeholder="/contact" + maxlength="255" data-maxlength="255" />
@@ -142,4 +148,4 @@ } }; }; - \ No newline at end of file + diff --git a/views/admin/home/sections/visaSolutions.ejs b/views/admin/home/sections/visaSolutions.ejs index 1fefdcb..aa6d3cb 100644 --- a/views/admin/home/sections/visaSolutions.ejs +++ b/views/admin/home/sections/visaSolutions.ejs @@ -14,12 +14,14 @@
+ value="<%= data.visaSolutions?.heading || '' %>" placeholder="e.g., Comprehensive Visa Solutions" + maxlength="64" data-maxlength="64" />
+ value="<%= data.visaSolutions?.subheading || '' %>" placeholder="e.g., Our Expert Services" + maxlength="40" data-maxlength="40" />
@@ -53,22 +55,25 @@
+ value="<%= item.number || '' %>" placeholder="e.g., 01" + maxlength="4" data-maxlength="4" />
+ value="<%= item.title || '' %>" placeholder="e.g., Student Visa Guidance" + maxlength="56" data-maxlength="56" />
+ placeholder="Enter description" maxlength="180" data-maxlength="180"><%= item.description || '' %>
+ value="<%= item.link || '' %>" placeholder="/service-details" + maxlength="255" data-maxlength="255" />
@@ -134,4 +139,4 @@ updateLabels(); }); - \ No newline at end of file + diff --git a/views/admin/home/sections/whyChooseUs.ejs b/views/admin/home/sections/whyChooseUs.ejs index fd98ef3..af13e5b 100644 --- a/views/admin/home/sections/whyChooseUs.ejs +++ b/views/admin/home/sections/whyChooseUs.ejs @@ -15,23 +15,26 @@ + placeholder="e.g., Turning Study Abroad Dreams Into Reality" + maxlength="72" data-maxlength="72" />
+ value="<%= data.whyChooseUs?.subheading || '' %>" placeholder="e.g., About Our Consultancy" + maxlength="48" data-maxlength="48" />
+ value="<%= data.whyChooseUs?.highlightWord || '' %>" placeholder="e.g., Dreams" + maxlength="24" data-maxlength="24" /> This word in the heading will be wrapped in a colored span.
+ placeholder="Enter description" maxlength="260" data-maxlength="260"><%= data.whyChooseUs?.description || '' %>
@@ -50,10 +53,11 @@
- Recommended size: 375x419px + Khung desktop hiện khoảng 318x347px. Khuyến nghị upload ít nhất 750x820px, tỉ lệ dọc khoảng 0.91:1.
+ value="<%= data.whyChooseUs?.mainImage || '' %>" placeholder="/assets/img/home-1/about/about-1.jpg" + maxlength="255" data-maxlength="255" />
@@ -148,7 +156,7 @@
+ placeholder="Enter feature" maxlength="96" data-maxlength="96" />
<% }); %>
@@ -168,12 +176,14 @@
+ value="<%= data.whyChooseUs?.ctaButton?.label || '' %>" placeholder="e.g., Get Started" + maxlength="32" data-maxlength="32" />
+ value="<%= data.whyChooseUs?.ctaButton?.href || '' %>" placeholder="/about" + maxlength="255" data-maxlength="255" />
@@ -230,4 +240,4 @@ }, }; }; - \ No newline at end of file + From 48a230105cda35cc951e62c5cef976c4fbe2cb49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20Nh=E1=BA=ADt?= Date: Fri, 10 Apr 2026 03:29:00 +0700 Subject: [PATCH 08/13] refactor: update btn on/off tab in home page --- models/home.js | 30 ++++++ views/admin/home/sections/achievements.ejs | 111 +++++++++----------- views/admin/home/sections/blogPreview.ejs | 19 +++- views/admin/home/sections/faq.ejs | 20 +++- views/admin/home/sections/hero.ejs | 14 ++- views/admin/home/sections/partners.ejs | 60 +++++++---- views/admin/home/sections/testimonials.ejs | 14 ++- views/admin/home/sections/videoGallery.ejs | 14 ++- views/admin/home/sections/visaCountries.ejs | 14 ++- views/admin/home/sections/visaSolutions.ejs | 14 ++- views/admin/home/sections/whyChooseUs.ejs | 13 ++- 11 files changed, 228 insertions(+), 95 deletions(-) diff --git a/models/home.js b/models/home.js index 09d3d52..736b683 100644 --- a/models/home.js +++ b/models/home.js @@ -27,6 +27,9 @@ const HeroSlideSchema = new Schema( const HeroSchema = new Schema( { + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + // Background for whole hero section backgroundImage: { type: String, default: "" }, @@ -56,6 +59,9 @@ const WhyChooseUsItemSchema = new Schema( 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: "" }, @@ -81,6 +87,9 @@ const VisaSolutionItemSchema = new Schema( 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: [] }, @@ -101,6 +110,9 @@ const VisaCountrySchema = new Schema( 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: "" }, @@ -124,6 +136,9 @@ const TestimonialSchema = new Schema( 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 +150,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: "" }, @@ -152,6 +170,9 @@ const FaqItemSchema = new Schema( 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: "" }, @@ -173,6 +194,9 @@ const AchievementItemSchema = new Schema( 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: [] }, @@ -212,6 +236,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: () => ({}) }, }, @@ -237,6 +264,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: () => ({}) }, diff --git a/views/admin/home/sections/achievements.ejs b/views/admin/home/sections/achievements.ejs index 28f4bd5..95b1942 100644 --- a/views/admin/home/sections/achievements.ejs +++ b/views/admin/home/sections/achievements.ejs @@ -3,8 +3,17 @@
+
+
+
+ > +
+
+
+
-
+
General Information
@@ -13,23 +22,13 @@
- +
- +
@@ -45,54 +44,38 @@
- <% for(let i=0; i<4; i++) { - const item = (data.achievements?.items && data.achievements.items[i]) || {}; - %> -
-
-
-
Achievement #<%= i + 1 %>
-
-
-
- - + <% for(let i=0; i<4; i++) { const item=(data.achievements?.items && data.achievements.items[i]) || {}; %> +
+
+
+
Achievement #<%= i + 1 %> +
-
- - -
-
- - -
-
- - +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
- <% } %> + <% } %>
@@ -102,8 +85,11 @@ + \ No newline at end of file diff --git a/views/admin/home/sections/blogPreview.ejs b/views/admin/home/sections/blogPreview.ejs index 342fef7..241b6e0 100644 --- a/views/admin/home/sections/blogPreview.ejs +++ b/views/admin/home/sections/blogPreview.ejs @@ -3,6 +3,15 @@
+
+
+
+ > +
+
+
+
@@ -106,7 +115,7 @@ function toggleBlogSelection(card, blogId) { const checkbox = card.querySelector('.blog-checkbox'); const isChecking = !checkbox.checked; - + if (isChecking) { const checkedCount = document.querySelectorAll('.blog-checkbox:checked').length; if (checkedCount >= 3) { @@ -114,7 +123,7 @@ return; } } - + checkbox.checked = isChecking; handleCheckboxUpdate(card, checkbox.checked); } @@ -144,6 +153,9 @@ window.homeScrapers = window.homeScrapers || {}; window.homeScrapers.blogPreview = () => { const selectedIds = []; + + const enabled = document.getElementById("blogpreviewEnabled")?.checked !== false; + document.querySelectorAll('.blog-checkbox:checked').forEach(cb => { selectedIds.push(cb.value); }); @@ -158,7 +170,8 @@ href: document.getElementById('blogPreviewCtaHref').value }, selectedBlogIds: selectedIds, - items: [] // Server side will handle full items content + items: [],// Server side will handle full items content + enabled }; }; diff --git a/views/admin/home/sections/faq.ejs b/views/admin/home/sections/faq.ejs index 7176faf..8be7751 100644 --- a/views/admin/home/sections/faq.ejs +++ b/views/admin/home/sections/faq.ejs @@ -3,11 +3,24 @@
+
+
+
+ > +
+
+
+
-
+
Basic Information
+
+ > +
@@ -122,6 +135,8 @@ window.homeScrapers.faq = () => { const getVal = (id) => (document.getElementById(id)?.value || "").trim(); + const enabled = document.getElementById('faqEnabled')?.checked !== false; + const items = []; document.querySelectorAll(".faq-item").forEach((el, idx) => { const index = el.getAttribute("data-index") || idx; @@ -142,7 +157,8 @@ label: getVal("faqCtaLabel"), href: getVal("faqCtaHref") }, - items + items, + enabled }; }; \ No newline at end of file diff --git a/views/admin/home/sections/hero.ejs b/views/admin/home/sections/hero.ejs index 60b2adc..d1992ab 100644 --- a/views/admin/home/sections/hero.ejs +++ b/views/admin/home/sections/hero.ejs @@ -3,14 +3,20 @@
+
+
+
+ > +
+
+
+
Hero Background
-
- -
@@ -173,6 +179,7 @@ const getVal = (id) => (document.getElementById(id)?.value || "").trim(); const backgroundImage = getVal("heroBackgroundImage"); + const enabled = document.getElementById("heroEnabled")?.checked !== false; const slides = []; const slideEls = document.querySelectorAll(".hero-slide-item"); @@ -208,6 +215,7 @@ const first = slides[0] || {}; return { + enabled, backgroundImage, slides, title: first.title || "", diff --git a/views/admin/home/sections/partners.ejs b/views/admin/home/sections/partners.ejs index 1536ade..5d74cc6 100644 --- a/views/admin/home/sections/partners.ejs +++ b/views/admin/home/sections/partners.ejs @@ -3,48 +3,61 @@
+
+
+
+ > +
+
+
+
-
+
Awards & Certifications (Fixed 4 Items)
- <% for(let i=0; i<4; i++) { - const item = (data.partners?.visaConsultancy?.items && data.partners.visaConsultancy.items[i]) || {}; - %> + <% for(let i=0; i<4; i++) { const item=(data.partners?.visaConsultancy?.items && + data.partners.visaConsultancy.items[i]) || {}; %>
-
Award #<%= i + 1 %>
+
Award #<%= i + 1 %> +
- +
- +
- -
- +
- <% } %> + <% } %>
@@ -69,24 +82,27 @@
Brand Logo -
- -
- +
- <% }); %> + <% }); %>
@@ -97,8 +113,11 @@ \ No newline at end of file diff --git a/views/admin/home/sections/visaCountries.ejs b/views/admin/home/sections/visaCountries.ejs index 67012ad..ea43bb5 100644 --- a/views/admin/home/sections/visaCountries.ejs +++ b/views/admin/home/sections/visaCountries.ejs @@ -3,8 +3,17 @@
+
+
+
+ > +
+
+
+
-
+
Basic Information
@@ -120,6 +129,8 @@ window.homeScrapers.visaCountries = () => { const getVal = (id) => (document.getElementById(id)?.value || "").trim(); + const enabled = document.getElementById("visaCountriesEnabled")?.checked !== false + const visaTypesRaw = getVal("visaCountriesVisaTypes_0"); const visaTypes = visaTypesRaw ? visaTypesRaw.split(",").map((s) => s.trim()).filter(Boolean) : []; @@ -132,6 +143,7 @@ }; return { + enabled, heading: getVal("visaCountriesHeading"), subheading: getVal("visaCountriesSubheading"), description: getVal("visaCountriesDescription"), diff --git a/views/admin/home/sections/visaSolutions.ejs b/views/admin/home/sections/visaSolutions.ejs index 1fefdcb..ce24125 100644 --- a/views/admin/home/sections/visaSolutions.ejs +++ b/views/admin/home/sections/visaSolutions.ejs @@ -3,8 +3,17 @@
+
+
+
+ > +
+
+
+
-
+
Basic Information
@@ -86,6 +95,8 @@ window.homeScrapers.visaSolutions = () => { const getVal = (id) => (document.getElementById(id)?.value || "").trim(); + const enabled = document.getElementById("visaSolutionsEnabled")?.checked !== false; + const items = []; document.querySelectorAll(".visa-solution-item").forEach((el, idx) => { const index = el.getAttribute("data-index") || idx; @@ -104,6 +115,7 @@ heading: getVal("visaSolutionsHeading"), subheading: getVal("visaSolutionsSubheading"), items, + enabled }; }; diff --git a/views/admin/home/sections/whyChooseUs.ejs b/views/admin/home/sections/whyChooseUs.ejs index fd98ef3..16cef63 100644 --- a/views/admin/home/sections/whyChooseUs.ejs +++ b/views/admin/home/sections/whyChooseUs.ejs @@ -3,8 +3,17 @@
+
+
+
+ > +
+
+
+
-
+
Basic Information
@@ -187,6 +196,7 @@ window.homeScrapers = window.homeScrapers || {}; window.homeScrapers.whyChooseUs = () => { const getVal = (id) => (document.getElementById(id)?.value || "").trim(); + const enabled = document.getElementById("whyChooseUsEnabled")?.checked !== false; // Collect items const items = []; @@ -224,6 +234,7 @@ secondaryImage: getVal("whyChooseUsSecondaryImage"), items, features, + enabled, ctaButton: { label: getVal("whyChooseUsCtaLabel"), href: getVal("whyChooseUsCtaHref"), From 51c6303437e44c15ff3a87e149b1cfa659ee072b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=E1=BB=91ng=20Th=C3=A0nh=20=C4=90=E1=BA=A1t?= <84076965+tongthanhdat009@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:55:15 +0700 Subject: [PATCH 09/13] feat: standardize admin form limits and guidance --- assets/css/components/form.css | 41 + constants/contentLengthRules.js | 191 ++++ controllers/aboutUsController.js | 35 + controllers/activityController.js | 53 ++ controllers/bookingController.js | 19 +- controllers/formController.js | 10 +- controllers/homeController.js | 6 +- controllers/pageController.js | 8 +- controllers/pricingController.js | 25 + controllers/settingController.js | 6 +- models/aboutUs.js | 86 +- models/activity.js | 63 +- models/booking.js | 56 +- models/pricing.js | 26 +- public/js/admin-form-helpers.js | 867 ++++++++++++++++++ ..._04_07_110000_add_home_floating_contact.js | 6 +- utils/lengthValidation.js | 158 ++++ views/admin/activity/form.ejs | 14 +- views/admin/blog/create.ejs | 14 +- views/admin/blog/edit.ejs | 16 +- views/admin/booking/index.ejs | 10 +- views/admin/contact/index.ejs | 63 +- views/admin/header/index.ejs | 12 +- views/admin/home/index.ejs | 89 +- views/admin/home/sections/floatingContact.ejs | 4 +- views/admin/home/sections/hero.ejs | 25 +- views/admin/home/sections/partners.ejs | 10 +- views/admin/home/sections/testimonials.ejs | 4 +- views/admin/home/sections/visaCountries.ejs | 2 +- views/admin/home/sections/whyChooseUs.ejs | 6 +- views/admin/level/index.ejs | 2 +- views/admin/visa/index.ejs | 112 ++- views/layouts/admin.ejs | 7 +- views/layouts/main.ejs | 7 +- 34 files changed, 1692 insertions(+), 361 deletions(-) create mode 100644 constants/contentLengthRules.js create mode 100644 public/js/admin-form-helpers.js create mode 100644 utils/lengthValidation.js diff --git a/assets/css/components/form.css b/assets/css/components/form.css index 069efd1..490dbe8 100644 --- a/assets/css/components/form.css +++ b/assets/css/components/form.css @@ -37,6 +37,47 @@ margin-top: var(--spacing-1); } +.admin-field-counter { + display: block; + margin-top: var(--spacing-1); + color: var(--text-muted); + font-size: var(--font-size-xs); + line-height: 1.4; + text-align: left; + width: 100%; +} + +.admin-field-counter.is-danger { + color: #b42318; + font-weight: var(--font-weight-medium); +} + +.admin-upload-guidance { + margin-top: var(--spacing-2); + padding: 0.85rem 1rem; + border: 1px solid rgba(184, 183, 106, 0.22); + border-radius: var(--border-radius); + background: linear-gradient(180deg, rgba(184, 183, 106, 0.08), rgba(184, 183, 106, 0.03)); + color: var(--text-main); +} + +.admin-upload-guidance__title { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + margin-bottom: 0.35rem; +} + +.admin-upload-guidance__list { + margin: 0; + padding-left: 1.15rem; + color: var(--text-muted); + font-size: var(--font-size-xs); +} + +.admin-upload-guidance__list li + li { + margin-top: 0.25rem; +} + /* Validation Styles */ .invalid-feedback { font-size: var(--font-size-xs); diff --git a/constants/contentLengthRules.js b/constants/contentLengthRules.js new file mode 100644 index 0000000..cf50b2a --- /dev/null +++ b/constants/contentLengthRules.js @@ -0,0 +1,191 @@ +const ABOUT_US_LENGTH_RULES = [ + { path: "hero.title", label: "Hero title", maxLength: 80 }, + { path: "hero.breadcrumb.*", label: "Hero breadcrumb", maxLength: 80 }, + { path: "hero.backgroundImage", label: "Hero background image", maxLength: 255 }, + { path: "intro.subheading", label: "Intro subheading", maxLength: 80 }, + { path: "intro.heading", label: "Intro heading", maxLength: 120 }, + { path: "intro.description", label: "Intro description", maxLength: 1000 }, + { path: "intro.image", label: "Intro image", maxLength: 255 }, + { path: "mission.subheading", label: "Mission subheading", maxLength: 80 }, + { path: "mission.heading", label: "Mission heading", maxLength: 120 }, + { path: "mission.description", label: "Mission description", maxLength: 1000 }, + { path: "mission.images.main", label: "Mission main image", maxLength: 255 }, + { path: "mission.images.secondary", label: "Mission secondary image", maxLength: 255 }, + { path: "mission.images.bgShape", label: "Mission background shape", maxLength: 255 }, + { path: "mission.images.planeShape", label: "Mission plane shape", maxLength: 255 }, + { path: "mission.images.topShape", label: "Mission top shape", maxLength: 255 }, + { path: "mission.images.globeShape", label: "Mission globe shape", maxLength: 255 }, + { path: "mission.items.*.icon", label: "Mission item icon", maxLength: 255 }, + { path: "mission.items.*.label", label: "Mission item label", maxLength: 80 }, + { path: "mission.items.*.description", label: "Mission item description", maxLength: 240 }, + { path: "mission.features.*", label: "Mission feature", maxLength: 80 }, + { path: "mission.ctaButton.label", label: "Mission CTA label", maxLength: 64 }, + { path: "mission.ctaButton.href", label: "Mission CTA link", maxLength: 255 }, + { path: "features.backgroundImage", label: "Features background image", maxLength: 255 }, + { path: "features.subheading", label: "Features subheading", maxLength: 80 }, + { path: "features.heading", label: "Features heading", maxLength: 120 }, + { path: "features.description", label: "Features description", maxLength: 1000 }, + { path: "features.image", label: "Features image", maxLength: 255 }, + { path: "features.items.*.icon", label: "Feature item icon", maxLength: 255 }, + { path: "features.items.*.title", label: "Feature item title", maxLength: 80 }, + { path: "features.items.*.description", label: "Feature item description", maxLength: 240 }, + { path: "features.ctaButton.label", label: "Features CTA label", maxLength: 64 }, + { path: "features.ctaButton.href", label: "Features CTA link", maxLength: 255 }, + { path: "news.subheading", label: "News subheading", maxLength: 80 }, + { path: "news.heading", label: "News heading", maxLength: 120 }, + { path: "news.ctaButton.label", label: "News CTA label", maxLength: 64 }, + { path: "news.ctaButton.href", label: "News CTA link", maxLength: 255 }, + { path: "news.selectedBlogIds.*", label: "Selected blog ID", maxLength: 64 }, + { path: "news.items.*.title", label: "News item title", maxLength: 120 }, + { path: "news.items.*.category", label: "News item category", maxLength: 48 }, + { path: "news.items.*.date", label: "News item date", maxLength: 32 }, + { path: "news.items.*.author.name", label: "News item author", maxLength: 48 }, + { path: "news.items.*.author.avatar", label: "News item author avatar", maxLength: 255 }, + { path: "news.items.*.link", label: "News item link", maxLength: 255 }, + { path: "news.items.*.thumbnail", label: "News item thumbnail", maxLength: 255 }, +]; + +const PRICING_LENGTH_RULES = [ + { path: "hero.title", label: "Hero title", maxLength: 60 }, + { path: "hero.backgroundImage", label: "Hero background image", maxLength: 255 }, + { path: "hero.shapeImage", label: "Hero shape image", maxLength: 255 }, + { path: "hero.breadcrumb.*.text", label: "Hero breadcrumb text", maxLength: 40 }, + { path: "hero.breadcrumb.*.link", label: "Hero breadcrumb link", maxLength: 255 }, + { path: "pricingSection.subtitle", label: "Pricing section subtitle", maxLength: 64 }, + { path: "pricingSection.heading", label: "Pricing section heading", maxLength: 120 }, + { path: "pricingSection.description", label: "Pricing section description", maxLength: 500 }, + { path: "plans.monthly.*.name", label: "Monthly plan name", maxLength: 64 }, + { path: "plans.monthly.*.price", label: "Monthly plan price", maxLength: 32 }, + { path: "plans.monthly.*.period", label: "Monthly plan period", maxLength: 8 }, + { path: "plans.monthly.*.currency", label: "Monthly plan currency", maxLength: 8 }, + { path: "plans.monthly.*.buttonText", label: "Monthly plan button text", maxLength: 64 }, + { path: "plans.monthly.*.buttonLink", label: "Monthly plan button link", maxLength: 255 }, + { path: "plans.monthly.*.buttonIcon", label: "Monthly plan button icon", maxLength: 64 }, + { path: "plans.monthly.*.features.*", label: "Monthly plan feature", maxLength: 96 }, + { path: "plans.yearly.*.name", label: "Yearly plan name", maxLength: 64 }, + { path: "plans.yearly.*.price", label: "Yearly plan price", maxLength: 32 }, + { path: "plans.yearly.*.period", label: "Yearly plan period", maxLength: 8 }, + { path: "plans.yearly.*.currency", label: "Yearly plan currency", maxLength: 8 }, + { path: "plans.yearly.*.buttonText", label: "Yearly plan button text", maxLength: 64 }, + { path: "plans.yearly.*.buttonLink", label: "Yearly plan button link", maxLength: 255 }, + { path: "plans.yearly.*.buttonIcon", label: "Yearly plan button icon", maxLength: 64 }, + { path: "plans.yearly.*.features.*", label: "Yearly plan feature", maxLength: 96 }, + { path: "testimonials.subtitle", label: "Testimonials subtitle", maxLength: 64 }, + { path: "testimonials.heading", label: "Testimonials heading", maxLength: 120 }, + { path: "testimonials.buttonText", label: "Testimonials button text", maxLength: 64 }, + { path: "testimonials.buttonLink", label: "Testimonials button link", maxLength: 255 }, + { path: "testimonials.buttonIcon", label: "Testimonials button icon", maxLength: 64 }, + { path: "testimonials.image", label: "Testimonials image", maxLength: 255 }, + { path: "testimonials.items.*.name", label: "Testimonial name", maxLength: 64 }, + { path: "testimonials.items.*.role", label: "Testimonial role", maxLength: 64 }, + { path: "testimonials.items.*.content", label: "Testimonial content", maxLength: 400 }, +]; + +const BOOKING_LENGTH_RULES = [ + { path: "hero.title", label: "Hero title", maxLength: 80 }, + { path: "hero.backgroundImage", label: "Hero background image", maxLength: 255 }, + { path: "searchBar.locationLabel", label: "Search bar location label", maxLength: 64 }, + { path: "searchBar.holidaySeasonLabel", label: "Search bar holiday label", maxLength: 64 }, + { path: "searchBar.searchButtonText", label: "Search bar button text", maxLength: 64 }, + { path: "filterPanel.title", label: "Filter panel title", maxLength: 80 }, + { path: "filterPanel.priceTitle", label: "Filter panel price title", maxLength: 64 }, + { path: "filterPanel.priceLabel", label: "Filter panel price label", maxLength: 64 }, + { path: "filterPanel.pricePlaceholder", label: "Filter panel price placeholder", maxLength: 64 }, + { path: "filterPanel.ageTitle", label: "Filter panel age title", maxLength: 64 }, + { path: "filterPanel.ageSelectPlaceholder", label: "Filter panel age placeholder", maxLength: 64 }, + { path: "filterPanel.activitiesTitle", label: "Filter panel activities title", maxLength: 64 }, + { path: "filterPanel.ratingTitle", label: "Filter panel rating title", maxLength: 64 }, + { path: "filterPanel.resetButtonText", label: "Filter panel reset button text", maxLength: 64 }, + { path: "filterPanel.ratingOptions.*.value", label: "Filter rating option value", maxLength: 48 }, + { path: "filterPanel.ratingOptions.*.label", label: "Filter rating option label", maxLength: 64 }, + { path: "programs.*.value", label: "Program value", maxLength: 64 }, + { path: "programs.*.label", label: "Program label", maxLength: 64 }, + { path: "holidays.*.value", label: "Holiday value", maxLength: 64 }, + { path: "holidays.*.label", label: "Holiday label", maxLength: 64 }, + { path: "locations.*.value", label: "Location value", maxLength: 64 }, + { path: "locations.*.label", label: "Location label", maxLength: 64 }, + { path: "camps.*.name", label: "Camp name", maxLength: 120 }, + { path: "camps.*.priceText", label: "Camp price text", maxLength: 32 }, + { path: "camps.*.image", label: "Camp image", maxLength: 255 }, + { path: "camps.*.link", label: "Camp link", maxLength: 255 }, + { path: "camps.*.program", label: "Camp program", maxLength: 80 }, + { path: "configuration.discounts.*.id", label: "Discount ID", maxLength: 64 }, + { path: "configuration.discounts.*.name", label: "Discount name", maxLength: 64 }, + { path: "configuration.discounts.*.description", label: "Discount description", maxLength: 220 }, + { path: "configuration.vouchers.*.validCodes", label: "Voucher code", maxLength: 64 }, + { path: "formSteps.*.title", label: "Booking step title", maxLength: 80 }, + { path: "formSteps.*.sections.*.id", label: "Booking section ID", maxLength: 48 }, + { path: "formSteps.*.sections.*.fields.*.name", label: "Booking field name", maxLength: 32 }, + { path: "formSteps.*.sections.*.fields.*.label", label: "Booking field label", maxLength: 48 }, + { path: "formSteps.*.sections.*.fields.*.placeholder", label: "Booking field placeholder", maxLength: 72 }, + { path: "formSteps.*.sections.*.fields.*.programmeName", label: "Booking field programme name", maxLength: 48 }, +]; + +const ACTIVITY_LENGTH_RULES = [ + { path: "hero.titleActivities", label: "Activity hero title", maxLength: 80 }, + { path: "hero.titleBooking", label: "Booking hero title", maxLength: 80 }, + { path: "hero.bannerImageActivities", label: "Activity hero banner image", maxLength: 255 }, + { path: "hero.bannerImageBooking", label: "Booking hero banner image", maxLength: 255 }, + { path: "name", label: "Activity name", maxLength: 120 }, + { path: "priceText", label: "Activity price text", maxLength: 32 }, + { path: "image", label: "Activity image", maxLength: 255 }, + { path: "link", label: "Activity link", maxLength: 255 }, + { path: "program", label: "Activity program", maxLength: 80 }, + { path: "campDetail.hero.title", label: "Camp detail hero title", maxLength: 120 }, + { path: "campDetail.hero.bgImage", label: "Camp detail hero image", maxLength: 255 }, + { path: "campDetail.basicInfo.location", label: "Camp location", maxLength: 120 }, + { path: "campDetail.basicInfo.ageRange", label: "Camp age range", maxLength: 120 }, + { path: "campDetail.basicInfo.accommodationType", label: "Camp accommodation type", maxLength: 120 }, + { path: "campDetail.basicInfo.careLevel", label: "Camp care level", maxLength: 120 }, + { path: "campDetail.basicInfo.languages", label: "Camp languages", maxLength: 120 }, + { path: "campDetail.sidebar.contact.phone", label: "Camp contact phone", maxLength: 32 }, + { path: "campDetail.sidebar.contact.email", label: "Camp contact email", maxLength: 120 }, + { path: "campDetail.sidebar.menuItems.*.name", label: "Sidebar menu item name", maxLength: 64 }, + { path: "campDetail.sidebar.menuItems.*.href", label: "Sidebar menu item link", maxLength: 255 }, + { path: "campDetail.sidebar.upcomingTours.*.title", label: "Upcoming tour title", maxLength: 120 }, + { path: "campDetail.sidebar.upcomingTours.*.location", label: "Upcoming tour location", maxLength: 80 }, + { path: "campDetail.sidebar.upcomingTours.*.image", label: "Upcoming tour image", maxLength: 255 }, + { path: "campDetail.mainGallery.overlayInfo.location", label: "Gallery overlay location", maxLength: 120 }, + { path: "campDetail.mainGallery.overlayInfo.season", label: "Gallery overlay season", maxLength: 120 }, + { path: "campDetail.mainGallery.overlayInfo.languages", label: "Gallery overlay languages", maxLength: 120 }, + { path: "campDetail.mainGallery.slides.*.url", label: "Gallery slide image", maxLength: 255 }, + { path: "campDetail.mainGallery.slides.*.alt", label: "Gallery slide alt text", maxLength: 120 }, + { path: "campDetail.eventSchedule.startDate", label: "Event schedule start date", maxLength: 32 }, + { path: "campDetail.eventSchedule.duration", label: "Event schedule duration", maxLength: 32 }, + { path: "campDetail.eventSchedule.tickets", label: "Event schedule tickets", maxLength: 32 }, + { path: "campDetail.sections.overview.intro", label: "Overview intro", maxLength: 240 }, + { path: "campDetail.sections.overview.mainText", label: "Overview main text", maxLength: 1000 }, + { path: "campDetail.sections.overview.featureImage", label: "Overview feature image", maxLength: 255 }, + { path: "campDetail.sections.overview.features.*", label: "Overview feature", maxLength: 120 }, + { path: "campDetail.sections.location.title", label: "Location section title", maxLength: 120 }, + { path: "campDetail.sections.location.description", label: "Location section description", maxLength: 1000 }, + { path: "campDetail.sections.accommodation.title", label: "Accommodation section title", maxLength: 120 }, + { path: "campDetail.sections.accommodation.quote", label: "Accommodation quote", maxLength: 120 }, + { path: "campDetail.sections.accommodation.mainHeading", label: "Accommodation main heading", maxLength: 120 }, + { path: "campDetail.sections.accommodation.description", label: "Accommodation description", maxLength: 1000 }, + { path: "campDetail.sections.program.title", label: "Program section title", maxLength: 120 }, + { path: "campDetail.sections.program.heading", label: "Program section heading", maxLength: 120 }, + { path: "campDetail.sections.program.quote", label: "Program quote", maxLength: 120 }, + { path: "campDetail.sections.program.description", label: "Program description", maxLength: 1000 }, + { path: "campDetail.sections.meals.title", label: "Meals section title", maxLength: 120 }, + { path: "campDetail.sections.meals.description", label: "Meals section description", maxLength: 1000 }, + { path: "campDetail.sections.meals.footer", label: "Meals footer text", maxLength: 500 }, + { path: "campDetail.sections.insurance.title", label: "Insurance section title", maxLength: 120 }, + { path: "campDetail.sections.insurance.description", label: "Insurance description", maxLength: 1000 }, + { path: "campDetail.sections.insurance.package.title", label: "Insurance package title", maxLength: 120 }, + { path: "campDetail.sections.insurance.package.desc", label: "Insurance package description", maxLength: 1000 }, + { path: "campDetail.sections.insurance.cancellation.title", label: "Cancellation title", maxLength: 120 }, + { path: "campDetail.sections.insurance.cancellation.desc", label: "Cancellation description", maxLength: 1000 }, + { path: "filters.*.label", label: "Filter label", maxLength: 64 }, + { path: "filters.*.value", label: "Filter value", maxLength: 64 }, + { path: "filters.*.items.*.label", label: "Filter item label", maxLength: 64 }, + { path: "filters.*.items.*.value", label: "Filter item value", maxLength: 64 }, + { path: "bookingSessions.*.sessionId", label: "Session ID", maxLength: 80 }, +]; + +module.exports = { + ABOUT_US_LENGTH_RULES, + PRICING_LENGTH_RULES, + BOOKING_LENGTH_RULES, + ACTIVITY_LENGTH_RULES, +}; diff --git a/controllers/aboutUsController.js b/controllers/aboutUsController.js index b46b1ee..42284c0 100644 --- a/controllers/aboutUsController.js +++ b/controllers/aboutUsController.js @@ -2,10 +2,33 @@ const { addBaseUrlToImages } = require("../utils/imageHelper"); const AboutUs = require("../models/aboutUs"); const Blog = require("../models/blog"); const jsonHelper = require("../utils/jsonHelper"); +const { + validateLengthRules, + summarizeLengthErrors, +} = require("../utils/lengthValidation"); +const { + ABOUT_US_LENGTH_RULES, +} = require("../constants/contentLengthRules"); const writeAuditLog = require("../audit/writeAuditLog"); const diffObject = require("../audit/diffObject"); const AUDIT_ACTIONS = require("../constants/auditAction"); +const handleLengthValidation = (validation, req, res, options = {}) => { + const message = + summarizeLengthErrors(validation, 3) || "One or more fields exceed the allowed length."; + + if (options.json) { + return res.status(400).json({ + success: false, + error: message, + errors: validation.errors, + }); + } + + req.flash("error_msg", message); + return res.redirect(options.redirectTo || "/admin/about-us"); +}; + /** * GET /api/about * Lấy dữ liệu About Us (Public API cho website và CMS load dữ liệu) @@ -110,6 +133,11 @@ exports.updateAbout = async (req, res) => { // ✅ Capture BEFORE state const beforeData = JSON.parse(JSON.stringify(doc.toObject())); + const validation = validateLengthRules(updateData, ABOUT_US_LENGTH_RULES); + if (!validation.valid) { + return handleLengthValidation(validation, req, res, { json: true }); + } + // Use .set() for better handling of nested objects/arrays in Mongoose doc.set(updateData); await doc.save(); @@ -210,6 +238,13 @@ exports.update = async (req, res) => { // ✅ Capture BEFORE state const beforeData = JSON.parse(JSON.stringify(doc.toObject())); + const validation = validateLengthRules(updateData, ABOUT_US_LENGTH_RULES); + if (!validation.valid) { + return handleLengthValidation(validation, req, res, { + redirectTo: `/admin/about-us?activeTab=${req.query.activeTab || "hero"}`, + }); + } + doc.set(updateData); await doc.save(); diff --git a/controllers/activityController.js b/controllers/activityController.js index 532fffb..dc1f9f0 100644 --- a/controllers/activityController.js +++ b/controllers/activityController.js @@ -1,6 +1,22 @@ const {addBaseUrlToImages} = require("../utils/imageHelper"); const Activity = require("../models/activity"); const mongoose = require('mongoose'); +const { + validateLengthRules, + summarizeLengthErrors, +} = require("../utils/lengthValidation"); +const { + ACTIVITY_LENGTH_RULES, +} = require("../constants/contentLengthRules"); + +const getActivityLengthMessage = (validation) => + summarizeLengthErrors(validation, 3) || + "One or more fields exceed the allowed length."; + +const redirectWithLengthError = (req, res, path, validation) => { + req.flash("error_msg", getActivityLengthMessage(validation)); + return req.session.save(() => res.redirect(path)); +}; // -------------------- Public (API) exports -------------------- @@ -302,6 +318,15 @@ exports.updateFilters = async (req, res) => { try { // Provide minimal valid fields when inserting a new filters document so // schema validators (e.g., age validator) do not fail on upsert. + const filterLengthValidation = validateLengthRules( + { filters: sanitizedFilters }, + ACTIVITY_LENGTH_RULES, + ); + if (!filterLengthValidation.valid) { + req.flash("error_msg", getActivityLengthMessage(filterLengthValidation)); + return res.redirect("/admin/activity"); + } + const setOnInsert = { name: "_filters_doc", price: 0, @@ -353,6 +378,14 @@ exports.updateHero = async (req, res) => { bannerImageBooking: bannerImageBooking || '/uploads/banner/b9.jpg', }; + const heroLengthValidation = validateLengthRules( + { hero }, + ACTIVITY_LENGTH_RULES, + ); + if (!heroLengthValidation.valid) { + return redirectWithLengthError(req, res, "/admin/activity", heroLengthValidation); + } + // Update all activity docs to keep hero consistent await Activity.updateMany({ isFiltersDoc: { $ne: true } }, { $set: { hero } }); @@ -413,6 +446,16 @@ exports.create = async (req, res) => { try { const activityData = parseActivityFormData(req.body); + const lengthValidation = validateLengthRules(activityData, ACTIVITY_LENGTH_RULES); + if (!lengthValidation.valid) { + return redirectWithLengthError( + req, + res, + "/admin/activity/create", + lengthValidation, + ); + } + const newActivity = new Activity(activityData); await newActivity.save(); @@ -465,6 +508,16 @@ exports.update = async (req, res) => { // Force status to active on update (always set isActive true when editing) activityData.isActive = true; + const lengthValidation = validateLengthRules(activityData, ACTIVITY_LENGTH_RULES); + if (!lengthValidation.valid) { + return redirectWithLengthError( + req, + res, + `/admin/activity/${req.params.id}/edit`, + lengthValidation, + ); + } + await Activity.findByIdAndUpdate(req.params.id, activityData, {new: true}); req.flash("success_msg", "Activity updated successfully"); diff --git a/controllers/bookingController.js b/controllers/bookingController.js index ccca6fb..79a6412 100644 --- a/controllers/bookingController.js +++ b/controllers/bookingController.js @@ -2,6 +2,17 @@ const fs = require('fs'); const path = require('path'); const { addBaseUrlToImages } = require("../utils/imageHelper"); const Booking = require("../models/booking"); +const { + validateLengthRules, + summarizeLengthErrors, +} = require("../utils/lengthValidation"); +const { + BOOKING_LENGTH_RULES, +} = require("../constants/contentLengthRules"); + +const getBookingLengthMessage = (validation) => + summarizeLengthErrors(validation, 3) || + "One or more fields exceed the allowed length."; // -------------------- Public helpers -------------------- const getBookingData = async () => { @@ -398,6 +409,12 @@ exports.update = async (req, res) => { return req.session.save(() => res.redirect("/admin/booking")); } + const lengthValidation = validateLengthRules(updateData, BOOKING_LENGTH_RULES); + if (!lengthValidation.valid) { + req.flash("error_msg", getBookingLengthMessage(lengthValidation)); + return req.session.save(() => res.redirect("/admin/booking")); + } + // Validate data structure const validation = validateBookingData(updateData); if (!validation.isValid) { @@ -546,4 +563,4 @@ const getFinalBooking = (staticBooking, dbBooking) => { } return merged; -}; \ No newline at end of file +}; diff --git a/controllers/formController.js b/controllers/formController.js index b31ca98..2139fa2 100644 --- a/controllers/formController.js +++ b/controllers/formController.js @@ -7,13 +7,13 @@ const formController = { try { res.render('admin/form/index', { layout: 'layouts/admin', - title: 'Quản lý Form', + title: 'Form Management', user: req.session.user, }); } catch (error) { console.error('Error loading form management page:', error); res.status(500).render('error', { - message: 'Lỗi khi tải trang quản lý form', + message: 'Failed to load the form management page', error: error }); } @@ -29,16 +29,16 @@ const formController = { res.json({ success: true, - message: 'Cập nhật form thành công' + message: 'Form settings updated successfully' }); } catch (error) { console.error('Error updating form:', error); res.status(500).json({ success: false, - message: 'Lỗi khi cập nhật form' + message: 'Failed to update form settings' }); } } }; -module.exports = formController; \ No newline at end of file +module.exports = formController; diff --git a/controllers/homeController.js b/controllers/homeController.js index 9b8e2e7..043f51c 100644 --- a/controllers/homeController.js +++ b/controllers/homeController.js @@ -118,7 +118,7 @@ const normalizeStoredImagePath = (imagePath) => { const getDefaultFloatingContactData = () => ({ enabled: true, position: "bottom-right", - panelTitle: "Anh chị cần tư vấn hay hỗ trợ gì thêm không ạ?", + panelTitle: "Do you need any additional advice or support?", brand: { imageSrc: "/assets/img/logo/black-logo.svg", imageAlt: "HAI Learning", @@ -132,7 +132,7 @@ const getDefaultFloatingContactData = () => ({ id: "facebook", platform: "facebook", enabled: true, - label: "Nhắn tin qua Facebook", + label: "Message via Facebook", subtitle: "facebook.com/hailearning.edu.vn", href: "https://www.facebook.com/hailearning.edu.vn/", iconImage: "/uploads/home/floating-contact/Facebook_Logo_Primary.webp", @@ -145,7 +145,7 @@ const getDefaultFloatingContactData = () => ({ id: "zalo", platform: "zalo", enabled: true, - label: "Nhắn tin qua Zalo", + label: "Message via Zalo", subtitle: "zalo.me/84961834040", href: "https://zalo.me/84961834040", iconImage: "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp", diff --git a/controllers/pageController.js b/controllers/pageController.js index 03daf3a..e5d96d9 100644 --- a/controllers/pageController.js +++ b/controllers/pageController.js @@ -8,7 +8,7 @@ exports.getAllPages = async (req, res) => { const pages = content.pages || []; res.render('admin/pages/index', { - title: 'Quản lý trang', + title: 'Page Management', pages }); } catch (err) { @@ -21,7 +21,7 @@ exports.getAllPages = async (req, res) => { // Hiển thị form tạo trang mới exports.getAddPage = (req, res) => { res.render('admin/pages/add', { - title: 'Thêm trang mới' + title: 'Add New Page' }); }; @@ -95,7 +95,7 @@ exports.getEditPage = async (req, res) => { } res.render('admin/pages/edit', { - title: 'Chỉnh sửa trang', + title: 'Edit Page', page }); } catch (err) { @@ -225,4 +225,4 @@ exports.getPageBySlug = async (req, res) => { message: 'An error occurred while loading the page. Please try again later.' }); } -}; \ No newline at end of file +}; diff --git a/controllers/pricingController.js b/controllers/pricingController.js index d362c3c..fd9722a 100644 --- a/controllers/pricingController.js +++ b/controllers/pricingController.js @@ -1,8 +1,19 @@ const Pricing = require("../models/pricing"); +const { + validateLengthRules, + summarizeLengthErrors, +} = require("../utils/lengthValidation"); +const { + PRICING_LENGTH_RULES, +} = require("../constants/contentLengthRules"); const writeAuditLog = require("../audit/writeAuditLog"); const diffObject = require("../audit/diffObject"); const AUDIT_ACTIONS = require("../constants/auditAction"); +const getLengthValidationMessage = (validation) => + summarizeLengthErrors(validation, 3) || + "One or more fields exceed the allowed length."; + // ==================== CMS ADMIN FUNCTIONS ==================== // Render admin page for pricing management @@ -86,6 +97,20 @@ exports.update = async (req, res) => { ? JSON.parse(testimonials) : testimonials; + const validation = validateLengthRules( + { + hero: heroData, + pricingSection: pricingSectionData, + plans: plansData, + testimonials: testimonialsData, + }, + PRICING_LENGTH_RULES, + ); + if (!validation.valid) { + req.flash("error", getLengthValidationMessage(validation)); + return res.redirect("/admin/pricing"); + } + let pricing = await Pricing.findOne({ name: "default" }); // ✅ Capture BEFORE state diff --git a/controllers/settingController.js b/controllers/settingController.js index 71a6561..d23d8f3 100644 --- a/controllers/settingController.js +++ b/controllers/settingController.js @@ -7,11 +7,11 @@ exports.getSettings = async (req, res) => { const content = readJsonFile('content'); const settings = content.settings || { siteName: 'CMS-SIMS', - description: 'Hệ thống quản lý nội dung đơn giản' + description: 'Simple content management system' }; res.render('admin/settings', { - title: 'Cài đặt hệ thống', + title: 'System Settings', settings }); } catch (err) { @@ -53,4 +53,4 @@ exports.updateSettings = async (req, res) => { req.flash('error_msg', 'Error updating settings'); res.redirect('/admin/settings'); } -}; \ No newline at end of file +}; diff --git a/models/aboutUs.js b/models/aboutUs.js index d72dc07..e7e5ca0 100644 --- a/models/aboutUs.js +++ b/models/aboutUs.js @@ -3,87 +3,87 @@ const mongoose = require("mongoose"); const aboutUsSchema = new mongoose.Schema( { hero: { - title: String, - breadcrumb: [String], - backgroundImage: String, + title: { type: String, trim: true, maxlength: 80 }, + breadcrumb: [{ type: String, trim: true, maxlength: 80 }], + backgroundImage: { type: String, trim: true, maxlength: 255 }, }, intro: { - subheading: String, - heading: String, - description: String, - image: String, + subheading: { type: String, trim: true, maxlength: 80 }, + heading: { type: String, trim: true, maxlength: 120 }, + description: { type: String, trim: true, maxlength: 1000 }, + image: { type: String, trim: true, maxlength: 255 }, }, mission: { - subheading: String, - heading: String, - description: String, + subheading: { type: String, trim: true, maxlength: 80 }, + heading: { type: String, trim: true, maxlength: 120 }, + description: { type: String, trim: true, maxlength: 1000 }, images: { - main: String, - secondary: String, - bgShape: String, - planeShape: String, - topShape: String, - globeShape: String, + main: { type: String, trim: true, maxlength: 255 }, + secondary: { type: String, trim: true, maxlength: 255 }, + bgShape: { type: String, trim: true, maxlength: 255 }, + planeShape: { type: String, trim: true, maxlength: 255 }, + topShape: { type: String, trim: true, maxlength: 255 }, + globeShape: { type: String, trim: true, maxlength: 255 }, }, items: [ new mongoose.Schema( { - icon: String, - label: String, - description: String, + icon: { type: String, trim: true, maxlength: 255 }, + label: { type: String, trim: true, maxlength: 80 }, + description: { type: String, trim: true, maxlength: 240 }, }, { _id: false }, ), ], - features: [String], + features: [{ type: String, trim: true, maxlength: 80 }], ctaButton: { - label: String, - href: String, + label: { type: String, trim: true, maxlength: 64 }, + href: { type: String, trim: true, maxlength: 255 }, }, }, features: { - backgroundImage: String, - subheading: String, - heading: String, - description: String, - image: String, + backgroundImage: { type: String, trim: true, maxlength: 255 }, + subheading: { type: String, trim: true, maxlength: 80 }, + heading: { type: String, trim: true, maxlength: 120 }, + description: { type: String, trim: true, maxlength: 1000 }, + image: { type: String, trim: true, maxlength: 255 }, items: [ new mongoose.Schema( { - icon: String, - title: String, - description: String, + icon: { type: String, trim: true, maxlength: 255 }, + title: { type: String, trim: true, maxlength: 80 }, + description: { type: String, trim: true, maxlength: 240 }, }, { _id: false }, ), ], ctaButton: { - label: String, - href: String, + label: { type: String, trim: true, maxlength: 64 }, + href: { type: String, trim: true, maxlength: 255 }, }, }, news: { - subheading: String, - heading: String, + subheading: { type: String, trim: true, maxlength: 80 }, + heading: { type: String, trim: true, maxlength: 120 }, ctaButton: { - label: String, - href: String, + label: { type: String, trim: true, maxlength: 64 }, + href: { type: String, trim: true, maxlength: 255 }, }, selectedBlogIds: [{ type: mongoose.Schema.Types.ObjectId, ref: "Blog" }], // Deprecated: items field kept for backward compatibility during migration items: [ new mongoose.Schema( { - title: String, - category: String, - date: String, + title: { type: String, trim: true, maxlength: 120 }, + category: { type: String, trim: true, maxlength: 48 }, + date: { type: String, trim: true, maxlength: 32 }, comments: Number, author: { - name: String, - avatar: String, + name: { type: String, trim: true, maxlength: 48 }, + avatar: { type: String, trim: true, maxlength: 255 }, }, - link: String, - thumbnail: String, + link: { type: String, trim: true, maxlength: 255 }, + thumbnail: { type: String, trim: true, maxlength: 255 }, }, { _id: false }, ), diff --git a/models/activity.js b/models/activity.js index 2c62e7e..312d005 100644 --- a/models/activity.js +++ b/models/activity.js @@ -7,28 +7,33 @@ const activitySchema = new mongoose.Schema( titleActivities: { type: String, trim: true, - default: '' + default: "", + maxlength: 80, }, titleBooking: { type: String, trim: true, - default: '' + default: "", + maxlength: 80, }, bannerImageActivities: { type: String, trim: true, - default: '' + default: "", + maxlength: 255, }, bannerImageBooking: { type: String, trim: true, - default: '' + default: "", + maxlength: 255, }, }, name: { type: String, required: true, trim: true, + maxlength: 120, }, price: { type: Number, @@ -38,6 +43,7 @@ const activitySchema = new mongoose.Schema( priceText: { type: String, trim: true, + maxlength: 32, }, season: [ { @@ -58,25 +64,28 @@ const activitySchema = new mongoose.Schema( { type: String, trim: true, + maxlength: 80, }, ], image: { type: String, trim: true, + maxlength: 255, }, link: { type: String, trim: true, + maxlength: 255, }, // Global filters document (single document in Activity collection) filters: [ { - label: { type: String, required: true, trim: true }, - value: { type: String, required: true, trim: true }, + label: { type: String, required: true, trim: true, maxlength: 64 }, + value: { type: String, required: true, trim: true, maxlength: 64 }, items: [ { - value: { type: String, required: true }, - label: { type: String, required: true }, + value: { type: String, required: true, maxlength: 64 }, + label: { type: String, required: true, maxlength: 64 }, }, ], order: { type: Number, default: 0 }, @@ -85,6 +94,7 @@ const activitySchema = new mongoose.Schema( program: { type: String, trim: true, + maxlength: 80, }, rating: { type: Number, @@ -113,7 +123,7 @@ const activitySchema = new mongoose.Schema( // Booking sessions - các đợt booking với thông số riêng bookingSessions: [ { - sessionId: { type: String, required: true }, + sessionId: { type: String, required: true, maxlength: 80 }, startDate: { type: Date, required: true }, endDate: { type: Date, required: true }, overnightStays: { type: Number, required: true, default: 14 }, @@ -127,11 +137,11 @@ const activitySchema = new mongoose.Schema( // Danh sách booking cho session này bookingList: [ { - address: { type: String, required: true }, + address: { type: String, required: true, maxlength: 255 }, agreeNewsletter: { type: Boolean, default: false }, agreeTerms: { type: Boolean, required: true }, - city: { type: String, required: true }, - country: { type: String, required: true }, + city: { type: String, required: true, maxlength: 80 }, + country: { type: String, required: true, maxlength: 80 }, dietaryRestrictions: { type: String, enum: ['none', 'vegetarian', 'vegan', 'halal', 'kosher', 'gluten-free', 'other'], @@ -141,26 +151,27 @@ const activitySchema = new mongoose.Schema( type: String, required: true, lowercase: true, - trim: true + trim: true, + maxlength: 120 }, - emergencyContact: { type: String, required: true }, - emergencyPhone: { type: String, required: true }, - medicalConditions: { type: String, default: '' }, + emergencyContact: { type: String, required: true, maxlength: 80 }, + emergencyPhone: { type: String, required: true, maxlength: 40 }, + medicalConditions: { type: String, default: '', maxlength: 500 }, numberOfParticipants: { type: Number, required: true, min: 1 }, - parentFirstName: { type: String, required: true, trim: true }, - parentLastName: { type: String, required: true, trim: true }, + parentFirstName: { type: String, required: true, trim: true, maxlength: 80 }, + parentLastName: { type: String, required: true, trim: true, maxlength: 80 }, participantBirthDate: { type: Date, required: true }, - participantFirstName: { type: String, required: true, trim: true }, + participantFirstName: { type: String, required: true, trim: true, maxlength: 80 }, participantGender: { type: String, enum: ['male', 'female', 'other'], required: true }, - participantLastName: { type: String, required: true, trim: true }, - phone: { type: String, required: true }, - postalCode: { type: String, required: true }, - sessionDate: { type: String, required: true }, // sessionId reference - specialRequests: { type: String, default: '' }, + participantLastName: { type: String, required: true, trim: true, maxlength: 80 }, + phone: { type: String, required: true, maxlength: 40 }, + postalCode: { type: String, required: true, maxlength: 20 }, + sessionDate: { type: String, required: true, maxlength: 80 }, // sessionId reference + specialRequests: { type: String, default: '', maxlength: 500 }, // Thêm các trường quản lý bookingStatus: { type: String, @@ -175,8 +186,8 @@ const activitySchema = new mongoose.Schema( totalAmount: { type: Number, default: 0 }, paidAmount: { type: Number, default: 0 }, bookingDate: { type: Date, default: Date.now }, - confirmationCode: { type: String, unique: true }, - adminNotes: { type: String, default: '' } + confirmationCode: { type: String, unique: true, maxlength: 32 }, + adminNotes: { type: String, default: '', maxlength: 1000 } } ] } diff --git a/models/booking.js b/models/booking.js index a447c35..637779b 100644 --- a/models/booking.js +++ b/models/booking.js @@ -11,70 +11,70 @@ if (mongoose.connection.models.Booking) { const bookingSchema = new mongoose.Schema( { hero: { - title: String, - backgroundImage: String, + title: { type: String, trim: true, maxlength: 80 }, + backgroundImage: { type: String, trim: true, maxlength: 255 }, }, searchBar: { - locationLabel: String, - holidaySeasonLabel: String, - searchButtonText: String, + locationLabel: { type: String, trim: true, maxlength: 64 }, + holidaySeasonLabel: { type: String, trim: true, maxlength: 64 }, + searchButtonText: { type: String, trim: true, maxlength: 64 }, }, filterPanel: { - title: String, - priceTitle: String, - priceLabel: String, - pricePlaceholder: String, + title: { type: String, trim: true, maxlength: 80 }, + priceTitle: { type: String, trim: true, maxlength: 64 }, + priceLabel: { type: String, trim: true, maxlength: 64 }, + pricePlaceholder: { type: String, trim: true, maxlength: 64 }, priceMin: Number, priceMax: Number, - activitiesTitle: String, - ageTitle: String, - ageSelectPlaceholder: String, + activitiesTitle: { type: String, trim: true, maxlength: 64 }, + ageTitle: { type: String, trim: true, maxlength: 64 }, + ageSelectPlaceholder: { type: String, trim: true, maxlength: 64 }, ageMin: Number, ageMax: Number, - ratingTitle: String, + ratingTitle: { type: String, trim: true, maxlength: 64 }, ratingOptions: [ { - value: String, - label: String, + value: { type: String, trim: true, maxlength: 48 }, + label: { type: String, trim: true, maxlength: 64 }, }, ], - resetButtonText: String, + resetButtonText: { type: String, trim: true, maxlength: 64 }, }, programs: [ { - value: String, - label: String, + value: { type: String, trim: true, maxlength: 64 }, + label: { type: String, trim: true, maxlength: 64 }, }, ], holidays: [ { - value: String, - label: String, + value: { type: String, trim: true, maxlength: 64 }, + label: { type: String, trim: true, maxlength: 64 }, }, ], locations: [ { - value: String, - label: String, + value: { type: String, trim: true, maxlength: 64 }, + label: { type: String, trim: true, maxlength: 64 }, }, ], camps: [ { - name: String, + name: { type: String, trim: true, maxlength: 120 }, price: Number, - priceText: String, + priceText: { type: String, trim: true, maxlength: 32 }, season: [String], age: [Number], locations: [String], - image: String, - link: String, - program: String, + image: { type: String, trim: true, maxlength: 255 }, + link: { type: String, trim: true, maxlength: 255 }, + program: { type: String, trim: true, maxlength: 80 }, rating: Number, }, ], @@ -103,4 +103,4 @@ const bookingSchema = new mongoose.Schema( } ); -module.exports = mongoose.model("Booking", bookingSchema); \ No newline at end of file +module.exports = mongoose.model("Booking", bookingSchema); diff --git a/models/pricing.js b/models/pricing.js index 4f9e931..49b52b0 100644 --- a/models/pricing.js +++ b/models/pricing.js @@ -15,11 +15,13 @@ const breadcrumbItemSchema = new mongoose.Schema( type: String, trim: true, default: "", + maxlength: 40, }, link: { type: String, trim: true, default: "", + maxlength: 255, }, }, { _id: false } @@ -32,16 +34,19 @@ const heroSchema = new mongoose.Schema( type: String, trim: true, default: "Pricing Plan", + maxlength: 60, }, backgroundImage: { type: String, trim: true, default: "/assets/img/inner-page/breadcrumb.jpg", + maxlength: 255, }, shapeImage: { type: String, trim: true, default: "/assets/img/inner-page/shape.png", + maxlength: 255, }, breadcrumb: { type: [breadcrumbItemSchema], @@ -58,16 +63,19 @@ const pricingSectionSchema = new mongoose.Schema( type: String, trim: true, default: "pricing plan", + maxlength: 64, }, heading: { type: String, trim: true, default: "Flexible Plans to Suit Every Traveler", + maxlength: 120, }, description: { type: String, trim: true, default: "", + maxlength: 500, }, }, { _id: false } @@ -80,36 +88,43 @@ const planSchema = new mongoose.Schema( type: String, trim: true, required: true, + maxlength: 64, }, price: { type: String, trim: true, default: "0", + maxlength: 32, }, period: { type: String, trim: true, default: "mo", + maxlength: 8, }, currency: { type: String, trim: true, default: "$", + maxlength: 8, }, buttonText: { type: String, trim: true, default: "Get Started Today", + maxlength: 64, }, buttonLink: { type: String, trim: true, default: "/pricing", + maxlength: 255, }, buttonIcon: { type: String, trim: true, default: "fa-solid fa-arrow-right", + maxlength: 64, }, style: { type: String, @@ -118,7 +133,7 @@ const planSchema = new mongoose.Schema( default: "default", }, features: { - type: [String], + type: [{ type: String, maxlength: 96 }], default: [], }, }, @@ -147,11 +162,13 @@ const testimonialItemSchema = new mongoose.Schema( type: String, trim: true, default: "", + maxlength: 64, }, role: { type: String, trim: true, default: "", + maxlength: 64, }, rating: { type: Number, @@ -163,6 +180,7 @@ const testimonialItemSchema = new mongoose.Schema( type: String, trim: true, default: "", + maxlength: 400, }, }, { _id: false } @@ -175,31 +193,37 @@ const testimonialsSchema = new mongoose.Schema( type: String, trim: true, default: "What Our Clients Say", + maxlength: 64, }, heading: { type: String, trim: true, default: "Immigration Success Stories", + maxlength: 120, }, buttonText: { type: String, trim: true, default: "View All Review", + maxlength: 64, }, buttonLink: { type: String, trim: true, default: "/contact", + maxlength: 255, }, buttonIcon: { type: String, trim: true, default: "fa-solid fa-arrow-right", + maxlength: 64, }, image: { type: String, trim: true, default: "", + maxlength: 255, }, items: { type: [testimonialItemSchema], diff --git a/public/js/admin-form-helpers.js b/public/js/admin-form-helpers.js new file mode 100644 index 0000000..fc28b53 --- /dev/null +++ b/public/js/admin-form-helpers.js @@ -0,0 +1,867 @@ +;(function (window, document) { + "use strict"; + + const COUNTER_SELECTOR = ".admin-field-counter"; + const GUIDANCE_SELECTOR = ".admin-upload-guidance"; + const COUNTER_BOUND_KEY = "adminCounterBound"; + const AUTO_GUIDANCE_ATTR = "data-admin-upload-guidance"; + const OBSERVER_BOUND_KEY = "__adminFormHelpersObserver"; + let generatedFieldToken = 0; + + const FIELD_RULES = { + "/admin/about-us": [ + { selector: "#heroTitle", maxLength: 72 }, + { selector: "#heroBreadcrumb", maxLength: 120 }, + { selector: "#heroBackgroundImage", maxLength: 255 }, + { selector: "#introSubheading", maxLength: 40 }, + { selector: "#introHeading", maxLength: 72 }, + { selector: "#introDescription", maxLength: 260 }, + { selector: "#introImage", maxLength: 255 }, + { selector: "#missionSubheading", maxLength: 40 }, + { selector: "#missionHeading", maxLength: 72 }, + { selector: "#missionDescription", maxLength: 260 }, + { selector: "#missionCtaLabel", maxLength: 32 }, + { selector: "#missionCtaHref", maxLength: 255 }, + { selector: "[id^='missionImg_']", maxLength: 255 }, + { selector: "#featuresSubheading", maxLength: 40 }, + { selector: "#featuresHeading", maxLength: 72 }, + { selector: "#featuresDescription", maxLength: 260 }, + { selector: "#featuresBgImage", maxLength: 255 }, + { selector: "#featuresImage", maxLength: 255 }, + { selector: "#featuresCtaLabel", maxLength: 32 }, + { selector: "#featuresCtaHref", maxLength: 255 }, + { selector: "[id^='missionItemLabel_']", maxLength: 48 }, + { selector: "[id^='missionItemDescription_']", maxLength: 160 }, + { selector: "[id^='missionItemIcon_']", maxLength: 255 }, + { selector: "[id^='missionFeature_']", maxLength: 96 }, + { selector: "[id^='featureItemTitle_']", maxLength: 48 }, + { selector: "[id^='featureItemDescription_']", maxLength: 160 }, + { selector: "[id^='featureItemIcon_']", maxLength: 255 }, + { selector: "#newsSubheading", maxLength: 40 }, + { selector: "#newsHeading", maxLength: 72 }, + { selector: "#newsCtaLabel", maxLength: 32 }, + { selector: "#newsCtaHref", maxLength: 255 }, + ], + "/admin/booking": [ + { selector: "#heroBackgroundImage", maxLength: 255 }, + { selector: "#heroTitle", maxLength: 72 }, + { selector: "#searchBarLocationLabel", maxLength: 32 }, + { selector: "#searchBarHolidaySeasonLabel", maxLength: 32 }, + { selector: "#searchBarSearchButtonText", maxLength: 24 }, + { selector: "input[name^='locationValue_']", maxLength: 32 }, + { selector: "input[name^='locationLabel_']", maxLength: 48 }, + { selector: "input[name^='holidayValue_']", maxLength: 32 }, + { selector: "input[name^='holidayLabel_']", maxLength: 48 }, + { selector: "#filterPanelTitle", maxLength: 48 }, + { selector: "#filterPanelPriceTitle", maxLength: 40 }, + { selector: "#filterPanelPriceLabel", maxLength: 32 }, + { selector: "#filterPanelPricePlaceholder", maxLength: 32 }, + { selector: "#filterPanelActivitiesTitle", maxLength: 40 }, + { selector: "#filterPanelAgeTitle", maxLength: 40 }, + { selector: "#filterPanelAgeSelectPlaceholder", maxLength: 32 }, + { selector: "#filterPanelRatingTitle", maxLength: 40 }, + { selector: "#filterPanelResetButtonText", maxLength: 24 }, + { selector: "input[name^='ratingValue_']", maxLength: 8 }, + { selector: "input[name^='ratingLabel_']", maxLength: 32 }, + { selector: "input[name^='programValue_']", maxLength: 32 }, + { selector: "input[name^='programLabel_']", maxLength: 48 }, + { selector: "input[name^='campName_']", maxLength: 72 }, + { selector: "input[name^='campPriceText_']", maxLength: 32 }, + { selector: "input[name^='campProgram_']", maxLength: 32 }, + { selector: "input[name^='campImage_']", maxLength: 255 }, + { selector: "input[name^='campLink_']", maxLength: 255 }, + { selector: "input[name^='discountName_']", maxLength: 48 }, + { selector: "textarea[name^='discountDescription_']", maxLength: 180 }, + { selector: "input[name^='voucherCode_']", maxLength: 24 }, + { selector: "input[name^='voucherDescription_']", maxLength: 120 }, + { selector: "[name='formTitle']", maxLength: 64 }, + { selector: "[name='formSubtitle']", maxLength: 48 }, + { selector: "[name^='stepTitle_']", maxLength: 64 }, + { selector: "[name^='sectionTitle_']", maxLength: 64 }, + { selector: "[name^='fieldLabel_']", maxLength: 48 }, + { selector: "[name^='fieldName_']", maxLength: 32 }, + { selector: "[name^='fieldPlaceholder_']", maxLength: 72 }, + { selector: "[name^='validationMessage_']", maxLength: 120 }, + ], + "/admin/pricing": [ + { selector: "#heroBackgroundImage", maxLength: 255 }, + { selector: "#heroTitle", maxLength: 72 }, + { selector: "#pricingSectionSubtitle", maxLength: 40 }, + { selector: "#pricingSectionHeading", maxLength: 72 }, + { selector: "#pricingSectionDescription", maxLength: 220 }, + { selector: ".plan-name", maxLength: 40 }, + { selector: ".plan-price", maxLength: 16 }, + { selector: ".plan-currency", maxLength: 8 }, + { selector: ".plan-period", maxLength: 12 }, + { selector: ".plan-button-text", maxLength: 32 }, + { selector: ".plan-button-link", maxLength: 255 }, + { selector: ".plan-button-icon", maxLength: 64 }, + { selector: ".plan-features", maxLength: 320 }, + { selector: "#testimonialsSubtitle", maxLength: 40 }, + { selector: "#testimonialsHeading", maxLength: 72 }, + { selector: "#testimonialsButtonText", maxLength: 32 }, + { selector: "#testimonialsButtonLink", maxLength: 255 }, + { selector: "#testimonialsButtonIcon", maxLength: 64 }, + { selector: "#testimonialsImage", maxLength: 255 }, + { selector: ".testimonial-name", maxLength: 48 }, + { selector: ".testimonial-role", maxLength: 48 }, + { selector: ".testimonial-content", maxLength: 220 }, + ], + "/admin/visa": [ + { selector: "input[name='name']", maxLength: 40 }, + { selector: "input[name='icon']", maxLength: 255 }, + { selector: "input[name='services[]']", maxLength: 56 }, + { selector: "input[name='detail_title']", maxLength: 72 }, + { selector: "input[name='mainImage']", maxLength: 255 }, + { selector: "textarea[name='description']", maxLength: 360 }, + { selector: "textarea[name='additionalInfo']", maxLength: 360 }, + { selector: "input[name='tagline']", maxLength: 72 }, + { selector: "input[name^='visa_title_']", maxLength: 56 }, + { selector: "textarea[name^='visa_desc_']", maxLength: 220 }, + { selector: "input[name='process_title']", maxLength: 72 }, + { selector: "input[name='step_title[]']", maxLength: 56 }, + { selector: "textarea[name='step_desc[]']", maxLength: 180 }, + { selector: "input[name='bannerImageGallery']", maxLength: 255 }, + { selector: "input[name='category_title[]']", maxLength: 56 }, + { selector: "textarea[name='category_desc[]']", maxLength: 180 }, + { selector: "input[name='related_title[]']", maxLength: 56 }, + { selector: "textarea[name='related_desc[]']", maxLength: 180 }, + { selector: "input[name='related_file[]']", maxLength: 255 }, + { selector: "input[name='contact_title']", maxLength: 72 }, + { selector: "input[name='contact_phone']", maxLength: 32 }, + { selector: "input[name='contact_email']", maxLength: 120 }, + { selector: "input[name='contact_address']", maxLength: 160 }, + { selector: "input[name='contact_image']", maxLength: 255 }, + ], + "/admin/activity/*": [ + { selector: "input[name='heroTitle']", maxLength: 72 }, + { selector: "input[name='heroBannerImage']", maxLength: 255 }, + { selector: "input[name='name']", maxLength: 72 }, + { selector: "input[name='priceText']", maxLength: 32 }, + { selector: "input[name='link']", maxLength: 255 }, + { selector: "input[name='program']", maxLength: 32 }, + { selector: "#customLocations", maxLength: 120 }, + { selector: "input[name='image']", maxLength: 255 }, + { selector: "input[name='campDetailHeroTitle']", maxLength: 72 }, + { selector: "input[name='campDetailHeroBgImage']", maxLength: 255 }, + { selector: "input[name='campDetailBasicInfoLocation']", maxLength: 48 }, + { selector: "textarea[name='campDetailBasicInfoAgeRange']", maxLength: 120 }, + { selector: "input[name='campDetailBasicInfoAccommodationType']", maxLength: 72 }, + { selector: "input[name='campDetailBasicInfoCareLevel']", maxLength: 72 }, + { selector: "input[name='campDetailBasicInfoLanguages']", maxLength: 72 }, + ], + "/admin/activity": [], + }; + + const GUIDANCE_RULES = { + "/admin/about-us": [ + { + selector: "#heroBackgroundImage", + title: "Upload guidance", + lines: [ + "Displayed as a wide page hero.", + "Recommended upload: at least 1920x700px.", + ], + }, + { + selector: "#introImage", + title: "Upload guidance", + lines: [ + "Displayed around 596x787px on desktop.", + "Recommended upload: at least 1200x1600px.", + ], + }, + { + selector: "#featuresImage", + title: "Upload guidance", + lines: [ + "Displayed around 375x419px on desktop.", + "Recommended upload: at least 750x840px.", + ], + }, + ], + "/admin/booking": [ + { + selector: "#heroBackgroundImage", + title: "Upload guidance", + lines: [ + "Booking page hero background.", + "Recommended upload: at least 1920x700px.", + ], + }, + { + selector: "input[name^='campImage_']", + title: "Upload guidance", + lines: [ + "Used in booking camp cards.", + "Recommended upload: a landscape image at 704x432px or larger.", + ], + }, + ], + "/admin/pricing": [ + { + selector: "#heroBackgroundImage", + title: "Upload guidance", + lines: [ + "Pricing page hero background.", + "Recommended upload: at least 1920x700px.", + ], + }, + ], + "/admin/visa": [ + { + selector: "input[name='icon']", + title: "Upload guidance", + lines: [ + "Displayed as a small country flag or icon.", + "Prefer SVG; otherwise use a square image at 96x96px or larger.", + ], + }, + { + selector: "input[name='mainImage'], input[name='bannerImageGallery'], input[name='contact_image'], input[name='related_file[]']", + title: "Upload guidance", + lines: [ + "Used in visa detail content blocks.", + "Recommended upload: at least 1000x750px for primary imagery and 800x600px for supporting images.", + ], + }, + ], + "/admin/activity/*": [ + { + selector: "input[name='heroBannerImage'], input[name='campDetailHeroBgImage']", + title: "Upload guidance", + lines: [ + "Activity page hero-style image.", + "Recommended upload: at least 1920x700px.", + ], + }, + { + selector: "input[name='image']", + title: "Upload guidance", + lines: [ + "Used in activity listing cards.", + "Recommended upload: a landscape image at 704x432px or larger.", + ], + }, + ], + "/admin/home": [ + { + selector: "#whyChooseUsMainImage", + title: "Upload guidance", + lines: [ + "Displayed around 318x347px on desktop.", + "Recommended upload: at least 750x820px.", + ], + }, + { + selector: "#whyChooseUsSecondaryImage", + title: "Upload guidance", + lines: [ + "Displayed around 363x380px on desktop.", + "Recommended upload: at least 760x800px.", + ], + }, + { + selector: "#testimonialsVideoThumbnail", + title: "Upload guidance", + lines: [ + "Displayed around 416x370px on desktop.", + "Recommended upload: at least 832x740px.", + ], + }, + { + selector: "[id^='testimonialsAvatar_']", + title: "Upload guidance", + lines: [ + "Displayed around 48x48px.", + "Recommended upload: 96x96px or 128x128px square.", + ], + }, + { + selector: "#visaCountriesFlag_0", + title: "Upload guidance", + lines: [ + "Displayed around 840x830px on desktop.", + "Recommended upload: at least 1000x1000px.", + ], + }, + ], + "*": [ + { + selector: ".btn-upload-image", + title: "Upload guidance", + lines: [ + "Use a clear, high-resolution image that matches the visible frame.", + "Prefer SVG for logos or icons and at least 2x the displayed size for raster uploads.", + ], + }, + ], + }; + + function toScope(scope) { + return scope && scope.querySelectorAll ? scope : document; + } + + function normalizeText(value) { + return String(value ?? "").replace(/\s+/g, " ").trim(); + } + + function buildDescriptor(input) { + return [ + input?.id, + input?.name, + input?.placeholder, + input?.closest(".col-md-12, .col-md-9, .col-md-6, .col-md-5, .col-md-4, .col-md-3, .col-md-2, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12")?.querySelector("label")?.textContent, + ] + .filter(Boolean) + .join(" ") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[_[\].-]+/g, " ") + .toLowerCase(); + } + + function isUploadDescriptor(descriptor) { + return /(?:^|[^a-z])(image|icon|logo|background|banner|thumbnail|avatar|flag|path|src|file)(?:[^a-z]|$)/.test(descriptor); + } + + function resolveUploadTarget(targetId, anchor) { + if (targetId) { + const byId = document.getElementById(targetId); + if (byId) { + return byId; + } + + if (window.CSS && typeof window.CSS.escape === "function") { + const byName = document.querySelector(`[name="${window.CSS.escape(targetId)}"]`); + if (byName) { + return byName; + } + } + } + + return anchor || null; + } + + function hasManualUploadHint(target) { + const anchor = resolveGuidanceAnchor(target); + if (!anchor) { + return false; + } + + const host = + anchor.closest(".form-group, .mb-3, .col-md-12, .col-md-9, .col-md-6, .col-md-5, .col-md-4, .col-md-3, .col-md-2, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12") || + anchor.parentElement; + if (!host) { + return false; + } + + const candidates = [ + ...host.querySelectorAll("small.text-muted, small.form-text, .form-text, .text-muted"), + ...Array.from(host.nextElementSibling ? host.nextElementSibling.querySelectorAll?.("small.text-muted, small.form-text, .form-text, .text-muted") || [] : []), + ]; + + return candidates.some((node) => { + if (node.classList?.contains("admin-field-counter") || node.classList?.contains("admin-upload-guidance")) { + return false; + } + + const text = normalizeText(node.textContent || ""); + return /recommended|min(imum)? upload|upload|svg|png|webp|render|displayed|size|preview|icon/i.test(text); + }); + } + + function getFieldLimit(input) { + const dataMax = Number(input?.dataset?.maxlength); + if (Number.isFinite(dataMax) && dataMax > 0) { + return dataMax; + } + + const attrMax = Number(input?.getAttribute("maxlength")); + if (Number.isFinite(attrMax) && attrMax > 0) { + return attrMax; + } + + return null; + } + + function getWordLimit(input) { + const dataMax = Number(input?.dataset?.maxwords); + if (Number.isFinite(dataMax) && dataMax > 0) { + return dataMax; + } + + return null; + } + + function getFieldToken(input) { + if (!input) { + return ""; + } + + if (input.id) { + return `id:${input.id}`; + } + + if (input.name) { + const indexWithinNameGroup = Array.from(document.querySelectorAll(`[name="${CSS.escape(input.name)}"]`)).indexOf(input); + return `name:${input.name}:${Math.max(indexWithinNameGroup, 0)}`; + } + + if (!input.dataset.adminFieldToken) { + generatedFieldToken += 1; + input.dataset.adminFieldToken = `generated:${generatedFieldToken}`; + } + + return input.dataset.adminFieldToken; + } + + function isDragDropField(element) { + if (!element || !element.closest) { + return false; + } + + return Boolean( + element.closest( + ".social-link-item, .floating-contact-action-item, .menu-links-sortable, .social-links-sortable, .sortable-container, .sortable-list, .sortable-item, [data-top-menu-index], [data-top-social-index], [data-bottom-menu-index], [data-bottom-social-index], [draggable='true']", + ), + ); + } + + function refreshCountersWithin(scope) { + if (!scope || !scope.querySelectorAll) { + return; + } + + scope + .querySelectorAll("input[data-maxlength], textarea[data-maxlength], input[data-maxwords], textarea[data-maxwords], input[maxlength], textarea[maxlength]") + .forEach((field) => { + if (field.dataset[COUNTER_BOUND_KEY] === "true") { + updateCounter(field); + } + }); + } + + function getCounterRefreshScope(input) { + if (!isDragDropField(input)) { + return null; + } + + return ( + input.closest(".floating-contact-action-item, .social-link-item, [data-top-menu-index], [data-top-social-index], [data-bottom-menu-index], [data-bottom-social-index]") || + input.closest(".menu-links-sortable, .social-links-sortable, .sortable-container, .sortable-list") + ); + } + + function getCounterHost(input) { + return ( + input.closest(".input-group") || + input.closest(".col-md-12, .col-md-9, .col-md-6, .col-md-4, .col-md-3, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12") || + input.parentElement || + input + ); + } + + function ensureCounterElement(input) { + const counterToken = getFieldToken(input); + const existingAnywhere = counterToken ? document.querySelector(`[data-counter-for="${counterToken}"]`) : null; + if (existingAnywhere) { + return existingAnywhere; + } + + const host = getCounterHost(input); + const nextSibling = host.nextElementSibling; + const matchingNextSibling = + nextSibling && + nextSibling.matches(COUNTER_SELECTOR) && + nextSibling.dataset.counterFor === counterToken + ? nextSibling + : null; + const existing = matchingNextSibling || host.querySelector(`${COUNTER_SELECTOR}[data-counter-for="${counterToken}"]`); + + if (existing) { + return existing; + } + + const counter = document.createElement("small"); + counter.className = "form-text admin-field-counter"; + counter.dataset.counterFor = counterToken; + counter.setAttribute("aria-live", "polite"); + host.insertAdjacentElement("afterend", counter); + return counter; + } + + function matchesPathKey(key, pathname) { + if (key === "*") { + return true; + } + + if (key.endsWith("*")) { + return pathname.startsWith(key.slice(0, -1)); + } + + return key === pathname; + } + + function getPathRules(registry) { + const pathname = window.location.pathname; + return Object.keys(registry).reduce((rules, key) => { + if (!matchesPathKey(key, pathname)) { + return rules; + } + + return rules.concat(registry[key] || []); + }, []); + } + + function findTargets(root, selector) { + const targets = []; + if (root.matches && root.matches(selector)) { + targets.push(root); + } + if (root.querySelectorAll) { + targets.push(...root.querySelectorAll(selector)); + } + return targets; + } + + function applyFieldRules(scope) { + const root = toScope(scope); + getPathRules(FIELD_RULES).forEach((rule) => { + findTargets(root, rule.selector).forEach((input) => { + if (rule.maxLength && !input.dataset.maxlength) { + input.dataset.maxlength = String(rule.maxLength); + input.setAttribute("maxlength", String(rule.maxLength)); + } + if (rule.maxWords && !input.dataset.maxwords) { + input.dataset.maxwords = String(rule.maxWords); + } + }); + }); + + root.querySelectorAll("input, textarea").forEach((input) => { + if ( + input.disabled || + input.type === "hidden" || + input.type === "file" || + input.dataset.maxlength || + input.dataset.maxwords || + input.getAttribute("maxlength") + ) { + return; + } + + const type = (input.getAttribute("type") || "").toLowerCase(); + if (type && !["text", "email", "tel", "search", "url"].includes(type) && input.tagName !== "TEXTAREA") { + return; + } + + const descriptor = buildDescriptor(input); + + if (/json|editor|html|content-block|blocks/.test(descriptor)) { + return; + } + + let inferredMaxLength = 72; + if (input.tagName === "TEXTAREA") { + inferredMaxLength = /description|content|overview|additional info|quote|note|message|summary/.test(descriptor) ? 500 : 220; + } else if (type === "email" || /email/.test(descriptor)) { + inferredMaxLength = 120; + } else if (type === "tel" || /phone|tel|mobile|whatsapp|zalo/.test(descriptor)) { + inferredMaxLength = 32; + } else if (/url|href|link/.test(descriptor) || isUploadDescriptor(descriptor)) { + inferredMaxLength = 255; + } else if (/slug|code|id/.test(descriptor)) { + inferredMaxLength = 32; + } else if (/title|heading|name|label|subtitle|platform/.test(descriptor)) { + inferredMaxLength = 72; + } + + input.dataset.maxlength = String(inferredMaxLength); + input.setAttribute("maxlength", String(inferredMaxLength)); + }); + } + + function updateCounter(input) { + const counter = ensureCounterElement(input); + const maxLength = getFieldLimit(input); + const maxWords = getWordLimit(input); + const currentValue = normalizeText(input.value || ""); + const currentLength = currentValue.length; + + if (maxWords) { + const words = currentValue ? currentValue.split(" ") : []; + const currentWords = words.filter(Boolean).length; + + if (maxLength && currentLength > maxLength) { + input.value = currentValue.slice(0, maxLength); + } + + if (maxLength) { + counter.textContent = `${currentWords}/${maxWords} words, ${Math.min(currentLength, maxLength)}/${maxLength} characters`; + counter.classList.toggle("is-danger", currentWords >= maxWords || currentLength >= maxLength); + } else { + counter.textContent = `${currentWords}/${maxWords} words`; + counter.classList.toggle("is-danger", currentWords >= maxWords); + } + return; + } + + if (!maxLength) { + counter.textContent = ""; + return; + } + + if (currentLength > maxLength) { + input.value = currentValue.slice(0, maxLength); + } + + counter.textContent = `${Math.min(currentLength, maxLength)}/${maxLength} characters`; + counter.classList.toggle("is-danger", currentLength >= maxLength); + } + + function bindCounter(input) { + if (!input || input.dataset[COUNTER_BOUND_KEY] === "true") { + return; + } + + input.dataset[COUNTER_BOUND_KEY] = "true"; + const syncCounter = () => { + updateCounter(input); + const refreshScope = getCounterRefreshScope(input); + if (refreshScope) { + refreshCountersWithin(refreshScope); + } + }; + + syncCounter(); + input.addEventListener("input", syncCounter); + input.addEventListener("change", syncCounter); + input.addEventListener("blur", syncCounter); + input.addEventListener("focus", syncCounter); + } + + function buildGuidanceLines(options = {}) { + const title = normalizeText(options.title) || "Upload guidance"; + const lines = Array.isArray(options.lines) ? options.lines.map(normalizeText).filter(Boolean) : []; + + if (!lines.length) { + lines.push("Use a clear, high-resolution image that matches the visible frame."); + lines.push("Prefer a file that is at least 2x the displayed size for crisp rendering."); + lines.push("Keep the original aspect ratio unless the page explicitly asks for a crop."); + } + + return { title, lines }; + } + + function resolveGuidanceAnchor(target) { + if (!target) { + return null; + } + + if (typeof target === "string") { + return document.querySelector(target); + } + + if (target instanceof Element) { + return target; + } + + return null; + } + + function renderUploadGuidance(target, options = {}) { + const anchor = resolveGuidanceAnchor(target); + if (!anchor) { + return null; + } + + const host = anchor.closest(".form-group, .mb-3, .col-md-12, .col-md-9, .col-md-6, .col-md-4, .col-md-3, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12") || anchor.parentElement; + if (!host) { + return null; + } + + const matchingAnchorSibling = + anchor.nextElementSibling && + anchor.nextElementSibling.matches(GUIDANCE_SELECTOR) && + anchor.nextElementSibling.dataset.guidanceFor === (options.for || "") + ? anchor.nextElementSibling + : null; + const matchingHostSibling = + host.nextElementSibling && + host.nextElementSibling.matches(GUIDANCE_SELECTOR) && + host.nextElementSibling.dataset.guidanceFor === (options.for || "") + ? host.nextElementSibling + : null; + const existing = + matchingAnchorSibling || + matchingHostSibling || + host.querySelector(`${GUIDANCE_SELECTOR}[data-guidance-for="${options.for || ""}"]`); + if (existing) { + return existing; + } + + const payload = buildGuidanceLines(options); + const note = document.createElement("div"); + note.className = "admin-upload-guidance"; + note.dataset.guidanceFor = options.for || ""; + note.setAttribute("role", "note"); + note.innerHTML = ` +
${payload.title}
+
    + ${payload.lines.map((line) => `
  • ${line}
  • `).join("")} +
+ `; + + const insertionTarget = host !== anchor ? host : anchor; + insertionTarget.insertAdjacentElement("afterend", note); + return note; + } + + function autoWireGuidance(scope) { + const root = toScope(scope); + root.querySelectorAll(`[${AUTO_GUIDANCE_ATTR}]`).forEach((anchor) => { + const guidanceValue = anchor.getAttribute(AUTO_GUIDANCE_ATTR); + if (guidanceValue === "false") { + return; + } + + if (isDragDropField(anchor)) { + return; + } + + if (hasManualUploadHint(anchor)) { + return; + } + + renderUploadGuidance(anchor, { + for: anchor.id || anchor.dataset.targetInput || "", + title: anchor.dataset.adminUploadGuidanceTitle || "Upload guidance", + lines: anchor.dataset.adminUploadGuidance + ? anchor.dataset.adminUploadGuidance.split("|").map((part) => part.trim()) + : undefined, + }); + }); + } + + function applyGuidanceRules(scope) { + const root = toScope(scope); + getPathRules(GUIDANCE_RULES).forEach((rule) => { + findTargets(root, rule.selector).forEach((anchor) => { + if (isDragDropField(anchor)) { + return; + } + + if (hasManualUploadHint(anchor)) { + return; + } + + if (anchor.matches(".btn-upload-image")) { + const targetId = anchor.dataset.targetInput; + const target = resolveUploadTarget(targetId, anchor); + renderUploadGuidance(target || anchor, { + for: targetId || anchor.id || "", + title: rule.title, + lines: rule.lines, + }); + return; + } + + renderUploadGuidance(anchor, { + for: anchor.id || anchor.name || "", + title: rule.title, + lines: rule.lines, + }); + }); + }); + + root.querySelectorAll("input[type='text'], textarea").forEach((input) => { + if (isDragDropField(input)) { + return; + } + + const descriptor = buildDescriptor(input); + + if (!isUploadDescriptor(descriptor)) { + return; + } + + if (hasManualUploadHint(input)) { + return; + } + + const host = + input.parentElement?.querySelector(".admin-upload-guidance") || + input.closest(".col-md-12, .col-md-6, .col-md-4, .col-lg-12, .col-lg-6, .col-lg-4, .col-12")?.querySelector(".admin-upload-guidance"); + if (host) { + return; + } + + renderUploadGuidance(input, { + for: input.id || input.name || "", + title: "Upload guidance", + lines: [ + "Use a clear, high-resolution image sized for the frontend frame.", + "Prefer SVG for logos or icons and at least 2x the displayed size for raster uploads.", + ], + }); + }); + } + + function observeMutations() { + if (document.body[OBSERVER_BOUND_KEY]) { + return; + } + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (!node || node.nodeType !== 1) { + return; + } + init(node); + }); + }); + }); + + observer.observe(document.body, { childList: true, subtree: true }); + document.body[OBSERVER_BOUND_KEY] = true; + } + + function init(scope = document) { + const root = toScope(scope); + + applyFieldRules(root); + root.querySelectorAll("input[data-maxlength], textarea[data-maxlength], input[data-maxwords], textarea[data-maxwords], input[maxlength], textarea[maxlength]").forEach(bindCounter); + autoWireGuidance(root); + applyGuidanceRules(root); + } + + function refresh(scope = document) { + init(scope); + } + + function shouldAutoInit() { + return document.body && document.body.dataset.adminHelpers === "true"; + } + + const api = { + init, + refresh, + renderUploadGuidance, + updateCounter, + }; + + window.AdminFormHelpers = api; + + if (shouldAutoInit()) { + if (document.readyState === "loading") { + window.addEventListener("load", () => { + init(document); + observeMutations(); + }, { once: true }); + } else { + init(document); + observeMutations(); + } + } +})(window, document); diff --git a/scripts/2026_04_07_110000_add_home_floating_contact.js b/scripts/2026_04_07_110000_add_home_floating_contact.js index 517194a..145e329 100644 --- a/scripts/2026_04_07_110000_add_home_floating_contact.js +++ b/scripts/2026_04_07_110000_add_home_floating_contact.js @@ -2,7 +2,7 @@ require("dotenv").config(); const connectDB = require("../config/database"); const DEFAULT_FACEBOOK_URL = "https://www.facebook.com/hailearning.edu.vn/"; -const DEFAULT_PANEL_TITLE = "Anh chị cần tư vấn hay hỗ trợ gì thêm không ạ?"; +const DEFAULT_PANEL_TITLE = "Do you need any additional advice or support?"; const DEFAULT_BRAND_IMAGE = "/assets/img/logo/black-logo.svg"; const DEFAULT_FACEBOOK_ICON = "/uploads/home/floating-contact/Facebook_Logo_Primary.webp"; const DEFAULT_ZALO_ICON = "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp"; @@ -60,7 +60,7 @@ async function migrate() { id: "facebook", platform: "facebook", enabled: true, - label: "Nhắn tin qua Facebook", + label: "Message via Facebook", subtitle: "facebook.com/hailearning.edu.vn", href: DEFAULT_FACEBOOK_URL, iconImage: DEFAULT_FACEBOOK_ICON, @@ -73,7 +73,7 @@ async function migrate() { id: "zalo", platform: "zalo", enabled: true, - label: "Nhắn tin qua Zalo", + label: "Message via Zalo", subtitle: `zalo.me/${zaloPhone}`, href: zaloUrl, iconImage: DEFAULT_ZALO_ICON, diff --git a/utils/lengthValidation.js b/utils/lengthValidation.js new file mode 100644 index 0000000..60e83c0 --- /dev/null +++ b/utils/lengthValidation.js @@ -0,0 +1,158 @@ +const DEFAULT_LABEL = "Field"; + +const normalizePath = (path) => { + return String(path || "") + .replace(/\[(\d+)\]/g, ".$1") + .replace(/\[\*\]/g, ".*") + .replace(/\[\]/g, ".*") + .split(".") + .filter(Boolean); +}; + +const toLabel = (path) => { + if (!path) { + return DEFAULT_LABEL; + } + + return String(path) + .replace(/\[\d+\]/g, "") + .replace(/\.\*/g, "") + .replace(/[._-]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .replace(/^./, (ch) => ch.toUpperCase()); +}; + +const collectMatches = (value, segments, currentPath = [], results = []) => { + if (segments.length === 0) { + results.push({ + path: currentPath.join("."), + value, + }); + return results; + } + + const [segment, ...rest] = segments; + + if (segment === "*") { + if (!Array.isArray(value)) { + return results; + } + + value.forEach((item, index) => { + collectMatches(item, rest, [...currentPath, String(index)], results); + }); + + return results; + } + + if (value === null || value === undefined) { + return results; + } + + if (typeof value !== "object") { + return results; + } + + if (!Object.prototype.hasOwnProperty.call(value, segment)) { + return results; + } + + return collectMatches(value[segment], rest, [...currentPath, segment], results); +}; + +const countWords = (value) => { + const normalized = String(value || "") + .replace(/\s+/g, " ") + .trim(); + + if (!normalized) { + return 0; + } + + return normalized.split(" ").length; +}; + +const buildErrorMessage = (label, path, maxLength, maxWords) => { + const target = path ? `${label} (${path})` : label; + + if (maxLength && maxWords) { + return `${target} must not exceed ${maxLength} characters or ${maxWords} words.`; + } + + if (maxLength) { + return `${target} must not exceed ${maxLength} characters.`; + } + + return `${target} must not exceed ${maxWords} words.`; +}; + +const validateLengthRules = (payload, rules = []) => { + const errors = []; + + for (const rule of rules) { + const paths = Array.isArray(rule.paths) + ? rule.paths + : [rule.path].filter(Boolean); + + for (const path of paths) { + const segments = normalizePath(path); + const matches = collectMatches(payload, segments); + + for (const match of matches) { + if (typeof match.value !== "string") { + continue; + } + + const normalized = match.value.trim(); + if (!normalized && rule.allowEmpty !== false) { + continue; + } + + const actualLength = normalized.length; + const actualWords = countWords(normalized); + const maxLength = Number(rule.maxLength); + const maxWords = Number(rule.maxWords); + const exceedsLength = Number.isFinite(maxLength) && maxLength > 0 && actualLength > maxLength; + const exceedsWords = Number.isFinite(maxWords) && maxWords > 0 && actualWords > maxWords; + + if (!exceedsLength && !exceedsWords) { + continue; + } + + const label = rule.label || toLabel(path); + errors.push({ + path: match.path, + label, + message: buildErrorMessage(label, match.path, exceedsLength ? maxLength : null, exceedsWords ? maxWords : null), + maxLength: Number.isFinite(maxLength) ? maxLength : undefined, + maxWords: Number.isFinite(maxWords) ? maxWords : undefined, + actualLength, + actualWords, + }); + } + } + } + + return { + valid: errors.length === 0, + errors, + }; +}; + +const summarizeLengthErrors = (validation, limit = 1) => { + if (!validation || !Array.isArray(validation.errors) || validation.errors.length === 0) { + return ""; + } + + return validation.errors + .slice(0, limit) + .map((error) => error.message) + .join(" "); +}; + +module.exports = { + validateLengthRules, + summarizeLengthErrors, + toLabel, +}; diff --git a/views/admin/activity/form.ejs b/views/admin/activity/form.ejs index 3b75cf6..a2fba55 100644 --- a/views/admin/activity/form.ejs +++ b/views/admin/activity/form.ejs @@ -41,7 +41,9 @@
+ placeholder="/templates/yootheme/activities/activity-banner.jpg" + maxlength="255" data-maxlength="255" + data-admin-upload-guidance="Activity page hero-style image.|Recommended upload: at least 1920x700px."> @@ -235,7 +237,9 @@ + placeholder="e.g., yootheme/banner/b14.jpg" + maxlength="255" data-maxlength="255" + data-admin-upload-guidance="Used in activity listing cards.|Recommended upload: a landscape image at 704x432px or larger."> Path to the main activity image (used in listings)
@@ -271,7 +275,9 @@
+ placeholder="e.g., yootheme/banner/b1.jpg" + maxlength="255" data-maxlength="255" + data-admin-upload-guidance="Activity page hero-style image.|Recommended upload: at least 1920x700px.">
@@ -470,14 +468,6 @@ console.error('Error initializing contentAfterQuote editor:', error); } - // Excerpt character counter - const excerptInput = document.getElementById('excerpt'); - const excerptCount = document.getElementById('excerptCount'); - - excerptInput.addEventListener('input', function () { - excerptCount.textContent = this.value.length; - }); - // Image upload handler document.querySelectorAll('.btn-upload-image').forEach(button => { button.addEventListener('click', function () { @@ -1062,4 +1052,4 @@ } }); }); - \ No newline at end of file + diff --git a/views/admin/blog/edit.ejs b/views/admin/blog/edit.ejs index f9528c8..83214b6 100644 --- a/views/admin/blog/edit.ejs +++ b/views/admin/blog/edit.ejs @@ -200,11 +200,7 @@ -
- - <%= (blog.excerpt || '' ).length %> - /500 characters -
+
Maximum 500 characters.
@@ -888,14 +884,6 @@ console.error('Error initializing contentAfterQuote editor:', error); } - // Excerpt character counter - const excerptInput = document.getElementById('excerpt'); - const excerptCount = document.getElementById('excerptCount'); - - excerptInput.addEventListener('input', function () { - excerptCount.textContent = this.value.length; - }); - // Image upload handler document.querySelectorAll('.btn-upload-image').forEach(button => { button.addEventListener('click', function () { @@ -1773,4 +1761,4 @@ } }); }); - \ No newline at end of file + diff --git a/views/admin/booking/index.ejs b/views/admin/booking/index.ejs index 0c86370..192b5cb 100644 --- a/views/admin/booking/index.ejs +++ b/views/admin/booking/index.ejs @@ -2000,13 +2000,13 @@ } - if (window.toastManager) window.toastManager.success('Upload thành công'); + if (window.toastManager) window.toastManager.success('Upload completed successfully'); } else { - if (window.toastManager) window.toastManager.success('Upload thành công'); + if (window.toastManager) window.toastManager.success('Upload completed successfully'); } } catch (err) { console.error('Upload error', err); - if (window.toastManager) window.toastManager.error('Lỗi upload: ' + (err.message || + if (window.toastManager) window.toastManager.error('Upload error: ' + (err.message || err)); } }); @@ -2015,7 +2015,7 @@ fileInput.click(); } catch (err) { console.error('openImageUploader error', err); - if (window.toastManager) window.toastManager.error('Lỗi: ' + (err.message || err)); + if (window.toastManager) window.toastManager.error('Error: ' + (err.message || err)); } } @@ -2031,4 +2031,4 @@ } openImageUploader(targetInput, imageType); }); - \ No newline at end of file + diff --git a/views/admin/contact/index.ejs b/views/admin/contact/index.ejs index 6dce90c..4f37814 100644 --- a/views/admin/contact/index.ejs +++ b/views/admin/contact/index.ejs @@ -75,7 +75,7 @@ Upload
- The contact hero currently renders at about 1496x544px on desktop. Recommended minimum upload: 1920x700px. + Recommended minimum upload: 1920x700px.
@@ -181,7 +181,6 @@ name="cardTitle_<%= index %>" value="<%= card.title || '' %>" maxlength="40" data-maxlength="40"> - Recommended maximum: 40 characters.
@@ -1210,63 +1209,12 @@ }); } - function ensureContactCounter(input) { - if (!input || !input.dataset.maxlength) { - return null; - } - - if (!input.id) { - input.id = `contactField_${Math.random().toString(36).slice(2, 10)}`; - } - - const field = input.closest('.col-md-12, .col-md-7, .col-md-6, .col-md-5, .col-md-4, .col-md-3, .col-md-2, .col-12') || input.parentElement; - const anchor = input.closest('.input-group') || input; - const parent = anchor?.parentElement || field; - if (!field || !anchor || !parent) { - return null; - } - - let hint = field.querySelector(`[data-counter-for="${input.id}"]`); - if (!hint) { - hint = document.createElement('small'); - hint.className = 'form-text contact-limit-counter text-secondary'; - hint.dataset.counterFor = input.id; - } - - if (hint.previousElementSibling !== anchor) { - parent.insertBefore(hint, anchor.nextSibling); - } - - return hint; - } - - function updateContactCounter(input) { - const hint = ensureContactCounter(input); - if (!hint) { - return; - } - - const max = Number(input.dataset.maxlength); - if (Number.isFinite(max) && max > 0 && (input.value || '').length > max) { - input.value = (input.value || '').slice(0, max); - } - - const length = (input.value || '').length; - hint.textContent = `${length}/${max} characters`; - hint.classList.toggle('text-danger', length >= max); - } - function initContactCharacterCounters(scope = document) { - scope.querySelectorAll('input[data-maxlength], textarea[data-maxlength]').forEach((input) => { - updateContactCounter(input); + scope.querySelectorAll('.contact-limit-counter').forEach((node) => node.remove()); - if (input.dataset.counterBound === 'true') { - return; - } - - input.dataset.counterBound = 'true'; - input.addEventListener('input', () => updateContactCounter(input)); - }); + if (window.AdminFormHelpers) { + window.AdminFormHelpers.refresh(scope); + } } function updateAllJsonInputs(data) { @@ -1327,7 +1275,6 @@
- Recommended maximum: 40 characters.
diff --git a/views/admin/header/index.ejs b/views/admin/header/index.ejs index 66ec410..c4252c3 100644 --- a/views/admin/header/index.ejs +++ b/views/admin/header/index.ejs @@ -404,7 +404,7 @@ } } catch (error) { console.error('=== TRACE: Unified Save ERROR ===', error); - showNotification('Lỗi: ' + error.message, 'error'); + showNotification('Error: ' + error.message, 'error'); } finally { submitBtn.innerHTML = originalText; submitBtn.disabled = false; @@ -709,19 +709,19 @@ const icon = document.getElementById('newSocialIcon').value.trim(); if (!platform) { - alert('Vui lòng nhập tên nền tảng'); + alert('Please enter a platform name'); return; } if (!url) { - alert('Vui lòng nhập URL'); + alert('Please enter a URL'); return; } // Check if platform already exists const existingPlatforms = Array.from(document.querySelectorAll('.social-platform')).map(el => el.value); if (existingPlatforms.includes(platform)) { - alert(`${platform} đã tồn tại`); + alert(`${platform} already exists`); return; } @@ -780,12 +780,12 @@ const newIcon = document.getElementById('editSocialIcon').value.trim(); if (!newPlatform) { - alert('Vui lòng nhập tên nền tảng'); + alert('Please enter a platform name'); return; } if (!newUrl) { - alert('Vui lòng nhập URL'); + alert('Please enter a URL'); return; } diff --git a/views/admin/home/index.ejs b/views/admin/home/index.ejs index f42a535..5865cdc 100644 --- a/views/admin/home/index.ejs +++ b/views/admin/home/index.ejs @@ -446,92 +446,11 @@ toast.addEventListener("hidden.bs.toast", () => toast.remove()); } - function ensureCharacterHint(input) { - if (!input || (!input.dataset.maxlength && !input.dataset.maxwords)) { - return null; - } - - if (!input.id) { - input.id = `homeField_${Math.random().toString(36).slice(2, 10)}`; - } - - const field = input.closest(".col-md-12, .col-md-9, .col-md-6, .col-md-4, .col-md-3, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12") || input.parentElement; - const anchor = input.closest(".input-group") || input; - const parent = anchor?.parentElement || field; - if (!field || !anchor || !parent) { - return null; - } - - let hint = field.querySelector(`[data-counter-for="${input.id}"]`); - if (!hint) { - hint = document.createElement("small"); - hint.className = "form-text home-limit-counter text-secondary"; - hint.dataset.counterFor = input.id; - } - - if (hint.previousElementSibling !== anchor) { - parent.insertBefore(hint, anchor.nextSibling); - } - - return hint; - } - - function updateCharacterHint(input) { - const hint = ensureCharacterHint(input); - if (!hint) { - return; - } - - const hasWordLimit = Boolean(input.dataset.maxwords); - const hasCharLimit = Boolean(input.dataset.maxlength); - - if (hasWordLimit) { - const maxWords = Number(input.dataset.maxwords); - const normalized = (input.value || "").replace(/\s+/g, " ").trim(); - const words = normalized ? normalized.split(" ") : []; - - if (Number.isFinite(maxWords) && maxWords > 0 && words.length > maxWords) { - input.value = words.slice(0, maxWords).join(" "); - } else if (normalized !== input.value) { - input.value = normalized; - } - } - - if (hasCharLimit) { - const max = Number(input.dataset.maxlength); - if (Number.isFinite(max) && max > 0 && (input.value || "").length > max) { - input.value = (input.value || "").slice(0, max); - } - } - - if (hasWordLimit) { - const maxWords = Number(input.dataset.maxwords); - const currentWords = input.value ? input.value.split(" ").filter(Boolean).length : 0; - const currentLength = (input.value || "").length; - const maxLength = Number(input.dataset.maxlength); - hint.textContent = hasCharLimit - ? `${currentWords}/${maxWords} words, ${currentLength}/${maxLength} characters` - : `${currentWords}/${maxWords} words`; - hint.classList.toggle("text-danger", currentWords >= maxWords || (hasCharLimit && currentLength >= maxLength)); - return; - } - - const max = Number(input.dataset.maxlength); - const length = (input.value || "").length; - hint.textContent = `${length}/${max} characters`; - hint.classList.toggle("text-danger", length >= max); - } - function initHomeCharacterCounters(scope = document) { - scope.querySelectorAll("input[data-maxlength], textarea[data-maxlength], input[data-maxwords], textarea[data-maxwords]").forEach((input) => { - updateCharacterHint(input); + scope.querySelectorAll(".home-limit-counter").forEach((node) => node.remove()); - if (input.dataset.counterBound === "true") { - return; - } - - input.dataset.counterBound = "true"; - input.addEventListener("input", () => updateCharacterHint(input)); - }); + if (window.AdminFormHelpers) { + window.AdminFormHelpers.refresh(scope); + } } diff --git a/views/admin/home/sections/floatingContact.ejs b/views/admin/home/sections/floatingContact.ejs index 8da4ae4..d0ff814 100644 --- a/views/admin/home/sections/floatingContact.ejs +++ b/views/admin/home/sections/floatingContact.ejs @@ -64,7 +64,7 @@ style="height: 120px; width: 120px; object-fit: contain; background: #fff;" alt="Brand preview" />
- Hiển thị thực tế khoảng 35x35px. Raster uploads are normalized to 104x104 WebP to match the homepage widget. + Displayed at roughly 35x35px. Raster uploads are normalized to 104x104 WebP to match the homepage widget.
@@ -95,7 +95,7 @@
- Hiển thị thực tế khoảng 26x26px. Shown only when the trigger slideshow cannot use the brand image and action icons. Raster uploads are normalized to 96x96 WebP. + Displayed at roughly 26x26px. Shown only when the trigger slideshow cannot use the brand image and action icons. Raster uploads are normalized to 96x96 WebP.
diff --git a/views/admin/home/sections/hero.ejs b/views/admin/home/sections/hero.ejs index 118ec9e..64922c2 100644 --- a/views/admin/home/sections/hero.ejs +++ b/views/admin/home/sections/hero.ejs @@ -9,18 +9,11 @@
-
-
Current homepage hero behavior
-
- Mỗi slide dùng ảnh riêng làm nền full-width. Title, description và 2 button chỉ là lớp overlay. - Trường video hiện không còn được hiển thị ở frontend. -
-
- Tùy chọn dự phòng. Khung hero desktop hiện hiển thị khoảng 1512x544px, nên nên upload ảnh ngang ít nhất 1920x700px. + Optional fallback. The hero desktop frame currently displays approximately 1512x544px, so upload a landscape image of at least 1920x700px.
Recommended content structure
    -
  • Dùng ảnh slide tỉ lệ ngang, ưu tiên ảnh lớn để fit container.
  • -
  • Title ngắn 2-4 dòng để không tràn trên mobile.
  • -
  • Description giữ ở mức 1-3 câu ngắn.
  • -
  • Hai nút nên dùng link nội bộ như /contact.
  • +
  • Use landscape slide images; prefer large images to fit the container.
  • +
  • Keep titles to 2-4 lines to avoid overflow on mobile.
  • +
  • Limit descriptions to 1-3 short sentences.
  • +
  • Both buttons should use internal links like /contact.
@@ -101,7 +94,7 @@
- Hiện không render ngoài frontend, chỉ giữ để tương thích dữ liệu cũ. + Currently not rendered on the frontend; kept for backward compatibility with existing data. @@ -113,7 +106,7 @@
- Ảnh này phủ toàn bộ hero. Khung desktop hiện khoảng 1512x544px, khuyến nghị upload 1920x700px hoặc lớn hơn. + Recommended upload size is 1920x700px or larger.
- Frontend hiện không render video trong hero. Giữ lại chỉ để tránh mất dữ liệu cũ. + The frontend currently does not render video in the hero. Kept only to preserve existing data.
diff --git a/views/admin/home/sections/partners.ejs b/views/admin/home/sections/partners.ejs index 271a5c6..0ee0be8 100644 --- a/views/admin/home/sections/partners.ejs +++ b/views/admin/home/sections/partners.ejs @@ -10,7 +10,7 @@
- Award icons trên homepage hiển thị khoảng 124x124px. Khuyến nghị upload ảnh vuông hoặc logo nền trong suốt tối thiểu 248x248px. + Award icons on the homepage display at roughly 124x124px. Recommended uploads are square images or transparent logos at least 248x248px.
<% for(let i=0; i<4; i++) { const item = (data.partners?.visaConsultancy?.items && data.partners.visaConsultancy.items[i]) || {}; @@ -31,7 +31,7 @@
- Khuyến nghị ảnh vuông hoặc logo nền trong suốt 248x248px để hiển thị sắc nét. + Recommended: a 248x248px square image or transparent logo for crisp rendering.
- Brand logo trong slider hiện hiển thị khoảng 159x48px. Khuyến nghị dùng SVG hoặc logo ngang nền trong suốt tối thiểu 320x96px. + Brand logos in the slider display at roughly 159x48px. Recommended assets are SVGs or horizontal transparent logos at least 320x96px.
<% (data.partners?.brands?.items || []).forEach(function(item, index) { %>
@@ -83,7 +83,7 @@
- Khuyến nghị SVG hoặc ảnh ngang 320x96px để logo không bị mờ trong slider. + Recommended: SVG or a 320x96px horizontal image so the logo stays sharp in the slider.
@@ -143,7 +143,7 @@
- Khuyến nghị SVG hoặc ảnh ngang 320x96px để logo không bị mờ trong slider. + Recommended: SVG or a 320x96px horizontal image so the logo stays sharp in the slider.
diff --git a/views/admin/home/sections/testimonials.ejs b/views/admin/home/sections/testimonials.ejs index 7bf9ab4..e30c228 100644 --- a/views/admin/home/sections/testimonials.ejs +++ b/views/admin/home/sections/testimonials.ejs @@ -31,7 +31,7 @@
- Khung thumbnail desktop hiện khoảng 416x370px. Khuyến nghị upload tối thiểu 832x740px. + The desktop thumbnail frame is approximately 416x370px. Recommended upload size is at least 832x740px.
@@ -100,7 +100,7 @@
- Avatar hiển thị ở khoảng 48x48px. Khuyến nghị ảnh vuông 96x96px hoặc 128x128px. + Avatars display at roughly 48x48px. Recommended square image sizes are 96x96px or 128x128px.
diff --git a/views/admin/home/sections/visaCountries.ejs b/views/admin/home/sections/visaCountries.ejs index eebeacf..53860c3 100644 --- a/views/admin/home/sections/visaCountries.ejs +++ b/views/admin/home/sections/visaCountries.ejs @@ -63,7 +63,7 @@
- Khung ảnh desktop hiện khoảng 840x830px. Khuyến nghị ảnh gần vuông, tối thiểu 1000x1000px. + The desktop image frame is approximately 840x830px. Recommended assets are near-square images at least 1000x1000px.
- Khung desktop hiện khoảng 318x347px. Khuyến nghị upload ít nhất 750x820px, tỉ lệ dọc khoảng 0.91:1. + The desktop frame is approximately 318x347px. Recommended upload size is at least 750x820px, with a portrait ratio near 0.91:1.
- Khung desktop hiện khoảng 363x380px. Khuyến nghị upload ít nhất 760x800px, tỉ lệ dọc khoảng 0.95:1. + The desktop frame is approximately 363x380px. Recommended upload size is at least 760x800px, with a portrait ratio near 0.95:1.
- Icon hiển thị ở khoảng 24x24px. Khuyến nghị dùng SVG hoặc ảnh vuông 48x48px. + Icons display at roughly 24x24px. Recommended assets are SVGs or square images at 48x48px.
diff --git a/views/admin/level/index.ejs b/views/admin/level/index.ejs index 86068cf..27a172c 100644 --- a/views/admin/level/index.ejs +++ b/views/admin/level/index.ejs @@ -529,7 +529,7 @@ document.addEventListener('DOMContentLoaded', function() { img.alt = 'Image preview'; if (window.toastManager) { - window.toastManager.success('Tải ảnh thành công'); + window.toastManager.success('Image uploaded successfully'); } } } else { diff --git a/views/admin/visa/index.ejs b/views/admin/visa/index.ejs index efefb65..27f7087 100644 --- a/views/admin/visa/index.ejs +++ b/views/admin/visa/index.ejs @@ -107,7 +107,7 @@
- + @@ -146,7 +146,7 @@
- + @@ -236,7 +236,7 @@
- + @@ -330,7 +330,7 @@
- + @@ -599,17 +599,75 @@ setupImageUploadHandlers(); }); - function showListView() { - document.getElementById("tableContainer").style.display = "block"; - document.getElementById("formContainer").style.display = "none"; - document.getElementById("viewContainer").style.display = "none"; - } - - function showFormView() { - document.getElementById("tableContainer").style.display = "none"; - document.getElementById("formContainer").style.display = "block"; - document.getElementById("viewContainer").style.display = "none"; - } + function showListView() { + document.getElementById("tableContainer").style.display = "block"; + document.getElementById("formContainer").style.display = "none"; + document.getElementById("viewContainer").style.display = "none"; + } + + function refreshVisaAdminHelpers() { + if (!window.AdminFormHelpers) { + return; + } + + const formContainer = document.getElementById("formContainer"); + window.AdminFormHelpers.refresh(formContainer); + + const guidanceTargets = [ + { + selector: "#icon_input", + lines: [ + "Displayed as a small country flag or icon.", + "Prefer SVG; otherwise use a square image at 96x96px or larger." + ] + }, + { + selector: "#mainImage_detail", + lines: [ + "Used in visa detail content blocks.", + "Recommended upload: at least 1000x750px for primary imagery." + ] + }, + { + selector: "input[name='bannerImageGallery']", + lines: [ + "Gallery image used in visa detail content.", + "Recommended upload: at least 1000x750px." + ] + }, + { + selector: "input[name='related_icon[]']", + lines: [ + "Supporting service image.", + "Recommended upload: at least 800x600px." + ] + }, + { + selector: "#contact_image_input", + lines: [ + "Contact-side supporting image.", + "Recommended upload: at least 800x600px." + ] + } + ]; + + guidanceTargets.forEach(({ selector, lines }) => { + document.querySelectorAll(selector).forEach((input) => { + window.AdminFormHelpers.renderUploadGuidance(input, { + for: input.id || input.name || "", + title: "Upload guidance", + lines + }); + }); + }); + } + + function showFormView() { + document.getElementById("tableContainer").style.display = "none"; + document.getElementById("formContainer").style.display = "block"; + document.getElementById("viewContainer").style.display = "none"; + refreshVisaAdminHelpers(); + } function showViewView() { document.getElementById("tableContainer").style.display = "none"; @@ -795,12 +853,13 @@ } } - - showFormView(); - - } catch (error) { - console.error("Error:", error); - showNotification('Cannot connect to server. Please try again.', 'error'); + + showFormView(); + refreshVisaAdminHelpers(); + + } catch (error) { + console.error("Error:", error); + showNotification('Cannot connect to server. Please try again.', 'error'); } } @@ -892,9 +951,10 @@ if (field) field.value = ""; }); - - showFormView(); - }); + + showFormView(); + refreshVisaAdminHelpers(); + }); document.getElementById("btnBackToList").addEventListener("click", showListView); document.getElementById("btnBackFromView").addEventListener("click", showListView); @@ -1031,4 +1091,4 @@ btnSave.innerHTML = 'Save'; } }); - \ No newline at end of file + diff --git a/views/layouts/admin.ejs b/views/layouts/admin.ejs index d566c7e..fec2383 100644 --- a/views/layouts/admin.ejs +++ b/views/layouts/admin.ejs @@ -12,6 +12,8 @@ + + \ No newline at end of file + diff --git a/views/admin/appointment/index.ejs b/views/admin/appointment/index.ejs index 707fba1..4bad587 100644 --- a/views/admin/appointment/index.ejs +++ b/views/admin/appointment/index.ejs @@ -767,16 +767,16 @@ formData.append('image', file); try { - const response = await fetch('/admin/upload/image', { + const response = await fetch(`/admin/upload/image?imageType=${encodeURIComponent(imageType || 'appointment')}`, { method: 'POST', body: formData }); const result = await response.json(); - if (result.success && result.imagePath) { - document.getElementById(targetInput).value = result.imagePath; + if (result.success && result.path) { + document.getElementById(targetInput).value = result.path; if (targetInput === 'heroBackgroundImage') { - updateHeroImagePreview(result.imagePath); + updateHeroImagePreview(result.path); } } else { alert('Upload failed: ' + (result.error || 'Unknown error')); @@ -788,4 +788,4 @@ }; input.click(); } - \ No newline at end of file + diff --git a/views/admin/footer/index.ejs b/views/admin/footer/index.ejs index 08257ef..ffebd94 100644 --- a/views/admin/footer/index.ejs +++ b/views/admin/footer/index.ejs @@ -634,8 +634,8 @@ async function loadFooterData() { try { - console.log("Fetching footer data from /api/footer..."); - const response = await fetch("/api/footer"); + console.log("Fetching footer data from /admin/footer/data..."); + const response = await fetch("/admin/footer/data"); console.log("Response status:", response.status); console.log("Response ok:", response.ok); From e7929568dc1a08c8a1998389fb1b2e86df0d5880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20Nh=E1=BA=ADt?= Date: Sat, 11 Apr 2026 04:46:33 +0700 Subject: [PATCH 11/13] fix: on/off tab in home page --- .env.example | Bin 616 -> 686 bytes controllers/homeController.js | 9 +++----- models/home.js | 3 +++ scripts/cleanup-home-docs.js | 22 ++++++++++++++++++++ views/admin/home/sections/achievements.ejs | 4 ++-- views/admin/home/sections/blogPreview.ejs | 4 ++-- views/admin/home/sections/faq.ejs | 4 ++-- views/admin/home/sections/hero.ejs | 4 ++-- views/admin/home/sections/partners.ejs | 4 ++-- views/admin/home/sections/testimonials.ejs | 4 ++-- views/admin/home/sections/videoGallery.ejs | 4 ++-- views/admin/home/sections/visaCountries.ejs | 4 ++-- views/admin/home/sections/visaSolutions.ejs | 4 ++-- views/admin/home/sections/whyChooseUs.ejs | 4 ++-- 14 files changed, 48 insertions(+), 26 deletions(-) create mode 100644 scripts/cleanup-home-docs.js diff --git a/.env.example b/.env.example index 6e9969b5d10bccc452cb2dae3cb5f057b31e2d94..797edcba66245019435729c4e6a583f87cbbe45a 100644 GIT binary patch delta 233 zcmYk0K@P!S5QV=AaRUo`Hl+U6f=HS!tl5+U2tpH7+OoB90n&SLElWr6{&pji_a^Vn zo6LObug0VGG~c3MYd41&l~*jC&Yw2L+qvI7d`7sMSMZpWj`H#cglvdO*|H{QO2SE1 zskl^u{FIBlE}1yxT5=>l2zIYZF4T{55~XuyL2!kzy0fQa_PZa)aTFhX@pbvz}47m)&K* E09I-v%>V!Z diff --git a/controllers/homeController.js b/controllers/homeController.js index 043f51c..5f8bc22 100644 --- a/controllers/homeController.js +++ b/controllers/homeController.js @@ -445,14 +445,11 @@ exports.apiGetBlogs = async (req, res) => { }; exports.api = async (req, res) => { try { - const docs = await getAllHomeDocs(); - let data = docs[0]?.toObject() || {}; + // 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")}`; - if (docs.length > 1) { - data.hero = getPreferredHeroData(docs.map((doc) => doc.toObject())); - } - // === Xử lý Blog Preview động === const blogPreview = data.blogPreview || {}; let blogs = []; diff --git a/models/home.js b/models/home.js index 57fd0b0..6c310b6 100644 --- a/models/home.js +++ b/models/home.js @@ -349,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/scripts/cleanup-home-docs.js b/scripts/cleanup-home-docs.js new file mode 100644 index 0000000..b341f6a --- /dev/null +++ b/scripts/cleanup-home-docs.js @@ -0,0 +1,22 @@ +require('dotenv').config(); +const mongoose = require('mongoose'); +const Home = require('../models/home'); + +mongoose.connect(process.env.MONGODB_URI).then(async () => { + const docs = await Home.find().sort({ updatedAt: -1 }).lean(); + console.log('Total docs:', docs.length); + + if (docs.length <= 1) { + console.log('Nothing to clean up.'); + return; + } + + const keep = docs[0]; + const idsToDelete = docs.slice(1).map(d => d._id); + await Home.deleteMany({ _id: { $in: idsToDelete } }); + + console.log('Kept doc:', keep._id, '| hero.enabled:', keep.hero?.enabled); + console.log('Deleted', idsToDelete.length, 'duplicate docs'); + + await mongoose.disconnect(); +}); diff --git a/views/admin/home/sections/achievements.ejs b/views/admin/home/sections/achievements.ejs index 95b1942..c2c836c 100644 --- a/views/admin/home/sections/achievements.ejs +++ b/views/admin/home/sections/achievements.ejs @@ -1,4 +1,4 @@ - +
@@ -88,7 +88,7 @@ window.homeScrapers.achievements = function () { const items = []; - const enabled = document.getElementById("achievementEnabled")?.checked !== false; + const enabled = document.getElementById("achievementEnabled")?.checked === true; document.querySelectorAll('.achievement-item').forEach(el => { items.push({ diff --git a/views/admin/home/sections/blogPreview.ejs b/views/admin/home/sections/blogPreview.ejs index 2c73f72..9a32d2b 100644 --- a/views/admin/home/sections/blogPreview.ejs +++ b/views/admin/home/sections/blogPreview.ejs @@ -1,4 +1,4 @@ - +
@@ -158,7 +158,7 @@ window.homeScrapers.blogPreview = () => { const selectedIds = []; - const enabled = document.getElementById("blogpreviewEnabled")?.checked !== false; + const enabled = document.getElementById("blogpreviewEnabled")?.checked === true; document.querySelectorAll('.blog-checkbox:checked').forEach(cb => { selectedIds.push(cb.value); diff --git a/views/admin/home/sections/faq.ejs b/views/admin/home/sections/faq.ejs index 73655ba..849bab5 100644 --- a/views/admin/home/sections/faq.ejs +++ b/views/admin/home/sections/faq.ejs @@ -1,4 +1,4 @@ - +
@@ -139,7 +139,7 @@ window.homeScrapers.faq = () => { const getVal = (id) => (document.getElementById(id)?.value || "").trim(); - const enabled = document.getElementById('faqEnabled')?.checked !== false; + const enabled = document.getElementById('faqEnabled')?.checked === true; const items = []; document.querySelectorAll(".faq-item").forEach((el, idx) => { diff --git a/views/admin/home/sections/hero.ejs b/views/admin/home/sections/hero.ejs index da755d9..21cdd35 100644 --- a/views/admin/home/sections/hero.ejs +++ b/views/admin/home/sections/hero.ejs @@ -1,4 +1,4 @@ - +
@@ -203,7 +203,7 @@ const getVal = (id) => (document.getElementById(id)?.value || "").trim(); const backgroundImage = getVal("heroBackgroundImage"); - const enabled = document.getElementById("heroEnabled")?.checked !== false; + const enabled = document.getElementById("heroEnabled")?.checked === true; const slides = []; const slideEls = document.querySelectorAll(".hero-slide-item"); diff --git a/views/admin/home/sections/partners.ejs b/views/admin/home/sections/partners.ejs index c340f81..3223ce1 100644 --- a/views/admin/home/sections/partners.ejs +++ b/views/admin/home/sections/partners.ejs @@ -1,4 +1,4 @@ - +
@@ -120,7 +120,7 @@ window.homeScrapers.partners = function () { const visaItems = []; - const enabled = document.getElementById('partnersEnabled')?.checked !== false; + const enabled = document.getElementById('partnersEnabled')?.checked === true; document.querySelectorAll('.visa-item').forEach(el => { visaItems.push({ diff --git a/views/admin/home/sections/testimonials.ejs b/views/admin/home/sections/testimonials.ejs index 26b6fca..6033226 100644 --- a/views/admin/home/sections/testimonials.ejs +++ b/views/admin/home/sections/testimonials.ejs @@ -1,4 +1,4 @@ - +
@@ -135,7 +135,7 @@ window.homeScrapers.testimonials = () => { const getVal = (id) => (document.getElementById(id)?.value || "").trim(); - const enabled = document.getElementById("testimonialEnabled")?.checked !== false; + const enabled = document.getElementById("testimonialEnabled")?.checked === true; const items = []; document.querySelectorAll(".testimonial-item").forEach((el, idx) => { diff --git a/views/admin/home/sections/videoGallery.ejs b/views/admin/home/sections/videoGallery.ejs index 32cf2df..a3eae6d 100644 --- a/views/admin/home/sections/videoGallery.ejs +++ b/views/admin/home/sections/videoGallery.ejs @@ -1,4 +1,4 @@ - +
@@ -64,7 +64,7 @@ window.homeScrapers.videoGallery = () => { const getVal = (id) => (document.getElementById(id)?.value || "").trim(); - const enabled = document.getElementById("videoGalleryEnabled")?.checked !== false; + const enabled = document.getElementById("videoGalleryEnabled")?.checked === true; return { heading: getVal("videoGalleryHeading"), videoUrl: getVal("videoGalleryVideoUrl"), diff --git a/views/admin/home/sections/visaCountries.ejs b/views/admin/home/sections/visaCountries.ejs index da1bc52..10c3f0c 100644 --- a/views/admin/home/sections/visaCountries.ejs +++ b/views/admin/home/sections/visaCountries.ejs @@ -1,4 +1,4 @@ - +
@@ -135,7 +135,7 @@ window.homeScrapers.visaCountries = () => { const getVal = (id) => (document.getElementById(id)?.value || "").trim(); - const enabled = document.getElementById("visaCountriesEnabled")?.checked !== false + const enabled = document.getElementById("visaCountriesEnabled")?.checked === true const visaTypesRaw = getVal("visaCountriesVisaTypes_0"); const visaTypes = visaTypesRaw ? visaTypesRaw.split(",").map((s) => s.trim()).filter(Boolean) : []; diff --git a/views/admin/home/sections/visaSolutions.ejs b/views/admin/home/sections/visaSolutions.ejs index 0f857f5..706b144 100644 --- a/views/admin/home/sections/visaSolutions.ejs +++ b/views/admin/home/sections/visaSolutions.ejs @@ -1,4 +1,4 @@ - +
@@ -100,7 +100,7 @@ window.homeScrapers.visaSolutions = () => { const getVal = (id) => (document.getElementById(id)?.value || "").trim(); - const enabled = document.getElementById("visaSolutionsEnabled")?.checked !== false; + const enabled = document.getElementById("visaSolutionsEnabled")?.checked === true; const items = []; document.querySelectorAll(".visa-solution-item").forEach((el, idx) => { diff --git a/views/admin/home/sections/whyChooseUs.ejs b/views/admin/home/sections/whyChooseUs.ejs index 93cbed2..55decf4 100644 --- a/views/admin/home/sections/whyChooseUs.ejs +++ b/views/admin/home/sections/whyChooseUs.ejs @@ -1,4 +1,4 @@ - +
@@ -206,7 +206,7 @@ window.homeScrapers = window.homeScrapers || {}; window.homeScrapers.whyChooseUs = () => { const getVal = (id) => (document.getElementById(id)?.value || "").trim(); - const enabled = document.getElementById("whyChooseUsEnabled")?.checked !== false; + const enabled = document.getElementById("whyChooseUsEnabled")?.checked === true; // Collect items const items = []; From 0109b08b5860e1294c9ea8137a7eeb417e464d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20Nh=E1=BA=ADt?= Date: Sat, 11 Apr 2026 05:45:07 +0700 Subject: [PATCH 12/13] refactor: add auto-generated menu for new services --- controllers/serviceController.js | 6 ++ scripts/sync-service-menu-now.js | 34 +++++++++++ services/syncServiceMenu.js | 57 +++++++++++++++++++ views/admin/home/sections/floatingContact.ejs | 24 +++----- 4 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 scripts/sync-service-menu-now.js create mode 100644 services/syncServiceMenu.js 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/scripts/sync-service-menu-now.js b/scripts/sync-service-menu-now.js new file mode 100644 index 0000000..efdb347 --- /dev/null +++ b/scripts/sync-service-menu-now.js @@ -0,0 +1,34 @@ +/** + * One-time script: sync service menu items from DB into HeaderMenu. + * Run: node scripts/sync-service-menu-now.js + */ +const mongoose = require("mongoose"); +const dotenv = require("dotenv"); +dotenv.config(); + +const Service = require("../models/service"); +const syncServiceMenu = require("../services/syncServiceMenu"); + +const MONGODB_URI = process.env.MONGODB_URI || "mongodb://localhost:27017/hailearning"; + +async function run() { + await mongoose.connect(MONGODB_URI); + console.log("✅ Connected to MongoDB"); + + const serviceDoc = await Service.findOne().lean(); + if (!serviceDoc?.services?.items?.length) { + console.log("⚠️ No services found in DB."); + process.exit(0); + } + + console.log(`Found ${serviceDoc.services.items.length} services. Syncing menu...`); + await syncServiceMenu(serviceDoc.services.items); + + console.log("✅ Done."); + process.exit(0); +} + +run().catch((err) => { + console.error("❌ Error:", err); + process.exit(1); +}); diff --git a/services/syncServiceMenu.js b/services/syncServiceMenu.js new file mode 100644 index 0000000..f749871 --- /dev/null +++ b/services/syncServiceMenu.js @@ -0,0 +1,57 @@ +/** + * Sync HeaderMenu children of the "Services" menu item + * to match the current list of services in the database. + * + * Strategy: + * - Find the HeaderMenu item whose url === '/services' + * - Delete all its direct children + * - Re-create one child per service item (url = /services/) + */ + +const HeaderMenu = require("../models/headerMenu"); +const slugify = require("slugify"); + +/** + * @param {Array} serviceItems - array of service objects { slug, name } + */ +const syncServiceMenu = async (serviceItems = []) => { + try { + // 1. Find the "Services" parent menu item + const servicesParent = await HeaderMenu.findOne({ url: "/services" }); + + if (!servicesParent) { + console.warn("[syncServiceMenu] No HeaderMenu item with url=/services found. Skipping sync."); + return; + } + + const parentId = servicesParent._id; + + // 2. Remove all existing children of that parent + await HeaderMenu.deleteMany({ parentId }); + + // 3. Re-create one child per service + const ops = serviceItems + .filter((s) => s && s.slug && s.name) + .map((s, index) => ({ + title: s.name, + slug: slugify(s.name, { lower: true, strict: true }), + url: `/services/${s.slug}`, + parentId, + order: index + 1, + status: "active", + type: "internal", + is_maintainance: false, + })); + + if (ops.length > 0) { + await HeaderMenu.insertMany(ops); + } + + console.log(`[syncServiceMenu] Synced ${ops.length} service menu items under parentId=${parentId}`); + } catch (err) { + // Non-fatal – log but don't crash the main request + console.error("[syncServiceMenu] Error syncing service menu:", err.message); + } +}; + +module.exports = syncServiceMenu; diff --git a/views/admin/home/sections/floatingContact.ejs b/views/admin/home/sections/floatingContact.ejs index d0ff814..a2b023e 100644 --- a/views/admin/home/sections/floatingContact.ejs +++ b/views/admin/home/sections/floatingContact.ejs @@ -2,6 +2,14 @@
+
+
+
+ > +
+
+
@@ -11,21 +19,7 @@
-
-
-
-
Widget visibility
- Enable or disable the floating contact widget on the homepage. -
-
- /> - -
-
-
+
Date: Sat, 11 Apr 2026 11:09:16 +0700 Subject: [PATCH 13/13] fix: fix resized image uploads --- controllers/uploadController.js | 10 +++++++--- middleware/upload.js | 10 +++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/controllers/uploadController.js b/controllers/uploadController.js index 7d4f80c..ff8042f 100644 --- a/controllers/uploadController.js +++ b/controllers/uploadController.js @@ -66,17 +66,21 @@ async function finalizeUploadedImage(file, req, resizePreset) { } const { finalFileName, finalPath } = getFinalUploadTarget(file, req, true); + const finalPathMatchesInput = path.resolve(file.path) === path.resolve(finalPath); - await sharp(file.path) + 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 }) - .toFile(finalPath); + .toBuffer(); - if (fs.existsSync(file.path)) { + fs.writeFileSync(finalPath, optimizedBuffer); + + if (!finalPathMatchesInput && fs.existsSync(file.path)) { try { fs.unlinkSync(file.path); } catch (cleanupError) { 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 +};