diff --git a/.env.example b/.env.example index ad8f4a5..797edcb 100644 Binary files a/.env.example and b/.env.example differ diff --git a/controllers/homeController.js b/controllers/homeController.js index 043f51c..5f8bc22 100644 --- a/controllers/homeController.js +++ b/controllers/homeController.js @@ -445,14 +445,11 @@ exports.apiGetBlogs = async (req, res) => { }; exports.api = async (req, res) => { try { - const docs = await getAllHomeDocs(); - let data = docs[0]?.toObject() || {}; + // Chỉ dùng doc mới nhất, không merge nhiều docs + const doc = await getHomeDoc(); + let data = doc?.toObject() || {}; const baseUrl = `${req.protocol}://${req.get("host")}`; - if (docs.length > 1) { - data.hero = getPreferredHeroData(docs.map((doc) => doc.toObject())); - } - // === Xử lý Blog Preview động === const blogPreview = data.blogPreview || {}; let blogs = []; diff --git a/controllers/serviceController.js b/controllers/serviceController.js index 9c25c63..9cf24a0 100644 --- a/controllers/serviceController.js +++ b/controllers/serviceController.js @@ -1,5 +1,6 @@ const { getServiceData } = require("../services/service.service"); const Service = require("../models/service"); +const syncServiceMenu = require("../services/syncServiceMenu"); const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper"); const writeAuditLog = require("../audit/writeAuditLog"); const diffObject = require("../audit/diffObject"); @@ -98,6 +99,8 @@ exports.updateService = async (req, res) => { changes, req, }); + // Sync header menu children to reflect updated service name/slug + await syncServiceMenu(updatedData.services?.items || []); req.flash("success_msg", "Service updated successfully"); res.redirect("/admin/service"); } catch (err) { @@ -168,6 +171,9 @@ exports.update = async (req, res) => { await Service.create(updatedData); } + // Sync header menu children to reflect current service list + await syncServiceMenu(updatedData.services?.items || []); + req.flash("success_msg", "Service updated successfully"); res.redirect("/admin/service"); } catch (err) { diff --git a/models/home.js b/models/home.js index 7d5edfd..6c310b6 100644 --- a/models/home.js +++ b/models/home.js @@ -72,6 +72,9 @@ const HeroSlideSchema = new Schema( const HeroSchema = new Schema( { + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + // Background for whole hero section backgroundImage: { type: String, default: "", maxlength: 255 }, @@ -101,12 +104,15 @@ const WhyChooseUsItemSchema = new Schema( const WhyChooseUsSchema = new Schema( { - heading: { type: String, default: "", maxlength: 72 }, - subheading: { type: String, default: "", maxlength: 48 }, - description: { type: String, default: "", maxlength: 260 }, - highlightWord: { type: String, default: "", maxlength: 24 }, - mainImage: { type: String, default: "", maxlength: 255 }, - secondaryImage: { type: String, default: "", maxlength: 255 }, + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + + heading: { type: String, default: "" }, + subheading: { type: String, default: "" }, + description: { type: String, default: "" }, + highlightWord: { type: String, default: "" }, + mainImage: { type: String, default: "" }, + secondaryImage: { type: String, default: "" }, items: { type: [WhyChooseUsItemSchema], default: [] }, features: { type: [{ type: String, maxlength: 96 }], default: [] }, ctaButton: { type: LinkSchema, default: () => ({}) }, @@ -126,8 +132,11 @@ const VisaSolutionItemSchema = new Schema( const VisaSolutionsSchema = new Schema( { - heading: { type: String, default: "", maxlength: 64 }, - subheading: { type: String, default: "", maxlength: 40 }, + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + + heading: { type: String, default: "" }, + subheading: { type: String, default: "" }, items: { type: [VisaSolutionItemSchema], default: [] }, }, { _id: false }, @@ -146,9 +155,12 @@ const VisaCountrySchema = new Schema( const VisaCountriesSchema = new Schema( { - heading: { type: String, default: "", maxlength: 88 }, - subheading: { type: String, default: "", maxlength: 56 }, - description: { type: String, default: "", maxlength: 240 }, + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + + heading: { type: String, default: "" }, + subheading: { type: String, default: "" }, + description: { type: String, default: "" }, countries: { type: [VisaCountrySchema], default: [] }, ctaButton: { type: LinkSchema, default: () => ({}) }, }, @@ -169,10 +181,13 @@ const TestimonialSchema = new Schema( const TestimonialsSchema = new Schema( { - heading: { type: String, default: "", maxlength: 64 }, - subheading: { type: String, default: "", maxlength: 40 }, - videoUrl: { type: String, default: "", maxlength: 255 }, - videoThumbnail: { type: String, default: "", maxlength: 255 }, + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + + heading: { type: String, default: "" }, + subheading: { type: String, default: "" }, + videoUrl: { type: String, default: "" }, + videoThumbnail: { type: String, default: "" }, items: { type: [TestimonialSchema], default: [] }, }, { _id: false }, @@ -180,23 +195,12 @@ const TestimonialsSchema = new Schema( const VideoGallerySchema = new Schema( { - heading: { - type: String, - default: "", - maxlength: 32, - validate: { - validator(value) { - const words = String(value || "") - .trim() - .split(/\s+/) - .filter(Boolean); - return words.length <= 4; - }, - message: "Video gallery heading must be 4 words or fewer.", - }, - }, - videoUrl: { type: String, default: "", maxlength: 255 }, - thumbnail: { type: String, default: "", maxlength: 255 }, + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + + heading: { type: String, default: "" }, + videoUrl: { type: String, default: "" }, + thumbnail: { type: String, default: "" }, }, { _id: false }, ); @@ -211,9 +215,12 @@ const FaqItemSchema = new Schema( const FaqSchema = new Schema( { - heading: { type: String, default: "", maxlength: 64 }, - subheading: { type: String, default: "", maxlength: 40 }, - description: { type: String, default: "", maxlength: 220 }, + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + + heading: { type: String, default: "" }, + subheading: { type: String, default: "" }, + description: { type: String, default: "" }, ctaButton: { type: LinkSchema, default: () => ({}) }, items: { type: [FaqItemSchema], default: [] }, }, @@ -232,8 +239,11 @@ const AchievementItemSchema = new Schema( const AchievementsSchema = new Schema( { - heading: { type: String, default: "", maxlength: 56 }, - subheading: { type: String, default: "", maxlength: 32 }, + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + + heading: { type: String, default: "" }, + subheading: { type: String, default: "" }, items: { type: [AchievementItemSchema], default: [] }, }, { _id: false }, @@ -271,6 +281,9 @@ const BrandsSchema = new Schema( const PartnersSchema = new Schema( { + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + visaConsultancy: { type: VisaConsultancySchema, default: () => ({}) }, brands: { type: BrandsSchema, default: () => ({}) }, }, @@ -296,8 +309,11 @@ const BlogPreviewItemSchema = new Schema( const BlogPreviewSchema = new Schema( { - heading: { type: String, default: "", maxlength: 64 }, - subheading: { type: String, default: "", maxlength: 40 }, + // Toggle visibility on frontend + enabled: { type: Boolean, default: true }, + + heading: { type: String, default: "" }, + subheading: { type: String, default: "" }, ctaButton: { type: LinkSchema, default: () => ({}) }, items: { type: [BlogPreviewItemSchema], default: [] }, selectedBlogIds: [{ type: Schema.Types.ObjectId, ref: 'Blog' }], @@ -333,5 +349,8 @@ const HomeSchema = new Schema( }, ); +// Đảm bảo chỉ có 1 document duy nhất (singleton pattern) +HomeSchema.index({ createdAt: 1 }, { unique: false }); + module.exports = mongoose.model("Home", HomeSchema); diff --git a/package.json b/package.json index 67a480f..a620fad 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "migrate-fresh": "node scripts/migrate-fresh.js", "migrate-status": "node scripts/migrate-status.js", "migrate-rollback": "node scripts/migrate-rollback.js", - "make-migration": "node scripts/make-migration.js" + "make-migration": "node scripts/make-migration.js", + "db:seed": "node scripts/seedDatabase.js" }, "keywords": [ "cms", diff --git a/scripts/cleanup-home-docs.js b/scripts/cleanup-home-docs.js new file mode 100644 index 0000000..b341f6a --- /dev/null +++ b/scripts/cleanup-home-docs.js @@ -0,0 +1,22 @@ +require('dotenv').config(); +const mongoose = require('mongoose'); +const Home = require('../models/home'); + +mongoose.connect(process.env.MONGODB_URI).then(async () => { + const docs = await Home.find().sort({ updatedAt: -1 }).lean(); + console.log('Total docs:', docs.length); + + if (docs.length <= 1) { + console.log('Nothing to clean up.'); + return; + } + + const keep = docs[0]; + const idsToDelete = docs.slice(1).map(d => d._id); + await Home.deleteMany({ _id: { $in: idsToDelete } }); + + console.log('Kept doc:', keep._id, '| hero.enabled:', keep.hero?.enabled); + console.log('Deleted', idsToDelete.length, 'duplicate docs'); + + await mongoose.disconnect(); +}); diff --git a/scripts/sync-service-menu-now.js b/scripts/sync-service-menu-now.js new file mode 100644 index 0000000..efdb347 --- /dev/null +++ b/scripts/sync-service-menu-now.js @@ -0,0 +1,34 @@ +/** + * One-time script: sync service menu items from DB into HeaderMenu. + * Run: node scripts/sync-service-menu-now.js + */ +const mongoose = require("mongoose"); +const dotenv = require("dotenv"); +dotenv.config(); + +const Service = require("../models/service"); +const syncServiceMenu = require("../services/syncServiceMenu"); + +const MONGODB_URI = process.env.MONGODB_URI || "mongodb://localhost:27017/hailearning"; + +async function run() { + await mongoose.connect(MONGODB_URI); + console.log("✅ Connected to MongoDB"); + + const serviceDoc = await Service.findOne().lean(); + if (!serviceDoc?.services?.items?.length) { + console.log("⚠️ No services found in DB."); + process.exit(0); + } + + console.log(`Found ${serviceDoc.services.items.length} services. Syncing menu...`); + await syncServiceMenu(serviceDoc.services.items); + + console.log("✅ Done."); + process.exit(0); +} + +run().catch((err) => { + console.error("❌ Error:", err); + process.exit(1); +}); diff --git a/services/syncServiceMenu.js b/services/syncServiceMenu.js new file mode 100644 index 0000000..f749871 --- /dev/null +++ b/services/syncServiceMenu.js @@ -0,0 +1,57 @@ +/** + * Sync HeaderMenu children of the "Services" menu item + * to match the current list of services in the database. + * + * Strategy: + * - Find the HeaderMenu item whose url === '/services' + * - Delete all its direct children + * - Re-create one child per service item (url = /services/) + */ + +const HeaderMenu = require("../models/headerMenu"); +const slugify = require("slugify"); + +/** + * @param {Array} serviceItems - array of service objects { slug, name } + */ +const syncServiceMenu = async (serviceItems = []) => { + try { + // 1. Find the "Services" parent menu item + const servicesParent = await HeaderMenu.findOne({ url: "/services" }); + + if (!servicesParent) { + console.warn("[syncServiceMenu] No HeaderMenu item with url=/services found. Skipping sync."); + return; + } + + const parentId = servicesParent._id; + + // 2. Remove all existing children of that parent + await HeaderMenu.deleteMany({ parentId }); + + // 3. Re-create one child per service + const ops = serviceItems + .filter((s) => s && s.slug && s.name) + .map((s, index) => ({ + title: s.name, + slug: slugify(s.name, { lower: true, strict: true }), + url: `/services/${s.slug}`, + parentId, + order: index + 1, + status: "active", + type: "internal", + is_maintainance: false, + })); + + if (ops.length > 0) { + await HeaderMenu.insertMany(ops); + } + + console.log(`[syncServiceMenu] Synced ${ops.length} service menu items under parentId=${parentId}`); + } catch (err) { + // Non-fatal – log but don't crash the main request + console.error("[syncServiceMenu] Error syncing service menu:", err.message); + } +}; + +module.exports = syncServiceMenu; diff --git a/views/admin/aboutUs/index.ejs b/views/admin/aboutUs/index.ejs index ff2f4a5..523d2ae 100644 --- a/views/admin/aboutUs/index.ejs +++ b/views/admin/aboutUs/index.ejs @@ -41,14 +41,9 @@ aria-selected="false">Intro -