fea/nhat-dat-11042026-merge #1

Closed
minhnhat wants to merge 27 commits from UKSOURCE/cms.hailearning.edu.vn:fea/nhat-dat-11042026-merge into fea/nhat-13042028-merge-kiet-thien
67 changed files with 4482 additions and 664 deletions
Showing only changes of commit ec1b2e2568 - Show all commits

Binary file not shown.

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ pids
#cursor #cursor
.cursor .cursor
package-lock.json package-lock.json
/.omc
CLAUDE.md
/.claude

View File

@@ -37,6 +37,47 @@
margin-top: var(--spacing-1); margin-top: var(--spacing-1);
} }
.admin-field-counter {
display: block;
margin-top: var(--spacing-1);
color: var(--text-muted);
font-size: var(--font-size-xs);
line-height: 1.4;
text-align: left;
width: 100%;
}
.admin-field-counter.is-danger {
color: #b42318;
font-weight: var(--font-weight-medium);
}
.admin-upload-guidance {
margin-top: var(--spacing-2);
padding: 0.85rem 1rem;
border: 1px solid rgba(184, 183, 106, 0.22);
border-radius: var(--border-radius);
background: linear-gradient(180deg, rgba(184, 183, 106, 0.08), rgba(184, 183, 106, 0.03));
color: var(--text-main);
}
.admin-upload-guidance__title {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
margin-bottom: 0.35rem;
}
.admin-upload-guidance__list {
margin: 0;
padding-left: 1.15rem;
color: var(--text-muted);
font-size: var(--font-size-xs);
}
.admin-upload-guidance__list li + li {
margin-top: 0.25rem;
}
/* Validation Styles */ /* Validation Styles */
.invalid-feedback { .invalid-feedback {
font-size: var(--font-size-xs); font-size: var(--font-size-xs);

View File

@@ -0,0 +1,191 @@
const ABOUT_US_LENGTH_RULES = [
{ path: "hero.title", label: "Hero title", maxLength: 80 },
{ path: "hero.breadcrumb.*", label: "Hero breadcrumb", maxLength: 80 },
{ path: "hero.backgroundImage", label: "Hero background image", maxLength: 255 },
{ path: "intro.subheading", label: "Intro subheading", maxLength: 80 },
{ path: "intro.heading", label: "Intro heading", maxLength: 120 },
{ path: "intro.description", label: "Intro description", maxLength: 1000 },
{ path: "intro.image", label: "Intro image", maxLength: 255 },
{ path: "mission.subheading", label: "Mission subheading", maxLength: 80 },
{ path: "mission.heading", label: "Mission heading", maxLength: 120 },
{ path: "mission.description", label: "Mission description", maxLength: 1000 },
{ path: "mission.images.main", label: "Mission main image", maxLength: 255 },
{ path: "mission.images.secondary", label: "Mission secondary image", maxLength: 255 },
{ path: "mission.images.bgShape", label: "Mission background shape", maxLength: 255 },
{ path: "mission.images.planeShape", label: "Mission plane shape", maxLength: 255 },
{ path: "mission.images.topShape", label: "Mission top shape", maxLength: 255 },
{ path: "mission.images.globeShape", label: "Mission globe shape", maxLength: 255 },
{ path: "mission.items.*.icon", label: "Mission item icon", maxLength: 255 },
{ path: "mission.items.*.label", label: "Mission item label", maxLength: 80 },
{ path: "mission.items.*.description", label: "Mission item description", maxLength: 240 },
{ path: "mission.features.*", label: "Mission feature", maxLength: 80 },
{ path: "mission.ctaButton.label", label: "Mission CTA label", maxLength: 64 },
{ path: "mission.ctaButton.href", label: "Mission CTA link", maxLength: 255 },
{ path: "features.backgroundImage", label: "Features background image", maxLength: 255 },
{ path: "features.subheading", label: "Features subheading", maxLength: 80 },
{ path: "features.heading", label: "Features heading", maxLength: 120 },
{ path: "features.description", label: "Features description", maxLength: 1000 },
{ path: "features.image", label: "Features image", maxLength: 255 },
{ path: "features.items.*.icon", label: "Feature item icon", maxLength: 255 },
{ path: "features.items.*.title", label: "Feature item title", maxLength: 80 },
{ path: "features.items.*.description", label: "Feature item description", maxLength: 240 },
{ path: "features.ctaButton.label", label: "Features CTA label", maxLength: 64 },
{ path: "features.ctaButton.href", label: "Features CTA link", maxLength: 255 },
{ path: "news.subheading", label: "News subheading", maxLength: 80 },
{ path: "news.heading", label: "News heading", maxLength: 120 },
{ path: "news.ctaButton.label", label: "News CTA label", maxLength: 64 },
{ path: "news.ctaButton.href", label: "News CTA link", maxLength: 255 },
{ path: "news.selectedBlogIds.*", label: "Selected blog ID", maxLength: 64 },
{ path: "news.items.*.title", label: "News item title", maxLength: 120 },
{ path: "news.items.*.category", label: "News item category", maxLength: 48 },
{ path: "news.items.*.date", label: "News item date", maxLength: 32 },
{ path: "news.items.*.author.name", label: "News item author", maxLength: 48 },
{ path: "news.items.*.author.avatar", label: "News item author avatar", maxLength: 255 },
{ path: "news.items.*.link", label: "News item link", maxLength: 255 },
{ path: "news.items.*.thumbnail", label: "News item thumbnail", maxLength: 255 },
];
const PRICING_LENGTH_RULES = [
{ path: "hero.title", label: "Hero title", maxLength: 60 },
{ path: "hero.backgroundImage", label: "Hero background image", maxLength: 255 },
{ path: "hero.shapeImage", label: "Hero shape image", maxLength: 255 },
{ path: "hero.breadcrumb.*.text", label: "Hero breadcrumb text", maxLength: 40 },
{ path: "hero.breadcrumb.*.link", label: "Hero breadcrumb link", maxLength: 255 },
{ path: "pricingSection.subtitle", label: "Pricing section subtitle", maxLength: 64 },
{ path: "pricingSection.heading", label: "Pricing section heading", maxLength: 120 },
{ path: "pricingSection.description", label: "Pricing section description", maxLength: 500 },
{ path: "plans.monthly.*.name", label: "Monthly plan name", maxLength: 64 },
{ path: "plans.monthly.*.price", label: "Monthly plan price", maxLength: 32 },
{ path: "plans.monthly.*.period", label: "Monthly plan period", maxLength: 8 },
{ path: "plans.monthly.*.currency", label: "Monthly plan currency", maxLength: 8 },
{ path: "plans.monthly.*.buttonText", label: "Monthly plan button text", maxLength: 64 },
{ path: "plans.monthly.*.buttonLink", label: "Monthly plan button link", maxLength: 255 },
{ path: "plans.monthly.*.buttonIcon", label: "Monthly plan button icon", maxLength: 64 },
{ path: "plans.monthly.*.features.*", label: "Monthly plan feature", maxLength: 96 },
{ path: "plans.yearly.*.name", label: "Yearly plan name", maxLength: 64 },
{ path: "plans.yearly.*.price", label: "Yearly plan price", maxLength: 32 },
{ path: "plans.yearly.*.period", label: "Yearly plan period", maxLength: 8 },
{ path: "plans.yearly.*.currency", label: "Yearly plan currency", maxLength: 8 },
{ path: "plans.yearly.*.buttonText", label: "Yearly plan button text", maxLength: 64 },
{ path: "plans.yearly.*.buttonLink", label: "Yearly plan button link", maxLength: 255 },
{ path: "plans.yearly.*.buttonIcon", label: "Yearly plan button icon", maxLength: 64 },
{ path: "plans.yearly.*.features.*", label: "Yearly plan feature", maxLength: 96 },
{ path: "testimonials.subtitle", label: "Testimonials subtitle", maxLength: 64 },
{ path: "testimonials.heading", label: "Testimonials heading", maxLength: 120 },
{ path: "testimonials.buttonText", label: "Testimonials button text", maxLength: 64 },
{ path: "testimonials.buttonLink", label: "Testimonials button link", maxLength: 255 },
{ path: "testimonials.buttonIcon", label: "Testimonials button icon", maxLength: 64 },
{ path: "testimonials.image", label: "Testimonials image", maxLength: 255 },
{ path: "testimonials.items.*.name", label: "Testimonial name", maxLength: 64 },
{ path: "testimonials.items.*.role", label: "Testimonial role", maxLength: 64 },
{ path: "testimonials.items.*.content", label: "Testimonial content", maxLength: 400 },
];
const BOOKING_LENGTH_RULES = [
{ path: "hero.title", label: "Hero title", maxLength: 80 },
{ path: "hero.backgroundImage", label: "Hero background image", maxLength: 255 },
{ path: "searchBar.locationLabel", label: "Search bar location label", maxLength: 64 },
{ path: "searchBar.holidaySeasonLabel", label: "Search bar holiday label", maxLength: 64 },
{ path: "searchBar.searchButtonText", label: "Search bar button text", maxLength: 64 },
{ path: "filterPanel.title", label: "Filter panel title", maxLength: 80 },
{ path: "filterPanel.priceTitle", label: "Filter panel price title", maxLength: 64 },
{ path: "filterPanel.priceLabel", label: "Filter panel price label", maxLength: 64 },
{ path: "filterPanel.pricePlaceholder", label: "Filter panel price placeholder", maxLength: 64 },
{ path: "filterPanel.ageTitle", label: "Filter panel age title", maxLength: 64 },
{ path: "filterPanel.ageSelectPlaceholder", label: "Filter panel age placeholder", maxLength: 64 },
{ path: "filterPanel.activitiesTitle", label: "Filter panel activities title", maxLength: 64 },
{ path: "filterPanel.ratingTitle", label: "Filter panel rating title", maxLength: 64 },
{ path: "filterPanel.resetButtonText", label: "Filter panel reset button text", maxLength: 64 },
{ path: "filterPanel.ratingOptions.*.value", label: "Filter rating option value", maxLength: 48 },
{ path: "filterPanel.ratingOptions.*.label", label: "Filter rating option label", maxLength: 64 },
{ path: "programs.*.value", label: "Program value", maxLength: 64 },
{ path: "programs.*.label", label: "Program label", maxLength: 64 },
{ path: "holidays.*.value", label: "Holiday value", maxLength: 64 },
{ path: "holidays.*.label", label: "Holiday label", maxLength: 64 },
{ path: "locations.*.value", label: "Location value", maxLength: 64 },
{ path: "locations.*.label", label: "Location label", maxLength: 64 },
{ path: "camps.*.name", label: "Camp name", maxLength: 120 },
{ path: "camps.*.priceText", label: "Camp price text", maxLength: 32 },
{ path: "camps.*.image", label: "Camp image", maxLength: 255 },
{ path: "camps.*.link", label: "Camp link", maxLength: 255 },
{ path: "camps.*.program", label: "Camp program", maxLength: 80 },
{ path: "configuration.discounts.*.id", label: "Discount ID", maxLength: 64 },
{ path: "configuration.discounts.*.name", label: "Discount name", maxLength: 64 },
{ path: "configuration.discounts.*.description", label: "Discount description", maxLength: 220 },
{ path: "configuration.vouchers.*.validCodes", label: "Voucher code", maxLength: 64 },
{ path: "formSteps.*.title", label: "Booking step title", maxLength: 80 },
{ path: "formSteps.*.sections.*.id", label: "Booking section ID", maxLength: 48 },
{ path: "formSteps.*.sections.*.fields.*.name", label: "Booking field name", maxLength: 32 },
{ path: "formSteps.*.sections.*.fields.*.label", label: "Booking field label", maxLength: 48 },
{ path: "formSteps.*.sections.*.fields.*.placeholder", label: "Booking field placeholder", maxLength: 72 },
{ path: "formSteps.*.sections.*.fields.*.programmeName", label: "Booking field programme name", maxLength: 48 },
];
const ACTIVITY_LENGTH_RULES = [
{ path: "hero.titleActivities", label: "Activity hero title", maxLength: 80 },
{ path: "hero.titleBooking", label: "Booking hero title", maxLength: 80 },
{ path: "hero.bannerImageActivities", label: "Activity hero banner image", maxLength: 255 },
{ path: "hero.bannerImageBooking", label: "Booking hero banner image", maxLength: 255 },
{ path: "name", label: "Activity name", maxLength: 120 },
{ path: "priceText", label: "Activity price text", maxLength: 32 },
{ path: "image", label: "Activity image", maxLength: 255 },
{ path: "link", label: "Activity link", maxLength: 255 },
{ path: "program", label: "Activity program", maxLength: 80 },
{ path: "campDetail.hero.title", label: "Camp detail hero title", maxLength: 120 },
{ path: "campDetail.hero.bgImage", label: "Camp detail hero image", maxLength: 255 },
{ path: "campDetail.basicInfo.location", label: "Camp location", maxLength: 120 },
{ path: "campDetail.basicInfo.ageRange", label: "Camp age range", maxLength: 120 },
{ path: "campDetail.basicInfo.accommodationType", label: "Camp accommodation type", maxLength: 120 },
{ path: "campDetail.basicInfo.careLevel", label: "Camp care level", maxLength: 120 },
{ path: "campDetail.basicInfo.languages", label: "Camp languages", maxLength: 120 },
{ path: "campDetail.sidebar.contact.phone", label: "Camp contact phone", maxLength: 32 },
{ path: "campDetail.sidebar.contact.email", label: "Camp contact email", maxLength: 120 },
{ path: "campDetail.sidebar.menuItems.*.name", label: "Sidebar menu item name", maxLength: 64 },
{ path: "campDetail.sidebar.menuItems.*.href", label: "Sidebar menu item link", maxLength: 255 },
{ path: "campDetail.sidebar.upcomingTours.*.title", label: "Upcoming tour title", maxLength: 120 },
{ path: "campDetail.sidebar.upcomingTours.*.location", label: "Upcoming tour location", maxLength: 80 },
{ path: "campDetail.sidebar.upcomingTours.*.image", label: "Upcoming tour image", maxLength: 255 },
{ path: "campDetail.mainGallery.overlayInfo.location", label: "Gallery overlay location", maxLength: 120 },
{ path: "campDetail.mainGallery.overlayInfo.season", label: "Gallery overlay season", maxLength: 120 },
{ path: "campDetail.mainGallery.overlayInfo.languages", label: "Gallery overlay languages", maxLength: 120 },
{ path: "campDetail.mainGallery.slides.*.url", label: "Gallery slide image", maxLength: 255 },
{ path: "campDetail.mainGallery.slides.*.alt", label: "Gallery slide alt text", maxLength: 120 },
{ path: "campDetail.eventSchedule.startDate", label: "Event schedule start date", maxLength: 32 },
{ path: "campDetail.eventSchedule.duration", label: "Event schedule duration", maxLength: 32 },
{ path: "campDetail.eventSchedule.tickets", label: "Event schedule tickets", maxLength: 32 },
{ path: "campDetail.sections.overview.intro", label: "Overview intro", maxLength: 240 },
{ path: "campDetail.sections.overview.mainText", label: "Overview main text", maxLength: 1000 },
{ path: "campDetail.sections.overview.featureImage", label: "Overview feature image", maxLength: 255 },
{ path: "campDetail.sections.overview.features.*", label: "Overview feature", maxLength: 120 },
{ path: "campDetail.sections.location.title", label: "Location section title", maxLength: 120 },
{ path: "campDetail.sections.location.description", label: "Location section description", maxLength: 1000 },
{ path: "campDetail.sections.accommodation.title", label: "Accommodation section title", maxLength: 120 },
{ path: "campDetail.sections.accommodation.quote", label: "Accommodation quote", maxLength: 120 },
{ path: "campDetail.sections.accommodation.mainHeading", label: "Accommodation main heading", maxLength: 120 },
{ path: "campDetail.sections.accommodation.description", label: "Accommodation description", maxLength: 1000 },
{ path: "campDetail.sections.program.title", label: "Program section title", maxLength: 120 },
{ path: "campDetail.sections.program.heading", label: "Program section heading", maxLength: 120 },
{ path: "campDetail.sections.program.quote", label: "Program quote", maxLength: 120 },
{ path: "campDetail.sections.program.description", label: "Program description", maxLength: 1000 },
{ path: "campDetail.sections.meals.title", label: "Meals section title", maxLength: 120 },
{ path: "campDetail.sections.meals.description", label: "Meals section description", maxLength: 1000 },
{ path: "campDetail.sections.meals.footer", label: "Meals footer text", maxLength: 500 },
{ path: "campDetail.sections.insurance.title", label: "Insurance section title", maxLength: 120 },
{ path: "campDetail.sections.insurance.description", label: "Insurance description", maxLength: 1000 },
{ path: "campDetail.sections.insurance.package.title", label: "Insurance package title", maxLength: 120 },
{ path: "campDetail.sections.insurance.package.desc", label: "Insurance package description", maxLength: 1000 },
{ path: "campDetail.sections.insurance.cancellation.title", label: "Cancellation title", maxLength: 120 },
{ path: "campDetail.sections.insurance.cancellation.desc", label: "Cancellation description", maxLength: 1000 },
{ path: "filters.*.label", label: "Filter label", maxLength: 64 },
{ path: "filters.*.value", label: "Filter value", maxLength: 64 },
{ path: "filters.*.items.*.label", label: "Filter item label", maxLength: 64 },
{ path: "filters.*.items.*.value", label: "Filter item value", maxLength: 64 },
{ path: "bookingSessions.*.sessionId", label: "Session ID", maxLength: 80 },
];
module.exports = {
ABOUT_US_LENGTH_RULES,
PRICING_LENGTH_RULES,
BOOKING_LENGTH_RULES,
ACTIVITY_LENGTH_RULES,
};

View File

@@ -1,11 +1,186 @@
const mongoose = require("mongoose");
const { addBaseUrlToImages } = require("../utils/imageHelper"); const { addBaseUrlToImages } = require("../utils/imageHelper");
const AboutUs = require("../models/aboutUs"); const AboutUs = require("../models/aboutUs");
const Blog = require("../models/blog"); const Blog = require("../models/blog");
const jsonHelper = require("../utils/jsonHelper"); const jsonHelper = require("../utils/jsonHelper");
const {
validateLengthRules,
summarizeLengthErrors,
} = require("../utils/lengthValidation");
const {
ABOUT_US_LENGTH_RULES,
} = require("../constants/contentLengthRules");
const writeAuditLog = require("../audit/writeAuditLog"); const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject"); const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction"); const AUDIT_ACTIONS = require("../constants/auditAction");
const ABOUT_NEWS_FALLBACK_THUMBNAIL = "/uploads/blog/7281.jpg";
const ABOUT_NEWS_FALLBACK_AVATAR = "/assets/img/home-1/news/client.png";
const ABOUT_NEWS_PLACEHOLDER_THUMBNAIL =
"img/inner-page/news-details/details-1.jpg";
const ABOUT_MISSION_ITEM_ICON = "/assets/img/home-1/icon/01.svg";
const normalizePath = (value) => {
if (!value || typeof value !== "string") return "";
if (value.startsWith("http://") || value.startsWith("https://")) return value;
if (value.startsWith("/")) return value;
return `/${value}`;
};
const isPlaceholderNewsThumbnail = (value) => {
if (!value || typeof value !== "string") return true;
const normalized = value.trim().toLowerCase();
return (
normalized === ABOUT_NEWS_PLACEHOLDER_THUMBNAIL ||
normalized === `/${ABOUT_NEWS_PLACEHOLDER_THUMBNAIL}` ||
normalized.endsWith("/inner-page/news-details/details-1.jpg") ||
normalized.endsWith("news-details/details-1.jpg")
);
};
const sanitizeAboutSection = (section = {}, allowedKeys = []) => {
const sanitized = {};
allowedKeys.forEach((key) => {
if (section[key] !== undefined) {
sanitized[key] = section[key];
}
});
return sanitized;
};
const sanitizeMissionItems = (items) => {
if (!Array.isArray(items)) return [];
return items
.map((item = {}) => ({
icon: ABOUT_MISSION_ITEM_ICON,
label:
typeof item.label === "string"
? item.label
: typeof item.title === "string"
? item.title
: "",
description: typeof item.description === "string" ? item.description : "",
}))
.filter((item) => item.label || item.description);
};
const buildCanonicalAboutData = (data) => {
const source = data || {};
return {
hero: sanitizeAboutSection(source.hero, [
"title",
"breadcrumb",
"backgroundImage",
]),
intro: sanitizeAboutSection(source.intro, [
"subheading",
"heading",
"description",
"image",
]),
mission: {
...sanitizeAboutSection(source.mission, [
"subheading",
"heading",
"description",
"features",
"ctaButton",
]),
items: sanitizeMissionItems(source.mission?.items),
images: sanitizeAboutSection(source.mission?.images, [
"main",
"secondary",
]),
},
features: {
...sanitizeAboutSection(source.features, [
"backgroundImage",
"subheading",
"heading",
"description",
"image",
"items",
"ctaButton",
]),
},
news: {
...sanitizeAboutSection(source.news, [
"subheading",
"heading",
"ctaButton",
"selectedBlogIds",
]),
items: Array.isArray(source.news?.items) ? source.news.items : [],
},
};
};
const resolveBlogImage = (value, fallback = ABOUT_NEWS_FALLBACK_THUMBNAIL) => {
if (!value || isPlaceholderNewsThumbnail(value)) {
return fallback;
}
return normalizePath(value);
};
const toObjectId = (value) => {
const stringValue = value && typeof value.toString === "function" ? value.toString() : "";
return mongoose.isValidObjectId(stringValue)
? new mongoose.Types.ObjectId(stringValue)
: null;
};
const resolveNewsItem = (blog, index = 0) => {
const fallbackThumbs = [
ABOUT_NEWS_FALLBACK_THUMBNAIL,
"/uploads/about/news-1.jpg",
"/uploads/about/news-3.jpg",
];
return {
title: blog.title,
category: blog.category && blog.category[0] ? blog.category[0] : "Visa",
date:
blog.publishedAt ||
new Date(blog.createdAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
}),
comments: blog.commentsCount || 0,
author: {
name: blog.author || "Admin",
avatar: ABOUT_NEWS_FALLBACK_AVATAR,
},
link: `/blog/${blog.slug}`,
thumbnail: resolveBlogImage(
blog.featuredImage,
fallbackThumbs[index % fallbackThumbs.length],
),
};
};
const handleLengthValidation = (validation, req, res, options = {}) => {
const message =
summarizeLengthErrors(validation, 3) || "One or more fields exceed the allowed length.";
if (options.json) {
return res.status(400).json({
success: false,
error: message,
errors: validation.errors,
});
}
req.flash("error_msg", message);
return res.redirect(options.redirectTo || "/admin/about-us");
};
/** /**
* GET /api/about * GET /api/about
* Lấy dữ liệu About Us (Public API cho website và CMS load dữ liệu) * Lấy dữ liệu About Us (Public API cho website và CMS load dữ liệu)
@@ -18,26 +193,49 @@ exports.getAbout = async (req, res) => {
res.setHeader("Expires", "0"); res.setHeader("Expires", "0");
const data = await AboutUs.getSingle(); const data = await AboutUs.getSingle();
const rawData = data.toObject(); const rawData = buildCanonicalAboutData(data.toObject());
// === Dynamic Blog News Section === // === Dynamic Blog News Section ===
const news = rawData.news || {}; const news = rawData.news || {};
const selectedBlogIds = Array.isArray(news.selectedBlogIds)
? news.selectedBlogIds.map((id) => id.toString())
: [];
const selectedObjectIds = selectedBlogIds
.map((id) => toObjectId(id))
.filter(Boolean);
let blogs = []; let blogs = [];
// Nếu có chọn blog cụ thể // Nếu có chọn blog cụ thể
if (news.selectedBlogIds && news.selectedBlogIds.length > 0) { if (selectedBlogIds.length > 0) {
blogs = await Blog.find({ const selectedQuery = await Blog.find({
_id: { $in: news.selectedBlogIds }, _id: { $in: selectedObjectIds },
status: "published", status: "published",
}).lean(); }).lean();
// Sắp xếp theo thứ tự đã chọn trong selectedBlogIds // Sắp xếp theo thứ tự đã chọn trong selectedBlogIds
blogs.sort((a, b) => { selectedQuery.sort((a, b) => {
return ( return (
news.selectedBlogIds.indexOf(a._id.toString()) - selectedBlogIds.indexOf(a._id.toString()) -
news.selectedBlogIds.indexOf(b._id.toString()) selectedBlogIds.indexOf(b._id.toString())
); );
}); });
blogs = selectedQuery.slice(0, 3);
if (blogs.length < 3) {
const missingCount = 3 - blogs.length;
const extraBlogs = await Blog.find({
status: "published",
_id: {
$nin: [...blogs.map((blog) => blog._id), ...selectedObjectIds],
},
})
.sort({ createdAt: -1 })
.limit(missingCount)
.lean();
blogs = [...blogs, ...extraBlogs];
}
} }
// Nếu không chọn hoặc chọn nhưng không đủ, lấy thêm 3 bài mới nhất // Nếu không chọn hoặc chọn nhưng không đủ, lấy thêm 3 bài mới nhất
@@ -49,24 +247,7 @@ exports.getAbout = async (req, res) => {
} }
// Map dữ liệu blog sang format mà frontend mong đợi // Map dữ liệu blog sang format mà frontend mong đợi
news.items = blogs.map((blog) => ({ news.items = blogs.slice(0, 3).map((blog, index) => resolveNewsItem(blog, index));
title: blog.title,
category: blog.category && blog.category[0] ? blog.category[0] : "Visa",
date:
blog.publishedAt ||
new Date(blog.createdAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
}),
comments: blog.commentsCount || 0,
author: {
name: blog.author || "Admin",
avatar: "/assets/img/home-1/news/client.png", // Default avatar
},
link: `/blog/${blog.slug}`,
thumbnail: blog.featuredImage,
}));
rawData.news = news; rawData.news = news;
// =============================== // ===============================
@@ -110,6 +291,13 @@ exports.updateAbout = async (req, res) => {
// ✅ Capture BEFORE state // ✅ Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(doc.toObject())); const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
updateData = buildCanonicalAboutData(updateData);
const validation = validateLengthRules(updateData, ABOUT_US_LENGTH_RULES);
if (!validation.valid) {
return handleLengthValidation(validation, req, res, { json: true });
}
// Use .set() for better handling of nested objects/arrays in Mongoose // Use .set() for better handling of nested objects/arrays in Mongoose
doc.set(updateData); doc.set(updateData);
await doc.save(); await doc.save();
@@ -137,16 +325,18 @@ exports.updateAbout = async (req, res) => {
} }
// Fetch fresh data for syncing and returning // Fetch fresh data for syncing and returning
const finalData = await AboutUs.findOne() const finalData = buildCanonicalAboutData(
await AboutUs.findOne()
.select("-_id -__v -createdAt -updatedAt") .select("-_id -__v -createdAt -updatedAt")
.lean(); .lean(),
);
// Update about.json file to keep it in sync // Update about.json file to keep it in sync
jsonHelper.writeJsonFile("about", finalData); jsonHelper.writeJsonFile("about", finalData);
res.json({ res.json({
success: true, success: true,
message: "About Us updated successfully", message: "About page updated successfully",
data: finalData, data: finalData,
}); });
} catch (error) { } catch (error) {
@@ -164,7 +354,7 @@ exports.updateAbout = async (req, res) => {
exports.index = async (req, res) => { exports.index = async (req, res) => {
try { try {
const data = await AboutUs.getSingle(); const data = await AboutUs.getSingle();
const rawData = data.toObject(); const rawData = buildCanonicalAboutData(data.toObject());
// Lấy tất cả blog để chọn trong CMS // Lấy tất cả blog để chọn trong CMS
const allBlogs = await Blog.find({ status: "published" }) const allBlogs = await Blog.find({ status: "published" })
@@ -174,7 +364,7 @@ exports.index = async (req, res) => {
const activeTab = req.query.activeTab || "hero"; const activeTab = req.query.activeTab || "hero";
res.render("admin/aboutUs/index", { res.render("admin/aboutUs/index", {
layout: "layouts/main", layout: "layouts/main",
title: "About Us Management", title: "About Page Management",
data: rawData, data: rawData,
allBlogs, allBlogs,
activeTab, activeTab,
@@ -185,7 +375,7 @@ exports.index = async (req, res) => {
}); });
} catch (err) { } catch (err) {
console.error("Error in about index:", err); console.error("Error in about index:", err);
req.flash("error_msg", "Error loading About Us page"); req.flash("error_msg", "Error loading About page");
res.redirect("/admin/dashboard"); res.redirect("/admin/dashboard");
} }
}; };
@@ -206,10 +396,18 @@ exports.update = async (req, res) => {
} }
const doc = await AboutUs.getSingle(); const doc = await AboutUs.getSingle();
updateData = buildCanonicalAboutData(updateData);
// ✅ Capture BEFORE state // ✅ Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(doc.toObject())); const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
const validation = validateLengthRules(updateData, ABOUT_US_LENGTH_RULES);
if (!validation.valid) {
return handleLengthValidation(validation, req, res, {
redirectTo: `/admin/about-us?activeTab=${req.query.activeTab || "hero"}`,
});
}
doc.set(updateData); doc.set(updateData);
await doc.save(); await doc.save();
@@ -230,17 +428,19 @@ exports.update = async (req, res) => {
}); });
} }
const finalData = await AboutUs.findOne() const finalData = buildCanonicalAboutData(
await AboutUs.findOne()
.select("-_id -__v -createdAt -updatedAt") .select("-_id -__v -createdAt -updatedAt")
.lean(); .lean(),
);
jsonHelper.writeJsonFile("about", finalData); jsonHelper.writeJsonFile("about", finalData);
req.flash("success_msg", "About Us updated successfully"); req.flash("success_msg", "About page updated successfully");
const activeTab = req.query.activeTab || "hero"; const activeTab = req.query.activeTab || "hero";
res.redirect(`/admin/about-us?activeTab=${activeTab}`); res.redirect(`/admin/about-us?activeTab=${activeTab}`);
} catch (err) { } catch (err) {
console.error("Update error:", err); console.error("Update error:", err);
req.flash("error_msg", "Error updating About Us: " + err.message); req.flash("error_msg", "Error updating About page: " + err.message);
res.redirect("/admin/about-us"); res.redirect("/admin/about-us");
} }
}; };

View File

@@ -1,6 +1,22 @@
const {addBaseUrlToImages} = require("../utils/imageHelper"); const {addBaseUrlToImages} = require("../utils/imageHelper");
const Activity = require("../models/activity"); const Activity = require("../models/activity");
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const {
validateLengthRules,
summarizeLengthErrors,
} = require("../utils/lengthValidation");
const {
ACTIVITY_LENGTH_RULES,
} = require("../constants/contentLengthRules");
const getActivityLengthMessage = (validation) =>
summarizeLengthErrors(validation, 3) ||
"One or more fields exceed the allowed length.";
const redirectWithLengthError = (req, res, path, validation) => {
req.flash("error_msg", getActivityLengthMessage(validation));
return req.session.save(() => res.redirect(path));
};
// -------------------- Public (API) exports -------------------- // -------------------- Public (API) exports --------------------
@@ -302,6 +318,15 @@ exports.updateFilters = async (req, res) => {
try { try {
// Provide minimal valid fields when inserting a new filters document so // Provide minimal valid fields when inserting a new filters document so
// schema validators (e.g., age validator) do not fail on upsert. // schema validators (e.g., age validator) do not fail on upsert.
const filterLengthValidation = validateLengthRules(
{ filters: sanitizedFilters },
ACTIVITY_LENGTH_RULES,
);
if (!filterLengthValidation.valid) {
req.flash("error_msg", getActivityLengthMessage(filterLengthValidation));
return res.redirect("/admin/activity");
}
const setOnInsert = { const setOnInsert = {
name: "_filters_doc", name: "_filters_doc",
price: 0, price: 0,
@@ -353,6 +378,14 @@ exports.updateHero = async (req, res) => {
bannerImageBooking: bannerImageBooking || '/uploads/banner/b9.jpg', bannerImageBooking: bannerImageBooking || '/uploads/banner/b9.jpg',
}; };
const heroLengthValidation = validateLengthRules(
{ hero },
ACTIVITY_LENGTH_RULES,
);
if (!heroLengthValidation.valid) {
return redirectWithLengthError(req, res, "/admin/activity", heroLengthValidation);
}
// Update all activity docs to keep hero consistent // Update all activity docs to keep hero consistent
await Activity.updateMany({ isFiltersDoc: { $ne: true } }, { $set: { hero } }); await Activity.updateMany({ isFiltersDoc: { $ne: true } }, { $set: { hero } });
@@ -413,6 +446,16 @@ exports.create = async (req, res) => {
try { try {
const activityData = parseActivityFormData(req.body); const activityData = parseActivityFormData(req.body);
const lengthValidation = validateLengthRules(activityData, ACTIVITY_LENGTH_RULES);
if (!lengthValidation.valid) {
return redirectWithLengthError(
req,
res,
"/admin/activity/create",
lengthValidation,
);
}
const newActivity = new Activity(activityData); const newActivity = new Activity(activityData);
await newActivity.save(); await newActivity.save();
@@ -465,6 +508,16 @@ exports.update = async (req, res) => {
// Force status to active on update (always set isActive true when editing) // Force status to active on update (always set isActive true when editing)
activityData.isActive = true; activityData.isActive = true;
const lengthValidation = validateLengthRules(activityData, ACTIVITY_LENGTH_RULES);
if (!lengthValidation.valid) {
return redirectWithLengthError(
req,
res,
`/admin/activity/${req.params.id}/edit`,
lengthValidation,
);
}
await Activity.findByIdAndUpdate(req.params.id, activityData, {new: true}); await Activity.findByIdAndUpdate(req.params.id, activityData, {new: true});
req.flash("success_msg", "Activity updated successfully"); req.flash("success_msg", "Activity updated successfully");

View File

@@ -2,6 +2,17 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { addBaseUrlToImages } = require("../utils/imageHelper"); const { addBaseUrlToImages } = require("../utils/imageHelper");
const Booking = require("../models/booking"); const Booking = require("../models/booking");
const {
validateLengthRules,
summarizeLengthErrors,
} = require("../utils/lengthValidation");
const {
BOOKING_LENGTH_RULES,
} = require("../constants/contentLengthRules");
const getBookingLengthMessage = (validation) =>
summarizeLengthErrors(validation, 3) ||
"One or more fields exceed the allowed length.";
// -------------------- Public helpers -------------------- // -------------------- Public helpers --------------------
const getBookingData = async () => { const getBookingData = async () => {
@@ -398,6 +409,12 @@ exports.update = async (req, res) => {
return req.session.save(() => res.redirect("/admin/booking")); return req.session.save(() => res.redirect("/admin/booking"));
} }
const lengthValidation = validateLengthRules(updateData, BOOKING_LENGTH_RULES);
if (!lengthValidation.valid) {
req.flash("error_msg", getBookingLengthMessage(lengthValidation));
return req.session.save(() => res.redirect("/admin/booking"));
}
// Validate data structure // Validate data structure
const validation = validateBookingData(updateData); const validation = validateBookingData(updateData);
if (!validation.isValid) { if (!validation.isValid) {

View File

@@ -4,6 +4,77 @@ const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject"); const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction"); const AUDIT_ACTIONS = require("../constants/auditAction");
const isPlainObject = (value) =>
value && typeof value === "object" && !Array.isArray(value);
const normalizeFooterImagePath = (value) => {
if (!value || typeof value !== "string") return "";
const trimmed = value.trim();
if (!trimmed) return "";
if (trimmed.startsWith("/uploads/footer/")) {
return trimmed;
}
if (trimmed.startsWith("uploads/footer/")) {
return `/${trimmed}`;
}
if (/^https?:\/\//i.test(trimmed)) {
try {
const parsed = new URL(trimmed);
if (parsed.pathname.startsWith("/uploads/footer/")) {
return parsed.pathname;
}
} catch (error) {
console.warn("Failed to parse footer image URL:", trimmed, error.message);
}
}
return trimmed;
};
const sanitizeFooterData = (data = {}) => {
const sanitized = JSON.parse(JSON.stringify(data || {}));
if (!sanitized.top) {
return sanitized;
}
if (typeof sanitized.top.bgImage === "string") {
sanitized.top.bgImage = normalizeFooterImagePath(sanitized.top.bgImage);
}
if (sanitized.top.logo && typeof sanitized.top.logo.src === "string") {
sanitized.top.logo.src = normalizeFooterImagePath(sanitized.top.logo.src);
}
return sanitized;
};
const mergeFooterData = (currentValue, incomingValue) => {
if (incomingValue === undefined) {
return currentValue;
}
if (Array.isArray(incomingValue)) {
return incomingValue;
}
if (isPlainObject(currentValue) && isPlainObject(incomingValue)) {
const merged = { ...currentValue };
Object.keys(incomingValue).forEach((key) => {
merged[key] = mergeFooterData(currentValue[key], incomingValue[key]);
});
return merged;
}
return incomingValue;
};
// GET /api/footer - Public API cho website và CMS load dữ liệu // GET /api/footer - Public API cho website và CMS load dữ liệu
exports.getFooter = async (req, res) => { exports.getFooter = async (req, res) => {
try { try {
@@ -42,6 +113,8 @@ exports.updateFooter = async (req, res) => {
} }
} }
updateData = sanitizeFooterData(updateData);
// Lấy footer hiện tại hoặc tạo mới (giống Header logic) // Lấy footer hiện tại hoặc tạo mới (giống Header logic)
let footer = await Footer.findOne(); let footer = await Footer.findOne();
@@ -52,8 +125,9 @@ exports.updateFooter = async (req, res) => {
console.log("✓ Footer created:", footer._id); console.log("✓ Footer created:", footer._id);
} else { } else {
console.log("✓ Found existing footer:", footer._id); console.log("✓ Found existing footer:", footer._id);
// Merge với dữ liệu cũ thay vì overwrite (giống Header) const mergedData = mergeFooterData(footer.toObject(), updateData);
Object.assign(footer, updateData); // Use set() so nested footer fields persist correctly in MongoDB
footer.set(mergedData);
await footer.save(); await footer.save();
console.log("✓ Footer updated successfully"); console.log("✓ Footer updated successfully");
} }
@@ -80,11 +154,10 @@ exports.updateFooter = async (req, res) => {
exports.index = async (req, res) => { exports.index = async (req, res) => {
try { try {
const data = await Footer.getSingle(); const data = await Footer.getSingle();
const processedData = addBaseUrlToImages(data.toObject());
res.render("admin/footer/index", { res.render("admin/footer/index", {
title: "Footer Management", title: "Footer Management",
data: processedData, data: data.toObject(),
}); });
} catch (error) { } catch (error) {
console.error("Error in footer index:", error); console.error("Error in footer index:", error);
@@ -114,6 +187,8 @@ exports.update = async (req, res) => {
} }
} }
updateData = sanitizeFooterData(updateData);
// Lấy footer hiện tại hoặc tạo mới (giống Header) // Lấy footer hiện tại hoặc tạo mới (giống Header)
let footer = await Footer.findOne(); let footer = await Footer.findOne();
@@ -130,8 +205,9 @@ exports.update = async (req, res) => {
req.flash("success_msg", "Footer created successfully"); req.flash("success_msg", "Footer created successfully");
} else { } else {
console.log("✓ Found existing footer:", footer._id); console.log("✓ Found existing footer:", footer._id);
// Merge với dữ liệu cũ (giống Header) const mergedData = mergeFooterData(footer.toObject(), updateData);
Object.assign(footer, updateData); // Use set() so nested footer fields persist correctly in MongoDB
footer.set(mergedData);
await footer.save(); await footer.save();
// ✅ Capture AFTER state // ✅ Capture AFTER state
@@ -166,4 +242,14 @@ exports.update = async (req, res) => {
// Legacy API endpoints (giữ lại cho tương thích) // Legacy API endpoints (giữ lại cho tương thích)
exports.api = exports.getFooter; exports.api = exports.getFooter;
exports.getFooterData = exports.getFooter; exports.getFooterData = async (req, res) => {
try {
const footer = await Footer.getSingle();
res.json(footer.toObject());
} catch (error) {
console.error("Error getting footer data for admin:", error);
res.status(500).json({
error: "Failed to get footer data",
});
}
};

View File

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

View File

@@ -42,6 +42,7 @@ exports.index = async (req, res) => {
socialLinks: header.top?.socialLinks || [], socialLinks: header.top?.socialLinks || [],
}, },
logo: header.logo?.light || "", logo: header.logo?.light || "",
header: header,
} }
: { : {
topbar: { topbar: {
@@ -53,6 +54,7 @@ exports.index = async (req, res) => {
socialLinks: [], socialLinks: [],
}, },
logo: "", logo: "",
header: null,
}; };
const activeTab = req.query.tab || "topbar"; const activeTab = req.query.tab || "topbar";
@@ -172,40 +174,28 @@ exports.update = async (req, res) => {
location: parsedData.contactInfo?.location || "", location: parsedData.contactInfo?.location || "",
socialLinks: parsedData.socialLinks || [], socialLinks: parsedData.socialLinks || [],
}; };
} catch (parseErr) {
if (logo) { console.error("✗ Error parsing topbarJson:", parseErr);
updateData.logo = logoData; }
} }
console.log( // Nếu có offcanvasJson, parse nó
"Preparing to update header with data:", const { offcanvasJson } = req.body;
JSON.stringify(updateData, null, 2), if (offcanvasJson && typeof offcanvasJson === "string") {
); try {
const parsedOffcanvas = JSON.parse(offcanvasJson);
const updatedHeader = await Header.findByIdAndUpdate( console.log("✓ Parsed offcanvasJson successfully:", parsedOffcanvas);
headerId, offcanvas = {
updateData, description: parsedOffcanvas.description || "",
{ new: true, runValidators: true }, contactInfo: {
); address: parsedOffcanvas.contactInfo?.address || "",
email: parsedOffcanvas.contactInfo?.email || "",
if (!updatedHeader) { workingHours: parsedOffcanvas.contactInfo?.workingHours || "",
console.error("✗ Header not found with ID:", headerId); phone: parsedOffcanvas.contactInfo?.phone || "",
return res.status(404).json({ },
success: false, };
message: "Header not found", } catch (parseErr) {
}); console.error("✗ Error parsing offcanvasJson:", parseErr);
}
res.json({
success: true,
message: "Header updated successfully",
data: updatedHeader,
});
} catch (error) {
console.error("✗ Error updating header:", error);
res.status(400).json({
success: false,
message: error.message,
});
} }
} }

View File

@@ -1,6 +1,36 @@
const HeaderMenu = require("../models/headerMenu"); const HeaderMenu = require("../models/headerMenu");
const slugify = require("slugify"); const slugify = require("slugify");
const parseBooleanFlag = (value) => {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return ["true", "1", "on", "yes"].includes(normalized);
}
return false;
};
const normalizeInternalUrl = (url = "") => {
if (typeof url !== "string") {
return null;
}
const trimmed = url.trim();
if (!trimmed || !trimmed.startsWith("/")) {
return null;
}
if (trimmed === "/") {
return "/";
}
return trimmed.replace(/\/+$/, "");
};
/** /**
* Helper: Build tree structure from flat array * Helper: Build tree structure from flat array
*/ */
@@ -19,8 +49,10 @@ const buildMenuTree = (items, parentId = null, isPublic = false) => {
cleanItem = { cleanItem = {
id: item._id, id: item._id,
title: item.title, title: item.title,
url: item.url, url: item.is_maintainance ? "/maintenance" : item.url,
originalUrl: item.url,
type: item.type, type: item.type,
is_maintainance: Boolean(item.is_maintainance),
}; };
} }
@@ -59,7 +91,7 @@ exports.store = async (req, res) => {
try { try {
console.log("=== BACKEND: store hit ==="); console.log("=== BACKEND: store hit ===");
console.log("Body:", req.body); console.log("Body:", req.body);
const { title, url, parentId, order, status, type } = req.body; const { title, url, parentId, order, status, type, is_maintainance } = req.body;
const slug = slugify(title, { lower: true, strict: true }); const slug = slugify(title, { lower: true, strict: true });
const newItem = new HeaderMenu({ const newItem = new HeaderMenu({
@@ -70,6 +102,7 @@ exports.store = async (req, res) => {
order: order || 0, order: order || 0,
status: status || "active", status: status || "active",
type: type || "internal", type: type || "internal",
is_maintainance: parseBooleanFlag(is_maintainance),
}); });
const savedItem = await newItem.save(); const savedItem = await newItem.save();
@@ -101,7 +134,7 @@ exports.update = async (req, res) => {
const { id } = req.params; const { id } = req.params;
console.log("=== BACKEND: update hit ===", { id }); console.log("=== BACKEND: update hit ===", { id });
console.log("Body:", req.body); console.log("Body:", req.body);
const { title, url, parentId, order, status, type } = req.body; const { title, url, parentId, order, status, type, is_maintainance } = req.body;
const updateData = { const updateData = {
url, url,
@@ -109,6 +142,7 @@ exports.update = async (req, res) => {
order, order,
status, status,
type, type,
is_maintainance: parseBooleanFlag(is_maintainance),
}; };
if (title) { if (title) {
@@ -203,3 +237,33 @@ exports.api = async (req, res) => {
res.status(500).json({ success: false, message: error.message }); res.status(500).json({ success: false, message: error.message });
} }
}; };
exports.maintenanceStatus = async (req, res) => {
try {
const items = await HeaderMenu.find({
status: "active",
is_maintainance: true,
})
.select("title url slug")
.sort({ order: 1 })
.lean();
const urls = [...new Set(items.map((item) => normalizeInternalUrl(item.url)).filter(Boolean))];
res.json({
success: true,
data: {
enabled: items.length > 0,
urls,
items: items.map((item) => ({
id: String(item._id),
title: item.title,
slug: item.slug,
url: item.url,
})),
},
});
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
};

View File

@@ -1,4 +1,7 @@
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper"); const {
addBaseUrlToImages,
getFullImageUrl,
} = require("../utils/imageHelper");
const Home = require("../models/home"); const Home = require("../models/home");
const Blog = require("../models/blog"); const Blog = require("../models/blog");
const writeAuditLog = require("../audit/writeAuditLog"); const writeAuditLog = require("../audit/writeAuditLog");
@@ -7,7 +10,210 @@ const AUDIT_ACTIONS = require("../constants/auditAction");
// Các hàm hỗ trợ // Các hàm hỗ trợ
const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 }); const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 });
const getAllHomeDocs = async () => Home.find().sort({ updatedAt: -1 });
const getHomeData = async () => (await getHomeDoc())?.toObject() || {}; const getHomeData = async () => (await getHomeDoc())?.toObject() || {};
const getOrCreateHomeDoc = async () => {
let doc = await getHomeDoc();
if (!doc) {
doc = new Home(getDefaultHomeData());
}
return doc;
};
const hasMeaningfulValue = (value) =>
typeof value === "string" && value.trim().length > 0;
const getMeaningfulHeroSlides = (hero = {}) => {
if (!Array.isArray(hero.slides)) return [];
return hero.slides.filter((slide = {}) => {
return (
hasMeaningfulValue(slide.title) ||
hasMeaningfulValue(slide.subtitle) ||
hasMeaningfulValue(slide.description) ||
hasMeaningfulValue(slide.heroImage) ||
hasMeaningfulValue(slide.videoUrl) ||
hasMeaningfulValue(slide.primaryButton?.label) ||
hasMeaningfulValue(slide.primaryButton?.href) ||
hasMeaningfulValue(slide.secondaryButton?.label) ||
hasMeaningfulValue(slide.secondaryButton?.href)
);
});
};
const scoreHeroSlides = (slides = []) =>
slides.reduce((score, slide = {}) => {
return (
score +
(slide.title || "").trim().length * 3 +
(slide.subtitle || "").trim().length +
(slide.description || "").trim().length * 2 +
(slide.primaryButton?.label || "").trim().length +
(slide.primaryButton?.href || "").trim().length +
(slide.secondaryButton?.label || "").trim().length +
(slide.secondaryButton?.href || "").trim().length +
(hasMeaningfulValue(slide.heroImage) ? 20 : 0) +
(hasMeaningfulValue(slide.videoUrl) ? 12 : 0)
);
}, 0);
const scoreHeroData = (hero = {}) => {
const slides = getMeaningfulHeroSlides(hero);
if (slides.length > 0) {
return scoreHeroSlides(slides) + slides.length * 30;
}
return (
(hero.title || "").trim().length * 3 +
(hero.subtitle || "").trim().length +
(hero.description || "").trim().length * 2 +
(hero.primaryButton?.label || "").trim().length +
(hero.primaryButton?.href || "").trim().length +
(hero.secondaryButton?.label || "").trim().length +
(hero.secondaryButton?.href || "").trim().length +
(hasMeaningfulValue(hero.heroImage) ? 20 : 0) +
(hasMeaningfulValue(hero.videoUrl) ? 12 : 0)
);
};
const getPreferredHeroData = (docs = []) => {
const heroes = docs
.map((doc) => doc?.hero)
.filter(Boolean);
if (!heroes.length) return {};
return heroes.reduce((bestHero, currentHero) => {
return scoreHeroData(currentHero) > scoreHeroData(bestHero)
? currentHero
: bestHero;
}, heroes[0]);
};
const normalizeStoredImagePath = (imagePath) => {
if (!imagePath || typeof imagePath !== "string") return "";
const raw = imagePath.trim();
if (!raw) return "";
const knownPrefixes = ["/uploads/", "/assets/", "/img/"];
for (const prefix of knownPrefixes) {
const index = raw.indexOf(prefix);
if (index >= 0) {
return raw.slice(index);
}
}
if (raw.startsWith("/")) {
return raw;
}
return `/${raw}`;
};
const getDefaultFloatingContactData = () => ({
enabled: true,
position: "bottom-right",
panelTitle: "Do you need any additional advice or support?",
brand: {
imageSrc: "/assets/img/logo/black-logo.svg",
imageAlt: "HAI Learning",
},
trigger: {
imageSrc: "",
icon: "fa-comments",
},
actions: [
{
id: "facebook",
platform: "facebook",
enabled: true,
label: "Message via Facebook",
subtitle: "facebook.com/hailearning.edu.vn",
href: "https://www.facebook.com/hailearning.edu.vn/",
iconImage: "/uploads/home/floating-contact/Facebook_Logo_Primary.webp",
iconType: "iconClass",
iconClass: "fa-brands fa-facebook-messenger",
iconText: "",
order: 1,
},
{
id: "zalo",
platform: "zalo",
enabled: true,
label: "Message via Zalo",
subtitle: "zalo.me/84961834040",
href: "https://zalo.me/84961834040",
iconImage: "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp",
iconType: "iconText",
iconClass: "",
iconText: "Zalo",
order: 2,
},
],
});
const normalizeFloatingContactData = (payload = {}) => {
const defaults = getDefaultFloatingContactData();
const brand = payload.brand || {};
const trigger = payload.trigger || {};
const hasProvidedActions = Array.isArray(payload.actions);
const rawActions = hasProvidedActions ? payload.actions : [];
const actions = rawActions
.map((action, index) => ({
id: String(action.id || `${action.platform || "action"}-${index + 1}`),
platform: String(action.platform || "").trim(),
enabled: action.enabled !== false,
label: String(action.label || "").trim(),
subtitle: String(action.subtitle || "").trim(),
href: String(action.href || "").trim(),
iconImage: normalizeStoredImagePath(String(action.iconImage || "").trim()),
iconType: action.iconType === "iconText" ? "iconText" : "iconClass",
iconClass: String(action.iconClass || "").trim(),
iconText: String(action.iconText || "").trim(),
order: Number.isFinite(Number(action.order)) ? Number(action.order) : index + 1,
}))
.filter((action) => {
return (
action.platform ||
action.label ||
action.subtitle ||
action.href ||
action.iconImage ||
action.iconClass ||
action.iconText
);
})
.sort((a, b) => a.order - b.order)
.map((action, index) => ({
...action,
order: index + 1,
}));
return {
enabled: payload.enabled !== false,
position: payload.position || defaults.position,
panelTitle: String(payload.panelTitle || defaults.panelTitle).trim(),
brand: {
imageSrc: normalizeStoredImagePath(
String(brand.imageSrc || defaults.brand.imageSrc).trim(),
),
imageAlt: String(brand.imageAlt || defaults.brand.imageAlt).trim(),
},
trigger: {
imageSrc: normalizeStoredImagePath(
String(trigger.imageSrc || "").trim(),
),
icon: String(trigger.icon || defaults.trigger.icon).trim() || defaults.trigger.icon,
},
actions: hasProvidedActions ? actions : defaults.actions,
};
};
const getDefaultHomeData = () => ({ const getDefaultHomeData = () => ({
hero: { hero: {
@@ -64,6 +270,7 @@ const getDefaultHomeData = () => ({
items: [], items: [],
selectedBlogIds: [], // Array of manually selected blog IDs selectedBlogIds: [], // Array of manually selected blog IDs
}, },
floatingContact: getDefaultFloatingContactData(),
}); });
// Admin: Xem trang quản lý // Admin: Xem trang quản lý
@@ -77,9 +284,10 @@ exports.index = async (req, res) => {
sections.forEach((s) => { sections.forEach((s) => {
data[s] = data[s] || defaults[s]; data[s] = data[s] || defaults[s];
}); });
data.floatingContact = normalizeFloatingContactData(data.floatingContact);
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001"; const backendUrl = `${req.protocol}://${req.get("host")}`;
// Lấy tất cả blog để chọn trong CMS // Lấy tất cả blog để chọn trong CMS
const allBlogs = await Blog.find({ status: "published" }) const allBlogs = await Blog.find({ status: "published" })
@@ -118,6 +326,7 @@ exports.update = async (req, res) => {
"achievements", "achievements",
"partners", "partners",
"blogPreview", "blogPreview",
"floatingContact",
]; ];
let doc = await getHomeDoc(); let doc = await getHomeDoc();
@@ -135,7 +344,10 @@ exports.update = async (req, res) => {
try { try {
const payload = JSON.parse(req.body[section]); const payload = JSON.parse(req.body[section]);
// Gán trực tiếp vào doc, Mongoose sẽ tự check schema // Gán trực tiếp vào doc, Mongoose sẽ tự check schema
doc[section] = payload; doc[section] =
section === "floatingContact"
? normalizeFloatingContactData(payload)
: payload;
doc.markModified(section); doc.markModified(section);
hasChanges = true; hasChanges = true;
updatedSections.push(section); updatedSections.push(section);
@@ -176,6 +388,49 @@ exports.update = async (req, res) => {
} }
}; };
exports.updateFloatingContact = async (req, res) => {
try {
const payload =
typeof req.body?.floatingContact === "string"
? JSON.parse(req.body.floatingContact)
: req.body?.floatingContact || req.body;
const doc = await getOrCreateHomeDoc();
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
doc.floatingContact = normalizeFloatingContactData(payload);
doc.markModified("floatingContact");
await doc.save();
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Home",
documentId: doc._id,
action: AUDIT_ACTIONS.UPDATE_HOME,
before: beforeData,
after: afterData,
changes,
req,
});
}
return res.status(200).json({
success: true,
message: "Floating contact updated successfully",
floatingContact: doc.floatingContact,
});
} catch (err) {
console.error("Floating contact update error:", err);
return res.status(500).json({
success: false,
error: err.message || "Failed to update floating contact",
});
}
};
// Public API// API lấy danh sách blog cho CMS // Public API// API lấy danh sách blog cho CMS
exports.apiGetBlogs = async (req, res) => { exports.apiGetBlogs = async (req, res) => {
try { try {
@@ -190,9 +445,10 @@ exports.apiGetBlogs = async (req, res) => {
}; };
exports.api = async (req, res) => { exports.api = async (req, res) => {
try { try {
let data = await getHomeData(); // Chỉ dùng doc mới nhất, không merge nhiều docs
const baseUrl = const doc = await getHomeDoc();
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`; let data = doc?.toObject() || {};
const baseUrl = `${req.protocol}://${req.get("host")}`;
// === Xử lý Blog Preview động === // === Xử lý Blog Preview động ===
const blogPreview = data.blogPreview || {}; const blogPreview = data.blogPreview || {};
@@ -238,6 +494,7 @@ exports.api = async (req, res) => {
})); }));
data.blogPreview = blogPreview; data.blogPreview = blogPreview;
data.floatingContact = normalizeFloatingContactData(data.floatingContact);
// =============================== // ===============================
const processed = addBaseUrlToImages(data, baseUrl); const processed = addBaseUrlToImages(data, baseUrl);

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
const { getServiceData } = require("../services/service.service"); const { getServiceData } = require("../services/service.service");
const Service = require("../models/service"); const Service = require("../models/service");
const syncServiceMenu = require("../services/syncServiceMenu");
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper"); const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
const writeAuditLog = require("../audit/writeAuditLog"); const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject"); const diffObject = require("../audit/diffObject");
@@ -98,6 +99,8 @@ exports.updateService = async (req, res) => {
changes, changes,
req, req,
}); });
// Sync header menu children to reflect updated service name/slug
await syncServiceMenu(updatedData.services?.items || []);
req.flash("success_msg", "Service updated successfully"); req.flash("success_msg", "Service updated successfully");
res.redirect("/admin/service"); res.redirect("/admin/service");
} catch (err) { } catch (err) {
@@ -168,6 +171,9 @@ exports.update = async (req, res) => {
await Service.create(updatedData); await Service.create(updatedData);
} }
// Sync header menu children to reflect current service list
await syncServiceMenu(updatedData.services?.items || []);
req.flash("success_msg", "Service updated successfully"); req.flash("success_msg", "Service updated successfully");
res.redirect("/admin/service"); res.redirect("/admin/service");
} catch (err) { } catch (err) {

View File

@@ -7,11 +7,11 @@ exports.getSettings = async (req, res) => {
const content = readJsonFile('content'); const content = readJsonFile('content');
const settings = content.settings || { const settings = content.settings || {
siteName: 'CMS-SIMS', siteName: 'CMS-SIMS',
description: 'Hệ thống quản lý nội dung đơn giản' description: 'Simple content management system'
}; };
res.render('admin/settings', { res.render('admin/settings', {
title: 'Cài đặt hệ thống', title: 'System Settings',
settings settings
}); });
} catch (err) { } catch (err) {

View File

@@ -1,7 +1,102 @@
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const sharp = require('sharp');
const jsonHelper = require('../utils/jsonHelper'); const jsonHelper = require('../utils/jsonHelper');
const imageResizePresets = {
floatingContactBrandImage: { width: 104, height: 104, quality: 88 },
floatingContactTriggerIcon: { width: 96, height: 96, quality: 88 },
floatingContactActionIcon: { width: 84, height: 84, quality: 88 },
};
const isSvgFile = (filePath) => path.extname(filePath).toLowerCase() === '.svg';
function scheduleTemporaryFileCleanup(filePath, attemptsLeft = 5, delayMs = 250) {
if (!filePath || attemptsLeft <= 0) {
return;
}
setTimeout(() => {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (cleanupError) {
scheduleTemporaryFileCleanup(filePath, attemptsLeft - 1, delayMs * 2);
}
}, delayMs);
}
function getFinalUploadTarget(file, req, forceWebp = false) {
const parsedPath = path.parse(file.path);
const requestedFileName =
req.uploadFinalFileName ||
file.filename.replace('.__upload__', '');
const parsedRequestedFileName = path.parse(requestedFileName);
const finalFileName = forceWebp
? `${parsedRequestedFileName.name}.webp`
: requestedFileName;
const finalDirectory = req.uploadFinalDirectory || parsedPath.dir;
return {
finalFileName,
finalPath: path.join(finalDirectory, finalFileName),
};
}
async function finalizeUploadedImage(file, req, resizePreset) {
const preset = imageResizePresets[resizePreset];
if (!file) {
return file;
}
if (!preset || isSvgFile(file.path)) {
const { finalFileName, finalPath } = getFinalUploadTarget(file, req);
if (path.resolve(file.path) !== path.resolve(finalPath)) {
fs.renameSync(file.path, finalPath);
}
return {
...file,
filename: finalFileName,
path: finalPath,
};
}
const { finalFileName, finalPath } = getFinalUploadTarget(file, req, true);
const finalPathMatchesInput = path.resolve(file.path) === path.resolve(finalPath);
const sourceBuffer = fs.readFileSync(file.path);
const optimizedBuffer = await sharp(sourceBuffer)
.resize(preset.width, preset.height, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
withoutEnlargement: true,
})
.webp({ quality: preset.quality })
.toBuffer();
fs.writeFileSync(finalPath, optimizedBuffer);
if (!finalPathMatchesInput && fs.existsSync(file.path)) {
try {
fs.unlinkSync(file.path);
} catch (cleanupError) {
console.warn('Unable to remove original uploaded image after optimization:', cleanupError.message);
scheduleTemporaryFileCleanup(file.path);
}
}
return {
...file,
filename: finalFileName,
path: finalPath,
mimetype: 'image/webp',
};
}
// Controller xử lý upload ảnh // Controller xử lý upload ảnh
const uploadController = { const uploadController = {
// Upload ảnh và trả về đường dẫn // Upload ảnh và trả về đường dẫn
@@ -13,15 +108,14 @@ const uploadController = {
// Lấy loại ảnh từ query params // Lấy loại ảnh từ query params
const imageType = req.query.imageType || 'general'; const imageType = req.query.imageType || 'general';
const resizePreset = req.query.resizePreset || '';
req.file = await finalizeUploadedImage(req.file, req, resizePreset);
// Tạo đường dẫn tương đối để lưu vào database // Tạo đường dẫn tương đối để lưu vào database
const relativePath = `/uploads/${imageType}/${req.file.filename}`; const relativePath = `/uploads/${imageType}/${req.file.filename}`;
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const fullUrl = `${baseUrl}${relativePath}`; const fullUrl = `${baseUrl}${relativePath}`;
// Kiểm tra nếu file đã tồn tại từ trước
const fileAlreadyExists = req.fileAlreadyExists || false;
// Nếu client yêu cầu cập nhật trực tiếp file JSON (ví dụ activities.json), // Nếu client yêu cầu cập nhật trực tiếp file JSON (ví dụ activities.json),
// thì đồng bộ camps.image và camps.camp-detail.hero.bgImage // thì đồng bộ camps.image và camps.camp-detail.hero.bgImage
try { try {
@@ -60,8 +154,9 @@ const uploadController = {
success: true, success: true,
path: relativePath, path: relativePath,
url: fullUrl, url: fullUrl,
reused: fileAlreadyExists, resizePreset: resizePreset || null,
message: fileAlreadyExists ? 'Using existing file' : 'File uploaded successfully' reused: false,
message: 'File uploaded successfully'
}); });
} catch (error) { } catch (error) {
console.error('Error uploading image:', error); console.error('Error uploading image:', error);

View File

@@ -5,72 +5,68 @@
"Home", "Home",
"About Us" "About Us"
], ],
"backgroundImage": "/uploads/about/breadcrumb.jpg" "backgroundImage": "/uploads/about/7281.jpg"
}, },
"intro": { "intro": {
"subheading": "Company Intro", "subheading": "Company Intro",
"heading": "Building Pathways to Your Immigration Success", "heading": "Building Pathways to Your Immigration Success",
"description": "We provide expert guidance, personalized solutions, and transparent processes to help you achieve your immigration goals. Our dedicated team ensures a smooth journey, building pathways to your international success.", "description": "We provide expert guidance, personalized solutions, and transparent processes to help you achieve your immigration goals. Our dedicated team ensures a smooth journey, building pathways to your international success.",
"image": "/uploads/about/businessman.jpg" "image": "/uploads/about/7281.jpg"
}, },
"mission": { "mission": {
"subheading": "About Our Consultancy", "subheading": "About Our ConsultancyAbout Our Consultan",
"heading": "Turning Study Abroad Dreams Into Reality", "heading": "Turning Study Abroad Dreams Into RealityTurning Study Abroad Dreams Into",
"description": "We guide students with expert visa consulting, ensuring a smooth process from application to approval, turning study abroad aspirations into life-changing opportunities for a brighter future.", "description": "We guide students with expert visa consulting, ensuring a smooth process from application to approval, turning study abroad aspirations into life-changing opportunities for a brighter future.",
"images": {
"main": "/uploads/about/375x419.jpg",
"secondary": "/uploads/about/375x419.jpg",
"bgShape": "/assets/img/home-1/about/Vector.png",
"planeShape": "/assets/img/home-1/about/plane.png",
"topShape": "/assets/img/home-1/about/shape.png",
"globeShape": "/assets/img/home-1/about/globe.png"
},
"items": [
{
"icon": "/assets/img/home-1/icon/01.svg",
"label": "Global Reach",
"description": "Expanding Opportunities Worldwide"
},
{
"icon": "/assets/img/home-1/icon/01.svg",
"label": "Global Reach",
"description": "Expanding Opportunities Worldwide"
}
],
"features": [ "features": [
"Fastest Visa form processing with skilled immigration agents", "Fastest Visa form processing with skilled immigration agents",
"Partnership with International Educational Institutions" "Partnership with International Educational Institutions"
], ],
"ctaButton": { "ctaButton": {
"label": "Get Started", "label": "Get StartedGet StartedGet Starte",
"href": "/about" "href": "/about"
},
"items": [
{
"icon": "/assets/img/home-1/icon/01.svg",
"label": "Global Reach",
"description": "Expanding Opportunities Worldwide"
},
{
"icon": "/assets/img/home-1/icon/01.svg",
"label": "Global Reach",
"description": "Expanding Opportunities Worldwide"
}
],
"images": {
"main": "/uploads/about/7281.jpg",
"secondary": "/uploads/about/7281.jpg"
} }
}, },
"features": { "features": {
"backgroundImage": "/assets/img/home-2/feature/bg-shape.png", "backgroundImage": "/uploads/about/7281.jpg",
"subheading": "Your Travel Made Easy", "subheading": "Your Travel Made EasyYour Travel Made Ea",
"heading": "Smooth Visa Journey Guaranteed", "heading": "Smooth Visa Journey GuaranteedSmooth Visa Journey GuaranteedSmooth Visa",
"description": "We provide expert guidance for every visa application, ensuring smooth processing, personalized support, and reliable assistance", "description": "We provide expert guidance for every visa application, ensuring smooth processing, personalized support, and reliable assistanceWe provide expert guidance for every visa application, ensuring smooth processing, personalized support, and reliable assistanceWe p",
"image": "/uploads/about/686x906.jpg", "image": "/uploads/about/7281.jpg",
"items": [ "items": [
{ {
"icon": "/assets/img/home-2/icon/01.png", "icon": "/uploads/about/7281.jpg",
"title": "Expert Consultants", "title": "Expert Consultants",
"description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors." "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa"
}, },
{ {
"icon": "/assets/img/home-2/icon/01.png", "icon": "/uploads/about/7281.jpg",
"title": "Personalized Support", "title": "Personalized Support",
"description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors." "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa"
}, },
{ {
"icon": "/assets/img/home-2/icon/01.png", "icon": "/uploads/about/7281.jpg",
"title": "Transparent Process", "title": "Transparent Process",
"description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors." "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa"
} }
], ],
"ctaButton": { "ctaButton": {
"label": "Get Started Today", "label": "Get Started TodayGet Started Tod",
"href": "/contact" "href": "/contact"
} }
}, },
@@ -82,9 +78,9 @@
"href": "/blog" "href": "/blog"
}, },
"selectedBlogIds": [ "selectedBlogIds": [
"69857d6c6d04fed459107944", "69d660e9f399cedf10d3b216",
"69857d6c6d04fed459107942", "69d660e9f399cedf10d3b214",
"69857d6c6d04fed459107940" "69d660e9f399cedf10d3b212"
], ],
"items": [] "items": []
} }

View File

@@ -34,6 +34,14 @@ const storage = multer.diskStorage({
// Lấy tên file gốc (sanitize để tránh ký tự đặc biệt) // Lấy tên file gốc (sanitize để tránh ký tự đặc biệt)
const originalName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); const originalName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
const resizePreset = req.query.resizePreset || '';
if (resizePreset) {
const parsedOriginalName = path.parse(originalName);
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
req.uploadFinalFileName = originalName;
return cb(null, `${parsedOriginalName.name}.__upload__${uniqueSuffix}${parsedOriginalName.ext}`);
}
const fullPath = path.join(uploadPath, originalName); const fullPath = path.join(uploadPath, originalName);
// Kiểm tra nếu file đã tồn tại // Kiểm tra nếu file đã tồn tại

View File

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

View File

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

View File

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

View File

@@ -7,11 +7,13 @@ const heroSchema = new mongoose.Schema(
type: String, type: String,
required: true, required: true,
trim: true, trim: true,
maxlength: 40,
}, },
backgroundImage: { backgroundImage: {
type: String, type: String,
trim: true, trim: true,
default: "", default: "",
maxlength: 255,
}, },
overlayColor: { overlayColor: {
type: String, type: String,
@@ -62,9 +64,10 @@ const contactCardSchema = new mongoose.Schema(
type: String, type: String,
required: true, required: true,
trim: true, trim: true,
maxlength: 40,
}, },
content: { content: {
type: [String], type: [{ type: String, maxlength: 96 }],
default: [], default: [],
}, },
iconType: { iconType: {
@@ -72,6 +75,7 @@ const contactCardSchema = new mongoose.Schema(
required: false, required: false,
trim: true, trim: true,
default: "", default: "",
maxlength: 255,
}, },
iconSource: { iconSource: {
type: String, type: String,
@@ -139,16 +143,19 @@ const mapSchema = new mongoose.Schema(
type: String, type: String,
required: true, required: true,
trim: true, trim: true,
maxlength: 120,
}, },
markerTitle: { markerTitle: {
type: String, type: String,
trim: true, trim: true,
default: "", default: "",
maxlength: 48,
}, },
embedUrl: { embedUrl: {
type: String, type: String,
trim: true, trim: true,
default: "", default: "",
maxlength: 1000,
}, },
tileLayer: { tileLayer: {
type: tileLayerSchema, type: tileLayerSchema,
@@ -165,11 +172,13 @@ const formFieldSchema = new mongoose.Schema(
type: String, type: String,
required: true, required: true,
trim: true, trim: true,
maxlength: 32,
}, },
label: { label: {
type: String, type: String,
trim: true, trim: true,
default: "", default: "",
maxlength: 32,
}, },
type: { type: {
type: String, type: String,
@@ -181,6 +190,7 @@ const formFieldSchema = new mongoose.Schema(
type: String, type: String,
trim: true, trim: true,
default: "", default: "",
maxlength: 72,
}, },
required: { required: {
type: Boolean, type: Boolean,
@@ -195,6 +205,7 @@ const formFieldSchema = new mongoose.Schema(
type: String, type: String,
trim: true, trim: true,
default: "", default: "",
maxlength: 48,
}, },
}, },
{ _id: false } { _id: false }
@@ -207,6 +218,7 @@ const submitButtonSchema = new mongoose.Schema(
type: String, type: String,
required: true, required: true,
trim: true, trim: true,
maxlength: 24,
}, },
icon: { icon: {
type: String, type: String,
@@ -229,16 +241,19 @@ const formSchema = new mongoose.Schema(
type: String, type: String,
trim: true, trim: true,
default: "", default: "",
maxlength: 32,
}, },
heading: { heading: {
type: String, type: String,
trim: true, trim: true,
default: "", default: "",
maxlength: 48,
}, },
description: { description: {
type: String, type: String,
trim: true, trim: true,
default: "", default: "",
maxlength: 160,
}, },
fields: { fields: {
type: [formFieldSchema], type: [formFieldSchema],

View File

@@ -31,6 +31,10 @@ const HeaderMenuSchema = new mongoose.Schema({
enum: ['active', 'inactive'], enum: ['active', 'inactive'],
default: 'active' default: 'active'
}, },
is_maintainance: {
type: Boolean,
default: false
},
type: { type: {
type: String, type: String,
enum: ['internal', 'external'], enum: ['internal', 'external'],
@@ -43,6 +47,7 @@ const HeaderMenuSchema = new mongoose.Schema({
// Indexes for optimization // Indexes for optimization
HeaderMenuSchema.index({ order: 1 }); HeaderMenuSchema.index({ order: 1 });
HeaderMenuSchema.index({ status: 1 }); HeaderMenuSchema.index({ status: 1 });
HeaderMenuSchema.index({ is_maintainance: 1 });
HeaderMenuSchema.index({ parentId: 1, order: 1 }); HeaderMenuSchema.index({ parentId: 1, order: 1 });
module.exports = mongoose.model('HeaderMenu', HeaderMenuSchema); module.exports = mongoose.model('HeaderMenu', HeaderMenuSchema);

View File

@@ -5,8 +5,53 @@ const { Schema } = mongoose;
// Reusable small schemas // Reusable small schemas
const LinkSchema = new Schema( const LinkSchema = new Schema(
{ {
label: { type: String, default: "" }, label: { type: String, default: "", maxlength: 32 },
href: { type: String, default: "" }, href: { type: String, default: "", maxlength: 255 },
},
{ _id: false },
);
const FloatingContactBrandSchema = new Schema(
{
imageSrc: { type: String, default: "", maxlength: 255 },
imageAlt: { type: String, default: "", maxlength: 60 },
},
{ _id: false },
);
const FloatingContactTriggerSchema = new Schema(
{
imageSrc: { type: String, default: "", maxlength: 255 },
icon: { type: String, default: "fa-comments", maxlength: 64 },
},
{ _id: false },
);
const FloatingContactActionSchema = new Schema(
{
id: { type: String, default: "" },
platform: { type: String, default: "" },
enabled: { type: Boolean, default: true },
label: { type: String, default: "", maxlength: 48 },
subtitle: { type: String, default: "", maxlength: 48 },
href: { type: String, default: "", maxlength: 255 },
iconImage: { type: String, default: "", maxlength: 255 },
iconType: { type: String, default: "iconClass" },
iconClass: { type: String, default: "", maxlength: 120 },
iconText: { type: String, default: "", maxlength: 12 },
order: { type: Number, default: 0 },
},
{ _id: false },
);
const FloatingContactSchema = new Schema(
{
enabled: { type: Boolean, default: true },
position: { type: String, default: "bottom-right" },
panelTitle: { type: String, default: "", maxlength: 72 },
brand: { type: FloatingContactBrandSchema, default: () => ({}) },
trigger: { type: FloatingContactTriggerSchema, default: () => ({}) },
actions: { type: [FloatingContactActionSchema], default: [] },
}, },
{ _id: false }, { _id: false },
); );
@@ -14,48 +59,54 @@ const LinkSchema = new Schema(
// Hero slide (for multiple hero items in slider) // Hero slide (for multiple hero items in slider)
const HeroSlideSchema = new Schema( const HeroSlideSchema = new Schema(
{ {
title: { type: String, default: "" }, title: { type: String, default: "", maxlength: 72 },
subtitle: { type: String, default: "" }, subtitle: { type: String, default: "", maxlength: 48 },
description: { type: String, default: "" }, description: { type: String, default: "", maxlength: 220 },
primaryButton: { type: LinkSchema, default: () => ({}) }, primaryButton: { type: LinkSchema, default: () => ({}) },
secondaryButton: { type: LinkSchema, default: () => ({}) }, secondaryButton: { type: LinkSchema, default: () => ({}) },
heroImage: { type: String, default: "" }, heroImage: { type: String, default: "", maxlength: 255 },
videoUrl: { type: String, default: "" }, videoUrl: { type: String, default: "", maxlength: 255 },
}, },
{ _id: false }, { _id: false },
); );
const HeroSchema = new Schema( const HeroSchema = new Schema(
{ {
// Toggle visibility on frontend
enabled: { type: Boolean, default: true },
// Background for whole hero section // Background for whole hero section
backgroundImage: { type: String, default: "" }, backgroundImage: { type: String, default: "", maxlength: 255 },
// Multiple slides // Multiple slides
slides: { type: [HeroSlideSchema], default: [] }, slides: { type: [HeroSlideSchema], default: [] },
// Legacy single-slide fields (backward compatible) // Legacy single-slide fields (backward compatible)
title: { type: String, default: "" }, title: { type: String, default: "", maxlength: 72 },
subtitle: { type: String, default: "" }, subtitle: { type: String, default: "", maxlength: 48 },
description: { type: String, default: "" }, description: { type: String, default: "", maxlength: 220 },
primaryButton: { type: LinkSchema, default: () => ({}) }, primaryButton: { type: LinkSchema, default: () => ({}) },
secondaryButton: { type: LinkSchema, default: () => ({}) }, secondaryButton: { type: LinkSchema, default: () => ({}) },
heroImage: { type: String, default: "" }, heroImage: { type: String, default: "", maxlength: 255 },
videoUrl: { type: String, default: "" }, videoUrl: { type: String, default: "", maxlength: 255 },
}, },
{ _id: false }, { _id: false },
); );
const WhyChooseUsItemSchema = new Schema( const WhyChooseUsItemSchema = new Schema(
{ {
icon: { type: String, default: "" }, icon: { type: String, default: "", maxlength: 255 },
title: { type: String, default: "" }, title: { type: String, default: "", maxlength: 40 },
description: { type: String, default: "" }, description: { type: String, default: "", maxlength: 72 },
}, },
{ _id: false }, { _id: false },
); );
const WhyChooseUsSchema = new Schema( const WhyChooseUsSchema = new Schema(
{ {
// Toggle visibility on frontend
enabled: { type: Boolean, default: true },
heading: { type: String, default: "" }, heading: { type: String, default: "" },
subheading: { type: String, default: "" }, subheading: { type: String, default: "" },
description: { type: String, default: "" }, description: { type: String, default: "" },
@@ -63,7 +114,7 @@ const WhyChooseUsSchema = new Schema(
mainImage: { type: String, default: "" }, mainImage: { type: String, default: "" },
secondaryImage: { type: String, default: "" }, secondaryImage: { type: String, default: "" },
items: { type: [WhyChooseUsItemSchema], default: [] }, items: { type: [WhyChooseUsItemSchema], default: [] },
features: { type: [String], default: [] }, features: { type: [{ type: String, maxlength: 96 }], default: [] },
ctaButton: { type: LinkSchema, default: () => ({}) }, ctaButton: { type: LinkSchema, default: () => ({}) },
}, },
{ _id: false }, { _id: false },
@@ -71,16 +122,19 @@ const WhyChooseUsSchema = new Schema(
const VisaSolutionItemSchema = new Schema( const VisaSolutionItemSchema = new Schema(
{ {
number: { type: String, default: "" }, number: { type: String, default: "", maxlength: 4 },
title: { type: String, default: "" }, title: { type: String, default: "", maxlength: 56 },
description: { type: String, default: "" }, description: { type: String, default: "", maxlength: 180 },
link: { type: String, default: "" }, link: { type: String, default: "", maxlength: 255 },
}, },
{ _id: false }, { _id: false },
); );
const VisaSolutionsSchema = new Schema( const VisaSolutionsSchema = new Schema(
{ {
// Toggle visibility on frontend
enabled: { type: Boolean, default: true },
heading: { type: String, default: "" }, heading: { type: String, default: "" },
subheading: { type: String, default: "" }, subheading: { type: String, default: "" },
items: { type: [VisaSolutionItemSchema], default: [] }, items: { type: [VisaSolutionItemSchema], default: [] },
@@ -90,17 +144,20 @@ const VisaSolutionsSchema = new Schema(
const VisaCountrySchema = new Schema( const VisaCountrySchema = new Schema(
{ {
name: { type: String, default: "" }, name: { type: String, default: "", maxlength: 40 },
code: { type: String, default: "" }, code: { type: String, default: "", maxlength: 12 },
flag: { type: String, default: "" }, flag: { type: String, default: "", maxlength: 255 },
link: { type: String, default: "" }, link: { type: String, default: "", maxlength: 255 },
visaTypes: { type: [String], default: [] }, visaTypes: { type: [{ type: String, maxlength: 48 }], default: [] },
}, },
{ _id: false }, { _id: false },
); );
const VisaCountriesSchema = new Schema( const VisaCountriesSchema = new Schema(
{ {
// Toggle visibility on frontend
enabled: { type: Boolean, default: true },
heading: { type: String, default: "" }, heading: { type: String, default: "" },
subheading: { type: String, default: "" }, subheading: { type: String, default: "" },
description: { type: String, default: "" }, description: { type: String, default: "" },
@@ -112,18 +169,21 @@ const VisaCountriesSchema = new Schema(
const TestimonialSchema = new Schema( const TestimonialSchema = new Schema(
{ {
name: { type: String, default: "" }, name: { type: String, default: "", maxlength: 48 },
role: { type: String, default: "" }, role: { type: String, default: "", maxlength: 48 },
country: { type: String, default: "" }, country: { type: String, default: "", maxlength: 48 },
rating: { type: Number, default: 5 }, rating: { type: Number, default: 5 },
comment: { type: String, default: "" }, comment: { type: String, default: "", maxlength: 280 },
avatar: { type: String, default: "" }, avatar: { type: String, default: "", maxlength: 255 },
}, },
{ _id: false }, { _id: false },
); );
const TestimonialsSchema = new Schema( const TestimonialsSchema = new Schema(
{ {
// Toggle visibility on frontend
enabled: { type: Boolean, default: true },
heading: { type: String, default: "" }, heading: { type: String, default: "" },
subheading: { type: String, default: "" }, subheading: { type: String, default: "" },
videoUrl: { type: String, default: "" }, videoUrl: { type: String, default: "" },
@@ -135,6 +195,9 @@ const TestimonialsSchema = new Schema(
const VideoGallerySchema = new Schema( const VideoGallerySchema = new Schema(
{ {
// Toggle visibility on frontend
enabled: { type: Boolean, default: true },
heading: { type: String, default: "" }, heading: { type: String, default: "" },
videoUrl: { type: String, default: "" }, videoUrl: { type: String, default: "" },
thumbnail: { type: String, default: "" }, thumbnail: { type: String, default: "" },
@@ -144,14 +207,17 @@ const VideoGallerySchema = new Schema(
const FaqItemSchema = new Schema( const FaqItemSchema = new Schema(
{ {
question: { type: String, default: "" }, question: { type: String, default: "", maxlength: 120 },
answer: { type: String, default: "" }, answer: { type: String, default: "", maxlength: 320 },
}, },
{ _id: false }, { _id: false },
); );
const FaqSchema = new Schema( const FaqSchema = new Schema(
{ {
// Toggle visibility on frontend
enabled: { type: Boolean, default: true },
heading: { type: String, default: "" }, heading: { type: String, default: "" },
subheading: { type: String, default: "" }, subheading: { type: String, default: "" },
description: { type: String, default: "" }, description: { type: String, default: "" },
@@ -163,16 +229,19 @@ const FaqSchema = new Schema(
const AchievementItemSchema = new Schema( const AchievementItemSchema = new Schema(
{ {
value: { type: String, default: "" }, value: { type: String, default: "", maxlength: 6 },
suffix: { type: String, default: "" }, suffix: { type: String, default: "", maxlength: 4 },
label: { type: String, default: "" }, label: { type: String, default: "", maxlength: 40 },
description: { type: String, default: "" }, description: { type: String, default: "", maxlength: 120 },
}, },
{ _id: false }, { _id: false },
); );
const AchievementsSchema = new Schema( const AchievementsSchema = new Schema(
{ {
// Toggle visibility on frontend
enabled: { type: Boolean, default: true },
heading: { type: String, default: "" }, heading: { type: String, default: "" },
subheading: { type: String, default: "" }, subheading: { type: String, default: "" },
items: { type: [AchievementItemSchema], default: [] }, items: { type: [AchievementItemSchema], default: [] },
@@ -182,9 +251,9 @@ const AchievementsSchema = new Schema(
const VisaConsultancyItemSchema = new Schema( const VisaConsultancyItemSchema = new Schema(
{ {
name: { type: String, default: "" }, name: { type: String, default: "", maxlength: 48 },
icon: { type: String, default: "" }, icon: { type: String, default: "", maxlength: 255 },
year: { type: String, default: "" }, year: { type: String, default: "", maxlength: 8 },
}, },
{ _id: false }, { _id: false },
); );
@@ -198,7 +267,7 @@ const VisaConsultancySchema = new Schema(
const BrandItemSchema = new Schema( const BrandItemSchema = new Schema(
{ {
logo: { type: String, default: "" }, logo: { type: String, default: "", maxlength: 255 },
}, },
{ _id: false }, { _id: false },
); );
@@ -212,6 +281,9 @@ const BrandsSchema = new Schema(
const PartnersSchema = new Schema( const PartnersSchema = new Schema(
{ {
// Toggle visibility on frontend
enabled: { type: Boolean, default: true },
visaConsultancy: { type: VisaConsultancySchema, default: () => ({}) }, visaConsultancy: { type: VisaConsultancySchema, default: () => ({}) },
brands: { type: BrandsSchema, default: () => ({}) }, brands: { type: BrandsSchema, default: () => ({}) },
}, },
@@ -220,16 +292,16 @@ const PartnersSchema = new Schema(
const BlogPreviewItemSchema = new Schema( const BlogPreviewItemSchema = new Schema(
{ {
title: { type: String, default: "" }, title: { type: String, default: "", maxlength: 120 },
excerpt: { type: String, default: "" }, excerpt: { type: String, default: "", maxlength: 280 },
category: { type: String, default: "" }, category: { type: String, default: "", maxlength: 48 },
date: { type: String, default: "" }, // keep as string for easy JSON compatibility (e.g. "2025-08-20") date: { type: String, default: "" }, // keep as string for easy JSON compatibility (e.g. "2025-08-20")
author: { author: {
name: { type: String, default: "" }, name: { type: String, default: "", maxlength: 48 },
avatar: { type: String, default: "" }, avatar: { type: String, default: "" },
}, },
comments: { type: Number, default: 0 }, comments: { type: Number, default: 0 },
link: { type: String, default: "" }, link: { type: String, default: "", maxlength: 255 },
thumbnail: { type: String, default: "" }, thumbnail: { type: String, default: "" },
}, },
{ _id: false }, { _id: false },
@@ -237,6 +309,9 @@ const BlogPreviewItemSchema = new Schema(
const BlogPreviewSchema = new Schema( const BlogPreviewSchema = new Schema(
{ {
// Toggle visibility on frontend
enabled: { type: Boolean, default: true },
heading: { type: String, default: "" }, heading: { type: String, default: "" },
subheading: { type: String, default: "" }, subheading: { type: String, default: "" },
ctaButton: { type: LinkSchema, default: () => ({}) }, ctaButton: { type: LinkSchema, default: () => ({}) },
@@ -266,6 +341,7 @@ const HomeSchema = new Schema(
achievements: { type: AchievementsSchema, default: () => ({}) }, achievements: { type: AchievementsSchema, default: () => ({}) },
partners: { type: PartnersSchema, default: () => ({}) }, partners: { type: PartnersSchema, default: () => ({}) },
blogPreview: { type: BlogPreviewSchema, default: () => ({}) }, blogPreview: { type: BlogPreviewSchema, default: () => ({}) },
floatingContact: { type: FloatingContactSchema, default: () => ({}) },
}, },
{ {
timestamps: true, timestamps: true,
@@ -273,5 +349,8 @@ const HomeSchema = new Schema(
}, },
); );
// Đảm bảo chỉ có 1 document duy nhất (singleton pattern)
HomeSchema.index({ createdAt: 1 }, { unique: false });
module.exports = mongoose.model("Home", HomeSchema); module.exports = mongoose.model("Home", HomeSchema);

View File

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

View File

@@ -11,7 +11,8 @@
"migrate-fresh": "node scripts/migrate-fresh.js", "migrate-fresh": "node scripts/migrate-fresh.js",
"migrate-status": "node scripts/migrate-status.js", "migrate-status": "node scripts/migrate-status.js",
"migrate-rollback": "node scripts/migrate-rollback.js", "migrate-rollback": "node scripts/migrate-rollback.js",
"make-migration": "node scripts/make-migration.js" "make-migration": "node scripts/make-migration.js",
"db:seed": "node scripts/seedDatabase.js"
}, },
"keywords": [ "keywords": [
"cms", "cms",

View File

@@ -0,0 +1,873 @@
;(function (window, document) {
"use strict";
const COUNTER_SELECTOR = ".admin-field-counter";
const GUIDANCE_SELECTOR = ".admin-upload-guidance";
const COUNTER_BOUND_KEY = "adminCounterBound";
const AUTO_GUIDANCE_ATTR = "data-admin-upload-guidance";
const OBSERVER_BOUND_KEY = "__adminFormHelpersObserver";
let generatedFieldToken = 0;
const FIELD_RULES = {
"/admin/about-us": [
{ selector: "#heroTitle", maxLength: 72 },
{ selector: "#heroBreadcrumb", maxLength: 120 },
{ selector: "#heroBackgroundImage", maxLength: 255 },
{ selector: "#introSubheading", maxLength: 40 },
{ selector: "#introHeading", maxLength: 72 },
{ selector: "#introDescription", maxLength: 260 },
{ selector: "#introImage", maxLength: 255 },
{ selector: "#missionSubheading", maxLength: 40 },
{ selector: "#missionHeading", maxLength: 72 },
{ selector: "#missionDescription", maxLength: 260 },
{ selector: "#missionCtaLabel", maxLength: 32 },
{ selector: "#missionCtaHref", maxLength: 255 },
{ selector: "[id^='missionImg_']", maxLength: 255 },
{ selector: "#featuresSubheading", maxLength: 40 },
{ selector: "#featuresHeading", maxLength: 72 },
{ selector: "#featuresDescription", maxLength: 260 },
{ selector: "#featuresBgImage", maxLength: 255 },
{ selector: "#featuresImage", maxLength: 255 },
{ selector: "#featuresCtaLabel", maxLength: 32 },
{ selector: "#featuresCtaHref", maxLength: 255 },
{ selector: "[id^='missionItemLabel_']", maxLength: 48 },
{ selector: "[id^='missionItemDescription_']", maxLength: 160 },
{ selector: "[id^='missionItemIcon_']", maxLength: 255 },
{ selector: "[id^='missionFeature_']", maxLength: 96 },
{ selector: "[id^='featureItemTitle_']", maxLength: 48 },
{ selector: "[id^='featureItemDescription_']", maxLength: 160 },
{ selector: "[id^='featureItemIcon_']", maxLength: 255 },
{ selector: "#newsSubheading", maxLength: 40 },
{ selector: "#newsHeading", maxLength: 72 },
{ selector: "#newsCtaLabel", maxLength: 32 },
{ selector: "#newsCtaHref", maxLength: 255 },
],
"/admin/booking": [
{ selector: "#heroBackgroundImage", maxLength: 255 },
{ selector: "#heroTitle", maxLength: 72 },
{ selector: "#searchBarLocationLabel", maxLength: 32 },
{ selector: "#searchBarHolidaySeasonLabel", maxLength: 32 },
{ selector: "#searchBarSearchButtonText", maxLength: 24 },
{ selector: "input[name^='locationValue_']", maxLength: 32 },
{ selector: "input[name^='locationLabel_']", maxLength: 48 },
{ selector: "input[name^='holidayValue_']", maxLength: 32 },
{ selector: "input[name^='holidayLabel_']", maxLength: 48 },
{ selector: "#filterPanelTitle", maxLength: 48 },
{ selector: "#filterPanelPriceTitle", maxLength: 40 },
{ selector: "#filterPanelPriceLabel", maxLength: 32 },
{ selector: "#filterPanelPricePlaceholder", maxLength: 32 },
{ selector: "#filterPanelActivitiesTitle", maxLength: 40 },
{ selector: "#filterPanelAgeTitle", maxLength: 40 },
{ selector: "#filterPanelAgeSelectPlaceholder", maxLength: 32 },
{ selector: "#filterPanelRatingTitle", maxLength: 40 },
{ selector: "#filterPanelResetButtonText", maxLength: 24 },
{ selector: "input[name^='ratingValue_']", maxLength: 8 },
{ selector: "input[name^='ratingLabel_']", maxLength: 32 },
{ selector: "input[name^='programValue_']", maxLength: 32 },
{ selector: "input[name^='programLabel_']", maxLength: 48 },
{ selector: "input[name^='campName_']", maxLength: 72 },
{ selector: "input[name^='campPriceText_']", maxLength: 32 },
{ selector: "input[name^='campProgram_']", maxLength: 32 },
{ selector: "input[name^='campImage_']", maxLength: 255 },
{ selector: "input[name^='campLink_']", maxLength: 255 },
{ selector: "input[name^='discountName_']", maxLength: 48 },
{ selector: "textarea[name^='discountDescription_']", maxLength: 180 },
{ selector: "input[name^='voucherCode_']", maxLength: 24 },
{ selector: "input[name^='voucherDescription_']", maxLength: 120 },
{ selector: "[name='formTitle']", maxLength: 64 },
{ selector: "[name='formSubtitle']", maxLength: 48 },
{ selector: "[name^='stepTitle_']", maxLength: 64 },
{ selector: "[name^='sectionTitle_']", maxLength: 64 },
{ selector: "[name^='fieldLabel_']", maxLength: 48 },
{ selector: "[name^='fieldName_']", maxLength: 32 },
{ selector: "[name^='fieldPlaceholder_']", maxLength: 72 },
{ selector: "[name^='validationMessage_']", maxLength: 120 },
],
"/admin/pricing": [
{ selector: "#heroBackgroundImage", maxLength: 255 },
{ selector: "#heroTitle", maxLength: 72 },
{ selector: "#pricingSectionSubtitle", maxLength: 40 },
{ selector: "#pricingSectionHeading", maxLength: 72 },
{ selector: "#pricingSectionDescription", maxLength: 220 },
{ selector: ".plan-name", maxLength: 40 },
{ selector: ".plan-price", maxLength: 16 },
{ selector: ".plan-currency", maxLength: 8 },
{ selector: ".plan-period", maxLength: 12 },
{ selector: ".plan-button-text", maxLength: 32 },
{ selector: ".plan-button-link", maxLength: 255 },
{ selector: ".plan-button-icon", maxLength: 64 },
{ selector: ".plan-features", maxLength: 320 },
{ selector: "#testimonialsSubtitle", maxLength: 40 },
{ selector: "#testimonialsHeading", maxLength: 72 },
{ selector: "#testimonialsButtonText", maxLength: 32 },
{ selector: "#testimonialsButtonLink", maxLength: 255 },
{ selector: "#testimonialsButtonIcon", maxLength: 64 },
{ selector: "#testimonialsImage", maxLength: 255 },
{ selector: ".testimonial-name", maxLength: 48 },
{ selector: ".testimonial-role", maxLength: 48 },
{ selector: ".testimonial-content", maxLength: 220 },
],
"/admin/visa": [
{ selector: "input[name='name']", maxLength: 40 },
{ selector: "input[name='icon']", maxLength: 255 },
{ selector: "input[name='services[]']", maxLength: 56 },
{ selector: "input[name='detail_title']", maxLength: 72 },
{ selector: "input[name='mainImage']", maxLength: 255 },
{ selector: "textarea[name='description']", maxLength: 360 },
{ selector: "textarea[name='additionalInfo']", maxLength: 360 },
{ selector: "input[name='tagline']", maxLength: 72 },
{ selector: "input[name^='visa_title_']", maxLength: 56 },
{ selector: "textarea[name^='visa_desc_']", maxLength: 220 },
{ selector: "input[name='process_title']", maxLength: 72 },
{ selector: "input[name='step_title[]']", maxLength: 56 },
{ selector: "textarea[name='step_desc[]']", maxLength: 180 },
{ selector: "input[name='bannerImageGallery']", maxLength: 255 },
{ selector: "input[name='category_title[]']", maxLength: 56 },
{ selector: "textarea[name='category_desc[]']", maxLength: 180 },
{ selector: "input[name='related_title[]']", maxLength: 56 },
{ selector: "textarea[name='related_desc[]']", maxLength: 180 },
{ selector: "input[name='related_file[]']", maxLength: 255 },
{ selector: "input[name='contact_title']", maxLength: 72 },
{ selector: "input[name='contact_phone']", maxLength: 32 },
{ selector: "input[name='contact_email']", maxLength: 120 },
{ selector: "input[name='contact_address']", maxLength: 160 },
{ selector: "input[name='contact_image']", maxLength: 255 },
],
"/admin/activity/*": [
{ selector: "input[name='heroTitle']", maxLength: 72 },
{ selector: "input[name='heroBannerImage']", maxLength: 255 },
{ selector: "input[name='name']", maxLength: 72 },
{ selector: "input[name='priceText']", maxLength: 32 },
{ selector: "input[name='link']", maxLength: 255 },
{ selector: "input[name='program']", maxLength: 32 },
{ selector: "#customLocations", maxLength: 120 },
{ selector: "input[name='image']", maxLength: 255 },
{ selector: "input[name='campDetailHeroTitle']", maxLength: 72 },
{ selector: "input[name='campDetailHeroBgImage']", maxLength: 255 },
{ selector: "input[name='campDetailBasicInfoLocation']", maxLength: 48 },
{ selector: "textarea[name='campDetailBasicInfoAgeRange']", maxLength: 120 },
{ selector: "input[name='campDetailBasicInfoAccommodationType']", maxLength: 72 },
{ selector: "input[name='campDetailBasicInfoCareLevel']", maxLength: 72 },
{ selector: "input[name='campDetailBasicInfoLanguages']", maxLength: 72 },
],
"/admin/activity": [],
};
const GUIDANCE_RULES = {
"/admin/about-us": [
{
selector: "#heroBackgroundImage",
title: "Upload guidance",
lines: [
"Displayed as a wide page hero.",
"Recommended upload: at least 1920x700px.",
],
},
{
selector: "#introImage",
title: "Upload guidance",
lines: [
"Displayed around 596x787px on desktop.",
"Recommended upload: at least 1200x1600px.",
],
},
{
selector: "#featuresImage",
title: "Upload guidance",
lines: [
"Displayed around 375x419px on desktop.",
"Recommended upload: at least 750x840px.",
],
},
],
"/admin/booking": [
{
selector: "#heroBackgroundImage",
title: "Upload guidance",
lines: [
"Booking page hero background.",
"Recommended upload: at least 1920x700px.",
],
},
{
selector: "input[name^='campImage_']",
title: "Upload guidance",
lines: [
"Used in booking camp cards.",
"Recommended upload: a landscape image at 704x432px or larger.",
],
},
],
"/admin/pricing": [
{
selector: "#heroBackgroundImage",
title: "Upload guidance",
lines: [
"Pricing page hero background.",
"Recommended upload: at least 1920x700px.",
],
},
],
"/admin/visa": [
{
selector: "input[name='icon']",
title: "Upload guidance",
lines: [
"Displayed as a small country flag or icon.",
"Prefer SVG; otherwise use a square image at 96x96px or larger.",
],
},
{
selector: "input[name='mainImage'], input[name='bannerImageGallery'], input[name='contact_image'], input[name='related_file[]']",
title: "Upload guidance",
lines: [
"Used in visa detail content blocks.",
"Recommended upload: at least 1000x750px for primary imagery and 800x600px for supporting images.",
],
},
],
"/admin/activity/*": [
{
selector: "input[name='heroBannerImage'], input[name='campDetailHeroBgImage']",
title: "Upload guidance",
lines: [
"Activity page hero-style image.",
"Recommended upload: at least 1920x700px.",
],
},
{
selector: "input[name='image']",
title: "Upload guidance",
lines: [
"Used in activity listing cards.",
"Recommended upload: a landscape image at 704x432px or larger.",
],
},
],
"/admin/home": [
{
selector: "#whyChooseUsMainImage",
title: "Upload guidance",
lines: [
"Displayed around 318x347px on desktop.",
"Recommended upload: at least 750x820px.",
],
},
{
selector: "#whyChooseUsSecondaryImage",
title: "Upload guidance",
lines: [
"Displayed around 363x380px on desktop.",
"Recommended upload: at least 760x800px.",
],
},
{
selector: "#testimonialsVideoThumbnail",
title: "Upload guidance",
lines: [
"Displayed around 416x370px on desktop.",
"Recommended upload: at least 832x740px.",
],
},
{
selector: "[id^='testimonialsAvatar_']",
title: "Upload guidance",
lines: [
"Displayed around 48x48px.",
"Recommended upload: 96x96px or 128x128px square.",
],
},
{
selector: "#visaCountriesFlag_0",
title: "Upload guidance",
lines: [
"Displayed around 840x830px on desktop.",
"Recommended upload: at least 1000x1000px.",
],
},
],
"*": [
{
selector: ".btn-upload-image",
title: "Upload guidance",
lines: [
"Use a clear, high-resolution image that matches the visible frame.",
"Prefer SVG for logos or icons and at least 2x the displayed size for raster uploads.",
],
},
],
};
function toScope(scope) {
return scope && scope.querySelectorAll ? scope : document;
}
function normalizeText(value) {
return String(value ?? "").replace(/\s+/g, " ").trim();
}
function buildDescriptor(input) {
return [
input?.id,
input?.name,
input?.placeholder,
input?.closest(".col-md-12, .col-md-9, .col-md-6, .col-md-5, .col-md-4, .col-md-3, .col-md-2, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12")?.querySelector("label")?.textContent,
]
.filter(Boolean)
.join(" ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[_[\].-]+/g, " ")
.toLowerCase();
}
function isUploadDescriptor(descriptor) {
return /(?:^|[^a-z])(image|icon|logo|background|banner|thumbnail|avatar|flag|path|src|file)(?:[^a-z]|$)/.test(descriptor);
}
function resolveUploadTarget(targetId, anchor) {
if (targetId) {
const byId = document.getElementById(targetId);
if (byId) {
return byId;
}
if (window.CSS && typeof window.CSS.escape === "function") {
const byName = document.querySelector(`[name="${window.CSS.escape(targetId)}"]`);
if (byName) {
return byName;
}
}
}
return anchor || null;
}
function hasManualUploadHint(target) {
const anchor = resolveGuidanceAnchor(target);
if (!anchor) {
return false;
}
const host =
anchor.closest(".form-group, .mb-3, .col-md-12, .col-md-9, .col-md-6, .col-md-5, .col-md-4, .col-md-3, .col-md-2, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12") ||
anchor.parentElement;
if (!host) {
return false;
}
const candidates = [
...host.querySelectorAll("small.text-muted, small.form-text, .form-text, .text-muted"),
...Array.from(host.nextElementSibling ? host.nextElementSibling.querySelectorAll?.("small.text-muted, small.form-text, .form-text, .text-muted") || [] : []),
];
return candidates.some((node) => {
if (node.classList?.contains("admin-field-counter") || node.classList?.contains("admin-upload-guidance")) {
return false;
}
const text = normalizeText(node.textContent || "");
return /recommended|min(imum)? upload|upload|svg|png|webp|render|displayed|size|preview|icon/i.test(text);
});
}
function getFieldLimit(input) {
const dataMax = Number(input?.dataset?.maxlength);
if (Number.isFinite(dataMax) && dataMax > 0) {
return dataMax;
}
const attrMax = Number(input?.getAttribute("maxlength"));
if (Number.isFinite(attrMax) && attrMax > 0) {
return attrMax;
}
return null;
}
function getWordLimit(input) {
const dataMax = Number(input?.dataset?.maxwords);
if (Number.isFinite(dataMax) && dataMax > 0) {
return dataMax;
}
return null;
}
function getFieldToken(input) {
if (!input) {
return "";
}
if (input.id) {
return `id:${input.id}`;
}
if (input.name) {
const indexWithinNameGroup = Array.from(document.querySelectorAll(`[name="${CSS.escape(input.name)}"]`)).indexOf(input);
return `name:${input.name}:${Math.max(indexWithinNameGroup, 0)}`;
}
if (!input.dataset.adminFieldToken) {
generatedFieldToken += 1;
input.dataset.adminFieldToken = `generated:${generatedFieldToken}`;
}
return input.dataset.adminFieldToken;
}
function isDragDropField(element) {
if (!element || !element.closest) {
return false;
}
return Boolean(
element.closest(
".social-link-item, .floating-contact-action-item, .menu-links-sortable, .social-links-sortable, .sortable-container, .sortable-list, .sortable-item, [data-top-menu-index], [data-top-social-index], [data-bottom-menu-index], [data-bottom-social-index], [draggable='true']",
),
);
}
function refreshCountersWithin(scope) {
if (!scope || !scope.querySelectorAll) {
return;
}
scope
.querySelectorAll("input[data-maxlength], textarea[data-maxlength], input[data-maxwords], textarea[data-maxwords], input[maxlength], textarea[maxlength]")
.forEach((field) => {
if (field.dataset[COUNTER_BOUND_KEY] === "true") {
updateCounter(field);
}
});
}
function getCounterRefreshScope(input) {
if (!isDragDropField(input)) {
return null;
}
return (
input.closest(".floating-contact-action-item, .social-link-item, [data-top-menu-index], [data-top-social-index], [data-bottom-menu-index], [data-bottom-social-index]") ||
input.closest(".menu-links-sortable, .social-links-sortable, .sortable-container, .sortable-list")
);
}
function getCounterHost(input) {
return (
input.closest(".input-group") ||
input.closest(".col, [class^='col-'], [class*=' col-']") ||
input.parentElement ||
input
);
}
function ensureCounterElement(input) {
const counterToken = getFieldToken(input);
const existingAnywhere = counterToken ? document.querySelector(`[data-counter-for="${counterToken}"]`) : null;
if (existingAnywhere) {
return existingAnywhere;
}
const host = getCounterHost(input);
const nextSibling = host.nextElementSibling;
const matchingNextSibling =
nextSibling &&
nextSibling.matches(COUNTER_SELECTOR) &&
nextSibling.dataset.counterFor === counterToken
? nextSibling
: null;
const existing = matchingNextSibling || host.querySelector(`${COUNTER_SELECTOR}[data-counter-for="${counterToken}"]`);
if (existing) {
return existing;
}
const counter = document.createElement("small");
counter.className = "form-text admin-field-counter";
counter.dataset.counterFor = counterToken;
counter.setAttribute("aria-live", "polite");
if (host.classList?.contains("input-group")) {
host.insertAdjacentElement("afterend", counter);
} else {
host.appendChild(counter);
}
return counter;
}
function matchesPathKey(key, pathname) {
if (key === "*") {
return true;
}
if (key.endsWith("*")) {
return pathname.startsWith(key.slice(0, -1));
}
return key === pathname;
}
function getPathRules(registry) {
const pathname = window.location.pathname;
return Object.keys(registry).reduce((rules, key) => {
if (!matchesPathKey(key, pathname)) {
return rules;
}
return rules.concat(registry[key] || []);
}, []);
}
function findTargets(root, selector) {
const targets = [];
if (root.matches && root.matches(selector)) {
targets.push(root);
}
if (root.querySelectorAll) {
targets.push(...root.querySelectorAll(selector));
}
return targets;
}
function applyFieldRules(scope) {
const root = toScope(scope);
getPathRules(FIELD_RULES).forEach((rule) => {
findTargets(root, rule.selector).forEach((input) => {
if (rule.maxLength && !input.dataset.maxlength) {
input.dataset.maxlength = String(rule.maxLength);
input.setAttribute("maxlength", String(rule.maxLength));
}
if (rule.maxWords && !input.dataset.maxwords) {
input.dataset.maxwords = String(rule.maxWords);
}
});
});
root.querySelectorAll("input, textarea").forEach((input) => {
if (
input.disabled ||
input.type === "hidden" ||
input.type === "file" ||
input.dataset.maxlength ||
input.dataset.maxwords ||
input.getAttribute("maxlength")
) {
return;
}
const type = (input.getAttribute("type") || "").toLowerCase();
if (type && !["text", "email", "tel", "search", "url"].includes(type) && input.tagName !== "TEXTAREA") {
return;
}
const descriptor = buildDescriptor(input);
if (/json|editor|html|content-block|blocks/.test(descriptor)) {
return;
}
let inferredMaxLength = 72;
if (input.tagName === "TEXTAREA") {
inferredMaxLength = /description|content|overview|additional info|quote|note|message|summary/.test(descriptor) ? 500 : 220;
} else if (type === "email" || /email/.test(descriptor)) {
inferredMaxLength = 120;
} else if (type === "tel" || /phone|tel|mobile|whatsapp|zalo/.test(descriptor)) {
inferredMaxLength = 32;
} else if (/url|href|link/.test(descriptor) || isUploadDescriptor(descriptor)) {
inferredMaxLength = 255;
} else if (/slug|code|id/.test(descriptor)) {
inferredMaxLength = 32;
} else if (/title|heading|name|label|subtitle|platform/.test(descriptor)) {
inferredMaxLength = 72;
}
input.dataset.maxlength = String(inferredMaxLength);
input.setAttribute("maxlength", String(inferredMaxLength));
});
}
function updateCounter(input) {
const counter = ensureCounterElement(input);
const maxLength = getFieldLimit(input);
const maxWords = getWordLimit(input);
const currentValue = normalizeText(input.value || "");
const currentLength = currentValue.length;
if (maxWords) {
const words = currentValue ? currentValue.split(" ") : [];
const currentWords = words.filter(Boolean).length;
if (maxLength && currentLength > maxLength) {
input.value = currentValue.slice(0, maxLength);
}
if (maxLength) {
counter.textContent = `${currentWords}/${maxWords} words, ${Math.min(currentLength, maxLength)}/${maxLength} characters`;
counter.classList.toggle("is-danger", currentWords >= maxWords || currentLength >= maxLength);
} else {
counter.textContent = `${currentWords}/${maxWords} words`;
counter.classList.toggle("is-danger", currentWords >= maxWords);
}
return;
}
if (!maxLength) {
counter.textContent = "";
return;
}
if (currentLength > maxLength) {
input.value = currentValue.slice(0, maxLength);
}
counter.textContent = `${Math.min(currentLength, maxLength)}/${maxLength} characters`;
counter.classList.toggle("is-danger", currentLength >= maxLength);
}
function bindCounter(input) {
if (!input || input.dataset[COUNTER_BOUND_KEY] === "true") {
return;
}
input.dataset[COUNTER_BOUND_KEY] = "true";
const syncCounter = () => {
updateCounter(input);
const refreshScope = getCounterRefreshScope(input);
if (refreshScope) {
refreshCountersWithin(refreshScope);
}
};
syncCounter();
input.addEventListener("input", syncCounter);
input.addEventListener("change", syncCounter);
input.addEventListener("blur", syncCounter);
input.addEventListener("focus", syncCounter);
}
function buildGuidanceLines(options = {}) {
const title = normalizeText(options.title) || "Upload guidance";
const lines = Array.isArray(options.lines) ? options.lines.map(normalizeText).filter(Boolean) : [];
if (!lines.length) {
lines.push("Use a clear, high-resolution image that matches the visible frame.");
lines.push("Prefer a file that is at least 2x the displayed size for crisp rendering.");
lines.push("Keep the original aspect ratio unless the page explicitly asks for a crop.");
}
return { title, lines };
}
function resolveGuidanceAnchor(target) {
if (!target) {
return null;
}
if (typeof target === "string") {
return document.querySelector(target);
}
if (target instanceof Element) {
return target;
}
return null;
}
function renderUploadGuidance(target, options = {}) {
const anchor = resolveGuidanceAnchor(target);
if (!anchor) {
return null;
}
const host = anchor.closest(".form-group, .mb-3, .col-md-12, .col-md-9, .col-md-6, .col-md-4, .col-md-3, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12") || anchor.parentElement;
if (!host) {
return null;
}
const matchingAnchorSibling =
anchor.nextElementSibling &&
anchor.nextElementSibling.matches(GUIDANCE_SELECTOR) &&
anchor.nextElementSibling.dataset.guidanceFor === (options.for || "")
? anchor.nextElementSibling
: null;
const matchingHostSibling =
host.nextElementSibling &&
host.nextElementSibling.matches(GUIDANCE_SELECTOR) &&
host.nextElementSibling.dataset.guidanceFor === (options.for || "")
? host.nextElementSibling
: null;
const existing =
matchingAnchorSibling ||
matchingHostSibling ||
host.querySelector(`${GUIDANCE_SELECTOR}[data-guidance-for="${options.for || ""}"]`);
if (existing) {
return existing;
}
const payload = buildGuidanceLines(options);
const note = document.createElement("div");
note.className = "admin-upload-guidance";
note.dataset.guidanceFor = options.for || "";
note.setAttribute("role", "note");
note.innerHTML = `
<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);

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -36,9 +36,34 @@ const videoGalleryController = require("../controllers/videoGalleryController");
// Dashboard // Dashboard
router.get("/dashboard", ensureAuthenticated, dashboardController.getDashboard); router.get("/dashboard", ensureAuthenticated, dashboardController.getDashboard);
const runUploadMiddleware = (middleware) => (req, res, next) => {
middleware(req, res, (error) => {
if (!error) {
return next();
}
console.error("Upload middleware error:", error);
const status =
error.code === "LIMIT_FILE_SIZE"
? 413
: error.statusCode || error.status || 400;
return res.status(status).json({
success: false,
error: error.message || "Upload failed",
});
});
};
// Home // Home
router.get("/home", ensureAuthenticated, homeController.index); router.get("/home", ensureAuthenticated, homeController.index);
router.post("/home/update", ensureAuthenticated, homeController.update); router.post("/home/update", ensureAuthenticated, homeController.update);
router.post(
"/home/floating-contact/update",
ensureAuthenticated,
homeController.updateFloatingContact,
);
router.get("/home/api/blogs", ensureAuthenticated, homeController.apiGetBlogs); router.get("/home/api/blogs", ensureAuthenticated, homeController.apiGetBlogs);
// Middleware chuẩn hóa code // Middleware chuẩn hóa code
@@ -72,13 +97,13 @@ router.get("/upload", ensureAuthenticated, (req, res) => {
router.post( router.post(
"/upload/image", "/upload/image",
ensureAuthenticated, ensureAuthenticated,
upload.single("image"), runUploadMiddleware(upload.single("image")),
uploadController.uploadImage, uploadController.uploadImage,
); );
router.post( router.post(
"/upload/video", "/upload/video",
ensureAuthenticated, ensureAuthenticated,
uploadVideo.single("video"), runUploadMiddleware(uploadVideo.single("video")),
uploadController.uploadVideo, uploadController.uploadVideo,
); );
router.post( router.post(

View File

@@ -52,6 +52,7 @@ router.get("/api/menu-tree", headerController.getMenuTreeAPI);
// Header Menu New Module API // Header Menu New Module API
router.get("/api/header-menu", headerMenuController.api); router.get("/api/header-menu", headerMenuController.api);
router.get("/api/header-menu/maintenance", headerMenuController.maintenanceStatus);
// Social Links API routes // Social Links API routes
router.get("/api/social-links", socialLinkController.index); router.get("/api/social-links", socialLinkController.index);

View File

@@ -0,0 +1,115 @@
require("dotenv").config();
const connectDB = require("../config/database");
const DEFAULT_FACEBOOK_URL = "https://www.facebook.com/hailearning.edu.vn/";
const DEFAULT_PANEL_TITLE = "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";
function normalizePhoneForZalo(value = "") {
const digits = String(value).replace(/\D/g, "");
if (!digits) {
return "";
}
if (digits.startsWith("84")) {
return digits;
}
if (digits.startsWith("0")) {
return `84${digits.slice(1)}`;
}
return digits;
}
async function migrate() {
const mongoose = require("mongoose");
let ownConn = false;
try {
const wasConnected = mongoose.connection.readyState === 1;
await connectDB();
if (!wasConnected) {
ownConn = true;
}
const Home = require("../models/home");
const Footer = require("../models/footer");
const footer = await Footer.findOne().sort({ updatedAt: -1 }).lean();
const footerPhone = footer?.top?.phone?.href || footer?.top?.phone?.display || "";
const zaloPhone = normalizePhoneForZalo(footerPhone) || "84961834040";
const zaloUrl = `https://zalo.me/${zaloPhone}`;
const defaultFloatingContact = {
enabled: true,
position: "bottom-right",
panelTitle: DEFAULT_PANEL_TITLE,
brand: {
imageSrc: DEFAULT_BRAND_IMAGE,
imageAlt: "HAI Learning",
},
trigger: {
icon: "fa-comments",
},
actions: [
{
id: "facebook",
platform: "facebook",
enabled: true,
label: "Message via Facebook",
subtitle: "facebook.com/hailearning.edu.vn",
href: DEFAULT_FACEBOOK_URL,
iconImage: DEFAULT_FACEBOOK_ICON,
iconType: "iconClass",
iconClass: "fa-brands fa-facebook-messenger",
iconText: "",
order: 1,
},
{
id: "zalo",
platform: "zalo",
enabled: true,
label: "Message via Zalo",
subtitle: `zalo.me/${zaloPhone}`,
href: zaloUrl,
iconImage: DEFAULT_ZALO_ICON,
iconType: "iconText",
iconClass: "",
iconText: "Zalo",
order: 2,
},
],
};
const updateResult = await Home.updateMany(
{ floatingContact: { $exists: false } },
{ $set: { floatingContact: defaultFloatingContact } },
);
if (updateResult.matchedCount === 0) {
await Home.create({ floatingContact: defaultFloatingContact });
console.log("Created a Home document with default floatingContact data.");
} else {
console.log(
`Updated ${updateResult.modifiedCount} Home document(s) with floatingContact defaults.`,
);
}
if (ownConn && mongoose.connection.readyState === 1) {
await mongoose.disconnect();
}
process.exit(0);
} catch (error) {
console.error("Failed to add floatingContact to Home:", error);
if (ownConn && mongoose.connection.readyState === 1) {
await mongoose.disconnect();
}
process.exit(1);
}
}
migrate();

View File

@@ -0,0 +1,129 @@
const mongoose = require("mongoose");
const connectDB = require("../config/database");
const Home = require("../models/home");
const DEFAULT_ICON_BY_PLATFORM = {
facebook: "/uploads/home/floating-contact/Facebook_Logo_Primary.webp",
zalo: "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp",
};
async function up() {
await connectDB();
try {
const homes = await Home.find({
floatingContact: { $exists: true },
});
let updatedCount = 0;
for (const home of homes) {
const floatingContact = home.floatingContact || {};
let modified = false;
if (!floatingContact.trigger) {
floatingContact.trigger = {};
modified = true;
}
if (typeof floatingContact.trigger.imageSrc !== "string") {
floatingContact.trigger.imageSrc = "";
modified = true;
}
if (Array.isArray(floatingContact.actions)) {
floatingContact.actions = floatingContact.actions.map((action) => {
const defaultIcon =
DEFAULT_ICON_BY_PLATFORM[String(action?.platform || "").trim().toLowerCase()] || "";
if (typeof action?.iconImage !== "string") {
modified = true;
return {
...action,
iconImage: defaultIcon,
};
}
if (!action.iconImage.trim() && defaultIcon) {
modified = true;
return {
...action,
iconImage: defaultIcon,
};
}
return action;
});
}
if (modified) {
home.floatingContact = floatingContact;
home.markModified("floatingContact");
await home.save();
updatedCount += 1;
}
}
console.log(
`Added floatingContact trigger/action image fields to ${updatedCount} Home document(s).`,
);
} catch (error) {
console.error("Failed to add floatingContact icon image fields:", error);
throw error;
} finally {
await mongoose.disconnect();
}
}
async function down() {
await connectDB();
try {
const homes = await Home.find({
floatingContact: { $exists: true },
});
let updatedCount = 0;
for (const home of homes) {
const floatingContact = home.floatingContact || {};
let modified = false;
if (floatingContact.trigger && "imageSrc" in floatingContact.trigger) {
delete floatingContact.trigger.imageSrc;
modified = true;
}
if (Array.isArray(floatingContact.actions)) {
floatingContact.actions = floatingContact.actions.map((action) => {
if (!action || !("iconImage" in action)) {
return action;
}
modified = true;
const nextAction = { ...action };
delete nextAction.iconImage;
return nextAction;
});
}
if (modified) {
home.floatingContact = floatingContact;
home.markModified("floatingContact");
await home.save();
updatedCount += 1;
}
}
console.log(
`Removed floatingContact trigger/action image fields from ${updatedCount} Home document(s).`,
);
} catch (error) {
console.error("Failed to rollback floatingContact icon image fields:", error);
throw error;
} finally {
await mongoose.disconnect();
}
}
module.exports = { up, down };

View File

@@ -0,0 +1,46 @@
const mongoose = require("mongoose");
const connectDB = require("../config/database");
async function up() {
await connectDB();
try {
const collection = mongoose.connection.db.collection("headermenus");
const result = await collection.updateMany(
{ is_maintainance: { $exists: false } },
{ $set: { is_maintainance: false } },
);
console.log(
`Added is_maintainance=false to ${result.modifiedCount || 0} HeaderMenu document(s).`,
);
} catch (error) {
console.error("Failed to add is_maintainance to HeaderMenu documents:", error);
throw error;
} finally {
await mongoose.disconnect();
}
}
async function down() {
await connectDB();
try {
const collection = mongoose.connection.db.collection("headermenus");
const result = await collection.updateMany(
{ is_maintainance: { $exists: true } },
{ $unset: { is_maintainance: "" } },
);
console.log(
`Removed is_maintainance from ${result.modifiedCount || 0} HeaderMenu document(s).`,
);
} catch (error) {
console.error("Failed to rollback is_maintainance on HeaderMenu documents:", error);
throw error;
} finally {
await mongoose.disconnect();
}
}
module.exports = { up, down };

View File

@@ -0,0 +1,169 @@
const mongoose = require("mongoose");
const connectDB = require("../config/database");
const AboutUs = require("../models/aboutUs");
const jsonHelper = require("../utils/jsonHelper");
const GENERIC_PLACEHOLDER = "7281.jpg";
const ABOUT_DEFAULTS = {
heroBackground: "/uploads/about/breadcrumb.jpg",
featuresBackground: "/assets/img/home-3/choose-us/pricing-bg.jpg",
featuresImage: "/uploads/about/businessman.jpg",
missionIcons: [
"/assets/img/home-3/choose-us/icon-1.png",
"/assets/img/home-3/choose-us/icon-2.png",
"/assets/img/home-3/choose-us/icon-3.png",
],
featureIcons: [
"/assets/img/home-3/choose-us/icon-1.png",
"/assets/img/home-3/choose-us/icon-2.png",
"/assets/img/home-3/choose-us/icon-3.png",
],
};
const trimString = (value) =>
typeof value === "string" ? value.trim() : "";
const normalizePath = (value) => {
const trimmed = trimString(value);
if (!trimmed) {
return "";
}
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
return trimmed;
}
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
};
const isPlaceholderLike = (value) => {
const normalized = normalizePath(value).toLowerCase();
return !normalized || normalized.endsWith(`/${GENERIC_PLACEHOLDER}`);
};
const normalizeButton = (value = {}) => ({
label: trimString(value?.label),
href: trimString(value?.href),
});
const repairIconItems = (items, fallbacks) =>
Array.isArray(items)
? items.map((item, index) => ({
...item,
icon: isPlaceholderLike(item?.icon)
? fallbacks[index % fallbacks.length]
: normalizePath(item?.icon),
}))
: [];
const buildCanonicalAboutData = (source = {}) => ({
hero: {
title: trimString(source?.hero?.title),
breadcrumb: Array.isArray(source?.hero?.breadcrumb)
? source.hero.breadcrumb.map(trimString).filter(Boolean)
: [],
backgroundImage: isPlaceholderLike(source?.hero?.backgroundImage)
? ABOUT_DEFAULTS.heroBackground
: normalizePath(source?.hero?.backgroundImage),
},
intro: {
subheading: trimString(source?.intro?.subheading),
heading: trimString(source?.intro?.heading),
description: trimString(source?.intro?.description),
image: normalizePath(source?.intro?.image),
},
mission: {
subheading: trimString(source?.mission?.subheading),
heading: trimString(source?.mission?.heading),
description: trimString(source?.mission?.description),
images: {
main: normalizePath(source?.mission?.images?.main),
secondary: normalizePath(source?.mission?.images?.secondary),
},
items: repairIconItems(source?.mission?.items, ABOUT_DEFAULTS.missionIcons)
.map((item) => ({
icon: item.icon,
label: trimString(item?.label),
description: trimString(item?.description),
}))
.filter((item) => item.icon || item.label || item.description),
features: Array.isArray(source?.mission?.features)
? source.mission.features.map(trimString).filter(Boolean)
: [],
ctaButton: normalizeButton(source?.mission?.ctaButton),
},
features: {
backgroundImage: isPlaceholderLike(source?.features?.backgroundImage)
? ABOUT_DEFAULTS.featuresBackground
: normalizePath(source?.features?.backgroundImage),
subheading: trimString(source?.features?.subheading),
heading: trimString(source?.features?.heading),
description: trimString(source?.features?.description),
image: isPlaceholderLike(source?.features?.image)
? ABOUT_DEFAULTS.featuresImage
: normalizePath(source?.features?.image),
items: repairIconItems(source?.features?.items, ABOUT_DEFAULTS.featureIcons)
.map((item) => ({
icon: item.icon,
title: trimString(item?.title),
description: trimString(item?.description),
}))
.filter((item) => item.icon || item.title || item.description),
ctaButton: normalizeButton(source?.features?.ctaButton),
},
news: {
subheading: trimString(source?.news?.subheading),
heading: trimString(source?.news?.heading),
ctaButton: normalizeButton(source?.news?.ctaButton),
selectedBlogIds: Array.isArray(source?.news?.selectedBlogIds)
? source.news.selectedBlogIds.filter(Boolean)
: [],
items: [],
},
});
async function up() {
await connectDB();
try {
const doc = await AboutUs.getSingle();
const repaired = buildCanonicalAboutData(doc.toObject());
doc.set(repaired);
await doc.save();
jsonHelper.writeJsonFile("about", repaired);
console.log("✓ Repaired About CMS contract");
console.log(` - Database: ${mongoose.connection.db.databaseName}`);
console.log(" - Canonicalized About singleton fields");
console.log(" - Backfilled hero/features/images/icons when placeholder-like");
} catch (error) {
console.error("✗ Failed to repair About contract:", error);
throw error;
} finally {
await mongoose.disconnect();
}
}
async function down() {
console.log(
" Rollback skipped for 2026_04_10_210000_repair_about_contract because the migration normalizes live content in place.",
);
}
if (require.main === module) {
up()
.then(() => {
console.log("\n✓ Migration script completed");
process.exit(0);
})
.catch((error) => {
console.error("\n✗ Migration script failed:", error);
process.exit(1);
});
}
module.exports = { up, down };

View File

@@ -0,0 +1,22 @@
require('dotenv').config();
const mongoose = require('mongoose');
const Home = require('../models/home');
mongoose.connect(process.env.MONGODB_URI).then(async () => {
const docs = await Home.find().sort({ updatedAt: -1 }).lean();
console.log('Total docs:', docs.length);
if (docs.length <= 1) {
console.log('Nothing to clean up.');
return;
}
const keep = docs[0];
const idsToDelete = docs.slice(1).map(d => d._id);
await Home.deleteMany({ _id: { $in: idsToDelete } });
console.log('Kept doc:', keep._id, '| hero.enabled:', keep.hero?.enabled);
console.log('Deleted', idsToDelete.length, 'duplicate docs');
await mongoose.disconnect();
});

View File

@@ -0,0 +1,34 @@
/**
* One-time script: sync service menu items from DB into HeaderMenu.
* Run: node scripts/sync-service-menu-now.js
*/
const mongoose = require("mongoose");
const dotenv = require("dotenv");
dotenv.config();
const Service = require("../models/service");
const syncServiceMenu = require("../services/syncServiceMenu");
const MONGODB_URI = process.env.MONGODB_URI || "mongodb://localhost:27017/hailearning";
async function run() {
await mongoose.connect(MONGODB_URI);
console.log("✅ Connected to MongoDB");
const serviceDoc = await Service.findOne().lean();
if (!serviceDoc?.services?.items?.length) {
console.log("⚠️ No services found in DB.");
process.exit(0);
}
console.log(`Found ${serviceDoc.services.items.length} services. Syncing menu...`);
await syncServiceMenu(serviceDoc.services.items);
console.log("✅ Done.");
process.exit(0);
}
run().catch((err) => {
console.error("❌ Error:", err);
process.exit(1);
});

View File

@@ -0,0 +1,57 @@
/**
* Sync HeaderMenu children of the "Services" menu item
* to match the current list of services in the database.
*
* Strategy:
* - Find the HeaderMenu item whose url === '/services'
* - Delete all its direct children
* - Re-create one child per service item (url = /services/<slug>)
*/
const HeaderMenu = require("../models/headerMenu");
const slugify = require("slugify");
/**
* @param {Array} serviceItems - array of service objects { slug, name }
*/
const syncServiceMenu = async (serviceItems = []) => {
try {
// 1. Find the "Services" parent menu item
const servicesParent = await HeaderMenu.findOne({ url: "/services" });
if (!servicesParent) {
console.warn("[syncServiceMenu] No HeaderMenu item with url=/services found. Skipping sync.");
return;
}
const parentId = servicesParent._id;
// 2. Remove all existing children of that parent
await HeaderMenu.deleteMany({ parentId });
// 3. Re-create one child per service
const ops = serviceItems
.filter((s) => s && s.slug && s.name)
.map((s, index) => ({
title: s.name,
slug: slugify(s.name, { lower: true, strict: true }),
url: `/services/${s.slug}`,
parentId,
order: index + 1,
status: "active",
type: "internal",
is_maintainance: false,
}));
if (ops.length > 0) {
await HeaderMenu.insertMany(ops);
}
console.log(`[syncServiceMenu] Synced ${ops.length} service menu items under parentId=${parentId}`);
} catch (err) {
// Non-fatal log but don't crash the main request
console.error("[syncServiceMenu] Error syncing service menu:", err.message);
}
};
module.exports = syncServiceMenu;

158
utils/lengthValidation.js Normal file
View File

@@ -0,0 +1,158 @@
const DEFAULT_LABEL = "Field";
const normalizePath = (path) => {
return String(path || "")
.replace(/\[(\d+)\]/g, ".$1")
.replace(/\[\*\]/g, ".*")
.replace(/\[\]/g, ".*")
.split(".")
.filter(Boolean);
};
const toLabel = (path) => {
if (!path) {
return DEFAULT_LABEL;
}
return String(path)
.replace(/\[\d+\]/g, "")
.replace(/\.\*/g, "")
.replace(/[._-]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/^./, (ch) => ch.toUpperCase());
};
const collectMatches = (value, segments, currentPath = [], results = []) => {
if (segments.length === 0) {
results.push({
path: currentPath.join("."),
value,
});
return results;
}
const [segment, ...rest] = segments;
if (segment === "*") {
if (!Array.isArray(value)) {
return results;
}
value.forEach((item, index) => {
collectMatches(item, rest, [...currentPath, String(index)], results);
});
return results;
}
if (value === null || value === undefined) {
return results;
}
if (typeof value !== "object") {
return results;
}
if (!Object.prototype.hasOwnProperty.call(value, segment)) {
return results;
}
return collectMatches(value[segment], rest, [...currentPath, segment], results);
};
const countWords = (value) => {
const normalized = String(value || "")
.replace(/\s+/g, " ")
.trim();
if (!normalized) {
return 0;
}
return normalized.split(" ").length;
};
const buildErrorMessage = (label, path, maxLength, maxWords) => {
const target = path ? `${label} (${path})` : label;
if (maxLength && maxWords) {
return `${target} must not exceed ${maxLength} characters or ${maxWords} words.`;
}
if (maxLength) {
return `${target} must not exceed ${maxLength} characters.`;
}
return `${target} must not exceed ${maxWords} words.`;
};
const validateLengthRules = (payload, rules = []) => {
const errors = [];
for (const rule of rules) {
const paths = Array.isArray(rule.paths)
? rule.paths
: [rule.path].filter(Boolean);
for (const path of paths) {
const segments = normalizePath(path);
const matches = collectMatches(payload, segments);
for (const match of matches) {
if (typeof match.value !== "string") {
continue;
}
const normalized = match.value.trim();
if (!normalized && rule.allowEmpty !== false) {
continue;
}
const actualLength = normalized.length;
const actualWords = countWords(normalized);
const maxLength = Number(rule.maxLength);
const maxWords = Number(rule.maxWords);
const exceedsLength = Number.isFinite(maxLength) && maxLength > 0 && actualLength > maxLength;
const exceedsWords = Number.isFinite(maxWords) && maxWords > 0 && actualWords > maxWords;
if (!exceedsLength && !exceedsWords) {
continue;
}
const label = rule.label || toLabel(path);
errors.push({
path: match.path,
label,
message: buildErrorMessage(label, match.path, exceedsLength ? maxLength : null, exceedsWords ? maxWords : null),
maxLength: Number.isFinite(maxLength) ? maxLength : undefined,
maxWords: Number.isFinite(maxWords) ? maxWords : undefined,
actualLength,
actualWords,
});
}
}
}
return {
valid: errors.length === 0,
errors,
};
};
const summarizeLengthErrors = (validation, limit = 1) => {
if (!validation || !Array.isArray(validation.errors) || validation.errors.length === 0) {
return "";
}
return validation.errors
.slice(0, limit)
.map((error) => error.message)
.join(" ");
};
module.exports = {
validateLengthRules,
summarizeLengthErrors,
toLabel,
};

View File

@@ -4,11 +4,11 @@
<h1 class="h3 mb-0" style="color: var(--primary-dark);"> <h1 class="h3 mb-0" style="color: var(--primary-dark);">
<%= title %> <%= title %>
</h1> </h1>
<p class="text-muted mb-0">Edit content displayed on About Us page</p> <p class="text-muted mb-0">Edit content displayed on the About page</p>
</div> </div>
<div> <div>
<a href="<%= frontendUrl %>/about-us/" class="btn btn-outline-primary" target="_blank"> <a href="<%= frontendUrl %>/about" class="btn btn-outline-primary" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>View About Us Page <i class="fas fa-external-link-alt me-2"></i>View About Page
</a> </a>
</div> </div>
</div> </div>
@@ -41,14 +41,9 @@
aria-selected="false"><i class="fas fa-info-circle me-2"></i>Intro</a> aria-selected="false"><i class="fas fa-info-circle me-2"></i>Intro</a>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<a class="nav-link <%= locals.activeTab === 'mission' ? 'active' : '' %>" id="mission-tab" <a class="nav-link <%= locals.activeTab === 'mission-vision' ? 'active' : '' %>" id="mission-vision-tab"
data-bs-toggle="tab" href="#mission" role="tab" data-bs-toggle="tab" href="#mission-vision" role="tab"
aria-selected="false"><i class="fas fa-bullseye me-2"></i>Mission</a> aria-selected="false"><i class="fas fa-bullseye me-2"></i>Mission & Vision</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link <%= locals.activeTab === 'features' ? 'active' : '' %>" id="features-tab"
data-bs-toggle="tab" href="#features" role="tab"
aria-selected="false"><i class="fas fa-star me-2"></i>Features</a>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<a class="nav-link <%= locals.activeTab === 'news' ? 'active' : '' %>" id="news-tab" <a class="nav-link <%= locals.activeTab === 'news' ? 'active' : '' %>" id="news-tab"
@@ -89,6 +84,7 @@
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
</button> </button>
</div> </div>
<small class="text-muted d-block mt-1">Recommended: 1920x640px wide banner (3:1). Keep the focal subject centered because the image is cropped responsively.</small>
<% if (data.hero?.backgroundImage) { %> <% if (data.hero?.backgroundImage) { %>
<img src="<%= data.hero.backgroundImage %>" <img src="<%= data.hero.backgroundImage %>"
class="img-thumbnail uploaded-preview mt-2" class="img-thumbnail uploaded-preview mt-2"
@@ -134,6 +130,7 @@
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
</button> </button>
</div> </div>
<small class="text-muted d-block mt-1">Recommended: 1600x900px landscape image (16:9). This section uses a full-width cover frame.</small>
<% if (data.intro?.image) { %> <% if (data.intro?.image) { %>
<img src="<%= data.intro.image %>" <img src="<%= data.intro.image %>"
class="img-thumbnail uploaded-preview mt-2" class="img-thumbnail uploaded-preview mt-2"
@@ -146,7 +143,7 @@
</div> </div>
<!-- Mission Tab --> <!-- Mission Tab -->
<div class="tab-pane fade <%= locals.activeTab === 'mission' ? 'show active' : '' %>" id="mission" role="tabpanel"> <div class="tab-pane fade <%= locals.activeTab === 'mission-vision' ? 'show active' : '' %>" id="mission-vision" role="tabpanel">
<div class="card border shadow-sm"> <div class="card border shadow-sm">
<div class="card-header bg-white"> <div class="card-header bg-white">
<h6 class="mb-0"><i class="fas fa-bullseye me-2"></i>Mission Section</h6> <h6 class="mb-0"><i class="fas fa-bullseye me-2"></i>Mission Section</h6>
@@ -180,15 +177,20 @@
<h6 class="mt-4 mb-3">Images</h6> <h6 class="mt-4 mb-3">Images</h6>
<div class="row g-3"> <div class="row g-3">
<% ['main', 'secondary', 'bgShape', 'planeShape', 'topShape', 'globeShape'].forEach(imgKey => { %> <% ['main', 'secondary'].forEach(imgKey => { %>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label"><%= imgKey.charAt(0).toUpperCase() + imgKey.slice(1) %></label> <label class="form-label"><%= imgKey.charAt(0).toUpperCase() + imgKey.slice(1) %> Image</label>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" id="missionImg_<%= imgKey %>" value="<%= data.mission?.images?.[imgKey] || '' %>"> <input type="text" class="form-control" id="missionImg_<%= imgKey %>" value="<%= data.mission?.images?.[imgKey] || '' %>">
<button class="btn btn-outline-primary btn-upload-image btn-sm" type="button" data-target-input="missionImg_<%= imgKey %>" data-image-type="about"> <button class="btn btn-outline-primary btn-upload-image btn-sm" type="button" data-target-input="missionImg_<%= imgKey %>" data-image-type="about">
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
</button> </button>
</div> </div>
<% if (imgKey === 'main') { %>
<small class="text-muted d-block mt-1">Recommended: 1200x1500px portrait image (4:5). This is the primary mission visual.</small>
<% } else { %>
<small class="text-muted d-block mt-1">Recommended: 1200x900px image (4:3). This image sits as the smaller overlapping card.</small>
<% } %>
</div> </div>
<% }) %> <% }) %>
</div> </div>
@@ -196,11 +198,12 @@
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-6"> <div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">Items (Icons & Labels)</label> <label class="form-label mb-0">Items</label>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="addMissionItem()"> <button type="button" class="btn btn-outline-primary btn-sm" onclick="addMissionItem()">
<i class="fas fa-plus me-1"></i>Add <i class="fas fa-plus me-1"></i>Add
</button> </button>
</div> </div>
<small class="text-muted d-block mb-2">Mission item icons are fixed by the frontend design and are not editable in CMS.</small>
<div id="missionItemsContainer"></div> <div id="missionItemsContainer"></div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
@@ -215,10 +218,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Features Tab -->
<div class="tab-pane fade <%= locals.activeTab === 'features' ? 'show active' : '' %>" id="features" role="tabpanel">
<div class="card border shadow-sm"> <div class="card border shadow-sm">
<div class="card-header bg-white"> <div class="card-header bg-white">
<h6 class="mb-0"><i class="fas fa-star me-2"></i>Features Section</h6> <h6 class="mb-0"><i class="fas fa-star me-2"></i>Features Section</h6>
@@ -245,6 +245,7 @@
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
</button> </button>
</div> </div>
<small class="text-muted d-block mt-1">Recommended: 1920x1080px background image (16:9). Darker imagery works best because the section adds a dark overlay.</small>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Side Image</label> <label class="form-label">Side Image</label>
@@ -254,6 +255,7 @@
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
</button> </button>
</div> </div>
<small class="text-muted d-block mt-1">Recommended: 1200x1500px portrait image (4:5). The image fills a tall cover frame on desktop.</small>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">CTA Button Label</label> <label class="form-label">CTA Button Label</label>
@@ -272,6 +274,7 @@
<i class="fas fa-plus me-1"></i>Add Item <i class="fas fa-plus me-1"></i>Add Item
</button> </button>
</div> </div>
<small class="text-muted d-block mb-2">Custom icons render at about 28x28px inside a 60x60 card. Use SVG or a square PNG/WebP at 64x64px or 128x128px.</small>
<div id="featureItemsContainer"></div> <div id="featureItemsContainer"></div>
</div> </div>
</div> </div>
@@ -320,7 +323,7 @@
<input class="form-check-input about-blog-checkbox" type="checkbox" value="<%= blog._id %>" <%= isSelected ? 'checked' : '' %> onclick="event.stopPropagation(); handleAboutCheckboxChange(this)"> <input class="form-check-input about-blog-checkbox" type="checkbox" value="<%= blog._id %>" <%= isSelected ? 'checked' : '' %> onclick="event.stopPropagation(); handleAboutCheckboxChange(this)">
</div> </div>
</div> </div>
<img src="<%= blog.featuredImage ? (blog.featuredImage.startsWith('http') ? blog.featuredImage : backendUrl + blog.featuredImage) : '/assets/img/placeholder.jpg' %>" class="card-img-top" style="height: 210px; object-fit: cover;"> <img src="<%= blog.featuredImage ? (blog.featuredImage.startsWith('http') ? blog.featuredImage : (blog.featuredImage.startsWith('/') ? backendUrl + blog.featuredImage : backendUrl + '/' + blog.featuredImage)) : '/assets/img/placeholder.jpg' %>" class="card-img-top" style="height: 210px; object-fit: cover;">
<div class="card-body p-2"> <div class="card-body p-2">
<h6 class="card-title small fw-bold mb-1" title="<%= blog.title %>" style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2.6em; line-height: 1.3em;"> <h6 class="card-title small fw-bold mb-1" title="<%= blog.title %>" style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2.6em; line-height: 1.3em;">
<%= blog.title %> <%= blog.title %>
@@ -361,11 +364,24 @@
</div> </div>
</div> </div>
<script id="serverAboutData" type="application/json">
<%- JSON.stringify(locals.data || {}) %>
</script>
<script> <script>
let originalFormData = null; let originalFormData = null;
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
originalFormData = <%- JSON.stringify(data) %>; // Lấy nội dung text từ thẻ script ẩn và parse nó thành Object
const dataElement = document.getElementById('serverAboutData');
try {
originalFormData = JSON.parse(dataElement.textContent || '{}');
} catch (error) {
console.error('Lỗi khi parse dữ liệu từ server:', error);
originalFormData = {}; // Giá trị mặc định an toàn nếu parse xịt
}
updateAllJsonInputs(originalFormData); updateAllJsonInputs(originalFormData);
initializeFormHandlers(); initializeFormHandlers();
}); });
@@ -403,7 +419,7 @@
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
showToast('Success', 'About Us updated successfully', 'success'); showToast('Success', 'About page updated successfully', 'success');
// Update the local state with returned data from server // Update the local state with returned data from server
// This ensures the UI is in sync with what was actually saved // This ensures the UI is in sync with what was actually saved
@@ -635,7 +651,7 @@
document.getElementById('missionCtaLabel').value = mission.ctaButton?.label || ''; document.getElementById('missionCtaLabel').value = mission.ctaButton?.label || '';
document.getElementById('missionCtaHref').value = mission.ctaButton?.href || ''; document.getElementById('missionCtaHref').value = mission.ctaButton?.href || '';
['main', 'secondary', 'bgShape', 'planeShape', 'topShape', 'globeShape'].forEach(k => { ['main', 'secondary'].forEach(k => {
const el = document.getElementById('missionImg_' + k); const el = document.getElementById('missionImg_' + k);
const val = mission.images?.[k] || ''; const val = mission.images?.[k] || '';
if (el) { if (el) {
@@ -706,18 +722,10 @@
<div class="card mb-2 mission-item"> <div class="card mb-2 mission-item">
<div class="card-body p-2"> <div class="card-body p-2">
<div class="row g-2"> <div class="row g-2">
<div class="col-md-4"> <div class="col-md-5">
<div class="input-group input-group-sm">
<input type="text" class="form-control" name="missionItemIcon_${idx}" placeholder="Icon path">
<button class="btn btn-outline-primary btn-upload-image" type="button" data-target-input="missionItemIcon_${idx}" data-image-type="about">
<i class="fas fa-upload"></i>
</button>
</div>
</div>
<div class="col-md-4">
<input type="text" class="form-control form-control-sm" name="missionItemLabel_${idx}" placeholder="Label"> <input type="text" class="form-control form-control-sm" name="missionItemLabel_${idx}" placeholder="Label">
</div> </div>
<div class="col-md-4"> <div class="col-md-7">
<input type="text" class="form-control form-control-sm" name="missionItemDesc_${idx}" placeholder="Description"> <input type="text" class="form-control form-control-sm" name="missionItemDesc_${idx}" placeholder="Description">
</div> </div>
</div> </div>
@@ -730,12 +738,11 @@
function populateMissionItems(items) { function populateMissionItems(items) {
const container = document.getElementById('missionItemsContainer'); const container = document.getElementById('missionItemsContainer');
container.innerHTML = ''; container.innerHTML = '';
items.forEach((item, i) => { items.forEach((item) => {
addMissionItem(); addMissionItem();
const last = container.lastElementChild; const last = container.lastElementChild;
last.querySelector(`[name="missionItemIcon_${i}"]`).value = item.icon || ''; last.querySelector(`[name^="missionItemLabel_"]`).value = item.label || item.title || '';
last.querySelector(`[name="missionItemLabel_${i}"]`).value = item.label || ''; last.querySelector(`[name^="missionItemDesc_"]`).value = item.description || '';
last.querySelector(`[name="missionItemDesc_${i}"]`).value = item.description || '';
}); });
} }
@@ -890,14 +897,10 @@
description: document.getElementById('missionDescription').value.trim(), description: document.getElementById('missionDescription').value.trim(),
images: { images: {
main: document.getElementById('missionImg_main').value.trim(), main: document.getElementById('missionImg_main').value.trim(),
secondary: document.getElementById('missionImg_secondary').value.trim(), secondary: document.getElementById('missionImg_secondary').value.trim()
bgShape: document.getElementById('missionImg_bgShape').value.trim(),
planeShape: document.getElementById('missionImg_planeShape').value.trim(),
topShape: document.getElementById('missionImg_topShape').value.trim(),
globeShape: document.getElementById('missionImg_globeShape').value.trim()
}, },
items: Array.from(document.querySelectorAll('.mission-item')).map(item => ({ items: Array.from(document.querySelectorAll('.mission-item')).map(item => ({
icon: item.querySelector('[name^="missionItemIcon_"]').value.trim(), icon: '/assets/img/home-1/icon/01.svg',
label: item.querySelector('[name^="missionItemLabel_"]').value.trim(), label: item.querySelector('[name^="missionItemLabel_"]').value.trim(),
description: item.querySelector('[name^="missionItemDesc_"]').value.trim() description: item.querySelector('[name^="missionItemDesc_"]').value.trim()
})).filter(i => i.label !== ''), })).filter(i => i.label !== ''),

View File

@@ -41,7 +41,9 @@
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" name="heroBannerImage" id="heroBannerImage" <input type="text" class="form-control" name="heroBannerImage" id="heroBannerImage"
value="<%= (data.hero && data.hero.bannerImage) || '' %>" value="<%= (data.hero && data.hero.bannerImage) || '' %>"
placeholder="/templates/yootheme/activities/activity-banner.jpg"> placeholder="/templates/yootheme/activities/activity-banner.jpg"
maxlength="255" data-maxlength="255"
data-admin-upload-guidance="Activity page hero-style image.|Recommended upload: at least 1920x700px.">
<button type="button" class="btn btn-outline-secondary" id="uploadHeroBannerBtn"> <button type="button" class="btn btn-outline-secondary" id="uploadHeroBannerBtn">
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
</button> </button>
@@ -235,7 +237,9 @@
<label class="form-label fw-medium">Image Path</label> <label class="form-label fw-medium">Image Path</label>
<input type="text" class="form-control" name="image" id="imageInput" <input type="text" class="form-control" name="image" id="imageInput"
value="<%= data.image || '' %>" value="<%= data.image || '' %>"
placeholder="e.g., yootheme/banner/b14.jpg"> placeholder="e.g., yootheme/banner/b14.jpg"
maxlength="255" data-maxlength="255"
data-admin-upload-guidance="Used in activity listing cards.|Recommended upload: a landscape image at 704x432px or larger.">
<small class="text-muted">Path to the main activity image (used in listings)</small> <small class="text-muted">Path to the main activity image (used in listings)</small>
</div> </div>
</div> </div>
@@ -271,7 +275,9 @@
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" name="campDetailHeroBgImage" id="campDetailHeroBgImage" <input type="text" class="form-control" name="campDetailHeroBgImage" id="campDetailHeroBgImage"
value="<%= (data.campDetail && data.campDetail.hero && data.campDetail.hero.bgImage) || '' %>" value="<%= (data.campDetail && data.campDetail.hero && data.campDetail.hero.bgImage) || '' %>"
placeholder="e.g., yootheme/banner/b1.jpg"> placeholder="e.g., yootheme/banner/b1.jpg"
maxlength="255" data-maxlength="255"
data-admin-upload-guidance="Activity page hero-style image.|Recommended upload: at least 1920x700px.">
<button type="button" class="btn btn-outline-secondary" id="uploadHeroBgBtn"><i class="fas fa-upload me-1"></i>Upload</button> <button type="button" class="btn btn-outline-secondary" id="uploadHeroBgBtn"><i class="fas fa-upload me-1"></i>Upload</button>
</div> </div>
<div class="mt-2" id="campDetailHeroBgPreviewWrapper" style="display: none;"> <div class="mt-2" id="campDetailHeroBgPreviewWrapper" style="display: none;">

View File

@@ -767,16 +767,16 @@
formData.append('image', file); formData.append('image', file);
try { try {
const response = await fetch('/admin/upload/image', { const response = await fetch(`/admin/upload/image?imageType=${encodeURIComponent(imageType || 'appointment')}`, {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
const result = await response.json(); const result = await response.json();
if (result.success && result.imagePath) { if (result.success && result.path) {
document.getElementById(targetInput).value = result.imagePath; document.getElementById(targetInput).value = result.path;
if (targetInput === 'heroBackgroundImage') { if (targetInput === 'heroBackgroundImage') {
updateHeroImagePreview(result.imagePath); updateHeroImagePreview(result.path);
} }
} else { } else {
alert('Upload failed: ' + (result.error || 'Unknown error')); alert('Upload failed: ' + (result.error || 'Unknown error'));

View File

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

View File

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

View File

@@ -2000,13 +2000,13 @@
} }
if (window.toastManager) window.toastManager.success('Upload thành công'); if (window.toastManager) window.toastManager.success('Upload completed successfully');
} else { } else {
if (window.toastManager) window.toastManager.success('Upload thành công'); if (window.toastManager) window.toastManager.success('Upload completed successfully');
} }
} catch (err) { } catch (err) {
console.error('Upload error', err); console.error('Upload error', err);
if (window.toastManager) window.toastManager.error('Lỗi upload: ' + (err.message || if (window.toastManager) window.toastManager.error('Upload error: ' + (err.message ||
err)); err));
} }
}); });
@@ -2015,7 +2015,7 @@
fileInput.click(); fileInput.click();
} catch (err) { } catch (err) {
console.error('openImageUploader error', err); console.error('openImageUploader error', err);
if (window.toastManager) window.toastManager.error('Lỗi: ' + (err.message || err)); if (window.toastManager) window.toastManager.error('Error: ' + (err.message || err));
} }
} }

View File

@@ -7,7 +7,7 @@
<p class="text-muted mb-0">Edit content displayed on Contact Us page</p> <p class="text-muted mb-0">Edit content displayed on Contact Us page</p>
</div> </div>
<div> <div>
<a href="<%= frontendUrl %>/contact-us/" class="btn btn-outline-primary" target="_blank"> <a href="<%= frontendUrl %>/contact" class="btn btn-outline-primary" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>View Contact Us Page <i class="fas fa-external-link-alt me-2"></i>View Contact Us Page
</a> </a>
</div> </div>
@@ -66,7 +66,8 @@
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="heroBackgroundImage" <input type="text" class="form-control" id="heroBackgroundImage"
name="heroBackgroundImage" name="heroBackgroundImage"
value="<%= data.hero?.backgroundImage || '' %>"> value="<%= data.hero?.backgroundImage || '' %>"
maxlength="255" data-maxlength="255">
<button type="button" <button type="button"
class="btn btn-outline-primary btn-upload-image" class="btn btn-outline-primary btn-upload-image"
data-target-input="heroBackgroundImage" data-target-input="heroBackgroundImage"
@@ -74,7 +75,7 @@
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
</button> </button>
</div> </div>
<small class="text-muted">Recommended size: 1920x1080px</small> <small class="text-muted d-block mt-1">Recommended minimum upload: 1920x700px.</small>
</div> </div>
<div class="col-md-7"> <div class="col-md-7">
<div id="heroImagePreview" style="height: 300px;"> <div id="heroImagePreview" style="height: 300px;">
@@ -106,7 +107,9 @@
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Title</label> <label class="form-label fw-medium">Title</label>
<input type="text" class="form-control" id="heroTitle" name="heroTitle" <input type="text" class="form-control" id="heroTitle" name="heroTitle"
value="<%= data.hero?.title || '' %>"> value="<%= data.hero?.title || '' %>"
maxlength="40" data-maxlength="40">
<small class="text-muted d-block mt-1">Keep the hero title short so the centered breadcrumb stays balanced on tablet and mobile.</small>
</div> </div>
</div> </div>
<!-- Hidden field for overlayColor - keep default value --> <!-- Hidden field for overlayColor - keep default value -->
@@ -176,7 +179,8 @@
<label class="form-label">Title</label> <label class="form-label">Title</label>
<input type="text" class="form-control" <input type="text" class="form-control"
name="cardTitle_<%= index %>" name="cardTitle_<%= index %>"
value="<%= card.title || '' %>"> value="<%= card.title || '' %>"
maxlength="40" data-maxlength="40">
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label">Icon Type</label> <label class="form-label">Icon Type</label>
@@ -324,7 +328,8 @@
class="form-control card-icon-image-input" class="form-control card-icon-image-input"
name="cardIconImage_<%= index %>" name="cardIconImage_<%= index %>"
value="<%= imageIconValue %>" value="<%= imageIconValue %>"
placeholder="/uploads/icon.png"> placeholder="/uploads/icon.png"
maxlength="255" data-maxlength="255">
<button type="button" <button type="button"
class="btn btn-outline-primary btn-upload-image" class="btn btn-outline-primary btn-upload-image"
data-target-input="cardIconImage_<%= index %>" data-target-input="cardIconImage_<%= index %>"
@@ -347,9 +352,7 @@
style="max-height: 100px; width: auto; display: none;" style="max-height: 100px; width: auto; display: none;"
alt="Icon preview"> alt="Icon preview">
<% } %> <% } %>
<small class="text-muted">Upload <small class="text-muted d-block mt-1">Custom icons render at about 28x28px inside a 64x64 circle. Use SVG or a square PNG/WebP at 64x64px or 128x128px.</small>
a custom icon image for this
contact card</small>
</div> </div>
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
@@ -357,9 +360,8 @@
line)</label> line)</label>
<textarea class="form-control" <textarea class="form-control"
name="cardContent_<%= index %>" name="cardContent_<%= index %>"
rows="3"><%= (card.content || []).join('\n') %></textarea> rows="3" maxlength="220" data-maxlength="220"><%= (card.content || []).join('\n') %></textarea>
<small class="text-muted">Enter each content <small class="text-muted d-block mt-1">Each line is shown inside a compact contact card. Keep it to 1-3 short lines.</small>
item on a new line</small>
</div> </div>
</div> </div>
<button type="button" <button type="button"
@@ -386,23 +388,24 @@
<label class="form-label">Marker Title</label> <label class="form-label">Marker Title</label>
<input type="text" class="form-control" id="mapMarkerTitle" <input type="text" class="form-control" id="mapMarkerTitle"
value="<%= data.map?.markerTitle || '' %>" value="<%= data.map?.markerTitle || '' %>"
placeholder="e.g., Our Office"> placeholder="e.g., Our Office"
maxlength="48" data-maxlength="48">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Location</label> <label class="form-label">Location</label>
<input type="text" class="form-control" id="mapLocation" <input type="text" class="form-control" id="mapLocation"
value="<%= data.map?.location || '' %>" value="<%= data.map?.location || '' %>"
placeholder="e.g., 123 Main St, City, Country"> placeholder="e.g., 123 Main St, City, Country"
<small class="text-muted">Enter address - map will be automatically maxlength="120" data-maxlength="120">
shown</small> <small class="text-muted d-block mt-1">Enter a full address. This text is used for map lookup and should stay concise.</small>
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label">Google Map Embed URL</label> <label class="form-label">Google Map Embed URL</label>
<input type="text" class="form-control" id="mapEmbedUrl" <input type="text" class="form-control" id="mapEmbedUrl"
value="<%= data.map?.embedUrl || '' %>" value="<%= data.map?.embedUrl || '' %>"
placeholder="https://www.google.com/maps/embed?..."> placeholder="https://www.google.com/maps/embed?..."
<small class="text-muted">Paste embed URL from Google Maps (Share -> maxlength="1000" data-maxlength="1000">
Embed a map)</small> <small class="text-muted d-block mt-1">Paste the Google Maps embed URL from Share -> Embed a map.</small>
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<div id="mapPreview" <div id="mapPreview"
@@ -468,22 +471,29 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Section Label</label> <label class="form-label">Section Label</label>
<input type="text" class="form-control" id="formSectionLabel" <input type="text" class="form-control" id="formSectionLabel"
value="<%= data.form?.sectionLabel || '' %>"> value="<%= data.form?.sectionLabel || '' %>"
maxlength="32" data-maxlength="32">
<small class="text-muted d-block mt-1">Legacy label. Keep it short if you still use it in future templates.</small>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Submit Button Text</label> <label class="form-label">Submit Button Text</label>
<input type="text" class="form-control" id="formSubmitButtonText" <input type="text" class="form-control" id="formSubmitButtonText"
value="<%= data.form?.submitButton?.text || 'Send Message' %>"> value="<%= data.form?.submitButton?.text || 'Send Message' %>"
maxlength="24" data-maxlength="24">
<small class="text-muted d-block mt-1">Recommended maximum: 24 characters.</small>
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label">Heading</label> <label class="form-label">Heading</label>
<input type="text" class="form-control" id="formHeading" <input type="text" class="form-control" id="formHeading"
value="<%= data.form?.heading || '' %>"> value="<%= data.form?.heading || '' %>"
maxlength="48" data-maxlength="48">
<small class="text-muted d-block mt-1">The form heading spans the full form width. Recommended maximum: 48 characters.</small>
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label">Description</label> <label class="form-label">Description</label>
<textarea class="form-control" id="formDescription" <textarea class="form-control" id="formDescription"
rows="2"><%= data.form?.description || '' %></textarea> rows="2" maxlength="160" data-maxlength="160"><%= data.form?.description || '' %></textarea>
<small class="text-muted d-block mt-1">This line is centered under the form heading. Recommended maximum: 160 characters.</small>
</div> </div>
<!-- Hidden fields for submitButton icon and buttonClass --> <!-- Hidden fields for submitButton icon and buttonClass -->
<input type="hidden" id="formSubmitButtonIcon" <input type="hidden" id="formSubmitButtonIcon"
@@ -510,7 +520,9 @@
<input type="text" class="form-control" <input type="text" class="form-control"
name="fieldName_<%= index %>" name="fieldName_<%= index %>"
value="<%= field.name || '' %>" value="<%= field.name || '' %>"
placeholder="e.g., Your Name"> placeholder="e.g., Your Name"
maxlength="32" data-maxlength="32">
<small class="text-muted d-block mt-1">Keep field labels short for the stacked mobile form.</small>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Field Type</label> <label class="form-label">Field Type</label>
@@ -536,7 +548,8 @@
<label class="form-label">Placeholder</label> <label class="form-label">Placeholder</label>
<input type="text" class="form-control" <input type="text" class="form-control"
name="fieldPlaceholder_<%= index %>" name="fieldPlaceholder_<%= index %>"
value="<%= field.placeholder || '' %>"> value="<%= field.placeholder || '' %>"
maxlength="72" data-maxlength="72">
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label">Required</label> <label class="form-label">Required</label>
@@ -552,9 +565,9 @@
<input type="text" class="form-control" <input type="text" class="form-control"
name="fieldProgrammeName_<%= index %>" name="fieldProgrammeName_<%= index %>"
value="<%= field.programmeName || '' %>" value="<%= field.programmeName || '' %>"
placeholder="e.g., Summer Camp 2024"> placeholder="e.g., Summer Camp 2024"
<small class="text-muted">Internal name for the maxlength="48" data-maxlength="48">
programme</small> <small class="text-muted d-block mt-1">Internal programme reference only. Recommended maximum: 48 characters.</small>
</div> </div>
<!-- Hidden fields for label and colClass --> <!-- Hidden fields for label and colClass -->
<input type="hidden" name="fieldLabel_<%= index %>" <input type="hidden" name="fieldLabel_<%= index %>"
@@ -775,6 +788,7 @@
updateAllJsonInputs(originalFormData); updateAllJsonInputs(originalFormData);
initializeFormHandlers(); initializeFormHandlers();
initContactCharacterCounters(document);
}); });
function applyDateFilter() { function applyDateFilter() {
@@ -1195,6 +1209,14 @@
}); });
} }
function initContactCharacterCounters(scope = document) {
scope.querySelectorAll('.contact-limit-counter').forEach((node) => node.remove());
if (window.AdminFormHelpers) {
window.AdminFormHelpers.refresh(scope);
}
}
function updateAllJsonInputs(data) { function updateAllJsonInputs(data) {
document.getElementById('heroJson').value = JSON.stringify(data.hero || {}); document.getElementById('heroJson').value = JSON.stringify(data.hero || {});
document.getElementById('contactCardsJson').value = JSON.stringify(data.contactCards || []); document.getElementById('contactCardsJson').value = JSON.stringify(data.contactCards || []);
@@ -1252,7 +1274,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Title</label> <label class="form-label">Title</label>
<input type="text" class="form-control" name="cardTitle_${index}"> <input type="text" class="form-control" name="cardTitle_${index}" maxlength="40" data-maxlength="40">
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label">Icon Type</label> <label class="form-label">Icon Type</label>
@@ -1306,20 +1328,20 @@
<label class="form-label">Upload Icon Image</label> <label class="form-label">Upload Icon Image</label>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control card-icon-image-input" name="cardIconImage_${index}" <input type="text" class="form-control card-icon-image-input" name="cardIconImage_${index}"
placeholder="/uploads/icon.png"> placeholder="/uploads/icon.png" maxlength="255" data-maxlength="255">
<button type="button" class="btn btn-outline-primary btn-upload-image" <button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="cardIconImage_${index}" data-image-type="contact"> data-target-input="cardIconImage_${index}" data-image-type="contact">
<i class="fas fa-upload me-1"></i>Upload Icon <i class="fas fa-upload me-1"></i>Upload Icon
</button> </button>
</div> </div>
<img src="" class="img-thumbnail mt-2 icon-image-preview" data-index="${index}" style="max-height: 100px; width: auto; display: none;" alt="Icon preview"> <img src="" class="img-thumbnail mt-2 icon-image-preview" data-index="${index}" style="max-height: 100px; width: auto; display: none;" alt="Icon preview">
<small class="text-muted">Upload a custom icon image for this contact card</small> <small class="text-muted d-block mt-1">Custom icons render at about 28x28px inside a 64x64 circle. Use SVG or a square PNG/WebP at 64x64px or 128x128px.</small>
</div> </div>
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label">Content (one per line)</label> <label class="form-label">Content (one per line)</label>
<textarea class="form-control" name="cardContent_${index}" rows="3"></textarea> <textarea class="form-control" name="cardContent_${index}" rows="3" maxlength="220" data-maxlength="220"></textarea>
<small class="text-muted">Enter each content item on a new line</small> <small class="text-muted d-block mt-1">Each line is shown inside a compact contact card. Keep it to 1-3 short lines.</small>
</div> </div>
</div> </div>
<button type="button" class="btn btn-outline-danger btn-sm mt-3" onclick="removeContactCard(this)"> <button type="button" class="btn btn-outline-danger btn-sm mt-3" onclick="removeContactCard(this)">
@@ -1340,6 +1362,8 @@
openImageUploader(targetInput, imageType); openImageUploader(targetInput, imageType);
}); });
} }
initContactCharacterCounters(newCard);
} }
function handleIconSourceChange(radio) { function handleIconSourceChange(radio) {
@@ -1385,7 +1409,8 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Label</label> <label class="form-label">Label</label>
<input type="text" class="form-control" name="fieldName_${index}" placeholder="e.g., Your Name"> <input type="text" class="form-control" name="fieldName_${index}" placeholder="e.g., Your Name" maxlength="32" data-maxlength="32">
<small class="text-muted d-block mt-1">Keep field labels short for the stacked mobile form.</small>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Field Type</label> <label class="form-label">Field Type</label>
@@ -1399,7 +1424,7 @@
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Placeholder</label> <label class="form-label">Placeholder</label>
<input type="text" class="form-control" name="fieldPlaceholder_${index}"> <input type="text" class="form-control" name="fieldPlaceholder_${index}" maxlength="72" data-maxlength="72">
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label">Required</label> <label class="form-label">Required</label>
@@ -1409,8 +1434,8 @@
</div> </div>
<div class="col-md-3 programme-name-field" style="display: none;"> <div class="col-md-3 programme-name-field" style="display: none;">
<label class="form-label">Programme Name</label> <label class="form-label">Programme Name</label>
<input type="text" class="form-control" name="fieldProgrammeName_${index}" placeholder="e.g., Summer Camp 2024"> <input type="text" class="form-control" name="fieldProgrammeName_${index}" placeholder="e.g., Summer Camp 2024" maxlength="48" data-maxlength="48">
<small class="text-muted">Internal name for the programme</small> <small class="text-muted d-block mt-1">Internal programme reference only. Recommended maximum: 48 characters.</small>
</div> </div>
<!-- Hidden fields for label and colClass --> <!-- Hidden fields for label and colClass -->
<input type="hidden" name="fieldLabel_${index}" value=""> <input type="hidden" name="fieldLabel_${index}" value="">
@@ -1423,6 +1448,7 @@
</div> </div>
`; `;
container.insertAdjacentHTML('beforeend', html); container.insertAdjacentHTML('beforeend', html);
initContactCharacterCounters(container.lastElementChild);
} }
function removeFormField(button) { function removeFormField(button) {

View File

@@ -634,8 +634,8 @@
async function loadFooterData() { async function loadFooterData() {
try { try {
console.log("Fetching footer data from /api/footer..."); console.log("Fetching footer data from /admin/footer/data...");
const response = await fetch("/api/footer"); const response = await fetch("/admin/footer/data");
console.log("Response status:", response.status); console.log("Response status:", response.status);
console.log("Response ok:", response.ok); console.log("Response ok:", response.ok);

View File

@@ -13,6 +13,7 @@
<div class="content-with-fixed-buttons"> <div class="content-with-fixed-buttons">
<!-- Hidden inputs for JSON data --> <!-- Hidden inputs for JSON data -->
<input type="hidden" name="topbarJson" id="topbarJson" /> <input type="hidden" name="topbarJson" id="topbarJson" />
<input type="hidden" name="offcanvasJson" id="offcanvasJson" />
<input type="hidden" name="logo" id="logoInput" /> <input type="hidden" name="logo" id="logoInput" />
<input type="hidden" name="activeTab" id="activeTabInput" value="topbar" /> <input type="hidden" name="activeTab" id="activeTabInput" value="topbar" />
<input type="hidden" name="menuUpdates" id="menuUpdates" /> <input type="hidden" name="menuUpdates" id="menuUpdates" />
@@ -99,6 +100,25 @@
placeholder="69 Street, 5th Avenue LA, United States" placeholder="69 Street, 5th Avenue LA, United States"
/> />
</div> </div>
<div class="col-md-6">
<label class="form-label fw-medium">Working Hours <small class="text-muted fw-normal">(Drawer Menu)</small></label>
<input
type="text"
class="form-control"
id="offcanvasWorkingHours"
value="<%= (data.header && data.header.offcanvas && data.header.offcanvas.contactInfo && data.header.offcanvas.contactInfo.workingHours) ? data.header.offcanvas.contactInfo.workingHours : '' %>"
placeholder="Mon-Friday, 09am - 05pm"
/>
</div>
<div class="col-md-12">
<label class="form-label fw-medium">Offcanvas Description <small class="text-muted fw-normal">(Drawer Menu)</small></label>
<textarea
class="form-control"
id="offcanvasDescription"
rows="3"
placeholder="Short description displayed in the offcanvas sidebar..."
><%= (data.header && data.header.offcanvas && data.header.offcanvas.description) ? data.header.offcanvas.description : '' %></textarea>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -179,6 +199,7 @@
</div> </div>
</div> </div>
<!-- Menu Structure Tab --> <!-- Menu Structure Tab -->
<div class="tab-pane fade <%= activeTab === 'menu' ? 'show active' : '' %>" id="menu" role="tabpanel"> <div class="tab-pane fade <%= activeTab === 'menu' ? 'show active' : '' %>" id="menu" role="tabpanel">
<%- include('menu') %> <%- include('menu') %>
@@ -349,6 +370,7 @@
// 1. Collect and Save Topbar & Logo // 1. Collect and Save Topbar & Logo
const headerData = { const headerData = {
topbarJson: document.getElementById('topbarJson').value, topbarJson: document.getElementById('topbarJson').value,
offcanvasJson: document.getElementById('offcanvasJson').value,
logo: document.getElementById('logoInput').value, logo: document.getElementById('logoInput').value,
activeTab: document.getElementById('activeTabInput').value activeTab: document.getElementById('activeTabInput').value
}; };
@@ -382,7 +404,7 @@
} }
} catch (error) { } catch (error) {
console.error('=== TRACE: Unified Save ERROR ===', error); console.error('=== TRACE: Unified Save ERROR ===', error);
showNotification('Lỗi: ' + error.message, 'error'); showNotification('Error: ' + error.message, 'error');
} finally { } finally {
submitBtn.innerHTML = originalText; submitBtn.innerHTML = originalText;
submitBtn.disabled = false; submitBtn.disabled = false;
@@ -421,6 +443,18 @@
document.getElementById('topbarJson').value = JSON.stringify(topbarData); document.getElementById('topbarJson').value = JSON.stringify(topbarData);
document.getElementById('logoInput').value = document.getElementById('logoImage').value || ''; document.getElementById('logoInput').value = document.getElementById('logoImage').value || '';
// Collect offcanvas data — phone/email/address shared from topbar
const offcanvasData = {
description: document.getElementById('offcanvasDescription').value || '',
contactInfo: {
phone: document.getElementById('contactPhone').value || '',
email: document.getElementById('contactEmail').value || '',
address: document.getElementById('contactLocation').value || '',
workingHours: document.getElementById('offcanvasWorkingHours').value || ''
}
};
document.getElementById('offcanvasJson').value = JSON.stringify(offcanvasData);
try { try {
const menuUpdates = collectMenuUpdates(); const menuUpdates = collectMenuUpdates();
document.getElementById('menuUpdates').value = JSON.stringify(menuUpdates); document.getElementById('menuUpdates').value = JSON.stringify(menuUpdates);
@@ -675,19 +709,19 @@
const icon = document.getElementById('newSocialIcon').value.trim(); const icon = document.getElementById('newSocialIcon').value.trim();
if (!platform) { if (!platform) {
alert('Vui lòng nhập tên nền tảng'); alert('Please enter a platform name');
return; return;
} }
if (!url) { if (!url) {
alert('Vui lòng nhập URL'); alert('Please enter a URL');
return; return;
} }
// Check if platform already exists // Check if platform already exists
const existingPlatforms = Array.from(document.querySelectorAll('.social-platform')).map(el => el.value); const existingPlatforms = Array.from(document.querySelectorAll('.social-platform')).map(el => el.value);
if (existingPlatforms.includes(platform)) { if (existingPlatforms.includes(platform)) {
alert(`${platform} đã tồn tại`); alert(`${platform} already exists`);
return; return;
} }
@@ -746,12 +780,12 @@
const newIcon = document.getElementById('editSocialIcon').value.trim(); const newIcon = document.getElementById('editSocialIcon').value.trim();
if (!newPlatform) { if (!newPlatform) {
alert('Vui lòng nhập tên nền tảng'); alert('Please enter a platform name');
return; return;
} }
if (!newUrl) { if (!newUrl) {
alert('Vui lòng nhập URL'); alert('Please enter a URL');
return; return;
} }
@@ -1067,6 +1101,17 @@
</select> </select>
</div> </div>
</div> </div>
<div class="mb-3">
<label class="form-label fw-medium d-block">Maintenance Mode</label>
<input type="hidden" name="is_maintainance" value="false">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="is_maintainance" id="formMaintainance" value="true">
<label class="form-check-label" for="formMaintainance">
Redirect this menu page to the maintenance screen while it is being repaired.
</label>
</div>
<small class="text-muted">Use this when the linked page should be temporarily unavailable to visitors.</small>
</div>
<div class="mb-0"> <div class="mb-0">
<label class="form-label fw-medium">Link Type</label> <label class="form-label fw-medium">Link Type</label>
<div class="d-flex gap-3"> <div class="d-flex gap-3">

View File

@@ -42,6 +42,9 @@
<% } else { %> <% } else { %>
<span class="badge bg-soft-success ms-2">Active</span> <span class="badge bg-soft-success ms-2">Active</span>
<% } %> <% } %>
<% if (item.is_maintainance) { %>
<span class="badge ms-2 bg-warning text-dark">Maintenance</span>
<% } %>
</div> </div>
<div class="text-muted small text-truncate" style="max-width: 300px;"> <div class="text-muted small text-truncate" style="max-width: 300px;">
<i class="fas fa-link me-1" style="font-size: 0.75rem;"></i><%= item.url %> <i class="fas fa-link me-1" style="font-size: 0.75rem;"></i><%= item.url %>
@@ -192,6 +195,7 @@
document.getElementById('formUrl').value = ''; document.getElementById('formUrl').value = '';
document.getElementById('formOrder').value = '0'; document.getElementById('formOrder').value = '0';
document.getElementById('formStatus').value = 'active'; document.getElementById('formStatus').value = 'active';
document.getElementById('formMaintainance').checked = false;
document.getElementById('typeInternal').checked = true; document.getElementById('typeInternal').checked = true;
const modalElement = document.getElementById('modalAddMenu'); const modalElement = document.getElementById('modalAddMenu');
@@ -216,6 +220,7 @@
document.getElementById('formUrl').value = item.url; document.getElementById('formUrl').value = item.url;
document.getElementById('formOrder').value = item.order; document.getElementById('formOrder').value = item.order;
document.getElementById('formStatus').value = item.status; document.getElementById('formStatus').value = item.status;
document.getElementById('formMaintainance').checked = Boolean(item.is_maintainance);
if (item.type === 'external') { if (item.type === 'external') {
document.getElementById('typeExternal').checked = true; document.getElementById('typeExternal').checked = true;

View File

@@ -27,6 +27,7 @@
<input type="hidden" name="achievements" id="achievementsJson" /> <input type="hidden" name="achievements" id="achievementsJson" />
<input type="hidden" name="partners" id="partnersJson" /> <input type="hidden" name="partners" id="partnersJson" />
<input type="hidden" name="blogPreview" id="blogPreviewJson" /> <input type="hidden" name="blogPreview" id="blogPreviewJson" />
<input type="hidden" name="floatingContact" id="floatingContactJson" />
<!-- Navigation Tabs --> <!-- Navigation Tabs -->
<div class="card shadow-sm border-0 mb-4"> <div class="card shadow-sm border-0 mb-4">
@@ -82,6 +83,11 @@
<i class="fas fa-blog me-2"></i>Blog Preview <i class="fas fa-blog me-2"></i>Blog Preview
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#floatingcontact" role="tab">
<i class="fas fa-comment-dots me-2"></i>Floating Contact
</a>
</li>
</ul> </ul>
</div> </div>
@@ -97,6 +103,7 @@
<%- include('sections/achievements') %> <%- include('sections/achievements') %>
<%- include('sections/partners') %> <%- include('sections/partners') %>
<%- include('sections/blogPreview') %> <%- include('sections/blogPreview') %>
<%- include('sections/floatingContact') %>
</div> </div>
</div> </div>
</div> </div>
@@ -118,9 +125,14 @@
</div> </div>
<!-- Image upload input --> <!-- Image upload input -->
<input type="file" id="directImageUpload" style="display: none" /> <input
type="file"
id="directImageUpload"
style="display: none"
accept="image/*,.png,.jpg,.jpeg,.gif,.webp,.svg" />
<input type="hidden" id="currentImageType" name="imageType" /> <input type="hidden" id="currentImageType" name="imageType" />
<input type="hidden" id="currentTargetInput" name="targetInput" /> <input type="hidden" id="currentTargetInput" name="targetInput" />
<input type="hidden" id="currentResizePreset" name="resizePreset" />
<script> <script>
/** /**
@@ -132,13 +144,26 @@
* <\/script> * <\/script>
*/ */
window.homeScrapers = window.homeScrapers || {}; window.homeScrapers = window.homeScrapers || {};
const pendingImageUploads = new Map();
const pendingPreviewUrls = new Map();
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const form = document.querySelector("form"); const form = document.querySelector("form");
if (form) { if (form) {
form.addEventListener("submit", function (e) { form.addEventListener("submit", async function (e) {
if (form.dataset.submitting === "true") {
return;
}
e.preventDefault();
if (typeof form.reportValidity === "function" && !form.reportValidity()) {
return;
}
console.log("Form submitting, collecting data from scrapers..."); console.log("Form submitting, collecting data from scrapers...");
try {
await flushPendingImageUploads();
// Tự động thu gom dữ liệu từ các section đã đăng ký // Tự động thu gom dữ liệu từ các section đã đăng ký
Object.keys(window.homeScrapers).forEach(section => { Object.keys(window.homeScrapers).forEach(section => {
const input = document.getElementById(section + 'Json'); const input = document.getElementById(section + 'Json');
@@ -153,22 +178,197 @@
} }
}); });
// Để form tự submit tự nhiên sau khi đã điền xong các hidden inputs form.dataset.submitting = "true";
form.submit();
} catch (error) {
console.error("Error during deferred image uploads:", error);
showToast("Error", error.message || "Failed to upload pending images", "error");
}
}); });
} }
// Khởi tạo các nút upload ảnh (dùng chung cho toàn bộ các section) // Khởi tạo các nút upload ảnh (dùng chung cho toàn bộ các section)
initImageUploads(); initImageUploads();
initHomeCharacterCounters(document);
initImagePreviewFallbacks(document);
}); });
// --- UTILITIES (Dùng chung) --- // --- UTILITIES (Dùng chung) ---
function extractHtmlErrorMessage(html) {
if (!html) {
return "";
}
const preMatch = html.match(/<pre>([\s\S]*?)<\/pre>/i);
const titleMatch = html.match(/<title>([\s\S]*?)<\/title>/i);
const rawMessage = (preMatch && preMatch[1]) || (titleMatch && titleMatch[1]) || html;
const decoded = rawMessage
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&");
return decoded.replace(/\s+/g, " ").trim();
}
function findImagePreview(input) {
if (!input) {
return null;
}
return (
input.closest(".input-group")?.nextElementSibling?.querySelector("img") ||
input.parentElement?.nextElementSibling?.querySelector("img") ||
null
);
}
function clearImagePreviewError(previewImg) {
if (!previewImg?.parentElement) {
return;
}
previewImg.parentElement.querySelectorAll(".image-preview-missing").forEach((node) => node.remove());
}
function bindImagePreviewFallback(previewImg) {
if (!previewImg || previewImg.dataset.previewFallbackBound === "true") {
return;
}
previewImg.dataset.previewFallbackBound = "true";
previewImg.addEventListener("error", () => {
previewImg.classList.add("d-none");
previewImg.removeAttribute("src");
if (!previewImg.parentElement?.querySelector(".image-preview-missing")) {
const note = document.createElement("small");
note.className = "text-warning d-block mt-2 image-preview-missing";
note.textContent = "Preview unavailable: current image path could not be loaded.";
previewImg.parentElement?.appendChild(note);
}
});
}
function initImagePreviewFallbacks(scope = document) {
scope.querySelectorAll("img.img-thumbnail").forEach((previewImg) => {
bindImagePreviewFallback(previewImg);
if (previewImg.complete && previewImg.getAttribute("src") && previewImg.naturalWidth === 0) {
previewImg.dispatchEvent(new Event("error"));
}
});
}
function revokePendingPreview(targetInput) {
const previewUrl = pendingPreviewUrls.get(targetInput);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
pendingPreviewUrls.delete(targetInput);
}
}
function isFloatingContactTargetInput(targetInput) {
return typeof targetInput === "string" && targetInput.startsWith("floatingContact");
}
async function persistFloatingContactDraft() {
const scraper = window.homeScrapers && window.homeScrapers.floatingContact;
if (typeof scraper !== "function") {
throw new Error("Floating contact scraper is not available");
}
const response = await fetch("/admin/home/floating-contact/update", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
floatingContact: scraper(),
}),
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.success) {
throw new Error(result?.error || "Failed to save floating contact changes");
}
return result;
}
async function uploadPendingImage(targetInput, uploadConfig) {
const input = document.getElementById(targetInput);
if (!input || !uploadConfig?.file) {
pendingImageUploads.delete(targetInput);
revokePendingPreview(targetInput);
return;
}
const formData = new FormData();
formData.append("image", uploadConfig.file);
const query = new URLSearchParams({ imageType: uploadConfig.imageType });
if (uploadConfig.resizePreset) {
query.set("resizePreset", uploadConfig.resizePreset);
}
const response = await fetch(`/admin/upload/image?${query.toString()}`, {
method: "POST",
body: formData,
});
const rawResponse = await response.text();
let result = null;
try {
result = rawResponse ? JSON.parse(rawResponse) : null;
} catch (parseError) {
result = null;
}
if (!response.ok) {
throw new Error(
result?.error ||
extractHtmlErrorMessage(rawResponse) ||
`Upload failed with status ${response.status}`,
);
}
if (!result?.success || !result.path) {
throw new Error(result?.error || "Upload failed");
}
input.value = result.path;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
const previewImg = findImagePreview(input);
if (previewImg) {
clearImagePreviewError(previewImg);
bindImagePreviewFallback(previewImg);
previewImg.src = new URL(result.path, window.location.origin).toString();
previewImg.classList.remove("d-none");
}
pendingImageUploads.delete(targetInput);
revokePendingPreview(targetInput);
}
async function flushPendingImageUploads() {
for (const [targetInput, uploadConfig] of pendingImageUploads.entries()) {
await uploadPendingImage(targetInput, uploadConfig);
}
}
function initImageUploads() { function initImageUploads() {
document.addEventListener("click", function (e) { document.addEventListener("click", function (e) {
const btn = e.target.closest(".btn-upload-image"); const btn = e.target.closest(".btn-upload-image");
if (btn) { if (btn) {
document.getElementById("currentImageType").value = btn.dataset.imageType; document.getElementById("currentImageType").value = btn.dataset.imageType;
document.getElementById("currentTargetInput").value = btn.dataset.targetInput; document.getElementById("currentTargetInput").value = btn.dataset.targetInput;
document.getElementById("currentResizePreset").value = btn.dataset.resizePreset || "";
document.getElementById("directImageUpload").click(); document.getElementById("directImageUpload").click();
} }
}); });
@@ -184,28 +384,45 @@
const file = this.files[0]; const file = this.files[0];
const imageType = document.getElementById("currentImageType").value; const imageType = document.getElementById("currentImageType").value;
const targetInput = document.getElementById("currentTargetInput").value; const targetInput = document.getElementById("currentTargetInput").value;
const resizePreset = document.getElementById("currentResizePreset").value;
const allowedExtensions = /\.(jpe?g|png|gif|webp|svg)$/i;
if (!(file.type.startsWith("image/") || allowedExtensions.test(file.name))) {
showToast("Error", "Only image files are allowed", "error");
this.value = "";
return;
}
try { try {
const formData = new FormData();
formData.append("image", file);
const response = await fetch(`/admin/upload/image?imageType=${imageType}`, { method: "POST", body: formData });
const result = await response.json();
if (result.success && result.path) {
const input = document.getElementById(targetInput); const input = document.getElementById(targetInput);
if (input) { if (!input) {
input.value = result.path; throw new Error("Target image field not found");
// Cập nhật preview nếu có img ngay sau input group }
const previewImg = input.closest('.input-group')?.nextElementSibling?.querySelector('img');
revokePendingPreview(targetInput);
const previewUrl = URL.createObjectURL(file);
pendingPreviewUrls.set(targetInput, previewUrl);
const previewImg = findImagePreview(input);
if (previewImg) { if (previewImg) {
previewImg.src = result.path; clearImagePreviewError(previewImg);
previewImg.classList.remove('d-none'); bindImagePreviewFallback(previewImg);
previewImg.src = previewUrl;
previewImg.classList.remove("d-none");
} }
if (isFloatingContactTargetInput(targetInput)) {
pendingImageUploads.delete(targetInput);
await uploadPendingImage(targetInput, { file, imageType, resizePreset });
await persistFloatingContactDraft();
showToast("Success", "Image uploaded and saved immediately.", "success");
this.value = "";
return;
} }
showToast("Success", "Image uploaded successfully", "success");
} else { pendingImageUploads.set(targetInput, { file, imageType, resizePreset });
throw new Error(result.error || "Upload failed");
} showToast("Ready", "Image selected. It will be uploaded when you save changes.", "info");
} catch (error) { } catch (error) {
showToast("Error", "Upload failed: " + error.message, "error"); showToast("Error", "Upload failed: " + error.message, "error");
} }
@@ -228,4 +445,12 @@
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show(); new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
toast.addEventListener("hidden.bs.toast", () => toast.remove()); toast.addEventListener("hidden.bs.toast", () => toast.remove());
} }
function initHomeCharacterCounters(scope = document) {
scope.querySelectorAll(".home-limit-counter").forEach((node) => node.remove());
if (window.AdminFormHelpers) {
window.AdminFormHelpers.refresh(scope);
}
}
</script> </script>

View File

@@ -1,10 +1,19 @@
<!-- Achievements Tab --> <!-- Achievements Tab -->
<div class="tab-pane fade" id="achievements" role="tabpanel"> <div class="tab-pane fade" id="achievements" role="tabpanel">
<div class="row g-4"> <div class="row g-4">
<!-- Basic Info --> <!-- Basic Info -->
<div class="col-md-12"> <div class="col-md-12">
<div class="card border shadow-sm mb-1">
<div class="card-header bg-white d-flex justify-content-center align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="achievementEnabled"
<%=(data.achievements?.enabled !==false ) ? 'checked' : '' %>>
</div>
</div>
</div>
<div class="card border shadow-sm"> <div class="card border shadow-sm">
<div class="card-header bg-white"> <div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0"> <h6 class="mb-0">
<i class="fas fa-chart-pie me-2"></i>General Information <i class="fas fa-chart-pie me-2"></i>General Information
</h6> </h6>
@@ -13,23 +22,13 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Heading</label> <label class="form-label fw-medium">Heading</label>
<input <input type="text" class="form-control" id="achievementsHeading"
type="text" value="<%= data.achievements?.heading || '' %>" placeholder="e.g., Our Achievements in Numbers" />
class="form-control"
id="achievementsHeading"
value="<%= data.achievements?.heading || '' %>"
placeholder="e.g., Our Achievements in Numbers"
/>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Subheading</label> <label class="form-label fw-medium">Subheading</label>
<input <input type="text" class="form-control" id="achievementsSubheading"
type="text" value="<%= data.achievements?.subheading || '' %>" placeholder="e.g., Did You Know" />
class="form-control"
id="achievementsSubheading"
value="<%= data.achievements?.subheading || '' %>"
placeholder="e.g., Did You Know"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -45,49 +44,33 @@
</h6> </h6>
</div> </div>
<div class="card-body" id="achievementItemsContainer"> <div class="card-body" id="achievementItemsContainer">
<% for(let i=0; i<4; i++) { <% for(let i=0; i<4; i++) { const item=(data.achievements?.items && data.achievements.items[i]) || {}; %>
const item = (data.achievements?.items && data.achievements.items[i]) || {};
%>
<div class="card mb-3 bg-light border achievement-item"> <div class="card mb-3 bg-light border achievement-item">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="card-title fw-bold mb-0">Achievement #<%= i + 1 %></h6> <h6 class="card-title fw-bold mb-0">Achievement #<%= i + 1 %>
</h6>
</div> </div>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label fw-medium">Value (Number)</label> <label class="form-label fw-medium">Value (Number)</label>
<input <input type="text" class="form-control achievement-value" value="<%= item.value || '' %>"
type="text" placeholder="e.g., 95" />
class="form-control achievement-value"
value="<%= item.value || '' %>"
placeholder="e.g., 95"
/>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label fw-medium">Suffix</label> <label class="form-label fw-medium">Suffix</label>
<input <input type="text" class="form-control achievement-suffix" value="<%= item.suffix || '' %>"
type="text" placeholder="e.g., %" />
class="form-control achievement-suffix"
value="<%= item.suffix || '' %>"
placeholder="e.g., %"
/>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Label</label> <label class="form-label fw-medium">Label</label>
<input <input type="text" class="form-control achievement-label" value="<%= item.label || '' %>"
type="text" placeholder="e.g., Visa Success Rate" />
class="form-control achievement-label"
value="<%= item.label || '' %>"
placeholder="e.g., Visa Success Rate"
/>
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Description</label> <label class="form-label fw-medium">Description</label>
<textarea <textarea class="form-control achievement-description" rows="2"
class="form-control achievement-description" placeholder="Short description of this achievement"><%= item.description || '' %></textarea>
rows="2"
placeholder="Short description of this achievement"
><%= item.description || '' %></textarea>
</div> </div>
</div> </div>
</div> </div>
@@ -104,6 +87,9 @@
window.homeScrapers = window.homeScrapers || {}; window.homeScrapers = window.homeScrapers || {};
window.homeScrapers.achievements = function () { window.homeScrapers.achievements = function () {
const items = []; const items = [];
const enabled = document.getElementById("achievementEnabled")?.checked === true;
document.querySelectorAll('.achievement-item').forEach(el => { document.querySelectorAll('.achievement-item').forEach(el => {
items.push({ items.push({
value: el.querySelector('.achievement-value').value, value: el.querySelector('.achievement-value').value,
@@ -116,7 +102,8 @@
return { return {
heading: document.getElementById('achievementsHeading').value, heading: document.getElementById('achievementsHeading').value,
subheading: document.getElementById('achievementsSubheading').value, subheading: document.getElementById('achievementsSubheading').value,
items: items items: items,
enabled
}; };
}; };
</script> </script>

View File

@@ -1,8 +1,17 @@
<!-- Blog Preview Tab --> <!-- Blog Preview Tab -->
<div class="tab-pane fade" id="blogpreview" role="tabpanel"> <div class="tab-pane fade" id="blogpreview" role="tabpanel">
<div class="row g-4"> <div class="row g-4">
<!-- Basic Info --> <!-- Basic Info -->
<div class="col-md-12"> <div class="col-md-12">
<div class="card border shadow-sm mb-1">
<div class="card-header bg-white d-flex justify-content-center align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="blogpreviewEnabled"
<%=(data.blogPreview?.enabled !==false ) ? 'checked' : '' %>>
</div>
</div>
</div>
<div class="card border shadow-sm"> <div class="card border shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center"> <div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0"> <h6 class="mb-0">
@@ -16,12 +25,14 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Heading</label> <label class="form-label fw-medium">Heading</label>
<input type="text" class="form-control" id="blogPreviewHeading" <input type="text" class="form-control" id="blogPreviewHeading"
value="<%= data.blogPreview?.heading || '' %>" placeholder="e.g., Latest Insights & Updates" /> value="<%= data.blogPreview?.heading || '' %>" placeholder="e.g., Latest Insights & Updates"
maxlength="64" data-maxlength="64" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Subheading</label> <label class="form-label fw-medium">Subheading</label>
<input type="text" class="form-control" id="blogPreviewSubheading" <input type="text" class="form-control" id="blogPreviewSubheading"
value="<%= data.blogPreview?.subheading || '' %>" placeholder="e.g., Visa Tips & Guides" /> value="<%= data.blogPreview?.subheading || '' %>" placeholder="e.g., Visa Tips & Guides"
maxlength="40" data-maxlength="40" />
</div> </div>
<div class="col-md-12 mt-4"> <div class="col-md-12 mt-4">
@@ -88,12 +99,14 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Label</label> <label class="form-label fw-medium">Label</label>
<input type="text" class="form-control" id="blogPreviewCtaLabel" <input type="text" class="form-control" id="blogPreviewCtaLabel"
value="<%= data.blogPreview?.ctaButton?.label || '' %>" placeholder="e.g., View All Articles" /> value="<%= data.blogPreview?.ctaButton?.label || '' %>" placeholder="e.g., View All Articles"
maxlength="32" data-maxlength="32" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Link</label> <label class="form-label fw-medium">Link</label>
<input type="text" class="form-control" id="blogPreviewCtaHref" <input type="text" class="form-control" id="blogPreviewCtaHref"
value="<%= data.blogPreview?.ctaButton?.href || '' %>" placeholder="/blog" /> value="<%= data.blogPreview?.ctaButton?.href || '' %>" placeholder="/blog"
maxlength="255" data-maxlength="255" />
</div> </div>
</div> </div>
</div> </div>
@@ -144,6 +157,9 @@
window.homeScrapers = window.homeScrapers || {}; window.homeScrapers = window.homeScrapers || {};
window.homeScrapers.blogPreview = () => { window.homeScrapers.blogPreview = () => {
const selectedIds = []; const selectedIds = [];
const enabled = document.getElementById("blogpreviewEnabled")?.checked === true;
document.querySelectorAll('.blog-checkbox:checked').forEach(cb => { document.querySelectorAll('.blog-checkbox:checked').forEach(cb => {
selectedIds.push(cb.value); selectedIds.push(cb.value);
}); });
@@ -158,7 +174,8 @@
href: document.getElementById('blogPreviewCtaHref').value href: document.getElementById('blogPreviewCtaHref').value
}, },
selectedBlogIds: selectedIds, selectedBlogIds: selectedIds,
items: [] // Server side will handle full items content items: [],// Server side will handle full items content
enabled
}; };
}; };
</script> </script>

View File

@@ -1,30 +1,43 @@
<!-- FAQ Tab --> <!-- FAQ Tab -->
<div class="tab-pane fade" id="faq" role="tabpanel"> <div class="tab-pane fade" id="faq" role="tabpanel">
<div class="row g-4"> <div class="row g-4">
<!-- Basic Info --> <!-- Basic Info -->
<div class="col-md-12"> <div class="col-md-12">
<div class="card border shadow-sm mb-1">
<div class="card-header bg-white d-flex justify-content-center align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="faqEnabled"
<%= (data.faq?.enabled !== false ) ? 'checked' : '' %>>
</div>
</div>
</div>
<div class="card border shadow-sm"> <div class="card border shadow-sm">
<div class="card-header bg-white"> <div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0"> <h6 class="mb-0">
<i class="fas fa-info-circle me-2"></i>Basic Information <i class="fas fa-info-circle me-2"></i>Basic Information
</h6> </h6>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="faqEnabled"
<%= (data.faq?.enabled !== false ) ? 'checked' : '' %>>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Heading</label> <label class="form-label fw-medium">Heading</label>
<input type="text" class="form-control" id="faqHeading" value="<%= data.faq?.heading || '' %>" <input type="text" class="form-control" id="faqHeading" value="<%= data.faq?.heading || '' %>"
placeholder="e.g., Got Questions? We've Got Answers" /> placeholder="e.g., Got Questions? We've Got Answers" maxlength="64" data-maxlength="64" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Subheading</label> <label class="form-label fw-medium">Subheading</label>
<input type="text" class="form-control" id="faqSubheading" value="<%= data.faq?.subheading || '' %>" <input type="text" class="form-control" id="faqSubheading" value="<%= data.faq?.subheading || '' %>"
placeholder="e.g., Visa FAQs" /> placeholder="e.g., Visa FAQs" maxlength="40" data-maxlength="40" />
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Description</label> <label class="form-label fw-medium">Description</label>
<textarea class="form-control" id="faqDescription" rows="3" <textarea class="form-control" id="faqDescription" rows="3"
placeholder="Enter description"><%= data.faq?.description || '' %></textarea> placeholder="Enter description" maxlength="220" data-maxlength="220"><%= data.faq?.description || '' %></textarea>
</div> </div>
</div> </div>
</div> </div>
@@ -69,6 +82,8 @@
class="form-control" class="form-control"
id="faqQuestion_<%= index %>" id="faqQuestion_<%= index %>"
value="<%= item.question || '' %>" value="<%= item.question || '' %>"
maxlength="120"
data-maxlength="120"
placeholder="Enter question" placeholder="Enter question"
/> />
</div> </div>
@@ -78,6 +93,8 @@
class="form-control" class="form-control"
id="faqAnswer_<%= index %>" id="faqAnswer_<%= index %>"
rows="3" rows="3"
maxlength="320"
data-maxlength="320"
placeholder="Enter answer" placeholder="Enter answer"
><%= item.answer || '' %></textarea> ><%= item.answer || '' %></textarea>
</div> </div>
@@ -102,12 +119,12 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Label</label> <label class="form-label fw-medium">Label</label>
<input type="text" class="form-control" id="faqCtaLabel" value="<%= data.faq?.ctaButton?.label || '' %>" <input type="text" class="form-control" id="faqCtaLabel" value="<%= data.faq?.ctaButton?.label || '' %>"
placeholder="e.g., contact us" /> placeholder="e.g., contact us" maxlength="32" data-maxlength="32" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Link</label> <label class="form-label fw-medium">Link</label>
<input type="text" class="form-control" id="faqCtaHref" value="<%= data.faq?.ctaButton?.href || '' %>" <input type="text" class="form-control" id="faqCtaHref" value="<%= data.faq?.ctaButton?.href || '' %>"
placeholder="/contact" /> placeholder="/contact" maxlength="255" data-maxlength="255" />
</div> </div>
</div> </div>
</div> </div>
@@ -122,6 +139,8 @@
window.homeScrapers.faq = () => { window.homeScrapers.faq = () => {
const getVal = (id) => (document.getElementById(id)?.value || "").trim(); const getVal = (id) => (document.getElementById(id)?.value || "").trim();
const enabled = document.getElementById('faqEnabled')?.checked === true;
const items = []; const items = [];
document.querySelectorAll(".faq-item").forEach((el, idx) => { document.querySelectorAll(".faq-item").forEach((el, idx) => {
const index = el.getAttribute("data-index") || idx; const index = el.getAttribute("data-index") || idx;
@@ -142,7 +161,8 @@
label: getVal("faqCtaLabel"), label: getVal("faqCtaLabel"),
href: getVal("faqCtaHref") href: getVal("faqCtaHref")
}, },
items items,
enabled
}; };
}; };
</script> </script>

View File

@@ -0,0 +1,477 @@
<!-- Floating Contact Tab -->
<div class="tab-pane fade" id="floatingcontact" role="tabpanel">
<div class="row g-4">
<div class="col-md-12">
<div class="card border shadow-sm mb-1">
<div class="card-header bg-white d-flex justify-content-center align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="floatingContactEnabled"
<%=(data.floatingContact?.enabled !== false ) ? 'checked' : '' %>>
</div>
</div>
</div>
<div class="card border shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<i class="fas fa-sliders-h me-2"></i>Widget Settings
</h6>
<span class="badge bg-light text-dark border">Homepage floating contact widget</span>
</div>
<div class="card-body">
<div class="row g-4 align-items-start">
<div class="col-lg-8">
<label class="form-label fw-medium">Panel Title</label>
<input type="text" class="form-control" id="floatingContactPanelTitle"
value="<%= data.floatingContact?.panelTitle || '' %>"
placeholder="How can we help you today?"
maxlength="72"
data-maxlength="72" />
<small class="text-muted">Maximum 72 characters to keep the header from breaking the widget layout.</small>
</div>
<div class="col-lg-4">
<label class="form-label fw-medium">Brand Alt Text</label>
<input type="text" class="form-control" id="floatingContactBrandAlt"
value="<%= data.floatingContact?.brand?.imageAlt || 'HAI Learning' %>" placeholder="HAI Learning"
maxlength="60"
data-maxlength="60" />
<small class="text-muted d-block mt-2">Used for accessibility and fallback image descriptions.</small>
</div>
<div class="col-lg-6">
<label class="form-label fw-medium">Brand Image</label>
<div class="input-group mb-2">
<input type="text" class="form-control" id="floatingContactBrandImage"
value="<%= data.floatingContact?.brand?.imageSrc || '' %>" placeholder="/assets/img/logo/black-logo.svg"
maxlength="255" data-maxlength="255" />
<button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="floatingContactBrandImage" data-image-type="home/floating-contact"
data-resize-preset="floatingContactBrandImage">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
<div class="mt-2">
<img
src="<%= data.floatingContact?.brand?.imageSrc ? getFullImageUrl(data.floatingContact.brand.imageSrc, backendUrl) : '' %>"
class="img-thumbnail <%= data.floatingContact?.brand?.imageSrc ? '' : 'd-none' %>"
id="floatingContactBrandPreview"
style="height: 120px; width: 120px; object-fit: contain; background: #fff;"
alt="Brand preview" />
</div>
<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>
<div class="input-group mb-2">
<input
type="text"
class="form-control"
id="floatingContactTriggerImage"
value="<%= data.floatingContact?.trigger?.imageSrc || '' %>"
placeholder="/uploads/home/floating-contact/floating-trigger-icon.webp"
maxlength="255" data-maxlength="255" />
<button
type="button"
class="btn btn-outline-primary btn-upload-image"
data-target-input="floatingContactTriggerImage"
data-image-type="home/floating-contact"
data-resize-preset="floatingContactTriggerIcon">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
<div class="mt-2">
<img
src="<%= data.floatingContact?.trigger?.imageSrc ? getFullImageUrl(data.floatingContact.trigger.imageSrc, backendUrl) : '' %>"
class="img-thumbnail <%= data.floatingContact?.trigger?.imageSrc ? '' : 'd-none' %>"
id="floatingContactTriggerPreview"
style="height: 120px; width: 120px; object-fit: contain; background: #fff;"
alt="Trigger icon preview" />
</div>
<input type="hidden" id="floatingContactTriggerIconFallback"
value="<%= data.floatingContact?.trigger?.icon || 'fa-comments' %>" />
<small class="text-muted d-block mt-2">Displayed at roughly 26x26px. Shown only when the trigger slideshow cannot use the brand image and action icons. Raster uploads are normalized to 96x96 WebP.</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-12">
<div class="card border shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-0">
<i class="fas fa-list me-2"></i>Contact Actions
</h6>
<small class="text-muted">Add, remove, and drag to reorder floating contact actions.</small>
</div>
<button type="button" class="btn btn-sm btn-primary" id="addFloatingContactActionBtn">
<i class="fas fa-plus me-1"></i>Add Action
</button>
</div>
<div class="card-body">
<div id="floatingContactActionsContainer" class="d-grid gap-3"></div>
</div>
</div>
</div>
</div>
</div>
<div
id="floatingContactConfig"
data-cms-base-url="<%= backendUrl.replace(/\/$/, '') %>"
hidden
></div>
<script id="floatingContactInitialData" type="application/json"><%- JSON.stringify(data.floatingContact || {}) %></script>
<script>
window.homeScrapers = window.homeScrapers || {};
const floatingContactConfig = document.getElementById("floatingContactConfig");
const floatingContactCmsBaseUrl = floatingContactConfig?.dataset.cmsBaseUrl || "";
const normalizeFloatingContactPublicPath = (value) => {
const raw = (value || "").trim();
if (!raw) {
return "";
}
const knownPrefixes = ["/uploads/", "/assets/", "/img/"];
for (const prefix of knownPrefixes) {
const absolutePrefix = `${floatingContactCmsBaseUrl}${prefix}`;
if (raw.startsWith(absolutePrefix)) {
return raw.slice(floatingContactCmsBaseUrl.length);
}
}
for (const prefix of knownPrefixes) {
const index = raw.indexOf(prefix);
if (index >= 0) {
return raw.slice(index);
}
}
if (raw.startsWith("/")) {
return raw;
}
return `/${raw}`;
};
const resolveFloatingContactImageUrl = (value) => {
const normalized = normalizeFloatingContactPublicPath(value);
return normalized ? `${floatingContactCmsBaseUrl}${normalized}` : "";
};
const escapeFloatingContactHtml = (value) =>
String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
document.addEventListener("DOMContentLoaded", function () {
const initialDataElement = document.getElementById("floatingContactInitialData");
const initialData = initialDataElement?.textContent
? JSON.parse(initialDataElement.textContent)
: {};
const container = document.getElementById("floatingContactActionsContainer");
const addBtn = document.getElementById("addFloatingContactActionBtn");
const brandInput = document.getElementById("floatingContactBrandImage");
const brandPreview = document.getElementById("floatingContactBrandPreview");
const triggerInput = document.getElementById("floatingContactTriggerImage");
const triggerPreview = document.getElementById("floatingContactTriggerPreview");
if (!container || !addBtn) {
return;
}
const createActionId = () => `floating-action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const getActionDefaults = (platform) => {
if (platform === "facebook") {
return {
platform: "facebook",
enabled: true,
label: "Message us on Facebook",
subtitle: "facebook.com/hailearning.edu.vn",
href: "https://www.facebook.com/hailearning.edu.vn/",
iconImage: "",
iconType: "iconClass",
iconClass: "fa-brands fa-facebook-messenger",
iconText: "",
};
}
if (platform === "zalo") {
return {
platform: "zalo",
enabled: true,
label: "Message us on Zalo",
subtitle: "zalo.me/84961834040",
href: "https://zalo.me/84961834040",
iconImage: "",
iconType: "iconText",
iconClass: "",
iconText: "Zalo",
};
}
return {
platform: "custom",
enabled: true,
label: "",
subtitle: "",
href: "",
iconImage: "",
iconType: "iconClass",
iconClass: "fa-solid fa-comment-dots",
iconText: "",
};
};
const bindImageField = (input, preview) => {
if (!input || !preview) {
return;
}
input.value = normalizeFloatingContactPublicPath(input.value);
if (input.value) {
preview.src = resolveFloatingContactImageUrl(input.value);
preview.classList.remove("d-none");
}
if (input.dataset.previewBound === "true") {
return;
}
input.addEventListener("input", () => {
const value = normalizeFloatingContactPublicPath(input.value);
input.value = value;
if (!value) {
preview.classList.add("d-none");
preview.removeAttribute("src");
return;
}
preview.src = resolveFloatingContactImageUrl(value);
preview.classList.remove("d-none");
});
input.dataset.previewBound = "true";
};
const createActionCard = (action = {}) => {
const defaults = {
id: createActionId(),
...getActionDefaults(action.platform || "custom"),
...action,
};
const normalizedIconImage = normalizeFloatingContactPublicPath(defaults.iconImage || "");
const iconInputId = `floatingContactActionIconImage-${defaults.id}`;
const iconPreviewId = `${iconInputId}-preview`;
const wrapper = document.createElement("div");
wrapper.className = "card border floating-contact-action-item";
wrapper.innerHTML = `
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary floating-contact-action-handle" title="Drag to reorder">
<i class="fas fa-grip-vertical"></i>
</button>
<strong class="floating-contact-action-title">Action</strong>
</div>
<button type="button" class="btn btn-sm btn-outline-danger floating-contact-remove-action">
<i class="fas fa-trash-alt me-1"></i>Remove
</button>
</div>
<div class="card-body">
<input type="hidden" class="floating-contact-action-id" value="${escapeFloatingContactHtml(defaults.id)}" />
<input type="hidden" class="floating-contact-action-legacy-icon-type" value="${escapeFloatingContactHtml(defaults.iconType)}" />
<input type="hidden" class="floating-contact-action-legacy-icon-class" value="${escapeFloatingContactHtml(defaults.iconClass)}" />
<input type="hidden" class="floating-contact-action-legacy-icon-text" value="${escapeFloatingContactHtml(defaults.iconText)}" />
<div class="row g-3 align-items-start">
<div class="col-lg-3 col-md-6">
<label class="form-label fw-medium">Platform</label>
<select class="form-select floating-contact-action-platform">
<option value="facebook" ${defaults.platform === "facebook" ? "selected" : ""}>Facebook</option>
<option value="zalo" ${defaults.platform === "zalo" ? "selected" : ""}>Zalo</option>
<option value="custom" ${defaults.platform === "custom" ? "selected" : ""}>Custom</option>
</select>
</div>
<div class="col-lg-5 col-md-6">
<label class="form-label fw-medium">Label</label>
<input type="text" class="form-control floating-contact-action-label" value="${escapeFloatingContactHtml(defaults.label)}" placeholder="Message us on Facebook" maxlength="48" data-maxlength="48" />
</div>
<div class="col-lg-4 col-md-8">
<label class="form-label fw-medium">Subtitle</label>
<input type="text" class="form-control floating-contact-action-subtitle" value="${escapeFloatingContactHtml(defaults.subtitle)}" placeholder="facebook.com/hailearning.edu.vn" maxlength="48" data-maxlength="48" />
</div>
<div class="col-lg-3 col-md-4">
<label class="form-label fw-medium d-block">Status</label>
<div class="form-check form-switch mt-2">
<input class="form-check-input floating-contact-action-enabled" type="checkbox" ${defaults.enabled !== false ? "checked" : ""}>
<label class="form-check-label fw-medium">Enabled</label>
</div>
</div>
<div class="col-lg-9 col-md-8">
<label class="form-label fw-medium">Link</label>
<input type="text" class="form-control floating-contact-action-href" value="${escapeFloatingContactHtml(defaults.href)}" placeholder="https://example.com" maxlength="255" data-maxlength="255" />
</div>
<div class="col-12">
<label class="form-label fw-medium">Icon Image</label>
<div class="input-group mb-2">
<input type="text" class="form-control floating-contact-action-icon-image" id="${escapeFloatingContactHtml(iconInputId)}" value="${escapeFloatingContactHtml(normalizedIconImage)}" placeholder="/uploads/home/floating-contact/floating-action-icon.webp" maxlength="255" data-maxlength="255" />
<button
type="button"
class="btn btn-outline-primary btn-upload-image"
data-target-input="${escapeFloatingContactHtml(iconInputId)}"
data-image-type="home/floating-contact"
data-resize-preset="floatingContactActionIcon">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
<div class="mt-2">
<img
src="${normalizedIconImage ? escapeFloatingContactHtml(resolveFloatingContactImageUrl(normalizedIconImage)) : ""}"
class="img-thumbnail floating-contact-action-icon-preview ${normalizedIconImage ? "" : "d-none"}"
id="${escapeFloatingContactHtml(iconPreviewId)}"
style="height: 120px; width: 120px; object-fit: contain; background: #fff;"
alt="Action icon preview" />
</div>
<small class="text-muted d-block mt-2">Raster uploads are normalized to 84x84 WebP. SVG files remain unchanged.</small>
</div>
</div>
</div>
`;
return wrapper;
};
const updateActionCardTitles = () => {
container.querySelectorAll(".floating-contact-action-item").forEach((item, index) => {
const title = item.querySelector(".floating-contact-action-title");
if (title) {
title.textContent = `Action ${index + 1}`;
}
});
};
const bindActionCard = (card) => {
if (typeof initHomeCharacterCounters === "function") {
initHomeCharacterCounters(card);
}
bindImageField(
card.querySelector(".floating-contact-action-icon-image"),
card.querySelector(".floating-contact-action-icon-preview"),
);
};
const maybeApplyPlatformDefaults = (item) => {
const platform = item.querySelector(".floating-contact-action-platform")?.value || "custom";
const defaults = getActionDefaults(platform);
item.querySelector(".floating-contact-action-label").value =
item.querySelector(".floating-contact-action-label").value || defaults.label;
item.querySelector(".floating-contact-action-subtitle").value =
item.querySelector(".floating-contact-action-subtitle").value || defaults.subtitle;
item.querySelector(".floating-contact-action-href").value =
item.querySelector(".floating-contact-action-href").value || defaults.href;
item.querySelector(".floating-contact-action-legacy-icon-type").value = defaults.iconType;
item.querySelector(".floating-contact-action-legacy-icon-class").value = defaults.iconClass;
item.querySelector(".floating-contact-action-legacy-icon-text").value = defaults.iconText;
};
const renderInitialActions = () => {
const actions = Array.isArray(initialData.actions) && initialData.actions.length > 0
? initialData.actions
: [getActionDefaults("facebook"), getActionDefaults("zalo")];
actions.forEach((action) => {
const card = createActionCard(action);
container.appendChild(card);
bindActionCard(card);
});
updateActionCardTitles();
};
addBtn.addEventListener("click", () => {
const card = createActionCard(getActionDefaults("custom"));
container.appendChild(card);
bindActionCard(card);
updateActionCardTitles();
});
container.addEventListener("click", (event) => {
const removeBtn = event.target.closest(".floating-contact-remove-action");
if (!removeBtn) {
return;
}
const card = removeBtn.closest(".floating-contact-action-item");
if (!card) {
return;
}
card.remove();
updateActionCardTitles();
});
container.addEventListener("change", (event) => {
const card = event.target.closest(".floating-contact-action-item");
if (!card) {
return;
}
if (event.target.matches(".floating-contact-action-platform")) {
maybeApplyPlatformDefaults(card);
}
});
bindImageField(brandInput, brandPreview);
bindImageField(triggerInput, triggerPreview);
if (window.Sortable) {
window.Sortable.create(container, {
animation: 150,
handle: ".floating-contact-action-handle",
onEnd: updateActionCardTitles,
});
}
renderInitialActions();
});
window.homeScrapers.floatingContact = () => {
const actions = Array.from(document.querySelectorAll(".floating-contact-action-item")).map((item, index) => ({
id: item.querySelector(".floating-contact-action-id")?.value || "",
platform: item.querySelector(".floating-contact-action-platform")?.value || "custom",
enabled: !!item.querySelector(".floating-contact-action-enabled")?.checked,
label: item.querySelector(".floating-contact-action-label")?.value?.trim() || "",
subtitle: item.querySelector(".floating-contact-action-subtitle")?.value?.trim() || "",
href: item.querySelector(".floating-contact-action-href")?.value?.trim() || "",
iconImage: normalizeFloatingContactPublicPath(item.querySelector(".floating-contact-action-icon-image")?.value?.trim() || ""),
iconType: item.querySelector(".floating-contact-action-legacy-icon-type")?.value || "iconClass",
iconClass: item.querySelector(".floating-contact-action-legacy-icon-class")?.value?.trim() || "",
iconText: item.querySelector(".floating-contact-action-legacy-icon-text")?.value?.trim() || "",
order: index + 1,
}));
return {
enabled: !!document.getElementById("floatingContactEnabled")?.checked,
panelTitle: document.getElementById("floatingContactPanelTitle")?.value?.trim() || "",
brand: {
imageSrc: normalizeFloatingContactPublicPath(document.getElementById("floatingContactBrandImage")?.value?.trim() || ""),
imageAlt: document.getElementById("floatingContactBrandAlt")?.value?.trim() || "",
},
trigger: {
imageSrc: normalizeFloatingContactPublicPath(document.getElementById("floatingContactTriggerImage")?.value?.trim() || ""),
icon: document.getElementById("floatingContactTriggerIconFallback")?.value?.trim() || "fa-comments",
},
actions,
};
};
</script>

View File

@@ -1,21 +1,33 @@
<!-- Hero Tab --> <!-- Hero Tab -->
<div class="tab-pane fade show active" id="hero" role="tabpanel"> <div class="tab-pane fade show active" id="hero" role="tabpanel">
<div class="row g-4"> <div class="row g-4">
<!-- Background Image (section-level) -->
<div class="col-md-12"> <div class="col-md-12">
<div class="card border shadow-sm mb-1">
<div class="card-header bg-white d-flex justify-content-center align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="heroEnabled"
<%=(data.hero?.enabled !== false ) ? 'checked' : '' %>>
</div>
</div>
</div>
<div class="card border shadow-sm mb-3"> <div class="card border shadow-sm mb-3">
<div class="card-header bg-white d-flex justify-content-between align-items-center"> <div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0"> <h6 class="mb-0">
<i class="fas fa-image me-2"></i>Hero Background <i class="fas fa-layer-group me-2"></i>Hero Carousel Setup
</h6> </h6>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <div class="row g-3 align-items-start">
<div class="col-md-6"> <div class="col-lg-6">
<label class="form-label fw-medium">Background Image</label> <label class="form-label fw-medium">Fallback Background Image</label>
<small class="text-muted d-block mb-1">
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"> <div class="input-group mb-2">
<input type="text" class="form-control" id="heroBackgroundImage" <input type="text" class="form-control" id="heroBackgroundImage"
value="<%= data.hero?.backgroundImage || '' %>" placeholder="/assets/img/home-1/hero/bg.jpg" /> value="<%= data.hero?.backgroundImage || '' %>" placeholder="/uploads/home/hero-fallback.jpg"
maxlength="255" data-maxlength="255" />
<button type="button" class="btn btn-outline-primary btn-upload-image" <button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="heroBackgroundImage" data-image-type="home"> data-target-input="heroBackgroundImage" data-image-type="home">
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
@@ -28,6 +40,17 @@
</div> </div>
<% } %> <% } %>
</div> </div>
<div class="col-lg-6">
<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>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>
</div> </div>
</div> </div>
</div> </div>
@@ -75,24 +98,28 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Title</label> <label class="form-label fw-medium">Title</label>
<input type="text" class="form-control" id="heroSlide_<%= index %>_title" <input type="text" class="form-control" id="heroSlide_<%= index %>_title"
value="<%= slide.title || '' %>" placeholder="e.g., From Application to Visa" /> value="<%= slide.title || '' %>" placeholder="e.g., From Application to Visa"
maxlength="72" data-maxlength="72" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Subtitle</label> <label class="form-label fw-medium">Subtitle</label>
<small class="text-muted d-block mb-1">Currently not rendered on the frontend; kept for backward compatibility with existing data.</small>
<input type="text" class="form-control" id="heroSlide_<%= index %>_subtitle" <input type="text" class="form-control" id="heroSlide_<%= index %>_subtitle"
value="<%= slide.subtitle || '' %>" placeholder="e.g., Global Education Simplified" /> value="<%= slide.subtitle || '' %>" placeholder="e.g., Global Education Simplified"
maxlength="48" data-maxlength="48" />
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Description</label> <label class="form-label fw-medium">Description</label>
<textarea class="form-control" id="heroSlide_<%= index %>_description" rows="3" <textarea class="form-control" id="heroSlide_<%= index %>_description" rows="3"
placeholder="Enter hero description"><%= slide.description || '' %></textarea> placeholder="Enter hero description" maxlength="220" data-maxlength="220"><%= slide.description || '' %></textarea>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Hero Image</label> <label class="form-label fw-medium">Slide Background Image</label>
<small class="text-muted d-block mb-1">Recommended size: 893x848px</small> <small class="text-muted d-block mb-1">Recommended upload size is 1920x700px or larger.</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="heroSlide_<%= index %>_heroImage" <input type="text" class="form-control" id="heroSlide_<%= index %>_heroImage"
value="<%= slide.heroImage || '' %>" placeholder="/assets/img/home-1/hero/man.png" /> value="<%= slide.heroImage || '' %>" placeholder="/uploads/home/hero-slide-01.jpg"
maxlength="255" data-maxlength="255" />
<button type="button" class="btn btn-outline-primary btn-upload-image" <button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="heroSlide_<%= index %>_heroImage" data-image-type="home"> data-target-input="heroSlide_<%= index %>_heroImage" data-image-type="home">
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
@@ -107,8 +134,10 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Video URL</label> <label class="form-label fw-medium">Video URL</label>
<small class="text-muted d-block mb-1">The frontend currently does not render video in the hero. Kept only to preserve existing data.</small>
<input type="text" class="form-control" id="heroSlide_<%= index %>_videoUrl" <input type="text" class="form-control" id="heroSlide_<%= index %>_videoUrl"
value="<%= slide.videoUrl || '' %>" placeholder="https://www.youtube.com/watch?v=..." /> value="<%= slide.videoUrl || '' %>" placeholder="Optional"
maxlength="255" data-maxlength="255" />
</div> </div>
<!-- Primary Button --> <!-- Primary Button -->
@@ -121,12 +150,14 @@
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Label</label> <label class="form-label fw-medium">Label</label>
<input type="text" class="form-control" id="heroSlide_<%= index %>_primaryLabel" <input type="text" class="form-control" id="heroSlide_<%= index %>_primaryLabel"
value="<%= slide.primaryButton?.label || '' %>" placeholder="e.g., Apply now" /> value="<%= slide.primaryButton?.label || '' %>" placeholder="e.g., Apply now"
maxlength="32" data-maxlength="32" />
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Link</label> <label class="form-label fw-medium">Link</label>
<input type="text" class="form-control" id="heroSlide_<%= index %>_primaryHref" <input type="text" class="form-control" id="heroSlide_<%= index %>_primaryHref"
value="<%= slide.primaryButton?.href || '' %>" placeholder="/contact" /> value="<%= slide.primaryButton?.href || '' %>" placeholder="/contact"
maxlength="255" data-maxlength="255" />
</div> </div>
</div> </div>
</div> </div>
@@ -143,12 +174,14 @@
<label class="form-label fw-medium">Label</label> <label class="form-label fw-medium">Label</label>
<input type="text" class="form-control" id="heroSlide_<%= index %>_secondaryLabel" <input type="text" class="form-control" id="heroSlide_<%= index %>_secondaryLabel"
value="<%= slide.secondaryButton?.label || '' %>" value="<%= slide.secondaryButton?.label || '' %>"
placeholder="e.g., Book Free Consultation" /> placeholder="e.g., Book Free Consultation"
maxlength="32" data-maxlength="32" />
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Link</label> <label class="form-label fw-medium">Link</label>
<input type="text" class="form-control" id="heroSlide_<%= index %>_secondaryHref" <input type="text" class="form-control" id="heroSlide_<%= index %>_secondaryHref"
value="<%= slide.secondaryButton?.href || '' %>" placeholder="/contact" /> value="<%= slide.secondaryButton?.href || '' %>" placeholder="/contact"
maxlength="255" data-maxlength="255" />
</div> </div>
</div> </div>
</div> </div>
@@ -170,6 +203,7 @@
const getVal = (id) => (document.getElementById(id)?.value || "").trim(); const getVal = (id) => (document.getElementById(id)?.value || "").trim();
const backgroundImage = getVal("heroBackgroundImage"); const backgroundImage = getVal("heroBackgroundImage");
const enabled = document.getElementById("heroEnabled")?.checked === true;
const slides = []; const slides = [];
const slideEls = document.querySelectorAll(".hero-slide-item"); const slideEls = document.querySelectorAll(".hero-slide-item");
@@ -205,6 +239,7 @@
const first = slides[0] || {}; const first = slides[0] || {};
return { return {
enabled,
backgroundImage, backgroundImage,
slides, slides,
title: first.title || "", title: first.title || "",
@@ -284,6 +319,9 @@
container.appendChild(clone); container.appendChild(clone);
updateLabels(); updateLabels();
if (typeof initHomeCharacterCounters === "function") {
initHomeCharacterCounters(clone);
}
}); });
container.addEventListener("click", (e) => { container.addEventListener("click", (e) => {
@@ -308,5 +346,8 @@
// Initial normalization (in case indices rendered from server are not 0..n) // Initial normalization (in case indices rendered from server are not 0..n)
updateLabels(); updateLabels();
if (typeof initHomeCharacterCounters === "function") {
initHomeCharacterCounters(container);
}
}); });
</script> </script>

View File

@@ -1,44 +1,59 @@
<!-- Partners Tab --> <!-- Partners Tab -->
<div class="tab-pane fade" id="partners" role="tabpanel"> <div class="tab-pane fade" id="partners" role="tabpanel">
<div class="row g-4"> <div class="row g-4">
<!-- Visa Consultancy Awards --> <!-- Visa Consultancy Awards -->
<div class="col-md-12"> <div class="col-md-12">
<div class="card border shadow-sm mb-1">
<div class="card-header bg-white d-flex justify-content-center align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="partnersEnabled"
<%=(data.partners?.enabled !==false ) ? 'checked' : '' %>>
</div>
</div>
</div>
<div class="card border shadow-sm"> <div class="card border shadow-sm">
<div class="card-header bg-white"> <div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0"> <h6 class="mb-0">
<i class="fas fa-award me-2"></i>Awards & Certifications (Fixed 4 Items) <i class="fas fa-award me-2"></i>Awards & Certifications (Fixed 4 Items)
</h6> </h6>
</div> </div>
<div class="card-body"> <div class="card-body">
<small class="text-muted d-block mb-3">Award icons on the homepage display at roughly 124x124px. Recommended uploads are square images or transparent logos at least 248x248px.</small>
<div id="visaConsultancyContainer"> <div id="visaConsultancyContainer">
<% for(let i=0; i<4; i++) { <% for(let i=0; i<4; i++) { const item=(data.partners?.visaConsultancy?.items &&
const item = (data.partners?.visaConsultancy?.items && data.partners.visaConsultancy.items[i]) || {}; data.partners.visaConsultancy.items[i]) || {}; %>
%>
<div class="card mb-3 bg-light border visa-item"> <div class="card mb-3 bg-light border visa-item">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="card-title fw-bold mb-0">Award #<%= i + 1 %></h6> <h6 class="card-title fw-bold mb-0">Award #<%= i + 1 %>
</h6>
</div> </div>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-8"> <div class="col-md-8">
<label class="form-label fw-medium">Award Name</label> <label class="form-label fw-medium">Award Name</label>
<input type="text" class="form-control visa-name" value="<%= item.name || '' %>" placeholder="Award Name" /> <input type="text" class="form-control visa-name" value="<%= item.name || '' %>"
placeholder="Award Name" />
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label fw-medium">Year</label> <label class="form-label fw-medium">Year</label>
<input type="text" class="form-control visa-year" value="<%= item.year || '' %>" placeholder="e.g., 2025" /> <input type="text" class="form-control visa-year" value="<%= item.year || '' %>"
placeholder="e.g., 2025" />
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Icon / Logo</label> <label class="form-label fw-medium">Icon / Logo</label>
<small class="text-muted d-block mb-1">Recommended: a 248x248px square image or transparent logo for crisp rendering.</small>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control visa-icon" id="visaIcon_<%= i %>" value="<%= item.icon || '' %>" /> <input type="text" class="form-control visa-icon" id="visaIcon_<%= i %>"
value="<%= item.icon || '' %>" />
<button type="button" class="btn btn-outline-primary btn-upload-image" <button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="visaIcon_<%= i %>" data-image-type="home"> data-target-input="visaIcon_<%= i %>" data-image-type="home">
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
</button> </button>
</div> </div>
<div class="mt-2 preview-container"> <div class="mt-2 preview-container">
<img src="<%= item.icon || '' %>" class="img-thumbnail <%= item.icon ? '' : 'd-none' %>" style="height: 60px; object-fit: contain;"> <img src="<%= item.icon || '' %>" class="img-thumbnail <%= item.icon ? '' : 'd-none' %>"
style="height: 60px; object-fit: contain;">
</div> </div>
</div> </div>
</div> </div>
@@ -62,6 +77,7 @@
</button> </button>
</div> </div>
<div class="card-body"> <div class="card-body">
<small class="text-muted d-block mb-3">Brand logos in the slider display at roughly 159x48px. Recommended assets are SVGs or horizontal transparent logos at least 320x96px.</small>
<div id="brandPartnersContainer" class="row g-3"> <div id="brandPartnersContainer" class="row g-3">
<% (data.partners?.brands?.items || []).forEach(function(item, index) { %> <% (data.partners?.brands?.items || []).forEach(function(item, index) { %>
<div class="col-md-4 brand-partner-item"> <div class="col-md-4 brand-partner-item">
@@ -69,19 +85,23 @@
<div class="card-body py-2"> <div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<span class="small fw-bold">Brand Logo</span> <span class="small fw-bold">Brand Logo</span>
<button type="button" class="btn btn-link text-danger p-0" onclick="this.closest('.brand-partner-item').remove()"> <button type="button" class="btn btn-link text-danger p-0"
onclick="this.closest('.brand-partner-item').remove()">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
</div> </div>
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="text" class="form-control brand-logo-input" id="brandLogo_<%= index %>" value="<%= item.logo || '' %>" /> <input type="text" class="form-control brand-logo-input" id="brandLogo_<%= index %>"
value="<%= item.logo || '' %>" />
<button type="button" class="btn btn-outline-primary btn-upload-image" <button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="brandLogo_<%= index %>" data-image-type="home"> data-target-input="brandLogo_<%= index %>" data-image-type="home">
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
</button> </button>
</div> </div>
<small class="text-muted d-block mt-2">Recommended: SVG or a 320x96px horizontal image so the logo stays sharp in the slider.</small>
<div class="mt-2 text-center preview-container"> <div class="mt-2 text-center preview-container">
<img src="<%= item.logo || '' %>" class="img-thumbnail <%= item.logo ? '' : 'd-none' %>" style="height: 50px; object-fit: contain;"> <img src="<%= item.logo || '' %>" class="img-thumbnail <%= item.logo ? '' : 'd-none' %>"
style="height: 50px; object-fit: contain;">
</div> </div>
</div> </div>
</div> </div>
@@ -99,6 +119,9 @@
window.homeScrapers = window.homeScrapers || {}; window.homeScrapers = window.homeScrapers || {};
window.homeScrapers.partners = function () { window.homeScrapers.partners = function () {
const visaItems = []; const visaItems = [];
const enabled = document.getElementById('partnersEnabled')?.checked === true;
document.querySelectorAll('.visa-item').forEach(el => { document.querySelectorAll('.visa-item').forEach(el => {
visaItems.push({ visaItems.push({
name: el.querySelector('.visa-name').value, name: el.querySelector('.visa-name').value,
@@ -115,7 +138,8 @@
return { return {
visaConsultancy: { items: visaItems }, visaConsultancy: { items: visaItems },
brands: { items: brandItems } brands: { items: brandItems },
enabled
}; };
}; };
@@ -134,11 +158,12 @@
</button> </button>
</div> </div>
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="text" class="form-control brand-logo-input" id="${id}"> <input type="text" class="form-control brand-logo-input" id="${id}" maxlength="255" data-maxlength="255">
<button type="button" class="btn btn-outline-primary btn-upload-image" data-target-input="${id}" data-image-type="home"> <button type="button" class="btn btn-outline-primary btn-upload-image" data-target-input="${id}" data-image-type="home">
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
</button> </button>
</div> </div>
<small class="text-muted d-block mt-2">Recommended: SVG or a 320x96px horizontal image so the logo stays sharp in the slider.</small>
<div class="mt-2 text-center preview-container"> <div class="mt-2 text-center preview-container">
<img src="" class="img-thumbnail d-none" style="height: 50px; object-fit: contain;"> <img src="" class="img-thumbnail d-none" style="height: 50px; object-fit: contain;">
</div> </div>
@@ -146,5 +171,8 @@
</div> </div>
`; `;
container.appendChild(div); container.appendChild(div);
if (typeof initHomeCharacterCounters === "function") {
initHomeCharacterCounters(div);
}
} }
</script> </script>

View File

@@ -1,10 +1,19 @@
<!-- Testimonials Tab --> <!-- Testimonials Tab -->
<div class="tab-pane fade" id="testimonials" role="tabpanel"> <div class="tab-pane fade" id="testimonials" role="tabpanel">
<div class="row g-4"> <div class="row g-4">
<!-- Basic Info --> <!-- Basic Info -->
<div class="col-md-12"> <div class="col-md-12">
<div class="card border shadow-sm mb-1">
<div class="card-header bg-white d-flex justify-content-center align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="testimonialEnabled"
<%=(data.testimonials?.enabled !==false ) ? 'checked' : '' %>>
</div>
</div>
</div>
<div class="card border shadow-sm"> <div class="card border shadow-sm">
<div class="card-header bg-white"> <div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0"> <h6 class="mb-0">
<i class="fas fa-info-circle me-2"></i>Basic Information <i class="fas fa-info-circle me-2"></i>Basic Information
</h6> </h6>
@@ -14,23 +23,27 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Heading</label> <label class="form-label fw-medium">Heading</label>
<input type="text" class="form-control" id="testimonialsHeading" <input type="text" class="form-control" id="testimonialsHeading"
value="<%= data.testimonials?.heading || '' %>" placeholder="e.g., Student Reviews & Testimonials" /> value="<%= data.testimonials?.heading || '' %>" placeholder="e.g., Student Reviews & Testimonials"
maxlength="64" data-maxlength="64" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Subheading</label> <label class="form-label fw-medium">Subheading</label>
<input type="text" class="form-control" id="testimonialsSubheading" <input type="text" class="form-control" id="testimonialsSubheading"
value="<%= data.testimonials?.subheading || '' %>" placeholder="e.g., What Our Students Say" /> value="<%= data.testimonials?.subheading || '' %>" placeholder="e.g., What Our Students Say"
maxlength="40" data-maxlength="40" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Video URL</label> <label class="form-label fw-medium">Video URL</label>
<input type="text" class="form-control" id="testimonialsVideoUrl" <input type="text" class="form-control" id="testimonialsVideoUrl"
value="<%= data.testimonials?.videoUrl || '' %>" placeholder="https://www.youtube.com/watch?v=..." /> value="<%= data.testimonials?.videoUrl || '' %>" placeholder="https://www.youtube.com/watch?v=..."
maxlength="255" data-maxlength="255" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Video Thumbnail</label> <label class="form-label fw-medium">Video Thumbnail</label>
<small class="text-muted d-block mb-1">The desktop thumbnail frame is approximately 416x370px. Recommended upload size is at least 832x740px.</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="testimonialsVideoThumbnail" <input type="text" class="form-control" id="testimonialsVideoThumbnail"
value="<%= data.testimonials?.videoThumbnail || '' %>" /> value="<%= data.testimonials?.videoThumbnail || '' %>" maxlength="255" data-maxlength="255" />
<button type="button" class="btn btn-outline-primary btn-upload-image" <button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="testimonialsVideoThumbnail" data-image-type="home"> data-target-input="testimonialsVideoThumbnail" data-image-type="home">
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
@@ -69,17 +82,20 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Name</label> <label class="form-label fw-medium">Name</label>
<input type="text" class="form-control" id="testimonialsName_<%= index %>" <input type="text" class="form-control" id="testimonialsName_<%= index %>"
value="<%= item.name || '' %>" placeholder="e.g., Sohel Tanvir" /> value="<%= item.name || '' %>" placeholder="e.g., Sohel Tanvir"
maxlength="48" data-maxlength="48" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Role</label> <label class="form-label fw-medium">Role</label>
<input type="text" class="form-control" id="testimonialsRole_<%= index %>" <input type="text" class="form-control" id="testimonialsRole_<%= index %>"
value="<%= item.role || '' %>" placeholder="e.g., Student" /> value="<%= item.role || '' %>" placeholder="e.g., Student"
maxlength="48" data-maxlength="48" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Country</label> <label class="form-label fw-medium">Country</label>
<input type="text" class="form-control" id="testimonialsCountry_<%= index %>" <input type="text" class="form-control" id="testimonialsCountry_<%= index %>"
value="<%= item.country || '' %>" placeholder="e.g., Canada" /> value="<%= item.country || '' %>" placeholder="e.g., Canada"
maxlength="48" data-maxlength="48" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Rating</label> <label class="form-label fw-medium">Rating</label>
@@ -89,13 +105,14 @@
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Comment</label> <label class="form-label fw-medium">Comment</label>
<textarea class="form-control" id="testimonialsComment_<%= index %>" rows="3" <textarea class="form-control" id="testimonialsComment_<%= index %>" rows="3"
placeholder="Enter testimonial comment"><%= item.comment || '' %></textarea> placeholder="Enter testimonial comment" maxlength="280" data-maxlength="280"><%= item.comment || '' %></textarea>
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Avatar</label> <label class="form-label fw-medium">Avatar</label>
<small class="text-muted d-block mb-1">Avatars display at roughly 48x48px. Recommended square image sizes are 96x96px or 128x128px.</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="testimonialsAvatar_<%= index %>" <input type="text" class="form-control" id="testimonialsAvatar_<%= index %>"
value="<%= item.avatar || '' %>" /> value="<%= item.avatar || '' %>" maxlength="255" data-maxlength="255" />
<button type="button" class="btn btn-outline-primary btn-upload-image" <button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="testimonialsAvatar_<%= index %>" data-image-type="home"> data-target-input="testimonialsAvatar_<%= index %>" data-image-type="home">
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
@@ -118,6 +135,8 @@
window.homeScrapers.testimonials = () => { window.homeScrapers.testimonials = () => {
const getVal = (id) => (document.getElementById(id)?.value || "").trim(); const getVal = (id) => (document.getElementById(id)?.value || "").trim();
const enabled = document.getElementById("testimonialEnabled")?.checked === true;
const items = []; const items = [];
document.querySelectorAll(".testimonial-item").forEach((el, idx) => { document.querySelectorAll(".testimonial-item").forEach((el, idx) => {
const index = el.getAttribute("data-index") || idx; const index = el.getAttribute("data-index") || idx;
@@ -148,6 +167,7 @@
videoUrl: getVal("testimonialsVideoUrl"), videoUrl: getVal("testimonialsVideoUrl"),
videoThumbnail: getVal("testimonialsVideoThumbnail"), videoThumbnail: getVal("testimonialsVideoThumbnail"),
items, items,
enabled
}; };
}; };
@@ -188,6 +208,9 @@
container.appendChild(clone); container.appendChild(clone);
updateLabels(); updateLabels();
if (typeof initHomeCharacterCounters === "function") {
initHomeCharacterCounters(clone);
}
}); });
container.addEventListener("click", (e) => { container.addEventListener("click", (e) => {
@@ -207,5 +230,8 @@
}); });
updateLabels(); updateLabels();
if (typeof initHomeCharacterCounters === "function") {
initHomeCharacterCounters(container);
}
}); });
</script> </script>

View File

@@ -1,9 +1,19 @@
<!-- Video Gallery Tab --> <!-- Video Gallery Tab -->
<div class="tab-pane fade" id="videogallery" role="tabpanel"> <div class="tab-pane fade" id="videogallery" role="tabpanel">
<div class="row g-4"> <div class="row g-4">
<div class="col-md-12"> <div class="col-md-12">
<div class="card border shadow-sm mb-1">
<div class="card-header bg-white d-flex justify-content-center align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="videoGalleryEnabled"
<%=(data.videoGallery?.enabled !==false ) ? 'checked' : '' %>>
</div>
</div>
</div>
<div class="card border shadow-sm"> <div class="card border shadow-sm">
<div class="card-header bg-white">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0"> <h6 class="mb-0">
<i class="fas fa-video me-2"></i>Video Gallery <i class="fas fa-video me-2"></i>Video Gallery
</h6> </h6>
@@ -13,18 +23,22 @@
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Heading</label> <label class="form-label fw-medium">Heading</label>
<input type="text" class="form-control" id="videoGalleryHeading" <input type="text" class="form-control" id="videoGalleryHeading"
value="<%= data.videoGallery?.heading || '' %>" placeholder="e.g., VIDEO PLAY GALLERY" /> value="<%= data.videoGallery?.heading || '' %>" placeholder="e.g., VIDEO PLAY GALLERY"
maxlength="32" data-maxlength="32" data-maxwords="4" />
<small class="text-muted d-block mt-1">Limit this title to 4 words and 32 characters so it stays readable on the homepage.</small>
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Video URL</label> <label class="form-label fw-medium">Video URL</label>
<input type="text" class="form-control" id="videoGalleryVideoUrl" <input type="text" class="form-control" id="videoGalleryVideoUrl"
value="<%= data.videoGallery?.videoUrl || '' %>" placeholder="https://example.com/video.mp4" /> value="<%= data.videoGallery?.videoUrl || '' %>" placeholder="https://example.com/video.mp4"
maxlength="255" data-maxlength="255" />
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Thumbnail Image</label> <label class="form-label fw-medium">Thumbnail Image</label>
<small class="text-muted d-block mb-1">If no video is provided, this image fills a desktop area of about 1552x906px. Recommended minimum upload: 1920x1120px.</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="videoGalleryThumbnail" <input type="text" class="form-control" id="videoGalleryThumbnail"
value="<%= data.videoGallery?.thumbnail || '' %>" /> value="<%= data.videoGallery?.thumbnail || '' %>" maxlength="255" data-maxlength="255" />
<button type="button" class="btn btn-outline-primary btn-upload-image" <button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="videoGalleryThumbnail" data-image-type="home"> data-target-input="videoGalleryThumbnail" data-image-type="home">
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
@@ -50,10 +64,12 @@
window.homeScrapers.videoGallery = () => { window.homeScrapers.videoGallery = () => {
const getVal = (id) => (document.getElementById(id)?.value || "").trim(); const getVal = (id) => (document.getElementById(id)?.value || "").trim();
const enabled = document.getElementById("videoGalleryEnabled")?.checked === true;
return { return {
heading: getVal("videoGalleryHeading"), heading: getVal("videoGalleryHeading"),
videoUrl: getVal("videoGalleryVideoUrl"), videoUrl: getVal("videoGalleryVideoUrl"),
thumbnail: getVal("videoGalleryThumbnail"), thumbnail: getVal("videoGalleryThumbnail"),
enabled
}; };
}; };
</script> </script>

View File

@@ -1,10 +1,19 @@
<!-- Visa Countries Tab --> <!-- Visa Countries Tab -->
<div class="tab-pane fade" id="visacountries" role="tabpanel"> <div class="tab-pane fade" id="visacountries" role="tabpanel">
<div class="row g-4"> <div class="row g-4">
<!-- Basic Info --> <!-- Basic Info -->
<div class="col-md-12"> <div class="col-md-12">
<div class="card border shadow-sm mb-1">
<div class="card-header bg-white d-flex justify-content-center align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="visaCountriesEnabled"
<%=(data.visaCountries?.enabled !==false ) ? 'checked' : '' %>>
</div>
</div>
</div>
<div class="card border shadow-sm"> <div class="card border shadow-sm">
<div class="card-header bg-white"> <div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0"> <h6 class="mb-0">
<i class="fas fa-info-circle me-2"></i>Basic Information <i class="fas fa-info-circle me-2"></i>Basic Information
</h6> </h6>
@@ -14,17 +23,19 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Heading</label> <label class="form-label fw-medium">Heading</label>
<input type="text" class="form-control" id="visaCountriesHeading" <input type="text" class="form-control" id="visaCountriesHeading"
value="<%= data.visaCountries?.heading || '' %>" placeholder="e.g., Visa & VISAWAY Services To UK" /> value="<%= data.visaCountries?.heading || '' %>" placeholder="e.g., Visa & VISAWAY Services To UK"
maxlength="88" data-maxlength="88" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Subheading</label> <label class="form-label fw-medium">Subheading</label>
<input type="text" class="form-control" id="visaCountriesSubheading" <input type="text" class="form-control" id="visaCountriesSubheading"
value="<%= data.visaCountries?.subheading || '' %>" placeholder="e.g., UK. United Kingdom" /> value="<%= data.visaCountries?.subheading || '' %>" placeholder="e.g., UK. United Kingdom"
maxlength="56" data-maxlength="56" />
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Description</label> <label class="form-label fw-medium">Description</label>
<textarea class="form-control" id="visaCountriesDescription" rows="3" <textarea class="form-control" id="visaCountriesDescription" rows="3"
placeholder="Enter description"><%= data.visaCountries?.description || '' %></textarea> placeholder="Enter description" maxlength="240" data-maxlength="240"><%= data.visaCountries?.description || '' %></textarea>
</div> </div>
</div> </div>
</div> </div>
@@ -52,18 +63,20 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Country Name</label> <label class="form-label fw-medium">Country Name</label>
<input type="text" class="form-control" id="visaCountriesName_0" value="<%= featured.name || '' %>" <input type="text" class="form-control" id="visaCountriesName_0" value="<%= featured.name || '' %>"
placeholder="e.g., United Kingdom" /> placeholder="e.g., United Kingdom" maxlength="40" data-maxlength="40" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Country Code</label> <label class="form-label fw-medium">Country Code</label>
<input type="text" class="form-control" id="visaCountriesCode_0" value="<%= featured.code || '' %>" <input type="text" class="form-control" id="visaCountriesCode_0" value="<%= featured.code || '' %>"
placeholder="e.g., UK" /> placeholder="e.g., UK" maxlength="12" data-maxlength="12" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Flag / Illustration Image</label> <label class="form-label fw-medium">Flag / Illustration Image</label>
<small class="text-muted d-block mb-1">The desktop image frame is approximately 840x830px. Recommended assets are near-square images at least 1000x1000px.</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="visaCountriesFlag_0" <input type="text" class="form-control" id="visaCountriesFlag_0"
value="<%= featured.flag || '' %>" placeholder="/assets/img/home-1/feature/shape.png" /> value="<%= featured.flag || '' %>" placeholder="/assets/img/home-1/feature/shape.png"
maxlength="255" data-maxlength="255" />
<button type="button" class="btn btn-outline-primary btn-upload-image" <button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="visaCountriesFlag_0" data-image-type="home"> data-target-input="visaCountriesFlag_0" data-image-type="home">
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
@@ -73,12 +86,12 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Link</label> <label class="form-label fw-medium">Link</label>
<input type="text" class="form-control" id="visaCountriesLink_0" value="<%= featured.link || '' %>" <input type="text" class="form-control" id="visaCountriesLink_0" value="<%= featured.link || '' %>"
placeholder="/country-details/uk" /> placeholder="/country-details/uk" maxlength="255" data-maxlength="255" />
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Visa Types (comma-separated)</label> <label class="form-label fw-medium">Visa Types (comma-separated)</label>
<textarea class="form-control" id="visaCountriesVisaTypes_0" rows="2" <textarea class="form-control" id="visaCountriesVisaTypes_0" rows="2"
placeholder="e.g., Student Visa, Work Visa, Tourist Visa"><%= (featured.visaTypes || []).join(', ') %></textarea> placeholder="e.g., Student Visa, Work Visa, Tourist Visa" maxlength="220" data-maxlength="220"><%= (featured.visaTypes || []).join(', ') %></textarea>
</div> </div>
</div> </div>
</div> </div>
@@ -100,12 +113,14 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Label</label> <label class="form-label fw-medium">Label</label>
<input type="text" class="form-control" id="visaCountriesCtaLabel" <input type="text" class="form-control" id="visaCountriesCtaLabel"
value="<%= data.visaCountries?.ctaButton?.label || '' %>" placeholder="e.g., Get Started" /> value="<%= data.visaCountries?.ctaButton?.label || '' %>" placeholder="e.g., Get Started"
maxlength="32" data-maxlength="32" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Link</label> <label class="form-label fw-medium">Link</label>
<input type="text" class="form-control" id="visaCountriesCtaHref" <input type="text" class="form-control" id="visaCountriesCtaHref"
value="<%= data.visaCountries?.ctaButton?.href || '' %>" placeholder="/contact" /> value="<%= data.visaCountries?.ctaButton?.href || '' %>" placeholder="/contact"
maxlength="255" data-maxlength="255" />
</div> </div>
</div> </div>
</div> </div>
@@ -120,6 +135,8 @@
window.homeScrapers.visaCountries = () => { window.homeScrapers.visaCountries = () => {
const getVal = (id) => (document.getElementById(id)?.value || "").trim(); const getVal = (id) => (document.getElementById(id)?.value || "").trim();
const enabled = document.getElementById("visaCountriesEnabled")?.checked === true
const visaTypesRaw = getVal("visaCountriesVisaTypes_0"); const visaTypesRaw = getVal("visaCountriesVisaTypes_0");
const visaTypes = visaTypesRaw ? visaTypesRaw.split(",").map((s) => s.trim()).filter(Boolean) : []; const visaTypes = visaTypesRaw ? visaTypesRaw.split(",").map((s) => s.trim()).filter(Boolean) : [];
@@ -132,6 +149,7 @@
}; };
return { return {
enabled,
heading: getVal("visaCountriesHeading"), heading: getVal("visaCountriesHeading"),
subheading: getVal("visaCountriesSubheading"), subheading: getVal("visaCountriesSubheading"),
description: getVal("visaCountriesDescription"), description: getVal("visaCountriesDescription"),

View File

@@ -1,10 +1,19 @@
<!-- Visa Solutions Tab --> <!-- Visa Solutions Tab -->
<div class="tab-pane fade" id="visasolutions" role="tabpanel"> <div class="tab-pane fade" id="visasolutions" role="tabpanel">
<div class="row g-4"> <div class="row g-4">
<!-- Basic Info --> <!-- Basic Info -->
<div class="col-md-12"> <div class="col-md-12">
<div class="card border shadow-sm mb-1">
<div class="card-header bg-white d-flex justify-content-center align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="visaSolutionsEnabled"
<%= (data.visaSolutions?.enabled !== false ) ? 'checked' : '' %>>
</div>
</div>
</div>
<div class="card border shadow-sm"> <div class="card border shadow-sm">
<div class="card-header bg-white"> <div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0"> <h6 class="mb-0">
<i class="fas fa-info-circle me-2"></i>Basic Information <i class="fas fa-info-circle me-2"></i>Basic Information
</h6> </h6>
@@ -14,12 +23,14 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Heading</label> <label class="form-label fw-medium">Heading</label>
<input type="text" class="form-control" id="visaSolutionsHeading" <input type="text" class="form-control" id="visaSolutionsHeading"
value="<%= data.visaSolutions?.heading || '' %>" placeholder="e.g., Comprehensive Visa Solutions" /> value="<%= data.visaSolutions?.heading || '' %>" placeholder="e.g., Comprehensive Visa Solutions"
maxlength="64" data-maxlength="64" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Subheading</label> <label class="form-label fw-medium">Subheading</label>
<input type="text" class="form-control" id="visaSolutionsSubheading" <input type="text" class="form-control" id="visaSolutionsSubheading"
value="<%= data.visaSolutions?.subheading || '' %>" placeholder="e.g., Our Expert Services" /> value="<%= data.visaSolutions?.subheading || '' %>" placeholder="e.g., Our Expert Services"
maxlength="40" data-maxlength="40" />
</div> </div>
</div> </div>
</div> </div>
@@ -53,22 +64,25 @@
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label fw-medium">Number</label> <label class="form-label fw-medium">Number</label>
<input type="text" class="form-control" id="visaSolutionsNumber_<%= index %>" <input type="text" class="form-control" id="visaSolutionsNumber_<%= index %>"
value="<%= item.number || '' %>" placeholder="e.g., 01" /> value="<%= item.number || '' %>" placeholder="e.g., 01"
maxlength="4" data-maxlength="4" />
</div> </div>
<div class="col-md-9"> <div class="col-md-9">
<label class="form-label fw-medium">Title</label> <label class="form-label fw-medium">Title</label>
<input type="text" class="form-control" id="visaSolutionsTitle_<%= index %>" <input type="text" class="form-control" id="visaSolutionsTitle_<%= index %>"
value="<%= item.title || '' %>" placeholder="e.g., Student Visa Guidance" /> value="<%= item.title || '' %>" placeholder="e.g., Student Visa Guidance"
maxlength="56" data-maxlength="56" />
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Description</label> <label class="form-label fw-medium">Description</label>
<textarea class="form-control" id="visaSolutionsDescription_<%= index %>" rows="2" <textarea class="form-control" id="visaSolutionsDescription_<%= index %>" rows="2"
placeholder="Enter description"><%= item.description || '' %></textarea> placeholder="Enter description" maxlength="180" data-maxlength="180"><%= item.description || '' %></textarea>
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Link</label> <label class="form-label fw-medium">Link</label>
<input type="text" class="form-control" id="visaSolutionsLink_<%= index %>" <input type="text" class="form-control" id="visaSolutionsLink_<%= index %>"
value="<%= item.link || '' %>" placeholder="/service-details" /> value="<%= item.link || '' %>" placeholder="/service-details"
maxlength="255" data-maxlength="255" />
</div> </div>
</div> </div>
</div> </div>
@@ -86,6 +100,8 @@
window.homeScrapers.visaSolutions = () => { window.homeScrapers.visaSolutions = () => {
const getVal = (id) => (document.getElementById(id)?.value || "").trim(); const getVal = (id) => (document.getElementById(id)?.value || "").trim();
const enabled = document.getElementById("visaSolutionsEnabled")?.checked === true;
const items = []; const items = [];
document.querySelectorAll(".visa-solution-item").forEach((el, idx) => { document.querySelectorAll(".visa-solution-item").forEach((el, idx) => {
const index = el.getAttribute("data-index") || idx; const index = el.getAttribute("data-index") || idx;
@@ -104,6 +120,7 @@
heading: getVal("visaSolutionsHeading"), heading: getVal("visaSolutionsHeading"),
subheading: getVal("visaSolutionsSubheading"), subheading: getVal("visaSolutionsSubheading"),
items, items,
enabled
}; };
}; };

View File

@@ -1,10 +1,19 @@
<!-- Why Choose Us Tab --> <!-- Why Choose Us Tab -->
<div class="tab-pane fade" id="whychooseus" role="tabpanel"> <div class="tab-pane fade" id="whychooseus" role="tabpanel">
<div class="row g-4"> <div class="row g-4">
<!-- Basic Info --> <!-- Basic Info -->
<div class="col-md-12"> <div class="col-md-12">
<div class="card border shadow-sm mb-1">
<div class="card-header bg-white d-flex justify-content-center align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="whyChooseUsEnabled"
<%= (data.whyChooseUs.enabled !== false ) ? 'checked' : '' %>>
</div>
</div>
</div>
<div class="card border shadow-sm mb-3"> <div class="card border shadow-sm mb-3">
<div class="card-header bg-white"> <div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0"> <h6 class="mb-0">
<i class="fas fa-info-circle me-2"></i>Basic Information <i class="fas fa-info-circle me-2"></i>Basic Information
</h6> </h6>
@@ -15,23 +24,26 @@
<label class="form-label fw-medium">Heading</label> <label class="form-label fw-medium">Heading</label>
<input type="text" class="form-control" id="whyChooseUsHeading" <input type="text" class="form-control" id="whyChooseUsHeading"
value="<%= data.whyChooseUs?.heading || '' %>" value="<%= data.whyChooseUs?.heading || '' %>"
placeholder="e.g., Turning Study Abroad Dreams Into Reality" /> placeholder="e.g., Turning Study Abroad Dreams Into Reality"
maxlength="72" data-maxlength="72" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Subheading</label> <label class="form-label fw-medium">Subheading</label>
<input type="text" class="form-control" id="whyChooseUsSubheading" <input type="text" class="form-control" id="whyChooseUsSubheading"
value="<%= data.whyChooseUs?.subheading || '' %>" placeholder="e.g., About Our Consultancy" /> value="<%= data.whyChooseUs?.subheading || '' %>" placeholder="e.g., About Our Consultancy"
maxlength="48" data-maxlength="48" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Highlight Word (Optional)</label> <label class="form-label fw-medium">Highlight Word (Optional)</label>
<input type="text" class="form-control" id="whyChooseUsHighlightWord" <input type="text" class="form-control" id="whyChooseUsHighlightWord"
value="<%= data.whyChooseUs?.highlightWord || '' %>" placeholder="e.g., Dreams" /> value="<%= data.whyChooseUs?.highlightWord || '' %>" placeholder="e.g., Dreams"
maxlength="24" data-maxlength="24" />
<small class="text-muted">This word in the heading will be wrapped in a colored span.</small> <small class="text-muted">This word in the heading will be wrapped in a colored span.</small>
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Description</label> <label class="form-label fw-medium">Description</label>
<textarea class="form-control" id="whyChooseUsDescription" rows="3" <textarea class="form-control" id="whyChooseUsDescription" rows="3"
placeholder="Enter description"><%= data.whyChooseUs?.description || '' %></textarea> placeholder="Enter description" maxlength="260" data-maxlength="260"><%= data.whyChooseUs?.description || '' %></textarea>
</div> </div>
</div> </div>
</div> </div>
@@ -50,10 +62,11 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Main Image</label> <label class="form-label fw-medium">Main Image</label>
<small class="text-muted d-block mb-1">Recommended size: 375x419px</small> <small class="text-muted d-block mb-1">The desktop frame is approximately 318x347px. Recommended upload size is at least 750x820px, with a portrait ratio near 0.91:1.</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="whyChooseUsMainImage" <input type="text" class="form-control" id="whyChooseUsMainImage"
value="<%= data.whyChooseUs?.mainImage || '' %>" placeholder="/assets/img/home-1/about/about-1.jpg" /> value="<%= data.whyChooseUs?.mainImage || '' %>" placeholder="/assets/img/home-1/about/about-1.jpg"
maxlength="255" data-maxlength="255" />
<button type="button" class="btn btn-outline-primary btn-upload-image" <button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="whyChooseUsMainImage" data-image-type="home"> data-target-input="whyChooseUsMainImage" data-image-type="home">
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
@@ -69,11 +82,12 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Secondary Image</label> <label class="form-label fw-medium">Secondary Image</label>
<small class="text-muted d-block mb-1">Recommended size: 376x394px</small> <small class="text-muted d-block mb-1">The desktop frame is approximately 363x380px. Recommended upload size is at least 760x800px, with a portrait ratio near 0.95:1.</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="whyChooseUsSecondaryImage" <input type="text" class="form-control" id="whyChooseUsSecondaryImage"
value="<%= data.whyChooseUs?.secondaryImage || '' %>" value="<%= data.whyChooseUs?.secondaryImage || '' %>"
placeholder="/assets/img/home-1/about/about-02.jpg" /> placeholder="/assets/img/home-1/about/about-02.jpg"
maxlength="255" data-maxlength="255" />
<button type="button" class="btn btn-outline-primary btn-upload-image" <button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="whyChooseUsSecondaryImage" data-image-type="home"> data-target-input="whyChooseUsSecondaryImage" data-image-type="home">
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
@@ -108,9 +122,10 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Icon URL</label> <label class="form-label fw-medium">Icon URL</label>
<small class="text-muted d-block mb-1">Icons display at roughly 24x24px. Recommended assets are SVGs or square images at 48x48px.</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="whyChooseUsIcon_<%= index %>" <input type="text" class="form-control" id="whyChooseUsIcon_<%= index %>"
value="<%= item.icon || '' %>" /> value="<%= item.icon || '' %>" maxlength="255" data-maxlength="255" />
<button type="button" class="btn btn-outline-primary btn-upload-image" <button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="whyChooseUsIcon_<%= index %>" data-image-type="home"> data-target-input="whyChooseUsIcon_<%= index %>" data-image-type="home">
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
@@ -120,12 +135,14 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Title</label> <label class="form-label fw-medium">Title</label>
<input type="text" class="form-control" id="whyChooseUsTitle_<%= index %>" <input type="text" class="form-control" id="whyChooseUsTitle_<%= index %>"
value="<%= item.title || '' %>" placeholder="e.g., Global Reach" /> value="<%= item.title || '' %>" placeholder="e.g., Global Reach"
maxlength="40" data-maxlength="40" />
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Description</label> <label class="form-label fw-medium">Description</label>
<input type="text" class="form-control" id="whyChooseUsItemDescription_<%= index %>" <input type="text" class="form-control" id="whyChooseUsItemDescription_<%= index %>"
value="<%= item.description || '' %>" placeholder="e.g., Expanding Opportunities Worldwide" /> value="<%= item.description || '' %>" placeholder="e.g., Expanding Opportunities Worldwide"
maxlength="72" data-maxlength="72" />
</div> </div>
</div> </div>
</div> </div>
@@ -148,7 +165,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-medium">Feature <%= index + 1 %></label> <label class="form-label fw-medium">Feature <%= index + 1 %></label>
<input type="text" class="form-control" id="whyChooseUsFeature_<%= index %>" value="<%= feature %>" <input type="text" class="form-control" id="whyChooseUsFeature_<%= index %>" value="<%= feature %>"
placeholder="Enter feature" /> placeholder="Enter feature" maxlength="96" data-maxlength="96" />
</div> </div>
<% }); %> <% }); %>
</div> </div>
@@ -168,12 +185,14 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Label</label> <label class="form-label fw-medium">Label</label>
<input type="text" class="form-control" id="whyChooseUsCtaLabel" <input type="text" class="form-control" id="whyChooseUsCtaLabel"
value="<%= data.whyChooseUs?.ctaButton?.label || '' %>" placeholder="e.g., Get Started" /> value="<%= data.whyChooseUs?.ctaButton?.label || '' %>" placeholder="e.g., Get Started"
maxlength="32" data-maxlength="32" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Link</label> <label class="form-label fw-medium">Link</label>
<input type="text" class="form-control" id="whyChooseUsCtaHref" <input type="text" class="form-control" id="whyChooseUsCtaHref"
value="<%= data.whyChooseUs?.ctaButton?.href || '' %>" placeholder="/about" /> value="<%= data.whyChooseUs?.ctaButton?.href || '' %>" placeholder="/about"
maxlength="255" data-maxlength="255" />
</div> </div>
</div> </div>
</div> </div>
@@ -187,6 +206,7 @@
window.homeScrapers = window.homeScrapers || {}; window.homeScrapers = window.homeScrapers || {};
window.homeScrapers.whyChooseUs = () => { window.homeScrapers.whyChooseUs = () => {
const getVal = (id) => (document.getElementById(id)?.value || "").trim(); const getVal = (id) => (document.getElementById(id)?.value || "").trim();
const enabled = document.getElementById("whyChooseUsEnabled")?.checked === true;
// Collect items // Collect items
const items = []; const items = [];
@@ -224,6 +244,7 @@
secondaryImage: getVal("whyChooseUsSecondaryImage"), secondaryImage: getVal("whyChooseUsSecondaryImage"),
items, items,
features, features,
enabled,
ctaButton: { ctaButton: {
label: getVal("whyChooseUsCtaLabel"), label: getVal("whyChooseUsCtaLabel"),
href: getVal("whyChooseUsCtaHref"), href: getVal("whyChooseUsCtaHref"),

View File

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

View File

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

View File

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

View File

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