diff --git a/.gitignore b/.gitignore
index 14b9aad..fc4bc00 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# dependencies
node_modules/
+/public
# environment
.env
@@ -21,3 +22,4 @@ pids
#cursor
.cursor
+package-lock.json
\ No newline at end of file
diff --git a/controllers/blogCategoryController.js b/controllers/blogCategoryController.js
index 4043d22..52f5086 100644
--- a/controllers/blogCategoryController.js
+++ b/controllers/blogCategoryController.js
@@ -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'
+ });
}
};
diff --git a/controllers/blogController.js b/controllers/blogController.js
index bc02bc4..22ce42b 100644
--- a/controllers/blogController.js
+++ b/controllers/blogController.js
@@ -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
@@ -69,12 +69,15 @@ 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({
diff --git a/controllers/blogTagController.js b/controllers/blogTagController.js
index f4232ef..6daeb6b 100644
--- a/controllers/blogTagController.js
+++ b/controllers/blogTagController.js
@@ -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'
+ });
}
};
diff --git a/controllers/visaController.js b/controllers/visaController.js
new file mode 100644
index 0000000..77ef292
--- /dev/null
+++ b/controllers/visaController.js
@@ -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",
+ });
+ }
+};
diff --git a/data/blog.json b/data/blog.json
index e04489c..4a6b1d2 100644
--- a/data/blog.json
+++ b/data/blog.json
@@ -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": "
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.
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.
",
"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": "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.
",
+ "content": "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.
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ế.
",
"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": "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.
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.
",
+ "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": "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.
",
+ "content": "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.
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ó.
",
"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": "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.
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.
",
"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",
diff --git a/data/visa.json b/data/visa.json
new file mode 100644
index 0000000..f39e1d5
--- /dev/null
+++ b/data/visa.json
@@ -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"
+ ]
+ }
+ ]
+ }
+}
diff --git a/models/blog.js b/models/blog.js
index 39aea69..89bf9bb 100644
--- a/models/blog.js
+++ b/models/blog.js
@@ -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
diff --git a/models/visa.js b/models/visa.js
index e69de29..6edae4e 100644
--- a/models/visa.js
+++ b/models/visa.js
@@ -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);
diff --git a/routes/admin.js b/routes/admin.js
index fb7d1ff..9c2ecb8 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -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;
diff --git a/routes/index.js b/routes/index.js
index 3322df9..daffad6 100644
--- a/routes/index.js
+++ b/routes/index.js
@@ -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);
diff --git a/scripts/2026_02_03_645124_visa.js b/scripts/2026_02_03_645124_visa.js
new file mode 100644
index 0000000..d3c031e
--- /dev/null
+++ b/scripts/2026_02_03_645124_visa.js
@@ -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();
diff --git a/server.js b/server.js
index eb71f17..01be70a 100644
--- a/server.js
+++ b/server.js
@@ -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({
diff --git a/views/admin/blog/create.ejs b/views/admin/blog/create.ejs
new file mode 100644
index 0000000..0a839d4
--- /dev/null
+++ b/views/admin/blog/create.ejs
@@ -0,0 +1,1008 @@
+
+
+
+
+ <%= title %>
+
+
Create a new blog post
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Are you sure you want to delete the category " "?
+
+
+ This action cannot be undone.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Are you sure you want to delete the tag " "?
+
+
+ This action cannot be undone.
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/views/admin/blog/edit.ejs b/views/admin/blog/edit.ejs
new file mode 100644
index 0000000..838ed55
--- /dev/null
+++ b/views/admin/blog/edit.ejs
@@ -0,0 +1,1152 @@
+
+
+
+
+ <%= title %>
+
+
Edit blog post
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Featured
+ Image *
+
+
+
+ Upload
+
+
+
Upload a new featured image or enter image URL.
+
+
+ <% if (blog.featuredImage) { %>
+
+ <% } %>
+
+
+
+
+
+
+
+
Title *
+
+
The title will be used to generate the URL slug
+ automatically.
+
+
+
+
+
+
+
Content *
+
+
+
Write the main content of the blog post using the
+ editor.
+
+
+
+
+
+
+
Gallery Images *
+
Exactly 2 images required (row, 2 columns)
+
+
+ <% const galleryImages=blog.galleryImages || []; const
+ image1=galleryImages[0] || '' ; const image2=galleryImages[1]
+ || '' ; %>
+
+
+
+
+ Upload
+
+
+
+ <% if (image1) { %>
+
+ <% } %>
+
+
+
+
+
+
+ Upload
+
+
+
+ <% if (image2) { %>
+
+ <% } %>
+
+
+
+
+
+
+
+
+
+
Quote/Sidebar
+
<%= blog.quote || '' %>
+
This will be displayed as a highlighted quote in
+ the blog post.
+
+
+
+
+
+
+
Content
+ After Quote
+
+
+
Content that appears after the quote section.
+
+
+
+
+
+
+
+
Excerpt *
+
<%= blog.excerpt || '' %>
+
+
+ <%= (blog.excerpt || '' ).length %>
+ /500 characters
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Categories
+
+
+
+ Add
+
+
+
+
Select one or more categories for this blog post.
+
+
+
+
+
+
Tags
+
+
+
+ Add
+
+
+
+
Select one or more tags for this blog post.
+
+
+
+
+
+
+
+
+
+
+
+
+ Author
+
+
+
+
+ Status
+
+ >Published
+
+ >Draft
+
+
+
+
+
+ >
+
+ Mark as Featured Post
+
+
+
Featured posts can be highlighted on the blog
+ page.
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ Update Blog Post
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Are you sure you want to delete the category " "?
+
+
+
+ This action cannot be undone.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Are you sure you want to delete the tag " "?
+
+
+ This action cannot be undone.
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/views/admin/blog/index.ejs b/views/admin/blog/index.ejs
new file mode 100644
index 0000000..68adadb
--- /dev/null
+++ b/views/admin/blog/index.ejs
@@ -0,0 +1,300 @@
+
+
+
+
+ <%= title %>
+
+
Manage blog posts and articles
+
+
+
+
+
+
+
+
+
+ Search
+
+
+
+ Status
+
+ All
+ >Published
+ >Draft
+
+
+
+ Category
+
+ All Categories
+ <% categories.forEach(cat=> { %>
+ >
+ <%= cat.name %>
+
+ <% }); %>
+
+
+
+
+
+ Filter
+
+
+
+
+
+
+
+
+
+
+ <% if (blogs && blogs.length> 0) { %>
+
+
+
+
+ Image
+ Title
+ Category
+ Status
+ Author
+ Published
+ Actions
+
+
+
+ <% blogs.forEach((blog, index)=> { %>
+
+
+ <% if (blog.featuredImage) { %>
+
+ <% } else { %>
+
+
+
+ <% } %>
+
+
+
+
+ <%= blog.title %>
+
+ <% if (blog.isFeatured) { %>
+ Featured
+ <% } %>
+
+
+ <%= blog.excerpt.substring(0, 60) %>...
+
+
+
+ <% if (blog.category && blog.category.length> 0) { %>
+ <% blog.category.slice(0, 2).forEach(cat=> { %>
+
+ <%= cat %>
+
+ <% }); %>
+ <% if (blog.category.length> 2) { %>
+ +<%= blog.category.length - 2 %>
+ <% } %>
+ <% } else { %>
+ -
+ <% } %>
+
+
+ <% if (blog.status==='published' ) { %>
+ Published
+ <% } else { %>
+ Draft
+ <% } %>
+
+
+ <%= blog.author || 'Admin' %>
+
+
+ <%= blog.publishedAt || '-' %>
+
+
+
+ <% if (typeof frontendUrl !=='undefined' ) { %>
+
+ View
+
+ <% } %>
+
+ Edit
+
+
+ Delete
+
+
+
+
+ <% }); %>
+
+
+
+
+
+ <% if (pagination && pagination.total> 1) { %>
+
+
+
+ <% } %>
+ <% } else { %>
+
+
+
No Blog Posts Found
+
+ Create First Blog Post
+
+
+ <% } %>
+
+
+
+
+
+
+
+
+
+
+
Are you sure you want to delete the blog post " "?
+
+
+
+ This action cannot be
+ undone.
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/views/admin/visa/index.ejs b/views/admin/visa/index.ejs
new file mode 100644
index 0000000..488d279
--- /dev/null
+++ b/views/admin/visa/index.ejs
@@ -0,0 +1,1231 @@
+
+
+
+
+
+
+
+
+
+
+
+ ID
+ Flag
+ Country Name
+ Services
+ Actions
+
+
+
+ <% if (data && data.hero && data.hero.summaryList) { %>
+ <% data.hero.summaryList.forEach(function(country) { %>
+
+
+ #<%= String(country.id).padStart(3, "0" ) %>
+
+
+
+
+
+
+
+ <%= country.name %>
+
+
+
+ <% country.services.forEach(function(service) { %>
+
+ <%= service %>
+
+ <% }); %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <% }); %>
+ <% } %>
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/views/layouts/admin.ejs b/views/layouts/admin.ejs
index 01eca05..7d7e49d 100644
--- a/views/layouts/admin.ejs
+++ b/views/layouts/admin.ejs
@@ -85,7 +85,6 @@
Camp Location
-
Form