forked from UKSOURCE/cms.hailearning.edu.vn
fea/nhat-dat-11042026-merge #1
@@ -37,6 +37,47 @@
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
.admin-field-counter {
|
||||
display: block;
|
||||
margin-top: var(--spacing-1);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-field-counter.is-danger {
|
||||
color: #b42318;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.admin-upload-guidance {
|
||||
margin-top: var(--spacing-2);
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid rgba(184, 183, 106, 0.22);
|
||||
border-radius: var(--border-radius);
|
||||
background: linear-gradient(180deg, rgba(184, 183, 106, 0.08), rgba(184, 183, 106, 0.03));
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.admin-upload-guidance__title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.admin-upload-guidance__list {
|
||||
margin: 0;
|
||||
padding-left: 1.15rem;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.admin-upload-guidance__list li + li {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Validation Styles */
|
||||
.invalid-feedback {
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
191
constants/contentLengthRules.js
Normal file
191
constants/contentLengthRules.js
Normal 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,
|
||||
};
|
||||
@@ -2,10 +2,33 @@ const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const AboutUs = require("../models/aboutUs");
|
||||
const Blog = require("../models/blog");
|
||||
const jsonHelper = require("../utils/jsonHelper");
|
||||
const {
|
||||
validateLengthRules,
|
||||
summarizeLengthErrors,
|
||||
} = require("../utils/lengthValidation");
|
||||
const {
|
||||
ABOUT_US_LENGTH_RULES,
|
||||
} = require("../constants/contentLengthRules");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
|
||||
const handleLengthValidation = (validation, req, res, options = {}) => {
|
||||
const message =
|
||||
summarizeLengthErrors(validation, 3) || "One or more fields exceed the allowed length.";
|
||||
|
||||
if (options.json) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: message,
|
||||
errors: validation.errors,
|
||||
});
|
||||
}
|
||||
|
||||
req.flash("error_msg", message);
|
||||
return res.redirect(options.redirectTo || "/admin/about-us");
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/about
|
||||
* Lấy dữ liệu About Us (Public API cho website và CMS load dữ liệu)
|
||||
@@ -110,6 +133,11 @@ exports.updateAbout = async (req, res) => {
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||
|
||||
const validation = validateLengthRules(updateData, ABOUT_US_LENGTH_RULES);
|
||||
if (!validation.valid) {
|
||||
return handleLengthValidation(validation, req, res, { json: true });
|
||||
}
|
||||
|
||||
// Use .set() for better handling of nested objects/arrays in Mongoose
|
||||
doc.set(updateData);
|
||||
await doc.save();
|
||||
@@ -210,6 +238,13 @@ exports.update = async (req, res) => {
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||
|
||||
const validation = validateLengthRules(updateData, ABOUT_US_LENGTH_RULES);
|
||||
if (!validation.valid) {
|
||||
return handleLengthValidation(validation, req, res, {
|
||||
redirectTo: `/admin/about-us?activeTab=${req.query.activeTab || "hero"}`,
|
||||
});
|
||||
}
|
||||
|
||||
doc.set(updateData);
|
||||
await doc.save();
|
||||
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
const {addBaseUrlToImages} = require("../utils/imageHelper");
|
||||
const Activity = require("../models/activity");
|
||||
const mongoose = require('mongoose');
|
||||
const {
|
||||
validateLengthRules,
|
||||
summarizeLengthErrors,
|
||||
} = require("../utils/lengthValidation");
|
||||
const {
|
||||
ACTIVITY_LENGTH_RULES,
|
||||
} = require("../constants/contentLengthRules");
|
||||
|
||||
const getActivityLengthMessage = (validation) =>
|
||||
summarizeLengthErrors(validation, 3) ||
|
||||
"One or more fields exceed the allowed length.";
|
||||
|
||||
const redirectWithLengthError = (req, res, path, validation) => {
|
||||
req.flash("error_msg", getActivityLengthMessage(validation));
|
||||
return req.session.save(() => res.redirect(path));
|
||||
};
|
||||
|
||||
// -------------------- Public (API) exports --------------------
|
||||
|
||||
@@ -302,6 +318,15 @@ exports.updateFilters = async (req, res) => {
|
||||
try {
|
||||
// Provide minimal valid fields when inserting a new filters document so
|
||||
// schema validators (e.g., age validator) do not fail on upsert.
|
||||
const filterLengthValidation = validateLengthRules(
|
||||
{ filters: sanitizedFilters },
|
||||
ACTIVITY_LENGTH_RULES,
|
||||
);
|
||||
if (!filterLengthValidation.valid) {
|
||||
req.flash("error_msg", getActivityLengthMessage(filterLengthValidation));
|
||||
return res.redirect("/admin/activity");
|
||||
}
|
||||
|
||||
const setOnInsert = {
|
||||
name: "_filters_doc",
|
||||
price: 0,
|
||||
@@ -353,6 +378,14 @@ exports.updateHero = async (req, res) => {
|
||||
bannerImageBooking: bannerImageBooking || '/uploads/banner/b9.jpg',
|
||||
};
|
||||
|
||||
const heroLengthValidation = validateLengthRules(
|
||||
{ hero },
|
||||
ACTIVITY_LENGTH_RULES,
|
||||
);
|
||||
if (!heroLengthValidation.valid) {
|
||||
return redirectWithLengthError(req, res, "/admin/activity", heroLengthValidation);
|
||||
}
|
||||
|
||||
// Update all activity docs to keep hero consistent
|
||||
await Activity.updateMany({ isFiltersDoc: { $ne: true } }, { $set: { hero } });
|
||||
|
||||
@@ -413,6 +446,16 @@ exports.create = async (req, res) => {
|
||||
try {
|
||||
const activityData = parseActivityFormData(req.body);
|
||||
|
||||
const lengthValidation = validateLengthRules(activityData, ACTIVITY_LENGTH_RULES);
|
||||
if (!lengthValidation.valid) {
|
||||
return redirectWithLengthError(
|
||||
req,
|
||||
res,
|
||||
"/admin/activity/create",
|
||||
lengthValidation,
|
||||
);
|
||||
}
|
||||
|
||||
const newActivity = new Activity(activityData);
|
||||
await newActivity.save();
|
||||
|
||||
@@ -465,6 +508,16 @@ exports.update = async (req, res) => {
|
||||
// Force status to active on update (always set isActive true when editing)
|
||||
activityData.isActive = true;
|
||||
|
||||
const lengthValidation = validateLengthRules(activityData, ACTIVITY_LENGTH_RULES);
|
||||
if (!lengthValidation.valid) {
|
||||
return redirectWithLengthError(
|
||||
req,
|
||||
res,
|
||||
`/admin/activity/${req.params.id}/edit`,
|
||||
lengthValidation,
|
||||
);
|
||||
}
|
||||
|
||||
await Activity.findByIdAndUpdate(req.params.id, activityData, {new: true});
|
||||
|
||||
req.flash("success_msg", "Activity updated successfully");
|
||||
|
||||
@@ -2,6 +2,17 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const Booking = require("../models/booking");
|
||||
const {
|
||||
validateLengthRules,
|
||||
summarizeLengthErrors,
|
||||
} = require("../utils/lengthValidation");
|
||||
const {
|
||||
BOOKING_LENGTH_RULES,
|
||||
} = require("../constants/contentLengthRules");
|
||||
|
||||
const getBookingLengthMessage = (validation) =>
|
||||
summarizeLengthErrors(validation, 3) ||
|
||||
"One or more fields exceed the allowed length.";
|
||||
|
||||
// -------------------- Public helpers --------------------
|
||||
const getBookingData = async () => {
|
||||
@@ -398,6 +409,12 @@ exports.update = async (req, res) => {
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
|
||||
const lengthValidation = validateLengthRules(updateData, BOOKING_LENGTH_RULES);
|
||||
if (!lengthValidation.valid) {
|
||||
req.flash("error_msg", getBookingLengthMessage(lengthValidation));
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
|
||||
// Validate data structure
|
||||
const validation = validateBookingData(updateData);
|
||||
if (!validation.isValid) {
|
||||
|
||||
@@ -7,13 +7,13 @@ const formController = {
|
||||
try {
|
||||
res.render('admin/form/index', {
|
||||
layout: 'layouts/admin',
|
||||
title: 'Quản lý Form',
|
||||
title: 'Form Management',
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading form management page:', error);
|
||||
res.status(500).render('error', {
|
||||
message: 'Lỗi khi tải trang quản lý form',
|
||||
message: 'Failed to load the form management page',
|
||||
error: error
|
||||
});
|
||||
}
|
||||
@@ -29,13 +29,13 @@ const formController = {
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Cập nhật form thành công'
|
||||
message: 'Form settings updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating form:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Lỗi khi cập nhật form'
|
||||
message: 'Failed to update form settings'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ const normalizeStoredImagePath = (imagePath) => {
|
||||
const getDefaultFloatingContactData = () => ({
|
||||
enabled: true,
|
||||
position: "bottom-right",
|
||||
panelTitle: "Anh chị cần tư vấn hay hỗ trợ gì thêm không ạ?",
|
||||
panelTitle: "Do you need any additional advice or support?",
|
||||
brand: {
|
||||
imageSrc: "/assets/img/logo/black-logo.svg",
|
||||
imageAlt: "HAI Learning",
|
||||
@@ -132,7 +132,7 @@ const getDefaultFloatingContactData = () => ({
|
||||
id: "facebook",
|
||||
platform: "facebook",
|
||||
enabled: true,
|
||||
label: "Nhắn tin qua Facebook",
|
||||
label: "Message via Facebook",
|
||||
subtitle: "facebook.com/hailearning.edu.vn",
|
||||
href: "https://www.facebook.com/hailearning.edu.vn/",
|
||||
iconImage: "/uploads/home/floating-contact/Facebook_Logo_Primary.webp",
|
||||
@@ -145,7 +145,7 @@ const getDefaultFloatingContactData = () => ({
|
||||
id: "zalo",
|
||||
platform: "zalo",
|
||||
enabled: true,
|
||||
label: "Nhắn tin qua Zalo",
|
||||
label: "Message via Zalo",
|
||||
subtitle: "zalo.me/84961834040",
|
||||
href: "https://zalo.me/84961834040",
|
||||
iconImage: "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp",
|
||||
|
||||
@@ -8,7 +8,7 @@ exports.getAllPages = async (req, res) => {
|
||||
const pages = content.pages || [];
|
||||
|
||||
res.render('admin/pages/index', {
|
||||
title: 'Quản lý trang',
|
||||
title: 'Page Management',
|
||||
pages
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -21,7 +21,7 @@ exports.getAllPages = async (req, res) => {
|
||||
// Hiển thị form tạo trang mới
|
||||
exports.getAddPage = (req, res) => {
|
||||
res.render('admin/pages/add', {
|
||||
title: 'Thêm trang mới'
|
||||
title: 'Add New Page'
|
||||
});
|
||||
};
|
||||
|
||||
@@ -95,7 +95,7 @@ exports.getEditPage = async (req, res) => {
|
||||
}
|
||||
|
||||
res.render('admin/pages/edit', {
|
||||
title: 'Chỉnh sửa trang',
|
||||
title: 'Edit Page',
|
||||
page
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
const Pricing = require("../models/pricing");
|
||||
const {
|
||||
validateLengthRules,
|
||||
summarizeLengthErrors,
|
||||
} = require("../utils/lengthValidation");
|
||||
const {
|
||||
PRICING_LENGTH_RULES,
|
||||
} = require("../constants/contentLengthRules");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
|
||||
const getLengthValidationMessage = (validation) =>
|
||||
summarizeLengthErrors(validation, 3) ||
|
||||
"One or more fields exceed the allowed length.";
|
||||
|
||||
// ==================== CMS ADMIN FUNCTIONS ====================
|
||||
|
||||
// Render admin page for pricing management
|
||||
@@ -86,6 +97,20 @@ exports.update = async (req, res) => {
|
||||
? JSON.parse(testimonials)
|
||||
: testimonials;
|
||||
|
||||
const validation = validateLengthRules(
|
||||
{
|
||||
hero: heroData,
|
||||
pricingSection: pricingSectionData,
|
||||
plans: plansData,
|
||||
testimonials: testimonialsData,
|
||||
},
|
||||
PRICING_LENGTH_RULES,
|
||||
);
|
||||
if (!validation.valid) {
|
||||
req.flash("error", getLengthValidationMessage(validation));
|
||||
return res.redirect("/admin/pricing");
|
||||
}
|
||||
|
||||
let pricing = await Pricing.findOne({ name: "default" });
|
||||
|
||||
// ✅ Capture BEFORE state
|
||||
|
||||
@@ -7,11 +7,11 @@ exports.getSettings = async (req, res) => {
|
||||
const content = readJsonFile('content');
|
||||
const settings = content.settings || {
|
||||
siteName: 'CMS-SIMS',
|
||||
description: 'Hệ thống quản lý nội dung đơn giản'
|
||||
description: 'Simple content management system'
|
||||
};
|
||||
|
||||
res.render('admin/settings', {
|
||||
title: 'Cài đặt hệ thống',
|
||||
title: 'System Settings',
|
||||
settings
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,87 +3,87 @@ const mongoose = require("mongoose");
|
||||
const aboutUsSchema = new mongoose.Schema(
|
||||
{
|
||||
hero: {
|
||||
title: String,
|
||||
breadcrumb: [String],
|
||||
backgroundImage: String,
|
||||
title: { type: String, trim: true, maxlength: 80 },
|
||||
breadcrumb: [{ type: String, trim: true, maxlength: 80 }],
|
||||
backgroundImage: { type: String, trim: true, maxlength: 255 },
|
||||
},
|
||||
intro: {
|
||||
subheading: String,
|
||||
heading: String,
|
||||
description: String,
|
||||
image: String,
|
||||
subheading: { type: String, trim: true, maxlength: 80 },
|
||||
heading: { type: String, trim: true, maxlength: 120 },
|
||||
description: { type: String, trim: true, maxlength: 1000 },
|
||||
image: { type: String, trim: true, maxlength: 255 },
|
||||
},
|
||||
mission: {
|
||||
subheading: String,
|
||||
heading: String,
|
||||
description: String,
|
||||
subheading: { type: String, trim: true, maxlength: 80 },
|
||||
heading: { type: String, trim: true, maxlength: 120 },
|
||||
description: { type: String, trim: true, maxlength: 1000 },
|
||||
images: {
|
||||
main: String,
|
||||
secondary: String,
|
||||
bgShape: String,
|
||||
planeShape: String,
|
||||
topShape: String,
|
||||
globeShape: String,
|
||||
main: { type: String, trim: true, maxlength: 255 },
|
||||
secondary: { type: String, trim: true, maxlength: 255 },
|
||||
bgShape: { type: String, trim: true, maxlength: 255 },
|
||||
planeShape: { type: String, trim: true, maxlength: 255 },
|
||||
topShape: { type: String, trim: true, maxlength: 255 },
|
||||
globeShape: { type: String, trim: true, maxlength: 255 },
|
||||
},
|
||||
items: [
|
||||
new mongoose.Schema(
|
||||
{
|
||||
icon: String,
|
||||
label: String,
|
||||
description: String,
|
||||
icon: { type: String, trim: true, maxlength: 255 },
|
||||
label: { type: String, trim: true, maxlength: 80 },
|
||||
description: { type: String, trim: true, maxlength: 240 },
|
||||
},
|
||||
{ _id: false },
|
||||
),
|
||||
],
|
||||
features: [String],
|
||||
features: [{ type: String, trim: true, maxlength: 80 }],
|
||||
ctaButton: {
|
||||
label: String,
|
||||
href: String,
|
||||
label: { type: String, trim: true, maxlength: 64 },
|
||||
href: { type: String, trim: true, maxlength: 255 },
|
||||
},
|
||||
},
|
||||
features: {
|
||||
backgroundImage: String,
|
||||
subheading: String,
|
||||
heading: String,
|
||||
description: String,
|
||||
image: String,
|
||||
backgroundImage: { type: String, trim: true, maxlength: 255 },
|
||||
subheading: { type: String, trim: true, maxlength: 80 },
|
||||
heading: { type: String, trim: true, maxlength: 120 },
|
||||
description: { type: String, trim: true, maxlength: 1000 },
|
||||
image: { type: String, trim: true, maxlength: 255 },
|
||||
items: [
|
||||
new mongoose.Schema(
|
||||
{
|
||||
icon: String,
|
||||
title: String,
|
||||
description: String,
|
||||
icon: { type: String, trim: true, maxlength: 255 },
|
||||
title: { type: String, trim: true, maxlength: 80 },
|
||||
description: { type: String, trim: true, maxlength: 240 },
|
||||
},
|
||||
{ _id: false },
|
||||
),
|
||||
],
|
||||
ctaButton: {
|
||||
label: String,
|
||||
href: String,
|
||||
label: { type: String, trim: true, maxlength: 64 },
|
||||
href: { type: String, trim: true, maxlength: 255 },
|
||||
},
|
||||
},
|
||||
news: {
|
||||
subheading: String,
|
||||
heading: String,
|
||||
subheading: { type: String, trim: true, maxlength: 80 },
|
||||
heading: { type: String, trim: true, maxlength: 120 },
|
||||
ctaButton: {
|
||||
label: String,
|
||||
href: String,
|
||||
label: { type: String, trim: true, maxlength: 64 },
|
||||
href: { type: String, trim: true, maxlength: 255 },
|
||||
},
|
||||
selectedBlogIds: [{ type: mongoose.Schema.Types.ObjectId, ref: "Blog" }],
|
||||
// Deprecated: items field kept for backward compatibility during migration
|
||||
items: [
|
||||
new mongoose.Schema(
|
||||
{
|
||||
title: String,
|
||||
category: String,
|
||||
date: String,
|
||||
title: { type: String, trim: true, maxlength: 120 },
|
||||
category: { type: String, trim: true, maxlength: 48 },
|
||||
date: { type: String, trim: true, maxlength: 32 },
|
||||
comments: Number,
|
||||
author: {
|
||||
name: String,
|
||||
avatar: String,
|
||||
name: { type: String, trim: true, maxlength: 48 },
|
||||
avatar: { type: String, trim: true, maxlength: 255 },
|
||||
},
|
||||
link: String,
|
||||
thumbnail: String,
|
||||
link: { type: String, trim: true, maxlength: 255 },
|
||||
thumbnail: { type: String, trim: true, maxlength: 255 },
|
||||
},
|
||||
{ _id: false },
|
||||
),
|
||||
|
||||
@@ -7,28 +7,33 @@ const activitySchema = new mongoose.Schema(
|
||||
titleActivities: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: ''
|
||||
default: "",
|
||||
maxlength: 80,
|
||||
},
|
||||
titleBooking: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: ''
|
||||
default: "",
|
||||
maxlength: 80,
|
||||
},
|
||||
bannerImageActivities: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: ''
|
||||
default: "",
|
||||
maxlength: 255,
|
||||
},
|
||||
bannerImageBooking: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: ''
|
||||
default: "",
|
||||
maxlength: 255,
|
||||
},
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
maxlength: 120,
|
||||
},
|
||||
price: {
|
||||
type: Number,
|
||||
@@ -38,6 +43,7 @@ const activitySchema = new mongoose.Schema(
|
||||
priceText: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 32,
|
||||
},
|
||||
season: [
|
||||
{
|
||||
@@ -58,25 +64,28 @@ const activitySchema = new mongoose.Schema(
|
||||
{
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 80,
|
||||
},
|
||||
],
|
||||
image: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 255,
|
||||
},
|
||||
link: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 255,
|
||||
},
|
||||
// Global filters document (single document in Activity collection)
|
||||
filters: [
|
||||
{
|
||||
label: { type: String, required: true, trim: true },
|
||||
value: { type: String, required: true, trim: true },
|
||||
label: { type: String, required: true, trim: true, maxlength: 64 },
|
||||
value: { type: String, required: true, trim: true, maxlength: 64 },
|
||||
items: [
|
||||
{
|
||||
value: { type: String, required: true },
|
||||
label: { type: String, required: true },
|
||||
value: { type: String, required: true, maxlength: 64 },
|
||||
label: { type: String, required: true, maxlength: 64 },
|
||||
},
|
||||
],
|
||||
order: { type: Number, default: 0 },
|
||||
@@ -85,6 +94,7 @@ const activitySchema = new mongoose.Schema(
|
||||
program: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 80,
|
||||
},
|
||||
rating: {
|
||||
type: Number,
|
||||
@@ -113,7 +123,7 @@ const activitySchema = new mongoose.Schema(
|
||||
// Booking sessions - các đợt booking với thông số riêng
|
||||
bookingSessions: [
|
||||
{
|
||||
sessionId: { type: String, required: true },
|
||||
sessionId: { type: String, required: true, maxlength: 80 },
|
||||
startDate: { type: Date, required: true },
|
||||
endDate: { type: Date, required: true },
|
||||
overnightStays: { type: Number, required: true, default: 14 },
|
||||
@@ -127,11 +137,11 @@ const activitySchema = new mongoose.Schema(
|
||||
// Danh sách booking cho session này
|
||||
bookingList: [
|
||||
{
|
||||
address: { type: String, required: true },
|
||||
address: { type: String, required: true, maxlength: 255 },
|
||||
agreeNewsletter: { type: Boolean, default: false },
|
||||
agreeTerms: { type: Boolean, required: true },
|
||||
city: { type: String, required: true },
|
||||
country: { type: String, required: true },
|
||||
city: { type: String, required: true, maxlength: 80 },
|
||||
country: { type: String, required: true, maxlength: 80 },
|
||||
dietaryRestrictions: {
|
||||
type: String,
|
||||
enum: ['none', 'vegetarian', 'vegan', 'halal', 'kosher', 'gluten-free', 'other'],
|
||||
@@ -141,26 +151,27 @@ const activitySchema = new mongoose.Schema(
|
||||
type: String,
|
||||
required: true,
|
||||
lowercase: true,
|
||||
trim: true
|
||||
trim: true,
|
||||
maxlength: 120
|
||||
},
|
||||
emergencyContact: { type: String, required: true },
|
||||
emergencyPhone: { type: String, required: true },
|
||||
medicalConditions: { type: String, default: '' },
|
||||
emergencyContact: { type: String, required: true, maxlength: 80 },
|
||||
emergencyPhone: { type: String, required: true, maxlength: 40 },
|
||||
medicalConditions: { type: String, default: '', maxlength: 500 },
|
||||
numberOfParticipants: { type: Number, required: true, min: 1 },
|
||||
parentFirstName: { type: String, required: true, trim: true },
|
||||
parentLastName: { type: String, required: true, trim: true },
|
||||
parentFirstName: { type: String, required: true, trim: true, maxlength: 80 },
|
||||
parentLastName: { type: String, required: true, trim: true, maxlength: 80 },
|
||||
participantBirthDate: { type: Date, required: true },
|
||||
participantFirstName: { type: String, required: true, trim: true },
|
||||
participantFirstName: { type: String, required: true, trim: true, maxlength: 80 },
|
||||
participantGender: {
|
||||
type: String,
|
||||
enum: ['male', 'female', 'other'],
|
||||
required: true
|
||||
},
|
||||
participantLastName: { type: String, required: true, trim: true },
|
||||
phone: { type: String, required: true },
|
||||
postalCode: { type: String, required: true },
|
||||
sessionDate: { type: String, required: true }, // sessionId reference
|
||||
specialRequests: { type: String, default: '' },
|
||||
participantLastName: { type: String, required: true, trim: true, maxlength: 80 },
|
||||
phone: { type: String, required: true, maxlength: 40 },
|
||||
postalCode: { type: String, required: true, maxlength: 20 },
|
||||
sessionDate: { type: String, required: true, maxlength: 80 }, // sessionId reference
|
||||
specialRequests: { type: String, default: '', maxlength: 500 },
|
||||
// Thêm các trường quản lý
|
||||
bookingStatus: {
|
||||
type: String,
|
||||
@@ -175,8 +186,8 @@ const activitySchema = new mongoose.Schema(
|
||||
totalAmount: { type: Number, default: 0 },
|
||||
paidAmount: { type: Number, default: 0 },
|
||||
bookingDate: { type: Date, default: Date.now },
|
||||
confirmationCode: { type: String, unique: true },
|
||||
adminNotes: { type: String, default: '' }
|
||||
confirmationCode: { type: String, unique: true, maxlength: 32 },
|
||||
adminNotes: { type: String, default: '', maxlength: 1000 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,70 +11,70 @@ if (mongoose.connection.models.Booking) {
|
||||
const bookingSchema = new mongoose.Schema(
|
||||
{
|
||||
hero: {
|
||||
title: String,
|
||||
backgroundImage: String,
|
||||
title: { type: String, trim: true, maxlength: 80 },
|
||||
backgroundImage: { type: String, trim: true, maxlength: 255 },
|
||||
},
|
||||
|
||||
searchBar: {
|
||||
locationLabel: String,
|
||||
holidaySeasonLabel: String,
|
||||
searchButtonText: String,
|
||||
locationLabel: { type: String, trim: true, maxlength: 64 },
|
||||
holidaySeasonLabel: { type: String, trim: true, maxlength: 64 },
|
||||
searchButtonText: { type: String, trim: true, maxlength: 64 },
|
||||
},
|
||||
|
||||
filterPanel: {
|
||||
title: String,
|
||||
priceTitle: String,
|
||||
priceLabel: String,
|
||||
pricePlaceholder: String,
|
||||
title: { type: String, trim: true, maxlength: 80 },
|
||||
priceTitle: { type: String, trim: true, maxlength: 64 },
|
||||
priceLabel: { type: String, trim: true, maxlength: 64 },
|
||||
pricePlaceholder: { type: String, trim: true, maxlength: 64 },
|
||||
priceMin: Number,
|
||||
priceMax: Number,
|
||||
activitiesTitle: String,
|
||||
ageTitle: String,
|
||||
ageSelectPlaceholder: String,
|
||||
activitiesTitle: { type: String, trim: true, maxlength: 64 },
|
||||
ageTitle: { type: String, trim: true, maxlength: 64 },
|
||||
ageSelectPlaceholder: { type: String, trim: true, maxlength: 64 },
|
||||
ageMin: Number,
|
||||
ageMax: Number,
|
||||
ratingTitle: String,
|
||||
ratingTitle: { type: String, trim: true, maxlength: 64 },
|
||||
ratingOptions: [
|
||||
{
|
||||
value: String,
|
||||
label: String,
|
||||
value: { type: String, trim: true, maxlength: 48 },
|
||||
label: { type: String, trim: true, maxlength: 64 },
|
||||
},
|
||||
],
|
||||
resetButtonText: String,
|
||||
resetButtonText: { type: String, trim: true, maxlength: 64 },
|
||||
},
|
||||
|
||||
programs: [
|
||||
{
|
||||
value: String,
|
||||
label: String,
|
||||
value: { type: String, trim: true, maxlength: 64 },
|
||||
label: { type: String, trim: true, maxlength: 64 },
|
||||
},
|
||||
],
|
||||
|
||||
holidays: [
|
||||
{
|
||||
value: String,
|
||||
label: String,
|
||||
value: { type: String, trim: true, maxlength: 64 },
|
||||
label: { type: String, trim: true, maxlength: 64 },
|
||||
},
|
||||
],
|
||||
|
||||
locations: [
|
||||
{
|
||||
value: String,
|
||||
label: String,
|
||||
value: { type: String, trim: true, maxlength: 64 },
|
||||
label: { type: String, trim: true, maxlength: 64 },
|
||||
},
|
||||
],
|
||||
|
||||
camps: [
|
||||
{
|
||||
name: String,
|
||||
name: { type: String, trim: true, maxlength: 120 },
|
||||
price: Number,
|
||||
priceText: String,
|
||||
priceText: { type: String, trim: true, maxlength: 32 },
|
||||
season: [String],
|
||||
age: [Number],
|
||||
locations: [String],
|
||||
image: String,
|
||||
link: String,
|
||||
program: String,
|
||||
image: { type: String, trim: true, maxlength: 255 },
|
||||
link: { type: String, trim: true, maxlength: 255 },
|
||||
program: { type: String, trim: true, maxlength: 80 },
|
||||
rating: Number,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -15,11 +15,13 @@ const breadcrumbItemSchema = new mongoose.Schema(
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "",
|
||||
maxlength: 40,
|
||||
},
|
||||
link: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "",
|
||||
maxlength: 255,
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
@@ -32,16 +34,19 @@ const heroSchema = new mongoose.Schema(
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "Pricing Plan",
|
||||
maxlength: 60,
|
||||
},
|
||||
backgroundImage: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "/assets/img/inner-page/breadcrumb.jpg",
|
||||
maxlength: 255,
|
||||
},
|
||||
shapeImage: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "/assets/img/inner-page/shape.png",
|
||||
maxlength: 255,
|
||||
},
|
||||
breadcrumb: {
|
||||
type: [breadcrumbItemSchema],
|
||||
@@ -58,16 +63,19 @@ const pricingSectionSchema = new mongoose.Schema(
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "pricing plan",
|
||||
maxlength: 64,
|
||||
},
|
||||
heading: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "Flexible Plans to Suit Every Traveler",
|
||||
maxlength: 120,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "",
|
||||
maxlength: 500,
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
@@ -80,36 +88,43 @@ const planSchema = new mongoose.Schema(
|
||||
type: String,
|
||||
trim: true,
|
||||
required: true,
|
||||
maxlength: 64,
|
||||
},
|
||||
price: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "0",
|
||||
maxlength: 32,
|
||||
},
|
||||
period: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "mo",
|
||||
maxlength: 8,
|
||||
},
|
||||
currency: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "$",
|
||||
maxlength: 8,
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "Get Started Today",
|
||||
maxlength: 64,
|
||||
},
|
||||
buttonLink: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "/pricing",
|
||||
maxlength: 255,
|
||||
},
|
||||
buttonIcon: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "fa-solid fa-arrow-right",
|
||||
maxlength: 64,
|
||||
},
|
||||
style: {
|
||||
type: String,
|
||||
@@ -118,7 +133,7 @@ const planSchema = new mongoose.Schema(
|
||||
default: "default",
|
||||
},
|
||||
features: {
|
||||
type: [String],
|
||||
type: [{ type: String, maxlength: 96 }],
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
@@ -147,11 +162,13 @@ const testimonialItemSchema = new mongoose.Schema(
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "",
|
||||
maxlength: 64,
|
||||
},
|
||||
role: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "",
|
||||
maxlength: 64,
|
||||
},
|
||||
rating: {
|
||||
type: Number,
|
||||
@@ -163,6 +180,7 @@ const testimonialItemSchema = new mongoose.Schema(
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "",
|
||||
maxlength: 400,
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
@@ -175,31 +193,37 @@ const testimonialsSchema = new mongoose.Schema(
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "What Our Clients Say",
|
||||
maxlength: 64,
|
||||
},
|
||||
heading: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "Immigration Success Stories",
|
||||
maxlength: 120,
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "View All Review",
|
||||
maxlength: 64,
|
||||
},
|
||||
buttonLink: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "/contact",
|
||||
maxlength: 255,
|
||||
},
|
||||
buttonIcon: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "fa-solid fa-arrow-right",
|
||||
maxlength: 64,
|
||||
},
|
||||
image: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "",
|
||||
maxlength: 255,
|
||||
},
|
||||
items: {
|
||||
type: [testimonialItemSchema],
|
||||
|
||||
867
public/js/admin-form-helpers.js
Normal file
867
public/js/admin-form-helpers.js
Normal 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);
|
||||
@@ -2,7 +2,7 @@ require("dotenv").config();
|
||||
const connectDB = require("../config/database");
|
||||
|
||||
const DEFAULT_FACEBOOK_URL = "https://www.facebook.com/hailearning.edu.vn/";
|
||||
const DEFAULT_PANEL_TITLE = "Anh chị cần tư vấn hay hỗ trợ gì thêm không ạ?";
|
||||
const DEFAULT_PANEL_TITLE = "Do you need any additional advice or support?";
|
||||
const DEFAULT_BRAND_IMAGE = "/assets/img/logo/black-logo.svg";
|
||||
const DEFAULT_FACEBOOK_ICON = "/uploads/home/floating-contact/Facebook_Logo_Primary.webp";
|
||||
const DEFAULT_ZALO_ICON = "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp";
|
||||
@@ -60,7 +60,7 @@ async function migrate() {
|
||||
id: "facebook",
|
||||
platform: "facebook",
|
||||
enabled: true,
|
||||
label: "Nhắn tin qua Facebook",
|
||||
label: "Message via Facebook",
|
||||
subtitle: "facebook.com/hailearning.edu.vn",
|
||||
href: DEFAULT_FACEBOOK_URL,
|
||||
iconImage: DEFAULT_FACEBOOK_ICON,
|
||||
@@ -73,7 +73,7 @@ async function migrate() {
|
||||
id: "zalo",
|
||||
platform: "zalo",
|
||||
enabled: true,
|
||||
label: "Nhắn tin qua Zalo",
|
||||
label: "Message via Zalo",
|
||||
subtitle: `zalo.me/${zaloPhone}`,
|
||||
href: zaloUrl,
|
||||
iconImage: DEFAULT_ZALO_ICON,
|
||||
|
||||
158
utils/lengthValidation.js
Normal file
158
utils/lengthValidation.js
Normal 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,
|
||||
};
|
||||
@@ -41,7 +41,9 @@
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" name="heroBannerImage" id="heroBannerImage"
|
||||
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">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
@@ -235,7 +237,9 @@
|
||||
<label class="form-label fw-medium">Image Path</label>
|
||||
<input type="text" class="form-control" name="image" id="imageInput"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,7 +275,9 @@
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" name="campDetailHeroBgImage" id="campDetailHeroBgImage"
|
||||
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>
|
||||
</div>
|
||||
<div class="mt-2" id="campDetailHeroBgPreviewWrapper" style="display: none;">
|
||||
|
||||
@@ -165,9 +165,7 @@
|
||||
<textarea class="form-control" id="excerpt" name="excerpt" rows="3"
|
||||
required maxlength="500"
|
||||
placeholder="Enter a brief summary of the blog post (max 500 characters)"></textarea>
|
||||
<div class="form-text">
|
||||
<span id="excerptCount">0</span>/500 characters
|
||||
</div>
|
||||
<div class="form-text">Maximum 500 characters.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -470,14 +468,6 @@
|
||||
console.error('Error initializing contentAfterQuote editor:', error);
|
||||
}
|
||||
|
||||
// Excerpt character counter
|
||||
const excerptInput = document.getElementById('excerpt');
|
||||
const excerptCount = document.getElementById('excerptCount');
|
||||
|
||||
excerptInput.addEventListener('input', function () {
|
||||
excerptCount.textContent = this.value.length;
|
||||
});
|
||||
|
||||
// Image upload handler
|
||||
document.querySelectorAll('.btn-upload-image').forEach(button => {
|
||||
button.addEventListener('click', function () {
|
||||
|
||||
@@ -200,11 +200,7 @@
|
||||
<textarea class="form-control" id="excerpt" name="excerpt" rows="3"
|
||||
required maxlength="500"
|
||||
placeholder="Enter a brief summary of the blog post (max 500 characters)"><%= blog.excerpt || '' %></textarea>
|
||||
<div class="form-text">
|
||||
<span id="excerptCount">
|
||||
<%= (blog.excerpt || '' ).length %>
|
||||
</span>/500 characters
|
||||
</div>
|
||||
<div class="form-text">Maximum 500 characters.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -888,14 +884,6 @@
|
||||
console.error('Error initializing contentAfterQuote editor:', error);
|
||||
}
|
||||
|
||||
// Excerpt character counter
|
||||
const excerptInput = document.getElementById('excerpt');
|
||||
const excerptCount = document.getElementById('excerptCount');
|
||||
|
||||
excerptInput.addEventListener('input', function () {
|
||||
excerptCount.textContent = this.value.length;
|
||||
});
|
||||
|
||||
// Image upload handler
|
||||
document.querySelectorAll('.btn-upload-image').forEach(button => {
|
||||
button.addEventListener('click', function () {
|
||||
|
||||
@@ -2000,13 +2000,13 @@
|
||||
}
|
||||
|
||||
|
||||
if (window.toastManager) window.toastManager.success('Upload thành công');
|
||||
if (window.toastManager) window.toastManager.success('Upload completed successfully');
|
||||
} else {
|
||||
if (window.toastManager) window.toastManager.success('Upload thành công');
|
||||
if (window.toastManager) window.toastManager.success('Upload completed successfully');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload error', err);
|
||||
if (window.toastManager) window.toastManager.error('Lỗi upload: ' + (err.message ||
|
||||
if (window.toastManager) window.toastManager.error('Upload error: ' + (err.message ||
|
||||
err));
|
||||
}
|
||||
});
|
||||
@@ -2015,7 +2015,7 @@
|
||||
fileInput.click();
|
||||
} catch (err) {
|
||||
console.error('openImageUploader error', err);
|
||||
if (window.toastManager) window.toastManager.error('Lỗi: ' + (err.message || err));
|
||||
if (window.toastManager) window.toastManager.error('Error: ' + (err.message || err));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</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 class="col-md-7">
|
||||
<div id="heroImagePreview" style="height: 300px;">
|
||||
@@ -181,7 +181,6 @@
|
||||
name="cardTitle_<%= index %>"
|
||||
value="<%= card.title || '' %>"
|
||||
maxlength="40" data-maxlength="40">
|
||||
<small class="text-muted d-block mt-1">Recommended maximum: 40 characters.</small>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<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) {
|
||||
scope.querySelectorAll('input[data-maxlength], textarea[data-maxlength]').forEach((input) => {
|
||||
updateContactCounter(input);
|
||||
scope.querySelectorAll('.contact-limit-counter').forEach((node) => node.remove());
|
||||
|
||||
if (input.dataset.counterBound === 'true') {
|
||||
return;
|
||||
if (window.AdminFormHelpers) {
|
||||
window.AdminFormHelpers.refresh(scope);
|
||||
}
|
||||
|
||||
input.dataset.counterBound = 'true';
|
||||
input.addEventListener('input', () => updateContactCounter(input));
|
||||
});
|
||||
}
|
||||
|
||||
function updateAllJsonInputs(data) {
|
||||
@@ -1327,7 +1275,6 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Title</label>
|
||||
<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 class="col-md-12">
|
||||
<label class="form-label">Icon Type</label>
|
||||
|
||||
@@ -404,7 +404,7 @@
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('=== TRACE: Unified Save ERROR ===', error);
|
||||
showNotification('Lỗi: ' + error.message, 'error');
|
||||
showNotification('Error: ' + error.message, 'error');
|
||||
} finally {
|
||||
submitBtn.innerHTML = originalText;
|
||||
submitBtn.disabled = false;
|
||||
@@ -709,19 +709,19 @@
|
||||
const icon = document.getElementById('newSocialIcon').value.trim();
|
||||
|
||||
if (!platform) {
|
||||
alert('Vui lòng nhập tên nền tảng');
|
||||
alert('Please enter a platform name');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
alert('Vui lòng nhập URL');
|
||||
alert('Please enter a URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if platform already exists
|
||||
const existingPlatforms = Array.from(document.querySelectorAll('.social-platform')).map(el => el.value);
|
||||
if (existingPlatforms.includes(platform)) {
|
||||
alert(`${platform} đã tồn tại`);
|
||||
alert(`${platform} already exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -780,12 +780,12 @@
|
||||
const newIcon = document.getElementById('editSocialIcon').value.trim();
|
||||
|
||||
if (!newPlatform) {
|
||||
alert('Vui lòng nhập tên nền tảng');
|
||||
alert('Please enter a platform name');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newUrl) {
|
||||
alert('Vui lòng nhập URL');
|
||||
alert('Please enter a URL');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -446,92 +446,11 @@
|
||||
toast.addEventListener("hidden.bs.toast", () => toast.remove());
|
||||
}
|
||||
|
||||
function ensureCharacterHint(input) {
|
||||
if (!input || (!input.dataset.maxlength && !input.dataset.maxwords)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!input.id) {
|
||||
input.id = `homeField_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
const field = input.closest(".col-md-12, .col-md-9, .col-md-6, .col-md-4, .col-md-3, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12") || input.parentElement;
|
||||
const anchor = input.closest(".input-group") || input;
|
||||
const parent = anchor?.parentElement || field;
|
||||
if (!field || !anchor || !parent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let hint = field.querySelector(`[data-counter-for="${input.id}"]`);
|
||||
if (!hint) {
|
||||
hint = document.createElement("small");
|
||||
hint.className = "form-text home-limit-counter text-secondary";
|
||||
hint.dataset.counterFor = input.id;
|
||||
}
|
||||
|
||||
if (hint.previousElementSibling !== anchor) {
|
||||
parent.insertBefore(hint, anchor.nextSibling);
|
||||
}
|
||||
|
||||
return hint;
|
||||
}
|
||||
|
||||
function updateCharacterHint(input) {
|
||||
const hint = ensureCharacterHint(input);
|
||||
if (!hint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasWordLimit = Boolean(input.dataset.maxwords);
|
||||
const hasCharLimit = Boolean(input.dataset.maxlength);
|
||||
|
||||
if (hasWordLimit) {
|
||||
const maxWords = Number(input.dataset.maxwords);
|
||||
const normalized = (input.value || "").replace(/\s+/g, " ").trim();
|
||||
const words = normalized ? normalized.split(" ") : [];
|
||||
|
||||
if (Number.isFinite(maxWords) && maxWords > 0 && words.length > maxWords) {
|
||||
input.value = words.slice(0, maxWords).join(" ");
|
||||
} else if (normalized !== input.value) {
|
||||
input.value = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasCharLimit) {
|
||||
const max = Number(input.dataset.maxlength);
|
||||
if (Number.isFinite(max) && max > 0 && (input.value || "").length > max) {
|
||||
input.value = (input.value || "").slice(0, max);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasWordLimit) {
|
||||
const maxWords = Number(input.dataset.maxwords);
|
||||
const currentWords = input.value ? input.value.split(" ").filter(Boolean).length : 0;
|
||||
const currentLength = (input.value || "").length;
|
||||
const maxLength = Number(input.dataset.maxlength);
|
||||
hint.textContent = hasCharLimit
|
||||
? `${currentWords}/${maxWords} words, ${currentLength}/${maxLength} characters`
|
||||
: `${currentWords}/${maxWords} words`;
|
||||
hint.classList.toggle("text-danger", currentWords >= maxWords || (hasCharLimit && currentLength >= maxLength));
|
||||
return;
|
||||
}
|
||||
|
||||
const max = Number(input.dataset.maxlength);
|
||||
const length = (input.value || "").length;
|
||||
hint.textContent = `${length}/${max} characters`;
|
||||
hint.classList.toggle("text-danger", length >= max);
|
||||
}
|
||||
|
||||
function initHomeCharacterCounters(scope = document) {
|
||||
scope.querySelectorAll("input[data-maxlength], textarea[data-maxlength], input[data-maxwords], textarea[data-maxwords]").forEach((input) => {
|
||||
updateCharacterHint(input);
|
||||
scope.querySelectorAll(".home-limit-counter").forEach((node) => node.remove());
|
||||
|
||||
if (input.dataset.counterBound === "true") {
|
||||
return;
|
||||
if (window.AdminFormHelpers) {
|
||||
window.AdminFormHelpers.refresh(scope);
|
||||
}
|
||||
|
||||
input.dataset.counterBound = "true";
|
||||
input.addEventListener("input", () => updateCharacterHint(input));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
style="height: 120px; width: 120px; object-fit: contain; background: #fff;"
|
||||
alt="Brand preview" />
|
||||
</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 class="col-lg-6">
|
||||
<label class="form-label fw-medium">Fallback Trigger Image</label>
|
||||
@@ -95,7 +95,7 @@
|
||||
</div>
|
||||
<input type="hidden" id="floatingContactTriggerIconFallback"
|
||||
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>
|
||||
|
||||
@@ -9,18 +9,11 @@
|
||||
</h6>
|
||||
</div>
|
||||
<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="col-lg-6">
|
||||
<label class="form-label fw-medium">Fallback Background Image</label>
|
||||
<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>
|
||||
<div class="input-group mb-2">
|
||||
<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="fw-semibold mb-2">Recommended content structure</div>
|
||||
<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>Title ngắn 2-4 dòng để không tràn trên mobile.</li>
|
||||
<li>Description giữ ở mức 1-3 câu ngắn.</li>
|
||||
<li>Hai nút nên dùng link nội bộ như <code>/contact</code>.</li>
|
||||
<li>Use landscape slide images; prefer large images to fit the container.</li>
|
||||
<li>Keep titles to 2-4 lines to avoid overflow on mobile.</li>
|
||||
<li>Limit descriptions to 1-3 short sentences.</li>
|
||||
<li>Both buttons should use internal links like <code>/contact</code>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,7 +94,7 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<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"
|
||||
value="<%= slide.subtitle || '' %>" placeholder="e.g., Global Education Simplified"
|
||||
maxlength="48" data-maxlength="48" />
|
||||
@@ -113,7 +106,7 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<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">
|
||||
<input type="text" class="form-control" id="heroSlide_<%= index %>_heroImage"
|
||||
value="<%= slide.heroImage || '' %>" placeholder="/uploads/home/hero-slide-01.jpg"
|
||||
@@ -132,9 +125,9 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<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"
|
||||
value="<%= slide.videoUrl || '' %>" placeholder="Không bắt buộc"
|
||||
value="<%= slide.videoUrl || '' %>" placeholder="Optional"
|
||||
maxlength="255" data-maxlength="255" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</h6>
|
||||
</div>
|
||||
<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">
|
||||
<% for(let i=0; i<4; i++) {
|
||||
const item = (data.partners?.visaConsultancy?.items && data.partners.visaConsultancy.items[i]) || {};
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<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">
|
||||
<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"
|
||||
@@ -64,7 +64,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<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">
|
||||
<% (data.partners?.brands?.items || []).forEach(function(item, index) { %>
|
||||
<div class="col-md-4 brand-partner-item">
|
||||
@@ -83,7 +83,7 @@
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
</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">
|
||||
<img src="<%= item.logo || '' %>" class="img-thumbnail <%= item.logo ? '' : 'd-none' %>" style="height: 50px; object-fit: contain;">
|
||||
</div>
|
||||
@@ -143,7 +143,7 @@
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
</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">
|
||||
<img src="" class="img-thumbnail d-none" style="height: 50px; object-fit: contain;">
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<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">
|
||||
<input type="text" class="form-control" id="testimonialsVideoThumbnail"
|
||||
value="<%= data.testimonials?.videoThumbnail || '' %>" maxlength="255" data-maxlength="255" />
|
||||
@@ -100,7 +100,7 @@
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<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">
|
||||
<input type="text" class="form-control" id="testimonialsAvatar_<%= index %>"
|
||||
value="<%= item.avatar || '' %>" maxlength="255" data-maxlength="255" />
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<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">
|
||||
<input type="text" class="form-control" id="visaCountriesFlag_0"
|
||||
value="<%= featured.flag || '' %>" placeholder="/assets/img/home-1/feature/shape.png"
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<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">
|
||||
<input type="text" class="form-control" id="whyChooseUsMainImage"
|
||||
value="<%= data.whyChooseUs?.mainImage || '' %>" placeholder="/assets/img/home-1/about/about-1.jpg"
|
||||
@@ -73,7 +73,7 @@
|
||||
|
||||
<div class="col-md-6">
|
||||
<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">
|
||||
<input type="text" class="form-control" id="whyChooseUsSecondaryImage"
|
||||
value="<%= data.whyChooseUs?.secondaryImage || '' %>"
|
||||
@@ -113,7 +113,7 @@
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<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">
|
||||
<input type="text" class="form-control" id="whyChooseUsIcon_<%= index %>"
|
||||
value="<%= item.icon || '' %>" maxlength="255" data-maxlength="255" />
|
||||
|
||||
@@ -529,7 +529,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
img.alt = 'Image preview';
|
||||
|
||||
if (window.toastManager) {
|
||||
window.toastManager.success('Tải ảnh thành công');
|
||||
window.toastManager.success('Image uploaded successfully');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Country Icon (Flag)</label>
|
||||
<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()">
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
@@ -146,7 +146,7 @@
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-medium">Main Image</label>
|
||||
<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()">
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
@@ -236,7 +236,7 @@
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-medium">Gallery Image <%= i %></label>
|
||||
<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()">
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
@@ -330,7 +330,7 @@
|
||||
<div class="col-md-5">
|
||||
<label class="form-label fw-medium small">Icon</label>
|
||||
<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/*">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('related_file_<%= i %>').click()">
|
||||
<i class="fas fa-upload"></i>
|
||||
@@ -365,7 +365,7 @@
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-medium">Image Contact</label>
|
||||
<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()">
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
@@ -605,10 +605,68 @@
|
||||
document.getElementById("viewContainer").style.display = "none";
|
||||
}
|
||||
|
||||
function refreshVisaAdminHelpers() {
|
||||
if (!window.AdminFormHelpers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formContainer = document.getElementById("formContainer");
|
||||
window.AdminFormHelpers.refresh(formContainer);
|
||||
|
||||
const guidanceTargets = [
|
||||
{
|
||||
selector: "#icon_input",
|
||||
lines: [
|
||||
"Displayed as a small country flag or icon.",
|
||||
"Prefer SVG; otherwise use a square image at 96x96px or larger."
|
||||
]
|
||||
},
|
||||
{
|
||||
selector: "#mainImage_detail",
|
||||
lines: [
|
||||
"Used in visa detail content blocks.",
|
||||
"Recommended upload: at least 1000x750px for primary imagery."
|
||||
]
|
||||
},
|
||||
{
|
||||
selector: "input[name='bannerImageGallery']",
|
||||
lines: [
|
||||
"Gallery image used in visa detail content.",
|
||||
"Recommended upload: at least 1000x750px."
|
||||
]
|
||||
},
|
||||
{
|
||||
selector: "input[name='related_icon[]']",
|
||||
lines: [
|
||||
"Supporting service image.",
|
||||
"Recommended upload: at least 800x600px."
|
||||
]
|
||||
},
|
||||
{
|
||||
selector: "#contact_image_input",
|
||||
lines: [
|
||||
"Contact-side supporting image.",
|
||||
"Recommended upload: at least 800x600px."
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
guidanceTargets.forEach(({ selector, lines }) => {
|
||||
document.querySelectorAll(selector).forEach((input) => {
|
||||
window.AdminFormHelpers.renderUploadGuidance(input, {
|
||||
for: input.id || input.name || "",
|
||||
title: "Upload guidance",
|
||||
lines
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showFormView() {
|
||||
document.getElementById("tableContainer").style.display = "none";
|
||||
document.getElementById("formContainer").style.display = "block";
|
||||
document.getElementById("viewContainer").style.display = "none";
|
||||
refreshVisaAdminHelpers();
|
||||
}
|
||||
|
||||
function showViewView() {
|
||||
@@ -797,6 +855,7 @@
|
||||
}
|
||||
|
||||
showFormView();
|
||||
refreshVisaAdminHelpers();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
@@ -894,6 +953,7 @@
|
||||
|
||||
|
||||
showFormView();
|
||||
refreshVisaAdminHelpers();
|
||||
});
|
||||
|
||||
document.getElementById("btnBackToList").addEventListener("click", showListView);
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<!-- Font Awesome -->
|
||||
<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>
|
||||
:root {
|
||||
--primary-color: #b8b76a;
|
||||
@@ -42,7 +44,7 @@
|
||||
<%- style %>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body data-admin-helpers="true">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<nav class="col-md-2 d-none d-md-block bg-light sidebar">
|
||||
@@ -117,6 +119,7 @@
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<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>
|
||||
// Global function to clean up any stuck modal backdrops
|
||||
function forceCleanupModals() {
|
||||
|
||||
@@ -676,7 +676,7 @@
|
||||
<%- style %>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body data-admin-helpers="true">
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar navbar-expand-lg navbar-light sticky-top">
|
||||
<div class="container">
|
||||
@@ -821,6 +821,9 @@
|
||||
<!-- Custom modal enhancement -->
|
||||
<script src="/js/custom-modal.js"></script>
|
||||
|
||||
<!-- Shared admin form helpers -->
|
||||
<script src="/js/admin-form-helpers.js"></script>
|
||||
|
||||
<script>
|
||||
// Load Level Types cho dropdown menu Academics với submenu programmes
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
|
||||
Reference in New Issue
Block a user