Merge branch 'main' of https://gits.techvanguard.vn/UKSOURCE/cms.hailearning.edu.vn into fix/bao-04022026-service-management

This commit is contained in:
nguyenvanbao
2026-02-04 11:50:51 +07:00
18 changed files with 5623 additions and 93 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# dependencies
node_modules/
/public
# environment
.env
@@ -21,3 +22,4 @@ pids
#cursor
.cursor
package-lock.json

View File

@@ -1,4 +1,5 @@
const BlogCategory = require('../models/blogCategory');
const slugify = require('slugify');
// -------------------- Admin Controllers --------------------
@@ -49,12 +50,11 @@ exports.store = async (req, res) => {
} = req.body;
// Generate slug
const slug = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim('-');
const slug = slugify(name, {
lower: true,
strict: true,
locale: 'vi'
});
// Check if slug exists
const existingCategory = await BlogCategory.findOne({ slug });
@@ -130,12 +130,11 @@ exports.update = async (req, res) => {
category.isActive = isActive === 'on';
// Generate new slug if name changed
const newSlug = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim('-');
const newSlug = slugify(name, {
lower: true,
strict: true,
locale: 'vi'
});
if (newSlug !== category.slug) {
const existingCategory = await BlogCategory.findOne({
@@ -166,6 +165,14 @@ exports.destroy = async (req, res) => {
const category = await BlogCategory.findById(req.params.id);
if (!category) {
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(404).json({
success: false,
message: 'Category not found'
});
}
req.flash('error_msg', 'Category not found');
return res.redirect('/admin/blog/categories');
}
@@ -175,16 +182,44 @@ exports.destroy = async (req, res) => {
const postCount = await Blog.countDocuments({ category: { $in: [category.name] } });
if (postCount > 0) {
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(400).json({
success: false,
message: 'Cannot delete category that has blog posts'
});
}
req.flash('error_msg', 'Cannot delete category that has blog posts');
return res.redirect('/admin/blog/categories');
}
await BlogCategory.findByIdAndDelete(req.params.id);
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.json({
success: true,
message: 'Category deleted successfully'
});
}
req.flash('success_msg', 'Category deleted successfully');
res.redirect('/admin/blog/categories');
} catch (err) {
console.error('Category delete error:', err);
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(500).json({
success: false,
message: 'Error deleting category',
error: err.message || 'Error deleting category'
});
}
req.flash('error_msg', 'Error deleting category');
res.redirect('/admin/blog/categories');
}
@@ -202,10 +237,18 @@ exports.api = async (req, res) => {
await category.updatePostCount();
}
res.json(categories);
res.json({
success: true,
message: 'Categories fetched successfully',
data: categories
});
} catch (err) {
console.error('Categories API error:', err);
res.status(500).json({ error: 'Error loading categories' });
res.status(500).json({
success: false,
message: 'Error loading categories',
error: err.message || 'Error loading categories'
});
}
};
@@ -218,13 +261,81 @@ exports.apiShow = async (req, res) => {
}).lean();
if (!category) {
return res.status(404).json({ error: 'Category not found' });
return res.status(404).json({
success: false,
message: 'Category not found'
});
}
res.json(category);
res.json({
success: true,
message: 'Category fetched successfully',
data: category
});
} catch (err) {
console.error('Category show API error:', err);
res.status(500).json({ error: 'Error loading category' });
res.status(500).json({
success: false,
message: 'Error loading category',
error: err.message || 'Error loading category'
});
}
};
// Quick create category (for inline creation in blog form)
exports.quickCreate = async (req, res) => {
try {
const { name, description } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({
success: false,
message: 'Category name is required'
});
}
const categoryName = name.trim();
// Generate slug
const slug = slugify(categoryName, {
lower: true,
strict: true,
locale: 'vi'
});
// Check if category already exists
let category = await BlogCategory.findOne({ slug });
if (category) {
return res.json({
success: true,
message: 'Category already exists',
data: category.toObject()
});
}
// Create new category
category = new BlogCategory({
name: categoryName,
slug,
description: description || '',
isActive: true
});
await category.save();
res.json({
success: true,
message: 'Category created successfully',
data: category.toObject()
});
} catch (err) {
console.error('Quick create category error:', err);
res.status(500).json({
success: false,
message: 'Error creating category',
error: err.message || 'Error creating category'
});
}
};

View File

@@ -4,17 +4,17 @@ const BlogTag = require('../models/blogTag');
const BlogComment = require('../models/blogComment');
const RecentPost = require('../models/recentPost');
const { addBaseUrlToImages } = require('../utils/imageHelper');
const slugify = require('slugify');
// -------------------- Helper Functions --------------------
// Generate slug from title
const generateSlug = (title) => {
return title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim('-');
return slugify(title, {
lower: true,
strict: true,
locale: 'vi'
});
};
// Update category post counts
@@ -70,11 +70,14 @@ exports.index = async (req, res) => {
// Get categories for filter
const categories = await BlogCategory.getActive();
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
res.render('admin/blog/index', {
layout: 'layouts/main',
title: 'Blog Management',
blogs,
categories,
frontendUrl,
pagination: {
current: page,
total: totalPages,
@@ -98,13 +101,16 @@ exports.create = async (req, res) => {
const categories = await BlogCategory.getActive();
const tags = await BlogTag.getActive();
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
res.render('admin/blog/create', {
layout: 'layouts/main',
title: 'Create New Blog Post',
categories,
tags,
currentPath: req.path,
user: req.session.user
user: req.session.user,
frontendUrl
});
} catch (err) {
console.error('Blog create form error:', err);
@@ -125,7 +131,9 @@ exports.store = async (req, res) => {
status,
isFeatured,
author,
galleryImages
galleryImages,
quote,
contentAfterQuote
} = req.body;
// Generate slug
@@ -149,12 +157,14 @@ exports.store = async (req, res) => {
status: status || 'published',
isFeatured: isFeatured === 'on',
author: author || 'Admin',
galleryImages: galleryImages ? (Array.isArray(galleryImages) ? galleryImages : [galleryImages]) : []
galleryImages: galleryImages ? (Array.isArray(galleryImages) ? galleryImages : [galleryImages]) : [],
quote: quote || '',
contentAfterQuote: contentAfterQuote || ''
};
// Handle featured image
if (req.file) {
blogData.featuredImage = `/uploads/blog/${req.file.filename}`;
// Handle featured image - using featuredImageUrl from form (uploaded via AJAX)
if (req.body.featuredImageUrl) {
blogData.featuredImage = req.body.featuredImageUrl;
}
// Create blog
@@ -188,6 +198,8 @@ exports.edit = async (req, res) => {
const categories = await BlogCategory.getActive();
const tags = await BlogTag.getActive();
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
res.render('admin/blog/edit', {
layout: 'layouts/main',
title: 'Edit Blog Post',
@@ -195,7 +207,8 @@ exports.edit = async (req, res) => {
categories,
tags,
currentPath: req.path,
user: req.session.user
user: req.session.user,
frontendUrl
});
} catch (err) {
console.error('Blog edit form error:', err);
@@ -223,7 +236,9 @@ exports.update = async (req, res) => {
status,
isFeatured,
author,
galleryImages
galleryImages,
quote,
contentAfterQuote
} = req.body;
// Update blog data
@@ -236,10 +251,12 @@ exports.update = async (req, res) => {
blog.isFeatured = isFeatured === 'on';
blog.author = author || 'Admin';
blog.galleryImages = galleryImages ? (Array.isArray(galleryImages) ? galleryImages : [galleryImages]) : [];
blog.quote = quote || '';
blog.contentAfterQuote = contentAfterQuote || '';
// Handle featured image
if (req.file) {
blog.featuredImage = `/uploads/blog/${req.file.filename}`;
// Handle featured image - using featuredImageUrl from form (uploaded via AJAX)
if (req.body.featuredImageUrl) {
blog.featuredImage = req.body.featuredImageUrl;
}
// Generate new slug if title changed
@@ -373,11 +390,30 @@ exports.apiShow = async (req, res) => {
});
}
// Get comments for this post
const comments = await BlogComment.getApprovedByPost(blog._id);
// Get comments for this post (parent comments only)
const parentComments = await BlogComment.getApprovedByPost(blog._id);
// Get replies for each parent comment
const commentsWithReplies = await Promise.all(
parentComments.map(async (parentComment) => {
const replies = await BlogComment.getReplies(parentComment._id);
return {
...parentComment.toObject(),
replies: replies.map(reply => reply.toObject())
};
})
);
// Flatten comments array (parent + replies)
const allComments = commentsWithReplies.flatMap(comment => [
comment,
...comment.replies
]);
// Add comments to blog
blog.comments = comments;
blog.comments = allComments;
// Keep commentsCount in sync for frontend
blog.commentsCount = allComments.length;
// Add base URL to images
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
@@ -398,6 +434,72 @@ exports.apiShow = async (req, res) => {
}
};
// Create a comment (no moderation for now: default approved)
exports.apiCreateComment = async (req, res) => {
try {
const { authorName, content, parentId } = req.body || {};
if (!authorName || !String(authorName).trim()) {
return res.status(400).json({
success: false,
message: "authorName is required",
});
}
if (!content || !String(content).trim()) {
return res.status(400).json({
success: false,
message: "content is required",
});
}
const blog = await Blog.findOne({ slug: req.params.slug, status: "published" }).lean();
if (!blog) {
return res.status(404).json({
success: false,
message: "Blog post not found",
});
}
// If replying, ensure parent exists and belongs to same post
let parentObjectId = null;
if (parentId) {
const parent = await BlogComment.findOne({ _id: parentId, postId: blog._id }).lean();
if (!parent) {
return res.status(400).json({
success: false,
message: "Invalid parentId",
});
}
parentObjectId = parentId;
}
const newComment = await BlogComment.create({
postId: blog._id,
authorName: String(authorName).trim(),
content: String(content).trim(),
parentId: parentObjectId,
status: "approved",
});
// Keep counter roughly correct (also counts replies)
await Blog.updateOne({ _id: blog._id }, { $inc: { commentsCount: 1 } });
return res.json({
success: true,
message: "Comment created successfully",
data: newComment.toJSON(),
});
} catch (err) {
console.error("Create comment API error:", err);
return res.status(500).json({
success: false,
message: "Error creating comment",
error: err.message || "Error creating comment",
});
}
};
// Get featured blogs
exports.apiFeatured = async (req, res) => {
try {
@@ -408,7 +510,7 @@ exports.apiFeatured = async (req, res) => {
.lean();
// Add base URL to images
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('port')}`;
const processedBlogs = blogs.map(blog => addBaseUrlToImages(blog, baseUrl));
res.json({

View File

@@ -1,4 +1,5 @@
const BlogTag = require('../models/blogTag');
const slugify = require('slugify');
// -------------------- Admin Controllers --------------------
@@ -48,12 +49,11 @@ exports.store = async (req, res) => {
} = req.body;
// Generate slug
const slug = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim('-');
const slug = slugify(name, {
lower: true,
strict: true,
locale: 'vi'
});
// Check if slug exists
const existingTag = await BlogTag.findOne({ slug });
@@ -126,12 +126,11 @@ exports.update = async (req, res) => {
tag.isActive = isActive === 'on';
// Generate new slug if name changed
const newSlug = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim('-');
const newSlug = slugify(name, {
lower: true,
strict: true,
locale: 'vi'
});
if (newSlug !== tag.slug) {
const existingTag = await BlogTag.findOne({
@@ -162,6 +161,14 @@ exports.destroy = async (req, res) => {
const tag = await BlogTag.findById(req.params.id);
if (!tag) {
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(404).json({
success: false,
message: 'Tag not found'
});
}
req.flash('error_msg', 'Tag not found');
return res.redirect('/admin/blog/tags');
}
@@ -171,16 +178,44 @@ exports.destroy = async (req, res) => {
const postCount = await Blog.countDocuments({ tags: { $in: [tag.name] } });
if (postCount > 0) {
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(400).json({
success: false,
message: 'Cannot delete tag that is used in blog posts'
});
}
req.flash('error_msg', 'Cannot delete tag that is used in blog posts');
return res.redirect('/admin/blog/tags');
}
await BlogTag.findByIdAndDelete(req.params.id);
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.json({
success: true,
message: 'Tag deleted successfully'
});
}
req.flash('success_msg', 'Tag deleted successfully');
res.redirect('/admin/blog/tags');
} catch (err) {
console.error('Tag delete error:', err);
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(500).json({
success: false,
message: 'Error deleting tag',
error: err.message || 'Error deleting tag'
});
}
req.flash('error_msg', 'Error deleting tag');
res.redirect('/admin/blog/tags');
}
@@ -198,10 +233,18 @@ exports.api = async (req, res) => {
await tag.updatePostCount();
}
res.json(tags);
res.json({
success: true,
message: 'Tags fetched successfully',
data: tags
});
} catch (err) {
console.error('Tags API error:', err);
res.status(500).json({ error: 'Error loading tags' });
res.status(500).json({
success: false,
message: 'Error loading tags',
error: err.message || 'Error loading tags'
});
}
};
@@ -210,10 +253,19 @@ exports.apiPopular = async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const tags = await BlogTag.getPopular(limit);
res.json(tags);
res.json({
success: true,
message: 'Popular tags fetched successfully',
data: tags
});
} catch (err) {
console.error('Popular tags API error:', err);
res.status(500).json({ error: 'Error loading popular tags' });
res.status(500).json({
success: false,
message: 'Error loading popular tags',
error: err.message || 'Error loading popular tags'
});
}
};
@@ -226,13 +278,80 @@ exports.apiShow = async (req, res) => {
}).lean();
if (!tag) {
return res.status(404).json({ error: 'Tag not found' });
return res.status(404).json({
success: false,
message: 'Tag not found'
});
}
res.json(tag);
res.json({
success: true,
message: 'Tag fetched successfully',
data: tag
});
} catch (err) {
console.error('Tag show API error:', err);
res.status(500).json({ error: 'Error loading tag' });
res.status(500).json({
success: false,
message: 'Error loading tag',
error: err.message || 'Error loading tag'
});
}
};
// Quick create tag (for inline creation in blog form)
exports.quickCreate = async (req, res) => {
try {
const { name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({
success: false,
message: 'Tag name is required'
});
}
const tagName = name.trim();
// Generate slug
const slug = slugify(tagName, {
lower: true,
strict: true,
locale: 'vi'
});
// Check if tag already exists
let tag = await BlogTag.findOne({ slug });
if (tag) {
return res.json({
success: true,
message: 'Tag already exists',
data: tag.toObject()
});
}
// Create new tag
tag = new BlogTag({
name: tagName,
slug,
isActive: true
});
await tag.save();
res.json({
success: true,
message: 'Tag created successfully',
data: tag.toObject()
});
} catch (err) {
console.error('Quick create tag error:', err);
res.status(500).json({
success: false,
message: 'Error creating tag',
error: err.message || 'Error creating tag'
});
}
};

View File

@@ -0,0 +1,557 @@
// controllers/visaController.js
const addBaseUrlToImages = (data, baseUrl) => {
if (!data) return data;
// Nếu là mảng, duyệt từng phần tử
if (Array.isArray(data)) {
return data.map((item) => addBaseUrlToImages(item, baseUrl));
}
// Nếu là object, duyệt từng key
if (typeof data === "object") {
const newObj = {};
for (const [key, value] of Object.entries(data)) {
// Kiểm tra nếu key là các trường chứa ảnh và value là string
const imageKeys = ["icon", "mainImage", "bannerImage", "image"];
if (
imageKeys.includes(key) &&
typeof value === "string" &&
!value.startsWith("http")
) {
newObj[key] = `${baseUrl}/${value}`
.replace(/\/+/g, "/")
.replace(":/", "://");
}
// Xử lý riêng cho mảng gallery (mảng các chuỗi)
else if (key === "gallery" && Array.isArray(value)) {
newObj[key] = value.map((img) =>
img.startsWith("http")
? img
: `${baseUrl}/${img}`.replace(/\/+/g, "/").replace(":/", "://"),
);
}
// Nếu là object hoặc mảng con khác, đệ quy tiếp
else if (typeof value === "object" && value !== null) {
newObj[key] = addBaseUrlToImages(value, baseUrl);
} else {
newObj[key] = value;
}
}
return newObj;
}
return data;
};
const Visa = require("../models/visa");
// -------------------- Helper Functions --------------------
// Get visa data from MongoDB
const getVisaData = async () => {
const visa = await Visa.findOne().sort({ updatedAt: -1 }).lean();
return visa || {};
};
// Get default visa data structure (updated to match new JSON)
const getDefaultVisaData = () => ({
hero: {
title: "Visa Service",
summaryList: [],
},
});
// Helper function: Generate next country ID
const getNextCountryId = (countries) => {
if (!Array.isArray(countries) || countries.length === 0) return 1;
return Math.max(...countries.map((c) => c.id || 0)) + 1;
};
// -------------------- Admin Exports --------------------
// Display visa management page
exports.index = async (req, res) => {
try {
// Fetch Visa data
let data = await getVisaData();
// If no data exists, use default
if (!data || Object.keys(data).length === 0) {
data = getDefaultVisaData();
} else {
// Merge with defaults to ensure all fields exist
const defaultData = getDefaultVisaData();
// Ensure hero section exists with defaults
data.hero = data.hero || defaultData.hero;
data.hero.title = data.hero.title || "Visa Service";
data.hero.summaryList = data.hero.summaryList || [];
}
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
res.render("admin/visa/index", {
layout: "layouts/main",
title: "Visa Management",
data,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
// return res.json(data);
} catch (err) {
console.error("Visa index error:", err);
req.flash("error_msg", "Error loading visa data");
res.redirect("/admin/dashboard");
}
};
// Get single country for edit
exports.getCountry = async (req, res) => {
try {
const { slug } = req.params;
const visaData = await getVisaData();
if (!visaData.hero || !visaData.hero.summaryList) {
return res.status(404).json({ error: "Visa data not found" });
}
const country = visaData.hero.summaryList.find((c) => c.slug === slug);
if (!country) {
return res.status(404).json({ error: `Country "${slug}" not found` });
}
res.json(country);
} catch (err) {
console.error("Get country error:", err);
res.status(500).json({ error: "Error loading country" });
}
};
// Update visa data (hero title only)
exports.update = async (req, res) => {
try {
// Get current data
const currentData = await getVisaData();
// Create updated data object
const updatedData = {
...(currentData.toObject ? currentData.toObject() : currentData),
};
// Ensure hero structure exists
updatedData.hero = updatedData.hero || {
title: "Visa Service",
summaryList: [],
};
// Update hero title
if (req.body.heroTitle) {
updatedData.hero.title = req.body.heroTitle;
}
// Check if there are changes
const hasChanges =
JSON.stringify(updatedData) !== JSON.stringify(currentData);
if (!hasChanges) {
req.flash("info_msg", "No changes were made");
return req.session.save(() => res.redirect("/admin/visa"));
}
// Update or create document
try {
if (currentData._id) {
await Visa.findByIdAndUpdate(currentData._id, updatedData, {
new: true,
});
} else {
await Visa.create(updatedData);
}
req.flash("success_msg", "Visa data updated successfully");
return req.session.save(() => res.redirect("/admin/visa"));
} catch (dbError) {
console.error("Database error:", dbError);
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/visa"));
}
} catch (err) {
console.error("Update error:", err);
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/visa"));
}
};
// Add new country
exports.addCountry = async (req, res) => {
try {
let visaData = await getVisaData();
// Initialize hero structure if not exist
if (!visaData.hero || !visaData.hero.summaryList) {
visaData = getDefaultVisaData();
}
// Validate required fields
if (!req.body.name || !req.body.slug) {
return res.status(400).json({ error: "Name and slug are required" });
}
// Check if slug already exists
const slugExists = visaData.hero.summaryList.some(
(c) => c.slug === req.body.slug,
);
if (slugExists) {
return res
.status(400)
.json({ error: `Slug "${req.body.slug}" already exists` });
}
// Parse services array
let services = [];
if (req.body.services) {
if (typeof req.body.services === "string") {
try {
services = JSON.parse(req.body.services);
} catch (e) {
services = [req.body.services];
}
} else if (Array.isArray(req.body.services)) {
services = req.body.services;
}
}
// Parse detailedView if provided (optional)
let detailedView = null;
if (req.body.detailedView) {
try {
detailedView =
typeof req.body.detailedView === "string"
? JSON.parse(req.body.detailedView)
: req.body.detailedView;
} catch (e) {
console.warn("Could not parse detailedView, creating without it");
}
}
// Create new country object
const newCountry = {
id: req.body.id || getNextCountryId(visaData.hero.summaryList),
name: req.body.name,
slug: req.body.slug,
icon: req.body.icon || "",
services: services,
...(detailedView && { detailedView }),
};
// Add new country to summaryList
visaData.hero.summaryList.push(newCountry);
// Update database
const updatedData = {
...(visaData.toObject ? visaData.toObject() : visaData),
};
let savedData;
if (visaData._id) {
savedData = await Visa.findByIdAndUpdate(visaData._id, updatedData, {
new: true,
});
} else {
savedData = await Visa.create(updatedData);
}
console.log(`✅ Country "${newCountry.name}" added successfully`);
res.json({
success: true,
message: `Country "${newCountry.name}" added successfully`,
country: newCountry,
});
} catch (err) {
console.error("Add country error:", err);
res.status(500).json({ error: err.message });
}
};
// Update single country
exports.updateCountry = async (req, res) => {
try {
const { slug } = req.params;
let visaData = await getVisaData();
if (!visaData.hero || !visaData.hero.summaryList) {
return res.status(400).json({ error: "Invalid visa data structure" });
}
const countryIndex = visaData.hero.summaryList.findIndex(
(c) => c.slug === slug,
);
if (countryIndex === -1) {
return res.status(404).json({ error: `Country "${slug}" not found` });
}
const currentCountry = visaData.hero.summaryList[countryIndex];
// Parse services array
let services = currentCountry.services || [];
if (req.body.services) {
if (typeof req.body.services === "string") {
try {
services = JSON.parse(req.body.services);
} catch (e) {
services = [req.body.services];
}
} else if (Array.isArray(req.body.services)) {
services = req.body.services;
}
}
// Parse detailedView if provided (optional)
let detailedView = currentCountry.detailedView;
if (req.body.detailedView) {
try {
detailedView =
typeof req.body.detailedView === "string"
? JSON.parse(req.body.detailedView)
: req.body.detailedView;
} catch (e) {
console.warn("Could not parse detailedView");
}
}
// Update country data
const updatedCountry = {
...currentCountry,
name: req.body.name || currentCountry.name,
slug: req.body.slug || currentCountry.slug, // Allow slug update
icon: req.body.icon || currentCountry.icon,
services: services,
...(detailedView && { detailedView }),
};
visaData.hero.summaryList[countryIndex] = updatedCountry;
// Update database
const updatedData = {
...(visaData.toObject ? visaData.toObject() : visaData),
};
if (visaData._id) {
await Visa.findByIdAndUpdate(visaData._id, updatedData, { new: true });
} else {
await Visa.create(updatedData);
}
console.log(`✅ Country "${updatedCountry.name}" updated successfully`);
res.json({
success: true,
message: `Country "${updatedCountry.name}" updated successfully`,
country: updatedCountry,
});
} catch (err) {
console.error("Update country error:", err);
res.status(500).json({ error: err.message });
}
};
// Delete country
exports.deleteCountry = async (req, res) => {
try {
const { slug } = req.params;
let visaData = await getVisaData();
if (!visaData.hero || !visaData.hero.summaryList) {
return res.status(400).json({ error: "Invalid visa data structure" });
}
const countryIndex = visaData.hero.summaryList.findIndex(
(c) => c.slug === slug,
);
if (countryIndex === -1) {
return res.status(404).json({ error: `Country "${slug}" not found` });
}
const deletedCountry = visaData.hero.summaryList[countryIndex];
visaData.hero.summaryList.splice(countryIndex, 1);
// Update database
const updatedData = {
...(visaData.toObject ? visaData.toObject() : visaData),
};
if (visaData._id) {
await Visa.findByIdAndUpdate(visaData._id, updatedData, { new: true });
} else {
await Visa.create(updatedData);
}
console.log(`✅ Country "${deletedCountry.name}" deleted successfully`);
res.json({
success: true,
message: `Country "${deletedCountry.name}" deleted successfully`,
});
} catch (err) {
console.error("Delete country error:", err);
res.status(500).json({ error: err.message });
}
};
// -------------------- Public API Exports --------------------
// API to get all visa data for frontend
exports.api = async (req, res) => {
try {
const visaData = await getVisaData();
if (!visaData) {
return res.status(404).json({
success: false,
error: "Visa data not found",
data: null,
});
}
const heroData = visaData?.hero;
// 2. Lấy riêng phần hero (Dùng biến mới, không gán đè vào const)
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(heroData, baseUrl);
return res.json({
success: true,
hero: processedData,
});
} catch (err) {
console.error("Visa API error:", err);
res.status(500).json({
success: false,
error: "Error loading visa data",
});
}
};
// API to get all countries (summaryList only)
exports.apiCountries = async (req, res) => {
try {
const visaData = await getVisaData();
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
return res.status(404).json({
success: false,
error: "Countries data not found",
data: null,
});
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
// 1. Lọc bỏ 'viewDetail' khỏi từng quốc gia trong danh sách
const filteredCountries = visaData.hero.summaryList.map((item) => {
// Dùng destructuring để tách viewDetail ra và gom phần còn lại vào countryInfo
const { detailedView, ...countryInfo } = item;
return countryInfo;
});
// 2. Gắn baseUrl vào ảnh cho danh sách đã lọc
const processedData = addBaseUrlToImages(filteredCountries, baseUrl);
return res.json({
success: true,
data: processedData, // Lúc này data chỉ chứa thông tin quốc gia, không có viewDetail
});
} catch (err) {
console.error("Countries API error:", err);
res.status(500).json({
success: false,
error: "Error loading countries data",
});
}
};
// API to get single country by slug
exports.apiCountry = async (req, res) => {
try {
const { slug } = req.params;
const visaData = await getVisaData();
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
return res.status(404).json({
success: false,
error: "Visa data not found",
data: null,
});
}
// 1. Tìm quốc gia khớp với slug
const country = visaData.hero.summaryList.find((c) => c.slug === slug);
// 2. Kiểm tra nếu không thấy quốc gia hoặc quốc gia đó không có viewDetail
if (!country || !country.viewDetail) {
return res.status(404).json({
success: false,
error: `Detailed information for country "${slug}" not found`,
data: null,
});
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
// 3. Chỉ lấy phần chi tiết (detailed view)
// Lưu ý: Chúng ta copy ra object mới để tránh tham chiếu dữ liệu gốc
const detailedData = JSON.parse(JSON.stringify(country.viewDetail));
// 4. Gắn baseUrl vào các ảnh nằm trong phần chi tiết này
const processedData = addBaseUrlToImages(detailedData, baseUrl);
return res.json({
success: true,
data: processedData, // Trả về nội dung của detailedView
});
} catch (err) {
console.error("Visa country API error:", err);
res.status(500).json({
success: false,
error: "Error loading country detailed data",
});
}
};
// API to get hero data (title + summaryList)
exports.apiHero = async (req, res) => {
try {
const visaData = await getVisaData();
// 1. Kiểm tra dữ liệu gốc
if (!visaData || !visaData.hero) {
return res.status(404).json({
success: false,
error: "Hero data not found",
data: null,
});
}
const { summaryList, ...heroData } = JSON.parse(
JSON.stringify(visaData.hero),
);
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(heroData, baseUrl);
return res.json({
success: true,
data: processedData,
});
} catch (err) {
console.error("Visa hero API error:", err);
res.status(500).json({
success: false,
error: "Error loading hero data",
});
}
};

View File

@@ -50,43 +50,53 @@
"status": "published",
"publishedAt": "11 March 2025",
"isFeatured": true,
"featuredImage": "/uploads/blog/work-visa-canada-main.jpg",
"featuredImage": "/assets/img/inner-page/news-details/details-1.jpg",
"galleryImages": [
"/uploads/blog/work-visa-canada-1.jpg",
"/uploads/blog/work-visa-canada-2.jpg"
"/assets/img/inner-page/news-details/details-2.jpg",
"/assets/img/inner-page/news-details/details-3.jpg"
],
"quote": "This blog really helped me understand the difference between student and work visas. The explanations were clear and practical.",
"contentAfterQuote": "<p class=\"mb-3\">It provides access to world-class universities, cultural exposure, and global networking opportunities. With a student visa, you may also get part-time work rights, which can help support your expenses and give you valuable international work experience. However, the primary focus remains on academics and personal growth. On the other hand, a work visa is perfect for those who want to establish themselves in a career overseas.</p><p>It provides immediate access to job markets, stable income, and often a pathway to permanent residency. Work visas are suitable for skilled professionals who are ready to contribute to the global workforce and achieve long-term career goals. Ultimately, the choice comes down to your personal aspirations. If education and exploration are your priorities, a student visa is ideal. If career advancement and stability are your goals, a work visa is the right fit.</p>",
"commentsCount": 3
},
{
"title": "Top 5 Scholarship Programs For International Students",
"slug": "top-5-scholarship-programs-international-students",
"excerpt": "Danh sách 5 chương trình học bổng nổi bật dành cho sinh viên quốc tế với mức hỗ trợ hấp dẫn.",
"content": "<p>Nếu bạn đang tìm kiếm học bổng để giảm chi phí du học, đây là 5 chương trình bạn không nên bỏ qua. Mỗi chương trình đều có tiêu chí xét tuyển, mức hỗ trợ và thời hạn đăng ký khác nhau.</p>",
"content": "<p>Nếu bạn đang tìm kiếm học bổng để giảm chi phí du học, đây là 5 chương trình bạn không nên bỏ qua. Mỗi chương trình đều có tiêu chí xét tuyển, mức hỗ trợ và thời hạn đăng ký khác nhau.</p><p class=\"mt-4 mb-3\">Học bổng là một trong những cách tốt nhất để giảm gánh nặng tài chính khi du học. Các chương trình học bổng không chỉ hỗ trợ về mặt tài chính mà còn mở ra nhiều cơ hội phát triển nghề nghiệp và mở rộng mạng lưới quan hệ quốc tế.</p>",
"category": ["Study Abroad"],
"tags": ["StudentVisa", "Scholarship"],
"author": "Admin",
"status": "published",
"publishedAt": "20 March 2025",
"isFeatured": false,
"featuredImage": "/uploads/blog/scholarship-programs-main.jpg",
"galleryImages": [],
"commentsCount": 0
"featuredImage": "/assets/img/inner-page/news-details/details-1.jpg",
"galleryImages": [
"/assets/img/inner-page/news-details/details-2.jpg",
"/assets/img/inner-page/news-details/details-3.jpg"
],
"quote": "These scholarship programs opened doors I never thought possible. The application process was straightforward, and the support I received was incredible.",
"contentAfterQuote": "<p class=\"mb-3\">Applying for scholarships requires careful planning and preparation. Start by researching each program's requirements, deadlines, and eligibility criteria. Make sure to prepare all necessary documents well in advance, including transcripts, recommendation letters, and personal statements. Each scholarship has its own unique focus, so tailor your application to highlight how you align with their values and goals.</p><p>Remember that scholarship applications are competitive, so it's important to stand out. Showcase your academic achievements, extracurricular activities, and community involvement. Be authentic in your personal statement and demonstrate how the scholarship will help you achieve your educational and career aspirations. With dedication and proper preparation, you can increase your chances of securing financial support for your studies abroad.</p>",
"commentsCount": 2
},
{
"title": "10 Travel Safety Tips You Should Know Before Flying",
"slug": "10-travel-safety-tips-before-flying",
"excerpt": "Những lưu ý quan trọng để đảm bảo an toàn cho chuyến bay và hành trình của bạn.",
"content": "<p>An toàn luôn là ưu tiên hàng đầu khi đi du lịch. Dưới đây là 10 tips giúp bạn yên tâm hơn trên mọi chuyến đi, từ việc chuẩn bị giấy tờ, bảo hiểm đến cách bảo vệ tài sản cá nhân.</p>",
"content": "<p>An toàn luôn là ưu tiên hàng đầu khi đi du lịch. Dưới đây là 10 tips giúp bạn yên tâm hơn trên mọi chuyến đi, từ việc chuẩn bị giấy tờ, bảo hiểm đến cách bảo vệ tài sản cá nhân.</p><p class=\"mt-4 mb-3\">Du lịch là một trải nghiệm tuyệt vời, nhưng điều quan trọng là phải chuẩn bị kỹ lưỡng để đảm bảo an toàn. Những tips này được đúc kết từ kinh nghiệm thực tế của nhiều du khách và sẽ giúp bạn tránh được những rủi ro không đáng có.</p>",
"category": ["Travel Tips"],
"tags": ["TravelSafety"],
"author": "Admin",
"status": "published",
"publishedAt": "05 April 2025",
"isFeatured": false,
"featuredImage": "/uploads/blog/travel-safety-main.jpg",
"featuredImage": "/assets/img/inner-page/news-details/details-1.jpg",
"galleryImages": [
"/uploads/blog/travel-safety-1.jpg"
"/assets/img/inner-page/news-details/details-2.jpg",
"/assets/img/inner-page/news-details/details-3.jpg"
],
"quote": "These safety tips saved me from potential problems during my trip. I especially appreciated the advice about travel insurance and document preparation.",
"contentAfterQuote": "<p class=\"mb-3\">Before you travel, make sure to research your destination thoroughly. Understand local customs, laws, and potential safety concerns. Keep copies of important documents like your passport, visa, and travel insurance in multiple places - both physical and digital. Inform family or friends about your itinerary and check in regularly during your trip.</p><p>When packing, prioritize essential items and keep valuables secure. Use luggage locks and consider travel insurance for expensive items. Stay aware of your surroundings, especially in crowded areas, and trust your instincts if something feels off. By following these safety tips, you can focus on enjoying your journey while staying protected throughout your travels.</p>",
"commentsCount": 1
}
],
@@ -94,19 +104,19 @@
{
"title": "Ultimate Guide To Getting A Work Visa In Canada",
"slug": "ultimate-guide-work-visa-canada",
"thumbnail": "/uploads/blog/work-visa-canada-main.jpg",
"thumbnail": "/assets/img/inner-page/news-details/post-1.jpg",
"publishedAt": "11 March 2025"
},
{
"title": "Top 5 Scholarship Programs For International Students",
"slug": "top-5-scholarship-programs-international-students",
"thumbnail": "/uploads/blog/scholarship-programs-main.jpg",
"thumbnail": "/assets/img/inner-page/news-details/post-2.jpg",
"publishedAt": "20 March 2025"
},
{
"title": "10 Travel Safety Tips You Should Know Before Flying",
"slug": "10-travel-safety-tips-before-flying",
"thumbnail": "/uploads/blog/travel-safety-main.jpg",
"thumbnail": "/assets/img/inner-page/news-details/post-3.jpg",
"publishedAt": "05 April 2025"
}
],
@@ -129,6 +139,24 @@
"status": "approved",
"parentAuthorName": "Frank Flores"
},
{
"postSlug": "top-5-scholarship-programs-international-students",
"authorName": "Sarah Johnson",
"authorAvatar": "/assets/img/inner-page/news-details/comment-1.png",
"content": "Cảm ơn bạn đã chia sẻ thông tin về các chương trình học bổng này. Mình đã apply và đang chờ kết quả!",
"createdAt": "March 15, 2025",
"status": "approved",
"parentAuthorName": null
},
{
"postSlug": "top-5-scholarship-programs-international-students",
"authorName": "Michael Chen",
"authorAvatar": "/assets/img/inner-page/news-details/comment-2.png",
"content": "Bài viết rất chi tiết và hữu ích. Mình đã tìm thấy một chương trình phù hợp với mình.",
"createdAt": "March 18, 2025",
"status": "approved",
"parentAuthorName": null
},
{
"postSlug": "10-travel-safety-tips-before-flying",
"authorName": "Jenny Wilson",

300
data/visa.json Normal file
View File

@@ -0,0 +1,300 @@
{
"hero": {
"title": "Visa Service",
"summaryList": [
{
"id": 1,
"name": "France",
"slug": "france",
"icon": "/img/home-2/visa/03.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
],
"detailedView": {
"activeCountry": {
"id": 1,
"name": "United States of America ",
"title": "COUNTRY USA",
"mainImage": "/img/inner-page/country-details/details-1.jpg",
"description": "The United States is one of the most popular destinations for international students and immigrants, offering world-class universities, diverse cultural experiences, and countless career opportunities...",
"additionalInfo": "Our consultancy provides complete guidance for study visas, work permits, and permanent residency pathways tailored to your goals.",
"tagline": "Over the last 35 Years we made an impact that is strong & we have long way to go.",
"visaTypes": [
{
"category": "Tourist & Work",
"items": [
{
"title": "Tourist Visa",
"description": "Broad term that can refer to various aspects of interconnectedness"
},
{
"title": "Work Permit",
"description": "Broad term that can refer to various aspects of interconnectedness"
}
]
},
{
"category": "Student & Family",
"items": [
{
"title": "Student",
"description": "Broad term that can refer to various aspects of interconnectedness"
},
{
"title": "Tourist Visa",
"description": "Broad term that can refer to various aspects of interconnectedness"
}
]
}
],
"visaProcess": {
"title": "USA Visa Process",
"steps": [
{
"number": "01",
"title": "Consultation & Eligibility Check",
"description": "Our experts review your profile and visa requirements."
},
{
"number": "02",
"title": "Application Preparation",
"description": "We help with document collection, form filling, and statement drafting."
},
{
"number": "03",
"title": "Submission",
"description": "Visa application is submitted online with required fees."
},
{
"number": "04",
"title": "Interview Guidance",
"description": "Get training and mock sessions for embassy interview."
},
{
"number": "05",
"title": "Approval & Travel",
"description": "Once approved, we provide travel and pre-departure guidance."
}
]
},
"gallery": [
"/img/inner-page/country-details/details-2.jpg",
"/img/inner-page/country-details/details-3.png"
],
"visaCategories": {
"title": "Types of USA Visas",
"steps": [
[
"Student Visa (F1, M1, J1)",
"Work Visa (H1B, L1)",
"Tourist Visa (B1/B2)"
],
[
"Family/Spouse Visa (K1, IR1, F2A)",
"Green Card / Immigrant Visa"
]
]
},
"visaService": {
"title": "Our USA Visa Service Options",
"steps": [
{
"number": "01",
"title": "Consultation & Eligibility Check",
"description": "Our experts review your profile and visa requirements."
},
{
"number": "02",
"title": "Application Preparation",
"description": "We help with document collection, form filling, and statement drafting."
},
{
"number": "03",
"title": "Submission",
"description": "Visa application is submitted online with required fees."
},
{
"number": "04",
"title": "Interview Guidance",
"description": "Get training and mock sessions for embassy interview."
},
{
"number": "05",
"title": "Approval & Travel",
"description": "Once approved, we provide travel and pre-departure guidance."
}
]
}
},
"relatedCountries": [
{
"id": 1,
"name": "Canada",
"icon": "/img/inner-page/country-details/01.png"
},
{
"id": 2,
"name": "USA",
"icon": "/img/inner-page/country-details/02.png"
},
{
"id": 3,
"name": "USA",
"icon": "/img/inner-page/country-details/03.png"
},
{
"id": 4,
"name": "Saint Helena",
"icon": "/img/inner-page/country-details/05.png"
},
{
"id": 5,
"name": "Iran",
"icon": "/img/inner-page/country-details/06.png"
},
{
"id": 6,
"name": "Spain",
"icon": "/img/inner-page/country-details/07.png"
},
{
"id": 7,
"name": "Japan",
"icon": "/img/inner-page/country-details/08.png"
}
],
"contactInfo": {
"img": "/img/inner-page/country-details/bg.jpg",
"sectionTitle": "Visa & Immigration",
"helpText": "Need Help? Book Lab Visit",
"phone": {
"label": "Call Us",
"value": "+009 438 222 9540",
"link": "tel:+0094382229540"
},
"email": {
"label": "Mail Us",
"value": "infor@xridergamil.com",
"link": "mailto:infor@xridergamil.com"
},
"location": {
"label": "Location",
"address": "Toronto, Montreal, City 2026"
}
}
}
},
{
"id": 2,
"name": "UK",
"slug": "uk",
"icon": "/img/home-2/visa/11.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 3,
"name": "Canada",
"slug": "canada",
"icon": "/img/home-2/visa/02.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 4,
"name": "Germany",
"slug": "germany",
"icon": "/img/home-2/visa/12.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 5,
"name": "Spain",
"slug": "spain",
"icon": "/img/home-2/visa/13.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 6,
"name": "South Korea",
"slug": "south-korea",
"icon": "/img/home-2/visa/14.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 7,
"name": "Japan",
"slug": "japan",
"icon": "/img/home-2/visa/15.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 8,
"name": "Croatia",
"slug": "croatia",
"icon": "/img/home-2/visa/16.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 9,
"name": "England",
"slug": "england",
"icon": "/img/home-2/visa/17.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 10,
"name": "Indonesia",
"slug": "indonesia",
"icon": "/img/home-2/visa/18.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
}
]
}
}

View File

@@ -71,6 +71,20 @@ const blogSchema = new mongoose.Schema({
commentsCount: {
type: Number,
default: 0
},
// Quote/Sidebar section
quote: {
type: String,
default: '',
trim: true
},
// Content after quote
contentAfterQuote: {
type: String,
default: '',
trim: true
}
}, {
timestamps: true

View File

@@ -0,0 +1,233 @@
// models/visa.js
const mongoose = require("mongoose");
// ==================== SCHEMAS ====================
// VisaItem Schema
const VisaItemSchema = new mongoose.Schema(
{
title: { type: String, default: "" },
description: { type: String, default: "" },
},
{ _id: false },
);
// VisaTypeCategory Schema
const VisaTypeCategorySchema = new mongoose.Schema(
{
category: { type: String, default: "" },
items: [VisaItemSchema],
},
{ _id: false },
);
// VisaProcessStep Schema
const VisaProcessStepSchema = new mongoose.Schema(
{
number: { type: String, default: "" },
title: { type: String, default: "" },
description: { type: String, default: "" },
},
{ _id: false },
);
// VisaProcess Schema
const VisaProcessSchema = new mongoose.Schema(
{
title: { type: String, default: "" },
steps: [VisaProcessStepSchema],
},
{ _id: false },
);
// VisaCategory Schema
const VisaCategorySchema = new mongoose.Schema(
{
title: { type: String, default: "" },
steps: {
type: [[String]],
default: [],
},
},
{ _id: false },
);
// VisaService Schema
const VisaServiceSchema = new mongoose.Schema(
{
title: { type: String, default: "" },
steps: [VisaProcessStepSchema],
},
{ _id: false },
);
// RelatedCountry Schema
const RelatedCountrySchema = new mongoose.Schema(
{
id: { type: Number, default: 0 },
name: { type: String, default: "" },
icon: { type: String, default: "" },
},
{ _id: false },
);
// Phone Schema
const PhoneSchema = new mongoose.Schema(
{
label: { type: String, default: "" },
value: { type: String, default: "" },
link: { type: String, default: "" },
},
{ _id: false },
);
// Email Schema
const EmailSchema = new mongoose.Schema(
{
label: { type: String, default: "" },
value: { type: String, default: "" },
link: { type: String, default: "" },
},
{ _id: false },
);
// Location Schema
const LocationSchema = new mongoose.Schema(
{
label: { type: String, default: "" },
address: { type: String, default: "" },
},
{ _id: false },
);
// ContactInfo Schema
const ContactInfoSchema = new mongoose.Schema(
{
img: { type: String, default: "" },
sectionTitle: { type: String, default: "" },
helpText: { type: String, default: "" },
phone: {
type: PhoneSchema,
default: () => ({}),
},
email: {
type: EmailSchema,
default: () => ({}),
},
location: {
type: LocationSchema,
default: () => ({}),
},
},
{ _id: false },
);
// ActiveCountry Schema
const ActiveCountrySchema = new mongoose.Schema(
{
id: { type: Number, default: 0 },
name: { type: String, default: "" },
title: { type: String, default: "" },
mainImage: { type: String, default: "" },
description: { type: String, default: "" },
additionalInfo: { type: String, default: "" },
tagline: { type: String, default: "" },
visaTypes: [VisaTypeCategorySchema],
visaProcess: {
type: VisaProcessSchema,
default: null,
},
gallery: {
type: [String],
default: [],
},
visaCategories: {
type: VisaCategorySchema,
default: null,
},
visaService: {
type: VisaServiceSchema,
default: null,
},
},
{ _id: false },
);
// DetailedView Schema
const DetailedViewSchema = new mongoose.Schema(
{
activeCountry: {
type: ActiveCountrySchema,
default: null,
},
relatedCountries: {
type: [RelatedCountrySchema],
default: [],
},
contactInfo: {
type: ContactInfoSchema,
default: null,
},
},
{ _id: false },
);
// ==================== MAIN VISA COUNTRY SCHEMA ====================
// Main VisaCountry Schema (Individual country object)
const VisaCountrySchema = new mongoose.Schema(
{
id: { type: Number, required: true, index: true },
name: { type: String, required: true, index: true },
slug: { type: String, required: true, unique: true, index: true },
icon: { type: String, default: "" },
services: {
type: [String],
default: [],
},
detailedView: {
type: DetailedViewSchema,
default: null,
},
},
{ _id: false },
);
// ==================== HERO SCHEMA ====================
const HeroSchema = new mongoose.Schema(
{
title: { type: String, default: "Visa" },
summaryList: {
type: [VisaCountrySchema],
default: [],
},
},
{ _id: false },
);
// ==================== MAIN VISA SCHEMA ====================
const visaDataSchema = new mongoose.Schema(
{
hero: {
type: HeroSchema,
default: () => ({ title: "Visa", summaryList: [] }),
},
},
{
timestamps: true,
},
);
// ==================== INDEXES ====================
visaDataSchema.index({ "hero.summaryList.slug": 1 });
visaDataSchema.index({ "hero.summaryList.id": 1 });
visaDataSchema.index({ "hero.summaryList.name": 1 });
// ==================== MODEL ====================
module.exports = mongoose.models.Visa || mongoose.model("Visa", visaDataSchema);

View File

@@ -16,7 +16,7 @@ const settingController = require("../controllers/settingController");
const faqController = require("../controllers/faqController"); // Thêm import này
const termsController = require("../controllers/termsController");
const travelController = require("../controllers/travelController");
const visaController = require("../controllers/visaController");
const { upload, uploadVideo, convertToWebp } = require("../middleware/upload");
const safetyController = require("../controllers/safetyController");
const insuranceController = require("../controllers/insuranceController");
@@ -161,12 +161,12 @@ router.get(
router.get(
"/contact/submissions",
ensureAuthenticated,
contactController.getSubmissions
contactController.getSubmissions,
);
router.put(
"/contact/submissions/:id",
ensureAuthenticated,
contactController.updateSubmissionStatus
contactController.updateSubmissionStatus,
);
// Appointment management
@@ -174,34 +174,46 @@ const appointmentController = require("../controllers/appointmentController");
router.get(
"/appointments",
ensureAuthenticated,
appointmentController.getAppointments
appointmentController.getAppointments,
);
router.get(
"/appointments/:id",
ensureAuthenticated,
appointmentController.getAppointmentById
appointmentController.getAppointmentById,
);
router.put(
"/appointments/:id",
ensureAuthenticated,
appointmentController.updateAppointmentStatus
appointmentController.updateAppointmentStatus,
);
router.delete(
"/appointments/:id",
ensureAuthenticated,
appointmentController.deleteAppointment
appointmentController.deleteAppointment,
);
// Appointment CMS page management
router.get("/appointment", ensureAuthenticated, appointmentController.index);
router.post("/appointment/update", ensureAuthenticated, appointmentController.update);
router.get("/appointment/data", ensureAuthenticated, appointmentController.getAppointmentData);
router.post(
"/appointment/update",
ensureAuthenticated,
appointmentController.update,
);
router.get(
"/appointment/data",
ensureAuthenticated,
appointmentController.getAppointmentData,
);
// Pricing CMS page management
const pricingController = require("../controllers/pricingController");
router.get("/pricing", ensureAuthenticated, pricingController.index);
router.post("/pricing/update", ensureAuthenticated, pricingController.update);
router.get("/pricing/data", ensureAuthenticated, pricingController.getPricingData);
router.get(
"/pricing/data",
ensureAuthenticated,
pricingController.getPricingData,
);
// Activity CRUD routes
router.get("/activity", ensureAuthenticated, activityController.index);
@@ -373,6 +385,8 @@ router.post(
ensureAuthenticated,
insuranceController.update,
);
<<<<<<< HEAD
=======
// Service routes
router.get("/service", ensureAuthenticated, serviceController.index);
@@ -399,11 +413,12 @@ router.post(
serviceController.updateDetails,
);
>>>>>>> a255d09ef0a6eb0c487595aac19cefbf729d78a2
// Test Image Paths route
router.get("/test-images", ensureAuthenticated, (req, res) => {
const fs = require('fs');
const path = require('path');
const campLocationData = require('../data/camp-location.json');
const fs = require("fs");
const path = require("path");
const campLocationData = require("../data/camp-location.json");
// Collect all image paths
const imagePaths = [];
@@ -454,14 +469,36 @@ router.get("/test-images", ensureAuthenticated, (req, res) => {
});
}
res.render('admin/test-images', {
layout: 'layouts/admin',
title: 'Test Image Paths',
res.render("admin/test-images", {
layout: "layouts/admin",
title: "Test Image Paths",
images: imagePaths,
user: req.session.user,
});
});
// Display visa management page
router.get("/visa", visaController.index);
// Update hero title
router.post("/visa/update", ensureAuthenticated, visaController.update);
// Add new country
router.post("/visa/add", ensureAuthenticated, visaController.addCountry);
// Update single country
router.put(
"/visa/update/:slug",
ensureAuthenticated,
visaController.updateCountry,
);
// Delete country
router.delete(
"/delete/:slug",
ensureAuthenticated,
visaController.deleteCountry,
);
// Blog routes
// Blog Management Routes
router.get("/blog", ensureAuthenticated, blogController.index);
@@ -478,6 +515,7 @@ router.post("/blog/categories/create", ensureAuthenticated, blogCategoryControll
router.get("/blog/categories/:id/edit", ensureAuthenticated, blogCategoryController.edit);
router.post("/blog/categories/:id/edit", ensureAuthenticated, blogCategoryController.update);
router.post("/blog/categories/:id/delete", ensureAuthenticated, blogCategoryController.destroy);
router.post("/blog/categories/quick-create", ensureAuthenticated, blogCategoryController.quickCreate);
// Blog Tags Management
router.get("/blog/tags", ensureAuthenticated, blogTagController.index);
@@ -486,5 +524,6 @@ router.post("/blog/tags/create", ensureAuthenticated, blogTagController.store);
router.get("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.edit);
router.post("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.update);
router.post("/blog/tags/:id/delete", ensureAuthenticated, blogTagController.destroy);
router.post("/blog/tags/quick-create", ensureAuthenticated, blogTagController.quickCreate);
module.exports = router;

View File

@@ -8,7 +8,7 @@ const headerController = require("../controllers/headerController");
const footerController = require("../controllers/footerController");
const contactController = require("../controllers/contactController");
const faqController = require("../controllers/faqController");
const visaController = require("../controllers/visaController");
const safetyController = require("../controllers/safetyController");
const campLocationController = require("../controllers/campLocationController");
// Booking flow removed
@@ -168,6 +168,9 @@ router.get("/api/blog/tags/:slug", blogTagController.apiShow);
router.get("/api/blog/:id/categories", blogController.apiCategories);
router.get("/api/blog/:id/tags", blogController.apiTags);
// Blog comments (must come before /api/blog/:slug)
router.post("/api/blog/:slug/comments", blogController.apiCreateComment);
// Blog detail by slug (must come last among blog routes)
router.get("/api/blog/:slug", blogController.apiShow);

View File

@@ -0,0 +1,336 @@
// scripts/migrateVisa.js
require("dotenv").config();
const fs = require("fs").promises;
const path = require("path");
const mongoose = require("mongoose");
const Visa = require("../models/visa");
// 1. Đọc file JSON
async function loadVisaData() {
const filePath = path.join(__dirname, "..", "data", "visa.json");
const raw = await fs.readFile(filePath, "utf8");
return JSON.parse(raw);
}
// 2. Hàm Transform: Đổ dữ liệu từ JSON vào đúng Schema
function transformVisa(sourceData) {
// JSON có structure hero.title và hero.summaryList
return {
hero: {
title: sourceData.hero?.title || "Visa",
summaryList: Array.isArray(sourceData.hero?.summaryList)
? sourceData.hero.summaryList.map((country) =>
transformCountry(country),
)
: [],
},
updatedAt: new Date(),
};
}
// Helper function: Transform individual country
function transformCountry(source) {
return {
id: source.id || 0,
name: source.name || "",
slug: source.slug || "",
icon: source.icon || "",
services: Array.isArray(source.services) ? source.services : [],
detailedView: source.detailedView
? transformDetailedView(source.detailedView)
: null,
};
}
// Helper function: Transform DetailedView
function transformDetailedView(source) {
return {
activeCountry: source.activeCountry
? transformActiveCountry(source.activeCountry)
: null,
relatedCountries: Array.isArray(source.relatedCountries)
? source.relatedCountries.map((country) => ({
id: country.id || 0,
name: country.name || "",
icon: country.icon || "",
}))
: [],
contactInfo: source.contactInfo
? transformContactInfo(source.contactInfo)
: null,
};
}
// Helper function: Transform ActiveCountry
function transformActiveCountry(source) {
return {
id: source.id || 0,
name: source.name || "",
title: source.title || "",
mainImage: source.mainImage || "",
description: source.description || "",
additionalInfo: source.additionalInfo || "",
tagline: source.tagline || "",
visaTypes: Array.isArray(source.visaTypes)
? source.visaTypes.map((type) => ({
category: type.category || "",
items: Array.isArray(type.items)
? type.items.map((item) => ({
title: item.title || "",
description: item.description || "",
}))
: [],
}))
: [],
visaProcess: source.visaProcess
? transformVisaProcess(source.visaProcess)
: null,
gallery: Array.isArray(source.gallery) ? source.gallery : [],
visaCategories: source.visaCategories
? transformVisaCategories(source.visaCategories)
: null,
visaService: source.visaService
? transformVisaService(source.visaService)
: null,
};
}
// Helper function: Transform VisaProcess
function transformVisaProcess(source) {
return {
title: source.title || "",
steps: Array.isArray(source.steps)
? source.steps.map((step) => ({
number: step.number || "",
title: step.title || "",
description: step.description || "",
}))
: [],
};
}
// Helper function: Transform VisaCategories
function transformVisaCategories(source) {
return {
title: source.title || "",
steps: Array.isArray(source.steps) ? source.steps : [],
};
}
// Helper function: Transform VisaService
function transformVisaService(source) {
return {
title: source.title || "",
steps: Array.isArray(source.steps)
? source.steps.map((step) => ({
number: step.number || "",
title: step.title || "",
description: step.description || "",
}))
: [],
};
}
// Helper function: Transform ContactInfo
function transformContactInfo(source) {
return {
img: source.img || "",
sectionTitle: source.sectionTitle || "Visa & Immigration",
helpText: source.helpText || "Need Help?",
phone: {
label: source.phone?.label || "Call Us",
value: source.phone?.value || "",
link: source.phone?.link || "",
},
email: {
label: source.email?.label || "Mail Us",
value: source.email?.value || "",
link: source.email?.link || "",
},
location: {
label: source.location?.label || "Location",
address: source.location?.address || "",
},
};
}
// 3. Validate data before migration
function validateVisaData(visaData) {
const errors = [];
if (!visaData.hero) {
errors.push("Missing hero section");
}
if (!visaData.hero?.title) {
console.warn("⚠️ Hero title is missing, using default 'Visa'");
}
if (!Array.isArray(visaData.hero?.summaryList)) {
errors.push("summaryList must be an array");
} else if (visaData.hero.summaryList.length === 0) {
errors.push("summaryList is empty");
} else {
// Validate each country
visaData.hero.summaryList.forEach((country, idx) => {
if (!country.name || !country.slug) {
errors.push(`Country at index ${idx}: missing name or slug`);
}
if (country.detailedView) {
if (!country.detailedView.activeCountry) {
console.warn(
`⚠️ Country "${country.name}" (${idx}): missing activeCountry details`,
);
}
if (!Array.isArray(country.detailedView.relatedCountries)) {
errors.push(
`Country "${country.name}" (${idx}): relatedCountries must be array`,
);
}
}
});
}
return errors;
}
// Helper function: Get data summary
function getDataSummary(visaData) {
const summary = {
heroTitle: visaData.hero?.title || "N/A",
totalCountries: visaData.hero?.summaryList?.length || 0,
withDetails: 0,
withoutDetails: 0,
byCountry: [],
};
if (visaData.hero?.summaryList) {
visaData.hero.summaryList.forEach((country) => {
const hasDetails = !!country.detailedView?.activeCountry;
const relatedCount = country.detailedView?.relatedCountries?.length || 0;
if (hasDetails) {
summary.withDetails++;
} else {
summary.withoutDetails++;
}
summary.byCountry.push({
name: country.name,
slug: country.slug,
hasDetails,
relatedCountries: relatedCount,
services: country.services?.length || 0,
});
});
}
return summary;
}
// 4. Chạy Migration
async function migrate() {
try {
// Kết nối DB
console.log("🔗 Connecting to MongoDB...");
await mongoose.connect(process.env.MONGODB_URI);
console.log("✅ Connected to MongoDB\n");
// A. Lấy dữ liệu thô
console.log("📖 Loading visa data from JSON...");
const rawData = await loadVisaData();
console.log("✅ JSON data loaded\n");
// B. Chuẩn hóa dữ liệu theo Schema
console.log("🔄 Transforming data structure...");
const visaData = transformVisa(rawData);
console.log("✅ Data transformation completed\n");
// C. Validate dữ liệu
console.log("✔️ Validating data structure...");
const errors = validateVisaData(visaData);
if (errors.length > 0) {
console.error("❌ Validation errors found:");
errors.forEach((err, idx) => console.error(` ${idx + 1}. ${err}`));
process.exit(1);
}
console.log("✅ Data validation passed\n");
// D. Get summary
const summary = getDataSummary(visaData);
console.log("📊 Migration Summary:");
console.log(` Hero Title: "${summary.heroTitle}"`);
console.log(` Total countries: ${summary.totalCountries}`);
console.log(` With details: ${summary.withDetails}`);
console.log(` Without details: ${summary.withoutDetails}`);
console.log(`\n Country Details:`);
summary.byCountry.forEach((country) => {
const detailBadge = country.hasDetails ? "✅" : "❌";
const detailText = country.hasDetails
? `(${country.relatedCountries} related)`
: "(basic only)";
console.log(
` ${detailBadge} ${country.name.padEnd(20)} (${country.slug.padEnd(
12,
)}) - ${country.services} services ${detailText}`,
);
});
console.log("");
// E. Lưu vào DB (Upsert: Có rồi thì update, chưa có thì tạo)
const existingDoc = await Visa.findOne().sort({ updatedAt: -1 });
if (existingDoc) {
console.log("📝 Updating existing Visa document...");
console.log(` Document ID: ${existingDoc._id}`);
const updated = await Visa.findByIdAndUpdate(
existingDoc._id,
{ $set: visaData },
{ new: true },
);
console.log("✅ Visa document updated successfully");
console.log(` Updated at: ${updated.updatedAt}`);
} else {
console.log("📝 Creating NEW Visa document...");
const newDoc = await Visa.create(visaData);
console.log("✅ Visa document created successfully");
console.log(` Document ID: ${newDoc._id}`);
console.log(` Created at: ${newDoc.createdAt}`);
}
console.log("\n✨ Visa migration completed successfully!");
} catch (error) {
console.error("\n❌ Migration failed:");
console.error(` Error: ${error.message}`);
if (error.name === "ValidationError") {
console.error("\n Validation Errors:");
Object.keys(error.errors).forEach((field) => {
console.error(` - ${field}: ${error.errors[field].message}`);
});
}
if (error.stack) {
console.error("\n📋 Stack trace:");
console.error(error.stack);
}
process.exit(1);
} finally {
await mongoose.connection.close();
console.log("\n🔌 MongoDB connection closed");
process.exit(0);
}
}
// Run migration
console.log("🚀 Starting Visa Migration...\n");
migrate();

View File

@@ -53,13 +53,9 @@ app.use(
express.static(path.join(__dirname, "public", "uploads")),
);
// Serve other public files
app.use(
express.static(path.join(__dirname, "public")),
);
// Serve other public files
app.use(express.static(path.join(__dirname, "public")));
// Session configuration
app.use(
session({

1008
views/admin/blog/create.ejs Normal file

File diff suppressed because it is too large Load Diff

1152
views/admin/blog/edit.ejs Normal file

File diff suppressed because it is too large Load Diff

300
views/admin/blog/index.ejs Normal file
View File

@@ -0,0 +1,300 @@
<div class="container">
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
<div>
<h1 class="h3 mb-0" style="color: var(--primary-dark);">
<%= title %>
</h1>
<p class="text-muted mb-0">Manage blog posts and articles</p>
</div>
<div class="d-flex gap-2">
<% if (typeof frontendUrl !=='undefined' ) { %>
<a href="<%= frontendUrl %>/blog" class="btn btn-outline-primary" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>View Blog Page
</a>
<% } %>
<a href="/admin/blog/create" class="btn btn-primary">
<i class="fas fa-plus me-2"></i>Create New Post
</a>
</div>
</div>
<!-- Filters -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<form method="GET" action="/admin/blog" class="row g-3">
<div class="col-md-3">
<label class="form-label">Search</label>
<input type="text" class="form-control" name="search" value="<%= query.search || '' %>"
placeholder="Search title or excerpt...">
</div>
<div class="col-md-2">
<label class="form-label">Status</label>
<select class="form-select" name="status">
<option value="">All</option>
<option value="published" <%=query.status==='published' ? 'selected' : '' %>>Published</option>
<option value="draft" <%=query.status==='draft' ? 'selected' : '' %>>Draft</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Category</label>
<select class="form-select" name="category">
<option value="">All Categories</option>
<% categories.forEach(cat=> { %>
<option value="<%= cat.name %>" <%=query.category===cat.name ? 'selected' : '' %>>
<%= cat.name %>
</option>
<% }); %>
</select>
</div>
<div class="col-md-2">
<label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-search me-1"></i>Filter
</button>
</div>
<div class="col-md-2">
<label class="form-label">&nbsp;</label>
<a href="/admin/blog" class="btn btn-outline-secondary w-100">
<i class="fas fa-times me-1"></i>Clear
</a>
</div>
</form>
</div>
</div>
<!-- Blog List -->
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<% if (blogs && blogs.length> 0) { %>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th scope="col" style="width: 60px">Image</th>
<th scope="col">Title</th>
<th scope="col">Category</th>
<th scope="col">Status</th>
<th scope="col">Author</th>
<th scope="col">Published</th>
<th scope="col" style="width: 200px">Actions</th>
</tr>
</thead>
<tbody>
<% blogs.forEach((blog, index)=> { %>
<tr>
<td>
<% if (blog.featuredImage) { %>
<img src="<%= blog.featuredImage.startsWith('http') ? blog.featuredImage : (typeof frontendUrl !== 'undefined' ? frontendUrl : '') + blog.featuredImage %>"
alt="<%= blog.title %>" class="img-thumbnail"
style="width: 50px; height: 50px; object-fit: cover;">
<% } else { %>
<div class="bg-light d-flex align-items-center justify-content-center"
style="width: 50px; height: 50px;">
<i class="fas fa-image text-muted"></i>
</div>
<% } %>
</td>
<td>
<div>
<span class="fw-medium">
<%= blog.title %>
</span>
<% if (blog.isFeatured) { %>
<span class="badge bg-warning text-dark ms-2">Featured</span>
<% } %>
</div>
<small class="text-muted">
<%= blog.excerpt.substring(0, 60) %>...
</small>
</td>
<td>
<% if (blog.category && blog.category.length> 0) { %>
<% blog.category.slice(0, 2).forEach(cat=> { %>
<span class="badge bg-secondary me-1">
<%= cat %>
</span>
<% }); %>
<% if (blog.category.length> 2) { %>
<span class="text-muted">+<%= blog.category.length - 2 %></span>
<% } %>
<% } else { %>
<span class="text-muted">-</span>
<% } %>
</td>
<td>
<% if (blog.status==='published' ) { %>
<span class="badge bg-success">Published</span>
<% } else { %>
<span class="badge bg-secondary">Draft</span>
<% } %>
</td>
<td>
<%= blog.author || 'Admin' %>
</td>
<td>
<%= blog.publishedAt || '-' %>
</td>
<td>
<div class="btn-group" role="group">
<% if (typeof frontendUrl !=='undefined' ) { %>
<a href="<%= frontendUrl %>/blog/<%= blog.slug %>" target="_blank"
class="btn btn-sm btn-outline-secondary">
<i class="fas fa-external-link-alt me-1"></i>View
</a>
<% } %>
<a href="/admin/blog/<%= blog._id %>/edit"
class="btn btn-sm btn-outline-primary">
<i class="fas fa-edit me-1"></i>Edit
</a>
<button type="button" class="btn btn-sm btn-outline-danger"
data-custom-modal="open" data-id="<%= blog._id %>"
data-title="<%= blog.title %>">
<i class="fas fa-trash-alt me-1"></i>Delete
</button>
</div>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<!-- Pagination -->
<% if (pagination && pagination.total> 1) { %>
<nav aria-label="Blog pagination" class="mt-4">
<ul class="pagination justify-content-center">
<% if (pagination.current> 1) { %>
<li class="page-item">
<a class="page-link"
href="?page=<%= pagination.current - 1 %><%= query.search ? '&search=' + query.search : '' %><%= query.status ? '&status=' + query.status : '' %><%= query.category ? '&category=' + query.category : '' %>">Previous</a>
</li>
<% } %>
<% for (let i=1; i <=pagination.total; i++) { %>
<% if (i===pagination.current) { %>
<li class="page-item active">
<span class="page-link">
<%= i %>
</span>
</li>
<% } else if (i===1 || i===pagination.total || (i>= pagination.current - 2
&& i <= pagination.current + 2)) { %>
<li class="page-item">
<a class="page-link"
href="?page=<%= i %><%= query.search ? '&search=' + query.search : '' %><%= query.status ? '&status=' + query.status : '' %><%= query.category ? '&category=' + query.category : '' %>">
<%= i %>
</a>
</li>
<% } else if (i===pagination.current - 3 || i===pagination.current +
3) { %>
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
<% } %>
<% } %>
<% if (pagination.current < pagination.total) { %>
<li class="page-item">
<a class="page-link"
href="?page=<%= pagination.current + 1 %><%= query.search ? '&search=' + query.search : '' %><%= query.status ? '&status=' + query.status : '' %><%= query.category ? '&category=' + query.category : '' %>">Next</a>
</li>
<% } %>
</ul>
</nav>
<% } %>
<% } else { %>
<div class="text-center py-5">
<i class="fas fa-blog text-muted mb-3" style="font-size: 3rem;"></i>
<h5 class="text-muted mb-3">No Blog Posts Found</h5>
<a href="/admin/blog/create" class="btn btn-primary">
<i class="fas fa-plus-circle me-1"></i>Create First Blog Post
</a>
</div>
<% } %>
</div>
</div>
</div>
<!-- Delete Blog Confirmation Modal -->
<div class="modal fade" id="deleteBlogModal" tabindex="-1" aria-labelledby="deleteBlogModalLabel" aria-hidden="true"
data-bs-backdrop="true" data-bs-keyboard="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteBlogModalLabel">
<i class="fas fa-trash me-2"></i>Confirm Delete
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete the blog post "<span id="deleteBlogTitle" class="fw-bold"></span>"?
</p>
<p class="text-danger mb-0">
<small>
<i class="fas fa-exclamation-triangle me-1"></i>This action cannot be
undone.</small>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form id="deleteBlogForm" method="POST" style="display: inline;">
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-1"></i>Delete
</button>
</form>
</div>
</div>
</div>
</div>
<style>
/* Fix modal z-index - must be higher than everything else */
#deleteBlogModal {
z-index: 2050 !important;
}
#deleteBlogModal .modal-dialog {
z-index: 2060 !important;
position: relative;
}
#deleteBlogModal .modal-content {
z-index: 2070 !important;
position: relative;
}
/* Ensure modal is clickable */
#deleteBlogModal.show {
display: block !important;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function () { // Initialize modal instance once
const deleteModalElement = document.getElementById('deleteBlogModal');
const deleteModal = new bootstrap.Modal(deleteModalElement, {
backdrop: false,
keyboard: true,
focus: true
});
// Handle delete buttons
document.querySelectorAll('[data-custom-modal="open"]').forEach(button => {
button.addEventListener('click', function (e) {
e.preventDefault();
const blogId = this.getAttribute('data-id');
const blogTitle = this.getAttribute('data-title');
// Set blog title in modal
document.getElementById('deleteBlogTitle').textContent = blogTitle;
// Set form action
document.getElementById('deleteBlogForm').action = `/admin/blog/${blogId}/delete`;
// Show Bootstrap modal
deleteModal.show();
});
});
});
</script>

1231
views/admin/visa/index.ejs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -85,7 +85,6 @@
<a class="nav-link" href="/admin/camp-location">Camp Location</a>
</li>
<li class="nav-item">
<li class="nav-item">
<a class="nav-link" href="/admin/form">Form</a>
</li>