feat: standardize admin form limits and guidance

This commit is contained in:
Tống Thành Đạt
2026-04-10 15:55:15 +07:00
parent 7ce5921fe0
commit 51c6303437
34 changed files with 1692 additions and 361 deletions

View File

@@ -37,6 +37,47 @@
margin-top: var(--spacing-1); 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 */ /* Validation Styles */
.invalid-feedback { .invalid-feedback {
font-size: var(--font-size-xs); font-size: var(--font-size-xs);

View File

@@ -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,
};

View File

@@ -2,10 +2,33 @@ const { addBaseUrlToImages } = require("../utils/imageHelper");
const AboutUs = require("../models/aboutUs"); const AboutUs = require("../models/aboutUs");
const Blog = require("../models/blog"); const Blog = require("../models/blog");
const jsonHelper = require("../utils/jsonHelper"); 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 writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject"); const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction"); 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 * GET /api/about
* Lấy dữ liệu About Us (Public API cho website và CMS load dữ liệu) * 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 // ✅ Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(doc.toObject())); 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 // Use .set() for better handling of nested objects/arrays in Mongoose
doc.set(updateData); doc.set(updateData);
await doc.save(); await doc.save();
@@ -210,6 +238,13 @@ exports.update = async (req, res) => {
// ✅ Capture BEFORE state // ✅ Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(doc.toObject())); 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); doc.set(updateData);
await doc.save(); await doc.save();

View File

@@ -1,6 +1,22 @@
const {addBaseUrlToImages} = require("../utils/imageHelper"); const {addBaseUrlToImages} = require("../utils/imageHelper");
const Activity = require("../models/activity"); const Activity = require("../models/activity");
const mongoose = require('mongoose'); 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 -------------------- // -------------------- Public (API) exports --------------------
@@ -302,6 +318,15 @@ exports.updateFilters = async (req, res) => {
try { try {
// Provide minimal valid fields when inserting a new filters document so // Provide minimal valid fields when inserting a new filters document so
// schema validators (e.g., age validator) do not fail on upsert. // 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 = { const setOnInsert = {
name: "_filters_doc", name: "_filters_doc",
price: 0, price: 0,
@@ -353,6 +378,14 @@ exports.updateHero = async (req, res) => {
bannerImageBooking: bannerImageBooking || '/uploads/banner/b9.jpg', 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 // Update all activity docs to keep hero consistent
await Activity.updateMany({ isFiltersDoc: { $ne: true } }, { $set: { hero } }); await Activity.updateMany({ isFiltersDoc: { $ne: true } }, { $set: { hero } });
@@ -413,6 +446,16 @@ exports.create = async (req, res) => {
try { try {
const activityData = parseActivityFormData(req.body); 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); const newActivity = new Activity(activityData);
await newActivity.save(); await newActivity.save();
@@ -465,6 +508,16 @@ exports.update = async (req, res) => {
// Force status to active on update (always set isActive true when editing) // Force status to active on update (always set isActive true when editing)
activityData.isActive = true; 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}); await Activity.findByIdAndUpdate(req.params.id, activityData, {new: true});
req.flash("success_msg", "Activity updated successfully"); req.flash("success_msg", "Activity updated successfully");

View File

@@ -2,6 +2,17 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { addBaseUrlToImages } = require("../utils/imageHelper"); const { addBaseUrlToImages } = require("../utils/imageHelper");
const Booking = require("../models/booking"); 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 -------------------- // -------------------- Public helpers --------------------
const getBookingData = async () => { const getBookingData = async () => {
@@ -398,6 +409,12 @@ exports.update = async (req, res) => {
return req.session.save(() => res.redirect("/admin/booking")); 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 // Validate data structure
const validation = validateBookingData(updateData); const validation = validateBookingData(updateData);
if (!validation.isValid) { if (!validation.isValid) {

View File

@@ -7,13 +7,13 @@ const formController = {
try { try {
res.render('admin/form/index', { res.render('admin/form/index', {
layout: 'layouts/admin', layout: 'layouts/admin',
title: 'Quản lý Form', title: 'Form Management',
user: req.session.user, user: req.session.user,
}); });
} catch (error) { } catch (error) {
console.error('Error loading form management page:', error); console.error('Error loading form management page:', error);
res.status(500).render('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 error: error
}); });
} }
@@ -29,13 +29,13 @@ const formController = {
res.json({ res.json({
success: true, success: true,
message: 'Cập nhật form thành công' message: 'Form settings updated successfully'
}); });
} catch (error) { } catch (error) {
console.error('Error updating form:', error); console.error('Error updating form:', error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: 'Lỗi khi cập nhật form' message: 'Failed to update form settings'
}); });
} }
} }

View File

@@ -118,7 +118,7 @@ const normalizeStoredImagePath = (imagePath) => {
const getDefaultFloatingContactData = () => ({ const getDefaultFloatingContactData = () => ({
enabled: true, enabled: true,
position: "bottom-right", 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: { brand: {
imageSrc: "/assets/img/logo/black-logo.svg", imageSrc: "/assets/img/logo/black-logo.svg",
imageAlt: "HAI Learning", imageAlt: "HAI Learning",
@@ -132,7 +132,7 @@ const getDefaultFloatingContactData = () => ({
id: "facebook", id: "facebook",
platform: "facebook", platform: "facebook",
enabled: true, enabled: true,
label: "Nhắn tin qua Facebook", label: "Message via Facebook",
subtitle: "facebook.com/hailearning.edu.vn", subtitle: "facebook.com/hailearning.edu.vn",
href: "https://www.facebook.com/hailearning.edu.vn/", href: "https://www.facebook.com/hailearning.edu.vn/",
iconImage: "/uploads/home/floating-contact/Facebook_Logo_Primary.webp", iconImage: "/uploads/home/floating-contact/Facebook_Logo_Primary.webp",
@@ -145,7 +145,7 @@ const getDefaultFloatingContactData = () => ({
id: "zalo", id: "zalo",
platform: "zalo", platform: "zalo",
enabled: true, enabled: true,
label: "Nhắn tin qua Zalo", label: "Message via Zalo",
subtitle: "zalo.me/84961834040", subtitle: "zalo.me/84961834040",
href: "https://zalo.me/84961834040", href: "https://zalo.me/84961834040",
iconImage: "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp", iconImage: "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp",

View File

@@ -8,7 +8,7 @@ exports.getAllPages = async (req, res) => {
const pages = content.pages || []; const pages = content.pages || [];
res.render('admin/pages/index', { res.render('admin/pages/index', {
title: 'Quản lý trang', title: 'Page Management',
pages pages
}); });
} catch (err) { } catch (err) {
@@ -21,7 +21,7 @@ exports.getAllPages = async (req, res) => {
// Hiển thị form tạo trang mới // Hiển thị form tạo trang mới
exports.getAddPage = (req, res) => { exports.getAddPage = (req, res) => {
res.render('admin/pages/add', { 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', { res.render('admin/pages/edit', {
title: 'Chỉnh sửa trang', title: 'Edit Page',
page page
}); });
} catch (err) { } catch (err) {

View File

@@ -1,8 +1,19 @@
const Pricing = require("../models/pricing"); const Pricing = require("../models/pricing");
const {
validateLengthRules,
summarizeLengthErrors,
} = require("../utils/lengthValidation");
const {
PRICING_LENGTH_RULES,
} = require("../constants/contentLengthRules");
const writeAuditLog = require("../audit/writeAuditLog"); const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject"); const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction"); const AUDIT_ACTIONS = require("../constants/auditAction");
const getLengthValidationMessage = (validation) =>
summarizeLengthErrors(validation, 3) ||
"One or more fields exceed the allowed length.";
// ==================== CMS ADMIN FUNCTIONS ==================== // ==================== CMS ADMIN FUNCTIONS ====================
// Render admin page for pricing management // Render admin page for pricing management
@@ -86,6 +97,20 @@ exports.update = async (req, res) => {
? JSON.parse(testimonials) ? JSON.parse(testimonials)
: 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" }); let pricing = await Pricing.findOne({ name: "default" });
// ✅ Capture BEFORE state // ✅ Capture BEFORE state

View File

@@ -7,11 +7,11 @@ exports.getSettings = async (req, res) => {
const content = readJsonFile('content'); const content = readJsonFile('content');
const settings = content.settings || { const settings = content.settings || {
siteName: 'CMS-SIMS', 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', { res.render('admin/settings', {
title: 'Cài đặt hệ thống', title: 'System Settings',
settings settings
}); });
} catch (err) { } catch (err) {

View File

@@ -3,87 +3,87 @@ const mongoose = require("mongoose");
const aboutUsSchema = new mongoose.Schema( const aboutUsSchema = new mongoose.Schema(
{ {
hero: { hero: {
title: String, title: { type: String, trim: true, maxlength: 80 },
breadcrumb: [String], breadcrumb: [{ type: String, trim: true, maxlength: 80 }],
backgroundImage: String, backgroundImage: { type: String, trim: true, maxlength: 255 },
}, },
intro: { intro: {
subheading: String, subheading: { type: String, trim: true, maxlength: 80 },
heading: String, heading: { type: String, trim: true, maxlength: 120 },
description: String, description: { type: String, trim: true, maxlength: 1000 },
image: String, image: { type: String, trim: true, maxlength: 255 },
}, },
mission: { mission: {
subheading: String, subheading: { type: String, trim: true, maxlength: 80 },
heading: String, heading: { type: String, trim: true, maxlength: 120 },
description: String, description: { type: String, trim: true, maxlength: 1000 },
images: { images: {
main: String, main: { type: String, trim: true, maxlength: 255 },
secondary: String, secondary: { type: String, trim: true, maxlength: 255 },
bgShape: String, bgShape: { type: String, trim: true, maxlength: 255 },
planeShape: String, planeShape: { type: String, trim: true, maxlength: 255 },
topShape: String, topShape: { type: String, trim: true, maxlength: 255 },
globeShape: String, globeShape: { type: String, trim: true, maxlength: 255 },
}, },
items: [ items: [
new mongoose.Schema( new mongoose.Schema(
{ {
icon: String, icon: { type: String, trim: true, maxlength: 255 },
label: String, label: { type: String, trim: true, maxlength: 80 },
description: String, description: { type: String, trim: true, maxlength: 240 },
}, },
{ _id: false }, { _id: false },
), ),
], ],
features: [String], features: [{ type: String, trim: true, maxlength: 80 }],
ctaButton: { ctaButton: {
label: String, label: { type: String, trim: true, maxlength: 64 },
href: String, href: { type: String, trim: true, maxlength: 255 },
}, },
}, },
features: { features: {
backgroundImage: String, backgroundImage: { type: String, trim: true, maxlength: 255 },
subheading: String, subheading: { type: String, trim: true, maxlength: 80 },
heading: String, heading: { type: String, trim: true, maxlength: 120 },
description: String, description: { type: String, trim: true, maxlength: 1000 },
image: String, image: { type: String, trim: true, maxlength: 255 },
items: [ items: [
new mongoose.Schema( new mongoose.Schema(
{ {
icon: String, icon: { type: String, trim: true, maxlength: 255 },
title: String, title: { type: String, trim: true, maxlength: 80 },
description: String, description: { type: String, trim: true, maxlength: 240 },
}, },
{ _id: false }, { _id: false },
), ),
], ],
ctaButton: { ctaButton: {
label: String, label: { type: String, trim: true, maxlength: 64 },
href: String, href: { type: String, trim: true, maxlength: 255 },
}, },
}, },
news: { news: {
subheading: String, subheading: { type: String, trim: true, maxlength: 80 },
heading: String, heading: { type: String, trim: true, maxlength: 120 },
ctaButton: { ctaButton: {
label: String, label: { type: String, trim: true, maxlength: 64 },
href: String, href: { type: String, trim: true, maxlength: 255 },
}, },
selectedBlogIds: [{ type: mongoose.Schema.Types.ObjectId, ref: "Blog" }], selectedBlogIds: [{ type: mongoose.Schema.Types.ObjectId, ref: "Blog" }],
// Deprecated: items field kept for backward compatibility during migration // Deprecated: items field kept for backward compatibility during migration
items: [ items: [
new mongoose.Schema( new mongoose.Schema(
{ {
title: String, title: { type: String, trim: true, maxlength: 120 },
category: String, category: { type: String, trim: true, maxlength: 48 },
date: String, date: { type: String, trim: true, maxlength: 32 },
comments: Number, comments: Number,
author: { author: {
name: String, name: { type: String, trim: true, maxlength: 48 },
avatar: String, avatar: { type: String, trim: true, maxlength: 255 },
}, },
link: String, link: { type: String, trim: true, maxlength: 255 },
thumbnail: String, thumbnail: { type: String, trim: true, maxlength: 255 },
}, },
{ _id: false }, { _id: false },
), ),

View File

@@ -7,28 +7,33 @@ const activitySchema = new mongoose.Schema(
titleActivities: { titleActivities: {
type: String, type: String,
trim: true, trim: true,
default: '' default: "",
maxlength: 80,
}, },
titleBooking: { titleBooking: {
type: String, type: String,
trim: true, trim: true,
default: '' default: "",
maxlength: 80,
}, },
bannerImageActivities: { bannerImageActivities: {
type: String, type: String,
trim: true, trim: true,
default: '' default: "",
maxlength: 255,
}, },
bannerImageBooking: { bannerImageBooking: {
type: String, type: String,
trim: true, trim: true,
default: '' default: "",
maxlength: 255,
}, },
}, },
name: { name: {
type: String, type: String,
required: true, required: true,
trim: true, trim: true,
maxlength: 120,
}, },
price: { price: {
type: Number, type: Number,
@@ -38,6 +43,7 @@ const activitySchema = new mongoose.Schema(
priceText: { priceText: {
type: String, type: String,
trim: true, trim: true,
maxlength: 32,
}, },
season: [ season: [
{ {
@@ -58,25 +64,28 @@ const activitySchema = new mongoose.Schema(
{ {
type: String, type: String,
trim: true, trim: true,
maxlength: 80,
}, },
], ],
image: { image: {
type: String, type: String,
trim: true, trim: true,
maxlength: 255,
}, },
link: { link: {
type: String, type: String,
trim: true, trim: true,
maxlength: 255,
}, },
// Global filters document (single document in Activity collection) // Global filters document (single document in Activity collection)
filters: [ filters: [
{ {
label: { type: String, required: true, trim: true }, label: { type: String, required: true, trim: true, maxlength: 64 },
value: { type: String, required: true, trim: true }, value: { type: String, required: true, trim: true, maxlength: 64 },
items: [ items: [
{ {
value: { type: String, required: true }, value: { type: String, required: true, maxlength: 64 },
label: { type: String, required: true }, label: { type: String, required: true, maxlength: 64 },
}, },
], ],
order: { type: Number, default: 0 }, order: { type: Number, default: 0 },
@@ -85,6 +94,7 @@ const activitySchema = new mongoose.Schema(
program: { program: {
type: String, type: String,
trim: true, trim: true,
maxlength: 80,
}, },
rating: { rating: {
type: Number, type: Number,
@@ -113,7 +123,7 @@ const activitySchema = new mongoose.Schema(
// Booking sessions - các đợt booking với thông số riêng // Booking sessions - các đợt booking với thông số riêng
bookingSessions: [ bookingSessions: [
{ {
sessionId: { type: String, required: true }, sessionId: { type: String, required: true, maxlength: 80 },
startDate: { type: Date, required: true }, startDate: { type: Date, required: true },
endDate: { type: Date, required: true }, endDate: { type: Date, required: true },
overnightStays: { type: Number, required: true, default: 14 }, overnightStays: { type: Number, required: true, default: 14 },
@@ -127,11 +137,11 @@ const activitySchema = new mongoose.Schema(
// Danh sách booking cho session này // Danh sách booking cho session này
bookingList: [ bookingList: [
{ {
address: { type: String, required: true }, address: { type: String, required: true, maxlength: 255 },
agreeNewsletter: { type: Boolean, default: false }, agreeNewsletter: { type: Boolean, default: false },
agreeTerms: { type: Boolean, required: true }, agreeTerms: { type: Boolean, required: true },
city: { type: String, required: true }, city: { type: String, required: true, maxlength: 80 },
country: { type: String, required: true }, country: { type: String, required: true, maxlength: 80 },
dietaryRestrictions: { dietaryRestrictions: {
type: String, type: String,
enum: ['none', 'vegetarian', 'vegan', 'halal', 'kosher', 'gluten-free', 'other'], enum: ['none', 'vegetarian', 'vegan', 'halal', 'kosher', 'gluten-free', 'other'],
@@ -141,26 +151,27 @@ const activitySchema = new mongoose.Schema(
type: String, type: String,
required: true, required: true,
lowercase: true, lowercase: true,
trim: true trim: true,
maxlength: 120
}, },
emergencyContact: { type: String, required: true }, emergencyContact: { type: String, required: true, maxlength: 80 },
emergencyPhone: { type: String, required: true }, emergencyPhone: { type: String, required: true, maxlength: 40 },
medicalConditions: { type: String, default: '' }, medicalConditions: { type: String, default: '', maxlength: 500 },
numberOfParticipants: { type: Number, required: true, min: 1 }, numberOfParticipants: { type: Number, required: true, min: 1 },
parentFirstName: { type: String, required: true, trim: true }, parentFirstName: { type: String, required: true, trim: true, maxlength: 80 },
parentLastName: { type: String, required: true, trim: true }, parentLastName: { type: String, required: true, trim: true, maxlength: 80 },
participantBirthDate: { type: Date, required: true }, participantBirthDate: { type: Date, required: true },
participantFirstName: { type: String, required: true, trim: true }, participantFirstName: { type: String, required: true, trim: true, maxlength: 80 },
participantGender: { participantGender: {
type: String, type: String,
enum: ['male', 'female', 'other'], enum: ['male', 'female', 'other'],
required: true required: true
}, },
participantLastName: { type: String, required: true, trim: true }, participantLastName: { type: String, required: true, trim: true, maxlength: 80 },
phone: { type: String, required: true }, phone: { type: String, required: true, maxlength: 40 },
postalCode: { type: String, required: true }, postalCode: { type: String, required: true, maxlength: 20 },
sessionDate: { type: String, required: true }, // sessionId reference sessionDate: { type: String, required: true, maxlength: 80 }, // sessionId reference
specialRequests: { type: String, default: '' }, specialRequests: { type: String, default: '', maxlength: 500 },
// Thêm các trường quản lý // Thêm các trường quản lý
bookingStatus: { bookingStatus: {
type: String, type: String,
@@ -175,8 +186,8 @@ const activitySchema = new mongoose.Schema(
totalAmount: { type: Number, default: 0 }, totalAmount: { type: Number, default: 0 },
paidAmount: { type: Number, default: 0 }, paidAmount: { type: Number, default: 0 },
bookingDate: { type: Date, default: Date.now }, bookingDate: { type: Date, default: Date.now },
confirmationCode: { type: String, unique: true }, confirmationCode: { type: String, unique: true, maxlength: 32 },
adminNotes: { type: String, default: '' } adminNotes: { type: String, default: '', maxlength: 1000 }
} }
] ]
} }

View File

@@ -11,70 +11,70 @@ if (mongoose.connection.models.Booking) {
const bookingSchema = new mongoose.Schema( const bookingSchema = new mongoose.Schema(
{ {
hero: { hero: {
title: String, title: { type: String, trim: true, maxlength: 80 },
backgroundImage: String, backgroundImage: { type: String, trim: true, maxlength: 255 },
}, },
searchBar: { searchBar: {
locationLabel: String, locationLabel: { type: String, trim: true, maxlength: 64 },
holidaySeasonLabel: String, holidaySeasonLabel: { type: String, trim: true, maxlength: 64 },
searchButtonText: String, searchButtonText: { type: String, trim: true, maxlength: 64 },
}, },
filterPanel: { filterPanel: {
title: String, title: { type: String, trim: true, maxlength: 80 },
priceTitle: String, priceTitle: { type: String, trim: true, maxlength: 64 },
priceLabel: String, priceLabel: { type: String, trim: true, maxlength: 64 },
pricePlaceholder: String, pricePlaceholder: { type: String, trim: true, maxlength: 64 },
priceMin: Number, priceMin: Number,
priceMax: Number, priceMax: Number,
activitiesTitle: String, activitiesTitle: { type: String, trim: true, maxlength: 64 },
ageTitle: String, ageTitle: { type: String, trim: true, maxlength: 64 },
ageSelectPlaceholder: String, ageSelectPlaceholder: { type: String, trim: true, maxlength: 64 },
ageMin: Number, ageMin: Number,
ageMax: Number, ageMax: Number,
ratingTitle: String, ratingTitle: { type: String, trim: true, maxlength: 64 },
ratingOptions: [ ratingOptions: [
{ {
value: String, value: { type: String, trim: true, maxlength: 48 },
label: String, label: { type: String, trim: true, maxlength: 64 },
}, },
], ],
resetButtonText: String, resetButtonText: { type: String, trim: true, maxlength: 64 },
}, },
programs: [ programs: [
{ {
value: String, value: { type: String, trim: true, maxlength: 64 },
label: String, label: { type: String, trim: true, maxlength: 64 },
}, },
], ],
holidays: [ holidays: [
{ {
value: String, value: { type: String, trim: true, maxlength: 64 },
label: String, label: { type: String, trim: true, maxlength: 64 },
}, },
], ],
locations: [ locations: [
{ {
value: String, value: { type: String, trim: true, maxlength: 64 },
label: String, label: { type: String, trim: true, maxlength: 64 },
}, },
], ],
camps: [ camps: [
{ {
name: String, name: { type: String, trim: true, maxlength: 120 },
price: Number, price: Number,
priceText: String, priceText: { type: String, trim: true, maxlength: 32 },
season: [String], season: [String],
age: [Number], age: [Number],
locations: [String], locations: [String],
image: String, image: { type: String, trim: true, maxlength: 255 },
link: String, link: { type: String, trim: true, maxlength: 255 },
program: String, program: { type: String, trim: true, maxlength: 80 },
rating: Number, rating: Number,
}, },
], ],

View File

@@ -15,11 +15,13 @@ const breadcrumbItemSchema = new mongoose.Schema(
type: String, type: String,
trim: true, trim: true,
default: "", default: "",
maxlength: 40,
}, },
link: { link: {
type: String, type: String,
trim: true, trim: true,
default: "", default: "",
maxlength: 255,
}, },
}, },
{ _id: false } { _id: false }
@@ -32,16 +34,19 @@ const heroSchema = new mongoose.Schema(
type: String, type: String,
trim: true, trim: true,
default: "Pricing Plan", default: "Pricing Plan",
maxlength: 60,
}, },
backgroundImage: { backgroundImage: {
type: String, type: String,
trim: true, trim: true,
default: "/assets/img/inner-page/breadcrumb.jpg", default: "/assets/img/inner-page/breadcrumb.jpg",
maxlength: 255,
}, },
shapeImage: { shapeImage: {
type: String, type: String,
trim: true, trim: true,
default: "/assets/img/inner-page/shape.png", default: "/assets/img/inner-page/shape.png",
maxlength: 255,
}, },
breadcrumb: { breadcrumb: {
type: [breadcrumbItemSchema], type: [breadcrumbItemSchema],
@@ -58,16 +63,19 @@ const pricingSectionSchema = new mongoose.Schema(
type: String, type: String,
trim: true, trim: true,
default: "pricing plan", default: "pricing plan",
maxlength: 64,
}, },
heading: { heading: {
type: String, type: String,
trim: true, trim: true,
default: "Flexible Plans to Suit Every Traveler", default: "Flexible Plans to Suit Every Traveler",
maxlength: 120,
}, },
description: { description: {
type: String, type: String,
trim: true, trim: true,
default: "", default: "",
maxlength: 500,
}, },
}, },
{ _id: false } { _id: false }
@@ -80,36 +88,43 @@ const planSchema = new mongoose.Schema(
type: String, type: String,
trim: true, trim: true,
required: true, required: true,
maxlength: 64,
}, },
price: { price: {
type: String, type: String,
trim: true, trim: true,
default: "0", default: "0",
maxlength: 32,
}, },
period: { period: {
type: String, type: String,
trim: true, trim: true,
default: "mo", default: "mo",
maxlength: 8,
}, },
currency: { currency: {
type: String, type: String,
trim: true, trim: true,
default: "$", default: "$",
maxlength: 8,
}, },
buttonText: { buttonText: {
type: String, type: String,
trim: true, trim: true,
default: "Get Started Today", default: "Get Started Today",
maxlength: 64,
}, },
buttonLink: { buttonLink: {
type: String, type: String,
trim: true, trim: true,
default: "/pricing", default: "/pricing",
maxlength: 255,
}, },
buttonIcon: { buttonIcon: {
type: String, type: String,
trim: true, trim: true,
default: "fa-solid fa-arrow-right", default: "fa-solid fa-arrow-right",
maxlength: 64,
}, },
style: { style: {
type: String, type: String,
@@ -118,7 +133,7 @@ const planSchema = new mongoose.Schema(
default: "default", default: "default",
}, },
features: { features: {
type: [String], type: [{ type: String, maxlength: 96 }],
default: [], default: [],
}, },
}, },
@@ -147,11 +162,13 @@ const testimonialItemSchema = new mongoose.Schema(
type: String, type: String,
trim: true, trim: true,
default: "", default: "",
maxlength: 64,
}, },
role: { role: {
type: String, type: String,
trim: true, trim: true,
default: "", default: "",
maxlength: 64,
}, },
rating: { rating: {
type: Number, type: Number,
@@ -163,6 +180,7 @@ const testimonialItemSchema = new mongoose.Schema(
type: String, type: String,
trim: true, trim: true,
default: "", default: "",
maxlength: 400,
}, },
}, },
{ _id: false } { _id: false }
@@ -175,31 +193,37 @@ const testimonialsSchema = new mongoose.Schema(
type: String, type: String,
trim: true, trim: true,
default: "What Our Clients Say", default: "What Our Clients Say",
maxlength: 64,
}, },
heading: { heading: {
type: String, type: String,
trim: true, trim: true,
default: "Immigration Success Stories", default: "Immigration Success Stories",
maxlength: 120,
}, },
buttonText: { buttonText: {
type: String, type: String,
trim: true, trim: true,
default: "View All Review", default: "View All Review",
maxlength: 64,
}, },
buttonLink: { buttonLink: {
type: String, type: String,
trim: true, trim: true,
default: "/contact", default: "/contact",
maxlength: 255,
}, },
buttonIcon: { buttonIcon: {
type: String, type: String,
trim: true, trim: true,
default: "fa-solid fa-arrow-right", default: "fa-solid fa-arrow-right",
maxlength: 64,
}, },
image: { image: {
type: String, type: String,
trim: true, trim: true,
default: "", default: "",
maxlength: 255,
}, },
items: { items: {
type: [testimonialItemSchema], type: [testimonialItemSchema],

View File

@@ -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 = `
<div class="admin-upload-guidance__title">${payload.title}</div>
<ul class="admin-upload-guidance__list">
${payload.lines.map((line) => `<li>${line}</li>`).join("")}
</ul>
`;
const insertionTarget = host !== anchor ? host : anchor;
insertionTarget.insertAdjacentElement("afterend", note);
return note;
}
function autoWireGuidance(scope) {
const root = toScope(scope);
root.querySelectorAll(`[${AUTO_GUIDANCE_ATTR}]`).forEach((anchor) => {
const guidanceValue = anchor.getAttribute(AUTO_GUIDANCE_ATTR);
if (guidanceValue === "false") {
return;
}
if (isDragDropField(anchor)) {
return;
}
if (hasManualUploadHint(anchor)) {
return;
}
renderUploadGuidance(anchor, {
for: anchor.id || anchor.dataset.targetInput || "",
title: anchor.dataset.adminUploadGuidanceTitle || "Upload guidance",
lines: anchor.dataset.adminUploadGuidance
? anchor.dataset.adminUploadGuidance.split("|").map((part) => part.trim())
: undefined,
});
});
}
function applyGuidanceRules(scope) {
const root = toScope(scope);
getPathRules(GUIDANCE_RULES).forEach((rule) => {
findTargets(root, rule.selector).forEach((anchor) => {
if (isDragDropField(anchor)) {
return;
}
if (hasManualUploadHint(anchor)) {
return;
}
if (anchor.matches(".btn-upload-image")) {
const targetId = anchor.dataset.targetInput;
const target = resolveUploadTarget(targetId, anchor);
renderUploadGuidance(target || anchor, {
for: targetId || anchor.id || "",
title: rule.title,
lines: rule.lines,
});
return;
}
renderUploadGuidance(anchor, {
for: anchor.id || anchor.name || "",
title: rule.title,
lines: rule.lines,
});
});
});
root.querySelectorAll("input[type='text'], textarea").forEach((input) => {
if (isDragDropField(input)) {
return;
}
const descriptor = buildDescriptor(input);
if (!isUploadDescriptor(descriptor)) {
return;
}
if (hasManualUploadHint(input)) {
return;
}
const host =
input.parentElement?.querySelector(".admin-upload-guidance") ||
input.closest(".col-md-12, .col-md-6, .col-md-4, .col-lg-12, .col-lg-6, .col-lg-4, .col-12")?.querySelector(".admin-upload-guidance");
if (host) {
return;
}
renderUploadGuidance(input, {
for: input.id || input.name || "",
title: "Upload guidance",
lines: [
"Use a clear, high-resolution image sized for the frontend frame.",
"Prefer SVG for logos or icons and at least 2x the displayed size for raster uploads.",
],
});
});
}
function observeMutations() {
if (document.body[OBSERVER_BOUND_KEY]) {
return;
}
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (!node || node.nodeType !== 1) {
return;
}
init(node);
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
document.body[OBSERVER_BOUND_KEY] = true;
}
function init(scope = document) {
const root = toScope(scope);
applyFieldRules(root);
root.querySelectorAll("input[data-maxlength], textarea[data-maxlength], input[data-maxwords], textarea[data-maxwords], input[maxlength], textarea[maxlength]").forEach(bindCounter);
autoWireGuidance(root);
applyGuidanceRules(root);
}
function refresh(scope = document) {
init(scope);
}
function shouldAutoInit() {
return document.body && document.body.dataset.adminHelpers === "true";
}
const api = {
init,
refresh,
renderUploadGuidance,
updateCounter,
};
window.AdminFormHelpers = api;
if (shouldAutoInit()) {
if (document.readyState === "loading") {
window.addEventListener("load", () => {
init(document);
observeMutations();
}, { once: true });
} else {
init(document);
observeMutations();
}
}
})(window, document);

View File

@@ -2,7 +2,7 @@ require("dotenv").config();
const connectDB = require("../config/database"); const connectDB = require("../config/database");
const DEFAULT_FACEBOOK_URL = "https://www.facebook.com/hailearning.edu.vn/"; 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_BRAND_IMAGE = "/assets/img/logo/black-logo.svg";
const DEFAULT_FACEBOOK_ICON = "/uploads/home/floating-contact/Facebook_Logo_Primary.webp"; 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"; const DEFAULT_ZALO_ICON = "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp";
@@ -60,7 +60,7 @@ async function migrate() {
id: "facebook", id: "facebook",
platform: "facebook", platform: "facebook",
enabled: true, enabled: true,
label: "Nhắn tin qua Facebook", label: "Message via Facebook",
subtitle: "facebook.com/hailearning.edu.vn", subtitle: "facebook.com/hailearning.edu.vn",
href: DEFAULT_FACEBOOK_URL, href: DEFAULT_FACEBOOK_URL,
iconImage: DEFAULT_FACEBOOK_ICON, iconImage: DEFAULT_FACEBOOK_ICON,
@@ -73,7 +73,7 @@ async function migrate() {
id: "zalo", id: "zalo",
platform: "zalo", platform: "zalo",
enabled: true, enabled: true,
label: "Nhắn tin qua Zalo", label: "Message via Zalo",
subtitle: `zalo.me/${zaloPhone}`, subtitle: `zalo.me/${zaloPhone}`,
href: zaloUrl, href: zaloUrl,
iconImage: DEFAULT_ZALO_ICON, iconImage: DEFAULT_ZALO_ICON,

158
utils/lengthValidation.js Normal file
View File

@@ -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,
};

View File

@@ -41,7 +41,9 @@
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" name="heroBannerImage" id="heroBannerImage" <input type="text" class="form-control" name="heroBannerImage" id="heroBannerImage"
value="<%= (data.hero && data.hero.bannerImage) || '' %>" value="<%= (data.hero && data.hero.bannerImage) || '' %>"
placeholder="/templates/yootheme/activities/activity-banner.jpg"> 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.">
<button type="button" class="btn btn-outline-secondary" id="uploadHeroBannerBtn"> <button type="button" class="btn btn-outline-secondary" id="uploadHeroBannerBtn">
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
</button> </button>
@@ -235,7 +237,9 @@
<label class="form-label fw-medium">Image Path</label> <label class="form-label fw-medium">Image Path</label>
<input type="text" class="form-control" name="image" id="imageInput" <input type="text" class="form-control" name="image" id="imageInput"
value="<%= data.image || '' %>" value="<%= data.image || '' %>"
placeholder="e.g., yootheme/banner/b14.jpg"> 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.">
<small class="text-muted">Path to the main activity image (used in listings)</small> <small class="text-muted">Path to the main activity image (used in listings)</small>
</div> </div>
</div> </div>
@@ -271,7 +275,9 @@
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" name="campDetailHeroBgImage" id="campDetailHeroBgImage" <input type="text" class="form-control" name="campDetailHeroBgImage" id="campDetailHeroBgImage"
value="<%= (data.campDetail && data.campDetail.hero && data.campDetail.hero.bgImage) || '' %>" value="<%= (data.campDetail && data.campDetail.hero && data.campDetail.hero.bgImage) || '' %>"
placeholder="e.g., yootheme/banner/b1.jpg"> 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.">
<button type="button" class="btn btn-outline-secondary" id="uploadHeroBgBtn"><i class="fas fa-upload me-1"></i>Upload</button> <button type="button" class="btn btn-outline-secondary" id="uploadHeroBgBtn"><i class="fas fa-upload me-1"></i>Upload</button>
</div> </div>
<div class="mt-2" id="campDetailHeroBgPreviewWrapper" style="display: none;"> <div class="mt-2" id="campDetailHeroBgPreviewWrapper" style="display: none;">

View File

@@ -165,9 +165,7 @@
<textarea class="form-control" id="excerpt" name="excerpt" rows="3" <textarea class="form-control" id="excerpt" name="excerpt" rows="3"
required maxlength="500" required maxlength="500"
placeholder="Enter a brief summary of the blog post (max 500 characters)"></textarea> placeholder="Enter a brief summary of the blog post (max 500 characters)"></textarea>
<div class="form-text"> <div class="form-text">Maximum 500 characters.</div>
<span id="excerptCount">0</span>/500 characters
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -470,14 +468,6 @@
console.error('Error initializing contentAfterQuote editor:', error); 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 // Image upload handler
document.querySelectorAll('.btn-upload-image').forEach(button => { document.querySelectorAll('.btn-upload-image').forEach(button => {
button.addEventListener('click', function () { button.addEventListener('click', function () {

View File

@@ -200,11 +200,7 @@
<textarea class="form-control" id="excerpt" name="excerpt" rows="3" <textarea class="form-control" id="excerpt" name="excerpt" rows="3"
required maxlength="500" required maxlength="500"
placeholder="Enter a brief summary of the blog post (max 500 characters)"><%= blog.excerpt || '' %></textarea> placeholder="Enter a brief summary of the blog post (max 500 characters)"><%= blog.excerpt || '' %></textarea>
<div class="form-text"> <div class="form-text">Maximum 500 characters.</div>
<span id="excerptCount">
<%= (blog.excerpt || '' ).length %>
</span>/500 characters
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -888,14 +884,6 @@
console.error('Error initializing contentAfterQuote editor:', error); 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 // Image upload handler
document.querySelectorAll('.btn-upload-image').forEach(button => { document.querySelectorAll('.btn-upload-image').forEach(button => {
button.addEventListener('click', function () { button.addEventListener('click', function () {

View File

@@ -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 { } else {
if (window.toastManager) window.toastManager.success('Upload thành công'); if (window.toastManager) window.toastManager.success('Upload completed successfully');
} }
} catch (err) { } catch (err) {
console.error('Upload error', 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)); err));
} }
}); });
@@ -2015,7 +2015,7 @@
fileInput.click(); fileInput.click();
} catch (err) { } catch (err) {
console.error('openImageUploader error', 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));
} }
} }

View File

@@ -75,7 +75,7 @@
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
</button> </button>
</div> </div>
<small class="text-muted d-block mt-1">The contact hero currently renders at about 1496x544px on desktop. Recommended minimum upload: 1920x700px.</small> <small class="text-muted d-block mt-1">Recommended minimum upload: 1920x700px.</small>
</div> </div>
<div class="col-md-7"> <div class="col-md-7">
<div id="heroImagePreview" style="height: 300px;"> <div id="heroImagePreview" style="height: 300px;">
@@ -181,7 +181,6 @@
name="cardTitle_<%= index %>" name="cardTitle_<%= index %>"
value="<%= card.title || '' %>" value="<%= card.title || '' %>"
maxlength="40" data-maxlength="40"> maxlength="40" data-maxlength="40">
<small class="text-muted d-block mt-1">Recommended maximum: 40 characters.</small>
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label">Icon Type</label> <label class="form-label">Icon Type</label>
@@ -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) { function initContactCharacterCounters(scope = document) {
scope.querySelectorAll('input[data-maxlength], textarea[data-maxlength]').forEach((input) => { scope.querySelectorAll('.contact-limit-counter').forEach((node) => node.remove());
updateContactCounter(input);
if (input.dataset.counterBound === 'true') { if (window.AdminFormHelpers) {
return; window.AdminFormHelpers.refresh(scope);
} }
input.dataset.counterBound = 'true';
input.addEventListener('input', () => updateContactCounter(input));
});
} }
function updateAllJsonInputs(data) { function updateAllJsonInputs(data) {
@@ -1327,7 +1275,6 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Title</label> <label class="form-label">Title</label>
<input type="text" class="form-control" name="cardTitle_${index}" maxlength="40" data-maxlength="40"> <input type="text" class="form-control" name="cardTitle_${index}" maxlength="40" data-maxlength="40">
<small class="text-muted d-block mt-1">Recommended maximum: 40 characters.</small>
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label">Icon Type</label> <label class="form-label">Icon Type</label>

View File

@@ -404,7 +404,7 @@
} }
} catch (error) { } catch (error) {
console.error('=== TRACE: Unified Save ERROR ===', error); console.error('=== TRACE: Unified Save ERROR ===', error);
showNotification('Lỗi: ' + error.message, 'error'); showNotification('Error: ' + error.message, 'error');
} finally { } finally {
submitBtn.innerHTML = originalText; submitBtn.innerHTML = originalText;
submitBtn.disabled = false; submitBtn.disabled = false;
@@ -709,19 +709,19 @@
const icon = document.getElementById('newSocialIcon').value.trim(); const icon = document.getElementById('newSocialIcon').value.trim();
if (!platform) { if (!platform) {
alert('Vui lòng nhập tên nền tảng'); alert('Please enter a platform name');
return; return;
} }
if (!url) { if (!url) {
alert('Vui lòng nhập URL'); alert('Please enter a URL');
return; return;
} }
// Check if platform already exists // Check if platform already exists
const existingPlatforms = Array.from(document.querySelectorAll('.social-platform')).map(el => el.value); const existingPlatforms = Array.from(document.querySelectorAll('.social-platform')).map(el => el.value);
if (existingPlatforms.includes(platform)) { if (existingPlatforms.includes(platform)) {
alert(`${platform} đã tồn tại`); alert(`${platform} already exists`);
return; return;
} }
@@ -780,12 +780,12 @@
const newIcon = document.getElementById('editSocialIcon').value.trim(); const newIcon = document.getElementById('editSocialIcon').value.trim();
if (!newPlatform) { if (!newPlatform) {
alert('Vui lòng nhập tên nền tảng'); alert('Please enter a platform name');
return; return;
} }
if (!newUrl) { if (!newUrl) {
alert('Vui lòng nhập URL'); alert('Please enter a URL');
return; return;
} }

View File

@@ -446,92 +446,11 @@
toast.addEventListener("hidden.bs.toast", () => toast.remove()); 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) { function initHomeCharacterCounters(scope = document) {
scope.querySelectorAll("input[data-maxlength], textarea[data-maxlength], input[data-maxwords], textarea[data-maxwords]").forEach((input) => { scope.querySelectorAll(".home-limit-counter").forEach((node) => node.remove());
updateCharacterHint(input);
if (input.dataset.counterBound === "true") { if (window.AdminFormHelpers) {
return; window.AdminFormHelpers.refresh(scope);
} }
input.dataset.counterBound = "true";
input.addEventListener("input", () => updateCharacterHint(input));
});
} }
</script> </script>

View File

@@ -64,7 +64,7 @@
style="height: 120px; width: 120px; object-fit: contain; background: #fff;" style="height: 120px; width: 120px; object-fit: contain; background: #fff;"
alt="Brand preview" /> alt="Brand preview" />
</div> </div>
<small class="text-muted d-block mt-2">Hiển thị thực tế khoảng 35x35px. Raster uploads are normalized to 104x104 WebP to match the homepage widget.</small> <small class="text-muted d-block mt-2">Displayed at roughly 35x35px. Raster uploads are normalized to 104x104 WebP to match the homepage widget.</small>
</div> </div>
<div class="col-lg-6"> <div class="col-lg-6">
<label class="form-label fw-medium">Fallback Trigger Image</label> <label class="form-label fw-medium">Fallback Trigger Image</label>
@@ -95,7 +95,7 @@
</div> </div>
<input type="hidden" id="floatingContactTriggerIconFallback" <input type="hidden" id="floatingContactTriggerIconFallback"
value="<%= data.floatingContact?.trigger?.icon || 'fa-comments' %>" /> value="<%= data.floatingContact?.trigger?.icon || 'fa-comments' %>" />
<small class="text-muted d-block mt-2">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.</small> <small class="text-muted d-block mt-2">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.</small>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -9,18 +9,11 @@
</h6> </h6>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="alert alert-light border mb-3">
<div class="fw-semibold mb-1">Current homepage hero behavior</div>
<div class="small text-muted">
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.
</div>
</div>
<div class="row g-3 align-items-start"> <div class="row g-3 align-items-start">
<div class="col-lg-6"> <div class="col-lg-6">
<label class="form-label fw-medium">Fallback Background Image</label> <label class="form-label fw-medium">Fallback Background Image</label>
<small class="text-muted d-block mb-1"> <small class="text-muted d-block mb-1">
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.
</small> </small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="heroBackgroundImage" <input type="text" class="form-control" id="heroBackgroundImage"
@@ -42,10 +35,10 @@
<div class="border rounded p-3 h-100 bg-light-subtle"> <div class="border rounded p-3 h-100 bg-light-subtle">
<div class="fw-semibold mb-2">Recommended content structure</div> <div class="fw-semibold mb-2">Recommended content structure</div>
<ul class="small text-muted mb-0 ps-3"> <ul class="small text-muted mb-0 ps-3">
<li>Dùng ảnh slide tỉ lệ ngang, ưu tiên ảnh lớn để fit container.</li> <li>Use landscape slide images; prefer large images to fit the container.</li>
<li>Title ngắn 2-4 dòng để không tràn trên mobile.</li> <li>Keep titles to 2-4 lines to avoid overflow on mobile.</li>
<li>Description giữ ở mức 1-3 câu ngắn.</li> <li>Limit descriptions to 1-3 short sentences.</li>
<li>Hai nút nên dùng link nội bộ như <code>/contact</code>.</li> <li>Both buttons should use internal links like <code>/contact</code>.</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -101,7 +94,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Subtitle</label> <label class="form-label fw-medium">Subtitle</label>
<small class="text-muted d-block mb-1">Hiện không render ngoài frontend, chỉ giữ để tương thích dữ liệu cũ.</small> <small class="text-muted d-block mb-1">Currently not rendered on the frontend; kept for backward compatibility with existing data.</small>
<input type="text" class="form-control" id="heroSlide_<%= index %>_subtitle" <input type="text" class="form-control" id="heroSlide_<%= index %>_subtitle"
value="<%= slide.subtitle || '' %>" placeholder="e.g., Global Education Simplified" value="<%= slide.subtitle || '' %>" placeholder="e.g., Global Education Simplified"
maxlength="48" data-maxlength="48" /> maxlength="48" data-maxlength="48" />
@@ -113,7 +106,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Slide Background Image</label> <label class="form-label fw-medium">Slide Background Image</label>
<small class="text-muted d-block mb-1">Ả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.</small> <small class="text-muted d-block mb-1">Recommended upload size is 1920x700px or larger.</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="heroSlide_<%= index %>_heroImage" <input type="text" class="form-control" id="heroSlide_<%= index %>_heroImage"
value="<%= slide.heroImage || '' %>" placeholder="/uploads/home/hero-slide-01.jpg" value="<%= slide.heroImage || '' %>" placeholder="/uploads/home/hero-slide-01.jpg"
@@ -132,9 +125,9 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Video URL</label> <label class="form-label fw-medium">Video URL</label>
<small class="text-muted d-block mb-1">Frontend hiện không render video trong hero. Giữ lại chỉ để tránh mất dữ liệu cũ.</small> <small class="text-muted d-block mb-1">The frontend currently does not render video in the hero. Kept only to preserve existing data.</small>
<input type="text" class="form-control" id="heroSlide_<%= index %>_videoUrl" <input type="text" class="form-control" id="heroSlide_<%= index %>_videoUrl"
value="<%= slide.videoUrl || '' %>" placeholder="Không bắt buộc" value="<%= slide.videoUrl || '' %>" placeholder="Optional"
maxlength="255" data-maxlength="255" /> maxlength="255" data-maxlength="255" />
</div> </div>

View File

@@ -10,7 +10,7 @@
</h6> </h6>
</div> </div>
<div class="card-body"> <div class="card-body">
<small class="text-muted d-block mb-3">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.</small> <small class="text-muted d-block mb-3">Award icons on the homepage display at roughly 124x124px. Recommended uploads are square images or transparent logos at least 248x248px.</small>
<div id="visaConsultancyContainer"> <div id="visaConsultancyContainer">
<% for(let i=0; i<4; i++) { <% for(let i=0; i<4; i++) {
const item = (data.partners?.visaConsultancy?.items && data.partners.visaConsultancy.items[i]) || {}; const item = (data.partners?.visaConsultancy?.items && data.partners.visaConsultancy.items[i]) || {};
@@ -31,7 +31,7 @@
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Icon / Logo</label> <label class="form-label fw-medium">Icon / Logo</label>
<small class="text-muted d-block mb-1">Khuyến nghị ảnh vuông hoặc logo nền trong suốt 248x248px để hiển thị sắc nét.</small> <small class="text-muted d-block mb-1">Recommended: a 248x248px square image or transparent logo for crisp rendering.</small>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control visa-icon" id="visaIcon_<%= i %>" value="<%= item.icon || '' %>" maxlength="255" data-maxlength="255" /> <input type="text" class="form-control visa-icon" id="visaIcon_<%= i %>" value="<%= item.icon || '' %>" maxlength="255" data-maxlength="255" />
<button type="button" class="btn btn-outline-primary btn-upload-image" <button type="button" class="btn btn-outline-primary btn-upload-image"
@@ -64,7 +64,7 @@
</button> </button>
</div> </div>
<div class="card-body"> <div class="card-body">
<small class="text-muted d-block mb-3">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.</small> <small class="text-muted d-block mb-3">Brand logos in the slider display at roughly 159x48px. Recommended assets are SVGs or horizontal transparent logos at least 320x96px.</small>
<div id="brandPartnersContainer" class="row g-3"> <div id="brandPartnersContainer" class="row g-3">
<% (data.partners?.brands?.items || []).forEach(function(item, index) { %> <% (data.partners?.brands?.items || []).forEach(function(item, index) { %>
<div class="col-md-4 brand-partner-item"> <div class="col-md-4 brand-partner-item">
@@ -83,7 +83,7 @@
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
</button> </button>
</div> </div>
<small class="text-muted d-block mt-2">Khuyến nghị SVG hoặc ảnh ngang 320x96px để logo không bị mờ trong slider.</small> <small class="text-muted d-block mt-2">Recommended: SVG or a 320x96px horizontal image so the logo stays sharp in the slider.</small>
<div class="mt-2 text-center preview-container"> <div class="mt-2 text-center preview-container">
<img src="<%= item.logo || '' %>" class="img-thumbnail <%= item.logo ? '' : 'd-none' %>" style="height: 50px; object-fit: contain;"> <img src="<%= item.logo || '' %>" class="img-thumbnail <%= item.logo ? '' : 'd-none' %>" style="height: 50px; object-fit: contain;">
</div> </div>
@@ -143,7 +143,7 @@
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
</button> </button>
</div> </div>
<small class="text-muted d-block mt-2">Khuyến nghị SVG hoặc ảnh ngang 320x96px để logo không bị mờ trong slider.</small> <small class="text-muted d-block mt-2">Recommended: SVG or a 320x96px horizontal image so the logo stays sharp in the slider.</small>
<div class="mt-2 text-center preview-container"> <div class="mt-2 text-center preview-container">
<img src="" class="img-thumbnail d-none" style="height: 50px; object-fit: contain;"> <img src="" class="img-thumbnail d-none" style="height: 50px; object-fit: contain;">
</div> </div>

View File

@@ -31,7 +31,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Video Thumbnail</label> <label class="form-label fw-medium">Video Thumbnail</label>
<small class="text-muted d-block mb-1">Khung thumbnail desktop hiện khoảng 416x370px. Khuyến nghị upload tối thiểu 832x740px.</small> <small class="text-muted d-block mb-1">The desktop thumbnail frame is approximately 416x370px. Recommended upload size is at least 832x740px.</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="testimonialsVideoThumbnail" <input type="text" class="form-control" id="testimonialsVideoThumbnail"
value="<%= data.testimonials?.videoThumbnail || '' %>" maxlength="255" data-maxlength="255" /> value="<%= data.testimonials?.videoThumbnail || '' %>" maxlength="255" data-maxlength="255" />
@@ -100,7 +100,7 @@
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Avatar</label> <label class="form-label fw-medium">Avatar</label>
<small class="text-muted d-block mb-1">Avatar hiển thị ở khoảng 48x48px. Khuyến nghị ảnh vuông 96x96px hoặc 128x128px.</small> <small class="text-muted d-block mb-1">Avatars display at roughly 48x48px. Recommended square image sizes are 96x96px or 128x128px.</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="testimonialsAvatar_<%= index %>" <input type="text" class="form-control" id="testimonialsAvatar_<%= index %>"
value="<%= item.avatar || '' %>" maxlength="255" data-maxlength="255" /> value="<%= item.avatar || '' %>" maxlength="255" data-maxlength="255" />

View File

@@ -63,7 +63,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Flag / Illustration Image</label> <label class="form-label fw-medium">Flag / Illustration Image</label>
<small class="text-muted d-block mb-1">Khung ảnh desktop hiện khoảng 840x830px. Khuyến nghị ảnh gần vuông, tối thiểu 1000x1000px.</small> <small class="text-muted d-block mb-1">The desktop image frame is approximately 840x830px. Recommended assets are near-square images at least 1000x1000px.</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="visaCountriesFlag_0" <input type="text" class="form-control" id="visaCountriesFlag_0"
value="<%= featured.flag || '' %>" placeholder="/assets/img/home-1/feature/shape.png" value="<%= featured.flag || '' %>" placeholder="/assets/img/home-1/feature/shape.png"

View File

@@ -53,7 +53,7 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Main Image</label> <label class="form-label fw-medium">Main Image</label>
<small class="text-muted d-block mb-1">Khung desktop hiện khoảng 318x347px. Khuyến nghị upload ít nhất 750x820px, tỉ lệ dọc khoảng 0.91:1.</small> <small class="text-muted d-block mb-1">The desktop frame is approximately 318x347px. Recommended upload size is at least 750x820px, with a portrait ratio near 0.91:1.</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="whyChooseUsMainImage" <input type="text" class="form-control" id="whyChooseUsMainImage"
value="<%= data.whyChooseUs?.mainImage || '' %>" placeholder="/assets/img/home-1/about/about-1.jpg" value="<%= data.whyChooseUs?.mainImage || '' %>" placeholder="/assets/img/home-1/about/about-1.jpg"
@@ -73,7 +73,7 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Secondary Image</label> <label class="form-label fw-medium">Secondary Image</label>
<small class="text-muted d-block mb-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.</small> <small class="text-muted d-block mb-1">The desktop frame is approximately 363x380px. Recommended upload size is at least 760x800px, with a portrait ratio near 0.95:1.</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="whyChooseUsSecondaryImage" <input type="text" class="form-control" id="whyChooseUsSecondaryImage"
value="<%= data.whyChooseUs?.secondaryImage || '' %>" value="<%= data.whyChooseUs?.secondaryImage || '' %>"
@@ -113,7 +113,7 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Icon URL</label> <label class="form-label fw-medium">Icon URL</label>
<small class="text-muted d-block mb-1">Icon hiển thị ở khoảng 24x24px. Khuyến nghị dùng SVG hoặc ảnh vuông 48x48px.</small> <small class="text-muted d-block mb-1">Icons display at roughly 24x24px. Recommended assets are SVGs or square images at 48x48px.</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="whyChooseUsIcon_<%= index %>" <input type="text" class="form-control" id="whyChooseUsIcon_<%= index %>"
value="<%= item.icon || '' %>" maxlength="255" data-maxlength="255" /> value="<%= item.icon || '' %>" maxlength="255" data-maxlength="255" />

View File

@@ -529,7 +529,7 @@ document.addEventListener('DOMContentLoaded', function() {
img.alt = 'Image preview'; img.alt = 'Image preview';
if (window.toastManager) { if (window.toastManager) {
window.toastManager.success('Tải ảnh thành công'); window.toastManager.success('Image uploaded successfully');
} }
} }
} else { } else {

View File

@@ -107,7 +107,7 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Country Icon (Flag)</label> <label class="form-label fw-medium">Country Icon (Flag)</label>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" name="icon" id="icon_input" class="form-control" placeholder="/uploads/visa/flag.png" required /> <input type="text" name="icon" id="icon_input" class="form-control" placeholder="/uploads/visa/flag.png" maxlength="255" data-maxlength="255" data-admin-upload-guidance="Displayed as a small country flag or icon.|Prefer SVG; otherwise use a square image at 96x96px or larger." required />
<button type="button" class="btn btn-outline-secondary" onclick="document.getElementById('fileFlagInput').click()"> <button type="button" class="btn btn-outline-secondary" onclick="document.getElementById('fileFlagInput').click()">
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
</button> </button>
@@ -146,7 +146,7 @@
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Main Image</label> <label class="form-label fw-medium">Main Image</label>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" id="mainImage_detail" name="mainImage" class="form-control" placeholder="/uploads/visa/details-1.jpg" required /> <input type="text" id="mainImage_detail" name="mainImage" class="form-control" placeholder="/uploads/visa/details-1.jpg" maxlength="255" data-maxlength="255" data-admin-upload-guidance="Used in visa detail content blocks.|Recommended upload: at least 1000x750px for primary imagery." required />
<button type="button" class="btn btn-outline-secondary" onclick="document.getElementById('fileDetailMainInput').click()"> <button type="button" class="btn btn-outline-secondary" onclick="document.getElementById('fileDetailMainInput').click()">
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
</button> </button>
@@ -236,7 +236,7 @@
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Gallery Image <%= i %></label> <label class="form-label fw-medium">Gallery Image <%= i %></label>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="bannerImageGallery<%= i %>" name="bannerImageGallery" placeholder="https://example.com/image.jpg" required /> <input type="text" class="form-control" id="bannerImageGallery<%= i %>" name="bannerImageGallery" placeholder="https://example.com/image.jpg" maxlength="255" data-maxlength="255" data-admin-upload-guidance="Gallery image used in visa detail content.|Recommended upload: at least 1000x750px." required />
<button type="button" class="btn btn-outline-secondary" onclick="document.getElementById('fileGalleryInput<%= i %>').click()"> <button type="button" class="btn btn-outline-secondary" onclick="document.getElementById('fileGalleryInput<%= i %>').click()">
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
</button> </button>
@@ -330,7 +330,7 @@
<div class="col-md-5"> <div class="col-md-5">
<label class="form-label fw-medium small">Icon</label> <label class="form-label fw-medium small">Icon</label>
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="text" name="related_icon[]" id="related_url_<%= i %>" class="form-control form-control-sm" placeholder="/uploads/visa/icon.png" required /> <input type="text" name="related_icon[]" id="related_url_<%= i %>" class="form-control form-control-sm" placeholder="/uploads/visa/icon.png" maxlength="255" data-maxlength="255" data-admin-upload-guidance="Supporting service image.|Recommended upload: at least 800x600px." required />
<input type="file" id="related_file_<%= i %>" class="d-none" accept="image/*"> <input type="file" id="related_file_<%= i %>" class="d-none" accept="image/*">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('related_file_<%= i %>').click()"> <button type="button" class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('related_file_<%= i %>').click()">
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
@@ -365,7 +365,7 @@
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Image Contact</label> <label class="form-label fw-medium">Image Contact</label>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" id="contact_image_input" name="contact_image" class="form-control" placeholder="/uploads/visa/contact-image.jpg" required /> <input type="text" id="contact_image_input" name="contact_image" class="form-control" placeholder="/uploads/visa/contact-image.jpg" maxlength="255" data-maxlength="255" data-admin-upload-guidance="Contact-side supporting image.|Recommended upload: at least 800x600px." required />
<button type="button" class="btn btn-outline-secondary" onclick="document.getElementById('fileContactImageInput').click()"> <button type="button" class="btn btn-outline-secondary" onclick="document.getElementById('fileContactImageInput').click()">
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
</button> </button>
@@ -605,10 +605,68 @@
document.getElementById("viewContainer").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() { function showFormView() {
document.getElementById("tableContainer").style.display = "none"; document.getElementById("tableContainer").style.display = "none";
document.getElementById("formContainer").style.display = "block"; document.getElementById("formContainer").style.display = "block";
document.getElementById("viewContainer").style.display = "none"; document.getElementById("viewContainer").style.display = "none";
refreshVisaAdminHelpers();
} }
function showViewView() { function showViewView() {
@@ -797,6 +855,7 @@
} }
showFormView(); showFormView();
refreshVisaAdminHelpers();
} catch (error) { } catch (error) {
console.error("Error:", error); console.error("Error:", error);
@@ -894,6 +953,7 @@
showFormView(); showFormView();
refreshVisaAdminHelpers();
}); });
document.getElementById("btnBackToList").addEventListener("click", showListView); document.getElementById("btnBackToList").addEventListener("click", showListView);

View File

@@ -12,6 +12,8 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<!-- Font Awesome --> <!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
<!-- Form helpers and shared admin UI styles -->
<link rel="stylesheet" href="/assets/css/components/form.css" />
<style> <style>
:root { :root {
--primary-color: #b8b76a; --primary-color: #b8b76a;
@@ -42,7 +44,7 @@
<%- style %> <%- style %>
</head> </head>
<body> <body data-admin-helpers="true">
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<nav class="col-md-2 d-none d-md-block bg-light sidebar"> <nav class="col-md-2 d-none d-md-block bg-light sidebar">
@@ -117,6 +119,7 @@
<!-- Bootstrap JS --> <!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/js/admin-form-helpers.js"></script>
<script> <script>
// Global function to clean up any stuck modal backdrops // Global function to clean up any stuck modal backdrops
function forceCleanupModals() { function forceCleanupModals() {

View File

@@ -676,7 +676,7 @@
<%- style %> <%- style %>
</head> </head>
<body> <body data-admin-helpers="true">
<!-- Navbar --> <!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-light sticky-top"> <nav class="navbar navbar-expand-lg navbar-light sticky-top">
<div class="container"> <div class="container">
@@ -821,6 +821,9 @@
<!-- Custom modal enhancement --> <!-- Custom modal enhancement -->
<script src="/js/custom-modal.js"></script> <script src="/js/custom-modal.js"></script>
<!-- Shared admin form helpers -->
<script src="/js/admin-form-helpers.js"></script>
<script> <script>
// Load Level Types cho dropdown menu Academics với submenu programmes // Load Level Types cho dropdown menu Academics với submenu programmes
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {