fix conflig pull request
2
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
/public
|
||||
|
||||
# environment
|
||||
.env
|
||||
@@ -21,3 +22,4 @@ pids
|
||||
|
||||
#cursor
|
||||
.cursor
|
||||
package-lock.json
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
360
controllers/serviceController.js
Normal file
@@ -0,0 +1,360 @@
|
||||
const { getServiceData } = require("../services/service.service");
|
||||
const Service = require("../models/service");
|
||||
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
|
||||
const slugify = require("slugify");
|
||||
|
||||
// Admin page - Service list
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const data = await getServiceData();
|
||||
console.log(data.services.items.image);
|
||||
res.render("admin/service/index", {
|
||||
title: "Service Management",
|
||||
data,
|
||||
layout: "layouts/main",
|
||||
getFullImageUrl, // Truyền helper function vào view
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", "Error loading service data");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Admin page - Service edit
|
||||
exports.edit = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const data = await getServiceData();
|
||||
|
||||
const service = data.services?.items?.find((item) => item.slug === slug);
|
||||
if (!service) {
|
||||
req.flash("error_msg", "Service not found");
|
||||
return res.redirect("/admin/service");
|
||||
}
|
||||
|
||||
res.render("admin/service/edit", {
|
||||
title: `Edit Service - ${service.name}`,
|
||||
service,
|
||||
layout: "layouts/main",
|
||||
getFullImageUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", "Error loading service for editing");
|
||||
res.redirect("/admin/service");
|
||||
}
|
||||
};
|
||||
|
||||
// Update single service
|
||||
exports.updateService = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const currentData = await getServiceData();
|
||||
|
||||
const serviceIndex = currentData.services?.items?.findIndex(
|
||||
(item) => item.slug === slug,
|
||||
);
|
||||
if (serviceIndex === -1) {
|
||||
req.flash("error_msg", "Service not found");
|
||||
return res.redirect("/admin/service");
|
||||
}
|
||||
|
||||
// Update service data
|
||||
const updatedData = { ...currentData.toObject?.() };
|
||||
updatedData.services.items[serviceIndex] = {
|
||||
...updatedData.services.items[serviceIndex],
|
||||
name: req.body.name,
|
||||
slug: req.body.slug,
|
||||
description: req.body.description,
|
||||
image: req.body.image,
|
||||
layout: req.body.layout,
|
||||
};
|
||||
|
||||
if (currentData._id) {
|
||||
await Service.findByIdAndUpdate(currentData._id, updatedData);
|
||||
} else {
|
||||
await Service.create(updatedData);
|
||||
}
|
||||
|
||||
req.flash("success_msg", "Service updated successfully");
|
||||
res.redirect("/admin/service");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", err.message);
|
||||
res.redirect("/admin/service");
|
||||
}
|
||||
};
|
||||
|
||||
// Admin page - Service details
|
||||
exports.details = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const data = await getServiceData();
|
||||
|
||||
const service = data.services?.items?.find((item) => item.slug === slug);
|
||||
if (!service) {
|
||||
req.flash("error_msg", "Service not found");
|
||||
return res.redirect("/admin/service");
|
||||
}
|
||||
|
||||
res.render("admin/service/details", {
|
||||
title: `Service Details - ${service.name}`,
|
||||
service,
|
||||
layout: "layouts/main",
|
||||
getFullImageUrl, // Truyền helper function vào view
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", "Error loading service details");
|
||||
res.redirect("/admin/service");
|
||||
}
|
||||
};
|
||||
|
||||
// Update service list
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const currentData = await getServiceData();
|
||||
const sections = [
|
||||
"pageTitle",
|
||||
"services",
|
||||
"destinations",
|
||||
"visas",
|
||||
"reviews",
|
||||
];
|
||||
|
||||
let updatedData = { ...currentData.toObject?.() };
|
||||
let hasChanges = false;
|
||||
|
||||
sections.forEach((section) => {
|
||||
if (!req.body[section]) return;
|
||||
|
||||
const newData = JSON.parse(req.body[section]);
|
||||
if (JSON.stringify(newData) !== JSON.stringify(currentData[section])) {
|
||||
updatedData[section] = newData;
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasChanges) {
|
||||
req.flash("info_msg", "No changes were made");
|
||||
return res.redirect("/admin/service");
|
||||
}
|
||||
|
||||
if (currentData._id) {
|
||||
await Service.findByIdAndUpdate(currentData._id, updatedData);
|
||||
} else {
|
||||
await Service.create(updatedData);
|
||||
}
|
||||
|
||||
req.flash("success_msg", "Service updated successfully");
|
||||
res.redirect("/admin/service");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", err.message);
|
||||
res.redirect("/admin/service");
|
||||
}
|
||||
};
|
||||
|
||||
// Update service details
|
||||
exports.updateDetails = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const currentData = await getServiceData();
|
||||
|
||||
const serviceIndex = currentData.services?.items?.findIndex(
|
||||
(item) => item.slug === slug,
|
||||
);
|
||||
if (serviceIndex === -1) {
|
||||
req.flash("error_msg", "Service not found");
|
||||
return res.redirect("/admin/service");
|
||||
}
|
||||
|
||||
// Parse features and FAQ from JSON strings
|
||||
const features = req.body.features ? JSON.parse(req.body.features) : [];
|
||||
const faq = req.body.faq ? JSON.parse(req.body.faq) : [];
|
||||
|
||||
// Update service details
|
||||
const updatedData = { ...currentData.toObject?.() };
|
||||
updatedData.services.items[serviceIndex].details = {
|
||||
title: req.body.title,
|
||||
description: req.body.description,
|
||||
mainImage: req.body.mainImage,
|
||||
overviewTitle: req.body.overviewTitle,
|
||||
overviewDescription: req.body.overviewDescription,
|
||||
additionalDescription: req.body.additionalDescription,
|
||||
keyFeaturesTitle: req.body.keyFeaturesTitle,
|
||||
keyFeaturesImage: req.body.keyFeaturesImage,
|
||||
features: features,
|
||||
faqTitle: req.body.faqTitle,
|
||||
faqImage: req.body.faqImage,
|
||||
faq: faq,
|
||||
};
|
||||
|
||||
if (currentData._id) {
|
||||
await Service.findByIdAndUpdate(currentData._id, updatedData);
|
||||
} else {
|
||||
await Service.create(updatedData);
|
||||
}
|
||||
|
||||
req.flash("success_msg", "Service details updated successfully");
|
||||
res.redirect(`/admin/service/${slug}/details`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", err.message);
|
||||
res.redirect("/admin/service");
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const serviceData = await getServiceData();
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
|
||||
|
||||
const processedData = addBaseUrlToImages(serviceData, baseUrl);
|
||||
res.json(processedData);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Error loading service data" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get service details by slug - API endpoint
|
||||
*/
|
||||
exports.getServiceBySlug = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
|
||||
const serviceDoc = await Service.findOne().lean();
|
||||
|
||||
if (!serviceDoc) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "Service data not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Find service by slug
|
||||
const service = serviceDoc.services?.items?.find(
|
||||
(item) => item.slug === slug,
|
||||
);
|
||||
|
||||
if (!service) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: `Service with slug '${slug}' not found`,
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
|
||||
|
||||
// Return service details in the expected format
|
||||
const responseData = {
|
||||
pageTitle: serviceDoc.pageTitle,
|
||||
breadcrumb: {
|
||||
...serviceDoc.breadcrumb,
|
||||
title: "Service Details",
|
||||
items: [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Services", href: "/services" },
|
||||
{ label: service.name, href: `/services/${slug}` },
|
||||
],
|
||||
},
|
||||
serviceDetails: {
|
||||
content: service.details,
|
||||
keyFeatures: {
|
||||
title: service.details.keyFeaturesTitle || "Key Features",
|
||||
sideImage: service.details.keyFeaturesImage || "img/default.jpg",
|
||||
items: service.details.features || [],
|
||||
},
|
||||
faq: {
|
||||
title: service.details.faqTitle || "Frequently Asked Questions",
|
||||
sideImage: service.details.faqImage || "img/default.jpg",
|
||||
items: service.details.faq || [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const processedData = addBaseUrlToImages(responseData, baseUrl);
|
||||
res.json(processedData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching service by slug:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate slug from text - API endpoint
|
||||
*/
|
||||
exports.generateSlug = async (req, res) => {
|
||||
try {
|
||||
const { text } = req.body;
|
||||
|
||||
if (!text || typeof text !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Text is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate slug using slugify library with Vietnamese support
|
||||
const slug = slugify(text, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
locale: "vi",
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
slug: slug,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating slug:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all service slugs - API endpoint
|
||||
*/
|
||||
exports.getServiceSlugs = async (req, res) => {
|
||||
try {
|
||||
const serviceDoc = await Service.findOne().lean();
|
||||
|
||||
if (!serviceDoc?.services?.items) {
|
||||
return res.json({
|
||||
success: true,
|
||||
slugs: [],
|
||||
});
|
||||
}
|
||||
|
||||
const slugs = serviceDoc.services.items.map((item) => ({
|
||||
slug: item.slug,
|
||||
name: item.name,
|
||||
id: item.id,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
slugs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching service slugs:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
363
data/service.json
Normal file
@@ -0,0 +1,363 @@
|
||||
{
|
||||
"pageTitle": "Visaway – Immigration & Visa Consulting HTML Template",
|
||||
|
||||
"services": {
|
||||
"title": {
|
||||
"subTitle": "What We Offer",
|
||||
"mainTitle": "Our Immigration Services"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"slug": "immigration-appeal",
|
||||
"name": "Immigration Appeal & Legal Support",
|
||||
"description": "Our experts provide professional guidance for immigration appeals and legal matters, helping clients overcome visa rejections with personalized strategies and strong case representation.",
|
||||
"image": "img/home-3/service/01.jpg",
|
||||
"layout": "left",
|
||||
"details": {
|
||||
"title": "Immigration Appeal & Legal Support",
|
||||
"description": "Our experts provide professional guidance for immigration appeals and legal matters, helping clients overcome visa rejections with personalized strategies and strong case representation. We analyze your case thoroughly and develop custom strategies to maximize your chances of success.",
|
||||
"mainImage": "img/inner-page/service-details/details-1.jpg",
|
||||
"overviewTitle": "Service Overview",
|
||||
"overviewDescription": "Our Immigration Appeal & Legal Support service is designed to help clients navigate complex immigration challenges. We provide expert legal guidance, case analysis, and strategic representation to maximize your chances of success. With our expert consultants, personalized approach, and global network, we ensure a smooth transition for every client.",
|
||||
"additionalDescription": "From start to finish, we are committed to turning your immigration challenges into success stories through professional legal representation and strategic planning.",
|
||||
"keyFeaturesTitle": "Key Features",
|
||||
"keyFeaturesImage": "img/inner-page/service-details/details-2.jpg",
|
||||
"features": [
|
||||
{
|
||||
"title": "Personalized Guidance",
|
||||
"description": "Tailored support for each client's specific legal situation and requirements."
|
||||
},
|
||||
{
|
||||
"title": "Expert Legal Team",
|
||||
"description": "Experienced immigration lawyers with proven track records in appeals."
|
||||
},
|
||||
{
|
||||
"title": "Case Analysis & Strategy",
|
||||
"description": "Thorough case review and development of winning appeal strategies."
|
||||
},
|
||||
{
|
||||
"title": "Document Preparation",
|
||||
"description": "Professional preparation of all legal documents and supporting evidence."
|
||||
},
|
||||
{
|
||||
"title": "Court Representation",
|
||||
"description": "Expert representation in immigration courts and tribunals."
|
||||
},
|
||||
{
|
||||
"title": "Success Monitoring",
|
||||
"description": "Regular updates and monitoring throughout the appeal process."
|
||||
}
|
||||
],
|
||||
"faqTitle": "Frequently Asked Question",
|
||||
"faqImage": "img/inner-page/service-details/details-3.jpg",
|
||||
"faq": [
|
||||
{
|
||||
"id": "faq-appeal-1",
|
||||
"question": "01. What are the chances of a successful appeal?",
|
||||
"answer": "Success rates vary by case type and circumstances, but our experienced legal team significantly improves your chances through thorough case analysis and strategic representation tailored to your specific situation.",
|
||||
"isExpanded": false
|
||||
},
|
||||
{
|
||||
"id": "faq-appeal-2",
|
||||
"question": "02. How long does the appeal process take?",
|
||||
"answer": "Appeal timelines vary by jurisdiction and case complexity, typically ranging from 6-18 months. We keep you informed throughout the process and work to expedite where possible.",
|
||||
"isExpanded": false
|
||||
},
|
||||
{
|
||||
"id": "faq-appeal-3",
|
||||
"question": "03. What documents do I need for an appeal?",
|
||||
"answer": "Required documents vary by case but typically include the original decision, supporting evidence, and legal submissions. We provide a comprehensive checklist and assist with document preparation.",
|
||||
"isExpanded": false
|
||||
},
|
||||
{
|
||||
"id": "faq-appeal-4",
|
||||
"question": "04. Do you handle all types of immigration appeals?",
|
||||
"answer": "Yes, we handle various types of immigration appeals including visa refusals, deportation orders, and residency rejections. Our team has expertise across all immigration categories.",
|
||||
"isExpanded": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "scholarship-guidance",
|
||||
"name": "Scholarship & Study Grant Guidance",
|
||||
"description": "We help students unlock opportunities to study abroad with the right financial support. Our expert advisors guide you in finding scholarships, grants, and funding options that match your academic background, chosen destination, and career goals.",
|
||||
"image": "img/home-3/service/02.jpg",
|
||||
"layout": "right",
|
||||
"details": {
|
||||
"title": "Scholarship & Study Grant Guidance",
|
||||
"description": "We help students unlock opportunities to study abroad with the right financial support. Our expert advisors guide you in finding scholarships, grants, and funding options that match your academic background, chosen destination, and career goals. From preparing strong applications to meeting eligibility criteria, we ensure you maximize your chances of securing financial aid.",
|
||||
"mainImage": "img/inner-page/service-details/details-1.jpg",
|
||||
"overviewTitle": "Service Overview",
|
||||
"overviewDescription": "Our Education Visa Consultancy is dedicated to guiding students in achieving their study abroad dreams. We provide complete support including university selection, application assistance, scholarship guidance, visa documentation, and interview preparation. With our expert consultants, personalized approach, and global network, we ensure a smooth transition for every student.",
|
||||
"additionalDescription": "From start to finish, we are committed to turning your education journey into a successful international experience.",
|
||||
"keyFeaturesTitle": "Key Features",
|
||||
"keyFeaturesImage": "img/inner-page/service-details/details-2.jpg",
|
||||
"features": [
|
||||
{
|
||||
"title": "Personalized Guidance",
|
||||
"description": "Tailored support for each student's goals and requirements."
|
||||
},
|
||||
{
|
||||
"title": "Target Audience & Persona Development",
|
||||
"description": "Experienced team with global education and visa knowledge."
|
||||
},
|
||||
{
|
||||
"title": "Scholarship & Grant Assistance",
|
||||
"description": "Helping students secure financial aid opportunities."
|
||||
},
|
||||
{
|
||||
"title": "Visa Application Support",
|
||||
"description": "Step-by-step guidance for smooth visa processing."
|
||||
},
|
||||
{
|
||||
"title": "Interview Preparation",
|
||||
"description": "Coaching for successful student visa interviews."
|
||||
},
|
||||
{
|
||||
"title": "Documentation Assistance",
|
||||
"description": "Accurate and complete paperwork for faster approvals."
|
||||
}
|
||||
],
|
||||
"faqTitle": "Frequently Asked Question",
|
||||
"faqImage": "img/inner-page/service-details/details-3.jpg",
|
||||
"faq": [
|
||||
{
|
||||
"id": "faq-scholarship-1",
|
||||
"question": "01. Do you assist with university selection?",
|
||||
"answer": "Absolutely! We identify suitable scholarships, guide application processes, and maximize your chances of receiving financial aid.",
|
||||
"isExpanded": false
|
||||
},
|
||||
{
|
||||
"id": "faq-scholarship-2",
|
||||
"question": "02. Can you help with scholarship applications?",
|
||||
"answer": "Absolutely! We identify suitable scholarships, guide application processes, and maximize your chances of receiving financial aid.",
|
||||
"isExpanded": true
|
||||
},
|
||||
{
|
||||
"id": "faq-scholarship-3",
|
||||
"question": "03. How long does the visa process take?",
|
||||
"answer": "Absolutely! We identify suitable scholarships, guide application processes, and maximize your chances of receiving financial aid.",
|
||||
"isExpanded": false
|
||||
},
|
||||
{
|
||||
"id": "faq-scholarship-4",
|
||||
"question": "04. Is post-arrival support available?",
|
||||
"answer": "Absolutely! We identify suitable scholarships, guide application processes, and maximize your chances of receiving financial aid.",
|
||||
"isExpanded": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "permanent-residency",
|
||||
"name": "Permanent Residency (PR) Services",
|
||||
"description": "Our PR services guide clients through every step of the residency process, including documentation, eligibility assessment, and application support, ensuring a smooth and successful approval.",
|
||||
"image": "img/home-3/service/03.jpg",
|
||||
"layout": "left",
|
||||
"details": {
|
||||
"title": "Permanent Residency (PR) Services",
|
||||
"description": "Our PR services guide clients through every step of the residency process, including documentation, eligibility assessment, and application support, ensuring a smooth and successful approval.",
|
||||
"mainImage": "img/inner-page/service-details/details-1.jpg",
|
||||
"overviewTitle": "Service Overview",
|
||||
"overviewDescription": "Our Permanent Residency services provide comprehensive support for individuals seeking to establish permanent residence in their chosen country. We handle all aspects of the PR application process with expertise and care.",
|
||||
"additionalDescription": "Our experienced team ensures that your PR application is handled professionally and efficiently, maximizing your chances of approval.",
|
||||
"keyFeaturesTitle": "Key Features",
|
||||
"keyFeaturesImage": "img/inner-page/service-details/details-2.jpg",
|
||||
"features": [
|
||||
{
|
||||
"title": "Eligibility Assessment",
|
||||
"description": "Comprehensive evaluation of your PR eligibility and options."
|
||||
},
|
||||
{
|
||||
"title": "Points Calculation",
|
||||
"description": "Accurate calculation and optimization of your points score."
|
||||
},
|
||||
{
|
||||
"title": "Document Verification",
|
||||
"description": "Thorough verification and preparation of all required documents."
|
||||
},
|
||||
{
|
||||
"title": "Application Tracking",
|
||||
"description": "Regular updates and tracking of your PR application status."
|
||||
},
|
||||
{
|
||||
"title": "Interview Preparation",
|
||||
"description": "Coaching and preparation for PR interviews if required."
|
||||
},
|
||||
{
|
||||
"title": "Post-Approval Support",
|
||||
"description": "Guidance on next steps after PR approval and settlement."
|
||||
}
|
||||
],
|
||||
"faqTitle": "Frequently Asked Question",
|
||||
"faqImage": "img/inner-page/service-details/details-3.jpg",
|
||||
"faq": [
|
||||
{
|
||||
"id": "faq-pr-1",
|
||||
"question": "01. How long does the PR process take?",
|
||||
"answer": "Processing times vary by country and program, typically ranging from 12-24 months. We provide realistic timelines based on current processing standards.",
|
||||
"isExpanded": false
|
||||
},
|
||||
{
|
||||
"id": "faq-pr-2",
|
||||
"question": "02. What documents are required for PR application?",
|
||||
"answer": "Document requirements vary by country but typically include educational credentials, work experience, language test results, and medical examinations. We provide a complete checklist.",
|
||||
"isExpanded": true
|
||||
},
|
||||
{
|
||||
"id": "faq-pr-3",
|
||||
"question": "03. Can I include my family in the PR application?",
|
||||
"answer": "Yes, most PR programs allow you to include your spouse and dependent children. We help you understand family inclusion requirements and processes.",
|
||||
"isExpanded": false
|
||||
},
|
||||
{
|
||||
"id": "faq-pr-4",
|
||||
"question": "04. What happens if my PR application is rejected?",
|
||||
"answer": "If rejected, we analyze the reasons and explore options including appeals, reapplication, or alternative immigration pathways to achieve your goals.",
|
||||
"isExpanded": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "citizenship-naturalization",
|
||||
"name": "Citizenship & Naturalization Guidance",
|
||||
"description": "We provide expert guidance for citizenship and naturalization processes, assisting clients with documentation, eligibility, and legal procedures to achieve a smooth and successful application.",
|
||||
"image": "img/home-3/service/04.jpg",
|
||||
"layout": "right",
|
||||
"details": {
|
||||
"title": "Citizenship & Naturalization Guidance",
|
||||
"description": "We provide expert guidance for citizenship and naturalization processes, assisting clients with documentation, eligibility, and legal procedures to achieve a smooth and successful application.",
|
||||
"mainImage": "img/inner-page/service-details/details-1.jpg",
|
||||
"overviewTitle": "Service Overview",
|
||||
"overviewDescription": "Our Citizenship & Naturalization service helps individuals navigate the complex process of becoming a citizen. We provide step-by-step guidance, documentation support, and legal expertise throughout the entire process.",
|
||||
"additionalDescription": "With our comprehensive approach, we make the path to citizenship clear, manageable, and successful for every client.",
|
||||
"keyFeaturesTitle": "Key Features",
|
||||
"keyFeaturesImage": "img/inner-page/service-details/details-2.jpg",
|
||||
"features": [
|
||||
{
|
||||
"title": "Citizenship Test Preparation",
|
||||
"description": "Comprehensive preparation for citizenship knowledge tests."
|
||||
},
|
||||
{
|
||||
"title": "Language Requirements",
|
||||
"description": "Guidance on meeting language proficiency requirements."
|
||||
},
|
||||
{
|
||||
"title": "Residency Verification",
|
||||
"description": "Assistance with proving residency and physical presence requirements."
|
||||
},
|
||||
{
|
||||
"title": "Application Processing",
|
||||
"description": "Complete support throughout the citizenship application process."
|
||||
},
|
||||
{
|
||||
"title": "Interview Coaching",
|
||||
"description": "Preparation and coaching for citizenship interviews."
|
||||
},
|
||||
{
|
||||
"title": "Ceremony Preparation",
|
||||
"description": "Support and guidance for the citizenship ceremony process."
|
||||
}
|
||||
],
|
||||
"faqTitle": "Frequently Asked Question",
|
||||
"faqImage": "img/inner-page/service-details/details-3.jpg",
|
||||
"faq": [
|
||||
{
|
||||
"id": "faq-citizenship-1",
|
||||
"question": "What are the basic requirements for citizenship?",
|
||||
"answer": "Requirements typically include permanent residency, physical presence, language proficiency, and knowledge of the country's history and government. Specific requirements vary by country.",
|
||||
"isExpanded": false
|
||||
},
|
||||
{
|
||||
"id": "faq-citizenship-2",
|
||||
"question": "How do I prepare for the citizenship test?",
|
||||
"answer": "We provide comprehensive study materials, practice tests, and coaching sessions to help you prepare for both the knowledge test and language requirements.",
|
||||
"isExpanded": false
|
||||
},
|
||||
{
|
||||
"id": "faq-citizenship-3",
|
||||
"question": "How long does the citizenship process take?",
|
||||
"answer": "Processing times vary by country but typically range from 12-24 months from application to ceremony. We help you understand specific timelines for your situation.",
|
||||
"isExpanded": false
|
||||
},
|
||||
{
|
||||
"id": "faq-citizenship-4",
|
||||
"question": "Can I maintain dual citizenship?",
|
||||
"answer": "Dual citizenship policies vary by country. We help you understand the implications and requirements for maintaining multiple citizenships if applicable.",
|
||||
"isExpanded": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"destinations": {
|
||||
"backgroundImage": "img/home-3/choose-us/bg.png",
|
||||
"title": {
|
||||
"subTitle": "Countries we offer",
|
||||
"mainTitle": "Choose Your Immigration Destination"
|
||||
}
|
||||
},
|
||||
|
||||
"visas": {
|
||||
"items": [
|
||||
{
|
||||
"id": "family-visa",
|
||||
"number": "01",
|
||||
"name": "Family Visa",
|
||||
"description": "Our Family Visa services help reunite loved ones by providing expert guidance.",
|
||||
"buttonText": "service _ 02",
|
||||
"buttonLink": "service-details.html"
|
||||
},
|
||||
{
|
||||
"id": "student-visa",
|
||||
"number": "02",
|
||||
"name": "Student Visa",
|
||||
"description": "We provide expert guidance for student visa applications.",
|
||||
"buttonText": "service _ 02",
|
||||
"buttonLink": "service-details.html"
|
||||
},
|
||||
{
|
||||
"id": "work-visa",
|
||||
"number": "03",
|
||||
"name": "Work Visa",
|
||||
"description": "Collaboratively disintermediate one to one functionalities and long term.",
|
||||
"buttonText": "service _ 02",
|
||||
"buttonLink": "service-details.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"reviews": {
|
||||
"title": {
|
||||
"subTitle": "What Our Clients Say",
|
||||
"mainTitle": "Immigration Success Stories"
|
||||
},
|
||||
"thumb": "img/home-3/test-thumb.jpg",
|
||||
"items": [
|
||||
{
|
||||
"id": "client-review-1",
|
||||
"rating": 5,
|
||||
"content": "The team provided exceptional guidance throughout my immigration process.",
|
||||
"author": {
|
||||
"name": "Mohammed Ali,",
|
||||
"type": "Family Visa"
|
||||
},
|
||||
"icon": "fa-solid fa-quote-right"
|
||||
},
|
||||
{
|
||||
"id": "client-review-2",
|
||||
"rating": 5,
|
||||
"content": "Their expertise and personalized support ensured a smooth visa approval.",
|
||||
"author": {
|
||||
"name": "Sarah Johnson,",
|
||||
"type": "Student Visa"
|
||||
},
|
||||
"icon": "fa-solid fa-quote-right"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
118
models/service.js
Normal file
@@ -0,0 +1,118 @@
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
// Define sub-schemas first
|
||||
const authorSchema = new mongoose.Schema(
|
||||
{
|
||||
name: String,
|
||||
type: String,
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const clientReviewSchema = new mongoose.Schema(
|
||||
{
|
||||
id: String,
|
||||
rating: Number,
|
||||
content: String,
|
||||
author: authorSchema,
|
||||
icon: String,
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const featureSchema = new mongoose.Schema(
|
||||
{
|
||||
title: String,
|
||||
description: String,
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const faqSchema = new mongoose.Schema(
|
||||
{
|
||||
id: String,
|
||||
question: String,
|
||||
answer: String,
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const serviceDetailsSchema = new mongoose.Schema(
|
||||
{
|
||||
title: String,
|
||||
description: String,
|
||||
mainImage: String,
|
||||
overviewTitle: String,
|
||||
overviewDescription: String,
|
||||
additionalDescription: String,
|
||||
keyFeaturesTitle: String,
|
||||
keyFeaturesImage: String,
|
||||
features: [featureSchema],
|
||||
faqTitle: String,
|
||||
faqImage: String,
|
||||
faq: [faqSchema],
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Main service page schema
|
||||
const serviceSchema = new mongoose.Schema(
|
||||
{
|
||||
pageTitle: String,
|
||||
|
||||
// Main services section
|
||||
services: {
|
||||
title: {
|
||||
subTitle: String,
|
||||
mainTitle: String,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
slug: String,
|
||||
name: String,
|
||||
description: String,
|
||||
image: String,
|
||||
layout: String,
|
||||
details: serviceDetailsSchema,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Destination countries section
|
||||
destinations: {
|
||||
backgroundImage: String,
|
||||
title: {
|
||||
subTitle: String,
|
||||
mainTitle: String,
|
||||
},
|
||||
},
|
||||
|
||||
// Visa types section
|
||||
visas: {
|
||||
items: [
|
||||
{
|
||||
id: String,
|
||||
number: String,
|
||||
name: String,
|
||||
description: String,
|
||||
buttonText: String,
|
||||
buttonLink: String,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Client reviews section
|
||||
reviews: {
|
||||
title: {
|
||||
subTitle: String,
|
||||
mainTitle: String,
|
||||
},
|
||||
thumb: String,
|
||||
items: [clientReviewSchema],
|
||||
},
|
||||
},
|
||||
{ timestamps: true },
|
||||
);
|
||||
|
||||
module.exports = mongoose.model("Service", serviceSchema);
|
||||
BIN
public/img/default.jpg
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/uploads/service/03.jpg
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/uploads/service/404.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
public/uploads/service/Dell_Inspiron15.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/uploads/service/intro.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/service/iphone15.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/uploads/service/smart_samsung_55inch.jpg
Normal file
|
After Width: | Height: | Size: 61 KiB |
@@ -23,6 +23,7 @@ const insuranceController = require("../controllers/insuranceController");
|
||||
|
||||
const activityController = require("../controllers/activityController");
|
||||
const bookingSubmissionController = require("../controllers/bookingSubmissionController");
|
||||
const serviceController = require("../controllers/serviceController");
|
||||
|
||||
// Blog controllers
|
||||
const blogController = require("../controllers/blogController");
|
||||
@@ -384,6 +385,35 @@ router.post(
|
||||
ensureAuthenticated,
|
||||
insuranceController.update,
|
||||
);
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
|
||||
// Service routes
|
||||
router.get("/service", ensureAuthenticated, serviceController.index);
|
||||
router.post("/service/update", ensureAuthenticated, serviceController.update);
|
||||
router.post(
|
||||
"/service/generate-slug",
|
||||
ensureAuthenticated,
|
||||
serviceController.generateSlug,
|
||||
);
|
||||
router.get("/service/:slug/edit", ensureAuthenticated, serviceController.edit);
|
||||
router.post(
|
||||
"/service/:slug/edit",
|
||||
ensureAuthenticated,
|
||||
serviceController.updateService,
|
||||
);
|
||||
router.get(
|
||||
"/service/:slug/details",
|
||||
ensureAuthenticated,
|
||||
serviceController.details,
|
||||
);
|
||||
router.post(
|
||||
"/service/:slug/details/update",
|
||||
ensureAuthenticated,
|
||||
serviceController.updateDetails,
|
||||
);
|
||||
|
||||
>>>>>>> a255d09ef0a6eb0c487595aac19cefbf729d78a2
|
||||
// Test Image Paths route
|
||||
router.get("/test-images", ensureAuthenticated, (req, res) => {
|
||||
const fs = require("fs");
|
||||
@@ -479,51 +509,21 @@ router.post("/blog/:id/edit", ensureAuthenticated, blogController.update);
|
||||
router.post("/blog/:id/delete", ensureAuthenticated, blogController.destroy);
|
||||
|
||||
// Blog Categories Management
|
||||
router.get(
|
||||
"/blog/categories",
|
||||
ensureAuthenticated,
|
||||
blogCategoryController.index,
|
||||
);
|
||||
router.get(
|
||||
"/blog/categories/create",
|
||||
ensureAuthenticated,
|
||||
blogCategoryController.create,
|
||||
);
|
||||
router.post(
|
||||
"/blog/categories/create",
|
||||
ensureAuthenticated,
|
||||
blogCategoryController.store,
|
||||
);
|
||||
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.get("/blog/categories", ensureAuthenticated, blogCategoryController.index);
|
||||
router.get("/blog/categories/create", ensureAuthenticated, blogCategoryController.create);
|
||||
router.post("/blog/categories/create", ensureAuthenticated, blogCategoryController.store);
|
||||
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);
|
||||
router.get("/blog/tags/create", ensureAuthenticated, blogTagController.create);
|
||||
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/: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;
|
||||
|
||||
@@ -19,6 +19,7 @@ const activityController = require("../controllers/activityController");
|
||||
const travelController = require("../controllers/travelController");
|
||||
const bookingSubmissionController = require("../controllers/bookingSubmissionController");
|
||||
|
||||
const serviceController = require("../controllers/serviceController");
|
||||
// Blog controllers
|
||||
const blogController = require("../controllers/blogController");
|
||||
const blogCategoryController = require("../controllers/blogCategoryController");
|
||||
@@ -167,20 +168,26 @@ 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);
|
||||
|
||||
// // API route cho blog detail
|
||||
// router.get('/api/blog-detail', blogDetailController.api);
|
||||
|
||||
// ==================== PUBLIC API ROUTES ====================
|
||||
/* CMS - Hailearning
|
||||
*/
|
||||
// service
|
||||
router.get("/service", serviceController.index);
|
||||
router.post("/service", serviceController.update);
|
||||
router.get("/api/service", serviceController.api);
|
||||
|
||||
// 1. Đưa các route cụ thể (chi tiết nhất) lên đầu tiên
|
||||
// 2. Route lấy TOÀN BỘ dữ liệu (phải nằm trên route :slug)
|
||||
router.get("/api/visa", visaController.api);
|
||||
router.get("/api/visa/hero", visaController.apiHero);
|
||||
router.get("/api/visa/countries", visaController.apiCountries);
|
||||
// Service details by slug
|
||||
router.get("/api/service/:slug", serviceController.getServiceBySlug);
|
||||
|
||||
// Service slugs list
|
||||
router.get("/api/service-slugs", serviceController.getServiceSlugs);
|
||||
|
||||
// 3. Route lấy chi tiết theo slug (luôn để dưới cùng của nhóm này)
|
||||
router.get("/api/visa/:slug", visaController.apiCountry);
|
||||
module.exports = router;
|
||||
|
||||
186
scripts/2026_02_02_131615_service.js
Normal file
@@ -0,0 +1,186 @@
|
||||
require("dotenv").config();
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
const connectDB = require("../config/database");
|
||||
const Service = require("../models/service");
|
||||
|
||||
/**
|
||||
* Transform service.json data to match Service schema
|
||||
*/
|
||||
function transformServiceData(sourceData) {
|
||||
return {
|
||||
pageTitle: sourceData.pageTitle || "",
|
||||
|
||||
// Breadcrumb navigation section
|
||||
breadcrumb: {
|
||||
title: sourceData?.breadcrumb?.title || "",
|
||||
backgroundImage: sourceData?.breadcrumb?.backgroundImage || "",
|
||||
shape: sourceData?.breadcrumb?.shape || "",
|
||||
items: Array.isArray(sourceData?.breadcrumb?.items)
|
||||
? sourceData.breadcrumb.items.map((item) => ({
|
||||
label: item.label || "",
|
||||
href: item.href || "",
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
|
||||
// Main services section
|
||||
services: {
|
||||
title: {
|
||||
subTitle: sourceData?.services?.title?.subTitle || "",
|
||||
mainTitle: sourceData?.services?.title?.mainTitle || "",
|
||||
},
|
||||
items: Array.isArray(sourceData?.services?.items)
|
||||
? sourceData.services.items.map((service) => ({
|
||||
slug: service.slug || "",
|
||||
name: service.name || "",
|
||||
description: service.description || "",
|
||||
image: service.image || "",
|
||||
layout: service.layout || "",
|
||||
details: {
|
||||
title: service.details?.title || "",
|
||||
description: service.details?.description || "",
|
||||
mainImage: service.details?.mainImage || "",
|
||||
overviewTitle: service.details?.overviewTitle || "",
|
||||
overviewDescription: service.details?.overviewDescription || "",
|
||||
additionalDescription:
|
||||
service.details?.additionalDescription || "",
|
||||
keyFeaturesTitle: service.details?.keyFeaturesTitle || "",
|
||||
keyFeaturesImage: service.details?.keyFeaturesImage || "",
|
||||
features: Array.isArray(service.details?.features)
|
||||
? service.details.features.map((feature) => ({
|
||||
icon: feature.icon || "",
|
||||
title: feature.title || "",
|
||||
description: feature.description || "",
|
||||
}))
|
||||
: [],
|
||||
faqTitle: service.details?.faqTitle || "",
|
||||
faqImage: service.details?.faqImage || "",
|
||||
faq: Array.isArray(service.details?.faq)
|
||||
? service.details.faq.map((faqItem) => ({
|
||||
id: faqItem.id || "",
|
||||
question: faqItem.question || "",
|
||||
answer: faqItem.answer || "",
|
||||
isExpanded: faqItem.isExpanded || false,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
|
||||
// Destination countries section
|
||||
destinations: {
|
||||
backgroundImage: sourceData?.destinations?.backgroundImage || "",
|
||||
title: {
|
||||
subTitle: sourceData?.destinations?.title?.subTitle || "",
|
||||
mainTitle: sourceData?.destinations?.title?.mainTitle || "",
|
||||
},
|
||||
items: Array.isArray(sourceData?.destinations?.items)
|
||||
? sourceData.destinations.items.map((country) => ({
|
||||
id: country.id || "",
|
||||
name: country.name || "",
|
||||
description: country.description || "",
|
||||
image: country.image || "",
|
||||
icon: country.icon || "",
|
||||
link: country.link || "",
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
|
||||
// Visa types section
|
||||
visas: {
|
||||
items: Array.isArray(sourceData?.visas?.items)
|
||||
? sourceData.visas.items.map((visa) => ({
|
||||
id: visa.id || "",
|
||||
number: visa.number || "",
|
||||
name: visa.name || "",
|
||||
description: visa.description || "",
|
||||
buttonText: visa.buttonText || "",
|
||||
buttonLink: visa.buttonLink || "",
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
|
||||
// Client reviews section
|
||||
reviews: {
|
||||
title: {
|
||||
subTitle: sourceData?.reviews?.title?.subTitle || "",
|
||||
mainTitle: sourceData?.reviews?.title?.mainTitle || "",
|
||||
},
|
||||
viewAllButton: {
|
||||
text: sourceData?.reviews?.viewAllButton?.text || "",
|
||||
icon: sourceData?.reviews?.viewAllButton?.icon || "",
|
||||
link: sourceData?.reviews?.viewAllButton?.link || "",
|
||||
},
|
||||
thumb: sourceData?.reviews?.thumb || "",
|
||||
items: Array.isArray(sourceData?.reviews?.items)
|
||||
? sourceData.reviews.items.map((review) => ({
|
||||
id: review.id || "",
|
||||
rating: review.rating || 5,
|
||||
content: review.content || "",
|
||||
author: {
|
||||
name: review.author?.name || "",
|
||||
type: review.author?.type || "",
|
||||
},
|
||||
icon: review.icon || "",
|
||||
}))
|
||||
: [],
|
||||
navigation: {
|
||||
prevButton: sourceData?.reviews?.navigation?.prevButton || "",
|
||||
nextButton: sourceData?.reviews?.navigation?.nextButton || "",
|
||||
prevIcon: sourceData?.reviews?.navigation?.prevIcon || "",
|
||||
nextIcon: sourceData?.reviews?.navigation?.nextIcon || "",
|
||||
},
|
||||
},
|
||||
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration function for service page data
|
||||
*/
|
||||
async function migrateServiceData() {
|
||||
try {
|
||||
await connectDB();
|
||||
console.log("🚀 Starting service page migration...");
|
||||
|
||||
// Clear existing service documents
|
||||
await Service.deleteMany({});
|
||||
console.log("🗑️ Cleared existing service documents");
|
||||
|
||||
// Read service.json file
|
||||
const serviceJsonPath = path.join(__dirname, "..", "data", "service.json");
|
||||
const rawJsonData = await fs.readFile(serviceJsonPath, "utf8");
|
||||
const sourceServiceData = JSON.parse(rawJsonData);
|
||||
|
||||
// Transform data to match schema
|
||||
const transformedServiceData = transformServiceData(sourceServiceData);
|
||||
|
||||
// Create new service document
|
||||
const newService = new Service(transformedServiceData);
|
||||
const savedService = await newService.save();
|
||||
|
||||
console.log("✅ Service page migration completed successfully!");
|
||||
console.log(`📄 Service document ID: ${savedService._id}`);
|
||||
|
||||
await mongoose.disconnect();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("❌ Service migration error:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run migration if called directly
|
||||
if (require.main === module) {
|
||||
migrateServiceData();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
migrate: migrateServiceData,
|
||||
transformServiceData,
|
||||
};
|
||||
43
services/service.service.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const Service = require("../models/service");
|
||||
|
||||
const getServiceData = async () => {
|
||||
const service = await Service.findOne().sort({ updatedAt: -1 });
|
||||
console.log("check layout", service.services.items.layout);
|
||||
|
||||
if (!service) {
|
||||
return {
|
||||
pageTitle: "",
|
||||
services: {
|
||||
title: {
|
||||
subTitle: "",
|
||||
mainTitle: "",
|
||||
},
|
||||
items: [],
|
||||
},
|
||||
destinations: {
|
||||
backgroundImage: "",
|
||||
title: {
|
||||
subTitle: "",
|
||||
mainTitle: "",
|
||||
},
|
||||
},
|
||||
visas: {
|
||||
items: [],
|
||||
},
|
||||
reviews: {
|
||||
title: {
|
||||
subTitle: "",
|
||||
mainTitle: "",
|
||||
},
|
||||
thumb: "",
|
||||
items: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return service;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getServiceData,
|
||||
};
|
||||
@@ -5,23 +5,23 @@
|
||||
*/
|
||||
function addBaseUrlToImages(data, baseUrl) {
|
||||
// baseUrl can be passed explicitly (e.g., from req), otherwise fall back to env
|
||||
const BACKEND_URL = baseUrl || process.env.BACKEND_URL || '';
|
||||
const BACKEND_URL = baseUrl || process.env.BACKEND_URL || "";
|
||||
|
||||
// Tạo bản sao sâu để tránh thay đổi dữ liệu gốc
|
||||
const processedData = JSON.parse(JSON.stringify(data));
|
||||
|
||||
// Hàm đệ quy để xử lý tất cả các URL hình ảnh trong đối tượng
|
||||
const processObject = (obj) => {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
Object.keys(obj).forEach(key => {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
// Kiểm tra nếu thuộc tính chứa đường dẫn hình ảnh bắt đầu bằng /uploads/
|
||||
if (typeof obj[key] === 'string' && obj[key].startsWith('/uploads/')) {
|
||||
if (typeof obj[key] === "string" && obj[key].startsWith("/uploads/")) {
|
||||
// Thêm BACKEND_URL nếu đường dẫn chưa có http
|
||||
if (!obj[key].startsWith('http')) {
|
||||
if (!obj[key].startsWith("http")) {
|
||||
obj[key] = `${BACKEND_URL}${obj[key]}`;
|
||||
}
|
||||
} else if (typeof obj[key] === 'object') {
|
||||
} else if (typeof obj[key] === "object") {
|
||||
// Đệ quy xử lý các đối tượng và mảng lồng nhau
|
||||
processObject(obj[key]);
|
||||
}
|
||||
@@ -32,6 +32,37 @@ function addBaseUrlToImages(data, baseUrl) {
|
||||
return processedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tạo full URL cho ảnh từ đường dẫn tương đối - dùng cho EJS templates
|
||||
* @param {string} imagePath - Đường dẫn ảnh
|
||||
* @param {string} backendUrl - Backend URL (optional, sẽ lấy từ env nếu không có)
|
||||
* @returns {string} - Full URL của ảnh
|
||||
*/
|
||||
function getFullImageUrl(imagePath, backendUrl = null) {
|
||||
if (!imagePath) return "";
|
||||
|
||||
// Nếu đã là full URL thì return luôn
|
||||
if (imagePath.startsWith("http")) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
// Lấy backend URL
|
||||
const baseUrl = (
|
||||
backendUrl ||
|
||||
process.env.BACKEND_URL ||
|
||||
"http://localhost:3001"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
// Xử lý đường dẫn
|
||||
let imgSrc = imagePath;
|
||||
if (!imgSrc.startsWith("/")) {
|
||||
imgSrc = "/" + imgSrc;
|
||||
}
|
||||
|
||||
return baseUrl + imgSrc;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addBaseUrlToImages
|
||||
addBaseUrlToImages,
|
||||
getFullImageUrl,
|
||||
};
|
||||
1008
views/admin/blog/create.ejs
Normal file
1152
views/admin/blog/edit.ejs
Normal file
300
views/admin/blog/index.ejs
Normal 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"> </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"> </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>
|
||||
@@ -4,7 +4,7 @@
|
||||
<!-- Quick Actions -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0" style="color: var(--primary-color);">Quick Management</h5>
|
||||
<h5 class="mb-0" style="color: var(--primary-color)">Quick Management</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="row g-0">
|
||||
@@ -62,8 +62,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="col-md-4 border-end border-top">
|
||||
<div class="p-4">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
@@ -166,7 +164,10 @@
|
||||
<p class="text-muted mb-0 small">Manage terms</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/admin/terms-conditions" class="btn btn-sm btn-primary w-100 mt-2">
|
||||
<a
|
||||
href="/admin/terms-conditions"
|
||||
class="btn btn-sm btn-primary w-100 mt-2"
|
||||
>
|
||||
<i class="fas fa-edit me-2"></i>Edit
|
||||
</a>
|
||||
</div>
|
||||
@@ -220,7 +221,10 @@
|
||||
<p class="text-muted mb-0 small">Manage camp location</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/admin/camp-location" class="btn btn-sm btn-primary w-100 mt-2">
|
||||
<a
|
||||
href="/admin/camp-location"
|
||||
class="btn btn-sm btn-primary w-100 mt-2"
|
||||
>
|
||||
<i class="fas fa-edit me-2"></i>Edit
|
||||
</a>
|
||||
</div>
|
||||
@@ -243,7 +247,32 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 border-end border-top">
|
||||
<div class="p-4">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div
|
||||
class="rounded-circle d-flex align-items-center justify-content-center me-3"
|
||||
style="
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-color: rgba(184, 183, 106, 0.1);
|
||||
"
|
||||
>
|
||||
<i
|
||||
class="fas fa-running fa-lg"
|
||||
style="color: var(--primary-color)"
|
||||
></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="mb-0">Services</h5>
|
||||
<p class="text-muted mb-0 small">Manage services</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/admin/service" class="btn btn-sm btn-primary w-100 mt-2">
|
||||
<i class="fas fa-edit me-2"></i>Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,10 +307,20 @@
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/header</code></td>
|
||||
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td>
|
||||
<td>
|
||||
<span
|
||||
class="badge"
|
||||
style="background-color: var(--primary-color)"
|
||||
>GET</span
|
||||
>
|
||||
</td>
|
||||
<td>API to get menu header data</td>
|
||||
<td>
|
||||
<a href="/api/header" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<a
|
||||
href="/api/header"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
@@ -297,10 +336,20 @@
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/home</code></td>
|
||||
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td>
|
||||
<td>
|
||||
<span
|
||||
class="badge"
|
||||
style="background-color: var(--primary-color)"
|
||||
>GET</span
|
||||
>
|
||||
</td>
|
||||
<td>API to get homepage data</td>
|
||||
<td>
|
||||
<a href="/api/home" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<a
|
||||
href="/api/home"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
@@ -316,10 +365,20 @@
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/about</code></td>
|
||||
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td>
|
||||
<td>
|
||||
<span
|
||||
class="badge"
|
||||
style="background-color: var(--primary-color)"
|
||||
>GET</span
|
||||
>
|
||||
</td>
|
||||
<td>API to get about page data</td>
|
||||
<td>
|
||||
<a href="/api/about" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<a
|
||||
href="/api/about"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
@@ -335,10 +394,20 @@
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/about-us</code></td>
|
||||
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td>
|
||||
<td>
|
||||
<span
|
||||
class="badge"
|
||||
style="background-color: var(--primary-color)"
|
||||
>GET</span
|
||||
>
|
||||
</td>
|
||||
<td>API to get about us data</td>
|
||||
<td>
|
||||
<a href="/api/about-us" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<a
|
||||
href="/api/about-us"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
@@ -354,10 +423,20 @@
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/faq</code></td>
|
||||
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td>
|
||||
<td>
|
||||
<span
|
||||
class="badge"
|
||||
style="background-color: var(--primary-color)"
|
||||
>GET</span
|
||||
>
|
||||
</td>
|
||||
<td>API to get FAQ data</td>
|
||||
<td>
|
||||
<a href="/api/faq" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<a
|
||||
href="/api/faq"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
@@ -373,10 +452,20 @@
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/terms</code></td>
|
||||
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td>
|
||||
<td>
|
||||
<span
|
||||
class="badge"
|
||||
style="background-color: var(--primary-color)"
|
||||
>GET</span
|
||||
>
|
||||
</td>
|
||||
<td>API to get terms & conditions data</td>
|
||||
<td>
|
||||
<a href="/api/terms" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<a
|
||||
href="/api/terms"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
@@ -392,10 +481,20 @@
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/travel</code></td>
|
||||
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td>
|
||||
<td>
|
||||
<span
|
||||
class="badge"
|
||||
style="background-color: var(--primary-color)"
|
||||
>GET</span
|
||||
>
|
||||
</td>
|
||||
<td>API to get travel data</td>
|
||||
<td>
|
||||
<a href="/api/travel" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<a
|
||||
href="/api/travel"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
@@ -411,10 +510,20 @@
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/safety</code></td>
|
||||
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td>
|
||||
<td>
|
||||
<span
|
||||
class="badge"
|
||||
style="background-color: var(--primary-color)"
|
||||
>GET</span
|
||||
>
|
||||
</td>
|
||||
<td>API to get safety data</td>
|
||||
<td>
|
||||
<a href="/api/safety" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<a
|
||||
href="/api/safety"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
@@ -430,10 +539,20 @@
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/camp-location</code></td>
|
||||
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td>
|
||||
<td>
|
||||
<span
|
||||
class="badge"
|
||||
style="background-color: var(--primary-color)"
|
||||
>GET</span
|
||||
>
|
||||
</td>
|
||||
<td>API to get camp location data</td>
|
||||
<td>
|
||||
<a href="/api/camp-location" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<a
|
||||
href="/api/camp-location"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
@@ -450,10 +569,20 @@
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/menu-tree</code></td>
|
||||
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td>
|
||||
<td>
|
||||
<span
|
||||
class="badge"
|
||||
style="background-color: var(--primary-color)"
|
||||
>GET</span
|
||||
>
|
||||
</td>
|
||||
<td>API to get menu tree data</td>
|
||||
<td>
|
||||
<a href="/api/menu-tree" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<a
|
||||
href="/api/menu-tree"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
@@ -469,10 +598,20 @@
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/contact</code></td>
|
||||
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td>
|
||||
<td>
|
||||
<span
|
||||
class="badge"
|
||||
style="background-color: var(--primary-color)"
|
||||
>GET</span
|
||||
>
|
||||
</td>
|
||||
<td>API to get contact data</td>
|
||||
<td>
|
||||
<a href="/api/contact" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<a
|
||||
href="/api/contact"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
@@ -488,10 +627,20 @@
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/camp-location</code></td>
|
||||
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td>
|
||||
<td>
|
||||
<span
|
||||
class="badge"
|
||||
style="background-color: var(--primary-color)"
|
||||
>GET</span
|
||||
>
|
||||
</td>
|
||||
<td>API to get camp location data</td>
|
||||
<td>
|
||||
<a href="/api/camp-location" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<a
|
||||
href="/api/camp-location"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
@@ -504,10 +653,16 @@
|
||||
|
||||
<!-- System Info -->
|
||||
<div class="card">
|
||||
<div class="card-header" style="background: linear-gradient(135deg, #b8b76a, #9a994a); color: white;">
|
||||
<div
|
||||
class="card-header"
|
||||
style="
|
||||
background: linear-gradient(135deg, #b8b76a, #9a994a);
|
||||
color: white;
|
||||
"
|
||||
>
|
||||
<h5 class="mb-0">System Information</h5>
|
||||
</div>
|
||||
<div class="card-body" style="background-color: #f8faf8;">
|
||||
<div class="card-body" style="background-color: #f8faf8">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
@@ -517,7 +672,9 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted small">Version</div>
|
||||
<div class="fw-bold" style="color: var(--primary-color);">CMS-SIMS v1.0.0</div>
|
||||
<div class="fw-bold" style="color: var(--primary-color)">
|
||||
CMS-SIMS v1.0.0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -542,11 +699,16 @@
|
||||
style="background-color: rgba(184, 183, 106, 0.05); border-left: 4px solid var(--primary-color);" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="me-3">
|
||||
<i class="fas fa-lightbulb fa-lg" style="color: var(--primary-color);"></i>
|
||||
<i
|
||||
class="fas fa-lightbulb fa-lg"
|
||||
style="color: var(--primary-color)"
|
||||
></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="mb-1" style="color: var(--primary-color);">Quick Tip</h6>
|
||||
<p class="mb-0 text-muted">Click the Edit button to make changes to your data.</p>
|
||||
<h6 class="mb-1" style="color: var(--primary-color)">Quick Tip</h6>
|
||||
<p class="mb-0 text-muted">
|
||||
Click the Edit button to make changes to your data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
723
views/admin/service/details.ejs
Normal file
@@ -0,0 +1,723 @@
|
||||
<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);">Service Details: <%= service.name %></h1>
|
||||
<p class="text-muted mb-0">Edit detailed content for <%= service.name %> service</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/admin/service" class="btn btn-outline-primary">
|
||||
<i class="fas fa-arrow-left me-2"></i>Back to Services
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="/admin/service/<%= service.slug %>/details/update" method="POST" class="content-with-fixed-buttons" id="serviceDetailsForm">
|
||||
<!-- Hidden inputs for JSON data -->
|
||||
<input type="hidden" name="details" id="detailsJson">
|
||||
<input type="hidden" name="features" id="featuresJson">
|
||||
<input type="hidden" name="faq" id="faqJson">
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" data-bs-toggle="tab" href="#basic-info" role="tab">
|
||||
<i class="fas fa-info-circle me-2"></i>Basic Information
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#key-features" role="tab">
|
||||
<i class="fas fa-star me-2"></i>Key Features
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#faq-section" role="tab">
|
||||
<i class="fas fa-question-circle me-2"></i>FAQ Section
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
<!-- Basic Information Tab -->
|
||||
<div class="tab-pane fade show active" id="basic-info" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label fw-medium">Main Image</label>
|
||||
<div class="input-group mb-2">
|
||||
<input type="text" class="form-control" id="mainImage" name="mainImage"
|
||||
value="<%= service.details?.mainImage || '' %>">
|
||||
<button type="button" class="btn btn-outline-primary btn-upload-image"
|
||||
data-target-input="mainImage" data-image-type="service">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-text text-muted">Recommended size: 800x600px</small>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<div id="mainImagePreview">
|
||||
<% if (service.details?.mainImage) { %>
|
||||
<img src="<%= getFullImageUrl(service.details.mainImage) %>" class="img-thumbnail" style="max-height: 250px; width: auto; max-width: 100%; object-fit: contain;" alt="Main image preview">
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-medium">Title</label>
|
||||
<input type="text" class="form-control" id="detailsTitle" name="title"
|
||||
value="<%= service.details?.title || service.name %>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-medium">Description</label>
|
||||
<textarea class="form-control" id="detailsDescription" name="description" rows="3"><%= service.details?.description || service.description %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview Section -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card border">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0">Overview Section</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Overview Title</label>
|
||||
<input type="text" class="form-control" id="overviewTitle" name="overviewTitle"
|
||||
value="<%= service.details?.overviewTitle || 'Service Overview' %>">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Overview Description</label>
|
||||
<textarea class="form-control" id="overviewDescription" name="overviewDescription" rows="4"><%= service.details?.overviewDescription || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Additional Description</label>
|
||||
<textarea class="form-control" id="additionalDescription" name="additionalDescription" rows="3"><%= service.details?.additionalDescription || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Features Tab -->
|
||||
<div class="tab-pane fade" id="key-features" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-body">
|
||||
<!-- Features Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Features Title</label>
|
||||
<input type="text" class="form-control" id="keyFeaturesTitle" name="keyFeaturesTitle"
|
||||
value="<%= service.details?.keyFeaturesTitle || 'Key Features' %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Features Image</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="keyFeaturesImage" name="keyFeaturesImage"
|
||||
value="<%= service.details?.keyFeaturesImage || '' %>">
|
||||
<button type="button" class="btn btn-outline-primary btn-upload-image"
|
||||
data-target-input="keyFeaturesImage" data-image-type="service">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Features Image Preview -->
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<div id="keyFeaturesImagePreview">
|
||||
<% if (service.details?.keyFeaturesImage) { %>
|
||||
<img src="<%= getFullImageUrl(service.details.keyFeaturesImage) %>" class="img-thumbnail" style="max-height: 180px; width: auto; max-width: 100%; object-fit: contain;" alt="Key Features image preview">
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features List -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="fw-medium mb-0">Features</h6>
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="addFeature()">
|
||||
<i class="fas fa-plus"></i> Add Feature
|
||||
</button>
|
||||
</div>
|
||||
<div id="featuresContainer">
|
||||
<% if (service.details?.features && service.details.features.length > 0) { %>
|
||||
<% service.details.features.forEach((feature, index) => { %>
|
||||
<div class="card mb-3 feature-item">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0 text-decoration-underline">Feature <%= index + 1 %></h6>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="removeFeature(this)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" class="form-control feature-title"
|
||||
value="<%= feature.title || '' %>" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control feature-description" rows="2" required><%= feature.description || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAQ Section Tab -->
|
||||
<div class="tab-pane fade" id="faq-section" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-body">
|
||||
<!-- FAQ Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">FAQ Title</label>
|
||||
<input type="text" class="form-control" id="faqTitle" name="faqTitle"
|
||||
value="<%= service.details?.faqTitle || 'Frequently Asked Questions' %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">FAQ Image</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="faqImage" name="faqImage"
|
||||
value="<%= service.details?.faqImage || '' %>">
|
||||
<button type="button" class="btn btn-outline-primary btn-upload-image"
|
||||
data-target-input="faqImage" data-image-type="service">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAQ Image Preview -->
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<div id="faqImagePreview">
|
||||
<% if (service.details?.faqImage) { %>
|
||||
<img src="<%= getFullImageUrl(service.details.faqImage) %>" class="img-thumbnail" style="max-height: 180px; width: auto; max-width: 100%; object-fit: contain;" alt="FAQ image preview">
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAQ List -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="fw-medium mb-0">FAQ Items</h6>
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="addFAQ()">
|
||||
<i class="fas fa-plus"></i> Add FAQ
|
||||
</button>
|
||||
</div>
|
||||
<div id="faqContainer">
|
||||
<% if (service.details?.faq && service.details.faq.length > 0) { %>
|
||||
<% service.details.faq.forEach((faq, index) => { %>
|
||||
<div class="card mb-3 faq-item">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0 text-decoration-underline">FAQ <%= index + 1 %></h6>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="removeFAQ(this)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">ID</label>
|
||||
<input type="text" class="form-control faq-id"
|
||||
value="<%= faq.id || 'faq-' + (index + 1) %>" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Expanded by Default</label>
|
||||
<select class="form-control faq-expanded">
|
||||
<option value="false" <%= !faq.isExpanded ? 'selected' : '' %>>No</option>
|
||||
<option value="true" <%= faq.isExpanded ? 'selected' : '' %>>Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Question</label>
|
||||
<input type="text" class="form-control faq-question"
|
||||
value="<%= faq.question || '' %>" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Answer</label>
|
||||
<textarea class="form-control faq-answer" rows="3" required><%= faq.answer || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Buttons -->
|
||||
<div class="bottom-buttons">
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="fas fa-undo me-2"></i>Reset
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
<i class="fas fa-save me-2"></i>Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Scripts -->
|
||||
<script>
|
||||
let originalFormData = null;
|
||||
let featureIndex = <%= service.details?.features?.length || 0 %>;
|
||||
let faqIndex = <%= service.details?.faq?.length || 0 %>;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize form data
|
||||
originalFormData = <%- JSON.stringify(service) %>;
|
||||
|
||||
// Set initial JSON values
|
||||
updateAllJsonInputs(originalFormData);
|
||||
|
||||
// Initialize form handlers
|
||||
initializeFormHandlers();
|
||||
});
|
||||
|
||||
function initializeFormHandlers() {
|
||||
// Form submission
|
||||
const form = document.getElementById('serviceDetailsForm');
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving...';
|
||||
|
||||
try {
|
||||
updateJsonData();
|
||||
this.submit();
|
||||
} catch (error) {
|
||||
console.error('Error updating data:', error);
|
||||
showError('Failed to process form data. Please try again.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize image upload buttons
|
||||
document.querySelectorAll('.btn-upload-image').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetInput = this.dataset.targetInput;
|
||||
const imageType = this.dataset.imageType;
|
||||
openImageUploader(targetInput, imageType);
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize image input change listeners for manual URL input
|
||||
const imageInputs = ['mainImage', 'keyFeaturesImage', 'faqImage'];
|
||||
imageInputs.forEach(inputId => {
|
||||
const input = document.getElementById(inputId);
|
||||
if (input) {
|
||||
input.addEventListener('input', function() {
|
||||
updateImagePreviewAfterUpload(inputId, this.value);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addFeature() {
|
||||
const container = document.getElementById('featuresContainer');
|
||||
const featureHtml = `
|
||||
<div class="card mb-3 feature-item">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0 text-decoration-underline">Feature ${featureIndex + 1}</h6>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="removeFeature(this)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" class="form-control feature-title" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control feature-description" rows="2" required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.insertAdjacentHTML('beforeend', featureHtml);
|
||||
featureIndex++;
|
||||
}
|
||||
|
||||
function removeFeature(button) {
|
||||
const featureItem = button.closest('.feature-item');
|
||||
if (featureItem) {
|
||||
featureItem.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function addFAQ() {
|
||||
const container = document.getElementById('faqContainer');
|
||||
const faqHtml = `
|
||||
<div class="card mb-3 faq-item">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0 text-decoration-underline">FAQ ${faqIndex + 1}</h6>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="removeFAQ(this)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">ID</label>
|
||||
<input type="text" class="form-control faq-id"
|
||||
value="faq-${faqIndex + 1}" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Expanded by Default</label>
|
||||
<select class="form-control faq-expanded">
|
||||
<option value="false">No</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Question</label>
|
||||
<input type="text" class="form-control faq-question" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Answer</label>
|
||||
<textarea class="form-control faq-answer" rows="3" required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.insertAdjacentHTML('beforeend', faqHtml);
|
||||
faqIndex++;
|
||||
}
|
||||
|
||||
function removeFAQ(button) {
|
||||
const faqItem = button.closest('.faq-item');
|
||||
if (faqItem) {
|
||||
faqItem.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function updateAllJsonInputs(data) {
|
||||
// Collect basic details data
|
||||
const details = {
|
||||
title: data.details?.title || data.name,
|
||||
description: data.details?.description || data.description,
|
||||
mainImage: data.details?.mainImage || '',
|
||||
overviewTitle: data.details?.overviewTitle || 'Service Overview',
|
||||
overviewDescription: data.details?.overviewDescription || '',
|
||||
additionalDescription: data.details?.additionalDescription || '',
|
||||
keyFeaturesTitle: data.details?.keyFeaturesTitle || 'Key Features',
|
||||
keyFeaturesImage: data.details?.keyFeaturesImage || '',
|
||||
faqTitle: data.details?.faqTitle || 'Frequently Asked Questions',
|
||||
faqImage: data.details?.faqImage || ''
|
||||
};
|
||||
|
||||
document.getElementById('detailsJson').value = JSON.stringify(details);
|
||||
document.getElementById('featuresJson').value = JSON.stringify(data.details?.features || []);
|
||||
document.getElementById('faqJson').value = JSON.stringify(data.details?.faq || []);
|
||||
}
|
||||
|
||||
function updateJsonData() {
|
||||
// Collect basic details data
|
||||
const details = {
|
||||
title: document.getElementById('detailsTitle').value,
|
||||
description: document.getElementById('detailsDescription').value,
|
||||
mainImage: document.getElementById('mainImage').value,
|
||||
overviewTitle: document.getElementById('overviewTitle').value,
|
||||
overviewDescription: document.getElementById('overviewDescription').value,
|
||||
additionalDescription: document.getElementById('additionalDescription').value,
|
||||
keyFeaturesTitle: document.getElementById('keyFeaturesTitle').value,
|
||||
keyFeaturesImage: document.getElementById('keyFeaturesImage').value,
|
||||
faqTitle: document.getElementById('faqTitle').value,
|
||||
faqImage: document.getElementById('faqImage').value
|
||||
};
|
||||
|
||||
// Collect features data
|
||||
const features = [];
|
||||
document.querySelectorAll('.feature-item').forEach(item => {
|
||||
features.push({
|
||||
title: item.querySelector('.feature-title').value,
|
||||
description: item.querySelector('.feature-description').value
|
||||
});
|
||||
});
|
||||
|
||||
// Collect FAQ data
|
||||
const faq = [];
|
||||
document.querySelectorAll('.faq-item').forEach(item => {
|
||||
faq.push({
|
||||
id: item.querySelector('.faq-id').value,
|
||||
question: item.querySelector('.faq-question').value,
|
||||
answer: item.querySelector('.faq-answer').value,
|
||||
isExpanded: item.querySelector('.faq-expanded').value === 'true'
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('detailsJson').value = JSON.stringify(details);
|
||||
document.getElementById('featuresJson').value = JSON.stringify(features);
|
||||
document.getElementById('faqJson').value = JSON.stringify(faq);
|
||||
}
|
||||
|
||||
// Helper function để tạo full URL cho ảnh - tương tự như server-side helper
|
||||
function getFullImageUrlJS(imagePath) {
|
||||
if (!imagePath) return '';
|
||||
|
||||
// Nếu đã là full URL thì return luôn
|
||||
if (imagePath.startsWith('http')) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
// Lấy backend URL
|
||||
const backendUrl = '<%= (process.env.BACKEND_URL || "http://localhost:3001").replace(/\/$/, "") %>';
|
||||
|
||||
// Xử lý đường dẫn
|
||||
let imgSrc = imagePath;
|
||||
if (!imgSrc.startsWith('/')) {
|
||||
imgSrc = '/uploads/' + imgSrc;
|
||||
}
|
||||
|
||||
return backendUrl + imgSrc;
|
||||
}
|
||||
|
||||
// Function để cập nhật image preview sau khi upload
|
||||
function updateImagePreviewAfterUpload(targetInput, imagePath) {
|
||||
const fullImageUrl = getFullImageUrlJS(imagePath);
|
||||
|
||||
switch(targetInput) {
|
||||
case 'mainImage':
|
||||
const mainPreview = document.getElementById('mainImagePreview');
|
||||
if (mainPreview) {
|
||||
mainPreview.innerHTML = `<img src="${fullImageUrl}" class="img-thumbnail" style="max-height: 250px; width: auto; max-width: 100%; object-fit: contain;" alt="Main image preview">`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'keyFeaturesImage':
|
||||
const featuresPreview = document.getElementById('keyFeaturesImagePreview');
|
||||
if (featuresPreview) {
|
||||
featuresPreview.innerHTML = `<img src="${fullImageUrl}" class="img-thumbnail" style="max-height: 180px; width: auto; max-width: 100%; object-fit: contain;" alt="Key Features image preview">`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'faqImage':
|
||||
const faqPreview = document.getElementById('faqImagePreview');
|
||||
if (faqPreview) {
|
||||
faqPreview.innerHTML = `<img src="${fullImageUrl}" class="img-thumbnail" style="max-height: 180px; width: auto; max-width: 100%; object-fit: contain;" alt="FAQ image preview">`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function openImageUploader(targetInput, imageType) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.style.display = 'none';
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.onchange = async function (e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return reject(new Error('No file selected'));
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const uploadBtn = document.querySelector(`[data-target-input="${targetInput}"]`);
|
||||
const originalBtnHtml = uploadBtn ? uploadBtn.innerHTML : null;
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Uploading...';
|
||||
}
|
||||
|
||||
const response = await fetch(`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const input = document.getElementById(targetInput) || document.querySelector(`[name="${targetInput}"]`);
|
||||
if (!input) throw new Error('Target input not found');
|
||||
|
||||
input.value = result.path;
|
||||
|
||||
// Update image preview based on target input
|
||||
updateImagePreviewAfterUpload(targetInput, result.path);
|
||||
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.innerHTML = originalBtnHtml;
|
||||
}
|
||||
|
||||
resolve(result);
|
||||
} catch (err) {
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.innerHTML = originalBtnHtml;
|
||||
}
|
||||
console.error('Upload error:', err);
|
||||
showError('Upload failed: ' + (err.message || 'Unknown error'));
|
||||
reject(err);
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
};
|
||||
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
// Create and show success alert
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
|
||||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
// Create and show error alert
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
|
||||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
padding: 15px 0;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.content-with-fixed-buttons {
|
||||
/* Remove bottom padding since buttons are no longer fixed */
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.btn-group .btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.card-header h6 {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.text-decoration-underline {
|
||||
text-decoration: underline;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* Image Preview Styles */
|
||||
#mainImagePreview, #keyFeaturesImagePreview, #faqImagePreview {
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f8f9fa;
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#mainImagePreview:empty::before,
|
||||
#keyFeaturesImagePreview:empty::before,
|
||||
#faqImagePreview:empty::before {
|
||||
content: "No image selected";
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#mainImagePreview img,
|
||||
#keyFeaturesImagePreview img,
|
||||
#faqImagePreview img {
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
</style>
|
||||
440
views/admin/service/edit.ejs
Normal file
@@ -0,0 +1,440 @@
|
||||
<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);">Edit Service: <%= service.name %></h1>
|
||||
<p class="text-muted mb-0">Update service information and settings</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/admin/service" class="btn btn-outline-primary">
|
||||
<i class="fas fa-arrow-left me-2"></i>Back to Services
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="/admin/service/<%= service.slug %>/edit" method="POST" id="editServiceForm">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h5 class="mb-0" style="color: var(--primary-dark);">
|
||||
<i class="fas fa-edit me-2"></i>Service Information
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-medium">Service Name</label>
|
||||
<input type="text" class="form-control" id="serviceName" name="name"
|
||||
value="<%= service.name %>" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">
|
||||
Slug
|
||||
<small class="text-muted">(generated from name)</small>
|
||||
<span id="slugAutoIndicator" class="badge bg-info ms-1" style="font-size: 0.7em;">EXISTING</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="serviceSlug" name="slug"
|
||||
value="<%= service.slug %>" readonly>
|
||||
<button type="button" class="btn btn-primary" id="generateSlugBtn" title="Generate slug from name">
|
||||
<i class="fas fa-magic me-1"></i>Generate
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-text text-muted">URL-friendly version of the service name.</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Layout</label>
|
||||
<select class="form-control" id="serviceLayout" name="layout">
|
||||
<option value="left" <%= service.layout === 'left' ? 'selected' : '' %>>Left</option>
|
||||
<option value="right" <%= service.layout === 'right' ? 'selected' : '' %>>Right</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-medium">Image</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="serviceImage" name="image"
|
||||
value="<%= service.image || '' %>">
|
||||
<button type="button" class="btn btn-outline-primary btn-upload-image"
|
||||
data-target-input="serviceImage" data-image-type="service">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div id="serviceImagePreview">
|
||||
<% if (service.image) { %>
|
||||
<img src="<%= getFullImageUrl(service.image) %>" class="img-thumbnail"
|
||||
style="height: 80px; width: 100%; object-fit: cover;" alt="Preview">
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Description</label>
|
||||
<textarea class="form-control" id="serviceDescription" name="description"
|
||||
rows="3" required><%= service.description %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Buttons -->
|
||||
<div class="bottom-buttons">
|
||||
<a href="/admin/service" class="btn btn-secondary">
|
||||
<i class="fas fa-times me-2"></i>Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
<i class="fas fa-save me-2"></i>Update Service
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script>
|
||||
let servicesData = []; // Will be populated for duplicate checking
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Load existing services data for duplicate checking
|
||||
loadServicesData();
|
||||
|
||||
// Initialize form handlers
|
||||
initializeFormHandlers();
|
||||
});
|
||||
|
||||
async function loadServicesData() {
|
||||
try {
|
||||
const response = await fetch('/api/service');
|
||||
const data = await response.json();
|
||||
servicesData = data.services?.items || [];
|
||||
} catch (error) {
|
||||
console.error('Error loading services data:', error);
|
||||
servicesData = [];
|
||||
}
|
||||
}
|
||||
|
||||
function initializeFormHandlers() {
|
||||
// Form submission
|
||||
const form = document.getElementById('editServiceForm');
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Updating...';
|
||||
|
||||
try {
|
||||
// Check for duplicate slug before submitting
|
||||
const slug = document.getElementById('serviceSlug').value.trim();
|
||||
const currentSlug = '<%= service.slug %>';
|
||||
|
||||
if (slug !== currentSlug && isSlugDuplicate(slug, -1)) {
|
||||
showError('Service with this slug already exists. Please generate a new slug.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Update Service';
|
||||
return;
|
||||
}
|
||||
|
||||
this.submit();
|
||||
} catch (error) {
|
||||
console.error('Error updating service:', error);
|
||||
showError('Failed to update service. Please try again.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Update Service';
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize image upload buttons
|
||||
document.querySelectorAll('.btn-upload-image').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetInput = this.dataset.targetInput;
|
||||
const imageType = this.dataset.imageType;
|
||||
openImageUploader(targetInput, imageType);
|
||||
});
|
||||
});
|
||||
|
||||
// Image preview for service image
|
||||
const serviceImageInput = document.getElementById('serviceImage');
|
||||
if (serviceImageInput) {
|
||||
serviceImageInput.addEventListener('input', function() {
|
||||
updateImagePreview('serviceImagePreview', this.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Generate slug from service name
|
||||
const serviceNameInput = document.getElementById('serviceName');
|
||||
const serviceSlugInput = document.getElementById('serviceSlug');
|
||||
const slugAutoIndicator = document.getElementById('slugAutoIndicator');
|
||||
const generateSlugBtn = document.getElementById('generateSlugBtn');
|
||||
|
||||
if (serviceNameInput && serviceSlugInput && generateSlugBtn) {
|
||||
// Generate slug button
|
||||
generateSlugBtn.addEventListener('click', async function() {
|
||||
const serviceName = serviceNameInput.value.trim();
|
||||
if (serviceName) {
|
||||
// Show loading state
|
||||
const originalBtnHtml = this.innerHTML;
|
||||
this.disabled = true;
|
||||
this.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Generating...';
|
||||
|
||||
try {
|
||||
const slug = await generateSlugFromText(serviceName);
|
||||
const currentSlug = '<%= service.slug %>';
|
||||
|
||||
// Check for duplicate slug (excluding current service)
|
||||
if (slug !== currentSlug && isSlugDuplicate(slug, -1)) {
|
||||
const uniqueSlug = generateUniqueSlug(slug);
|
||||
serviceSlugInput.value = uniqueSlug;
|
||||
showWarning(`Slug "${slug}" already exists. Generated unique slug: "${uniqueSlug}"`);
|
||||
} else {
|
||||
serviceSlugInput.value = slug;
|
||||
showSuccess('Slug generated successfully!');
|
||||
}
|
||||
|
||||
if (slugAutoIndicator) {
|
||||
slugAutoIndicator.textContent = 'GENERATED';
|
||||
slugAutoIndicator.className = 'badge bg-success ms-1';
|
||||
slugAutoIndicator.style.fontSize = '0.7em';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating slug:', error);
|
||||
showError('Failed to generate slug. Please try again.');
|
||||
} finally {
|
||||
// Restore button state
|
||||
this.disabled = false;
|
||||
this.innerHTML = originalBtnHtml;
|
||||
}
|
||||
} else {
|
||||
showError('Please enter a service name first.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate slug using backend API
|
||||
async function generateSlugFromText(text) {
|
||||
try {
|
||||
const response = await fetch('/admin/service/generate-slug', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text: text })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
return result.slug;
|
||||
} else {
|
||||
throw new Error(result.message || 'Failed to generate slug');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating slug:', error);
|
||||
// Fallback to simple slug generation if API fails
|
||||
return text
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w\-]+/g, '')
|
||||
.replace(/\-\-+/g, '-')
|
||||
.replace(/^-+/, '')
|
||||
.replace(/-+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if slug already exists
|
||||
function isSlugDuplicate(slug, excludeIndex = -1) {
|
||||
return servicesData.some((service, index) => {
|
||||
return service && service.slug === slug && index !== excludeIndex;
|
||||
});
|
||||
}
|
||||
|
||||
// Generate unique slug by appending number
|
||||
function generateUniqueSlug(baseSlug) {
|
||||
let counter = 1;
|
||||
let uniqueSlug = baseSlug;
|
||||
|
||||
while (isSlugDuplicate(uniqueSlug, -1)) {
|
||||
uniqueSlug = baseSlug + '-' + counter;
|
||||
counter++;
|
||||
}
|
||||
|
||||
return uniqueSlug;
|
||||
}
|
||||
|
||||
function updateImagePreview(previewId, imagePath) {
|
||||
const preview = document.getElementById(previewId);
|
||||
if (imagePath) {
|
||||
const fullImageUrl = getFullImageUrlJS(imagePath);
|
||||
preview.innerHTML = `<img src="${fullImageUrl}" class="img-thumbnail" style="height: 80px; width: 100%; object-fit: cover;" alt="Preview">`;
|
||||
} else {
|
||||
preview.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function để tạo full URL cho ảnh
|
||||
function getFullImageUrlJS(imagePath) {
|
||||
if (!imagePath) return '';
|
||||
|
||||
if (imagePath.startsWith('http')) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
const backendUrl = '<%= (process.env.BACKEND_URL || "http://localhost:3001").replace(/\/$/, "") %>';
|
||||
|
||||
let imgSrc = imagePath;
|
||||
if (!imgSrc.startsWith('/')) {
|
||||
imgSrc = '/uploads/' + imgSrc;
|
||||
}
|
||||
|
||||
return backendUrl + imgSrc;
|
||||
}
|
||||
|
||||
async function openImageUploader(targetInput, imageType) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.style.display = 'none';
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.onchange = async function (e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return reject(new Error('No file selected'));
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const uploadBtn = document.querySelector(`[data-target-input="${targetInput}"]`);
|
||||
const originalBtnHtml = uploadBtn ? uploadBtn.innerHTML : null;
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Uploading...';
|
||||
}
|
||||
|
||||
const response = await fetch(`/admin/upload/image?imageType=service`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const input = document.getElementById(targetInput);
|
||||
if (!input) throw new Error('Target input not found');
|
||||
|
||||
input.value = result.path;
|
||||
|
||||
// Update preview
|
||||
if (targetInput === 'serviceImage') {
|
||||
updateImagePreview('serviceImagePreview', result.path);
|
||||
}
|
||||
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.innerHTML = originalBtnHtml;
|
||||
}
|
||||
|
||||
resolve(result);
|
||||
} catch (err) {
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.innerHTML = originalBtnHtml;
|
||||
}
|
||||
console.error('Upload error:', err);
|
||||
showError('Upload failed: ' + (err.message || 'Unknown error'));
|
||||
reject(err);
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
};
|
||||
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
|
||||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showWarning(message) {
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-warning alert-dismissible fade show position-fixed';
|
||||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
|
||||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
padding: 15px 0;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.card-header h5 {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
</style>
|
||||
782
views/admin/service/index.ejs
Normal file
@@ -0,0 +1,782 @@
|
||||
<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 services and their detailed content</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= frontendUrl %>/admin/service/" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Services Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="/admin/service/update" method="POST" class="content-with-fixed-buttons" id="serviceForm">
|
||||
<!-- Hidden inputs for JSON data -->
|
||||
<input type="hidden" name="services" id="servicesJson">
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" data-bs-toggle="tab" href="#services-list" role="tab">
|
||||
<i class="fas fa-list me-2"></i>Services List
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#section-settings" role="tab">
|
||||
<i class="fas fa-cog me-2"></i>Section Settings
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#add-service" role="tab">
|
||||
<i class="fas fa-plus me-2"></i>Add Service
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
<!-- Services List Tab -->
|
||||
<div class="tab-pane fade show active" id="services-list" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-body">
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle p-3">
|
||||
<i class="fas fa-cogs text-primary"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<h6 class="text-muted mb-0">Total Services</h6>
|
||||
<h3 class="mb-0" id="totalCount">
|
||||
<%= (data.services && data.services.items) ? data.services.items.length : 0 %>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="bg-success bg-opacity-10 rounded-circle p-3">
|
||||
<i class="fas fa-align-left text-success"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<h6 class="text-muted mb-0">Left Layout</h6>
|
||||
<h3 class="mb-0" id="leftCount">
|
||||
<%= (data.services && data.services.items) ? data.services.items.filter(s => s && s.layout === 'left').length : 0 %>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="bg-info bg-opacity-10 rounded-circle p-3">
|
||||
<i class="fas fa-align-right text-info"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<h6 class="text-muted mb-0">Right Layout</h6>
|
||||
<h3 class="mb-0" id="rightCount">
|
||||
<%= (data.services && data.services.items) ? data.services.items.filter(s => s && s.layout === 'right').length : 0 %>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Services Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0" id="servicesTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 40px;">#</th>
|
||||
<th style="width: 80px;">Image</th>
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th style="width: 100px;">Layout</th>
|
||||
<th style="width: 200px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="servicesTableBody">
|
||||
<% if (data.services && data.services.items) { %>
|
||||
<% data.services.items.forEach((service, index) => { %>
|
||||
<% if (service) { %>
|
||||
<tr data-index="<%= index %>">
|
||||
<td class="text-muted"><%= index + 1 %></td>
|
||||
<td>
|
||||
<% if (service.image) { %>
|
||||
<img src="<%= getFullImageUrl(service.image) %>" class="rounded" style="width: 60px; height: 45px; object-fit: cover;" alt="<%= service.name %>" >
|
||||
<% } else { %>
|
||||
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 60px; height: 45px;">
|
||||
<i class="fas fa-image text-muted"></i>
|
||||
</div>
|
||||
<% } %>
|
||||
</td>
|
||||
<td>
|
||||
<strong><%= service.name %></strong>
|
||||
<% if (service.description) { %>
|
||||
<br><small class="text-muted"><%= service.description.substring(0, 50) %>...</small>
|
||||
<% } %>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark"><%= service.slug %></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-<%= service.layout === 'left' ? 'primary' : 'success' %>">
|
||||
<i class="fas fa-align-<%= service.layout %>"></i> <%= service.layout %>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="/admin/service/<%= service.slug %>/edit" class="btn btn-sm btn-outline-primary" title="Edit Service">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<a href="/admin/service/<%= service.slug %>/details" class="btn btn-sm btn-outline-warning" title="Edit Details">
|
||||
<i class="fas fa-cog"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteService(<%= index %>)" title="Delete Service">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<% if (!data.services || !data.services.items || data.services.items.length === 0) { %>
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">No services found</h5>
|
||||
<p class="text-muted">Click "Add Service" tab to create your first service.</p>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Settings Tab -->
|
||||
<div class="tab-pane fade" id="section-settings" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-medium mb-3">Services Section Title</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Sub Title</label>
|
||||
<input type="text" class="form-control" id="servicesSubTitle"
|
||||
value="<%= data.services?.title?.subTitle || '' %>"
|
||||
placeholder="Enter subtitle for services section">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Main Title</label>
|
||||
<input type="text" class="form-control" id="servicesMainTitle"
|
||||
value="<%= data.services?.title?.mainTitle || '' %>"
|
||||
placeholder="Enter main title for services section">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Service Tab -->
|
||||
<div class="tab-pane fade" id="add-service" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-medium mb-3">Add New Service</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-medium">Service Name</label>
|
||||
<input type="text" class="form-control" id="newServiceName" placeholder="Enter service name">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">
|
||||
Slug
|
||||
<small class="text-muted">(generated from name)</small>
|
||||
<span id="slugAutoIndicator" class="badge bg-success ms-1" style="font-size: 0.7em;">AUTO</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="newServiceSlug" placeholder="Click generate to create slug" readonly>
|
||||
<button type="button" class="btn btn-primary" id="generateSlugBtn" title="Generate slug from name">
|
||||
<i class="fas fa-magic me-1"></i>Generate
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-text text-muted">URL-friendly version of the service name. Generated automatically from the service name.</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Layout</label>
|
||||
<select class="form-control" id="newServiceLayout">
|
||||
<option value="left">Left</option>
|
||||
<option value="right">Right</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-medium">Image</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="newServiceImage" placeholder="Enter image path">
|
||||
<button type="button" class="btn btn-outline-primary btn-upload-image" data-target-input="newServiceImage" data-image-type="service">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div id="newServiceImagePreview"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Description</label>
|
||||
<textarea class="form-control" id="newServiceDescription" rows="3" placeholder="Enter service description"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-primary" onclick="addService()">
|
||||
<i class="fas fa-plus me-2"></i>Add Service
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Buttons -->
|
||||
<div class="bottom-buttons">
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="fas fa-undo me-2"></i>Reset
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
<i class="fas fa-save me-2"></i>Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script>
|
||||
let originalFormData = null;
|
||||
let servicesData = <%- JSON.stringify(data.services?.items || []) %>;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize form data
|
||||
originalFormData = <%- JSON.stringify(data) %>;
|
||||
|
||||
// Set initial JSON values
|
||||
updateAllJsonInputs(originalFormData);
|
||||
|
||||
// Initialize form handlers
|
||||
initializeFormHandlers();
|
||||
});
|
||||
|
||||
function initializeFormHandlers() {
|
||||
// Form submission
|
||||
const form = document.getElementById('serviceForm');
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving...';
|
||||
|
||||
try {
|
||||
updateJsonData();
|
||||
this.submit();
|
||||
} catch (error) {
|
||||
console.error('Error updating data:', error);
|
||||
showError('Failed to process form data. Please try again.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize image upload buttons
|
||||
document.querySelectorAll('.btn-upload-image').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetInput = this.dataset.targetInput;
|
||||
const imageType = this.dataset.imageType;
|
||||
openImageUploader(targetInput, imageType);
|
||||
});
|
||||
});
|
||||
|
||||
// Image preview for new service
|
||||
const newServiceImageInput = document.getElementById('newServiceImage');
|
||||
if (newServiceImageInput) {
|
||||
newServiceImageInput.addEventListener('input', function() {
|
||||
updateImagePreview('newServiceImagePreview', this.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Generate slug from service name
|
||||
const newServiceNameInput = document.getElementById('newServiceName');
|
||||
const newServiceSlugInput = document.getElementById('newServiceSlug');
|
||||
const slugAutoIndicator = document.getElementById('slugAutoIndicator');
|
||||
const generateSlugBtn = document.getElementById('generateSlugBtn');
|
||||
|
||||
if (newServiceNameInput && newServiceSlugInput && generateSlugBtn) {
|
||||
// Generate slug button
|
||||
generateSlugBtn.addEventListener('click', async function() {
|
||||
const serviceName = newServiceNameInput.value.trim();
|
||||
if (serviceName) {
|
||||
// Show loading state
|
||||
const originalBtnHtml = this.innerHTML;
|
||||
this.disabled = true;
|
||||
this.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Generating...';
|
||||
|
||||
try {
|
||||
const slug = await generateSlugFromText(serviceName);
|
||||
|
||||
// Check if we're in edit mode
|
||||
const addBtn = document.querySelector('#add-service .btn-primary');
|
||||
const isEditMode = addBtn && addBtn.textContent.includes('Update');
|
||||
|
||||
// Get current service index if editing
|
||||
let currentIndex = -1;
|
||||
if (isEditMode) {
|
||||
const onclickAttr = addBtn.getAttribute('onclick');
|
||||
const match = onclickAttr.match(/updateService\((\d+)\)/);
|
||||
if (match) {
|
||||
currentIndex = parseInt(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate slug
|
||||
if (isSlugDuplicate(slug, currentIndex)) {
|
||||
const uniqueSlug = generateUniqueSlug(slug);
|
||||
newServiceSlugInput.value = uniqueSlug;
|
||||
showWarning(`Slug "${slug}" already exists. Generated unique slug: "${uniqueSlug}"`);
|
||||
} else {
|
||||
newServiceSlugInput.value = slug;
|
||||
showSuccess('Slug generated successfully!');
|
||||
}
|
||||
|
||||
if (slugAutoIndicator) {
|
||||
slugAutoIndicator.textContent = 'GENERATED';
|
||||
slugAutoIndicator.className = 'badge bg-success ms-1';
|
||||
slugAutoIndicator.style.fontSize = '0.7em';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating slug:', error);
|
||||
showError('Failed to generate slug. Please try again.');
|
||||
} finally {
|
||||
// Restore button state
|
||||
this.disabled = false;
|
||||
this.innerHTML = originalBtnHtml;
|
||||
}
|
||||
} else {
|
||||
showError('Please enter a service name first.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function để tạo full URL cho ảnh - tương tự như server-side helper
|
||||
function getFullImageUrlJS(imagePath) {
|
||||
if (!imagePath) return '';
|
||||
|
||||
// Nếu đã là full URL thì return luôn
|
||||
if (imagePath.startsWith('http')) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
// Lấy backend URL
|
||||
const backendUrl = '<%= (process.env.BACKEND_URL || "http://localhost:3001").replace(/\/$/, "") %>';
|
||||
|
||||
// Xử lý đường dẫn
|
||||
let imgSrc = imagePath;
|
||||
if (!imgSrc.startsWith('/')) {
|
||||
imgSrc = '/uploads/' + imgSrc;
|
||||
}
|
||||
|
||||
return backendUrl + imgSrc;
|
||||
}
|
||||
|
||||
// Generate slug using backend API
|
||||
async function generateSlugFromText(text) {
|
||||
try {
|
||||
const response = await fetch('/admin/service/generate-slug', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text: text })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
return result.slug;
|
||||
} else {
|
||||
throw new Error(result.message || 'Failed to generate slug');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating slug:', error);
|
||||
// Fallback to simple slug generation if API fails
|
||||
return text
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w\-]+/g, '')
|
||||
.replace(/\-\-+/g, '-')
|
||||
.replace(/^-+/, '')
|
||||
.replace(/-+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if slug already exists (excluding current service when editing)
|
||||
function isSlugDuplicate(slug, excludeIndex = -1) {
|
||||
return servicesData.some((service, index) => {
|
||||
return service && service.slug === slug && index !== excludeIndex;
|
||||
});
|
||||
}
|
||||
|
||||
// Generate unique slug by appending number
|
||||
function generateUniqueSlug(baseSlug) {
|
||||
let counter = 1;
|
||||
let uniqueSlug = baseSlug;
|
||||
|
||||
while (isSlugDuplicate(uniqueSlug, -1)) {
|
||||
uniqueSlug = baseSlug + '-' + counter;
|
||||
counter++;
|
||||
}
|
||||
|
||||
return uniqueSlug;
|
||||
}
|
||||
|
||||
function updateImagePreview(previewId, imagePath) {
|
||||
const preview = document.getElementById(previewId);
|
||||
if (imagePath) {
|
||||
const fullImageUrl = getFullImageUrlJS(imagePath);
|
||||
preview.innerHTML = `<img src="${fullImageUrl}" class="img-thumbnail" style="height: 80px; width: 100%; object-fit: cover;" alt="Preview">`;
|
||||
} else {
|
||||
preview.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function addService() {
|
||||
const name = document.getElementById('newServiceName').value.trim();
|
||||
const slug = document.getElementById('newServiceSlug').value.trim();
|
||||
const description = document.getElementById('newServiceDescription').value.trim();
|
||||
const image = document.getElementById('newServiceImage').value.trim();
|
||||
const layout = document.getElementById('newServiceLayout').value;
|
||||
|
||||
if (!name || !slug || !description) {
|
||||
showError('Please fill in all required fields and generate a slug.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if service with same slug already exists
|
||||
if (isSlugDuplicate(slug, -1)) {
|
||||
showError('Service with this slug already exists. Please generate a new slug.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newService = {
|
||||
slug: slug,
|
||||
name: name,
|
||||
description: description,
|
||||
image: image,
|
||||
layout: layout,
|
||||
details: {
|
||||
title: name,
|
||||
description: description,
|
||||
mainImage: "img/inner-page/service-details/details-1.jpg",
|
||||
overviewTitle: "Service Overview",
|
||||
overviewDescription: "Service overview description...",
|
||||
additionalDescription: "Additional description...",
|
||||
keyFeaturesTitle: "Key Features",
|
||||
keyFeaturesImage: "img/inner-page/service-details/details-2.jpg",
|
||||
features: [],
|
||||
faqTitle: "Frequently Asked Question",
|
||||
faqImage: "img/inner-page/service-details/details-3.jpg",
|
||||
faq: []
|
||||
}
|
||||
};
|
||||
|
||||
servicesData.push(newService);
|
||||
updateServicesTable();
|
||||
updateStatistics();
|
||||
|
||||
// Clear form
|
||||
clearAddServiceForm();
|
||||
|
||||
showSuccess('Service added successfully!');
|
||||
}
|
||||
|
||||
function deleteService(index) {
|
||||
if (confirm('Are you sure you want to delete this service? This action cannot be undone.')) {
|
||||
servicesData.splice(index, 1);
|
||||
updateServicesTable();
|
||||
updateStatistics();
|
||||
showSuccess('Service deleted successfully!');
|
||||
}
|
||||
}
|
||||
|
||||
function clearAddServiceForm() {
|
||||
document.getElementById('newServiceName').value = '';
|
||||
document.getElementById('newServiceSlug').value = '';
|
||||
document.getElementById('newServiceDescription').value = '';
|
||||
document.getElementById('newServiceImage').value = '';
|
||||
document.getElementById('newServiceLayout').value = 'left';
|
||||
document.getElementById('newServiceImagePreview').innerHTML = '';
|
||||
|
||||
// Reset slug indicator
|
||||
const slugAutoIndicator = document.getElementById('slugAutoIndicator');
|
||||
if (slugAutoIndicator) {
|
||||
slugAutoIndicator.textContent = 'AUTO';
|
||||
slugAutoIndicator.className = 'badge bg-success ms-1';
|
||||
slugAutoIndicator.style.fontSize = '0.7em';
|
||||
}
|
||||
}
|
||||
|
||||
function updateServicesTable() {
|
||||
const tbody = document.getElementById('servicesTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
servicesData.forEach((service, index) => {
|
||||
if (!service) return; // Skip undefined services
|
||||
|
||||
let imageHtml = '';
|
||||
if (service.image) {
|
||||
const fullImageUrl = getFullImageUrlJS(service.image);
|
||||
imageHtml = `<img src="${fullImageUrl}" class="rounded" style="width: 60px; height: 45px; object-fit: cover;" alt="${service.name || 'Service'}" onerror="this.onerror=null; this.src='/images/placeholder.png'; this.classList.add('bg-light');">`;
|
||||
} else {
|
||||
imageHtml = `<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 60px; height: 45px;"><i class="fas fa-image text-muted"></i></div>`;
|
||||
}
|
||||
|
||||
const row = `
|
||||
<tr data-index="${index}">
|
||||
<td class="text-muted">${index + 1}</td>
|
||||
<td>${imageHtml}</td>
|
||||
<td>
|
||||
<strong>${service.name || 'Unnamed Service'}</strong>
|
||||
${service.description ? `<br><small class="text-muted">${service.description.substring(0, 50)}...</small>` : ''}
|
||||
</td>
|
||||
<td><span class="badge bg-light text-dark">${service.slug || 'no-slug'}</span></td>
|
||||
<td><span class="badge bg-${(service.layout === 'left') ? 'primary' : 'success'}"><i class="fas fa-align-${service.layout || 'left'}"></i> ${service.layout || 'left'}</span></td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="/admin/service/${service.slug || 'unknown'}/edit" class="btn btn-sm btn-outline-primary" title="Edit Service">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<a href="/admin/service/${service.slug || 'unknown'}/details" class="btn btn-sm btn-outline-warning" title="Edit Details">
|
||||
<i class="fas fa-cog"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteService(${index})" title="Delete Service">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
tbody.innerHTML += row;
|
||||
});
|
||||
}
|
||||
|
||||
function updateStatistics() {
|
||||
const validServices = servicesData.filter(s => s && s.layout);
|
||||
document.getElementById('totalCount').textContent = servicesData.length;
|
||||
document.getElementById('leftCount').textContent = validServices.filter(s => s.layout === 'left').length;
|
||||
document.getElementById('rightCount').textContent = validServices.filter(s => s.layout === 'right').length;
|
||||
}
|
||||
|
||||
function updateAllJsonInputs(data) {
|
||||
const servicesSection = {
|
||||
title: data.services?.title || { subTitle: '', mainTitle: '' },
|
||||
items: data.services?.items || []
|
||||
};
|
||||
|
||||
document.getElementById('servicesJson').value = JSON.stringify(servicesSection);
|
||||
}
|
||||
|
||||
function updateJsonData() {
|
||||
const servicesSection = {
|
||||
title: {
|
||||
subTitle: document.getElementById('servicesSubTitle').value,
|
||||
mainTitle: document.getElementById('servicesMainTitle').value,
|
||||
},
|
||||
items: servicesData
|
||||
};
|
||||
|
||||
// Gửi toàn bộ services section (bao gồm cả title và items)
|
||||
document.getElementById('servicesJson').value = JSON.stringify(servicesSection);
|
||||
}
|
||||
|
||||
async function openImageUploader(targetInput, imageType) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.style.display = 'none';
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.onchange = async function (e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return reject(new Error('No file selected'));
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const uploadBtn = document.querySelector(`[data-target-input="${targetInput}"]`);
|
||||
const originalBtnHtml = uploadBtn ? uploadBtn.innerHTML : null;
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Uploading...';
|
||||
}
|
||||
|
||||
// Sử dụng imageType=service để upload vào folder service
|
||||
const response = await fetch(`/admin/upload/image?imageType=service`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const input = document.getElementById(targetInput) || document.querySelector(`[name="${targetInput}"]`);
|
||||
if (!input) throw new Error('Target input not found');
|
||||
|
||||
input.value = result.path;
|
||||
|
||||
// Update preview if it's the new service image
|
||||
if (targetInput === 'newServiceImage') {
|
||||
updateImagePreview('newServiceImagePreview', result.path);
|
||||
}
|
||||
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.innerHTML = originalBtnHtml;
|
||||
}
|
||||
|
||||
resolve(result);
|
||||
} catch (err) {
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.innerHTML = originalBtnHtml;
|
||||
}
|
||||
console.error('Upload error:', err);
|
||||
showError('Upload failed: ' + (err.message || 'Unknown error'));
|
||||
reject(err);
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
};
|
||||
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
// Create and show success alert
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
|
||||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showWarning(message) {
|
||||
// Create and show warning alert
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-warning alert-dismissible fade show position-fixed';
|
||||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
// Create and show error alert
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
|
||||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
padding: 15px 0;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.content-with-fixed-buttons {
|
||||
/* Remove bottom padding since buttons are no longer fixed */
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.btn-group .btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -776,6 +776,35 @@
|
||||
<a class="nav-link <%= currentPath === '/admin/activity' ? 'active' : '' %>" href="/admin/activity">Activity
|
||||
& Booking</a>
|
||||
</li>
|
||||
</ul>
|
||||
<li>
|
||||
<a class="dropdown-item <%= currentPath === '/admin/travel' ? 'active' : '' %>"
|
||||
href="/admin/travel">Travel</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item <%= currentPath === '/admin/terms-conditions' ? 'active' : '' %>"
|
||||
href="/admin/terms-conditions">Terms & Conditions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item <%= currentPath === '/admin/service' ? 'active' : '' %>"
|
||||
href="/admin/service">Service</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= currentPath === '/admin/contact' ? 'active' : '' %>" href="/admin/contact">Contact
|
||||
Us</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= currentPath === '/admin/camp-location' ? 'active' : '' %>"
|
||||
href="/admin/camp-location">Camp Location</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= currentPath === '/admin/activity' ? 'active' : '' %>" href="/admin/activity">Activity &
|
||||
Booking</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<li>
|
||||
|
||||