Merge branch 'main' of https://gits.techvanguard.vn/UKSOURCE/cms.hailearning.edu.vn into hoanganh-03022026-appointment-contact-pricing

This commit is contained in:
LNHA
2026-02-07 09:38:53 +07:00
36 changed files with 2194 additions and 6388 deletions

Binary file not shown.

View File

@@ -1,17 +0,0 @@
FROM node:18-alpine
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
WORKDIR /home/node/app
COPY --chown=node:node package*.json ./
USER node
RUN npm install
COPY --chown=node:node . .
EXPOSE 3000
CMD [ "npm", "start" ]

View File

@@ -35,7 +35,28 @@
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }
.bg-soft-success { background-color: var(--success-soft); color: var(--success-color); border: 1px solid rgba(40, 167, 69, 0.2); } .bg-soft-success {
.bg-soft-danger { background-color: var(--danger-soft); color: var(--danger-color); border: 1px solid rgba(220, 53, 69, 0.2); } background-color: var(--success-soft);
.bg-soft-warning { background-color: var(--warning-soft); color: var(--warning-color); border: 1px solid rgba(255, 193, 7, 0.2); } color: var(--success-color);
.bg-soft-info { background-color: var(--info-soft); color: var(--info-color); border: 1px solid rgba(23, 162, 184, 0.2); } border: 1px solid rgba(40, 167, 69, 0.2);
}
.bg-soft-danger {
background-color: var(--danger-soft);
color: var(--danger-color);
border: 1px solid rgba(220, 53, 69, 0.2);
}
.bg-soft-warning {
background-color: var(--warning-soft);
color: var(--warning-color);
border: 1px solid rgba(255, 193, 7, 0.2);
}
.bg-soft-info {
background-color: var(--info-soft);
color: var(--info-color);
border: 1px solid rgba(23, 162, 184, 0.2);
}
.bg-soft-secondary {
background-color: #f8f9fa;
color: #6c757d;
border: 1px solid rgba(108, 117, 125, 0.2);
}

View File

@@ -1,161 +0,0 @@
const { addBaseUrlToImages } = require('../utils/imageHelper');
const About = require('../models/about');
// Get about data from MongoDB
const getAboutData = async () => {
const about = await About.findOne().sort({ updatedAt: -1 });
// Trả về object rỗng với cấu trúc cơ bản nếu không có dữ liệu
if (!about) {
return {
banner: {
image: '',
title: '',
text: ''
},
about: {
title: '',
paragraphs: [],
list_items: [],
button: {
text: '',
url: ''
},
image: '',
quote: {
mark_image: '',
title: '',
text: '',
author: ''
}
},
values: {
background_image: '',
items: []
},
education: {
images: {
student1: '',
student2: ''
},
subtitle: '',
title: '',
text: ''
},
advantages: {
title: '',
items: []
},
academic_board: {
title: '',
members: []
}
};
}
return about;
};
// Display about management page
exports.index = async (req, res) => {
try {
const data = await getAboutData();
res.render('admin/about', {
title: 'About Management',
data
});
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error loading about data');
res.redirect('/admin/dashboard');
}
};
// Update about data
exports.update = async (req, res) => {
try {
// Lấy document hiện tại từ MongoDB
const currentData = await getAboutData();
// Danh sách các section cần cập nhật
const sections = ['banner', 'about', 'values', 'education', 'advantages', 'academic_board'];
const errors = [];
let hasChanges = false;
// Tạo đối tượng dữ liệu mới dựa trên dữ liệu hiện tại
const updatedData = { ...currentData.toObject() };
// Xử lý từng section
sections.forEach(section => {
try {
// Kiểm tra nếu section không được gửi lên
if (!req.body[section]) {
console.warn(`No data for section: ${section}`);
return;
}
// Parse dữ liệu JSON từ form
const newSectionData = JSON.parse(req.body[section]);
// So sánh dữ liệu mới với dữ liệu hiện tại
const currentSectionData = currentData[section];
const sectionHasChanges = JSON.stringify(newSectionData) !== JSON.stringify(currentSectionData);
// Nếu có thay đổi, cập nhật vào đối tượng dữ liệu mới
if (sectionHasChanges) {
updatedData[section] = newSectionData;
hasChanges = true;
}
} catch (error) {
console.error(`Error processing section ${section}:`, error);
errors.push(`Error processing ${section} data: ${error.message}`);
}
});
// Nếu có lỗi, thông báo và chuyển hướng
if (errors.length > 0) {
req.flash('error_msg', `Data processing error: ${errors[0]}`);
return req.session.save(() => res.redirect('/admin/about'));
}
// Nếu không có thay đổi, thông báo và chuyển hướng
if (!hasChanges) {
req.flash('info_msg', 'No changes were made');
return req.session.save(() => res.redirect('/admin/about'));
}
try {
// Cập nhật hoặc tạo mới document trong MongoDB
if (currentData._id) {
await About.findByIdAndUpdate(currentData._id, updatedData, { new: true });
} else {
await About.create(updatedData);
}
// Success notification and redirect
req.flash('success_msg', 'About data updated successfully');
return req.session.save(() => res.redirect('/admin/about'));
} catch (dbError) {
console.error('Database error:', dbError);
req.flash('error_msg', `Database error: ${dbError.message || 'Unknown'}`);
return req.session.save(() => res.redirect('/admin/about'));
}
} catch (err) {
console.error('Update error:', err);
req.flash('error_msg', `Update error: ${err.message || 'Unknown'}`);
return req.session.save(() => res.redirect('/admin/about'));
}
};
// API to get about data
exports.api = async (req, res) => {
try {
const aboutData = await getAboutData();
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const processedData = addBaseUrlToImages(aboutData, baseUrl);
res.json(processedData);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Error loading about data' });
}
};

View File

@@ -1,363 +1,141 @@
const { addBaseUrlToImages } = require("../utils/imageHelper"); const { addBaseUrlToImages } = require("../utils/imageHelper");
const About = require("../models/about");
const AboutUs = require("../models/aboutUs"); const AboutUs = require("../models/aboutUs");
const jsonHelper = require("../utils/jsonHelper");
// -------------------- Public (read-only) helpers -------------------- /**
// Map stored About document back to the original aboutUs.json shape * GET /api/about
function transformToAboutUs(doc) { * Lấy dữ liệu About Us (Public API cho website và CMS load dữ liệu)
if (!doc) return null; */
exports.getAbout = async (req, res) => {
try {
// Force no-cache headers
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
const hero = { const data = await AboutUs.getSingle();
banner: doc.banner?.image || "", const rawData = data.toObject();
title: doc.banner?.title || "",
breadcrumb: doc.banner?.text || "",
};
const stats = Array.isArray(doc.advantages?.items) const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
? doc.advantages.items.map((item) => ({ const processedData = addBaseUrlToImages(rawData, baseUrl);
number: item.number || "",
description: item.title || "",
}))
: [];
const services = Array.isArray(doc.about?.paragraphs) res.json(processedData);
? doc.about.paragraphs.map((p) => ({title: "", description: p})) } catch (error) {
: []; console.error("Error getting about data:", error);
res.status(500).json({
const features = Array.isArray(doc.values?.items) success: false,
? doc.values.items.map((i) => ({ error: "Failed to get about data"
title: i.title || "",
description: i.text || "",
icon: i.icon || "",
}))
: [];
const events = Array.isArray(doc.academic_board?.members)
? doc.academic_board.members.map((m) => ({
imageUrl: m.image || "",
date: "",
title: m.title || "",
description: "",
authorName: m.name || "",
authorRole: "",
}))
: [];
return {
hero,
stats,
services,
features,
events,
};
}
// Get aboutUs data: prefer AboutUs collection, fallback to transforming About
const getAboutUsData = async () => {
// Prefer stored AboutUs document
const aboutUsDoc = await AboutUs.findOne().sort({updatedAt: -1});
if (aboutUsDoc)
return aboutUsDoc.toObject ? aboutUsDoc.toObject() : aboutUsDoc;
// Fallback: transform legacy About document into aboutUs shape
const about = await About.findOne().sort({updatedAt: -1});
if (!about) return null;
return transformToAboutUs(about);
};
// -------------------- Admin (CRUD on AboutUs model) helpers --------------------
// Default shape for AboutUs documents (matches data/aboutUs.json)
const getDefaultAboutUsData = () => ({
hero: {title: "", backgroundImage: ""},
introduction: {
subtitle: "",
title: "",
description: "",
mainImage: "",
services: [],
},
statistics: {
items: [],
},
accommodation: {
subtitle: "",
title: "",
description: "",
features: [],
},
activities: {
subtitle: "",
title: "",
description: "",
gallery: [],
},
newsletter: {
imagePath: "",
title: "",
description: "",
buttonText: "",
},
events: {
title: "",
items: [],
},
}); });
// Get latest stored AboutUs document or default (returned as plain object)
const getStoredAboutUs = async () => {
const aboutUs = await AboutUs.findOne().sort({updatedAt: -1});
if (!aboutUs) return getDefaultAboutUsData();
return aboutUs.toObject ? aboutUs.toObject() : aboutUs;
};
// -------------------- Public exports --------------------
// Public endpoint: return AboutUs JSON (previously rendered HTML)
exports.page = async (req, res) => {
try {
const aboutUsData = await getAboutUsData();
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const processed = addBaseUrlToImages(aboutUsData || {}, baseUrl);
return res.json(processed);
} catch (err) {
console.error("aboutUs.page error:", err);
return res.status(500).json({ error: "Error loading about-us data" });
} }
}; };
// API endpoint to return aboutUs JSON /**
exports.api = async (req, res) => { * PUT /api/about
* Cập nhật dữ liệu About Us (Dùng cho AJAX từ CMS)
*/
exports.updateAbout = async (req, res) => {
try { try {
const aboutUsData = await getAboutUsData(); let updateData = req.body;
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const processed = addBaseUrlToImages(aboutUsData || {}, baseUrl); // Nếu dữ liệu gửi qua trường aboutJson (dạng string JSON)
return res.json(processed); if (updateData.aboutJson && typeof updateData.aboutJson === "string") {
} catch (err) { try {
console.error("aboutUs.api error:", err); updateData = JSON.parse(updateData.aboutJson);
return res.status(500).json({error: "Error loading about-us data"}); } catch (e) {
return res.status(400).json({
success: false,
message: "Invalid JSON in aboutJson"
});
}
}
const doc = await AboutUs.getSingle();
// Use .set() for better handling of nested objects/arrays in Mongoose
doc.set(updateData);
await doc.save();
// Fetch fresh data for syncing and returning
const finalData = await AboutUs.findOne()
.select('-_id -__v -createdAt -updatedAt')
.lean();
// Update about.json file to keep it in sync
jsonHelper.writeJsonFile("about", finalData);
res.json({
success: true,
message: "About Us updated successfully",
data: finalData
});
} catch (error) {
console.error("Error updating about data:", error);
res.status(500).json({
success: false,
error: "Failed to update about data: " + error.message
});
} }
}; };
// API endpoint to return an array of AboutUs records (for frontend listing) /**
exports.apiList = async (req, res) => { * Render admin page (Dùng cho Admin UI)
try { */
const docs = await AboutUs.find().sort({ updatedAt: -1 }).lean();
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const processed = (docs || []).map((d) => addBaseUrlToImages(d || {}, baseUrl));
return res.json(processed);
} catch (err) {
console.error("aboutUs.apiList error:", err);
return res.status(500).json({ error: "Error loading about-us list" });
}
};
// -------------------- Admin exports --------------------
// Display AboutUs management page
exports.index = async (req, res) => { exports.index = async (req, res) => {
try { try {
const data = await getStoredAboutUs(); const data = await AboutUs.getSingle();
const items = await AboutUs.find().sort({updatedAt: -1}).limit(10); const rawData = data.toObject();
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 Us Management",
data, data: rawData,
items, activeTab,
frontendUrl:
process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
currentPath: req.path,
user: req.session.user, user: req.session.user,
currentPath: req.path,
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000'
}); });
} catch (err) { } catch (err) {
console.error(err); console.error("Error in about index:", err);
req.flash("error_msg", "Error loading About Us data"); req.flash("error_msg", "Error loading About Us page");
res.redirect("/admin/dashboard"); res.redirect("/admin/dashboard");
} }
}; };
// Display create form /**
exports.createForm = async (req, res) => { * Update method cho form-based submission (Admin UI - Post fallback)
try { */
const data = getDefaultAboutUsData();
res.render("admin/aboutUs/create", {
layout: "layouts/main",
title: "Create About Us",
data,
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading create form");
res.redirect("/admin/about-us");
}
};
// Create new AboutUs record
exports.create = async (req, res) => {
try {
const aboutUsData = {
hero: JSON.parse(req.body.hero || "{}"),
introduction: JSON.parse(req.body.introduction || "{}"),
statistics: JSON.parse(req.body.statistics || "{}"),
accommodation: JSON.parse(req.body.accommodation || "{}"),
activities: JSON.parse(req.body.activities || "{}"),
newsletter: JSON.parse(req.body.newsletter || "{}"),
events: JSON.parse(req.body.events || "{}"),
};
const newAboutUs = new AboutUs(aboutUsData);
await newAboutUs.save();
req.flash("success_msg", "About Us created successfully");
res.redirect("/admin/about-us");
} catch (err) {
console.error("Create error:", err);
req.flash("error_msg", `Create error: ${err.message || "Unknown"}`);
res.redirect("/admin/about-us/create");
}
};
// Display edit form
exports.editForm = async (req, res) => {
try {
const aboutUs = await AboutUs.findById(req.params.id);
if (!aboutUs) {
req.flash("error_msg", "About Us record not found");
return res.redirect("/admin/about-us");
}
res.render("admin/aboutUs/edit", {
layout: "layouts/main",
title: "Edit About Us",
data: aboutUs.toObject ? aboutUs.toObject() : aboutUs,
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading edit form");
res.redirect("/admin/about-us");
}
};
// Update AboutUs record
exports.update = async (req, res) => { exports.update = async (req, res) => {
try { try {
// Get current data let updateData = req.body;
const currentData = await getStoredAboutUs(); if (updateData.aboutJson && typeof updateData.aboutJson === "string") {
// Parse form data
const sections = [
"hero",
"introduction",
"statistics",
"accommodation",
"activities",
"newsletter",
"events",
];
const errors = [];
let hasChanges = false;
// Create updated data object
const updatedData = {
...(currentData.toObject ? currentData.toObject() : currentData),
};
// Process each section
sections.forEach((section) => {
try { try {
if (!req.body[section]) { updateData = JSON.parse(updateData.aboutJson);
console.warn(`No data for section: ${section}`); } catch (e) {
return; req.flash("error_msg", "Invalid JSON data");
}
const newSectionData = JSON.parse(req.body[section]);
const currentSectionData = currentData[section];
const sectionHasChanges =
JSON.stringify(newSectionData) !== JSON.stringify(currentSectionData);
if (sectionHasChanges) {
updatedData[section] = newSectionData;
hasChanges = true;
}
} catch (error) {
console.error(`Error processing section ${section}:`, error);
errors.push(`Error processing ${section} data: ${error.message}`);
}
});
if (errors.length > 0) {
req.flash("error_msg", `Data processing error: ${errors[0]}`);
return req.session.save(() => res.redirect("/admin/about-us"));
}
if (!hasChanges) {
req.flash("info_msg", "No changes were made");
return req.session.save(() => res.redirect("/admin/about-us"));
}
try {
// Only update existing document; do not create a new one here
if (!currentData || !currentData._id) {
req.flash("error_msg", "No existing About Us record to update. Create one first.");
return req.session.save(() => res.redirect("/admin/about-us"));
}
await AboutUs.findByIdAndUpdate(currentData._id, updatedData, {
new: true,
});
req.flash("success_msg", "About Us data updated successfully");
return req.session.save(() => res.redirect("/admin/about-us"));
} catch (dbError) {
console.error("Database error:", dbError);
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/about-us"));
}
} catch (err) {
console.error("Update error:", err);
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/about-us"));
}
};
// Delete AboutUs record
exports.delete = async (req, res) => {
try {
const aboutUs = await AboutUs.findById(req.params.id);
if (!aboutUs) {
req.flash("error_msg", "About Us record not found");
return res.redirect("/admin/about-us"); return res.redirect("/admin/about-us");
} }
}
await AboutUs.findByIdAndDelete(req.params.id); const doc = await AboutUs.getSingle();
doc.set(updateData);
await doc.save();
req.flash("success_msg", "About Us record deleted successfully"); const finalData = await AboutUs.findOne()
res.redirect("/admin/about-us"); .select('-_id -__v -createdAt -updatedAt')
.lean();
jsonHelper.writeJsonFile("about", finalData);
req.flash("success_msg", "About Us updated successfully");
const activeTab = req.query.activeTab || "hero";
res.redirect(`/admin/about-us?activeTab=${activeTab}`);
} catch (err) { } catch (err) {
console.error("Delete error:", err); console.error("Update error:", err);
req.flash("error_msg", `Delete error: ${err.message || "Unknown"}`); req.flash("error_msg", "Error updating About Us: " + err.message);
res.redirect("/admin/about-us"); res.redirect("/admin/about-us");
} }
}; };
// Preview AboutUs record // Aliases for compatibility
exports.preview = async (req, res) => { exports.api = exports.getAbout;
try { exports.page = exports.getAbout;
const aboutUs = await AboutUs.findById(req.params.id); exports.updateAboutUs = exports.updateAbout;
if (!aboutUs) {
return res.status(404).json({error: "About Us record not found"});
}
const processedData = addBaseUrlToImages(aboutUs.toObject());
res.json(processedData);
} catch (err) {
console.error("Preview error:", err);
res.status(500).json({error: "Error loading preview data"});
}
};

View File

@@ -6,8 +6,8 @@ const slugify = require("slugify");
*/ */
const buildMenuTree = (items, parentId = null, isPublic = false) => { const buildMenuTree = (items, parentId = null, isPublic = false) => {
const branch = []; const branch = [];
const children = items.filter(item => const children = items.filter(
String(item.parentId) === String(parentId) || (item.parentId === null && parentId === null) (item) => String(item.parentId) === String(parentId) || (item.parentId === null && parentId === null),
); );
for (const child of children) { for (const child of children) {
@@ -20,7 +20,7 @@ const buildMenuTree = (items, parentId = null, isPublic = false) => {
id: item._id, id: item._id,
title: item.title, title: item.title,
url: item.url, url: item.url,
type: item.type type: item.type,
}; };
} }
@@ -57,8 +57,8 @@ exports.index = async (req, res) => {
// 2. Create Menu Item // 2. Create Menu Item
exports.store = async (req, res) => { 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 } = req.body;
const slug = slugify(title, { lower: true, strict: true }); const slug = slugify(title, { lower: true, strict: true });
@@ -69,15 +69,27 @@ exports.store = async (req, res) => {
parentId: parentId || null, parentId: parentId || null,
order: order || 0, order: order || 0,
status: status || "active", status: status || "active",
type: type || "internal" type: type || "internal",
}); });
const savedItem = await newItem.save(); const savedItem = await newItem.save();
console.log('=== MENU CREATED ===', savedItem); console.log("=== MENU CREATED ===", savedItem);
// Return JSON for AJAX requests
if (req.xhr || req.headers.accept?.indexOf("json") > -1) {
return res.json({ success: true, message: "Menu item created successfully", data: savedItem });
}
req.flash("success_msg", "Menu item created successfully"); req.flash("success_msg", "Menu item created successfully");
res.redirect("/admin/header?tab=menu"); res.redirect("/admin/header?tab=menu");
} catch (error) { } catch (error) {
console.error('=== CREATE MENU ERROR ===', error); console.error("=== CREATE MENU ERROR ===", error);
// Return JSON for AJAX requests
if (req.xhr || req.headers.accept?.indexOf("json") > -1) {
return res.status(400).json({ success: false, message: error.message });
}
req.flash("error_msg", "Failed to create menu item: " + error.message); req.flash("error_msg", "Failed to create menu item: " + error.message);
res.redirect("/admin/header?tab=menu"); res.redirect("/admin/header?tab=menu");
} }
@@ -87,8 +99,8 @@ exports.store = async (req, res) => {
exports.update = async (req, res) => { exports.update = async (req, res) => {
try { try {
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 } = req.body;
const updateData = { const updateData = {
@@ -96,7 +108,7 @@ exports.update = async (req, res) => {
parentId: parentId || null, parentId: parentId || null,
order, order,
status, status,
type type,
}; };
if (title) { if (title) {
@@ -107,15 +119,33 @@ exports.update = async (req, res) => {
const updated = await HeaderMenu.findByIdAndUpdate(id, updateData, { new: true }); const updated = await HeaderMenu.findByIdAndUpdate(id, updateData, { new: true });
if (!updated) { if (!updated) {
console.log('=== UPDATE MENU NOT FOUND ===', id); console.log("=== UPDATE MENU NOT FOUND ===", id);
// Return JSON for AJAX requests
if (req.xhr || req.headers.accept?.indexOf("json") > -1) {
return res.status(404).json({ success: false, message: "Menu item not found" });
}
req.flash("error_msg", "Menu item not found"); req.flash("error_msg", "Menu item not found");
} else { } else {
console.log('=== MENU UPDATED ===', updated); console.log("=== MENU UPDATED ===", updated);
// Return JSON for AJAX requests
if (req.xhr || req.headers.accept?.indexOf("json") > -1) {
return res.json({ success: true, message: "Menu item updated successfully", data: updated });
}
req.flash("success_msg", "Menu item updated successfully"); req.flash("success_msg", "Menu item updated successfully");
} }
res.redirect("/admin/header?tab=menu"); res.redirect("/admin/header?tab=menu");
} catch (error) { } catch (error) {
console.error('=== UPDATE MENU ERROR ===', error); console.error("=== UPDATE MENU ERROR ===", error);
// Return JSON for AJAX requests
if (req.xhr || req.headers.accept?.indexOf("json") > -1) {
return res.status(400).json({ success: false, message: error.message });
}
req.flash("error_msg", "Update failed: " + error.message); req.flash("error_msg", "Update failed: " + error.message);
res.redirect("/admin/header?tab=menu"); res.redirect("/admin/header?tab=menu");
} }
@@ -126,16 +156,16 @@ exports.destroy = async (req, res) => {
try { try {
const { id } = req.body; const { id } = req.body;
const menuId = id || req.params.id; const menuId = id || req.params.id;
console.log('=== BACKEND: destroy hit ===', { menuId, body: req.body }); console.log("=== BACKEND: destroy hit ===", { menuId, body: req.body });
await deleteRecursive(menuId); await deleteRecursive(menuId);
await HeaderMenu.findByIdAndDelete(menuId); await HeaderMenu.findByIdAndDelete(menuId);
console.log('=== MENU DELETED ===', menuId); console.log("=== MENU DELETED ===", menuId);
req.flash("success_msg", "Menu item and its sub-menu deleted successfully"); req.flash("success_msg", "Menu item and its sub-menu deleted successfully");
res.redirect("/admin/header?tab=menu"); res.redirect("/admin/header?tab=menu");
} catch (error) { } catch (error) {
console.error('=== DELETE MENU ERROR ===', error); console.error("=== DELETE MENU ERROR ===", error);
req.flash("error_msg", "Delete failed: " + error.message); req.flash("error_msg", "Delete failed: " + error.message);
res.redirect("/admin/header?tab=menu"); res.redirect("/admin/header?tab=menu");
} }
@@ -147,11 +177,11 @@ exports.reorder = async (req, res) => {
const { items } = req.body; // Array of { id, order, parentId } const { items } = req.body; // Array of { id, order, parentId }
if (items && Array.isArray(items)) { if (items && Array.isArray(items)) {
const bulkOps = items.map(item => ({ const bulkOps = items.map((item) => ({
updateOne: { updateOne: {
filter: { _id: item.id }, filter: { _id: item.id },
update: { order: item.order, parentId: item.parentId || null } update: { order: item.order, parentId: item.parentId || null },
} },
})); }));
await HeaderMenu.bulkWrite(bulkOps); await HeaderMenu.bulkWrite(bulkOps);
return res.json({ success: true, message: "Reordered successfully" }); return res.json({ success: true, message: "Reordered successfully" });

View File

@@ -7,8 +7,28 @@ const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 });
const getHomeData = async () => (await getHomeDoc())?.toObject() || {}; const getHomeData = async () => (await getHomeDoc())?.toObject() || {};
const getDefaultHomeData = () => ({ const getDefaultHomeData = () => ({
hero: { title: "", subtitle: "", description: "", backgroundImage: "", videoUrl: "", primaryButton: {}, secondaryButton: {} }, hero: {
whyChooseUs: { heading: "", subheading: "", description: "", items: [], features: [], ctaButton: {} }, backgroundImage: "",
slides: [],
title: "",
subtitle: "",
description: "",
heroImage: "",
videoUrl: "",
primaryButton: {},
secondaryButton: {},
},
whyChooseUs: {
heading: "",
subheading: "",
description: "",
highlightWord: "",
mainImage: "",
secondaryImage: "",
items: [],
features: [],
ctaButton: {},
},
visaSolutions: { heading: "", subheading: "", items: [] }, visaSolutions: { heading: "", subheading: "", items: [] },
visaCountries: { heading: "", subheading: "", description: "", countries: [], ctaButton: {} }, visaCountries: { heading: "", subheading: "", description: "", countries: [], ctaButton: {} },
testimonials: { heading: "", subheading: "", videoUrl: "", videoThumbnail: "", items: [] }, testimonials: { heading: "", subheading: "", videoUrl: "", videoThumbnail: "", items: [] },

123
data/about.json Normal file
View File

@@ -0,0 +1,123 @@
{
"hero": {
"title": "About Us",
"breadcrumb": [
"Home",
"About Us"
],
"backgroundImage": "/uploads/about/breadcrumb.jpg"
},
"intro": {
"subheading": "Company Intro",
"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.",
"image": "http://localhost:3001/uploads/about/intro.jpg"
},
"mission": {
"subheading": "About Our Consultancy",
"heading": "Turning Study Abroad Dreams Into Reality",
"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": "/assets/img/home-1/about/about-1.jpg",
"secondary": "/assets/img/home-1/about/about-02.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": [
"Fastest Visa form processing with skilled immigration agents",
"Partnership with International Educational Institutions"
],
"ctaButton": {
"label": "Get Started",
"href": "/about"
}
},
"features": {
"backgroundImage": "/assets/img/home-2/feature/bg-shape.png",
"subheading": "Your Travel Made Easy",
"heading": "Smooth Visa Journey Guaranteed",
"description": "We provide expert guidance for every visa application, ensuring smooth processing, personalized support, and reliable assistance",
"image": "/assets/img/home-2/feature/02.png",
"items": [
{
"icon": "/assets/img/home-2/icon/01.png",
"title": "Expert Consultants",
"description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors."
},
{
"icon": "/assets/img/home-2/icon/01.png",
"title": "Personalized Support",
"description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors."
},
{
"icon": "/assets/img/home-2/icon/01.png",
"title": "Transparent Process",
"description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors."
}
],
"ctaButton": {
"label": "Get Started Today",
"href": "/contact"
}
},
"news": {
"subheading": "Visa Tips & Guides",
"heading": "Latest Insights & Updates",
"ctaButton": {
"label": "view all articles",
"href": "/blog"
},
"items": [
{
"title": "Step-by-Step Guide to Applying for a Student Visa",
"category": "Student Visa",
"date": "20 August ,2025",
"comments": 8,
"author": {
"name": "Sohel",
"avatar": "/assets/img/home-1/news/client.png"
},
"link": "/blog/step-by-step-guide-student-visa",
"thumbnail": "/assets/img/home-1/news/news-1.jpg"
},
{
"title": "Tips to Prepare Financial Documents for Visa Approval",
"category": "IELTS / TOEFL",
"date": "20 August ,2025",
"comments": 8,
"author": {
"name": "Sohel",
"avatar": "/assets/img/home-1/news/client.png"
},
"link": "/blog/financial-documents-visa-approval",
"thumbnail": "/assets/img/home-1/news/news-2.jpg"
},
{
"title": "Post-Arrival Guide What Every Student Should Know",
"category": "Study Abroad",
"date": "20 August ,2025",
"comments": 8,
"author": {
"name": "Sohel",
"avatar": "/assets/img/home-1/news/client.png"
},
"link": "/blog/post-arrival-guide-students",
"thumbnail": "/assets/img/home-1/news/news-3.jpg"
}
]
}
}

View File

@@ -1,28 +0,0 @@
{
"avatars": [
"yootheme/aboutImage/profile-face_1.jpg",
"yootheme/aboutImage/young-tourist-sitting-tent.jpg",
"yootheme/aboutImage/portrait-young-male-tourist-standing-forest-with-tent.jpg"
],
"images": {
"mainImage1": "yootheme/img/a1.jpg",
"mainImage2": "yootheme/img/a2.jpg"
},
"content": {
"sectionTitle": "About Us",
"mainTitle": "Creating Amazing Camps",
"description": "Learning is closely tied to practical experience—summer is the perfect time for hands-on opportunities. While knowledge must still be nurtured, it can take on new and more engaging forms.",
"quote": "Your Journey, Your Comfort,\nYour Adventure.",
"authorText": "Adventurer with\nhappy customer",
"targetCount": 50
},
"features": [
"Fun-Filled Experiences for Every Camper",
"Adventures That Inspire Confidence and Growth",
"Memories and Friendships That Last a Lifetime"
],
"button": {
"label": "Learn More About",
"href": "/info/about"
}
}

View File

@@ -1,149 +0,0 @@
{
"hero": {
"title": "About Us",
"backgroundImage": "/uploads/about/banner.jpg"
},
"introduction": {
"subtitle": "Go & Grow Camp",
"title": "Go & Grow Camp A Place to Learn, Connect, and Grow",
"description": "Go & Grow Camp brings together young people from different countries and cultures to enjoy fun activities, meaningful projects, and positive community experiences. Every camper—new or returning—quickly feels included thanks to our welcoming environment and supportive team.",
"mainImage": "/uploads/about/section2.jpg",
"services": [
{
"title": "Always Here",
"description": "Camp leaders are ready to guide and support you whenever needed."
},
{
"title": "Fun & Learning",
"description": "Engage in exciting activities that help you grow new skills."
},
{
"title": "Team Spirit",
"description": "Work together, take responsibility, and support each other at camp."
}
]
},
"statistics": {
"items": [
{
"number": "2K+",
"description": "Smiles and Friendships Made"
},
{
"number": "25+",
"description": "Countries Connected"
},
{
"number": "50+",
"description": "Adventure & Skill-Building Activities"
},
{
"number": "20+",
"description": "Exciting Challenges Every Camp"
}
]
},
"accommodation": {
"subtitle": "Accommodation",
"title": "Blending Comfort With Responsible Living",
"description": "Enjoy a tranquil atmosphere with beautiful views, modern facilities, and personal touches that make you feel at home.",
"features": [
{
"title": "Safe Environment",
"description": "Safety is our top priority, with secure facilities and connecting with nature.",
"icon": "/uploads/about/act2.jpg"
},
{
"title": "Family Atmosphere",
"description": "Every camper is part of our big camp family, where friendships grow and everyone feels included.",
"icon": "/uploads/about/act2.jpg"
},
{
"title": "Cultural Exchange",
"description": "Experience diversity and learn about different cultures from campers around the world.",
"icon": "/uploads/about/act2.jpg"
},
{
"title": "Personal Growth",
"description": "Activities encourage confidence, independence, and learning through fun challenges.",
"icon": "/uploads/about/act2.jpg"
},
{
"title": "Creativity & Fun",
"description": "Express yourself through games, arts, and exciting hands-on experiences.",
"icon": "/uploads/about/act2.jpg"
},
{
"title": "Creativity & Fun",
"description": "Express yourself through games, arts, and exciting hands-on experiences.",
"icon": "/uploads/about/act2.jpg"
}
]
},
"activities": {
"subtitle": "Activities",
"title": "Enjoy unforgettable experiences at Go and Grow Camp",
"description": "Discover a world of adventure, creativity, and friendship. From exciting outdoor activities to hands-on workshops, every day is full of new experiences that help campers grow, have fun, and make memories that last a lifetime.",
"gallery": [
{
"image": "/uploads/about/act1.jpg",
"title": "Outdoor Adventures",
"description": "Climb, paddle and explore with our experienced team."
},
{
"image": "/uploads/about/act2.jpg",
"title": "Creative Workshops",
"description": "Arts & crafts sessions to spark imagination."
},
{
"image": "/uploads/about/act3.jpg",
"title": "Water Sports",
"description": "Safe swimming and supervised water activities."
},
{
"image": "/uploads/about/act4.jpg",
"title": "Campfire Nights",
"description": "Evening stories, music, and marshmallow roasting."
}
]
},
"newsletter": {
"imagePath": "/uploads/about/newsletter.jpg",
"title": "Stay Updated with Our Monthly",
"description": "Sign up to receive the latest news about new camps, activities, and exciting opportunities. Don't miss out on anything fun!",
"buttonText": "Subscribe"
},
"events": {
"title": "Tour Events for you",
"items": [
{
"imageUrl": "/uploads/about/act1.jpg",
"date": "September 19, 2022",
"title": "The Bottom Line on Dietary Supplements",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris sit amet tristique neque, in suscipit sem. Fusce ut tellus neque. Suspendisse ...",
"age": "Age Group: 1014"
},
{
"imageUrl": "/uploads/about/act2.jpg",
"date": "September 19, 2022",
"title": "The Bottom Line on Dietary Supplements",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris sit amet tristique neque, in suscipit sem. Fusce ut tellus neque. Suspendisse ...",
"age": "Age Group: 1014"
},
{
"imageUrl": "/uploads/about/act3.jpg",
"date": "September 19, 2022",
"title": "The Bottom Line on Dietary Supplements",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris sit amet tristique neque, in suscipit sem. Fusce ut tellus neque. Suspendisse ...",
"age": "Age Group: 1014"
}
]
}
}

View File

@@ -1,7 +1,6 @@
{ {
"hero": { "hero": {
"title": "From Application to Visa We've Got You Covered", "title": "From Application to Visa Weve Got You Covered",
"subtitle": "Global Education Simplified", "subtitle": "Global Education Simplified",
"description": "We guide you through every step of the education visa process, from initial application to final approval, ensuring a smooth, hassle-free journey.", "description": "We guide you through every step of the education visa process, from initial application to final approval, ensuring a smooth, hassle-free journey.",
"primaryButton": { "primaryButton": {
@@ -48,25 +47,25 @@
"number": "01", "number": "01",
"title": "Student Visa Guidance", "title": "Student Visa Guidance",
"description": "Assistance with admission, documentation, and visa application.Assistance", "description": "Assistance with admission, documentation, and visa application.Assistance",
"link": "/service-details" "link": "/services/student-visa"
}, },
{ {
"number": "02", "number": "02",
"title": "PTE Exam Preparation", "title": "PTE Exam Preparation",
"description": "We provide expert guidance and personalized support throughout the education visa process,", "description": "We provide expert guidance and personalized support throughout the education visa process,",
"link": "/service-details" "link": "/services/pte-exam"
}, },
{ {
"number": "03", "number": "03",
"title": "University Selection Assistance", "title": "University Selection Assistance",
"description": "We provide expert guidance and personalized support throughout the education visa process,", "description": "We provide expert guidance and personalized support throughout the education visa process,",
"link": "/service-details" "link": "/services/university-selection"
}, },
{ {
"number": "04", "number": "04",
"title": "IELTS Exam Preparation", "title": "IELTS Exam Preparation",
"description": "We provide expert guidance and personalized support throughout the education visa process,", "description": "We provide expert guidance and personalized support throughout the education visa process,",
"link": "/service-details" "link": "/services/ielts-exam"
} }
] ]
}, },

View File

@@ -1,13 +0,0 @@
version: '3.8'
services:
app_cms_ggcamp:
build: .
container_name: my_node_app_cms_ggcamp
restart: unless-stopped
environment:
- NODE_ENV=production
volumes:
- .:/home/node/app
- /home/node/app/node_modules
network_mode: host

View File

@@ -1,64 +0,0 @@
const mongoose = require('mongoose');
const aboutSchema = new mongoose.Schema({
banner: {
image: String,
title: String,
text: String
},
about: {
title: String,
paragraphs: [String],
list_items: [String],
button: {
text: String,
url: String
},
image: String,
quote: {
mark_image: String,
title: String,
text: String,
author: String
}
},
values: {
background_image: String,
items: [{
icon: String,
title: String,
text: String
}]
},
education: {
images: {
student1: String,
student2: String
},
subtitle: String,
title: String,
text: String
},
advantages: {
title: String,
items: [{
number: String,
title: String,
text: String
}]
},
academic_board: {
title: String,
members: [{
image: String,
title: String,
name: String,
color: String
}]
},
updatedAt: Date
}, {
timestamps: true
});
module.exports = mongoose.model('About', aboutSchema);

View File

@@ -2,87 +2,105 @@ const mongoose = require("mongoose");
const aboutUsSchema = new mongoose.Schema( const aboutUsSchema = new mongoose.Schema(
{ {
// Hero section
hero: { hero: {
title: String, title: String,
breadcrumb: [String],
backgroundImage: String, backgroundImage: String,
}, },
intro: {
// Introduction section with nested services subheading: String,
introduction: { heading: String,
subtitle: String,
title: String,
description: String, description: String,
mainImage: String,
services: [
{
title: String,
description: String,
},
],
},
// Statistics with nested items
statistics: {
items: [
{
number: String,
description: String,
},
],
},
// Accommodation section with nested features
accommodation: {
subtitle: String,
title: String,
description: String,
features: [
{
title: String,
description: String,
icon: String,
},
],
},
// Activities section with nested gallery
activities: {
subtitle: String,
title: String,
description: String,
gallery: [
{
image: String, image: String,
title: String, },
mission: {
subheading: String,
heading: String,
description: String, description: String,
images: {
main: String,
secondary: String,
bgShape: String,
planeShape: String,
topShape: String,
globeShape: String,
}, },
],
},
// Newsletter
newsletter: {
imagePath: String,
title: String,
description: String,
buttonText: String,
},
// Events with nested items
events: {
title: String,
items: [ items: [
new mongoose.Schema(
{ {
imageUrl: String, icon: String,
date: String, label: String,
description: String,
},
{ _id: false }
),
],
features: [String],
ctaButton: {
label: String,
href: String,
},
},
features: {
backgroundImage: String,
subheading: String,
heading: String,
description: String,
image: String,
items: [
new mongoose.Schema(
{
icon: String,
title: String, title: String,
description: String, description: String,
age: String,
}, },
{ _id: false }
),
],
ctaButton: {
label: String,
href: String,
},
},
news: {
subheading: String,
heading: String,
ctaButton: {
label: String,
href: String,
},
items: [
new mongoose.Schema(
{
title: String,
category: String,
date: String,
comments: Number,
author: {
name: String,
avatar: String,
},
link: String,
thumbnail: String,
},
{ _id: false }
),
], ],
}, },
}, },
{timestamps: true} {
timestamps: true,
collection: "aboutus",
}
); );
// Static method để đảm bảo luôn chỉ có 1 bản ghi duy nhất (Singleton)
aboutUsSchema.statics.getSingle = async function () {
let doc = await this.findOne();
if (!doc) {
doc = await this.create({});
}
return doc;
};
module.exports = mongoose.model("AboutUs", aboutUsSchema); module.exports = mongoose.model("AboutUs", aboutUsSchema);

View File

@@ -11,14 +11,35 @@ const LinkSchema = new Schema(
{ _id: false }, { _id: false },
); );
const HeroSchema = new Schema( // Hero slide (for multiple hero items in slider)
const HeroSlideSchema = new Schema(
{ {
title: { type: String, default: "" }, title: { type: String, default: "" },
subtitle: { type: String, default: "" }, subtitle: { type: String, default: "" },
description: { type: String, default: "" }, description: { type: String, default: "" },
primaryButton: { type: LinkSchema, default: () => ({}) }, primaryButton: { type: LinkSchema, default: () => ({}) },
secondaryButton: { type: LinkSchema, default: () => ({}) }, secondaryButton: { type: LinkSchema, default: () => ({}) },
heroImage: { type: String, default: "" },
videoUrl: { type: String, default: "" },
},
{ _id: false },
);
const HeroSchema = new Schema(
{
// Background for whole hero section
backgroundImage: { type: String, default: "" }, backgroundImage: { type: String, default: "" },
// Multiple slides
slides: { type: [HeroSlideSchema], default: [] },
// Legacy single-slide fields (backward compatible)
title: { type: String, default: "" },
subtitle: { type: String, default: "" },
description: { type: String, default: "" },
primaryButton: { type: LinkSchema, default: () => ({}) },
secondaryButton: { type: LinkSchema, default: () => ({}) },
heroImage: { type: String, default: "" },
videoUrl: { type: String, default: "" }, videoUrl: { type: String, default: "" },
}, },
{ _id: false }, { _id: false },
@@ -38,6 +59,9 @@ const WhyChooseUsSchema = new Schema(
heading: { type: String, default: "" }, heading: { type: String, default: "" },
subheading: { type: String, default: "" }, subheading: { type: String, default: "" },
description: { type: String, default: "" }, description: { type: String, default: "" },
highlightWord: { type: String, default: "" },
mainImage: { type: String, default: "" },
secondaryImage: { type: String, default: "" },
items: { type: [WhyChooseUsItemSchema], default: [] }, items: { type: [WhyChooseUsItemSchema], default: [] },
features: { type: [String], default: [] }, features: { type: [String], default: [] },
ctaButton: { type: LinkSchema, default: () => ({}) }, ctaButton: { type: LinkSchema, default: () => ({}) },

2891
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,6 @@ const { ensureAuthenticated } = require("../middleware/auth");
const dashboardController = require("../controllers/dashboardController"); const dashboardController = require("../controllers/dashboardController");
const uploadController = require("../controllers/uploadController"); const uploadController = require("../controllers/uploadController");
const homeController = require("../controllers/homeController"); const homeController = require("../controllers/homeController");
const aboutController = require("../controllers/aboutController");
const headerController = require("../controllers/headerController"); const headerController = require("../controllers/headerController");
const footerController = require("../controllers/footerController"); const footerController = require("../controllers/footerController");
const aboutUsController = require("../controllers/aboutUsController"); const aboutUsController = require("../controllers/aboutUsController");
@@ -48,18 +47,9 @@ router.param("code", (req, res, next, code) => {
next(); next();
}); });
// About // About Us
router.get("/about", ensureAuthenticated, aboutController.index);
router.post("/about/update", ensureAuthenticated, aboutController.update);
// AboutUs admin CRUD
router.get("/about-us", ensureAuthenticated, aboutUsController.index); router.get("/about-us", ensureAuthenticated, aboutUsController.index);
router.get("/about-us/create", ensureAuthenticated, aboutUsController.createForm); router.post("/about-us/update", ensureAuthenticated, aboutUsController.update);
router.post("/about-us/create", ensureAuthenticated, aboutUsController.create);
router.get("/about-us/:id/edit", ensureAuthenticated, aboutUsController.editForm);
router.post("/about-us/:id/update", ensureAuthenticated, aboutUsController.update);
router.post("/about-us/:id/delete", ensureAuthenticated, aboutUsController.delete);
router.get("/about-us/:id/preview", ensureAuthenticated, aboutUsController.preview);
// Booking admin CRUD removed // Booking admin CRUD removed

View File

@@ -2,7 +2,6 @@ const express = require("express");
const path = require("path"); const path = require("path");
const router = express.Router(); const router = express.Router();
const homeController = require("../controllers/homeController"); const homeController = require("../controllers/homeController");
const aboutController = require("../controllers/aboutController");
const aboutUsController = require("../controllers/aboutUsController"); const aboutUsController = require("../controllers/aboutUsController");
const headerController = require("../controllers/headerController"); const headerController = require("../controllers/headerController");
const socialLinkController = require("../controllers/socialLinkController"); const socialLinkController = require("../controllers/socialLinkController");
@@ -38,12 +37,12 @@ router.get("/", (req, res) => {
router.get("/api/home", homeController.api); router.get("/api/home", homeController.api);
// API để lấy dữ liệu about // API để lấy dữ liệu about
router.get("/api/about", aboutController.api); router.get("/api/about", aboutUsController.getAbout);
router.put("/api/about", aboutUsController.updateAbout);
// Public about-us page and API (aboutUs.json flow) // Public about-us page and API (legacy support)
router.get("/about-us", aboutUsController.page); router.get("/about-us", aboutUsController.getAbout);
// Return a list/array of AboutUs records for frontend consumption router.get("/api/about-us", aboutUsController.getAbout);
router.get("/api/about-us", aboutUsController.apiList);
// Header API route // Header API route
router.get("/api/header", headerController.api); router.get("/api/header", headerController.api);

48
scripts/migrateAboutUs.js Normal file
View File

@@ -0,0 +1,48 @@
const mongoose = require("mongoose");
const dotenv = require("dotenv");
const fs = require("fs");
const path = require("path");
// Load environment variables
dotenv.config();
const AboutUs = require("../models/aboutUs");
const migrate = async () => {
try {
console.log("🚀 Starting About Us migration...");
// 1. Connect to MongoDB
await mongoose.connect(process.env.MONGODB_URI);
console.log("✅ MongoDB Connected");
// 2. Read about.json from Backend (Source of Truth)
const jsonPath = path.join(__dirname, "../data/about.json");
if (!fs.existsSync(jsonPath)) {
throw new Error(`Source about.json not found at: ${jsonPath}`);
}
const rawData = fs.readFileSync(jsonPath, "utf8");
const jsonData = JSON.parse(rawData);
console.log("✅ Read about.json successfully");
// 3. Delete existing AboutUs documents (Singleton pattern)
await AboutUs.deleteMany({});
console.log("✅ Cleared existing AboutUs collection");
// 4. Create new AboutUs document with JSON data
const newAboutUs = new AboutUs(jsonData);
await newAboutUs.save();
console.log("✅ Successfully migrated about.json data to MongoDB");
} catch (error) {
console.error("❌ Migration failed:", error.message);
} finally {
// 5. Close connection
await mongoose.connection.close();
console.log("👋 Database connection closed");
process.exit(0);
}
};
migrate();

52
scripts/seedAbout.js Normal file
View File

@@ -0,0 +1,52 @@
const mongoose = require("mongoose");
const dotenv = require("dotenv");
const fs = require("fs");
const path = require("path");
// Load environment variables
dotenv.config();
const AboutUs = require("../models/aboutUs");
const seedAbout = async () => {
try {
console.log("🚀 Starting About section seeding...");
// 1. Connect to MongoDB
if (!process.env.MONGODB_URI) {
throw new Error("MONGODB_URI is not defined in environment variables");
}
await mongoose.connect(process.env.MONGODB_URI);
console.log("✅ MongoDB Connected");
// 2. Read about.json (Single Source of Truth)
const jsonPath = path.join(__dirname, "../data/about.json");
if (!fs.existsSync(jsonPath)) {
throw new Error(`Source about.json not found at: ${jsonPath}`);
}
const rawData = fs.readFileSync(jsonPath, "utf8");
const jsonData = JSON.parse(rawData);
console.log("✅ Read data/about.json successfully");
// 3. Upsert logic (Singleton pattern)
// We look for any existing document and update it, or create a new one if none exists.
await AboutUs.findOneAndUpdate(
{},
jsonData,
{ upsert: true, new: true, setDefaultsOnInsert: true }
);
console.log("✅ Successfully seeded about.json data to MongoDB (Upserted)");
} catch (error) {
console.error("❌ Seeding failed:", error.message);
} finally {
// 4. Close connection
await mongoose.connection.close();
console.log("👋 Database connection closed");
process.exit(0);
}
};
seedAbout();

View File

@@ -44,6 +44,9 @@ app.use(
express.static(path.join(__dirname, "assets")), express.static(path.join(__dirname, "assets")),
); );
// Map /assets/img to public/img to support frontend paths
app.use("/assets/img", express.static(path.join(__dirname, "public", "img")));
// Serve public folder at root (for /js, /img, etc.) // Serve public folder at root (for /js, /img, etc.)
app.use(express.static(path.join(__dirname, "public"))); app.use(express.static(path.join(__dirname, "public")));

View File

@@ -4,8 +4,8 @@
* @returns {Object} - Dữ liệu đã được xử lý với đường dẫn hình ảnh đầy đủ * @returns {Object} - Dữ liệu đã được xử lý với đường dẫn hình ảnh đầy đủ
*/ */
function addBaseUrlToImages(data, baseUrl) { function addBaseUrlToImages(data, baseUrl) {
// baseUrl can be passed explicitly (e.g., from req), otherwise fall back to env // Use passed baseUrl, then env var, then default to localhost:3001
const BACKEND_URL = baseUrl || process.env.BACKEND_URL || ""; const BACKEND_URL = (baseUrl || process.env.BACKEND_URL || "http://localhost:3001").replace(/\/$/, "");
// Tạo bản sao sâu để tránh thay đổi dữ liệu gốc // Tạo bản sao sâu để tránh thay đổi dữ liệu gốc
const processedData = JSON.parse(JSON.stringify(data)); const processedData = JSON.parse(JSON.stringify(data));
@@ -14,16 +14,27 @@ function addBaseUrlToImages(data, baseUrl) {
const processObject = (obj) => { const processObject = (obj) => {
if (!obj || typeof obj !== "object") return; if (!obj || typeof obj !== "object") return;
Object.keys(obj).forEach((key) => { if (Array.isArray(obj)) {
// Kiểm tra nếu thuộc tính chứa đường dẫn hình ảnh bắt đầu bằng /uploads/ obj.forEach((item, index) => {
if (typeof obj[key] === "string" && obj[key].startsWith("/uploads/")) { if (typeof item === "string" && (item.startsWith("/uploads/") || item.startsWith("/assets/img/"))) {
// Thêm BACKEND_URL nếu đường dẫn chưa có http if (!item.startsWith("http")) {
if (!obj[key].startsWith("http")) { obj[index] = `${BACKEND_URL}${item}`;
obj[key] = `${BACKEND_URL}${obj[key]}`;
} }
} else if (typeof obj[key] === "object") { } else if (typeof item === "object") {
// Đệ quy xử lý các đối tượng và mảng lồng nhau processObject(item);
processObject(obj[key]); }
});
return;
}
Object.keys(obj).forEach((key) => {
const value = obj[key];
if (typeof value === "string" && (value.startsWith("/uploads/") || value.startsWith("/assets/img/"))) {
if (!value.startsWith("http")) {
obj[key] = `${BACKEND_URL}${value}`;
}
} else if (value && typeof value === "object") {
processObject(value);
} }
}); });
}; };

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -269,6 +269,11 @@
const targetId = event.target.getAttribute('href').substring(1); const targetId = event.target.getAttribute('href').substring(1);
document.getElementById('activeTabInput').value = targetId; document.getElementById('activeTabInput').value = targetId;
// Update URL without reload to preserve tab state
const url = new URL(window.location);
url.searchParams.set('tab', targetId);
window.history.replaceState({}, '', url);
// Only load Menu Tree if clicking on the menu tab // Only load Menu Tree if clicking on the menu tab
if (targetId === 'menu') { if (targetId === 'menu') {
loadMenuTree(); loadMenuTree();
@@ -365,6 +370,12 @@
showNotification('All changes saved successfully', 'success'); showNotification('All changes saved successfully', 'success');
submitBtn.classList.remove('btn-primary'); submitBtn.classList.remove('btn-primary');
submitBtn.classList.add('btn-outline-primary'); submitBtn.classList.add('btn-outline-primary');
// Reload to refresh data, preserve current tab
const currentTab = document.getElementById('activeTabInput').value;
setTimeout(() => {
window.location.href = window.location.pathname + '?tab=' + currentTab;
}, 1000);
} else { } else {
const errorMsg = (!headerResult.success ? headerResult.message : '') || (!menuResult.success ? menuResult.message : '') || 'Unable to save some changes'; const errorMsg = (!headerResult.success ? headerResult.message : '') || (!menuResult.success ? menuResult.message : '') || 'Unable to save some changes';
showNotification('Error: ' + errorMsg, 'error'); showNotification('Error: ' + errorMsg, 'error');
@@ -1113,19 +1124,29 @@
console.log('Response:', response.data); console.log('Response:', response.data);
if (response.data.success || response.status === 200) { if (response.data.success || response.status === 200) {
showToast('Success', 'Menu information has been updated', 'success'); showNotification('Menu item saved successfully', 'success');
// Hide modal // Hide modal
const modalElement = document.getElementById('modalAddMenu'); const modalElement = document.getElementById('modalAddMenu');
const modal = bootstrap.Modal.getOrCreateInstance(modalElement); const modal = bootstrap.Modal.getOrCreateInstance(modalElement);
modal.hide(); modal.hide();
// Refresh data or reload tab
setTimeout(() => window.location.reload(), 1000); // Mark as changed so user needs to click Save Changes
if (typeof window.markHeaderChanged === 'function') {
window.markHeaderChanged();
}
// Reload page to show updated menu structure, preserve current tab
const currentTab = document.getElementById('activeTabInput').value;
setTimeout(() => {
window.location.href = window.location.pathname + '?tab=' + currentTab;
}, 1000);
} else { } else {
showToast('Error', response.data.message || 'Unable to save menu', 'error'); showNotification(response.data.message || 'Unable to save menu', 'error');
} }
} catch (error) { } catch (error) {
console.error('AJAX Error:', error); console.error('AJAX Error:', error);
showToast('Error', 'Server connection error: ' + (error.response?.data?.message || error.message), 'error'); showNotification('Server connection error: ' + (error.response?.data?.message || error.message), 'error');
} finally { } finally {
if (submitBtn) { if (submitBtn) {
submitBtn.disabled = false; submitBtn.disabled = false;

View File

@@ -38,7 +38,7 @@
<span class="badge bg-light text-dark border ms-2" style="font-size: 0.7rem;">External</span> <span class="badge bg-light text-dark border ms-2" style="font-size: 0.7rem;">External</span>
<% } %> <% } %>
<% if (item.status === 'inactive') { %> <% if (item.status === 'inactive') { %>
<span class="badge bg-soft-secondary ms-2">Inactive</span> <span class="badge ms-2 bg-soft-danger text-danger">Inactive</span>
<% } else { %> <% } else { %>
<span class="badge bg-soft-success ms-2">Active</span> <span class="badge bg-soft-success ms-2">Active</span>
<% } %> <% } %>

View File

@@ -13,32 +13,18 @@
<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="faqHeading" value="<%= data.faq?.heading || '' %>"
type="text" placeholder="e.g., Got Questions? We've Got Answers" />
class="form-control"
id="faqHeading"
value="<%= data.faq?.heading || '' %>"
placeholder="e.g., Got Questions? We've Got Answers"
/>
</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="faqSubheading" value="<%= data.faq?.subheading || '' %>"
type="text" placeholder="e.g., Visa FAQs" />
class="form-control"
id="faqSubheading"
value="<%= data.faq?.subheading || '' %>"
placeholder="e.g., Visa FAQs"
/>
</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" id="faqDescription" rows="3"
class="form-control" placeholder="Enter description"><%= data.faq?.description || '' %></textarea>
id="faqDescription"
rows="3"
placeholder="Enter description"
><%= data.faq?.description || '' %></textarea>
</div> </div>
</div> </div>
</div> </div>
@@ -48,16 +34,33 @@
<!-- FAQ Items --> <!-- FAQ Items -->
<div class="col-md-12"> <div class="col-md-12">
<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-question-circle me-2"></i>FAQ Items <i class="fas fa-question-circle me-2"></i>FAQ Items
</h6> </h6>
</div> </div>
<div class="card-body" id="faqItemsContainer">
<%
const faqItems = (data.faq && Array.isArray(data.faq.items) && data.faq.items.length === 5)
? data.faq.items
: (data.faq && Array.isArray(data.faq.items) && data.faq.items.length > 0)
? (function () {
const clone = data.faq.items.slice(0, 5);
while (clone.length < 5) clone.push({});
return clone;
})()
: [{}, {}, {}, {}, {}];
%>
<% faqItems.forEach(function(item, index) { %>
<div class="card mb-3 bg-light border faq-item" data-index="<%= index %>">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<h6 class="card-title fw-bold mb-0">FAQ
<span class="faq-item-label">
<%= index + 1 %>
</span>
</h6>
</div>
<div class="card-body"> <div class="card-body">
<% (data.faq?.items || []).forEach(function(item, index) { %>
<div class="card mb-3 bg-light border">
<div class="card-body">
<h6 class="card-title fw-bold mb-3">FAQ <%= index + 1 %></h6>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Question</label> <label class="form-label fw-medium">Question</label>
@@ -98,23 +101,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">Label</label> <label class="form-label fw-medium">Label</label>
<input <input type="text" class="form-control" id="faqCtaLabel" value="<%= data.faq?.ctaButton?.label || '' %>"
type="text" placeholder="e.g., contact us" />
class="form-control"
id="faqCtaLabel"
value="<%= data.faq?.ctaButton?.label || '' %>"
placeholder="e.g., contact us"
/>
</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 <input type="text" class="form-control" id="faqCtaHref" value="<%= data.faq?.ctaButton?.href || '' %>"
type="text" placeholder="/contact" />
class="form-control"
id="faqCtaHref"
value="<%= data.faq?.ctaButton?.href || '' %>"
placeholder="/contact"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -122,3 +115,34 @@
</div> </div>
</div> </div>
</div> </div>
<script>
// Register scraper to collect FAQ data before form submit
window.homeScrapers = window.homeScrapers || {};
window.homeScrapers.faq = () => {
const getVal = (id) => (document.getElementById(id)?.value || "").trim();
const items = [];
document.querySelectorAll(".faq-item").forEach((el, idx) => {
const index = el.getAttribute("data-index") || idx;
const question = getVal(`faqQuestion_${index}`);
const answer = getVal(`faqAnswer_${index}`);
if (question || answer) {
items.push({ question, answer });
}
});
return {
heading: getVal("faqHeading"),
subheading: getVal("faqSubheading"),
description: getVal("faqDescription"),
ctaButton: {
label: getVal("faqCtaLabel"),
href: getVal("faqCtaHref")
},
items
};
};
</script>

View File

@@ -1,119 +1,132 @@
<!-- 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">
<!-- Basic Info --> <!-- Background Image (section-level) -->
<div class="col-md-12"> <div class="col-md-12">
<div class="card border shadow-sm"> <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-image me-2"></i>Hero Background
</h6> </h6>
</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">
<label class="form-label fw-medium">Title</label>
<input
type="text"
class="form-control"
id="heroTitle"
value="<%= data.hero?.title || '' %>"
placeholder="e.g., From Application to Visa"
/>
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Subtitle</label>
<input
type="text"
class="form-control"
id="heroSubtitle"
value="<%= data.hero?.subtitle || '' %>"
placeholder="e.g., Global Education Simplified"
/>
</div>
<div class="col-md-12">
<label class="form-label fw-medium">Description</label>
<textarea
class="form-control"
id="heroDescription"
rows="3"
placeholder="Enter hero description"
><%= data.hero?.description || '' %></textarea>
</div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Background Image</label> <label class="form-label fw-medium">Background Image</label>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input <input type="text" class="form-control" id="heroBackgroundImage"
type="text" value="<%= data.hero?.backgroundImage || '' %>" placeholder="/assets/img/home-1/hero/bg.jpg" />
class="form-control" <button type="button" class="btn btn-outline-primary btn-upload-image"
id="heroBackgroundImage" data-target-input="heroBackgroundImage" data-image-type="home">
value="<%= data.hero?.backgroundImage || '' %>"
/>
<button
type="button"
class="btn btn-outline-primary btn-upload-image"
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
</button> </button>
</div> </div>
<% if (data.hero?.backgroundImage) { %> <% if (data.hero?.backgroundImage) { %>
<div class="mt-2"> <div class="mt-2">
<img <img src="<%= data.hero.backgroundImage %>" class="img-thumbnail"
src="<%= data.hero.backgroundImage %>" style="height: 200px; width: 100%; object-fit: cover;" alt="Background preview" />
class="img-thumbnail"
style="height: 200px; width: 100%; object-fit: cover;"
alt="Background preview"
/>
</div> </div>
<% } %> <% } %>
</div> </div>
<div class="col-md-6">
<label class="form-label fw-medium">Video URL</label>
<input
type="text"
class="form-control"
id="heroVideoUrl"
value="<%= data.hero?.videoUrl || '' %>"
placeholder="https://www.youtube.com/watch?v=..."
/>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Primary Button --> <!-- Slides -->
<div class="col-md-6"> <div class="col-md-12">
<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-mouse-pointer me-2"></i>Primary Button <i class="fas fa-sliders-h me-2"></i>Hero Slides
</h6> </h6>
<button type="button" class="btn btn-sm btn-primary" id="addHeroSlideBtn">
<i class="fas fa-plus me-1"></i>Add Slide
</button>
</div>
<div class="card-body" id="heroSlidesContainer">
<% const existingSlides=(data.hero && Array.isArray(data.hero.slides) && data.hero.slides.length> 0)
? data.hero.slides
: [{
title: data.hero?.title || '',
subtitle: data.hero?.subtitle || '',
description: data.hero?.description || '',
heroImage: data.hero?.heroImage || '',
videoUrl: data.hero?.videoUrl || '',
primaryButton: data.hero?.primaryButton || {},
secondaryButton: data.hero?.secondaryButton || {},
}];
%>
<% existingSlides.forEach(function(slide, index) { %>
<div class="card mb-3 border hero-slide-item" data-index="<%= index %>">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-semibold">Slide
<span class="hero-slide-label">
<%= index + 1 %>
</span>
</span>
<button type="button" class="btn btn-sm btn-outline-danger btn-remove-hero-slide">
<i class="fas fa-trash-alt"></i>
</button>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-medium">Title</label>
<input type="text" class="form-control" id="heroSlide_<%= index %>_title"
value="<%= slide.title || '' %>" placeholder="e.g., From Application to Visa" />
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Subtitle</label>
<input type="text" class="form-control" id="heroSlide_<%= index %>_subtitle"
value="<%= slide.subtitle || '' %>" placeholder="e.g., Global Education Simplified" />
</div>
<div class="col-md-12">
<label class="form-label fw-medium">Description</label>
<textarea class="form-control" id="heroSlide_<%= index %>_description" rows="3"
placeholder="Enter hero description"><%= slide.description || '' %></textarea>
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Hero Image</label>
<small class="text-muted d-block mb-1">Recommended size: 893x848px</small>
<div class="input-group mb-2">
<input type="text" class="form-control" id="heroSlide_<%= index %>_heroImage"
value="<%= slide.heroImage || '' %>" placeholder="/assets/img/home-1/hero/man.png" />
<button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="heroSlide_<%= index %>_heroImage" data-image-type="home">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
<% if (slide.heroImage) { %>
<div class="mt-2">
<img src="<%= slide.heroImage %>" class="img-thumbnail"
style="height: 200px; width: 100%; object-fit: cover;" alt="Hero image preview" />
</div>
<% } %>
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Video URL</label>
<input type="text" class="form-control" id="heroSlide_<%= index %>_videoUrl"
value="<%= slide.videoUrl || '' %>" placeholder="https://www.youtube.com/watch?v=..." />
</div>
<!-- Primary Button -->
<div class="col-md-6">
<div class="border rounded p-3 h-100">
<h6 class="fw-semibold mb-3">
<i class="fas fa-mouse-pointer me-2"></i>Primary Button
</h6>
<div class="row g-3"> <div class="row g-3">
<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 <input type="text" class="form-control" id="heroSlide_<%= index %>_primaryLabel"
type="text" value="<%= slide.primaryButton?.label || '' %>" placeholder="e.g., Apply now" />
class="form-control"
id="heroPrimaryButtonLabel"
value="<%= data.hero?.primaryButton?.label || '' %>"
placeholder="e.g., Apply now"
/>
</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 <input type="text" class="form-control" id="heroSlide_<%= index %>_primaryHref"
type="text" value="<%= slide.primaryButton?.href || '' %>" placeholder="/contact" />
class="form-control"
id="heroPrimaryButtonHref"
value="<%= data.hero?.primaryButton?.href || '' %>"
placeholder="/contact"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -121,33 +134,21 @@
<!-- Secondary Button --> <!-- Secondary Button -->
<div class="col-md-6"> <div class="col-md-6">
<div class="card border shadow-sm"> <div class="border rounded p-3 h-100">
<div class="card-header bg-white"> <h6 class="fw-semibold mb-3">
<h6 class="mb-0">
<i class="fas fa-mouse-pointer me-2"></i>Secondary Button <i class="fas fa-mouse-pointer me-2"></i>Secondary Button
</h6> </h6>
</div>
<div class="card-body">
<div class="row g-3"> <div class="row g-3">
<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 <input type="text" class="form-control" id="heroSlide_<%= index %>_secondaryLabel"
type="text" value="<%= slide.secondaryButton?.label || '' %>"
class="form-control" placeholder="e.g., Book Free Consultation" />
id="heroSecondaryButtonLabel"
value="<%= data.hero?.secondaryButton?.label || '' %>"
placeholder="e.g., Book Free Consultation"
/>
</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 <input type="text" class="form-control" id="heroSlide_<%= index %>_secondaryHref"
type="text" value="<%= slide.secondaryButton?.href || '' %>" placeholder="/contact" />
class="form-control"
id="heroSecondaryButtonHref"
value="<%= data.hero?.secondaryButton?.href || '' %>"
placeholder="/contact"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -155,3 +156,157 @@
</div> </div>
</div> </div>
</div> </div>
<% }); %>
</div>
</div>
</div>
</div>
</div>
<script>
// Register scraper to collect Hero data before form submit
window.homeScrapers = window.homeScrapers || {};
window.homeScrapers.hero = () => {
const getVal = (id) => (document.getElementById(id)?.value || "").trim();
const backgroundImage = getVal("heroBackgroundImage");
const slides = [];
const slideEls = document.querySelectorAll(".hero-slide-item");
slideEls.forEach((el, idx) => {
const index = el.getAttribute("data-index") || idx;
const prefix = `heroSlide_${index}_`;
const slide = {
title: getVal(prefix + "title"),
subtitle: getVal(prefix + "subtitle"),
description: getVal(prefix + "description"),
heroImage: getVal(prefix + "heroImage"),
videoUrl: getVal(prefix + "videoUrl"),
primaryButton: {
label: getVal(prefix + "primaryLabel"),
href: getVal(prefix + "primaryHref")
},
secondaryButton: {
label: getVal(prefix + "secondaryLabel"),
href: getVal(prefix + "secondaryHref")
}
};
// Bỏ qua slide trống hoàn toàn
const hasContent = slide.title || slide.subtitle || slide.description || slide.heroImage || slide.videoUrl || slide.primaryButton.label || slide.secondaryButton.label;
if (hasContent) {
slides.push(slide);
}
});
// Legacy single-slide fields: map từ slide đầu tiên (nếu có) để không vỡ FE cũ
const first = slides[0] || {};
return {
backgroundImage,
slides,
title: first.title || "",
subtitle: first.subtitle || "",
description: first.description || "",
heroImage: first.heroImage || "",
videoUrl: first.videoUrl || "",
primaryButton: first.primaryButton || {
label: "",
href: ""
},
secondaryButton: first.secondaryButton || {
label: "",
href: ""
}
};
};
// Add / remove slides on the client
document.addEventListener("DOMContentLoaded", function () {
const container = document.getElementById("heroSlidesContainer");
const addBtn = document.getElementById("addHeroSlideBtn");
if (!container || !addBtn)
return;
// Re-index slides, update labels, IDs and upload target attributes
const updateLabels = () => {
container.querySelectorAll(".hero-slide-item").forEach((el, idx) => {
el.setAttribute("data-index", String(idx));
const label = el.querySelector(".hero-slide-label");
if (label)
label.textContent = String(idx + 1);
// Update input/textarea IDs to match new index
el.querySelectorAll("input, textarea").forEach((input) => {
if (!input.id)
return;
const newId = input.id.replace(/heroSlide_\d+_/, `heroSlide_${idx}_`);
input.id = newId;
});
// Update upload button target-input so upload goes to correct slide
el.querySelectorAll(".btn-upload-image").forEach((btn) => {
const target = btn.getAttribute("data-target-input") || "";
if (!target)
return;
const newTarget = target.replace(/heroSlide_\d+_/, `heroSlide_${idx}_`);
btn.setAttribute("data-target-input", newTarget);
});
});
};
addBtn.addEventListener("click", () => {
const template = container.querySelector(".hero-slide-item");
if (!template)
return;
const clone = template.cloneNode(true);
// Clear values for the cloned slide (IDs will be fixed by updateLabels)
clone.querySelectorAll("input, textarea").forEach((input) => {
input.value = "";
});
// Clear image previews
clone.querySelectorAll("img").forEach((img) => {
img.classList.add("d-none");
img.removeAttribute("src");
});
container.appendChild(clone);
updateLabels();
});
container.addEventListener("click", (e) => {
const btn = e.target.closest(".btn-remove-hero-slide");
if (!btn)
return;
const card = btn.closest(".hero-slide-item");
if (!card)
return;
const all = container.querySelectorAll(".hero-slide-item");
if (all.length <= 1) { // Không cho xóa slide cuối cùng
return;
}
card.remove();
updateLabels();
});
// Initial normalization (in case indices rendered from server are not 0..n)
updateLabels();
});
</script>

View File

@@ -13,49 +13,26 @@
<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="testimonialsHeading"
type="text" value="<%= data.testimonials?.heading || '' %>" placeholder="e.g., Student Reviews & Testimonials" />
class="form-control"
id="testimonialsHeading"
value="<%= data.testimonials?.heading || '' %>"
placeholder="e.g., Student Reviews & Testimonials"
/>
</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="testimonialsSubheading"
type="text" value="<%= data.testimonials?.subheading || '' %>" placeholder="e.g., What Our Students Say" />
class="form-control"
id="testimonialsSubheading"
value="<%= data.testimonials?.subheading || '' %>"
placeholder="e.g., What Our Students Say"
/>
</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 <input type="text" class="form-control" id="testimonialsVideoUrl"
type="text" value="<%= data.testimonials?.videoUrl || '' %>" placeholder="https://www.youtube.com/watch?v=..." />
class="form-control"
id="testimonialsVideoUrl"
value="<%= data.testimonials?.videoUrl || '' %>"
placeholder="https://www.youtube.com/watch?v=..."
/>
</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>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input <input type="text" class="form-control" id="testimonialsVideoThumbnail"
type="text" value="<%= data.testimonials?.videoThumbnail || '' %>" />
class="form-control" <button type="button" class="btn btn-outline-primary btn-upload-image"
id="testimonialsVideoThumbnail" data-target-input="testimonialsVideoThumbnail" data-image-type="home">
value="<%= data.testimonials?.videoThumbnail || '' %>"
/>
<button
type="button"
class="btn btn-outline-primary btn-upload-image"
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
</button> </button>
</div> </div>
@@ -68,82 +45,59 @@
<!-- Testimonial Items --> <!-- Testimonial Items -->
<div class="col-md-12"> <div class="col-md-12">
<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-comments me-2"></i>Testimonials <i class="fas fa-comments me-2"></i>Testimonials
</h6> </h6>
<button type="button" class="btn btn-sm btn-primary" id="addTestimonialBtn">
<i class="fas fa-plus me-1"></i>Add Testimonial
</button>
</div>
<div class="card-body" id="testimonialsItemsContainer">
<% (data.testimonials?.items || []).forEach(function(item, index) { %>
<div class="card mb-3 bg-light border testimonial-item" data-index="<%= index %>">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<h6 class="card-title fw-bold mb-0">Testimonial <span class="testimonial-label">
<%= index + 1 %>
</span></h6>
<button type="button" class="btn btn-sm btn-outline-danger btn-remove-testimonial">
<i class="fas fa-trash-alt"></i>
</button>
</div> </div>
<div class="card-body"> <div class="card-body">
<% (data.testimonials?.items || []).forEach(function(item, index) { %>
<div class="card mb-3 bg-light border">
<div class="card-body">
<h6 class="card-title fw-bold mb-3">Testimonial <%= index + 1 %></h6>
<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">Name</label> <label class="form-label fw-medium">Name</label>
<input <input type="text" class="form-control" id="testimonialsName_<%= index %>"
type="text" value="<%= item.name || '' %>" placeholder="e.g., Sohel Tanvir" />
class="form-control"
id="testimonialsName_<%= index %>"
value="<%= item.name || '' %>"
placeholder="e.g., Sohel Tanvir"
/>
</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 <input type="text" class="form-control" id="testimonialsRole_<%= index %>"
type="text" value="<%= item.role || '' %>" placeholder="e.g., Student" />
class="form-control"
id="testimonialsRole_<%= index %>"
value="<%= item.role || '' %>"
placeholder="e.g., Student"
/>
</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 <input type="text" class="form-control" id="testimonialsCountry_<%= index %>"
type="text" value="<%= item.country || '' %>" placeholder="e.g., Canada" />
class="form-control"
id="testimonialsCountry_<%= index %>"
value="<%= item.country || '' %>"
placeholder="e.g., Canada"
/>
</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>
<input <input type="number" class="form-control" id="testimonialsRating_<%= index %>"
type="number" value="<%= item.rating || 5 %>" min="1" max="5" />
class="form-control"
id="testimonialsRating_<%= index %>"
value="<%= item.rating || 5 %>"
min="1"
max="5"
/>
</div> </div>
<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 <textarea class="form-control" id="testimonialsComment_<%= index %>" rows="3"
class="form-control" placeholder="Enter testimonial comment"><%= item.comment || '' %></textarea>
id="testimonialsComment_<%= index %>"
rows="3"
placeholder="Enter testimonial comment"
><%= 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>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input <input type="text" class="form-control" id="testimonialsAvatar_<%= index %>"
type="text" value="<%= item.avatar || '' %>" />
class="form-control" <button type="button" class="btn btn-outline-primary btn-upload-image"
id="testimonialsAvatar_<%= index %>" data-target-input="testimonialsAvatar_<%= index %>" data-image-type="home">
value="<%= item.avatar || '' %>"
/>
<button
type="button"
class="btn btn-outline-primary btn-upload-image"
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
</button> </button>
</div> </div>
@@ -157,3 +111,101 @@
</div> </div>
</div> </div>
</div> </div>
<script>
// Register scraper to collect Testimonials data before form submit
window.homeScrapers = window.homeScrapers || {};
window.homeScrapers.testimonials = () => {
const getVal = (id) => (document.getElementById(id)?.value || "").trim();
const items = [];
document.querySelectorAll(".testimonial-item").forEach((el, idx) => {
const index = el.getAttribute("data-index") || idx;
const name = getVal(`testimonialsName_${index}`);
const role = getVal(`testimonialsRole_${index}`);
const country = getVal(`testimonialsCountry_${index}`);
const ratingStr = getVal(`testimonialsRating_${index}`);
const rating = ratingStr ? parseInt(ratingStr, 10) : 5;
const comment = getVal(`testimonialsComment_${index}`);
const avatar = getVal(`testimonialsAvatar_${index}`);
if (name || role || country || comment || avatar) {
items.push({
name,
role,
country,
rating: isNaN(rating) ? 5 : Math.min(Math.max(rating, 1), 5),
comment,
avatar,
});
}
});
return {
heading: getVal("testimonialsHeading"),
subheading: getVal("testimonialsSubheading"),
videoUrl: getVal("testimonialsVideoUrl"),
videoThumbnail: getVal("testimonialsVideoThumbnail"),
items,
};
};
// Client-side add/remove for testimonials
document.addEventListener("DOMContentLoaded", function () {
const container = document.getElementById("testimonialsItemsContainer");
const addBtn = document.getElementById("addTestimonialBtn");
if (!container || !addBtn) return;
const updateLabels = () => {
container.querySelectorAll(".testimonial-item").forEach((el, idx) => {
el.setAttribute("data-index", String(idx));
const label = el.querySelector(".testimonial-label");
if (label) label.textContent = String(idx + 1);
el.querySelectorAll("input, textarea").forEach((input) => {
if (!input.id) return;
const newId = input.id.replace(/testimonials\w+_\d+/, (match) => {
const [prefix] = match.split("_");
return `${prefix}_${idx}`;
});
input.id = newId;
});
});
};
addBtn.addEventListener("click", () => {
const template = container.querySelector(".testimonial-item");
if (!template) return;
const clone = template.cloneNode(true);
clone.querySelectorAll("input, textarea").forEach((input) => {
input.value = "";
});
container.appendChild(clone);
updateLabels();
});
container.addEventListener("click", (e) => {
const btn = e.target.closest(".btn-remove-testimonial");
if (!btn) return;
const card = btn.closest(".testimonial-item");
if (!card) return;
const all = container.querySelectorAll(".testimonial-item");
if (all.length <= 1) {
return;
}
card.remove();
updateLabels();
});
updateLabels();
});
</script>

View File

@@ -12,50 +12,28 @@
<div class="row g-3"> <div class="row g-3">
<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 <input type="text" class="form-control" id="videoGalleryHeading"
type="text" value="<%= data.videoGallery?.heading || '' %>" placeholder="e.g., VIDEO PLAY GALLERY" />
class="form-control"
id="videoGalleryHeading"
value="<%= data.videoGallery?.heading || '' %>"
placeholder="e.g., VIDEO PLAY GALLERY"
/>
</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 <input type="text" class="form-control" id="videoGalleryVideoUrl"
type="text" value="<%= data.videoGallery?.videoUrl || '' %>" placeholder="https://example.com/video.mp4" />
class="form-control"
id="videoGalleryVideoUrl"
value="<%= data.videoGallery?.videoUrl || '' %>"
placeholder="https://example.com/video.mp4"
/>
</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>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input <input type="text" class="form-control" id="videoGalleryThumbnail"
type="text" value="<%= data.videoGallery?.thumbnail || '' %>" />
class="form-control" <button type="button" class="btn btn-outline-primary btn-upload-image"
id="videoGalleryThumbnail" data-target-input="videoGalleryThumbnail" data-image-type="home">
value="<%= data.videoGallery?.thumbnail || '' %>"
/>
<button
type="button"
class="btn btn-outline-primary btn-upload-image"
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
</button> </button>
</div> </div>
<% if (data.videoGallery?.thumbnail) { %> <% if (data.videoGallery?.thumbnail) { %>
<div class="mt-2"> <div class="mt-2">
<img <img src="<%= data.videoGallery.thumbnail %>" class="img-thumbnail"
src="<%= data.videoGallery.thumbnail %>" style="height: 200px; width: 100%; object-fit: cover;" alt="Thumbnail preview" />
class="img-thumbnail"
style="height: 200px; width: 100%; object-fit: cover;"
alt="Thumbnail preview"
/>
</div> </div>
<% } %> <% } %>
</div> </div>
@@ -65,3 +43,17 @@
</div> </div>
</div> </div>
</div> </div>
<script>
// Register scraper to collect Video Gallery data before form submit
window.homeScrapers = window.homeScrapers || {};
window.homeScrapers.videoGallery = () => {
const getVal = (id) => (document.getElementById(id)?.value || "").trim();
return {
heading: getVal("videoGalleryHeading"),
videoUrl: getVal("videoGalleryVideoUrl"),
thumbnail: getVal("videoGalleryThumbnail"),
};
};
</script>

View File

@@ -13,114 +13,76 @@
<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="visaCountriesHeading"
type="text" value="<%= data.visaCountries?.heading || '' %>" placeholder="e.g., Visa & VISAWAY Services To UK" />
class="form-control"
id="visaCountriesHeading"
value="<%= data.visaCountries?.heading || '' %>"
placeholder="e.g., Visa & VISAWAY Services To UK"
/>
</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="visaCountriesSubheading"
type="text" value="<%= data.visaCountries?.subheading || '' %>" placeholder="e.g., UK. United Kingdom" />
class="form-control"
id="visaCountriesSubheading"
value="<%= data.visaCountries?.subheading || '' %>"
placeholder="e.g., UK. United Kingdom"
/>
</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" id="visaCountriesDescription" rows="3"
class="form-control" placeholder="Enter description"><%= data.visaCountries?.description || '' %></textarea>
id="visaCountriesDescription"
rows="3"
placeholder="Enter description"
><%= data.visaCountries?.description || '' %></textarea>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Countries --> <!-- Countries (Featured Country configuration) -->
<div class="col-md-12"> <div class="col-md-12">
<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"> <h6 class="mb-0">
<i class="fas fa-globe me-2"></i>Countries <i class="fas fa-globe me-2"></i>Featured Country
</h6> </h6>
<small class="text-muted">This country is used in the home page feature section.</small>
</div> </div>
<div class="card-body"> <div class="card-body">
<% (data.visaCountries?.countries || []).forEach(function(country, index) { %> <% const featured=(data.visaCountries && Array.isArray(data.visaCountries.countries) &&
<div class="card mb-3 bg-light border"> data.visaCountries.countries.length> 0)
? data.visaCountries.countries[0]
: {};
%>
<div class="card mb-3 bg-light border visa-country-item" data-index="0">
<div class="card-body"> <div class="card-body">
<h6 class="card-title fw-bold mb-3">Country <%= index + 1 %></h6>
<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">Country Name</label> <label class="form-label fw-medium">Country Name</label>
<input <input type="text" class="form-control" id="visaCountriesName_0" value="<%= featured.name || '' %>"
type="text" placeholder="e.g., United Kingdom" />
class="form-control"
id="visaCountriesName_<%= index %>"
value="<%= country.name || '' %>"
placeholder="e.g., United Kingdom"
/>
</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 <input type="text" class="form-control" id="visaCountriesCode_0" value="<%= featured.code || '' %>"
type="text" placeholder="e.g., UK" />
class="form-control"
id="visaCountriesCode_<%= index %>"
value="<%= country.code || '' %>"
placeholder="e.g., UK"
/>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Flag Image</label> <label class="form-label fw-medium">Flag / Illustration Image</label>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input <input type="text" class="form-control" id="visaCountriesFlag_0"
type="text" value="<%= featured.flag || '' %>" placeholder="/assets/img/home-1/feature/shape.png" />
class="form-control" <button type="button" class="btn btn-outline-primary btn-upload-image"
id="visaCountriesFlag_<%= index %>" data-target-input="visaCountriesFlag_0" data-image-type="home">
value="<%= country.flag || '' %>"
/>
<button
type="button"
class="btn btn-outline-primary btn-upload-image"
data-target-input="visaCountriesFlag_<%= index %>"
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> </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 <input type="text" class="form-control" id="visaCountriesLink_0" value="<%= featured.link || '' %>"
type="text" placeholder="/country-details/uk" />
class="form-control"
id="visaCountriesLink_<%= index %>"
value="<%= country.link || '' %>"
placeholder="/country-details/uk"
/>
</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 <textarea class="form-control" id="visaCountriesVisaTypes_0" rows="2"
class="form-control" placeholder="e.g., Student Visa, Work Visa, Tourist Visa"><%= (featured.visaTypes || []).join(', ') %></textarea>
id="visaCountriesVisaTypes_<%= index %>"
rows="2"
placeholder="e.g., Student Visa, Work Visa, Tourist Visa"
><%= (country.visaTypes || []).join(', ') %></textarea>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<% }); %>
</div> </div>
</div> </div>
</div> </div>
@@ -137,23 +99,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">Label</label> <label class="form-label fw-medium">Label</label>
<input <input type="text" class="form-control" id="visaCountriesCtaLabel"
type="text" value="<%= data.visaCountries?.ctaButton?.label || '' %>" placeholder="e.g., Get Started" />
class="form-control"
id="visaCountriesCtaLabel"
value="<%= data.visaCountries?.ctaButton?.label || '' %>"
placeholder="e.g., Get Started"
/>
</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 <input type="text" class="form-control" id="visaCountriesCtaHref"
type="text" value="<%= data.visaCountries?.ctaButton?.href || '' %>" placeholder="/contact" />
class="form-control"
id="visaCountriesCtaHref"
value="<%= data.visaCountries?.ctaButton?.href || '' %>"
placeholder="/contact"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -161,3 +113,33 @@
</div> </div>
</div> </div>
</div> </div>
<script>
// Register scraper to collect Visa Countries data before form submit
window.homeScrapers = window.homeScrapers || {};
window.homeScrapers.visaCountries = () => {
const getVal = (id) => (document.getElementById(id)?.value || "").trim();
const visaTypesRaw = getVal("visaCountriesVisaTypes_0");
const visaTypes = visaTypesRaw ? visaTypesRaw.split(",").map((s) => s.trim()).filter(Boolean) : [];
const featuredCountry = {
name: getVal("visaCountriesName_0"),
code: getVal("visaCountriesCode_0"),
flag: getVal("visaCountriesFlag_0"),
link: getVal("visaCountriesLink_0"),
visaTypes
};
return {
heading: getVal("visaCountriesHeading"),
subheading: getVal("visaCountriesSubheading"),
description: getVal("visaCountriesDescription"),
countries: [featuredCountry],
ctaButton: {
label: getVal("visaCountriesCtaLabel"),
href: getVal("visaCountriesCtaHref")
}
};
};
</script>

View File

@@ -13,23 +13,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="visaSolutionsHeading"
type="text" value="<%= data.visaSolutions?.heading || '' %>" placeholder="e.g., Comprehensive Visa Solutions" />
class="form-control"
id="visaSolutionsHeading"
value="<%= data.visaSolutions?.heading || '' %>"
placeholder="e.g., Comprehensive Visa Solutions"
/>
</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="visaSolutionsSubheading"
type="text" value="<%= data.visaSolutions?.subheading || '' %>" placeholder="e.g., Our Expert Services" />
class="form-control"
id="visaSolutionsSubheading"
value="<%= data.visaSolutions?.subheading || '' %>"
placeholder="e.g., Our Expert Services"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -39,55 +29,46 @@
<!-- Services Items --> <!-- Services Items -->
<div class="col-md-12"> <div class="col-md-12">
<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-list-ul me-2"></i>Visa Solutions Items <i class="fas fa-list-ul me-2"></i>Visa Solutions Items
</h6> </h6>
</div> </div>
<div class="card-body" id="visaSolutionsItemsContainer">
<% const vsItems=(data.visaSolutions && Array.isArray(data.visaSolutions.items) &&
data.visaSolutions.items.length===4) ? data.visaSolutions.items : (data.visaSolutions &&
Array.isArray(data.visaSolutions.items) && data.visaSolutions.items.length> 0)
? (function () {
const clone = data.visaSolutions.items.slice(0, 4);
while (clone.length < 4) clone.push({}); return clone; })() : [{}, {}, {}, {}]; %>
<% vsItems.forEach(function(item, index) { %>
<div class="card mb-3 bg-light border visa-solution-item" data-index="<%= index %>">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<h6 class="card-title fw-bold mb-0">Service <span class="visa-solution-label">
<%= index + 1 %>
</span></h6>
</div>
<div class="card-body"> <div class="card-body">
<% (data.visaSolutions?.items || []).forEach(function(item, index) { %>
<div class="card mb-3 bg-light border">
<div class="card-body">
<h6 class="card-title fw-bold mb-3">Service <%= index + 1 %></h6>
<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">Number</label> <label class="form-label fw-medium">Number</label>
<input <input type="text" class="form-control" id="visaSolutionsNumber_<%= index %>"
type="text" value="<%= item.number || '' %>" placeholder="e.g., 01" />
class="form-control"
id="visaSolutionsNumber_<%= index %>"
value="<%= item.number || '' %>"
placeholder="e.g., 01"
/>
</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 <input type="text" class="form-control" id="visaSolutionsTitle_<%= index %>"
type="text" value="<%= item.title || '' %>" placeholder="e.g., Student Visa Guidance" />
class="form-control"
id="visaSolutionsTitle_<%= index %>"
value="<%= item.title || '' %>"
placeholder="e.g., Student Visa Guidance"
/>
</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" id="visaSolutionsDescription_<%= index %>" rows="2"
class="form-control" placeholder="Enter description"><%= item.description || '' %></textarea>
id="visaSolutionsDescription_<%= index %>"
rows="2"
placeholder="Enter description"
><%= 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 <input type="text" class="form-control" id="visaSolutionsLink_<%= index %>"
type="text" value="<%= item.link || '' %>" placeholder="/service-details" />
class="form-control"
id="visaSolutionsLink_<%= index %>"
value="<%= item.link || '' %>"
placeholder="/service-details"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -98,3 +79,59 @@
</div> </div>
</div> </div>
</div> </div>
<script>
// Register scraper to collect Visa Solutions data before form submit
window.homeScrapers = window.homeScrapers || {};
window.homeScrapers.visaSolutions = () => {
const getVal = (id) => (document.getElementById(id)?.value || "").trim();
const items = [];
document.querySelectorAll(".visa-solution-item").forEach((el, idx) => {
const index = el.getAttribute("data-index") || idx;
const number = getVal(`visaSolutionsNumber_${index}`);
const title = getVal(`visaSolutionsTitle_${index}`);
const description = getVal(`visaSolutionsDescription_${index}`);
const link = getVal(`visaSolutionsLink_${index}`);
if (number || title || description || link) {
items.push({ number, title, description, link });
}
});
return {
heading: getVal("visaSolutionsHeading"),
subheading: getVal("visaSolutionsSubheading"),
items,
};
};
// Client-side add/remove for visa solutions
document.addEventListener("DOMContentLoaded", function () {
const container = document.getElementById("visaSolutionsItemsContainer");
if (!container) return;
const updateLabels = () => {
const items = container.querySelectorAll(".visa-solution-item");
items.forEach((el, idx) => {
el.setAttribute("data-index", String(idx));
const label = el.querySelector(".visa-solution-label");
if (label) label.textContent = String(idx + 1);
el.querySelectorAll("input, textarea").forEach((input) => {
if (!input.id) return;
const newId = input.id.replace(/visaSolutions\w+_\d+/, (match) => {
const [prefix] = match.split("_");
return `${prefix}_${idx}`;
});
input.id = newId;
});
});
};
updateLabels();
});
</script>

View File

@@ -3,7 +3,7 @@
<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"> <div class="card border shadow-sm mb-3">
<div class="card-header bg-white"> <div class="card-header bg-white">
<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
@@ -13,32 +13,78 @@
<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="whyChooseUsHeading"
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" />
/>
</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="whyChooseUsSubheading"
type="text" value="<%= data.whyChooseUs?.subheading || '' %>" placeholder="e.g., About Our Consultancy" />
class="form-control" </div>
id="whyChooseUsSubheading" <div class="col-md-6">
value="<%= data.whyChooseUs?.subheading || '' %>" <label class="form-label fw-medium">Highlight Word (Optional)</label>
placeholder="e.g., About Our Consultancy" <input type="text" class="form-control" id="whyChooseUsHighlightWord"
/> value="<%= data.whyChooseUs?.highlightWord || '' %>" placeholder="e.g., Dreams" />
<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 <textarea class="form-control" id="whyChooseUsDescription" rows="3"
class="form-control" placeholder="Enter description"><%= data.whyChooseUs?.description || '' %></textarea>
id="whyChooseUsDescription" </div>
rows="3" </div>
placeholder="Enter description" </div>
><%= data.whyChooseUs?.description || '' %></textarea> </div>
</div>
<!-- About Images -->
<div class="col-md-12">
<div class="card border shadow-sm mb-3">
<div class="card-header bg-white">
<h6 class="mb-0">
<i class="fas fa-images me-2"></i>About Images (Left side)
</h6>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-medium">Main Image</label>
<small class="text-muted d-block mb-1">Recommended size: 375x419px</small>
<div class="input-group mb-2">
<input type="text" class="form-control" id="whyChooseUsMainImage"
value="<%= data.whyChooseUs?.mainImage || '' %>" placeholder="/assets/img/home-1/about/about-1.jpg" />
<button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="whyChooseUsMainImage" data-image-type="home">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
<% if (data.whyChooseUs?.mainImage) { %>
<div class="mt-2">
<img src="<%= data.whyChooseUs.mainImage %>" class="img-thumbnail"
style="height: 200px; width: 100%; object-fit: cover;" alt="Main about image preview" />
</div>
<% } %>
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Secondary Image</label>
<small class="text-muted d-block mb-1">Recommended size: 376x394px</small>
<div class="input-group mb-2">
<input type="text" class="form-control" id="whyChooseUsSecondaryImage"
value="<%= data.whyChooseUs?.secondaryImage || '' %>"
placeholder="/assets/img/home-1/about/about-02.jpg" />
<button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="whyChooseUsSecondaryImage" data-image-type="home">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
<% if (data.whyChooseUs?.secondaryImage) { %>
<div class="mt-2">
<img src="<%= data.whyChooseUs.secondaryImage %>" class="img-thumbnail"
style="height: 200px; width: 100%; object-fit: cover;" alt="Secondary about image preview" />
</div>
<% } %>
</div> </div>
</div> </div>
</div> </div>
@@ -57,46 +103,29 @@
<% (data.whyChooseUs?.items || []).forEach(function(item, index) { %> <% (data.whyChooseUs?.items || []).forEach(function(item, index) { %>
<div class="card mb-3 bg-light border"> <div class="card mb-3 bg-light border">
<div class="card-body"> <div class="card-body">
<h6 class="card-title fw-bold mb-3">Item <%= index + 1 %></h6> <h6 class="card-title fw-bold mb-3">Item <%= index + 1 %>
</h6>
<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>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input <input type="text" class="form-control" id="whyChooseUsIcon_<%= index %>"
type="text" value="<%= item.icon || '' %>" />
class="form-control" <button type="button" class="btn btn-outline-primary btn-upload-image"
id="whyChooseUsIcon_<%= index %>" data-target-input="whyChooseUsIcon_<%= index %>" data-image-type="home">
value="<%= item.icon || '' %>"
/>
<button
type="button"
class="btn btn-outline-primary btn-upload-image"
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
</button> </button>
</div> </div>
</div> </div>
<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 <input type="text" class="form-control" id="whyChooseUsTitle_<%= index %>"
type="text" value="<%= item.title || '' %>" placeholder="e.g., Global Reach" />
class="form-control"
id="whyChooseUsTitle_<%= index %>"
value="<%= item.title || '' %>"
placeholder="e.g., Global Reach"
/>
</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 <input type="text" class="form-control" id="whyChooseUsItemDescription_<%= index %>"
type="text" value="<%= item.description || '' %>" placeholder="e.g., Expanding Opportunities Worldwide" />
class="form-control"
id="whyChooseUsItemDescription_<%= index %>"
value="<%= item.description || '' %>"
placeholder="e.g., Expanding Opportunities Worldwide"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -118,13 +147,8 @@
<% (data.whyChooseUs?.features || []).forEach(function(feature, index) { %> <% (data.whyChooseUs?.features || []).forEach(function(feature, index) { %>
<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 <input type="text" class="form-control" id="whyChooseUsFeature_<%= index %>" value="<%= feature %>"
type="text" placeholder="Enter feature" />
class="form-control"
id="whyChooseUsFeature_<%= index %>"
value="<%= feature %>"
placeholder="Enter feature"
/>
</div> </div>
<% }); %> <% }); %>
</div> </div>
@@ -143,23 +167,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">Label</label> <label class="form-label fw-medium">Label</label>
<input <input type="text" class="form-control" id="whyChooseUsCtaLabel"
type="text" value="<%= data.whyChooseUs?.ctaButton?.label || '' %>" placeholder="e.g., Get Started" />
class="form-control"
id="whyChooseUsCtaLabel"
value="<%= data.whyChooseUs?.ctaButton?.label || '' %>"
placeholder="e.g., Get Started"
/>
</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 <input type="text" class="form-control" id="whyChooseUsCtaHref"
type="text" value="<%= data.whyChooseUs?.ctaButton?.href || '' %>" placeholder="/about" />
class="form-control"
id="whyChooseUsCtaHref"
value="<%= data.whyChooseUs?.ctaButton?.href || '' %>"
placeholder="/about"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -167,3 +181,53 @@
</div> </div>
</div> </div>
</div> </div>
<script>
// Register scraper to collect Why Choose Us data before form submit
window.homeScrapers = window.homeScrapers || {};
window.homeScrapers.whyChooseUs = () => {
const getVal = (id) => (document.getElementById(id)?.value || "").trim();
// Collect items
const items = [];
document.querySelectorAll('input[id^="whyChooseUsTitle_"]').forEach((titleInput) => {
const id = titleInput.id || "";
const match = id.match(/whyChooseUsTitle_(\d+)/);
if (!match) return;
const idx = match[1];
const title = (titleInput.value || "").trim();
const icon = getVal(`whyChooseUsIcon_${idx}`);
const description = getVal(`whyChooseUsItemDescription_${idx}`);
// Bỏ qua item trống hoàn toàn
if (title || icon || description) {
items.push({ icon, title, description });
}
});
// Collect features
const features = [];
document.querySelectorAll('input[id^="whyChooseUsFeature_"]').forEach((featureInput) => {
const value = (featureInput.value || "").trim();
if (value) {
features.push(value);
}
});
return {
heading: getVal("whyChooseUsHeading"),
subheading: getVal("whyChooseUsSubheading"),
description: getVal("whyChooseUsDescription"),
highlightWord: getVal("whyChooseUsHighlightWord"),
mainImage: getVal("whyChooseUsMainImage"),
secondaryImage: getVal("whyChooseUsSecondaryImage"),
items,
features,
ctaButton: {
label: getVal("whyChooseUsCtaLabel"),
href: getVal("whyChooseUsCtaHref"),
},
};
};
</script>

View File

@@ -362,6 +362,19 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-12">
<label class="form-label fw-medium">Image Contact</label>
<div class="input-group mb-2">
<input type="text" id="contact_image_input" name="contact_image" class="form-control" placeholder="/uploads/visa/contact-image.jpg" required />
<button type="button" class="btn btn-outline-secondary" onclick="document.getElementById('fileContactImageInput').click()">
<i class="fas fa-upload"></i>
</button>
</div>
<input type="file" id="fileContactImageInput" style="display:none" accept="image/*"/>
<div style="margin-top: 10px;">
<img id="preview_contact_image" src="" alt="Contact Image Preview" style="max-width: 200px; max-height: 150px; object-fit: cover; display: none; border-radius: 4px; border: 1px solid #ddd;">
</div>
</div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label fw-medium">Section Title</label> <label class="form-label fw-medium">Section Title</label>
<input type="text" name="contact_sectionTitle" class="form-control" placeholder="e.g. Visa & Immigration" required/> <input type="text" name="contact_sectionTitle" class="form-control" placeholder="e.g. Visa & Immigration" required/>
@@ -452,18 +465,20 @@
// Image upload handler // Image upload handler
function setupImageUploadHandlers() { function setupImageUploadHandlers() {
const imageInputs = ["fileFlagInput", "fileDetailMainInput", "fileGalleryInput1", "fileGalleryInput2"]; const imageInputs = ["fileFlagInput", "fileDetailMainInput", "fileGalleryInput1", "fileGalleryInput2", "fileContactImageInput"];
const previewMap = { const previewMap = {
fileFlagInput: "preview_icon", fileFlagInput: "preview_icon",
fileDetailMainInput: "preview_main_detail", fileDetailMainInput: "preview_main_detail",
fileGalleryInput1: "preview_bannerImageGallery1", fileGalleryInput1: "preview_bannerImageGallery1",
fileGalleryInput2: "preview_bannerImageGallery2" fileGalleryInput2: "preview_bannerImageGallery2",
fileContactImageInput: "preview_contact_image"
}; };
const inputMap = { const inputMap = {
fileFlagInput: "icon_input", fileFlagInput: "icon_input",
fileDetailMainInput: "mainImage_detail", fileDetailMainInput: "mainImage_detail",
fileGalleryInput1: "bannerImageGallery1", fileGalleryInput1: "bannerImageGallery1",
fileGalleryInput2: "bannerImageGallery2" fileGalleryInput2: "bannerImageGallery2",
fileContactImageInput: "contact_image_input"
}; };
imageInputs.forEach((fileId) => { imageInputs.forEach((fileId) => {
@@ -758,6 +773,17 @@
const contactInfo = country.detailedView?.contactInfo; const contactInfo = country.detailedView?.contactInfo;
if (contactInfo) { if (contactInfo) {
document.querySelector('input[name="contact_image"]').value = contactInfo.img || "";
// Update contact image preview
if (contactInfo.img) {
const contactPreview = document.getElementById("preview_contact_image");
if (contactPreview) {
contactPreview.src = contactInfo.img;
contactPreview.style.display = "block";
}
}
document.querySelector('input[name="contact_image"]').value = contactInfo.img || "";
document.querySelector('input[name="contact_sectionTitle"]').value = contactInfo.sectionTitle || ""; document.querySelector('input[name="contact_sectionTitle"]').value = contactInfo.sectionTitle || "";
document.querySelector('input[name="contact_helpText"]').value = contactInfo.helpText || ""; document.querySelector('input[name="contact_helpText"]').value = contactInfo.helpText || "";
document.querySelector('input[name="contact_phone_label"]').value = contactInfo.phone?.label || ""; document.querySelector('input[name="contact_phone_label"]').value = contactInfo.phone?.label || "";
@@ -766,6 +792,7 @@
document.querySelector('input[name="contact_email"]').value = contactInfo.email?.value || ""; document.querySelector('input[name="contact_email"]').value = contactInfo.email?.value || "";
document.querySelector('input[name="contact_location_label"]').value = contactInfo.location?.label || ""; document.querySelector('input[name="contact_location_label"]').value = contactInfo.location?.label || "";
document.querySelector('input[name="contact_location"]').value = contactInfo.location?.address || ""; document.querySelector('input[name="contact_location"]').value = contactInfo.location?.address || "";
} }
} }
@@ -843,6 +870,7 @@
document.getElementById("preview_main_detail").style.display = "none"; document.getElementById("preview_main_detail").style.display = "none";
document.getElementById("preview_bannerImageGallery1").style.display = "none"; document.getElementById("preview_bannerImageGallery1").style.display = "none";
document.getElementById("preview_bannerImageGallery2").style.display = "none"; document.getElementById("preview_bannerImageGallery2").style.display = "none";
document.getElementById("preview_contact_image").style.display = "none";
// Clear related countries previews // Clear related countries previews
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
@@ -858,12 +886,13 @@
relatedNameInputs.forEach(input => input.value = ""); relatedNameInputs.forEach(input => input.value = "");
relatedIconInputs.forEach(input => input.value = ""); relatedIconInputs.forEach(input => input.value = "");
const contactFields = ["contact_sectionTitle", "contact_helpText", "contact_phone_label", "contact_phone", "contact_email_label", "contact_email", "contact_location_label", "contact_location"]; const contactFields = ["contact_image", "contact_sectionTitle", "contact_helpText", "contact_phone_label", "contact_phone", "contact_email_label", "contact_email", "contact_location_label", "contact_location"];
contactFields.forEach(fieldName => { contactFields.forEach(fieldName => {
const field = document.querySelector(`input[name="${fieldName}"]`); const field = document.querySelector(`input[name="${fieldName}"]`);
if (field) field.value = ""; if (field) field.value = "";
}); });
showFormView(); showFormView();
}); });
@@ -954,6 +983,7 @@
icon: document.getElementById(`related_url_${index}`)?.value || "" icon: document.getElementById(`related_url_${index}`)?.value || ""
})), })),
contactInfo: { contactInfo: {
img: document.querySelector('input[name="contact_image"]').value,
sectionTitle: document.querySelector('input[name="contact_sectionTitle"]').value, sectionTitle: document.querySelector('input[name="contact_sectionTitle"]').value,
helpText: document.querySelector('input[name="contact_helpText"]').value, helpText: document.querySelector('input[name="contact_helpText"]').value,
phone: { phone: {
@@ -971,6 +1001,7 @@
} }
} }
}; };
console.log('Payload:', payload);
try { try {
btnSave.disabled = true; btnSave.disabled = true;

View File

@@ -60,9 +60,6 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/admin/footer">Footer</a> <a class="nav-link" href="/admin/footer">Footer</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="/admin/about">About</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/admin/about-us">About Us</a> <a class="nav-link" href="/admin/about-us">About Us</a>
</li> </li>