forked from UKSOURCE/cms.hailearning.edu.vn
first commit
This commit is contained in:
161
controllers/aboutController.js
Normal file
161
controllers/aboutController.js
Normal file
@@ -0,0 +1,161 @@
|
||||
const { addBaseUrlToImages } = require('../utils/imageHelper');
|
||||
const About = require('../models/about');
|
||||
|
||||
// Get about data from MongoDB
|
||||
const getAboutData = async () => {
|
||||
const about = await About.findOne().sort({ updatedAt: -1 });
|
||||
|
||||
// Trả về object rỗng với cấu trúc cơ bản nếu không có dữ liệu
|
||||
if (!about) {
|
||||
return {
|
||||
banner: {
|
||||
image: '',
|
||||
title: '',
|
||||
text: ''
|
||||
},
|
||||
about: {
|
||||
title: '',
|
||||
paragraphs: [],
|
||||
list_items: [],
|
||||
button: {
|
||||
text: '',
|
||||
url: ''
|
||||
},
|
||||
image: '',
|
||||
quote: {
|
||||
mark_image: '',
|
||||
title: '',
|
||||
text: '',
|
||||
author: ''
|
||||
}
|
||||
},
|
||||
values: {
|
||||
background_image: '',
|
||||
items: []
|
||||
},
|
||||
education: {
|
||||
images: {
|
||||
student1: '',
|
||||
student2: ''
|
||||
},
|
||||
subtitle: '',
|
||||
title: '',
|
||||
text: ''
|
||||
},
|
||||
advantages: {
|
||||
title: '',
|
||||
items: []
|
||||
},
|
||||
academic_board: {
|
||||
title: '',
|
||||
members: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return about;
|
||||
};
|
||||
|
||||
// Display about management page
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const data = await getAboutData();
|
||||
res.render('admin/about', {
|
||||
title: 'About Management',
|
||||
data
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash('error_msg', 'Error loading about data');
|
||||
res.redirect('/admin/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
// Update about data
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
// Lấy document hiện tại từ MongoDB
|
||||
const currentData = await getAboutData();
|
||||
|
||||
// Danh sách các section cần cập nhật
|
||||
const sections = ['banner', 'about', 'values', 'education', 'advantages', 'academic_board'];
|
||||
const errors = [];
|
||||
let hasChanges = false;
|
||||
|
||||
// Tạo đối tượng dữ liệu mới dựa trên dữ liệu hiện tại
|
||||
const updatedData = { ...currentData.toObject() };
|
||||
|
||||
// Xử lý từng section
|
||||
sections.forEach(section => {
|
||||
try {
|
||||
// Kiểm tra nếu section không được gửi lên
|
||||
if (!req.body[section]) {
|
||||
console.warn(`No data for section: ${section}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse dữ liệu JSON từ form
|
||||
const newSectionData = JSON.parse(req.body[section]);
|
||||
|
||||
// So sánh dữ liệu mới với dữ liệu hiện tại
|
||||
const currentSectionData = currentData[section];
|
||||
const sectionHasChanges = JSON.stringify(newSectionData) !== JSON.stringify(currentSectionData);
|
||||
|
||||
// Nếu có thay đổi, cập nhật vào đối tượng dữ liệu mới
|
||||
if (sectionHasChanges) {
|
||||
updatedData[section] = newSectionData;
|
||||
hasChanges = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing section ${section}:`, error);
|
||||
errors.push(`Error processing ${section} data: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Nếu có lỗi, thông báo và chuyển hướng
|
||||
if (errors.length > 0) {
|
||||
req.flash('error_msg', `Data processing error: ${errors[0]}`);
|
||||
return req.session.save(() => res.redirect('/admin/about'));
|
||||
}
|
||||
|
||||
// Nếu không có thay đổi, thông báo và chuyển hướng
|
||||
if (!hasChanges) {
|
||||
req.flash('info_msg', 'No changes were made');
|
||||
return req.session.save(() => res.redirect('/admin/about'));
|
||||
}
|
||||
|
||||
try {
|
||||
// Cập nhật hoặc tạo mới document trong MongoDB
|
||||
if (currentData._id) {
|
||||
await About.findByIdAndUpdate(currentData._id, updatedData, { new: true });
|
||||
} else {
|
||||
await About.create(updatedData);
|
||||
}
|
||||
|
||||
// Success notification and redirect
|
||||
req.flash('success_msg', 'About data updated successfully');
|
||||
return req.session.save(() => res.redirect('/admin/about'));
|
||||
} catch (dbError) {
|
||||
console.error('Database error:', dbError);
|
||||
req.flash('error_msg', `Database error: ${dbError.message || 'Unknown'}`);
|
||||
return req.session.save(() => res.redirect('/admin/about'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Update error:', err);
|
||||
req.flash('error_msg', `Update error: ${err.message || 'Unknown'}`);
|
||||
return req.session.save(() => res.redirect('/admin/about'));
|
||||
}
|
||||
};
|
||||
|
||||
// API to get about data
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const aboutData = await getAboutData();
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processedData = addBaseUrlToImages(aboutData, baseUrl);
|
||||
res.json(processedData);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Error loading about data' });
|
||||
}
|
||||
};
|
||||
363
controllers/aboutUsController.js
Normal file
363
controllers/aboutUsController.js
Normal file
@@ -0,0 +1,363 @@
|
||||
const {addBaseUrlToImages} = require("../utils/imageHelper");
|
||||
const About = require("../models/about");
|
||||
const AboutUs = require("../models/aboutUs");
|
||||
|
||||
// -------------------- Public (read-only) helpers --------------------
|
||||
// Map stored About document back to the original aboutUs.json shape
|
||||
function transformToAboutUs(doc) {
|
||||
if (!doc) return null;
|
||||
|
||||
const hero = {
|
||||
banner: doc.banner?.image || "",
|
||||
title: doc.banner?.title || "",
|
||||
breadcrumb: doc.banner?.text || "",
|
||||
};
|
||||
|
||||
const stats = Array.isArray(doc.advantages?.items)
|
||||
? doc.advantages.items.map((item) => ({
|
||||
number: item.number || "",
|
||||
description: item.title || "",
|
||||
}))
|
||||
: [];
|
||||
|
||||
const services = Array.isArray(doc.about?.paragraphs)
|
||||
? doc.about.paragraphs.map((p) => ({title: "", description: p}))
|
||||
: [];
|
||||
|
||||
const features = Array.isArray(doc.values?.items)
|
||||
? doc.values.items.map((i) => ({
|
||||
title: i.title || "",
|
||||
description: i.text || "",
|
||||
icon: i.icon || "",
|
||||
}))
|
||||
: [];
|
||||
|
||||
const events = Array.isArray(doc.academic_board?.members)
|
||||
? doc.academic_board.members.map((m) => ({
|
||||
imageUrl: m.image || "",
|
||||
date: "",
|
||||
title: m.title || "",
|
||||
description: "",
|
||||
authorName: m.name || "",
|
||||
authorRole: "",
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
hero,
|
||||
stats,
|
||||
services,
|
||||
features,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
// Get aboutUs data: prefer AboutUs collection, fallback to transforming About
|
||||
const getAboutUsData = async () => {
|
||||
// Prefer stored AboutUs document
|
||||
const aboutUsDoc = await AboutUs.findOne().sort({updatedAt: -1});
|
||||
if (aboutUsDoc)
|
||||
return aboutUsDoc.toObject ? aboutUsDoc.toObject() : aboutUsDoc;
|
||||
|
||||
// Fallback: transform legacy About document into aboutUs shape
|
||||
const about = await About.findOne().sort({updatedAt: -1});
|
||||
if (!about) return null;
|
||||
return transformToAboutUs(about);
|
||||
};
|
||||
|
||||
// -------------------- Admin (CRUD on AboutUs model) helpers --------------------
|
||||
// Default shape for AboutUs documents (matches data/aboutUs.json)
|
||||
const getDefaultAboutUsData = () => ({
|
||||
hero: {title: "", backgroundImage: ""},
|
||||
introduction: {
|
||||
subtitle: "",
|
||||
title: "",
|
||||
description: "",
|
||||
mainImage: "",
|
||||
services: [],
|
||||
},
|
||||
statistics: {
|
||||
items: [],
|
||||
},
|
||||
accommodation: {
|
||||
subtitle: "",
|
||||
title: "",
|
||||
description: "",
|
||||
features: [],
|
||||
},
|
||||
activities: {
|
||||
subtitle: "",
|
||||
title: "",
|
||||
description: "",
|
||||
gallery: [],
|
||||
},
|
||||
newsletter: {
|
||||
imagePath: "",
|
||||
title: "",
|
||||
description: "",
|
||||
buttonText: "",
|
||||
},
|
||||
events: {
|
||||
title: "",
|
||||
items: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Get latest stored AboutUs document or default (returned as plain object)
|
||||
const getStoredAboutUs = async () => {
|
||||
const aboutUs = await AboutUs.findOne().sort({updatedAt: -1});
|
||||
if (!aboutUs) return getDefaultAboutUsData();
|
||||
return aboutUs.toObject ? aboutUs.toObject() : aboutUs;
|
||||
};
|
||||
|
||||
// -------------------- Public exports --------------------
|
||||
// Public endpoint: return AboutUs JSON (previously rendered HTML)
|
||||
exports.page = async (req, res) => {
|
||||
try {
|
||||
const aboutUsData = await getAboutUsData();
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processed = addBaseUrlToImages(aboutUsData || {}, baseUrl);
|
||||
return res.json(processed);
|
||||
} catch (err) {
|
||||
console.error("aboutUs.page error:", err);
|
||||
return res.status(500).json({ error: "Error loading about-us data" });
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint to return aboutUs JSON
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const aboutUsData = await getAboutUsData();
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processed = addBaseUrlToImages(aboutUsData || {}, baseUrl);
|
||||
return res.json(processed);
|
||||
} catch (err) {
|
||||
console.error("aboutUs.api error:", err);
|
||||
return res.status(500).json({error: "Error loading about-us data"});
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint to return an array of AboutUs records (for frontend listing)
|
||||
exports.apiList = async (req, res) => {
|
||||
try {
|
||||
const docs = await AboutUs.find().sort({ updatedAt: -1 }).lean();
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processed = (docs || []).map((d) => addBaseUrlToImages(d || {}, baseUrl));
|
||||
return res.json(processed);
|
||||
} catch (err) {
|
||||
console.error("aboutUs.apiList error:", err);
|
||||
return res.status(500).json({ error: "Error loading about-us list" });
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------- Admin exports --------------------
|
||||
// Display AboutUs management page
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const data = await getStoredAboutUs();
|
||||
const items = await AboutUs.find().sort({updatedAt: -1}).limit(10);
|
||||
|
||||
res.render("admin/aboutUs/index", {
|
||||
layout: "layouts/main",
|
||||
title: "About Us Management",
|
||||
data,
|
||||
items,
|
||||
frontendUrl:
|
||||
process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", "Error loading About Us data");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Display create form
|
||||
exports.createForm = async (req, res) => {
|
||||
try {
|
||||
const data = getDefaultAboutUsData();
|
||||
|
||||
res.render("admin/aboutUs/create", {
|
||||
layout: "layouts/main",
|
||||
title: "Create About Us",
|
||||
data,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", "Error loading create form");
|
||||
res.redirect("/admin/about-us");
|
||||
}
|
||||
};
|
||||
|
||||
// Create new AboutUs record
|
||||
exports.create = async (req, res) => {
|
||||
try {
|
||||
const aboutUsData = {
|
||||
hero: JSON.parse(req.body.hero || "{}"),
|
||||
introduction: JSON.parse(req.body.introduction || "{}"),
|
||||
statistics: JSON.parse(req.body.statistics || "{}"),
|
||||
accommodation: JSON.parse(req.body.accommodation || "{}"),
|
||||
activities: JSON.parse(req.body.activities || "{}"),
|
||||
newsletter: JSON.parse(req.body.newsletter || "{}"),
|
||||
events: JSON.parse(req.body.events || "{}"),
|
||||
};
|
||||
|
||||
const newAboutUs = new AboutUs(aboutUsData);
|
||||
await newAboutUs.save();
|
||||
|
||||
req.flash("success_msg", "About Us created successfully");
|
||||
res.redirect("/admin/about-us");
|
||||
} catch (err) {
|
||||
console.error("Create error:", err);
|
||||
req.flash("error_msg", `Create error: ${err.message || "Unknown"}`);
|
||||
res.redirect("/admin/about-us/create");
|
||||
}
|
||||
};
|
||||
|
||||
// Display edit form
|
||||
exports.editForm = async (req, res) => {
|
||||
try {
|
||||
const aboutUs = await AboutUs.findById(req.params.id);
|
||||
|
||||
if (!aboutUs) {
|
||||
req.flash("error_msg", "About Us record not found");
|
||||
return res.redirect("/admin/about-us");
|
||||
}
|
||||
|
||||
res.render("admin/aboutUs/edit", {
|
||||
layout: "layouts/main",
|
||||
title: "Edit About Us",
|
||||
data: aboutUs.toObject ? aboutUs.toObject() : aboutUs,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", "Error loading edit form");
|
||||
res.redirect("/admin/about-us");
|
||||
}
|
||||
};
|
||||
|
||||
// Update AboutUs record
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
// Get current data
|
||||
const currentData = await getStoredAboutUs();
|
||||
|
||||
// Parse form data
|
||||
const sections = [
|
||||
"hero",
|
||||
"introduction",
|
||||
"statistics",
|
||||
"accommodation",
|
||||
"activities",
|
||||
"newsletter",
|
||||
"events",
|
||||
];
|
||||
const errors = [];
|
||||
let hasChanges = false;
|
||||
|
||||
// Create updated data object
|
||||
const updatedData = {
|
||||
...(currentData.toObject ? currentData.toObject() : currentData),
|
||||
};
|
||||
|
||||
// Process each section
|
||||
sections.forEach((section) => {
|
||||
try {
|
||||
if (!req.body[section]) {
|
||||
console.warn(`No data for section: ${section}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newSectionData = JSON.parse(req.body[section]);
|
||||
const currentSectionData = currentData[section];
|
||||
const sectionHasChanges =
|
||||
JSON.stringify(newSectionData) !== JSON.stringify(currentSectionData);
|
||||
|
||||
if (sectionHasChanges) {
|
||||
updatedData[section] = newSectionData;
|
||||
hasChanges = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing section ${section}:`, error);
|
||||
errors.push(`Error processing ${section} data: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
req.flash("error_msg", `Data processing error: ${errors[0]}`);
|
||||
return req.session.save(() => res.redirect("/admin/about-us"));
|
||||
}
|
||||
|
||||
if (!hasChanges) {
|
||||
req.flash("info_msg", "No changes were made");
|
||||
return req.session.save(() => res.redirect("/admin/about-us"));
|
||||
}
|
||||
|
||||
try {
|
||||
// Only update existing document; do not create a new one here
|
||||
if (!currentData || !currentData._id) {
|
||||
req.flash("error_msg", "No existing About Us record to update. Create one first.");
|
||||
return req.session.save(() => res.redirect("/admin/about-us"));
|
||||
}
|
||||
|
||||
await AboutUs.findByIdAndUpdate(currentData._id, updatedData, {
|
||||
new: true,
|
||||
});
|
||||
|
||||
req.flash("success_msg", "About Us data updated successfully");
|
||||
return req.session.save(() => res.redirect("/admin/about-us"));
|
||||
} catch (dbError) {
|
||||
console.error("Database error:", dbError);
|
||||
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
|
||||
return req.session.save(() => res.redirect("/admin/about-us"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Update error:", err);
|
||||
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
|
||||
return req.session.save(() => res.redirect("/admin/about-us"));
|
||||
}
|
||||
};
|
||||
|
||||
// Delete AboutUs record
|
||||
exports.delete = async (req, res) => {
|
||||
try {
|
||||
const aboutUs = await AboutUs.findById(req.params.id);
|
||||
|
||||
if (!aboutUs) {
|
||||
req.flash("error_msg", "About Us record not found");
|
||||
return res.redirect("/admin/about-us");
|
||||
}
|
||||
|
||||
await AboutUs.findByIdAndDelete(req.params.id);
|
||||
|
||||
req.flash("success_msg", "About Us record deleted successfully");
|
||||
res.redirect("/admin/about-us");
|
||||
} catch (err) {
|
||||
console.error("Delete error:", err);
|
||||
req.flash("error_msg", `Delete error: ${err.message || "Unknown"}`);
|
||||
res.redirect("/admin/about-us");
|
||||
}
|
||||
};
|
||||
|
||||
// Preview AboutUs record
|
||||
exports.preview = async (req, res) => {
|
||||
try {
|
||||
const aboutUs = await AboutUs.findById(req.params.id);
|
||||
|
||||
if (!aboutUs) {
|
||||
return res.status(404).json({error: "About Us record not found"});
|
||||
}
|
||||
|
||||
const processedData = addBaseUrlToImages(aboutUs.toObject());
|
||||
res.json(processedData);
|
||||
} catch (err) {
|
||||
console.error("Preview error:", err);
|
||||
res.status(500).json({error: "Error loading preview data"});
|
||||
}
|
||||
};
|
||||
1616
controllers/activityController.js
Normal file
1616
controllers/activityController.js
Normal file
File diff suppressed because it is too large
Load Diff
549
controllers/bookingController.js
Normal file
549
controllers/bookingController.js
Normal file
@@ -0,0 +1,549 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const Booking = require("../models/booking");
|
||||
|
||||
// -------------------- Public helpers --------------------
|
||||
const getBookingData = async () => {
|
||||
const booking = await Booking.findOne().sort({ updatedAt: -1 });
|
||||
return booking ? (booking.toObject ? booking.toObject() : booking) : null;
|
||||
};
|
||||
|
||||
// Load static booking JSON from `data/booking.json` (if present)
|
||||
const loadStaticBooking = () => {
|
||||
try {
|
||||
const p = path.join(__dirname, '..', 'data', 'booking.json');
|
||||
if (!fs.existsSync(p)) return null;
|
||||
const raw = fs.readFileSync(p, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.error('booking.loadStaticBooking error:', e && e.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Normalize booking shape: ensure configuration exists with discounts/vouchers
|
||||
const normalizeBookingShape = (booking) => {
|
||||
if (!booking || typeof booking !== 'object') return booking;
|
||||
const b = JSON.parse(JSON.stringify(booking));
|
||||
|
||||
if (!b.configuration || typeof b.configuration !== 'object') {
|
||||
b.configuration = { currency: 'USD', discounts: [], vouchers: [] };
|
||||
}
|
||||
|
||||
// Ensure configuration.discounts and configuration.vouchers exist
|
||||
if (!Array.isArray(b.configuration.discounts)) {
|
||||
b.configuration.discounts = [];
|
||||
}
|
||||
if (!Array.isArray(b.configuration.vouchers)) {
|
||||
b.configuration.vouchers = [];
|
||||
}
|
||||
|
||||
return b;
|
||||
};
|
||||
|
||||
// Deep merge: properties from `overrides` replace / merge into `base`.
|
||||
const deepMerge = (base, overrides) => {
|
||||
if (overrides === undefined) return base;
|
||||
if (base === undefined || base === null) return overrides;
|
||||
if (Array.isArray(overrides)) return overrides;
|
||||
if (typeof overrides !== 'object' || overrides === null) return overrides;
|
||||
const out = Object.assign({}, base);
|
||||
Object.keys(overrides).forEach((k) => {
|
||||
if (Array.isArray(overrides[k]) || typeof overrides[k] !== 'object' || overrides[k] === null) {
|
||||
out[k] = overrides[k];
|
||||
} else {
|
||||
out[k] = deepMerge(base[k], overrides[k]);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
// Ensure booking data fields have the expected shapes to avoid runtime errors
|
||||
const sanitizeBookingData = (raw) => {
|
||||
const defaults = {
|
||||
hero: { title: '', backgroundImage: '' },
|
||||
searchBar: { locationLabel: '', holidaySeasonLabel: '', searchButtonText: '' },
|
||||
filterPanel: {
|
||||
title: '',
|
||||
priceTitle: '',
|
||||
priceLabel: '',
|
||||
pricePlaceholder: '',
|
||||
priceMin: 0,
|
||||
priceMax: 0,
|
||||
ageTitle: '',
|
||||
ageMin: 0,
|
||||
ageMax: 0,
|
||||
ageSelectPlaceholder: '',
|
||||
activitiesTitle: '',
|
||||
ratingTitle: '',
|
||||
ratingOptions: [],
|
||||
resetButtonText: ''
|
||||
},
|
||||
programs: [],
|
||||
holidays: [],
|
||||
locations: [],
|
||||
camps: [],
|
||||
configuration: { currency: 'USD', discounts: [], vouchers: [] },
|
||||
formSteps: [],
|
||||
validation: {}
|
||||
};
|
||||
|
||||
if (!raw || typeof raw !== 'object') return defaults;
|
||||
|
||||
// Use raw data first, then fill in missing fields with defaults
|
||||
const safe = Object.assign({}, raw);
|
||||
|
||||
// Ensure nested objects/arrays have correct types (use raw data if valid, otherwise defaults)
|
||||
safe.hero = (safe.hero && typeof safe.hero === 'object') ? safe.hero : defaults.hero;
|
||||
safe.searchBar = (safe.searchBar && typeof safe.searchBar === 'object') ? safe.searchBar : defaults.searchBar;
|
||||
safe.filterPanel = (safe.filterPanel && typeof safe.filterPanel === 'object') ? safe.filterPanel : defaults.filterPanel;
|
||||
|
||||
if (!Array.isArray(safe.filterPanel.ratingOptions)) safe.filterPanel.ratingOptions = defaults.filterPanel.ratingOptions;
|
||||
|
||||
safe.programs = Array.isArray(safe.programs) ? safe.programs : defaults.programs;
|
||||
safe.holidays = Array.isArray(safe.holidays) ? safe.holidays : defaults.holidays;
|
||||
safe.locations = Array.isArray(safe.locations) ? safe.locations : defaults.locations;
|
||||
safe.camps = Array.isArray(safe.camps) ? safe.camps : defaults.camps;
|
||||
|
||||
// Ensure configuration has proper structure
|
||||
if (!safe.configuration || typeof safe.configuration !== 'object') {
|
||||
safe.configuration = defaults.configuration;
|
||||
}
|
||||
if (!Array.isArray(safe.configuration.discounts)) {
|
||||
safe.configuration.discounts = defaults.configuration.discounts;
|
||||
}
|
||||
if (!Array.isArray(safe.configuration.vouchers)) {
|
||||
safe.configuration.vouchers = defaults.configuration.vouchers;
|
||||
}
|
||||
|
||||
// Ensure formSteps and validation have correct types
|
||||
safe.formSteps = Array.isArray(safe.formSteps) ? safe.formSteps : defaults.formSteps;
|
||||
safe.validation = (safe.validation && typeof safe.validation === 'object' && !Array.isArray(safe.validation)) ? safe.validation : defaults.validation;
|
||||
|
||||
return safe;
|
||||
};
|
||||
|
||||
// Safe JSON parse with better error handling - handles double-encoded JSON and JS object notation
|
||||
const safeParse = (value, fieldName = 'unknown') => {
|
||||
// If already an object or array, return as-is
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// If string, try to parse
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
let cleaned = value.trim();
|
||||
|
||||
// Check if it looks like JavaScript object notation (has single quotes or unquoted keys)
|
||||
if (cleaned.includes("'") || /\{\s*\w+:/.test(cleaned)) {
|
||||
console.warn(`safeParse: Converting JS notation to JSON for "${fieldName}"`);
|
||||
|
||||
// Aggressive conversion approach
|
||||
cleaned = cleaned
|
||||
.replace(/'/g, '"') // Replace ALL single quotes with double quotes
|
||||
.replace(/\r?\n|\r/g, ' ') // Remove all newlines
|
||||
.replace(/\s+/g, ' ') // Normalize multiple spaces to single space
|
||||
.replace(/,(\s*[}\]])/g, '$1') // Remove trailing commas before } or ]
|
||||
.replace(/([{,]\s*)(\w+):/g, '$1"$2":'); // Quote unquoted object keys
|
||||
}
|
||||
|
||||
// Try parsing
|
||||
let parsed = JSON.parse(cleaned);
|
||||
|
||||
// If result is still a string, try parsing again (double-encoded)
|
||||
if (typeof parsed === 'string') {
|
||||
console.warn(`safeParse: Double-encoded JSON detected for "${fieldName}"`);
|
||||
parsed = JSON.parse(parsed);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
console.error(`safeParse: Failed to parse field "${fieldName}"`, {
|
||||
error: e.message,
|
||||
valuePreview: value.substring(0, 200)
|
||||
});
|
||||
|
||||
throw new Error(`Invalid JSON format for field: ${fieldName}. Error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// For other types, return empty array or object
|
||||
console.warn(`safeParse: Unexpected type for field "${fieldName}": ${typeof value}`);
|
||||
return Array.isArray(value) ? [] : {};
|
||||
};
|
||||
|
||||
// Validate booking data structure
|
||||
const validateBookingData = (data) => {
|
||||
const errors = [];
|
||||
|
||||
// Check required fields
|
||||
if (!data.hero || typeof data.hero !== 'object') {
|
||||
errors.push('Hero data is required and must be an object');
|
||||
}
|
||||
|
||||
if (!data.searchBar || typeof data.searchBar !== 'object') {
|
||||
errors.push('SearchBar data is required and must be an object');
|
||||
}
|
||||
|
||||
if (!data.filterPanel || typeof data.filterPanel !== 'object') {
|
||||
errors.push('FilterPanel data is required and must be an object');
|
||||
}
|
||||
|
||||
// Validate arrays
|
||||
if (data.programs && !Array.isArray(data.programs)) {
|
||||
errors.push('Programs must be an array');
|
||||
}
|
||||
|
||||
if (data.holidays && !Array.isArray(data.holidays)) {
|
||||
errors.push('Holidays must be an array');
|
||||
}
|
||||
|
||||
if (data.locations && !Array.isArray(data.locations)) {
|
||||
errors.push('Locations must be an array');
|
||||
}
|
||||
|
||||
if (data.camps && !Array.isArray(data.camps)) {
|
||||
errors.push('Camps must be an array');
|
||||
}
|
||||
|
||||
// Validate configuration structure
|
||||
if (data.configuration) {
|
||||
if (typeof data.configuration !== 'object') {
|
||||
errors.push('Configuration must be an object');
|
||||
} else {
|
||||
if (data.configuration.discounts && !Array.isArray(data.configuration.discounts)) {
|
||||
errors.push('Configuration.discounts must be an array');
|
||||
}
|
||||
if (data.configuration.vouchers && !Array.isArray(data.configuration.vouchers)) {
|
||||
errors.push('Configuration.vouchers must be an array');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate formSteps and validation structure if provided
|
||||
if (data.formSteps && !Array.isArray(data.formSteps)) {
|
||||
errors.push('formSteps must be an array');
|
||||
}
|
||||
|
||||
if (data.validation && (typeof data.validation !== 'object' || Array.isArray(data.validation))) {
|
||||
errors.push('validation must be an object');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
};
|
||||
|
||||
// -------------------- Public endpoints --------------------
|
||||
// Public endpoint: return Booking JSON
|
||||
exports.page = async (req, res) => {
|
||||
try {
|
||||
const dbBooking = await getBookingData();
|
||||
const staticBooking = loadStaticBooking();
|
||||
|
||||
// Normalize shapes so `configuration.discounts`/`configuration.vouchers` exist
|
||||
const normStatic = normalizeBookingShape(staticBooking);
|
||||
const normDb = normalizeBookingShape(dbBooking);
|
||||
|
||||
// Build final payload according to BOOKING_MODE env var
|
||||
const finalBooking = getFinalBooking(normStatic, normDb);
|
||||
|
||||
if (!finalBooking) {
|
||||
return res.status(404).json({
|
||||
error: "No booking data found",
|
||||
message: "Please configure booking data in admin panel"
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processed = addBaseUrlToImages(finalBooking, baseUrl);
|
||||
|
||||
return res.json(processed);
|
||||
} catch (err) {
|
||||
console.error("booking.page error:", err);
|
||||
return res.status(500).json({
|
||||
error: "Error loading booking data",
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// API endpoint to return booking JSON
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const dbBooking = await getBookingData();
|
||||
const staticBooking = loadStaticBooking();
|
||||
|
||||
// Normalize shapes so `configuration.discounts`/`configuration.vouchers` exist
|
||||
const normStatic = normalizeBookingShape(staticBooking);
|
||||
const normDb = normalizeBookingShape(dbBooking);
|
||||
|
||||
const finalBooking = getFinalBooking(normStatic, normDb);
|
||||
|
||||
if (!finalBooking) {
|
||||
return res.status(404).json({
|
||||
error: "No booking data found",
|
||||
message: "Please configure booking data in admin panel"
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processed = addBaseUrlToImages(finalBooking, baseUrl);
|
||||
|
||||
return res.json(processed);
|
||||
} catch (err) {
|
||||
console.error("booking.api error:", err);
|
||||
return res.status(500).json({
|
||||
error: "Error loading booking data",
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------- Admin endpoints --------------------
|
||||
// Display Booking management page
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const dbBooking = await getBookingData();
|
||||
const staticBooking = loadStaticBooking();
|
||||
|
||||
// Merge static booking with DB data (use same merge logic as public endpoints)
|
||||
const normStatic = normalizeBookingShape(staticBooking);
|
||||
const normDb = normalizeBookingShape(dbBooking);
|
||||
const mergedData = getFinalBooking(normStatic, normDb);
|
||||
|
||||
// Normalize again after merge to ensure discounts/vouchers are synced to top-level
|
||||
const data = normalizeBookingShape(mergedData);
|
||||
|
||||
// Sanitize data to ensure nested fields are objects/arrays (fixes malformed DB entries)
|
||||
const safeData = sanitizeBookingData(data);
|
||||
|
||||
res.render("admin/booking/index", {
|
||||
layout: "layouts/main",
|
||||
title: "Booking Management",
|
||||
data: safeData,
|
||||
frontendUrl: process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("booking.index error:", err);
|
||||
req.flash("error_msg", "Error loading booking page");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Update booking data
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
// ADD THIS DEBUG LOG
|
||||
console.log('=== RAW REQUEST BODY ===');
|
||||
console.log('Discounts type:', typeof req.body.discounts);
|
||||
console.log('Discounts first 500 chars:', req.body.discounts?.substring(0, 500));
|
||||
console.log('Vouchers type:', typeof req.body.vouchers);
|
||||
console.log('Vouchers first 500 chars:', req.body.vouchers?.substring(0, 500));
|
||||
console.log('========================');
|
||||
const {
|
||||
hero,
|
||||
searchBar,
|
||||
filterPanel,
|
||||
programs,
|
||||
holidays,
|
||||
locations,
|
||||
camps,
|
||||
discounts,
|
||||
vouchers,
|
||||
formSteps,
|
||||
validation: validationRaw
|
||||
} = req.body;
|
||||
|
||||
// Parse JSON strings
|
||||
const errors = [];
|
||||
let updateData = {};
|
||||
|
||||
try {
|
||||
console.log('Raw discounts from req.body:', typeof discounts, discounts);
|
||||
console.log('Raw vouchers from req.body:', typeof vouchers, vouchers);
|
||||
|
||||
const parsedDiscounts = safeParse(discounts, 'discounts');
|
||||
const parsedVouchers = safeParse(vouchers, 'vouchers');
|
||||
|
||||
console.log('Parsed discounts:', typeof parsedDiscounts, Array.isArray(parsedDiscounts), parsedDiscounts);
|
||||
console.log('Parsed vouchers:', typeof parsedVouchers, Array.isArray(parsedVouchers), parsedVouchers);
|
||||
|
||||
updateData = {
|
||||
hero: safeParse(hero, 'hero'),
|
||||
searchBar: safeParse(searchBar, 'searchBar'),
|
||||
filterPanel: safeParse(filterPanel, 'filterPanel'),
|
||||
programs: safeParse(programs, 'programs'),
|
||||
holidays: safeParse(holidays, 'holidays'),
|
||||
locations: safeParse(locations, 'locations'),
|
||||
camps: safeParse(camps, 'camps'),
|
||||
formSteps: safeParse(formSteps, 'formSteps'),
|
||||
validation: safeParse(validationRaw, 'validation'),
|
||||
configuration: {
|
||||
currency: 'USD',
|
||||
discounts: parsedDiscounts,
|
||||
vouchers: parsedVouchers
|
||||
}
|
||||
};
|
||||
} catch (parseError) {
|
||||
console.error('booking.update: Parse error', parseError);
|
||||
req.flash("error_msg", `Data processing error: ${parseError.message}`);
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
|
||||
// Validate data structure
|
||||
const validation = validateBookingData(updateData);
|
||||
if (!validation.isValid) {
|
||||
console.error('booking.update: Validation failed', validation.errors);
|
||||
req.flash("error_msg", `Validation failed: ${validation.errors[0]}`);
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
|
||||
console.log('Final updateData keys:', Object.keys(updateData));
|
||||
console.log('updateData.discounts:', updateData.discounts);
|
||||
console.log('updateData.configuration:', updateData.configuration);
|
||||
|
||||
// CRITICAL: Remove any top-level discounts/vouchers to prevent schema conflicts
|
||||
// These should ONLY exist in configuration object
|
||||
delete updateData.discounts;
|
||||
delete updateData.vouchers;
|
||||
|
||||
// Update or create booking document
|
||||
let result;
|
||||
try {
|
||||
if (id && id !== 'undefined') {
|
||||
result = await Booking.findByIdAndUpdate(
|
||||
id,
|
||||
{
|
||||
...updateData,
|
||||
$unset: { discounts: "", vouchers: "" } // Remove old top-level fields
|
||||
},
|
||||
{
|
||||
new: true,
|
||||
runValidators: false, // TẮT validator để tránh lỗi cast
|
||||
strict: false // TẮT strict mode
|
||||
}
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
req.flash("error_msg", "Booking document not found");
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
} else {
|
||||
// Upsert: update existing or create new
|
||||
result = await Booking.findOneAndUpdate(
|
||||
{},
|
||||
{
|
||||
...updateData,
|
||||
$unset: { discounts: "", vouchers: "" } // Remove old top-level fields
|
||||
},
|
||||
{
|
||||
upsert: true,
|
||||
new: true,
|
||||
runValidators: false, // TẮT validator
|
||||
strict: false // TẮT strict mode
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
req.flash("success_msg", "Booking data updated successfully");
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
} catch (dbError) {
|
||||
console.error("booking.update: Database error", dbError);
|
||||
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("booking.update error:", err);
|
||||
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
};
|
||||
|
||||
// Booking selection mode: 'merge' (default) = static base, DB overrides;
|
||||
// 'static' = use `data/booking.json` only; 'db' = use DB only.
|
||||
const getFinalBooking = (staticBooking, dbBooking) => {
|
||||
const mode = (process.env.BOOKING_MODE || 'merge').toLowerCase();
|
||||
if (mode === 'static') return staticBooking || dbBooking || null;
|
||||
if (mode === 'db') return dbBooking || staticBooking || null;
|
||||
// default: merge static (base) with DB overrides
|
||||
// If both static and db present, attempt to map DB primitive lists (e.g. ["915"]) to
|
||||
// full objects from the static file (e.g. {id:"915", name:..., type:...}).
|
||||
const mapDbPrimitivesToObjects = (db, stat) => {
|
||||
if (!db || !stat) return db;
|
||||
const dbCfg = db.configuration || {};
|
||||
const statCfg = stat.configuration || {};
|
||||
|
||||
console.log('DB discounts/vouchers:', db.discounts, db.vouchers);
|
||||
console.log('DB config:', dbCfg.discounts, dbCfg.vouchers);
|
||||
console.log('Static config:', statCfg.discounts, statCfg.vouchers);
|
||||
|
||||
// Handle legacy: if top-level discounts/vouchers exist as strings, migrate to configuration
|
||||
if (Array.isArray(db.discounts) && db.discounts.length > 0 && (!dbCfg.discounts || dbCfg.discounts.length === 0)) {
|
||||
const statDiscountById = {};
|
||||
if (statCfg.discounts) {
|
||||
statCfg.discounts.forEach(d => { if (d && d.id) statDiscountById[String(d.id)] = d; });
|
||||
}
|
||||
if (typeof db.discounts[0] === 'string') {
|
||||
dbCfg.discounts = db.discounts.map(s => statDiscountById[String(s)] || { id: s, name: '', type: 'percentage', value: 0 });
|
||||
} else {
|
||||
dbCfg.discounts = db.discounts;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(db.vouchers) && db.vouchers.length > 0 && (!dbCfg.vouchers || dbCfg.vouchers.length === 0)) {
|
||||
const statVouchByCode = {};
|
||||
if (statCfg.vouchers) {
|
||||
statCfg.vouchers.forEach(v => { if (v && v.validCodes) statVouchByCode[String(v.validCodes)] = v; });
|
||||
}
|
||||
if (typeof db.vouchers[0] === 'string') {
|
||||
dbCfg.vouchers = db.vouchers.map(s => statVouchByCode[String(s)] || { validCodes: s, type: 'percentage', value: 0 });
|
||||
} else {
|
||||
dbCfg.vouchers = db.vouchers;
|
||||
}
|
||||
}
|
||||
|
||||
// If DB configuration still empty, use static data
|
||||
if (Array.isArray(dbCfg.discounts) && dbCfg.discounts.length === 0 && statCfg.discounts && statCfg.discounts.length > 0) {
|
||||
dbCfg.discounts = statCfg.discounts;
|
||||
} else if (Array.isArray(dbCfg.discounts) && dbCfg.discounts.length > 0 && typeof dbCfg.discounts[0] === 'string') {
|
||||
// Map string IDs to full objects from static
|
||||
const statDiscountById = {};
|
||||
if (statCfg.discounts) {
|
||||
statCfg.discounts.forEach(d => { if (d && d.id) statDiscountById[String(d.id)] = d; });
|
||||
}
|
||||
dbCfg.discounts = dbCfg.discounts.map(s => statDiscountById[String(s)] || { id: s, name: '', type: 'percentage', value: 0 });
|
||||
}
|
||||
|
||||
if (Array.isArray(dbCfg.vouchers) && dbCfg.vouchers.length === 0 && statCfg.vouchers && statCfg.vouchers.length > 0) {
|
||||
dbCfg.vouchers = statCfg.vouchers;
|
||||
} else if (Array.isArray(dbCfg.vouchers) && dbCfg.vouchers.length > 0 && typeof dbCfg.vouchers[0] === 'string') {
|
||||
// Map string codes to full objects from static
|
||||
const statVouchByCode = {};
|
||||
if (statCfg.vouchers) {
|
||||
statCfg.vouchers.forEach(v => { if (v && v.validCodes) statVouchByCode[String(v.validCodes)] = v; });
|
||||
}
|
||||
dbCfg.vouchers = dbCfg.vouchers.map(s => statVouchByCode[String(s)] || { validCodes: s, type: 'percentage', value: 0 });
|
||||
}
|
||||
|
||||
return Object.assign({}, db, { configuration: dbCfg });
|
||||
};
|
||||
|
||||
const mappedDb = mapDbPrimitivesToObjects(dbBooking, staticBooking);
|
||||
const merged = staticBooking ? deepMerge(staticBooking, mappedDb || {}) : (mappedDb || null);
|
||||
|
||||
// Clean up: remove top-level discounts/vouchers after migrating to configuration
|
||||
if (merged) {
|
||||
delete merged.discounts;
|
||||
delete merged.vouchers;
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
558
controllers/bookingSubmissionController.js
Normal file
558
controllers/bookingSubmissionController.js
Normal file
@@ -0,0 +1,558 @@
|
||||
const BookingSubmission = require('../models/bookingSubmission');
|
||||
const Activity = require('../models/activity');
|
||||
|
||||
// API endpoint để tạo booking submission mới
|
||||
exports.submitBooking = async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
activityId,
|
||||
sessionId,
|
||||
parentFirstName,
|
||||
parentLastName,
|
||||
email,
|
||||
phone,
|
||||
address,
|
||||
city,
|
||||
country,
|
||||
postalCode,
|
||||
participantFirstName,
|
||||
participantLastName,
|
||||
participantBirthDate,
|
||||
participantGender,
|
||||
numberOfParticipants,
|
||||
medicalConditions,
|
||||
dietaryRestrictions,
|
||||
specialRequests,
|
||||
emergencyContact,
|
||||
emergencyPhone,
|
||||
agreeTerms,
|
||||
agreeNewsletter
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!activityId || !sessionId || !parentFirstName || !parentLastName ||
|
||||
!email || !phone || !address || !city || !country || !postalCode ||
|
||||
!participantFirstName || !participantLastName || !participantBirthDate ||
|
||||
!participantGender || !emergencyContact || !emergencyPhone || !agreeTerms) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields',
|
||||
message: 'Please fill in all required fields'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify activity exists
|
||||
const activity = await Activity.findById(activityId);
|
||||
if (!activity) {
|
||||
return res.status(404).json({
|
||||
error: 'Activity not found',
|
||||
message: 'The selected activity does not exist'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify session exists and is active
|
||||
const session = activity.bookingSessions?.find(s => s.sessionId === sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).json({
|
||||
error: 'Session not found',
|
||||
message: 'The selected session does not exist'
|
||||
});
|
||||
}
|
||||
|
||||
if (!session.isActive) {
|
||||
return res.status(400).json({
|
||||
error: 'Session not available',
|
||||
message: 'The selected session is no longer available for booking'
|
||||
});
|
||||
}
|
||||
|
||||
// Check availability based on participant gender
|
||||
const currentBookings = await BookingSubmission.countDocuments({
|
||||
activityId,
|
||||
sessionId,
|
||||
participantGender,
|
||||
status: { $in: ['pending', 'confirmed'] }
|
||||
});
|
||||
|
||||
const availableSpots = participantGender === 'male'
|
||||
? session.totalMaleSpots - session.bookedMaleSpots
|
||||
: session.totalFemaleSpots - session.bookedFemaleSpots;
|
||||
|
||||
if (currentBookings >= availableSpots) {
|
||||
return res.status(400).json({
|
||||
error: 'Session full',
|
||||
message: `No more spots available for ${participantGender} participants in this session`
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate total amount based on activity price and number of participants
|
||||
const totalAmount = (activity.price || 0) * (parseInt(numberOfParticipants) || 1);
|
||||
|
||||
// Create booking submission
|
||||
const bookingSubmission = new BookingSubmission({
|
||||
activityId,
|
||||
sessionId,
|
||||
parentFirstName: parentFirstName.trim(),
|
||||
parentLastName: parentLastName.trim(),
|
||||
email: email.toLowerCase().trim(),
|
||||
phone: phone.trim(),
|
||||
address: address.trim(),
|
||||
city: city.trim(),
|
||||
country: country.trim(),
|
||||
postalCode: postalCode.trim(),
|
||||
participantFirstName: participantFirstName.trim(),
|
||||
participantLastName: participantLastName.trim(),
|
||||
participantBirthDate: new Date(participantBirthDate),
|
||||
participantGender,
|
||||
numberOfParticipants: parseInt(numberOfParticipants) || 1,
|
||||
medicalConditions: (medicalConditions || '').trim(),
|
||||
dietaryRestrictions: dietaryRestrictions || 'none',
|
||||
specialRequests: (specialRequests || '').trim(),
|
||||
emergencyContact: emergencyContact.trim(),
|
||||
emergencyPhone: emergencyPhone.trim(),
|
||||
agreeTerms: Boolean(agreeTerms),
|
||||
agreeNewsletter: Boolean(agreeNewsletter),
|
||||
totalAmount,
|
||||
status: 'pending',
|
||||
paymentStatus: 'pending'
|
||||
});
|
||||
|
||||
await bookingSubmission.save();
|
||||
|
||||
// Update session booked spots
|
||||
const updateField = participantGender === 'male' ? 'bookingSessions.$.bookedMaleSpots' : 'bookingSessions.$.bookedFemaleSpots';
|
||||
await Activity.updateOne(
|
||||
{ _id: activityId, 'bookingSessions.sessionId': sessionId },
|
||||
{ $inc: { [updateField]: 1 } }
|
||||
);
|
||||
|
||||
// Populate activity info for response
|
||||
await bookingSubmission.populate('activityId', 'name price');
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
message: 'Booking submitted successfully',
|
||||
booking: {
|
||||
id: bookingSubmission._id,
|
||||
activityName: bookingSubmission.activityId.name,
|
||||
sessionId: bookingSubmission.sessionId,
|
||||
participantName: `${bookingSubmission.participantFirstName} ${bookingSubmission.participantLastName}`,
|
||||
totalAmount: bookingSubmission.totalAmount,
|
||||
status: bookingSubmission.status
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('submitBooking error:', error);
|
||||
|
||||
// Handle validation errors
|
||||
if (error.name === 'ValidationError') {
|
||||
const validationErrors = Object.values(error.errors).map(err => err.message);
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
message: validationErrors.join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Server error',
|
||||
message: 'An error occurred while processing your booking. Please try again.'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint để lấy thông tin session availability
|
||||
exports.getSessionAvailability = async (req, res) => {
|
||||
try {
|
||||
const { activityId, sessionId } = req.params;
|
||||
|
||||
const activity = await Activity.findById(activityId);
|
||||
if (!activity) {
|
||||
return res.status(404).json({ error: 'Activity not found' });
|
||||
}
|
||||
|
||||
const session = activity.bookingSessions?.find(s => s.sessionId === sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
// Get current booking counts
|
||||
const maleBookings = await BookingSubmission.countDocuments({
|
||||
activityId,
|
||||
sessionId,
|
||||
participantGender: 'male',
|
||||
status: { $in: ['pending', 'confirmed'] }
|
||||
});
|
||||
|
||||
const femaleBookings = await BookingSubmission.countDocuments({
|
||||
activityId,
|
||||
sessionId,
|
||||
participantGender: 'female',
|
||||
status: { $in: ['pending', 'confirmed'] }
|
||||
});
|
||||
|
||||
return res.json({
|
||||
sessionId,
|
||||
isActive: session.isActive,
|
||||
startDate: session.startDate,
|
||||
endDate: session.endDate,
|
||||
overnightStays: session.overnightStays,
|
||||
price: session.price || activity.price,
|
||||
availability: {
|
||||
male: {
|
||||
total: session.totalMaleSpots,
|
||||
booked: maleBookings,
|
||||
available: Math.max(0, session.totalMaleSpots - maleBookings)
|
||||
},
|
||||
female: {
|
||||
total: session.totalFemaleSpots,
|
||||
booked: femaleBookings,
|
||||
available: Math.max(0, session.totalFemaleSpots - femaleBookings)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('getSessionAvailability error:', error);
|
||||
return res.status(500).json({ error: 'Error loading session availability' });
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint để lấy tất cả sessions có sẵn cho một activity
|
||||
exports.getAvailableSessions = async (req, res) => {
|
||||
try {
|
||||
const { activityId } = req.params;
|
||||
|
||||
const activity = await Activity.findById(activityId);
|
||||
if (!activity) {
|
||||
return res.status(404).json({ error: 'Activity not found' });
|
||||
}
|
||||
|
||||
const sessions = activity.bookingSessions || [];
|
||||
const availableSessions = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!session.isActive) continue;
|
||||
|
||||
// Get current booking counts
|
||||
const maleBookings = await BookingSubmission.countDocuments({
|
||||
activityId,
|
||||
sessionId: session.sessionId,
|
||||
participantGender: 'male',
|
||||
status: { $in: ['pending', 'confirmed'] }
|
||||
});
|
||||
|
||||
const femaleBookings = await BookingSubmission.countDocuments({
|
||||
activityId,
|
||||
sessionId: session.sessionId,
|
||||
participantGender: 'female',
|
||||
status: { $in: ['pending', 'confirmed'] }
|
||||
});
|
||||
|
||||
const maleAvailable = Math.max(0, session.totalMaleSpots - maleBookings);
|
||||
const femaleAvailable = Math.max(0, session.totalFemaleSpots - femaleBookings);
|
||||
|
||||
// Only include sessions that have available spots
|
||||
if (maleAvailable > 0 || femaleAvailable > 0) {
|
||||
availableSessions.push({
|
||||
sessionId: session.sessionId,
|
||||
startDate: session.startDate,
|
||||
endDate: session.endDate,
|
||||
overnightStays: session.overnightStays,
|
||||
price: session.price || activity.price,
|
||||
availability: {
|
||||
male: {
|
||||
total: session.totalMaleSpots,
|
||||
booked: maleBookings,
|
||||
available: maleAvailable
|
||||
},
|
||||
female: {
|
||||
total: session.totalFemaleSpots,
|
||||
booked: femaleBookings,
|
||||
available: femaleAvailable
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
activityId,
|
||||
activityName: activity.name,
|
||||
sessions: availableSessions
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('getAvailableSessions error:', error);
|
||||
return res.status(500).json({ error: 'Error loading available sessions' });
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint để cập nhật booking submission
|
||||
exports.updateBookingSubmission = async (req, res) => {
|
||||
try {
|
||||
const { bookingId } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
// Find the booking
|
||||
let booking = await BookingSubmission.findById(bookingId);
|
||||
|
||||
// If not found as a separate document, try to find it as an embedded booking in Activity.bookingSessions
|
||||
let activityContaining = null;
|
||||
let sessionIndex = -1;
|
||||
let bookingIndex = -1;
|
||||
if (!booking) {
|
||||
activityContaining = await Activity.findOne({ 'bookingSessions.bookingList._id': bookingId });
|
||||
if (!activityContaining) {
|
||||
return res.status(404).json({
|
||||
error: 'Booking not found',
|
||||
message: 'The booking submission does not exist'
|
||||
});
|
||||
}
|
||||
|
||||
// locate the exact session and booking positions
|
||||
for (let si = 0; si < activityContaining.bookingSessions.length; si++) {
|
||||
const bl = activityContaining.bookingSessions[si].bookingList || [];
|
||||
const bi = bl.findIndex(b => b._id && b._id.toString() === bookingId.toString());
|
||||
if (bi !== -1) {
|
||||
sessionIndex = si;
|
||||
bookingIndex = bi;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionIndex === -1 || bookingIndex === -1) {
|
||||
return res.status(404).json({ error: 'Booking not found', message: 'The booking submission does not exist' });
|
||||
}
|
||||
|
||||
booking = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
|
||||
}
|
||||
|
||||
// Define allowed fields to update
|
||||
const allowedUpdates = [
|
||||
'status',
|
||||
'paymentStatus',
|
||||
'paidAmount',
|
||||
'totalAmount',
|
||||
'adminNotes',
|
||||
'emergencyContact',
|
||||
'emergencyPhone',
|
||||
'medicalConditions',
|
||||
'dietaryRestrictions',
|
||||
'specialRequests'
|
||||
];
|
||||
|
||||
// Build update object with only allowed fields
|
||||
const updateFields = {};
|
||||
for (const field of allowedUpdates) {
|
||||
if (updateData[field] !== undefined) {
|
||||
updateFields[field] = updateData[field];
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateFields).length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'No valid fields to update',
|
||||
message: 'Please provide at least one valid field to update'
|
||||
});
|
||||
}
|
||||
|
||||
// If booking is a separate document, update the BookingSubmission collection
|
||||
if (activityContaining === null) {
|
||||
const updatedBooking = await BookingSubmission.findByIdAndUpdate(
|
||||
bookingId,
|
||||
updateFields,
|
||||
{ new: true, runValidators: true }
|
||||
).populate('activityId', 'name price');
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Booking updated successfully',
|
||||
booking: updatedBooking
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise update the embedded booking in the Activity document
|
||||
const currentBooking = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
|
||||
|
||||
// Handle status updates and spot adjustments
|
||||
const newStatus = updateData.status || updateData.bookingStatus;
|
||||
const currentStatus = currentBooking.status || currentBooking.bookingStatus;
|
||||
|
||||
// Apply allowed updates to the embedded booking
|
||||
const allowedEmbeddedUpdates = [
|
||||
'status', 'bookingStatus', 'paymentStatus', 'paidAmount', 'totalAmount', 'adminNotes',
|
||||
'emergencyContact', 'emergencyPhone', 'medicalConditions', 'dietaryRestrictions', 'specialRequests'
|
||||
];
|
||||
|
||||
for (const field of allowedEmbeddedUpdates) {
|
||||
if (updateData[field] !== undefined) {
|
||||
if (field === 'status') {
|
||||
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex].status = updateData.status;
|
||||
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex].bookingStatus = updateData.status;
|
||||
} else {
|
||||
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex][field] = updateData[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If status change affects spots, adjust counts
|
||||
if (newStatus && newStatus !== currentStatus) {
|
||||
const numberOfParticipants = currentBooking.numberOfParticipants || 1;
|
||||
const participantGender = currentBooking.participantGender;
|
||||
|
||||
// If booking is being cancelled, free up spots
|
||||
if (newStatus === 'cancelled' && currentStatus !== 'cancelled') {
|
||||
if (participantGender === 'male') {
|
||||
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedMaleSpots - numberOfParticipants);
|
||||
} else if (participantGender === 'female') {
|
||||
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots - numberOfParticipants);
|
||||
}
|
||||
}
|
||||
|
||||
// If restoring from cancelled, ensure capacity then book spots
|
||||
if (currentStatus === 'cancelled' && newStatus !== 'cancelled') {
|
||||
if (participantGender === 'male') {
|
||||
const totalMale = activityContaining.bookingSessions[sessionIndex].totalMaleSpots;
|
||||
const currentMale = activityContaining.bookingSessions[sessionIndex].bookedMaleSpots;
|
||||
if (currentMale + numberOfParticipants > totalMale) {
|
||||
return res.status(400).json({ error: "Not enough male spots available to restore this booking" });
|
||||
}
|
||||
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots += numberOfParticipants;
|
||||
} else if (participantGender === 'female') {
|
||||
const totalFemale = activityContaining.bookingSessions[sessionIndex].totalFemaleSpots;
|
||||
const currentFemale = activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots;
|
||||
if (currentFemale + numberOfParticipants > totalFemale) {
|
||||
return res.status(400).json({ error: "Not enough female spots available to restore this booking" });
|
||||
}
|
||||
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots += numberOfParticipants;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await activityContaining.save();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Embedded booking updated successfully',
|
||||
booking: activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('updateBookingSubmission error:', error);
|
||||
|
||||
// Handle validation errors
|
||||
if (error.name === 'ValidationError') {
|
||||
const validationErrors = Object.values(error.errors).map(err => err.message);
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
message: validationErrors.join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Server error',
|
||||
message: 'An error occurred while updating the booking'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint để xóa booking submission
|
||||
exports.deleteBookingSubmission = async (req, res) => {
|
||||
try {
|
||||
const { bookingId } = req.params;
|
||||
|
||||
// Find and delete the booking
|
||||
let booking = await BookingSubmission.findById(bookingId);
|
||||
|
||||
// If not found in separate collection, try to delete embedded booking in Activity
|
||||
if (!booking) {
|
||||
const activityContaining = await Activity.findOne({ 'bookingSessions.bookingList._id': bookingId });
|
||||
if (!activityContaining) {
|
||||
return res.status(404).json({
|
||||
error: 'Booking not found',
|
||||
message: 'The booking submission does not exist'
|
||||
});
|
||||
}
|
||||
|
||||
// locate session and booking
|
||||
let sessionIndex = -1;
|
||||
let bookingIndex = -1;
|
||||
for (let si = 0; si < activityContaining.bookingSessions.length; si++) {
|
||||
const bl = activityContaining.bookingSessions[si].bookingList || [];
|
||||
const bi = bl.findIndex(b => b._id && b._id.toString() === bookingId.toString());
|
||||
if (bi !== -1) {
|
||||
sessionIndex = si;
|
||||
bookingIndex = bi;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionIndex === -1 || bookingIndex === -1) {
|
||||
return res.status(404).json({ error: 'Booking not found', message: 'The booking submission does not exist' });
|
||||
}
|
||||
|
||||
const bookingToDelete = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
|
||||
|
||||
// Free up spots if booking is not cancelled
|
||||
if ((bookingToDelete.bookingStatus || bookingToDelete.status) !== 'cancelled') {
|
||||
const numberOfParticipants = bookingToDelete.numberOfParticipants || 1;
|
||||
const participantGender = bookingToDelete.participantGender;
|
||||
|
||||
if (participantGender === 'male') {
|
||||
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedMaleSpots - numberOfParticipants);
|
||||
} else if (participantGender === 'female') {
|
||||
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots - numberOfParticipants);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove booking and save
|
||||
activityContaining.bookingSessions[sessionIndex].bookingList.splice(bookingIndex, 1);
|
||||
await activityContaining.save();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Embedded booking deleted successfully',
|
||||
booking: {
|
||||
id: bookingId,
|
||||
participantName: `${bookingToDelete.participantFirstName} ${bookingToDelete.participantLastName}`,
|
||||
email: bookingToDelete.email
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Store info for session spot adjustment
|
||||
const { activityId, sessionId, participantGender, numberOfParticipants } = booking;
|
||||
|
||||
// Delete the booking
|
||||
await BookingSubmission.findByIdAndDelete(bookingId);
|
||||
|
||||
// Update session booked spots (decrease the count)
|
||||
if (booking.status !== 'cancelled') {
|
||||
const updateField = participantGender === 'male'
|
||||
? 'bookingSessions.$.bookedMaleSpots'
|
||||
: 'bookingSessions.$.bookedFemaleSpots';
|
||||
|
||||
await Activity.updateOne(
|
||||
{ _id: activityId, 'bookingSessions.sessionId': sessionId },
|
||||
{ $inc: { [updateField]: -numberOfParticipants } }
|
||||
);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Booking deleted successfully',
|
||||
booking: {
|
||||
id: bookingId,
|
||||
participantName: `${booking.participantFirstName} ${booking.participantLastName}`,
|
||||
email: booking.email
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('deleteBookingSubmission error:', error);
|
||||
return res.status(500).json({
|
||||
error: 'Server error',
|
||||
message: 'An error occurred while deleting the booking'
|
||||
});
|
||||
}
|
||||
};
|
||||
322
controllers/campLocationController.js
Normal file
322
controllers/campLocationController.js
Normal file
@@ -0,0 +1,322 @@
|
||||
const { addBaseUrlToImages } = require('../utils/imageHelper');
|
||||
const CampLocation = require('../models/campLocation');
|
||||
|
||||
// -------------------- Public (read-only) helpers --------------------
|
||||
// Get camp location data from MongoDB
|
||||
const getCampLocationData = async () => {
|
||||
const campLocation = await CampLocation.findOne().sort({ updatedAt: -1 });
|
||||
if (!campLocation) return null;
|
||||
return campLocation.toObject ? campLocation.toObject() : campLocation;
|
||||
};
|
||||
|
||||
// -------------------- Admin (CRUD on CampLocation model) helpers --------------------
|
||||
// Default shape for CampLocation documents
|
||||
const getDefaultCampLocationData = () => ({
|
||||
metadata: { title: '', description: '', keywords: '' },
|
||||
hero: { title: '', subtitle: '', backgroundImage: '' },
|
||||
camps: [],
|
||||
locations: [],
|
||||
locationsSection: { title: '', description: '' },
|
||||
intro: { title: '', description: '' },
|
||||
map: {
|
||||
coordinates: { lat: 0, lng: 0 },
|
||||
zoom: 15,
|
||||
location: '',
|
||||
markerTitle: '',
|
||||
tileLayer: {
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '',
|
||||
maxZoom: 18,
|
||||
minZoom: 0
|
||||
}
|
||||
},
|
||||
faq: [],
|
||||
faqSection: { title: '', description: '' },
|
||||
welcomeQuote: { text: '', author: '' },
|
||||
securityConcept: { title: '', description: '', items: [] }
|
||||
});
|
||||
|
||||
// Get latest stored CampLocation document or default
|
||||
const getStoredCampLocation = async () => {
|
||||
const campLocation = await CampLocation.findOne().sort({ updatedAt: -1 });
|
||||
if (!campLocation) return getDefaultCampLocationData();
|
||||
return campLocation.toObject ? campLocation.toObject() : campLocation;
|
||||
};
|
||||
|
||||
// -------------------- Public exports --------------------
|
||||
// API endpoint to return camp location JSON
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const campLocationData = await getCampLocationData();
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processed = addBaseUrlToImages(campLocationData || {}, baseUrl);
|
||||
return res.json(processed);
|
||||
} catch (err) {
|
||||
console.error('campLocation.api error:', err);
|
||||
return res.status(500).json({ error: 'Error loading camp location data' });
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint to return an array of CampLocation records (for frontend listing)
|
||||
exports.apiList = async (req, res) => {
|
||||
try {
|
||||
const docs = await CampLocation.find().sort({ updatedAt: -1 }).lean();
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processed = (docs || []).map((d) => addBaseUrlToImages(d || {}, baseUrl));
|
||||
return res.json(processed);
|
||||
} catch (err) {
|
||||
console.error('campLocation.apiList error:', err);
|
||||
return res.status(500).json({ error: 'Error loading camp location list' });
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------- Admin exports --------------------
|
||||
// Display camp location management page
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
// Set cache control headers to prevent caching
|
||||
res.set({
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
});
|
||||
|
||||
const data = await getStoredCampLocation();
|
||||
const items = await CampLocation.find().sort({ updatedAt: -1 }).limit(10);
|
||||
|
||||
res.render('admin/campLocation/index', {
|
||||
layout: 'layouts/main',
|
||||
title: 'Camp Location Management',
|
||||
data,
|
||||
items,
|
||||
frontendUrl: process.env.FRONTEND_URL || `${req.protocol}://${req.get('host')}`,
|
||||
currentPath: req.path,
|
||||
user: req.session.user
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash('error_msg', 'Error loading camp location data');
|
||||
res.redirect('/admin/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
// Display create form
|
||||
exports.createForm = async (req, res) => {
|
||||
try {
|
||||
const data = getDefaultCampLocationData();
|
||||
|
||||
res.render('admin/campLocation/create', {
|
||||
layout: 'layouts/main',
|
||||
title: 'Create Camp Location',
|
||||
data,
|
||||
currentPath: req.path,
|
||||
user: req.session.user
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash('error_msg', 'Error loading create form');
|
||||
res.redirect('/admin/camp-location');
|
||||
}
|
||||
};
|
||||
|
||||
// Create new CampLocation record
|
||||
exports.create = async (req, res) => {
|
||||
try {
|
||||
const campLocationData = {
|
||||
metadata: JSON.parse(req.body.metadata || '{}'),
|
||||
hero: JSON.parse(req.body.hero || '{}'),
|
||||
camps: JSON.parse(req.body.camps || '[]'),
|
||||
locations: JSON.parse(req.body.locations || '[]'),
|
||||
locationsSection: JSON.parse(req.body.locationsSection || '{}'),
|
||||
intro: JSON.parse(req.body.intro || '{}'),
|
||||
map: JSON.parse(req.body.map || '{}'),
|
||||
faq: JSON.parse(req.body.faq || '[]'),
|
||||
faqSection: JSON.parse(req.body.faqSection || '{}'),
|
||||
welcomeQuote: JSON.parse(req.body.welcomeQuote || '{}'),
|
||||
securityConcept: JSON.parse(req.body.securityConcept || '{}')
|
||||
};
|
||||
|
||||
const newCampLocation = new CampLocation(campLocationData);
|
||||
await newCampLocation.save();
|
||||
|
||||
req.flash('success_msg', 'Camp Location created successfully');
|
||||
res.redirect('/admin/camp-location');
|
||||
} catch (err) {
|
||||
console.error('Create error:', err);
|
||||
req.flash('error_msg', `Create error: ${err.message || 'Unknown'}`);
|
||||
res.redirect('/admin/camp-location/create');
|
||||
}
|
||||
};
|
||||
|
||||
// Display edit form
|
||||
exports.editForm = async (req, res) => {
|
||||
try {
|
||||
const campLocation = await CampLocation.findById(req.params.id);
|
||||
|
||||
if (!campLocation) {
|
||||
req.flash('error_msg', 'Camp Location record not found');
|
||||
return res.redirect('/admin/camp-location');
|
||||
}
|
||||
|
||||
res.render('admin/campLocation/edit', {
|
||||
layout: 'layouts/main',
|
||||
title: 'Edit Camp Location',
|
||||
data: campLocation.toObject ? campLocation.toObject() : campLocation,
|
||||
currentPath: req.path,
|
||||
user: req.session.user
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash('error_msg', 'Error loading edit form');
|
||||
res.redirect('/admin/camp-location');
|
||||
}
|
||||
};
|
||||
|
||||
// Update CampLocation record
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
// Get current data
|
||||
const currentData = await getStoredCampLocation();
|
||||
|
||||
// List of sections to update
|
||||
const sections = [
|
||||
'metadata',
|
||||
'hero',
|
||||
'camps',
|
||||
'locations',
|
||||
'locationsSection',
|
||||
'intro',
|
||||
'map',
|
||||
'faq',
|
||||
'faqSection',
|
||||
'welcomeQuote',
|
||||
'securityConcept'
|
||||
];
|
||||
const errors = [];
|
||||
let hasChanges = false;
|
||||
|
||||
// Create updated data object
|
||||
const updatedData = {
|
||||
...(currentData.toObject ? currentData.toObject() : currentData)
|
||||
};
|
||||
|
||||
// Process each section
|
||||
sections.forEach((section) => {
|
||||
try {
|
||||
if (!req.body[section]) {
|
||||
console.warn(`No data for section: ${section}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newSectionData = JSON.parse(req.body[section]);
|
||||
const currentSectionData = currentData[section];
|
||||
const sectionHasChanges =
|
||||
JSON.stringify(newSectionData) !== JSON.stringify(currentSectionData);
|
||||
|
||||
if (sectionHasChanges) {
|
||||
updatedData[section] = newSectionData;
|
||||
hasChanges = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing section ${section}:`, error);
|
||||
errors.push(`Error processing ${section} data: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
req.flash('error_msg', `Data processing error: ${errors[0]}`);
|
||||
return req.session.save(() => res.redirect('/admin/camp-location'));
|
||||
}
|
||||
|
||||
if (!hasChanges) {
|
||||
req.flash('info_msg', 'No changes were made');
|
||||
return req.session.save(() => res.redirect('/admin/camp-location'));
|
||||
}
|
||||
|
||||
try {
|
||||
// Only update existing document; do not create a new one here
|
||||
if (!currentData || !currentData._id) {
|
||||
req.flash('error_msg', 'No existing Camp Location record to update. Create one first.');
|
||||
return req.session.save(() => res.redirect('/admin/camp-location'));
|
||||
}
|
||||
|
||||
// Update document and ensure it's saved to MongoDB
|
||||
const updatedDoc = await CampLocation.findByIdAndUpdate(
|
||||
currentData._id,
|
||||
updatedData,
|
||||
{
|
||||
new: true,
|
||||
runValidators: true,
|
||||
useFindAndModify: false
|
||||
}
|
||||
);
|
||||
|
||||
// Verify the update was successful
|
||||
if (!updatedDoc) {
|
||||
throw new Error('Failed to update document');
|
||||
}
|
||||
|
||||
// Force a save to ensure MongoDB commits the changes
|
||||
await updatedDoc.save();
|
||||
|
||||
console.log('✓ Camp location updated successfully in MongoDB');
|
||||
console.log('✓ Document ID:', updatedDoc._id);
|
||||
console.log('✓ Updated at:', updatedDoc.updatedAt);
|
||||
|
||||
req.flash('success_msg', 'Camp location data updated successfully');
|
||||
// Redirect back to the same page with cache-busting parameter to force refresh
|
||||
const timestamp = Date.now();
|
||||
return req.session.save(() => res.redirect(`/admin/camp-location?updated=${timestamp}`));
|
||||
} catch (dbError) {
|
||||
console.error('Database error:', dbError);
|
||||
req.flash('error_msg', `Database error: ${dbError.message || 'Unknown'}`);
|
||||
return req.session.save(() => res.redirect('/admin/camp-location'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Update error:', err);
|
||||
req.flash('error_msg', `Update error: ${err.message || 'Unknown'}`);
|
||||
return req.session.save(() => res.redirect('/admin/camp-location'));
|
||||
}
|
||||
};
|
||||
|
||||
// Delete CampLocation record
|
||||
exports.delete = async (req, res) => {
|
||||
try {
|
||||
const campLocation = await CampLocation.findById(req.params.id);
|
||||
|
||||
if (!campLocation) {
|
||||
req.flash('error_msg', 'Camp Location record not found');
|
||||
return res.redirect('/admin/camp-location');
|
||||
}
|
||||
|
||||
await CampLocation.findByIdAndDelete(req.params.id);
|
||||
|
||||
req.flash('success_msg', 'Camp Location record deleted successfully');
|
||||
res.redirect('/admin/camp-location');
|
||||
} catch (err) {
|
||||
console.error('Delete error:', err);
|
||||
req.flash('error_msg', `Delete error: ${err.message || 'Unknown'}`);
|
||||
res.redirect('/admin/camp-location');
|
||||
}
|
||||
};
|
||||
|
||||
// Preview CampLocation record
|
||||
exports.preview = async (req, res) => {
|
||||
try {
|
||||
const campLocation = await CampLocation.findById(req.params.id);
|
||||
|
||||
if (!campLocation) {
|
||||
return res.status(404).json({ error: 'Camp Location record not found' });
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processedData = addBaseUrlToImages(
|
||||
campLocation.toObject ? campLocation.toObject() : campLocation,
|
||||
baseUrl
|
||||
);
|
||||
res.json(processedData);
|
||||
} catch (err) {
|
||||
console.error('Preview error:', err);
|
||||
res.status(500).json({ error: 'Error loading preview data' });
|
||||
}
|
||||
};
|
||||
181
controllers/contactController.js
Normal file
181
controllers/contactController.js
Normal file
@@ -0,0 +1,181 @@
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const Contact = require("../models/contact");
|
||||
|
||||
// Get contact data from MongoDB
|
||||
const getContactData = async () => {
|
||||
const contact = await Contact.findOne({ name: "default" });
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
return contact.toObject();
|
||||
};
|
||||
|
||||
// API to get contact data
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const contact = await getContactData();
|
||||
if (!contact) {
|
||||
return res.status(404).json({ error: "Contact data not found" });
|
||||
}
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
|
||||
const processedData = addBaseUrlToImages(contact, baseUrl);
|
||||
res.json(processedData);
|
||||
} catch (err) {
|
||||
console.error("API Error:", err);
|
||||
res.status(500).json({ error: "Error loading contact data" });
|
||||
}
|
||||
};
|
||||
|
||||
// API để lấy toàn bộ contact data
|
||||
exports.getContactData = async (req, res) => {
|
||||
try {
|
||||
const contactData = await getContactData();
|
||||
if (!contactData) {
|
||||
return res.status(404).json({ error: "Contact data not found" });
|
||||
}
|
||||
res.json(contactData);
|
||||
} catch (error) {
|
||||
console.error("Error getting contact data:", error);
|
||||
res.status(500).json({ error: "Error loading contact data" });
|
||||
}
|
||||
};
|
||||
|
||||
// Render admin view
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const data = (await getContactData()) || {
|
||||
hero: {
|
||||
title: "Contact Us",
|
||||
backgroundImage: "",
|
||||
overlayColor: "rgba(0, 0, 0, 0)",
|
||||
sectionClass: "",
|
||||
titleClass: "",
|
||||
enableScrollspy: false,
|
||||
backgroundPosition: "center",
|
||||
},
|
||||
contactCards: [],
|
||||
map: {
|
||||
coordinates: { lat: 0, lng: 0 },
|
||||
zoom: 15,
|
||||
location: "",
|
||||
markerTitle: "",
|
||||
tileLayer: {
|
||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
attribution: "",
|
||||
maxZoom: 18,
|
||||
minZoom: 0,
|
||||
},
|
||||
},
|
||||
form: {
|
||||
sectionLabel: "",
|
||||
heading: "",
|
||||
fields: [],
|
||||
submitButton: { text: "Send Message" },
|
||||
},
|
||||
};
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
|
||||
res.render("admin/contact/index", {
|
||||
title: "Contact Management",
|
||||
layout: "layouts/main",
|
||||
data,
|
||||
frontendUrl,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in contact index:", error);
|
||||
req.flash("error_msg", "An error occurred while loading the page");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Cập nhật dữ liệu contact
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const { hero, contactCards, map, form } = req.body;
|
||||
|
||||
// Parse JSON strings nếu cần
|
||||
const parseJson = (data) => {
|
||||
if (!data) return null;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const heroData = parseJson(hero);
|
||||
const contactCardsData = parseJson(contactCards);
|
||||
const mapData = parseJson(map);
|
||||
const formData = parseJson(form);
|
||||
|
||||
// Tìm hoặc tạo contact
|
||||
let contact = await Contact.findOne({ name: "default" });
|
||||
|
||||
if (!contact) {
|
||||
// Tạo mới với default values
|
||||
contact = new Contact({
|
||||
name: "default",
|
||||
hero: heroData || {
|
||||
title: "Contact Us",
|
||||
backgroundImage: "",
|
||||
overlayColor: "rgba(0, 0, 0, 0)",
|
||||
sectionClass: "",
|
||||
titleClass: "",
|
||||
enableScrollspy: false,
|
||||
backgroundPosition: "center",
|
||||
},
|
||||
contactCards: (contactCardsData || []).map((card) => ({
|
||||
...card,
|
||||
iconType: card.iconType || "",
|
||||
iconSource: card.iconSource || (card.iconType && card.iconType.startsWith('/uploads/') ? 'image' : 'fontawesome'),
|
||||
})),
|
||||
map: mapData || {
|
||||
coordinates: { lat: 0, lng: 0 },
|
||||
zoom: 15,
|
||||
location: "",
|
||||
markerTitle: "",
|
||||
tileLayer: {
|
||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
attribution: "",
|
||||
maxZoom: 18,
|
||||
minZoom: 0,
|
||||
},
|
||||
},
|
||||
form: formData || {
|
||||
sectionLabel: "",
|
||||
heading: "",
|
||||
fields: [],
|
||||
submitButton: { text: "Send Message" },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Cập nhật dữ liệu
|
||||
if (heroData) contact.hero = heroData;
|
||||
if (contactCardsData && Array.isArray(contactCardsData)) {
|
||||
// Đảm bảo mỗi card có iconType và iconSource
|
||||
contact.contactCards = contactCardsData.map((card) => ({
|
||||
...card,
|
||||
iconType: card.iconType || "",
|
||||
iconSource: card.iconSource || (card.iconType && card.iconType.startsWith('/uploads/') ? 'image' : 'fontawesome'),
|
||||
}));
|
||||
}
|
||||
if (mapData) contact.map = mapData;
|
||||
if (formData) contact.form = formData;
|
||||
}
|
||||
|
||||
await contact.save();
|
||||
|
||||
req.flash("success_msg", "Contact updated successfully");
|
||||
res.redirect("/admin/contact");
|
||||
} catch (err) {
|
||||
console.error("Error updating contact:", err);
|
||||
req.flash("error_msg", err.message || "Error updating contact");
|
||||
res.redirect("/admin/contact");
|
||||
}
|
||||
};
|
||||
17
controllers/dashboardController.js
Normal file
17
controllers/dashboardController.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const { readJsonFile } = require('../utils/jsonHelper');
|
||||
|
||||
// Hiển thị dashboard
|
||||
exports.getDashboard = async (req, res) => {
|
||||
try {
|
||||
res.render('admin/dashboard', {
|
||||
title: 'Dashboard',
|
||||
user: req.session.user
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.render('admin/dashboard', {
|
||||
title: 'Dashboard',
|
||||
user: req.session.user
|
||||
});
|
||||
}
|
||||
};
|
||||
720
controllers/faqController.js
Normal file
720
controllers/faqController.js
Normal file
@@ -0,0 +1,720 @@
|
||||
// controllers/faqController.js
|
||||
const FAQ = require("../models/faq");
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
|
||||
// Helper function để lấy FAQ data
|
||||
const getFAQData = async () => {
|
||||
try {
|
||||
const faq = await FAQ.findOne({ name: "default" });
|
||||
return faq ? faq.toObject() : null;
|
||||
} catch (error) {
|
||||
console.error("Error getting FAQ data:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function để tạo FAQ data mặc định hoàn chỉnh
|
||||
const getDefaultFAQData = () => {
|
||||
return {
|
||||
name: "default",
|
||||
hero: {
|
||||
title: "Go and Grow Camp",
|
||||
backgroundImage: "yootheme/cache/18/faqs_header_new.jpg",
|
||||
overlayColor: "rgba(0, 0, 0, 0)",
|
||||
sectionClass: "uk-section-secondary uk-section-overlap uk-preserve-color uk-light",
|
||||
titleClass: "uk-heading-large uk-text-center !text-[5vw]",
|
||||
enableScrollspy: true,
|
||||
backgroundPosition: "top-center"
|
||||
},
|
||||
sidebarNav: [
|
||||
{
|
||||
id: "general-information",
|
||||
label: "General Information"
|
||||
},
|
||||
{
|
||||
id: "camps",
|
||||
label: "Camps"
|
||||
},
|
||||
{
|
||||
id: "camp-routine",
|
||||
label: "Camp Routine"
|
||||
},
|
||||
{
|
||||
id: "camp-counselors",
|
||||
label: "Camp Counselors"
|
||||
},
|
||||
{
|
||||
id: "camp-rules",
|
||||
label: "Camp Rules"
|
||||
},
|
||||
{
|
||||
id: "safety",
|
||||
label: "Safety"
|
||||
},
|
||||
{
|
||||
id: "accommodation-catering",
|
||||
label: "Accommodation & Catering"
|
||||
},
|
||||
{
|
||||
id: "transfers-shuttles",
|
||||
label: "Transfers & Shuttles"
|
||||
}
|
||||
],
|
||||
contactBox: {
|
||||
title: "Let's plan your perfect nature escape",
|
||||
phone: {
|
||||
icon: "phone",
|
||||
text: "+(123)-456-789"
|
||||
},
|
||||
email: {
|
||||
icon: "email",
|
||||
text: "hello@ggcamp.org"
|
||||
}
|
||||
},
|
||||
faqSections: [
|
||||
{
|
||||
id: "general-information",
|
||||
title: "General Information",
|
||||
faqs: [
|
||||
{
|
||||
title: "What are FAQ?",
|
||||
description: "FAQ are the initials for \"Frequently Asked Questions\".\n\nThe FAQ have been compiled by us over a long period of time and are intended to help give a general overview of our camps and clarify questions that arise before booking a camp."
|
||||
},
|
||||
{
|
||||
title: "General booking process",
|
||||
description: "Once the booking has been confirmed by us, you will receive an e-mail requesting a deposit. As soon as we have received this, you will receive an e-mail with a payment confirmation.\nPlease have a look at the welcome package, which will reach you by e-mail with the Last Travel Information. This contains information that applies to the camp you have booked.\n\nStep 1: Registration\nStep 2: Receipt of registration confirmation, total invoice and deposit request (e-mail)\nStep 3: Deposit of USD 50 (due within 7 days after booking)\nStep 4: Receiving an email with the latest important travel information, a packing list, addresses and important emergency phone numbers plus remaining payment request about 3-4 weeks before the camp starts."
|
||||
},
|
||||
{
|
||||
title: "Terms & Conditions",
|
||||
description: "Our Terms & Conditions can be found in our official documents section."
|
||||
},
|
||||
{
|
||||
title: "Where can I find a packing guide for Camps?",
|
||||
description: "Just click here to download our packing list."
|
||||
},
|
||||
{
|
||||
title: "Where can I find contact information from Camps and addresses?",
|
||||
description: "Here you can find all the necessary information if you want to drive to our camps or send something. If you want to send something please ALWAYS include the full name of your child on the letter/package and please only send it at the time when your kids are staying in camp as we cannot store it for a longer period of time.\n\nWalsrode/Lüneburger Heide - Germany:\nCamp Adventure, Vethem 58, 29664 Walsrode, Germany\nwalsrode@campadventure.de\n\nRegen/Bavarian Forest - Germany:\nCamp Adventure, Badstrasse 18, 94209 Regen\nregen@campadventure.de\n\nBarcelona - Spain:\nBISC International Sailing Center, c/o Camp Adventure, Parc del Fòrum Sota plaça fotovoltàica, 08930 Sant Adrià de Besòs, Barcelona, Spain\nbarcelona@campadventure.de\n\nBath - England:\nUniversity of Bath, c/o Camp Adventure, Claverton Down, Bath BA2 7AY, England\nengland@campadventure.de\n\nRossall - England:\nRossall School, Broadway, Fleetwood, Lancashire FY7 8JW, England\nengland@campadventure.de"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "camps",
|
||||
title: "Camps",
|
||||
faqs: [
|
||||
{
|
||||
title: "Where do kids and camp counselors come from?",
|
||||
description: "Camp Adventure attaches great importance to internationality. The participants and supervisors in our camps come from many different countries. Last year, for example, we had participants from over 60 different countries and counselors from 25 different nations. Of course, we don't know where they will come from this year. So we are at least as excited as you are.\n\nThrough our office in Hamburg and our branch office in Canada, we reach motivated and committed counselors from all over the world. Canadian and Australian teamers can therefore be found as well as German or Spanish teamers.\n\nDue to the different experiences and cultural backgrounds an indescribably fantastic, international atmosphere is created."
|
||||
},
|
||||
{
|
||||
title: "Which languages are spoken in camp?",
|
||||
description: "The main language in all our camps is English. In addition, there is the language of the country in which the camp takes place. As we have our headquarters in Germany, German teamers are always present in all camps in Germany. All announcements and explanations are here therefore always in German and English. Of course, all our teamers with their different nationalities are also available for individual translations."
|
||||
},
|
||||
{
|
||||
title: "Are there problems if children have low language skills?",
|
||||
description: "No, because there are usually more participants and team members who speak the same language. We know from experience that children are excellent at communicating nonverbally. They often need a few days to warm up to it, but are then very open to other children as well."
|
||||
},
|
||||
{
|
||||
title: "Are girls and boys separated?",
|
||||
description: "Girls and boys are accommodated separately in the dormitories/tents. The program is completely mixed."
|
||||
},
|
||||
{
|
||||
title: "How big are the camps? How high is the caregiver ratio?",
|
||||
description: "Capacities range from around 30 participants in smaller language camps to a maximum of about 400 children in our camp Lueneburger Heide. However, the maximum capacity is not reached every week. However, a minimum number of participants must be guaranteed in order to run the camp.\n\nIt is important to us that all children are always grouped in small groups of 5-8, with a counselor as a contact person. This way homesickness doesn't stand a chance and despite the size of the camp in their group family, they experience a strong bond on which they can count on!"
|
||||
},
|
||||
{
|
||||
title: "Should 12-year-olds go to Junior Camp or Senior Camp?",
|
||||
description: "This question is not easy to answer and depends on the individual stage of development of your child. Therefore, as parents, we leave you the opportunity to decide for yourself. In the Junior Camp they belong to the older ones and can explore a lot in a playful way. In the Senior Camp they are the younger ones, who have role models through the older ones, whom they can emulate."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "camp-routine",
|
||||
title: "Camp Routine",
|
||||
faqs: [
|
||||
{
|
||||
title: "How is the choice of activities/courses in the camps made?",
|
||||
description: "If your child would like to participate in a paid additional course (e.g. horse riding, language course, Survival etc.), this must be booked in advance when registering. In principle, no extra additional courses have to be booked. A program with a variety of activities is of course available to the participants in all camps. The various activities can be chosen by the participants on site in the respective camps. We present the offers to the participants, so that everyone gets an insight into the different courses. The children can then register in the lists of the respective courses."
|
||||
},
|
||||
{
|
||||
title: "What is a hike?",
|
||||
description: "The hike is a 1-3 day walking tour, in which all participants of the Adventure Camp who stay 2 weeks in the camp take part. On this hike the participants will not spend the night in a tent, but either in the open air or under a self-made shelter e.g. from tarpaulins. They will of course be accompanied by their teamers. The hike is a very special experience and a highlight for all participants. For this hike the participants need sturdy shoes and a big backpack."
|
||||
},
|
||||
{
|
||||
title: "Can I wash my clothes during the camp?",
|
||||
description: "In principle, participants should bring sufficient clothing and change of clothes for the entire camp period.\n\nOnly in the camps in Lüneburger Heide and Bayerischer Wald a laundry service will be offered for kids staying three weeks or more, which means that a laundry bag (approx. 3 kg) will be washed in the laundry centre of the next village at a price of USD 45. This service can be booked upon registration for three-week camps. Please note that the laundry will be done either after one week or after two weeks."
|
||||
},
|
||||
{
|
||||
title: "Anti Homesick Adviser",
|
||||
description: "Dear parents\n\nNow it's almost time: In summer your child travels for the first time with Camp Adventure. Maybe it will be the first time that he travels alone without parents or relatives. As we are getting more and more questions, we have decided to put together a small package for you parents with little tips from experts to make everything as easy as possible for you and your child. Follow our tips and your child will have a fantastic holiday, have many new experiences and make friends from all over the world! All these tips have been developed together with the International Camping Fellowship. And the more you think your child will be a \"homesick candidate\" - or your child even claims to be one - the more you consider the following tips."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "camp-counselors",
|
||||
title: "Camp Counselors - Our Teamers",
|
||||
faqs: [
|
||||
{
|
||||
title: "Who are the camp counselors?",
|
||||
description: "Every year our team is made up of an international mix. The non-profit association Camp Europe e.V. with headquarters in Hamburg and a branch office in Canada takes care of the acquisition of national and international applicants. Since we have about 50% German-speaking children, there are also German carers in every location. But many also come from other countries, such as England, Spain, Canada and Australia, to name just a few."
|
||||
},
|
||||
{
|
||||
title: "How are the teamers trained?",
|
||||
description: "All counselors go through an extensive application process. For a successful application, not only an interesting curriculum vitae and a minimum age of 19 years are sufficient! We conduct a personal interview with each individual in which our employees get a first impression of the applicant.\n\nBefore the camp season, everyone, both the first supervisors (teamers) as well as many recomers, complete a one-week training in which they are prepared for their assignment by trained coaches. They must have a first aid certificate, which may not be older than two years, as well as an internationally flawless police clearance certificate. We know how important the teamers are for a great camp and therefore select them very conscientiously."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "camp-rules",
|
||||
title: "Camp Rules",
|
||||
faqs: [
|
||||
{
|
||||
title: "Drugs, Alcohol & Camp?",
|
||||
description: "From our point of view an absolutely unacceptable and indiscutable combination! Due to our cooperation with the association \"Keine Macht den Drogen\" (No power to drugs) and our common opinion that all kinds of drugs do not belong in the hands of children & teenagers, any possession or consumption of drugs is forbidden for teenagers and children in the camp and also outside the camp.\n\nViolations can lead to exclusion or even to criminal charges. The term \"drugs\" also includes cigarettes and alcohol! Through our varied activities, we offer a much better alternative! We would like to make it clear from the outset that we are also against any form of discrimination or \"putting down\". This is - just like violence - immediately prevented by us, in order to offer each young person a relaxed and joyful time in the camp."
|
||||
},
|
||||
{
|
||||
title: "Should I call my kid or write an old-fashioned letter?",
|
||||
description: "We ask all parents to write to their child at least once. This is especially useful at the beginning, as it is a particularly upsetting experience for every child and every teenager when most of the participants receive a letter, but they do not.\n\nPlease note that there is NO public \"camp phone\" available for incoming or outgoing calls. If your child doesn't bring her/his own phone, she/he won't be able to call you. In case of any problems, we will of course contact you immediately.\n\nIf your child brings a mobile phone, we will collect it on arrival and store it with the valuables. Your child's Teamer may hand it over during the phone time after lunch. Please keep in mind: no news is good news (the location manager will contact you if it is necessary due to homesickness or illness). We kindly ask you not to call the office in Hamburg to ask about your kid's health and wellbeing, nor if you would like to know why your child hasn't called you yet. Please use our camp email service for such enquiries.\n\nOur recommendation is the following:\nWe recommend not to call your child (even if he or she has a mobile phone with him or her) and not to tell him or her to call you. Telephoning can in our experience promote homesickness very strongly and your child will be cured thereby if completely immersed in camp life! At noon after lunch, if absolutely necessary, your child can pick up his or her mobile phone from the counselors until the start of next program and make phone calls. Instead, you are welcome to bring a pre-stamped and addressed envelope with you. We will then make sure that your child has enough time to write letters. Since letters and postcards often arrive late at the camps, we also offer the e-mail service. You can send your child max. ONE email per day directly to the camp, which we then print out and give to your child. There is no way for them to reply, but your child will be happy to receive a small message from home. You can find the postal and email address in the info package of the booked camp."
|
||||
},
|
||||
{
|
||||
title: "Are there any prohibited items?",
|
||||
description: "Yes, there are. Not allowed are pocket knives with lockable blades, all weapons, lighters and matches (danger of fire in the forest!). Drugs of any kind, including alcohol and cigarettes, are also included."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "safety",
|
||||
title: "Safety",
|
||||
faqs: [
|
||||
{
|
||||
title: "Electronic equipment and valuables",
|
||||
description: "We recommend that you do not take an MP3 player, e-book, tablet, etc. or any valuables with you. On the one hand we do not assume any liability and on the other hand there are no possibilities to charge the devices. We are of the opinion that the camp time is a special experience for the participants if they do not have the headphones in their ears all the time or are busy with their mobile phones. Instead they have the chance to deal with other topics and they find time to dedicate themselves to the new people in the camp."
|
||||
},
|
||||
{
|
||||
title: "How do you provide safety for the kids?",
|
||||
description: "Before our camp counselors start working with us, we check their police clearance certificates. You must be at least 19 years old to work for us as a teamer. They must also have a \"First Aid Certificate\", which must not be older than two years. In the camps we try to make sure that only adults from our camp or familiar faces are on the campground and that all our carers look after strangers.\n\nWe have many different camp sites. Some of them are fenced in, others are not. There are no armed guards or the like in our camps, as we believe that these conditions create a very insecure feeling. We do not have a high security zone in Germany, Northern Ireland or England, but we keep our eyes open and do everything we can to ensure that all participants have a great time."
|
||||
},
|
||||
{
|
||||
title: "Insurance in case of illness?",
|
||||
description: "If your child should fall ill during the camp and medical help is required, he or she will of course be taken to the doctor by our carers and cared for there as well. It is therefore necessary for each participant to take their insurance card with them to the camp. We offer all participants the possibility of taking out liability, casualty & health insurance for travel abroad with us. This covers all costs in case of illness and prevents international children in particular from having to \"advance\" their own cash. You can find more detailed information on insurance in our documents section."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "accommodation-catering",
|
||||
title: "Accommodation & Catering",
|
||||
faqs: [
|
||||
{
|
||||
title: "How's the food at the camps?",
|
||||
description: "Full board for the entire duration of the camp is of course already included in the camp price. In addition, water and fruit are available for the participants around the clock. For us it is a matter of course to provide one variant for vegetarians and one pork-free with each meal. In case of special allergies or intolerances of your children let us know in advance and we will try to find a solution."
|
||||
},
|
||||
{
|
||||
title: "How is my child accommodated in the camp?",
|
||||
description: "In our Adventure Camp Bayerischer Wald and our Camp Lueneburger Heide, the Juniors (7-12) and the Seniors (12-16) can choose between tents and huts.\n\nThe tents are equipped with a floor and a wooden platform, up to 7 children can share one tent. The participants can make themselves comfortable with sleeping bag and sleeping mat. The wooden huts are equipped with bunk beds and can accommodate 4-8 children. At the other locations, participants will be accommodated in shared rooms in youth hostels, sports centres or boarding schools of private schools. You will find detailed information about the accommodation on the individual camp pages."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "transfers-shuttles",
|
||||
title: "Transfers & Shuttles",
|
||||
faqs: [
|
||||
{
|
||||
title: "Entry regulations/Travel Consent for group flights",
|
||||
description: "All parents need to fill this out and bring it to camp:\n\nBelow is a summary of the travel requirements for minors from various EU countries traveling with Camp Adventure on group flights. Please note that regulations can change, so it's essential to consult the official resources provided for the most up-to-date information."
|
||||
},
|
||||
{
|
||||
title: "Which transfers are offered?",
|
||||
description: "The respective transfer possibilities depend on the period and venue of the camp. Check directly on the respective camp page under \"Arrival & Departure Services\"."
|
||||
},
|
||||
{
|
||||
title: "Where can I find the exact arrival and departure times?",
|
||||
description: "Information about the different arrival and departure times can be found on the respective camp page under \"Arrival & Departure Services\"."
|
||||
},
|
||||
{
|
||||
title: "How do the transfer costs come about?",
|
||||
description: "When booking a train or air trip, the indicated price includes the arrival and departure as well as the accompaniment by a supervisor."
|
||||
},
|
||||
{
|
||||
title: "Where can I find the address/driving directions from the camp?",
|
||||
description: "You will receive the exact address and directions of the camp with the Last Travel Information about 3-4 weeks before the camp starts."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
video: {
|
||||
url: "https://www.youtube.com/embed/3NtE5wSwYTo?list=PLSOedrxa1c-bxvH6uuz_oZdIfJkov66wB&disablekb=1",
|
||||
title: "Anti Homesickness Adviser"
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// API để lấy FAQ data
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const faqData = await getFAQData();
|
||||
|
||||
if (!faqData) {
|
||||
return res.status(404).json({ error: "FAQ data not found" });
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}:${req.get('host')}`;
|
||||
const processed = addBaseUrlToImages(faqData || {}, baseUrl);
|
||||
res.json(processed);
|
||||
} catch (err) {
|
||||
console.error("API Error:", err);
|
||||
res.status(500).json({ error: "Error loading FAQ data" });
|
||||
}
|
||||
};
|
||||
|
||||
// API để lấy toàn bộ FAQ data
|
||||
exports.getFAQData = async (req, res) => {
|
||||
try {
|
||||
const faqData = await getFAQData();
|
||||
|
||||
if (!faqData) {
|
||||
return res.status(404).json({ error: "FAQ data not found" });
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processed = addBaseUrlToImages(faqData || {}, baseUrl);
|
||||
res.json(processed);
|
||||
} catch (error) {
|
||||
console.error("Error getting FAQ data:", error);
|
||||
res.status(500).json({ error: "Error loading FAQ data" });
|
||||
}
|
||||
};
|
||||
|
||||
// API để seed FAQ data mặc định
|
||||
exports.seed = async (req, res) => {
|
||||
try {
|
||||
// Kiểm tra xem đã có FAQ data chưa
|
||||
let faq = await FAQ.findOne({ name: "default" });
|
||||
|
||||
if (faq) {
|
||||
return res.json({
|
||||
success: false,
|
||||
message: "FAQ data already exists. Use update instead."
|
||||
});
|
||||
}
|
||||
|
||||
// Tạo FAQ data mới với nội dung đầy đủ
|
||||
faq = new FAQ(getDefaultFAQData());
|
||||
await faq.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "FAQ data seeded successfully",
|
||||
data: faq
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error seeding FAQ data:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message || "Error seeding FAQ data"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Render admin view
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
// Lấy FAQ data hoặc sử dụng mặc định
|
||||
const data = await getFAQData();
|
||||
const faqData = data || getDefaultFAQData();
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
|
||||
res.render("admin/faq/index", {
|
||||
title: "FAQ Management",
|
||||
layout: "layouts/main",
|
||||
data: faqData,
|
||||
frontendUrl,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
// Helper để stringify JSON
|
||||
stringify: (obj) => JSON.stringify(obj),
|
||||
// Helper để parse JSON
|
||||
parse: (str) => {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in FAQ index:", error);
|
||||
req.flash("error_msg", "An error occurred while loading the page");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Cập nhật toàn bộ FAQ data
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
hero,
|
||||
sidebarNav,
|
||||
contactBox,
|
||||
faqSections,
|
||||
video
|
||||
} = req.body;
|
||||
|
||||
// Parse JSON strings nếu cần
|
||||
const parseJson = (data) => {
|
||||
if (!data) return null;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
console.error("JSON parse error:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const heroData = parseJson(hero);
|
||||
const sidebarNavData = parseJson(sidebarNav);
|
||||
const contactBoxData = parseJson(contactBox);
|
||||
const faqSectionsData = parseJson(faqSections);
|
||||
const videoData = parseJson(video);
|
||||
|
||||
// Tìm hoặc tạo FAQ
|
||||
let faq = await FAQ.findOne({ name: "default" });
|
||||
|
||||
// Sử dụng data mặc định nếu không có dữ liệu
|
||||
const updateData = {
|
||||
hero: heroData || getDefaultFAQData().hero,
|
||||
sidebarNav: sidebarNavData || getDefaultFAQData().sidebarNav,
|
||||
contactBox: contactBoxData || getDefaultFAQData().contactBox,
|
||||
faqSections: faqSectionsData || getDefaultFAQData().faqSections,
|
||||
video: videoData || getDefaultFAQData().video
|
||||
};
|
||||
|
||||
if (!faq) {
|
||||
// Tạo mới
|
||||
faq = new FAQ({
|
||||
name: "default",
|
||||
...updateData
|
||||
});
|
||||
} else {
|
||||
// Cập nhật
|
||||
Object.keys(updateData).forEach(key => {
|
||||
faq[key] = updateData[key];
|
||||
});
|
||||
}
|
||||
|
||||
await faq.save();
|
||||
|
||||
req.flash("success_msg", "FAQ updated successfully");
|
||||
res.redirect("/admin/faq");
|
||||
} catch (err) {
|
||||
console.error("Error updating FAQ:", err);
|
||||
req.flash("error_msg", err.message || "Error updating FAQ");
|
||||
res.redirect("/admin/faq");
|
||||
}
|
||||
};
|
||||
|
||||
// API: Reset về mặc định
|
||||
exports.reset = async (req, res) => {
|
||||
try {
|
||||
let faq = await FAQ.findOne({ name: "default" });
|
||||
|
||||
if (!faq) {
|
||||
faq = new FAQ(getDefaultFAQData());
|
||||
} else {
|
||||
// Cập nhật tất cả các trường về mặc định
|
||||
const defaultData = getDefaultFAQData();
|
||||
Object.keys(defaultData).forEach(key => {
|
||||
if (key !== "name") { // Giữ name
|
||||
faq[key] = defaultData[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await faq.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "FAQ data reset to default successfully",
|
||||
data: faq
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error resetting FAQ data:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message || "Error resetting FAQ data"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API: Thêm FAQ vào section
|
||||
exports.addFAQ = async (req, res) => {
|
||||
try {
|
||||
const { sectionId, faqItem } = req.body;
|
||||
|
||||
let faq = await FAQ.findOne({ name: "default" });
|
||||
|
||||
if (!faq) {
|
||||
faq = new FAQ(getDefaultFAQData());
|
||||
}
|
||||
|
||||
const result = await faq.addFaqToSection(sectionId, faqItem);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "FAQ added successfully",
|
||||
data: result
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error adding FAQ:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message || "Error adding FAQ"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API: Cập nhật FAQ item
|
||||
exports.updateFAQItem = async (req, res) => {
|
||||
try {
|
||||
const { sectionId, faqId } = req.params;
|
||||
const { title, description } = req.body;
|
||||
|
||||
// Bảo toàn newlines và whitespace
|
||||
const cleanDescription = description
|
||||
.replace(/\r\n/g, '\n') // Chuẩn hóa newline
|
||||
.replace(/\r/g, '\n'); // Xử lý cả \r
|
||||
|
||||
const faq = await FAQ.findOne({ name: "default" });
|
||||
|
||||
if (!faq) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "FAQ data not found"
|
||||
});
|
||||
}
|
||||
|
||||
const result = await faq.updateFaqItem(sectionId, faqId, {
|
||||
title,
|
||||
description: cleanDescription
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "FAQ item updated successfully",
|
||||
data: result
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error updating FAQ item:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message || "Error updating FAQ item"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API: Xóa FAQ item
|
||||
exports.deleteFAQItem = async (req, res) => {
|
||||
try {
|
||||
const { sectionId, faqId } = req.params;
|
||||
|
||||
const faq = await FAQ.findOne({ name: "default" });
|
||||
|
||||
if (!faq) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "FAQ data not found"
|
||||
});
|
||||
}
|
||||
|
||||
const result = await faq.deleteFaqItem(sectionId, faqId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "FAQ item deleted successfully",
|
||||
data: result
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error deleting FAQ item:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message || "Error deleting FAQ item"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API: Cập nhật FAQ section
|
||||
exports.updateFAQSection = async (req, res) => {
|
||||
try {
|
||||
const { sectionId } = req.params;
|
||||
const { title } = req.body;
|
||||
|
||||
const faq = await FAQ.findOne({ name: "default" });
|
||||
|
||||
if (!faq) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "FAQ data not found"
|
||||
});
|
||||
}
|
||||
|
||||
const result = await faq.updateFaqSection(sectionId, { title });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "FAQ section updated successfully",
|
||||
data: result
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error updating FAQ section:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message || "Error updating FAQ section"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API: Thêm FAQ section mới
|
||||
exports.addFAQSection = async (req, res) => {
|
||||
try {
|
||||
const { section } = req.body;
|
||||
|
||||
let faq = await FAQ.findOne({ name: "default" });
|
||||
|
||||
if (!faq) {
|
||||
faq = new FAQ(getDefaultFAQData());
|
||||
}
|
||||
|
||||
// Tạo ID mới nếu chưa có
|
||||
if (!section.id) {
|
||||
section.id = section.title.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
}
|
||||
|
||||
// Đảm bảo section có mảng faqs
|
||||
if (!section.faqs) {
|
||||
section.faqs = [];
|
||||
}
|
||||
|
||||
faq.faqSections.push(section);
|
||||
await faq.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "FAQ section added successfully",
|
||||
data: faq
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error adding FAQ section:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message || "Error adding FAQ section"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API: Xóa FAQ section
|
||||
exports.deleteFAQSection = async (req, res) => {
|
||||
try {
|
||||
const { sectionId } = req.params;
|
||||
|
||||
const faq = await FAQ.findOne({ name: "default" });
|
||||
|
||||
if (!faq) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "FAQ data not found"
|
||||
});
|
||||
}
|
||||
|
||||
// Lọc ra section cần xóa
|
||||
faq.faqSections = faq.faqSections.filter(s => s.id !== sectionId);
|
||||
await faq.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "FAQ section deleted successfully",
|
||||
data: faq
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error deleting FAQ section:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message || "Error deleting FAQ section"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API: Reorder FAQ sections
|
||||
exports.reorderFAQSection = async (req, res) => {
|
||||
try {
|
||||
const { sections } = req.body;
|
||||
|
||||
const faq = await FAQ.findOne({ name: "default" });
|
||||
|
||||
if (!faq) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "FAQ data not found"
|
||||
});
|
||||
}
|
||||
|
||||
// Tạo map để tìm section theo id
|
||||
const sectionMap = new Map();
|
||||
faq.faqSections.forEach(section => {
|
||||
sectionMap.set(section.id, section);
|
||||
});
|
||||
|
||||
// Tạo mảng mới theo thứ tự mới
|
||||
const newSections = [];
|
||||
for (const sectionId of sections) {
|
||||
const section = sectionMap.get(sectionId);
|
||||
if (section) {
|
||||
newSections.push(section);
|
||||
}
|
||||
}
|
||||
|
||||
faq.faqSections = newSections;
|
||||
await faq.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "FAQ sections reordered successfully",
|
||||
data: faq
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error reordering FAQ sections:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message || "Error reordering FAQ sections"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API: Cập nhật sidebar navigation
|
||||
exports.updateSidebarNav = async (req, res) => {
|
||||
try {
|
||||
const { sidebarNav } = req.body;
|
||||
|
||||
let faq = await FAQ.findOne({ name: "default" });
|
||||
|
||||
if (!faq) {
|
||||
faq = new FAQ(getDefaultFAQData());
|
||||
}
|
||||
|
||||
faq.sidebarNav = sidebarNav;
|
||||
await faq.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Sidebar navigation updated successfully",
|
||||
data: faq
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error updating sidebar navigation:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message || "Error updating sidebar navigation"
|
||||
});
|
||||
}
|
||||
};
|
||||
178
controllers/footerController.js
Normal file
178
controllers/footerController.js
Normal file
@@ -0,0 +1,178 @@
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const Footer = require("../models/footer");
|
||||
|
||||
// Get footer data from MongoDB
|
||||
const getFooterData = async () => {
|
||||
const footer = await Footer.findOne({ name: "default" });
|
||||
|
||||
if (!footer) {
|
||||
return {
|
||||
logo: {
|
||||
src: '',
|
||||
alt: ''
|
||||
},
|
||||
about: {
|
||||
title: "About GGC",
|
||||
description: "",
|
||||
mapLink: {
|
||||
text: "Check on google map",
|
||||
url: "",
|
||||
},
|
||||
},
|
||||
address: {
|
||||
text: "",
|
||||
address2: "",
|
||||
mapUrl: "",
|
||||
},
|
||||
contact: {
|
||||
phone: "",
|
||||
hours: "",
|
||||
email: "",
|
||||
},
|
||||
columns: [],
|
||||
social: {
|
||||
links: [],
|
||||
},
|
||||
copyright: {
|
||||
text: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return footer.toObject();
|
||||
};
|
||||
|
||||
// API to get footer data
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
// Lấy footer data
|
||||
const footer = await getFooterData();
|
||||
|
||||
// Xử lý URL cho logo và các hình ảnh khác
|
||||
const processedData = addBaseUrlToImages(footer);
|
||||
|
||||
res.json(processedData);
|
||||
} catch (err) {
|
||||
console.error("API Error:", err);
|
||||
res.status(500).json({ error: "Error loading footer data" });
|
||||
}
|
||||
};
|
||||
|
||||
// API để lấy toàn bộ footer data
|
||||
exports.getFooterData = async (req, res) => {
|
||||
try {
|
||||
const footerData = await getFooterData();
|
||||
const processed = addBaseUrlToImages(footerData);
|
||||
res.json(processed);
|
||||
} catch (error) {
|
||||
console.error("Error getting footer data:", error);
|
||||
res.status(500).json({ error: "Error loading footer data" });
|
||||
}
|
||||
};
|
||||
|
||||
// Render admin view
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const data = await getFooterData();
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
|
||||
// Ensure image paths are absolute for admin preview
|
||||
const processedData = addBaseUrlToImages(data);
|
||||
|
||||
res.render("admin/footer/index", {
|
||||
|
||||
title: "Footer Management",
|
||||
data
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in footer index:", error);
|
||||
req.flash("error_msg", "An error occurred while loading the page");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Cập nhật dữ liệu footer
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
let footerData = req.body;
|
||||
if (footerData.footerJson) {
|
||||
try {
|
||||
footerData = JSON.parse(footerData.footerJson);
|
||||
} catch (err) {
|
||||
console.warn('Invalid footerJson payload, falling back to req.body');
|
||||
}
|
||||
}
|
||||
|
||||
// Tìm footer hiện có hoặc tạo mới nếu không tồn tại
|
||||
let footer = await Footer.findOne({ name: "default" });
|
||||
|
||||
if (!footer) {
|
||||
footer = new Footer({
|
||||
name: "default",
|
||||
about: footerData.about || {
|
||||
title: "About GGC",
|
||||
description: "",
|
||||
mapLink: { text: "Check on google map", url: "" },
|
||||
},
|
||||
address: footerData.address || {
|
||||
text: "",
|
||||
address2: "",
|
||||
mapUrl: "",
|
||||
link2: "",
|
||||
},
|
||||
contact: footerData.contact || { phone: "", hours: "", email: "" },
|
||||
columns: Array.isArray(footerData.columns) ? footerData.columns : [],
|
||||
social: footerData.social || { links: [] },
|
||||
copyright: footerData.copyright || { text: "" },
|
||||
});
|
||||
} else {
|
||||
// Cập nhật các trường
|
||||
if (footerData.about) {
|
||||
footer.about = {
|
||||
title: footerData.about.title || footer.about?.title || "About GGC",
|
||||
description: footerData.about.description || footer.about?.description || "",
|
||||
mapLink: {
|
||||
text: footerData.about.mapLink?.text || footer.about?.mapLink?.text || "Check on google map",
|
||||
url: footerData.about.mapLink?.url || footer.about?.mapLink?.url || ""
|
||||
}
|
||||
};
|
||||
}
|
||||
if (footerData.address) {
|
||||
// Đảm bảo address2 tồn tại để tránh undefined trong view/schema
|
||||
footer.address = {
|
||||
...(footer.address?.toObject
|
||||
? footer.address.toObject()
|
||||
: footer.address),
|
||||
...footerData.address,
|
||||
address2:
|
||||
footerData.address.address2 !== undefined
|
||||
? footerData.address.address2
|
||||
: footer.address?.address2 || "",
|
||||
link2:
|
||||
footerData.address.link2 !== undefined
|
||||
? footerData.address.link2
|
||||
: footer.address?.link2 || "",
|
||||
};
|
||||
}
|
||||
if (footerData.contact) footer.contact = footerData.contact;
|
||||
if (Array.isArray(footerData.columns))
|
||||
footer.columns = footerData.columns;
|
||||
if (footerData.social && Array.isArray(footerData.social.links))
|
||||
footer.social = footerData.social;
|
||||
if (footerData.copyright && typeof footerData.copyright.text === "string")
|
||||
footer.copyright = footerData.copyright;
|
||||
}
|
||||
|
||||
await footer.save();
|
||||
|
||||
req.flash("success_msg", "Footer updated successfully");
|
||||
|
||||
// Redirect back to the active tab
|
||||
const activeTab = req.body.activeTab || 'about';
|
||||
res.redirect(`/admin/footer?activeTab=${activeTab}`);
|
||||
} catch (err) {
|
||||
console.error("Error updating footer:", err);
|
||||
req.flash("error_msg", err.message || "Error updating footer");
|
||||
res.redirect("/admin/footer");
|
||||
}
|
||||
};
|
||||
44
controllers/formController.js
Normal file
44
controllers/formController.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
const formController = {
|
||||
// Display form management page
|
||||
index: async (req, res) => {
|
||||
try {
|
||||
res.render('admin/form/index', {
|
||||
layout: 'layouts/admin',
|
||||
title: 'Quản lý Form',
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading form management page:', error);
|
||||
res.status(500).render('error', {
|
||||
message: 'Lỗi khi tải trang quản lý form',
|
||||
error: error
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Update default form settings
|
||||
updateDefaultForm: async (req, res) => {
|
||||
try {
|
||||
const formData = req.body;
|
||||
|
||||
// Here you would typically save form configuration to database or file
|
||||
// For now, just return success response
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Cập nhật form thành công'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating form:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Lỗi khi cập nhật form'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = formController;
|
||||
347
controllers/headerController.js
Normal file
347
controllers/headerController.js
Normal file
@@ -0,0 +1,347 @@
|
||||
const { addBaseUrlToImages } = require('../utils/imageHelper');
|
||||
const Header = require('../models/header');
|
||||
const Menu = require('../models/menuHeader');
|
||||
|
||||
// Helper function để thêm title và url cho programmes
|
||||
const addProgrammeDetails = (programmes, menuUrl) => {
|
||||
return programmes.map(prog => ({
|
||||
...prog,
|
||||
title: prog.name,
|
||||
url: `${menuUrl}${prog.code}/`
|
||||
}));
|
||||
};
|
||||
|
||||
// Helper function để xử lý menu tree cho API (đơn giản hóa, map menuid thành id)
|
||||
const processMenuTreeForAPI = (menuTree) => {
|
||||
return menuTree.map(item => {
|
||||
const processedItem = {
|
||||
id: item.menuid, // Map menuid to id for frontend
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
order: item.order,
|
||||
parent: item.parent || null,
|
||||
type: item.type,
|
||||
children: []
|
||||
};
|
||||
|
||||
// Đệ quy cho children
|
||||
if (item.children && item.children.length > 0) {
|
||||
processedItem.children = processMenuTreeForAPI(item.children);
|
||||
}
|
||||
|
||||
return processedItem;
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function để xử lý menu tree và thêm programme details (cho admin)
|
||||
const processMenuTree = (menuTree) => {
|
||||
return menuTree.map(item => {
|
||||
const processedItem = { ...item };
|
||||
|
||||
// Nếu có programmes, thêm title và url
|
||||
if (item.programmes && item.programmes.length > 0) {
|
||||
processedItem.programmes = addProgrammeDetails(item.programmes, item.url);
|
||||
}
|
||||
|
||||
// Đệ quy cho children
|
||||
if (item.children && item.children.length > 0) {
|
||||
processedItem.children = processMenuTree(item.children);
|
||||
}
|
||||
|
||||
return processedItem;
|
||||
});
|
||||
};
|
||||
|
||||
// Get header data from MongoDB
|
||||
const getHeaderData = async () => {
|
||||
const header = await Header.findOne({ name: 'default' });
|
||||
|
||||
if (!header) {
|
||||
return {
|
||||
topbar: {
|
||||
contactInfo: {
|
||||
phone: '',
|
||||
email: ''
|
||||
},
|
||||
links: []
|
||||
},
|
||||
mainMenu: [],
|
||||
logo: ''
|
||||
};
|
||||
}
|
||||
|
||||
// Convert to plain object to allow modifications
|
||||
const headerData = header.toObject();
|
||||
|
||||
// Lấy menu tree từ collection menuHeader (đơn giản, không có programmes)
|
||||
try {
|
||||
const menuTree = await Menu.getMenuTree();
|
||||
// Xử lý menu tree để map menuid thành id cho frontend
|
||||
headerData.mainMenu = processMenuTreeForAPI(menuTree);
|
||||
} catch (error) {
|
||||
console.error('Error getting menu tree:', error);
|
||||
headerData.mainMenu = [];
|
||||
}
|
||||
|
||||
return headerData;
|
||||
};
|
||||
|
||||
// API to get header data
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
// Lấy header data
|
||||
const header = await getHeaderData();
|
||||
|
||||
// Xử lý URL cho logo và các hình ảnh khác
|
||||
const processedData = addBaseUrlToImages(header);
|
||||
|
||||
res.json(processedData);
|
||||
} catch (err) {
|
||||
console.error('API Error:', err);
|
||||
res.status(500).json({ error: 'Error loading header data' });
|
||||
}
|
||||
};
|
||||
|
||||
// API để lấy menu tree cho frontend (public API)
|
||||
exports.getMenuTreeAPI = async (req, res) => {
|
||||
try {
|
||||
const menuTree = await Menu.getMenuTree();
|
||||
// Xử lý menu tree để map menuid thành id cho frontend
|
||||
const processedMenuTree = processMenuTreeForAPI(menuTree);
|
||||
res.json(processedMenuTree);
|
||||
} catch (error) {
|
||||
console.error('Error getting menu tree:', error);
|
||||
res.status(500).json({ error: 'Error loading menu tree' });
|
||||
}
|
||||
};
|
||||
|
||||
// API để lấy menu tree (cho admin)
|
||||
exports.getMenuTree = async (req, res) => {
|
||||
try {
|
||||
const menuTree = await Menu.getMenuTree();
|
||||
res.json(menuTree);
|
||||
} catch (error) {
|
||||
console.error('Error getting menu tree:', error);
|
||||
res.status(500).json({ error: 'Error loading menu tree' });
|
||||
}
|
||||
};
|
||||
|
||||
// API để lấy programmes theo menu ID
|
||||
exports.getProgrammesByMenuId = async (req, res) => {
|
||||
try {
|
||||
const { menuId } = req.params;
|
||||
const programmes = await Header.getProgrammesByMenuId(menuId);
|
||||
|
||||
// Lấy menu item để có URL
|
||||
const menuItem = await Menu.findOne({ menuid: menuId });
|
||||
if (menuItem) {
|
||||
const programmesWithDetails = addProgrammeDetails(programmes, menuItem.url);
|
||||
res.json(programmesWithDetails);
|
||||
} else {
|
||||
res.json(programmes);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting programmes by menu ID:', error);
|
||||
res.status(500).json({ error: 'Error loading programmes' });
|
||||
}
|
||||
};
|
||||
|
||||
// API để lấy toàn bộ header data
|
||||
exports.getHeaderData = async (req, res) => {
|
||||
try {
|
||||
const headerData = await getHeaderData();
|
||||
res.json(headerData);
|
||||
} catch (error) {
|
||||
console.error('Error getting header data:', error);
|
||||
res.status(500).json({ error: 'Error loading header data' });
|
||||
}
|
||||
};
|
||||
|
||||
// API để lấy menu item theo ID
|
||||
exports.getMenuItem = async (req, res) => {
|
||||
try {
|
||||
const { menuId } = req.params;
|
||||
const Menu = require('../models/menuHeader');
|
||||
const menuItem = await Menu.findOne({ menuid: menuId });
|
||||
|
||||
if (!menuItem) {
|
||||
return res.status(404).json({ error: 'Menu item not found' });
|
||||
}
|
||||
|
||||
// Nếu là level type và có fetch = true, lấy programmes
|
||||
if (menuItem.type === 'level' && menuItem.fetch) {
|
||||
const programmes = await Menu.getProgrammesByMenuId(menuItem.menuid);
|
||||
menuItem.programmes = addProgrammeDetails(programmes, menuItem.url);
|
||||
}
|
||||
|
||||
res.json(menuItem);
|
||||
} catch (error) {
|
||||
console.error('Error getting menu item:', error);
|
||||
res.status(500).json({ error: 'Error loading menu item' });
|
||||
}
|
||||
};
|
||||
|
||||
// Render admin view
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const data = await getHeaderData();
|
||||
|
||||
res.render('admin/header/index', {
|
||||
title: 'Header Management',
|
||||
data
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in header index:', error);
|
||||
req.flash('error_msg', 'An error occurred while loading the page');
|
||||
res.redirect('/admin/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
// Update header (chỉ cập nhật topbar và logo)
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const headerData = req.body;
|
||||
|
||||
// Tìm và cập nhật header
|
||||
const header = await Header.findOne({ name: 'default' });
|
||||
if (!header) {
|
||||
req.flash('error_msg', 'Header not found');
|
||||
return res.redirect('/admin/header');
|
||||
}
|
||||
|
||||
// Cập nhật từng phần
|
||||
if (headerData.topbarJson) {
|
||||
header.topbar = JSON.parse(headerData.topbarJson);
|
||||
}
|
||||
|
||||
if (headerData.logo) {
|
||||
header.logo = headerData.logo;
|
||||
}
|
||||
header.updatedAt = new Date();
|
||||
await header.save();
|
||||
|
||||
// Process menu updates if any
|
||||
let menuUpdateCount = 0;
|
||||
let menuErrorCount = 0;
|
||||
|
||||
if (headerData.menuUpdates) {
|
||||
try {
|
||||
const updates = JSON.parse(headerData.menuUpdates);
|
||||
|
||||
if (Array.isArray(updates) && updates.length > 0) {
|
||||
const Menu = require('../models/menuHeader');
|
||||
|
||||
for (const update of updates) {
|
||||
try {
|
||||
const { menuid, title, order, type, parent, fetch, isActive } = update;
|
||||
|
||||
const updateData = {
|
||||
title: title,
|
||||
order: order,
|
||||
type: type,
|
||||
parent: parent
|
||||
};
|
||||
|
||||
// Add fetch field if provided
|
||||
if (fetch !== undefined) {
|
||||
updateData.fetch = fetch;
|
||||
}
|
||||
|
||||
// Add isActive field if provided
|
||||
if (isActive !== undefined) {
|
||||
updateData.isActive = isActive;
|
||||
}
|
||||
|
||||
const result = await Menu.findOneAndUpdate(
|
||||
{ menuid: menuid },
|
||||
updateData,
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (result) {
|
||||
menuUpdateCount++;
|
||||
} else {
|
||||
console.warn(`Menu item not found for update: ${menuid}`);
|
||||
menuErrorCount++;
|
||||
}
|
||||
} catch (innerErr) {
|
||||
console.error(`Error updating menu item ${update.menuid}:`, innerErr);
|
||||
menuErrorCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error processing menu updates:', err);
|
||||
}
|
||||
}
|
||||
|
||||
let flashMsg = 'Header updated successfully.';
|
||||
if (menuUpdateCount > 0) {
|
||||
flashMsg += ` Updated ${menuUpdateCount} menu items.`;
|
||||
}
|
||||
if (menuErrorCount > 0) {
|
||||
req.flash('error_msg', `Updated ${menuUpdateCount} items, but failed to update ${menuErrorCount} items. Check logs.`);
|
||||
} else {
|
||||
req.flash('success_msg', flashMsg);
|
||||
}
|
||||
|
||||
// Redirect back to the active tab
|
||||
const activeTab = req.body.activeTab || 'topbar';
|
||||
res.redirect(`/admin/header?activeTab=${activeTab}`);
|
||||
} catch (error) {
|
||||
console.error('Error updating header:', error);
|
||||
req.flash('error_msg', 'Error updating header: ' + error.message);
|
||||
res.redirect('/admin/header');
|
||||
}
|
||||
};
|
||||
|
||||
// Update menu structure (order and parent)
|
||||
exports.updateMenu = async (req, res) => {
|
||||
try {
|
||||
const { updates } = req.body;
|
||||
|
||||
if (!updates || !Array.isArray(updates)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid updates data'
|
||||
});
|
||||
}
|
||||
|
||||
const Menu = require('../models/menuHeader');
|
||||
|
||||
// Update each menu item
|
||||
for (const update of updates) {
|
||||
const { menuid, title, order, type, parent, fetch, isActive } = update;
|
||||
|
||||
console.log(menuid, title, order, type, parent, fetch, isActive);
|
||||
|
||||
const updateData = {
|
||||
title: title,
|
||||
order: order,
|
||||
type: type,
|
||||
parent: parent
|
||||
};
|
||||
|
||||
// Add fetch field if provided (for level type menus)
|
||||
if (fetch !== undefined) {
|
||||
updateData.fetch = fetch;
|
||||
}
|
||||
|
||||
// Add isActive field if provided
|
||||
if (isActive !== undefined) {
|
||||
updateData.isActive = isActive;
|
||||
}
|
||||
|
||||
await Menu.findOneAndUpdate(
|
||||
{ menuid: menuid },
|
||||
updateData,
|
||||
{ new: true }
|
||||
);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error updating menu structure:', error);
|
||||
req.flash('error_msg', 'Error updating menu structure: ' + error.message);
|
||||
res.redirect('/admin/header');
|
||||
}
|
||||
};
|
||||
315
controllers/homeController.js
Normal file
315
controllers/homeController.js
Normal file
@@ -0,0 +1,315 @@
|
||||
const { addBaseUrlToImages } = require('../utils/imageHelper');
|
||||
const Home = require('../models/home');
|
||||
|
||||
// -------------------- Helper Functions --------------------
|
||||
|
||||
// Get home data from MongoDB
|
||||
const getHomeData = async () => {
|
||||
const home = await Home.findOne().sort({ updatedAt: -1 }).lean();
|
||||
return home || {};
|
||||
};
|
||||
|
||||
// Get default home data structure
|
||||
const getDefaultHomeData = () => ({
|
||||
hero: {
|
||||
title: '',
|
||||
description: '',
|
||||
backgroundImage: '',
|
||||
button: { label: 'Book Your Adventure', href: '/booking' },
|
||||
contactBox: {
|
||||
welcomeText: '',
|
||||
phone: { label: 'Call us', number: '', href: '' },
|
||||
email: { label: 'Email', address: '', href: '' },
|
||||
workingHours: { label: 'Working Hours', hours: '' }
|
||||
}
|
||||
},
|
||||
about: {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
description: '',
|
||||
images: { mainImage1: '', mainImage2: '', avatars: [] },
|
||||
features: [],
|
||||
quote: '',
|
||||
button: { label: '', href: '' },
|
||||
stats: { customerCount: 0, customerLabel: '' }
|
||||
},
|
||||
missionVision: {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
backgroundImage: '',
|
||||
cards: []
|
||||
},
|
||||
whyChooseUs: {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
description: '',
|
||||
button: { label: '', href: '' },
|
||||
features: [],
|
||||
tags: [],
|
||||
cta: { text: '', linkText: '', linkHref: '' }
|
||||
},
|
||||
activities: {
|
||||
cards: []
|
||||
},
|
||||
faq: {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
description: '',
|
||||
image: '',
|
||||
contact: { title: '', info: '' },
|
||||
questions: []
|
||||
},
|
||||
partners: {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
backgroundImage: '',
|
||||
logos: [],
|
||||
cta: { badge: '', text: '', linkText: '', linkHref: '' }
|
||||
},
|
||||
programs: {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
button: { label: '', href: '' },
|
||||
card: {
|
||||
pricePrefix: 'from',
|
||||
priceSuffix: 'USD',
|
||||
buttonLabel: 'Camp Detail',
|
||||
buttonHref: '/camp-profiles'
|
||||
},
|
||||
items: []
|
||||
},
|
||||
newsletter: {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
description: '',
|
||||
image: '',
|
||||
decorativeImage: '',
|
||||
button: {
|
||||
label: '',
|
||||
placeholder: '',
|
||||
href: ''
|
||||
}
|
||||
},
|
||||
latestPosts: {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
searchPlaceholder: '',
|
||||
sidebarTitle: '',
|
||||
blogPosts: [],
|
||||
sidebarPosts: [],
|
||||
featuredCard: { image: '', title: '', description: '' }
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// -------------------- Admin Exports --------------------
|
||||
|
||||
|
||||
// Display home management page
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
// Fetch Home data
|
||||
let data = await getHomeData();
|
||||
|
||||
// If no data exists, use default
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
data = getDefaultHomeData();
|
||||
} else {
|
||||
// Merge with defaults to ensure all fields exist
|
||||
const defaultData = getDefaultHomeData();
|
||||
|
||||
// Ensure all sections exist with defaults
|
||||
data.hero = data.hero || defaultData.hero;
|
||||
data.about = data.about || defaultData.about;
|
||||
data.missionVision = data.missionVision || defaultData.missionVision;
|
||||
data.whyChooseUs = data.whyChooseUs || defaultData.whyChooseUs;
|
||||
data.activities = data.activities || defaultData.activities;
|
||||
data.faq = data.faq || defaultData.faq;
|
||||
data.partners = data.partners || defaultData.partners;
|
||||
data.programs = data.programs || defaultData.programs;
|
||||
data.newsletter = data.newsletter || defaultData.newsletter;
|
||||
data.latestPosts = data.latestPosts || defaultData.latestPosts;
|
||||
}
|
||||
|
||||
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||
|
||||
res.render('admin/home/index', {
|
||||
layout: 'layouts/main',
|
||||
title: 'Home Management',
|
||||
data,
|
||||
frontendUrl,
|
||||
currentPath: req.path,
|
||||
user: req.session.user
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Home index error:', err);
|
||||
req.flash('error_msg', 'Error loading home data');
|
||||
res.redirect('/admin/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
// Update home data
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
// Get current data
|
||||
const currentData = await getHomeData();
|
||||
|
||||
// Create updated data object
|
||||
const updatedData = { ...(currentData.toObject ? currentData.toObject() : currentData) };
|
||||
|
||||
// Update Hero section data (from Welcome tab)
|
||||
if (req.body.heroTitle || req.body.heroDescription || req.body.heroBackgroundImage) {
|
||||
updatedData.hero = {
|
||||
title: req.body.heroTitle || '',
|
||||
description: req.body.heroDescription || '',
|
||||
backgroundImage: req.body.heroBackgroundImage || '',
|
||||
button: {
|
||||
label: req.body.heroButtonLabel || 'Book Your Adventure',
|
||||
href: req.body.heroButtonHref || '/booking'
|
||||
},
|
||||
contactBox: {
|
||||
welcomeText: req.body.heroContactWelcome || '',
|
||||
phone: {
|
||||
label: 'Call us',
|
||||
number: req.body.heroContactPhone || '',
|
||||
href: req.body.heroContactPhone ? `tel:${req.body.heroContactPhone}` : ''
|
||||
},
|
||||
email: {
|
||||
label: 'Email',
|
||||
address: req.body.heroContactEmail || '',
|
||||
href: req.body.heroContactEmail ? `mailto:${req.body.heroContactEmail}` : ''
|
||||
},
|
||||
workingHours: {
|
||||
label: 'Working Hours',
|
||||
hours: req.body.heroContactHours || ''
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Update Why Choose Us section
|
||||
if (req.body.whyChooseUsTitle || req.body.whyChooseUsSubtitle) {
|
||||
updatedData.whyChooseUs = {
|
||||
...(updatedData.whyChooseUs || {}),
|
||||
title: req.body.whyChooseUsTitle || '',
|
||||
subtitle: req.body.whyChooseUsSubtitle || '',
|
||||
description: req.body.whyChooseUsDescription || '',
|
||||
button: {
|
||||
label: req.body.whyChooseUsButtonLabel || '',
|
||||
href: req.body.whyChooseUsButtonHref || ''
|
||||
},
|
||||
features: updatedData.whyChooseUs?.features || [],
|
||||
tags: updatedData.whyChooseUs?.tags || [],
|
||||
cta: updatedData.whyChooseUs?.cta || { text: '', linkText: '', linkHref: '' }
|
||||
};
|
||||
}
|
||||
|
||||
// Handle Home sections (new camp structure only)
|
||||
const sections = ['hero', 'about', 'missionVision', 'whyChooseUs', 'activities', 'faq',
|
||||
'partners', 'programs', 'newsletter', 'latestPosts'];
|
||||
const errors = [];
|
||||
let hasChanges = false;
|
||||
|
||||
// Process each section
|
||||
for (const section of sections) {
|
||||
try {
|
||||
if (!req.body[section]) {
|
||||
console.warn(`No data for section: ${section}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse JSON data from form
|
||||
const newSectionData = JSON.parse(req.body[section]);
|
||||
|
||||
// Check for changes
|
||||
const currentSectionData = currentData[section];
|
||||
const sectionHasChanges = JSON.stringify(newSectionData) !== JSON.stringify(currentSectionData);
|
||||
|
||||
if (sectionHasChanges) {
|
||||
updatedData[section] = newSectionData;
|
||||
hasChanges = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing section ${section}:`, error);
|
||||
errors.push(`Error processing ${section} data: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (errors.length > 0) {
|
||||
req.flash('error_msg', `Data processing error: ${errors[0]}`);
|
||||
return req.session.save(() => res.redirect('/admin/home'));
|
||||
}
|
||||
|
||||
// Check if there are changes
|
||||
if (!hasChanges) {
|
||||
req.flash('info_msg', 'No changes were made');
|
||||
return req.session.save(() => res.redirect('/admin/home'));
|
||||
}
|
||||
|
||||
// Update or create document
|
||||
try {
|
||||
if (currentData._id) {
|
||||
await Home.findByIdAndUpdate(currentData._id, updatedData, { new: true });
|
||||
} else {
|
||||
await Home.create(updatedData);
|
||||
}
|
||||
|
||||
req.flash('success_msg', 'Home data updated successfully');
|
||||
return req.session.save(() => res.redirect('/admin/home'));
|
||||
} catch (dbError) {
|
||||
console.error('Database error:', dbError);
|
||||
req.flash('error_msg', `Database error: ${dbError.message || 'Unknown'}`);
|
||||
return req.session.save(() => res.redirect('/admin/home'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Update error:', err);
|
||||
req.flash('error_msg', `Update error: ${err.message || 'Unknown'}`);
|
||||
return req.session.save(() => res.redirect('/admin/home'));
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------- Public API Exports --------------------
|
||||
|
||||
// API to get home data for frontend
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const homeData = await getHomeData();
|
||||
|
||||
|
||||
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processedData = addBaseUrlToImages(homeData, baseUrl);
|
||||
|
||||
res.json(processedData);
|
||||
} catch (err) {
|
||||
console.error('Home API error:', err);
|
||||
res.status(500).json({ error: 'Error loading home data' });
|
||||
}
|
||||
};
|
||||
|
||||
// API to get hero data for frontend
|
||||
exports.apiHero = async (req, res) => {
|
||||
try {
|
||||
const homeData = await getHomeData();
|
||||
const heroData = homeData?.hero;
|
||||
|
||||
if (!heroData) {
|
||||
return res.status(404).json({
|
||||
error: 'Hero data not found',
|
||||
data: null
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processedData = addBaseUrlToImages(heroData, baseUrl);
|
||||
|
||||
res.json(processedData);
|
||||
} catch (err) {
|
||||
console.error('Hero API error:', err);
|
||||
res.status(500).json({ error: 'Error loading hero data' });
|
||||
}
|
||||
};
|
||||
495
controllers/insuranceController.js
Normal file
495
controllers/insuranceController.js
Normal file
@@ -0,0 +1,495 @@
|
||||
const Insurance = require("../models/insurance");
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
|
||||
// API để lấy insurance data (cho frontend)
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const language = req.query.lang || "en";
|
||||
|
||||
// Sử dụng getDefault để đảm bảo luôn có data
|
||||
const insurance = await Insurance.getDefault(language);
|
||||
|
||||
// Trả về data với cấu trúc mới
|
||||
const insuranceData = insurance.toObject();
|
||||
|
||||
// Sử dụng helper để thêm base URL vào đường dẫn ảnh
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processedData = addBaseUrlToImages(insuranceData, baseUrl);
|
||||
|
||||
// Trả về trực tiếp hero, page, content (không wrap trong object)
|
||||
res.json({
|
||||
hero: processedData.hero,
|
||||
page: processedData.page,
|
||||
content: processedData.content
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("API Error:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error loading insurance data",
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API để lấy toàn bộ insurance data (cho admin)
|
||||
exports.getInsuranceData = async (req, res) => {
|
||||
try {
|
||||
const language = req.query.lang || "en";
|
||||
const insurance = await Insurance.findOne({ name: "default", language: language });
|
||||
|
||||
if (!insurance) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Insurance data not found"
|
||||
});
|
||||
}
|
||||
|
||||
const insuranceData = insurance.toObject();
|
||||
|
||||
// Thêm base URL vào đường dẫn ảnh
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processedData = addBaseUrlToImages(insuranceData, baseUrl);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: processedData
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error getting insurance data:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error loading insurance data"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API để lấy data theo ngôn ngữ
|
||||
exports.getByLanguage = async (req, res) => {
|
||||
try {
|
||||
const language = req.params.lang || "en";
|
||||
|
||||
const insurance = await Insurance.findOne({ name: "default", language: language });
|
||||
|
||||
if (!insurance) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Insurance data not found"
|
||||
});
|
||||
}
|
||||
|
||||
const insuranceData = insurance.toObject();
|
||||
|
||||
// Thêm base URL vào đường dẫn ảnh
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processedData = addBaseUrlToImages(insuranceData, baseUrl);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
hero: processedData.hero,
|
||||
page: processedData.page,
|
||||
content: processedData.content
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error getting insurance by language:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error loading insurance data"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Render admin view
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
// Luôn đảm bảo có default data
|
||||
const insurance = await Insurance.getDefault("en");
|
||||
const data = insurance.toObject();
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
|
||||
res.render("admin/insurance/index", {
|
||||
title: "Insurance Management",
|
||||
layout: "layouts/main",
|
||||
data,
|
||||
frontendUrl,
|
||||
currentPath: req.path,
|
||||
user: req.session.user
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in insurance index:", error);
|
||||
req.flash("error_msg", "An error occurred while loading the page");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Seed data từ JSON file (cấu trúc mới)
|
||||
exports.seed = async (req, res) => {
|
||||
try {
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
// Đọc file JSON
|
||||
const jsonPath = path.join(__dirname, '../data/insurance.json');
|
||||
const jsonData = JSON.parse(await fs.readFile(jsonPath, 'utf8'));
|
||||
|
||||
console.log('Seeding insurance from JSON...');
|
||||
|
||||
// Migrate từ cấu trúc cũ sang mới
|
||||
const insurance = await Insurance.migrateFromJson(jsonData, "en");
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Insurance data seeded successfully",
|
||||
data: {
|
||||
id: insurance._id,
|
||||
hero: insurance.hero,
|
||||
page: insurance.page,
|
||||
content: insurance.content
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error seeding insurance:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || "Error seeding insurance data"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API preview cho admin (tạo HTML preview)
|
||||
exports.preview = async (req, res) => {
|
||||
try {
|
||||
const { hero, page, content } = req.body;
|
||||
|
||||
// Parse JSON strings
|
||||
const parseJson = (data) => {
|
||||
if (!data) return null;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
console.error("JSON parse error:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const heroData = parseJson(hero) || {};
|
||||
const pageData = parseJson(page) || {};
|
||||
const contentData = parseJson(content) || {};
|
||||
|
||||
// Thêm base URL vào đường dẫn ảnh cho preview
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processedHeroData = addBaseUrlToImages(heroData, baseUrl);
|
||||
|
||||
// Render preview HTML
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${pageData.title || 'Insurance Preview'}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; }
|
||||
.hero-section {
|
||||
background: linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.7)),
|
||||
url('${processedHeroData.backgroundImage || ''}');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
color: white;
|
||||
padding: 100px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.page-header {
|
||||
padding: 40px 20px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.content-section {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.content-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-section">
|
||||
<h1>${heroData.title || 'Insurance'}</h1>
|
||||
<p>${heroData.subtitle || ''}</p>
|
||||
</div>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="container">
|
||||
<h2>${pageData.title || 'Insurance Information'}</h2>
|
||||
${pageData.divider !== false ? '<hr>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Section -->
|
||||
<div class="content-section">
|
||||
<div class="container">
|
||||
${renderContentItems(contentData.content || [])}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
res.send(html);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error generating preview:", error);
|
||||
res.status(500).send("Error generating preview");
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function để render content items
|
||||
function renderContentItems(contentItems) {
|
||||
if (!Array.isArray(contentItems) || contentItems.length === 0) {
|
||||
return '<p>No content available.</p>';
|
||||
}
|
||||
|
||||
return contentItems.map(item => {
|
||||
switch (item.type) {
|
||||
case 'header':
|
||||
return `<h${item.level || 2} class="content-item">${item.text}</h${item.level || 2}>`;
|
||||
|
||||
case 'paragraph':
|
||||
return `<p class="content-item">${item.text}</p>`;
|
||||
|
||||
case 'section':
|
||||
return `
|
||||
<div class="content-item">
|
||||
<h3>${item.title}</h3>
|
||||
<p>${item.content}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'list':
|
||||
const listItems = (item.items || []).map(li => `<li>${li}</li>`).join('');
|
||||
return `<ul class="content-item">${listItems}</ul>`;
|
||||
|
||||
case 'note':
|
||||
return `<div class="alert alert-info content-item">${item.text}</div>`;
|
||||
|
||||
case 'embed':
|
||||
if (item.source === 'youtube') {
|
||||
return `
|
||||
<div class="content-item">
|
||||
<iframe width="${item.width || 560}" height="${item.height || 315}"
|
||||
src="${item.url || item.embed}"
|
||||
frameborder="0" allowfullscreen></iframe>
|
||||
${item.caption ? `<p class="text-muted mt-2">${item.caption}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return '';
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// API để tạo insurance mới (cho các ngôn ngữ khác)
|
||||
exports.create = async (req, res) => {
|
||||
try {
|
||||
const { hero, page, content, language } = req.body;
|
||||
|
||||
if (!language) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Language is required"
|
||||
});
|
||||
}
|
||||
|
||||
// Kiểm tra đã tồn tại chưa
|
||||
const existing = await Insurance.findOne({ name: "default", language: language });
|
||||
if (existing) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Insurance already exists for this language"
|
||||
});
|
||||
}
|
||||
|
||||
// Parse JSON nếu cần
|
||||
const parseJson = (data) => {
|
||||
if (!data) return null;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
console.error("JSON parse error:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const insurance = new Insurance({
|
||||
name: "default",
|
||||
language: language,
|
||||
hero: parseJson(hero) || {},
|
||||
page: parseJson(page) || {},
|
||||
content: parseJson(content) || {},
|
||||
version: "2.0.0",
|
||||
isActive: true,
|
||||
migratedFromOldStructure: false
|
||||
});
|
||||
|
||||
await insurance.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Insurance created successfully for language: " + language,
|
||||
data: insurance
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error creating insurance:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || "Error creating insurance"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Cập nhật dữ liệu insurance (CẬP NHẬT CẤU TRÚC MỚI)
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const { hero, page, content } = req.body;
|
||||
|
||||
// Parse JSON strings
|
||||
const parseJson = (data) => {
|
||||
if (!data) return null;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
console.error("JSON parse error:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
// Parse all data với cấu trúc mới
|
||||
const heroData = parseJson(hero) || {};
|
||||
const pageData = parseJson(page) || {};
|
||||
const contentData = parseJson(content) || {};
|
||||
|
||||
// Normalize embed blocks (convert YouTube watch URLs to /embed/ URLs)
|
||||
function extractYouTubeId(url) {
|
||||
const regex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/;
|
||||
const match = url.match(regex);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
if (contentData && Array.isArray(contentData.content)) {
|
||||
contentData.content.forEach(item => {
|
||||
if (item.type === 'embed' && item.source === 'youtube') {
|
||||
if (item.url && item.url.includes('watch?v=')) {
|
||||
const videoId = extractYouTubeId(item.url);
|
||||
if (videoId) {
|
||||
item.url = `https://www.youtube.com/embed/${videoId}`;
|
||||
item.videoId = videoId;
|
||||
}
|
||||
}
|
||||
if (item.embed && item.embed.includes('watch?v=')) {
|
||||
const videoId = extractYouTubeId(item.embed);
|
||||
if (videoId) {
|
||||
item.embed = `https://www.youtube.com/embed/${videoId}`;
|
||||
item.videoId = videoId;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Tìm hoặc tạo insurance
|
||||
let insurance = await Insurance.findOne({ name: "default", language: "en" });
|
||||
|
||||
if (!insurance) {
|
||||
insurance = new Insurance({
|
||||
name: "default",
|
||||
language: "en",
|
||||
hero: heroData,
|
||||
page: pageData,
|
||||
content: contentData,
|
||||
version: "2.0.0",
|
||||
isActive: true
|
||||
});
|
||||
} else {
|
||||
insurance.hero = heroData;
|
||||
insurance.page = pageData;
|
||||
insurance.content = contentData;
|
||||
insurance.version = "2.0.0";
|
||||
}
|
||||
|
||||
await insurance.save();
|
||||
|
||||
req.flash("success_msg", "Insurance updated successfully");
|
||||
res.redirect("/admin/insurance");
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error updating insurance:", err);
|
||||
req.flash("error_msg", err.message || "Error updating insurance");
|
||||
res.redirect("/admin/insurance");
|
||||
}
|
||||
};
|
||||
|
||||
// API để xóa insurance (theo ngôn ngữ)
|
||||
exports.delete = async (req, res) => {
|
||||
try {
|
||||
const language = req.params.lang;
|
||||
|
||||
if (!language) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Language parameter is required"
|
||||
});
|
||||
}
|
||||
|
||||
// Không cho phép xóa tiếng Anh mặc định
|
||||
if (language === "en") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Cannot delete default English insurance data"
|
||||
});
|
||||
}
|
||||
|
||||
const result = await Insurance.deleteOne({ name: "default", language: language });
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Insurance not found for this language"
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Insurance deleted successfully for language: " + language
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error deleting insurance:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || "Error deleting insurance"
|
||||
});
|
||||
}
|
||||
};
|
||||
228
controllers/pageController.js
Normal file
228
controllers/pageController.js
Normal file
@@ -0,0 +1,228 @@
|
||||
const { readJsonFile, writeJsonFile } = require('../utils/jsonHelper');
|
||||
const slugify = require('slugify');
|
||||
|
||||
// Hiển thị tất cả các trang
|
||||
exports.getAllPages = async (req, res) => {
|
||||
try {
|
||||
const content = readJsonFile('content');
|
||||
const pages = content.pages || [];
|
||||
|
||||
res.render('admin/pages/index', {
|
||||
title: 'Quản lý trang',
|
||||
pages
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash('error_msg', 'Error loading page list');
|
||||
res.redirect('/admin/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
// Hiển thị form tạo trang mới
|
||||
exports.getAddPage = (req, res) => {
|
||||
res.render('admin/pages/add', {
|
||||
title: 'Thêm trang mới'
|
||||
});
|
||||
};
|
||||
|
||||
// Xử lý tạo trang mới
|
||||
exports.addPage = async (req, res) => {
|
||||
try {
|
||||
const { title, content } = req.body;
|
||||
|
||||
// Kiểm tra dữ liệu
|
||||
if (!title || !content) {
|
||||
req.flash('error_msg', 'Please fill in all required fields');
|
||||
return res.redirect('/admin/pages/add');
|
||||
}
|
||||
|
||||
// Tạo slug từ tiêu đề
|
||||
const slug = slugify(title, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
locale: 'vi'
|
||||
});
|
||||
|
||||
// Lấy dữ liệu hiện tại
|
||||
const contentData = readJsonFile('content');
|
||||
const pages = contentData.pages || [];
|
||||
|
||||
// Kiểm tra slug đã tồn tại chưa
|
||||
const existingPage = pages.find(page => page.slug === slug);
|
||||
if (existingPage) {
|
||||
req.flash('error_msg', 'This title is already in use. Please choose a different title.');
|
||||
return res.redirect('/admin/pages/add');
|
||||
}
|
||||
|
||||
// Tạo trang mới
|
||||
const newPage = {
|
||||
id: Date.now().toString(), // Sử dụng timestamp làm ID
|
||||
title,
|
||||
slug,
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Thêm trang mới vào danh sách
|
||||
pages.push(newPage);
|
||||
contentData.pages = pages;
|
||||
|
||||
// Lưu lại dữ liệu
|
||||
writeJsonFile('content', contentData);
|
||||
|
||||
req.flash('success_msg', 'New page created successfully');
|
||||
res.redirect('/admin/pages');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash('error_msg', 'Error creating new page');
|
||||
res.redirect('/admin/pages/add');
|
||||
}
|
||||
};
|
||||
|
||||
// Hiển thị form chỉnh sửa trang
|
||||
exports.getEditPage = async (req, res) => {
|
||||
try {
|
||||
const pageId = req.params.id;
|
||||
const contentData = readJsonFile('content');
|
||||
const pages = contentData.pages || [];
|
||||
|
||||
const page = pages.find(p => p.id === pageId);
|
||||
|
||||
if (!page) {
|
||||
req.flash('error_msg', 'Page not found');
|
||||
return res.redirect('/admin/pages');
|
||||
}
|
||||
|
||||
res.render('admin/pages/edit', {
|
||||
title: 'Chỉnh sửa trang',
|
||||
page
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash('error_msg', 'Error loading page');
|
||||
res.redirect('/admin/pages');
|
||||
}
|
||||
};
|
||||
|
||||
// Xử lý chỉnh sửa trang
|
||||
exports.updatePage = async (req, res) => {
|
||||
try {
|
||||
const pageId = req.params.id;
|
||||
const { title, content } = req.body;
|
||||
|
||||
// Kiểm tra dữ liệu
|
||||
if (!title || !content) {
|
||||
req.flash('error_msg', 'Please fill in all required fields');
|
||||
return res.redirect(`/admin/pages/edit/${pageId}`);
|
||||
}
|
||||
|
||||
// Lấy dữ liệu hiện tại
|
||||
const contentData = readJsonFile('content');
|
||||
const pages = contentData.pages || [];
|
||||
|
||||
// Tìm trang cần cập nhật
|
||||
const pageIndex = pages.findIndex(p => p.id === pageId);
|
||||
|
||||
if (pageIndex === -1) {
|
||||
req.flash('error_msg', 'Page not found');
|
||||
return res.redirect('/admin/pages');
|
||||
}
|
||||
|
||||
const page = pages[pageIndex];
|
||||
|
||||
// Kiểm tra nếu tiêu đề thay đổi thì cập nhật slug
|
||||
let newSlug = page.slug;
|
||||
if (page.title !== title) {
|
||||
newSlug = slugify(title, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
locale: 'vi'
|
||||
});
|
||||
|
||||
// Kiểm tra slug đã tồn tại chưa (trừ trang hiện tại)
|
||||
const existingPage = pages.find(p => p.slug === newSlug && p.id !== pageId);
|
||||
if (existingPage) {
|
||||
req.flash('error_msg', 'This title is already in use. Please choose a different title.');
|
||||
return res.redirect(`/admin/pages/edit/${pageId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cập nhật thông tin trang
|
||||
pages[pageIndex] = {
|
||||
...page,
|
||||
title,
|
||||
slug: newSlug,
|
||||
content,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Lưu lại dữ liệu
|
||||
contentData.pages = pages;
|
||||
writeJsonFile('content', contentData);
|
||||
|
||||
req.flash('success_msg', 'Page updated successfully');
|
||||
res.redirect('/admin/pages');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash('error_msg', 'Error updating page');
|
||||
res.redirect(`/admin/pages/edit/${req.params.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Xử lý xóa trang
|
||||
exports.deletePage = async (req, res) => {
|
||||
try {
|
||||
const pageId = req.params.id;
|
||||
|
||||
// Lấy dữ liệu hiện tại
|
||||
const contentData = readJsonFile('content');
|
||||
const pages = contentData.pages || [];
|
||||
|
||||
// Lọc bỏ trang cần xóa
|
||||
contentData.pages = pages.filter(p => p.id !== pageId);
|
||||
|
||||
// Lưu lại dữ liệu
|
||||
writeJsonFile('content', contentData);
|
||||
|
||||
req.flash('success_msg', 'Page deleted successfully');
|
||||
res.redirect('/admin/pages');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash('error_msg', 'Error deleting page');
|
||||
res.redirect('/admin/pages');
|
||||
}
|
||||
};
|
||||
|
||||
// Hiển thị trang theo slug
|
||||
exports.getPageBySlug = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
|
||||
// Lấy dữ liệu từ content.json
|
||||
const contentData = readJsonFile('content');
|
||||
const pages = contentData.pages || [];
|
||||
|
||||
// Tìm trang theo slug
|
||||
const page = pages.find(p => p.slug === slug);
|
||||
|
||||
if (!page) {
|
||||
return res.status(404).render('page/not-found', {
|
||||
title: 'Page Not Found',
|
||||
message: 'The page you are looking for does not exist or has been deleted.'
|
||||
});
|
||||
}
|
||||
|
||||
// Hiển thị trang
|
||||
res.render('page/view', {
|
||||
title: page.title,
|
||||
page
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).render('page/error', {
|
||||
title: 'Error',
|
||||
message: 'An error occurred while loading the page. Please try again later.'
|
||||
});
|
||||
}
|
||||
};
|
||||
164
controllers/safetyController.js
Normal file
164
controllers/safetyController.js
Normal file
@@ -0,0 +1,164 @@
|
||||
const Safety = require("../models/safety");
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
|
||||
// Lấy dữ liệu Safety từ MongoDB
|
||||
const getSafetyData = async () => {
|
||||
const safety = await Safety.findOne().sort({ updatedAt: -1 });
|
||||
if (!safety) {
|
||||
return null;
|
||||
}
|
||||
return safety.toObject();
|
||||
};
|
||||
|
||||
// API endpoint cho frontend
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const safety = await getSafetyData();
|
||||
if (!safety) {
|
||||
return res.status(404).json({ error: "Safety data not found" });
|
||||
}
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
|
||||
const processedData = addBaseUrlToImages(safety, baseUrl);
|
||||
res.json(processedData);
|
||||
} catch (err) {
|
||||
console.error("Safety API error:", err);
|
||||
res.status(500).json({ error: "Error loading safety data" });
|
||||
}
|
||||
};
|
||||
|
||||
// Hiển thị danh sách Safety cho admin
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const items = await Safety.find().sort({ updatedAt: -1 }).limit(10);
|
||||
// Lấy bản ghi mới nhất hoặc object rỗng nếu chưa có dữ liệu
|
||||
const latest = items && items.length > 0 ? items[0] : null;
|
||||
const data = latest ? (latest.toObject ? latest.toObject() : latest) : {
|
||||
hero: { title: "", banner: "" },
|
||||
approach: {},
|
||||
approachImgs: [],
|
||||
approachStats: [],
|
||||
approachFeatures: [],
|
||||
approachCards: [],
|
||||
philosophy: {},
|
||||
philosophyCards: [],
|
||||
security: {},
|
||||
securityCards: []
|
||||
};
|
||||
res.render("admin/safety/index", {
|
||||
layout: "layouts/main",
|
||||
title: "Safety Management",
|
||||
items,
|
||||
data,
|
||||
frontendUrl: process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", "Error loading Safety data");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Hiển thị form tạo mới Safety
|
||||
exports.createForm = async (req, res) => {
|
||||
try {
|
||||
res.render("admin/safety/create", {
|
||||
layout: "layouts/main",
|
||||
title: "Create Safety",
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", "Error loading create form");
|
||||
res.redirect("/admin/safety");
|
||||
}
|
||||
};
|
||||
|
||||
// Tạo mới Safety
|
||||
exports.create = async (req, res) => {
|
||||
try {
|
||||
const safetyData = req.body; // Tùy chỉnh parse nếu cần
|
||||
const newSafety = new Safety(safetyData);
|
||||
await newSafety.save();
|
||||
req.flash("success_msg", "Safety created successfully");
|
||||
res.redirect("/admin/safety");
|
||||
} catch (err) {
|
||||
console.error("Create error:", err);
|
||||
req.flash("error_msg", `Create error: ${err.message || "Unknown"}`);
|
||||
res.redirect("/admin/safety/create");
|
||||
}
|
||||
};
|
||||
|
||||
// Cập nhật Safety
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const { hero, approach, philosophy, security } = req.body;
|
||||
|
||||
// Parse JSON strings
|
||||
const parseJson = (data) => {
|
||||
if (!data) return null;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const heroData = parseJson(hero);
|
||||
const approachData = parseJson(approach);
|
||||
const philosophyData = parseJson(philosophy);
|
||||
const securityData = parseJson(security);
|
||||
|
||||
// Tìm hoặc tạo safety record
|
||||
const items = await Safety.find().sort({ updatedAt: -1 }).limit(1);
|
||||
let safety = items && items.length > 0 ? items[0] : null;
|
||||
|
||||
if (!safety) {
|
||||
// Tạo mới
|
||||
safety = new Safety({
|
||||
hero: heroData || { title: "", banner: "" },
|
||||
approach: approachData || {},
|
||||
philosophy: philosophyData || {},
|
||||
security: securityData || {}
|
||||
});
|
||||
} else {
|
||||
// Cập nhật
|
||||
if (heroData) safety.hero = heroData;
|
||||
if (approachData) safety.approach = approachData;
|
||||
if (philosophyData) safety.philosophy = philosophyData;
|
||||
if (securityData) safety.security = securityData;
|
||||
}
|
||||
|
||||
await safety.save();
|
||||
|
||||
req.flash("success_msg", "Safety updated successfully");
|
||||
res.redirect("/admin/safety");
|
||||
} catch (err) {
|
||||
console.error("Update error:", err);
|
||||
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
|
||||
res.redirect("/admin/safety");
|
||||
}
|
||||
};
|
||||
|
||||
// Xóa Safety
|
||||
exports.delete = async (req, res) => {
|
||||
try {
|
||||
const safety = await Safety.findById(req.params.id);
|
||||
if (!safety) {
|
||||
req.flash("error_msg", "Safety record not found");
|
||||
return res.redirect("/admin/safety");
|
||||
}
|
||||
await Safety.findByIdAndDelete(req.params.id);
|
||||
req.flash("success_msg", "Safety record deleted successfully");
|
||||
res.redirect("/admin/safety");
|
||||
} catch (err) {
|
||||
console.error("Delete error:", err);
|
||||
req.flash("error_msg", `Delete error: ${err.message || "Unknown"}`);
|
||||
res.redirect("/admin/safety");
|
||||
}
|
||||
};
|
||||
56
controllers/settingController.js
Normal file
56
controllers/settingController.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const { readJsonFile, writeJsonFile } = require('../utils/jsonHelper');
|
||||
|
||||
// Hiển thị cài đặt
|
||||
exports.getSettings = async (req, res) => {
|
||||
try {
|
||||
// Lấy cài đặt từ file content.json
|
||||
const content = readJsonFile('content');
|
||||
const settings = content.settings || {
|
||||
siteName: 'CMS-SIMS',
|
||||
description: 'Hệ thống quản lý nội dung đơn giản'
|
||||
};
|
||||
|
||||
res.render('admin/settings', {
|
||||
title: 'Cài đặt hệ thống',
|
||||
settings
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash('error_msg', 'Error loading settings');
|
||||
res.redirect('/admin/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
// Cập nhật cài đặt
|
||||
exports.updateSettings = async (req, res) => {
|
||||
try {
|
||||
const { siteName, description } = req.body;
|
||||
|
||||
// Kiểm tra dữ liệu
|
||||
if (!siteName) {
|
||||
req.flash('error_msg', 'Website name cannot be empty');
|
||||
return res.redirect('/admin/settings');
|
||||
}
|
||||
|
||||
// Lấy dữ liệu hiện tại
|
||||
const content = readJsonFile('content');
|
||||
|
||||
// Cập nhật thông tin
|
||||
content.settings = {
|
||||
...content.settings,
|
||||
siteName,
|
||||
description,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Lưu lại dữ liệu
|
||||
writeJsonFile('content', content);
|
||||
|
||||
req.flash('success_msg', 'Settings updated successfully');
|
||||
res.redirect('/admin/settings');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash('error_msg', 'Error updating settings');
|
||||
res.redirect('/admin/settings');
|
||||
}
|
||||
};
|
||||
536
controllers/termsController.js
Normal file
536
controllers/termsController.js
Normal file
@@ -0,0 +1,536 @@
|
||||
// controllers/termsController.js
|
||||
const Terms = require("../models/terms");
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper"); // Import helper
|
||||
|
||||
// API để lấy terms data (cho frontend)
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const language = req.query.lang || "en";
|
||||
|
||||
// Sử dụng getDefault để đảm bảo luôn có data
|
||||
const terms = await Terms.getDefault(language);
|
||||
|
||||
// Trả về data với cấu trúc mới
|
||||
const termsData = terms.toObject();
|
||||
|
||||
// Sử dụng helper để thêm base URL vào đường dẫn ảnh
|
||||
// Truyền baseUrl từ request hoặc từ environment
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processedData = addBaseUrlToImages(termsData, baseUrl);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
hero: processedData.hero,
|
||||
page: processedData.page,
|
||||
content: processedData.content
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("API Error:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error loading terms data",
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API để lấy toàn bộ terms data (cho admin)
|
||||
exports.getTermsData = async (req, res) => {
|
||||
try {
|
||||
const language = req.query.lang || "en";
|
||||
const terms = await Terms.findOne({ name: "default", language: language });
|
||||
|
||||
if (!terms) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Terms data not found"
|
||||
});
|
||||
}
|
||||
|
||||
const termsData = terms.toObject();
|
||||
|
||||
// Thêm base URL vào đường dẫn ảnh
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processedData = addBaseUrlToImages(termsData, baseUrl);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: processedData
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error getting terms data:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error loading terms data"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API để lấy data theo ngôn ngữ
|
||||
exports.getByLanguage = async (req, res) => {
|
||||
try {
|
||||
const language = req.params.lang || "en";
|
||||
|
||||
const terms = await Terms.findOne({ name: "default", language: language });
|
||||
|
||||
if (!terms) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Terms data not found for language: " + language
|
||||
});
|
||||
}
|
||||
|
||||
const termsData = terms.toObject();
|
||||
|
||||
// Thêm base URL vào đường dẫn ảnh
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processedData = addBaseUrlToImages(termsData, baseUrl);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
hero: processedData.hero,
|
||||
page: processedData.page,
|
||||
content: processedData.content
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error getting terms by language:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error loading terms data"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Render admin view (không cần thêm baseUrl ở đây vì dùng trong CMS)
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
// Luôn đảm bảo có default data
|
||||
const terms = await Terms.getDefault("en");
|
||||
const data = terms.toObject();
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
|
||||
res.render("admin/terms/index", {
|
||||
title: "Terms & Conditions Management",
|
||||
layout: "layouts/main",
|
||||
data, // Không cần addBaseUrlToImages cho admin view
|
||||
frontendUrl,
|
||||
currentPath: req.path,
|
||||
user: req.session.user
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in terms index:", error);
|
||||
req.flash("error_msg", "An error occurred while loading the page");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Cập nhật dữ liệu terms (CẬP NHẬT CẤU TRÚC MỚI)
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const { hero, page, content } = req.body;
|
||||
|
||||
// Parse JSON strings
|
||||
const parseJson = (data) => {
|
||||
if (!data) return null;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
console.error("JSON parse error:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
// Parse all data với cấu trúc mới
|
||||
const heroData = parseJson(hero) || {};
|
||||
const pageData = parseJson(page) || {};
|
||||
const contentData = parseJson(content) || {};
|
||||
|
||||
// Normalize embed blocks (convert YouTube watch URLs to /embed/ URLs)
|
||||
function extractYouTubeId(url) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
// common YouTube URL patterns
|
||||
const m = url.match(/(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// Trong exports.update
|
||||
if (contentData && Array.isArray(contentData.content)) {
|
||||
contentData.content = contentData.content.map(item => {
|
||||
if (item && item.type === 'embed') {
|
||||
let embedUrl = item.embed || item.url || item.source || '';
|
||||
|
||||
// Luôn chuyển đổi sang embed URL nếu là watch URL
|
||||
if (embedUrl.includes('youtube.com/watch')) {
|
||||
const videoId = extractYouTubeId(embedUrl);
|
||||
if (videoId) {
|
||||
item.embed = `https://www.youtube.com/embed/${videoId}`;
|
||||
item.videoId = videoId;
|
||||
}
|
||||
}
|
||||
// Đảm bảo có videoId
|
||||
else if (embedUrl && !item.videoId) {
|
||||
const videoId = extractYouTubeId(embedUrl);
|
||||
if (videoId) {
|
||||
item.videoId = videoId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
// Tìm hoặc tạo terms
|
||||
let terms = await Terms.findOne({ name: "default", language: "en" });
|
||||
|
||||
if (!terms) {
|
||||
// Tạo mới với cấu trúc mới
|
||||
terms = new Terms({
|
||||
name: "default",
|
||||
language: "en",
|
||||
hero: heroData,
|
||||
page: pageData,
|
||||
content: contentData,
|
||||
version: "2.0.0",
|
||||
isActive: true,
|
||||
migratedFromOldStructure: false
|
||||
});
|
||||
} else {
|
||||
// Update existing với cấu trúc mới
|
||||
terms.hero = heroData;
|
||||
terms.page = pageData;
|
||||
terms.content = contentData;
|
||||
terms.version = "2.0.0";
|
||||
terms.migratedFromOldStructure = false;
|
||||
terms.updatedAt = new Date();
|
||||
}
|
||||
|
||||
await terms.save();
|
||||
|
||||
req.flash("success_msg", "Terms & Conditions updated successfully");
|
||||
res.redirect("/admin/terms-conditions");
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error updating terms:", err);
|
||||
req.flash("error_msg", err.message || "Error updating terms");
|
||||
res.redirect("/admin/terms-conditions");
|
||||
}
|
||||
};
|
||||
|
||||
// Seed data từ JSON file mới (cấu trúc mới)
|
||||
exports.seed = async (req, res) => {
|
||||
try {
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
// Đọc file JSON
|
||||
const jsonPath = path.join(__dirname, '../data/terms-conditions.json');
|
||||
const jsonData = JSON.parse(await fs.readFile(jsonPath, 'utf8'));
|
||||
|
||||
console.log('Seeding from JSON...');
|
||||
console.log('JSON structure keys:', Object.keys(jsonData));
|
||||
|
||||
// Kiểm tra cấu trúc JSON
|
||||
let terms;
|
||||
if (jsonData.hero && jsonData.page && jsonData.content) {
|
||||
// Cấu trúc mới
|
||||
console.log('Using new structure (hero, page, content)');
|
||||
terms = await Terms.migrateFromNewJson(jsonData, "en");
|
||||
} else if (jsonData.hero && jsonData.termsHeader && jsonData.sections) {
|
||||
// Cấu trúc cũ
|
||||
console.log('Using old structure, converting to new...');
|
||||
terms = await Terms.migrateFromJson(jsonData, "en");
|
||||
} else {
|
||||
throw new Error("Unknown JSON structure");
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Terms data seeded successfully",
|
||||
data: {
|
||||
id: terms._id,
|
||||
hero: terms.hero,
|
||||
page: terms.page,
|
||||
content: terms.content
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error seeding terms:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || "Error seeding terms data"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API preview cho admin (tạo HTML preview)
|
||||
exports.preview = async (req, res) => {
|
||||
try {
|
||||
const { hero, page, content } = req.body;
|
||||
|
||||
// Parse JSON strings
|
||||
const parseJson = (data) => {
|
||||
if (!data) return null;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
console.error("JSON parse error:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const heroData = parseJson(hero) || {};
|
||||
const pageData = parseJson(page) || {};
|
||||
const contentData = parseJson(content) || {};
|
||||
|
||||
// Thêm base URL vào đường dẫn ảnh cho preview
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processedHeroData = addBaseUrlToImages(heroData, baseUrl);
|
||||
|
||||
// Render preview HTML
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${pageData.title || 'Terms & Conditions Preview'}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; }
|
||||
.hero-section {
|
||||
background: linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.7)),
|
||||
url('${processedHeroData.backgroundImage || ''}');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
color: white;
|
||||
padding: 100px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.page-header {
|
||||
padding: 40px 20px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.content-section {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.content-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-section">
|
||||
<h1>${heroData.title || 'Terms & Conditions'}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="container">
|
||||
<h2>${pageData.title || 'Terms & Conditions'}</h2>
|
||||
${pageData.divider !== false ? '<hr>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Section -->
|
||||
<div class="content-section">
|
||||
<div class="container">
|
||||
${renderContentItems(contentData.content || [])}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
res.send(html);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error generating preview:", error);
|
||||
res.status(500).send("Error generating preview");
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function để render content items
|
||||
function renderContentItems(contentItems) {
|
||||
if (!Array.isArray(contentItems) || contentItems.length === 0) {
|
||||
return '<p>No content available.</p>';
|
||||
}
|
||||
|
||||
return contentItems.map(item => {
|
||||
switch (item.type) {
|
||||
case 'paragraph':
|
||||
return `<div class="content-item"><p>${item.text || ''}</p></div>`;
|
||||
|
||||
case 'section':
|
||||
let html = `<div class="content-item">`;
|
||||
html += `<h3>${item.title || ''}</h3>`;
|
||||
html += `<p>${item.content || ''}</p>`;
|
||||
|
||||
if (item.subsections && item.subsections.length > 0) {
|
||||
item.subsections.forEach(subsection => {
|
||||
if (subsection.type === 'cancellation_table') {
|
||||
html += `<h4>${subsection.title || ''}</h4>`;
|
||||
if (subsection.items && subsection.items.length > 0) {
|
||||
html += '<ul>';
|
||||
subsection.items.forEach(listItem => {
|
||||
html += `<li>${listItem}</li>`;
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
} else if (subsection.type === 'cancellation_section') {
|
||||
html += `<h4>${subsection.title || ''}</h4>`;
|
||||
if (subsection.items && subsection.items.length > 0) {
|
||||
html += '<ul>';
|
||||
subsection.items.forEach(listItem => {
|
||||
html += `<li>${listItem}</li>`;
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
} else if (subsection.type === 'note') {
|
||||
html += `<div class="alert alert-info">${subsection.text || ''}</div>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
|
||||
case 'note':
|
||||
return `<div class="content-item alert alert-info">${item.text || ''}</div>`;
|
||||
case 'embed':
|
||||
// Support several embed shapes: { embed }, { url }, { source }, { videoId }
|
||||
const embedSrc = (item.embed || item.url || item.source || (item.videoId ? `https://www.youtube.com/embed/${item.videoId}` : ''));
|
||||
if (!embedSrc) return `<div class="content-item">Invalid embed</div>`;
|
||||
return `<div class="content-item embed-item" style="margin-bottom:20px;">
|
||||
<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;">
|
||||
<iframe src="${embedSrc}" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" allowfullscreen loading="lazy" referrerpolicy="no-referrer"></iframe>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
default:
|
||||
return `<div class="content-item">Unknown content type: ${item.type}</div>`;
|
||||
}
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// API để tạo terms mới (cho các ngôn ngữ khác)
|
||||
exports.create = async (req, res) => {
|
||||
try {
|
||||
const { hero, page, content, language } = req.body;
|
||||
|
||||
if (!language) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Language is required"
|
||||
});
|
||||
}
|
||||
|
||||
// Kiểm tra đã tồn tại chưa
|
||||
const existing = await Terms.findOne({ name: "default", language: language });
|
||||
if (existing) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Terms already exists for language: " + language
|
||||
});
|
||||
}
|
||||
|
||||
// Parse JSON nếu cần
|
||||
const parseJson = (data) => {
|
||||
if (!data) return null;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
console.error("JSON parse error:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const terms = new Terms({
|
||||
name: "default",
|
||||
language: language,
|
||||
hero: parseJson(hero) || {},
|
||||
page: parseJson(page) || {},
|
||||
content: parseJson(content) || {},
|
||||
version: "2.0.0",
|
||||
isActive: true,
|
||||
migratedFromOldStructure: false
|
||||
});
|
||||
|
||||
await terms.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Terms created successfully for language: " + language,
|
||||
data: terms
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error creating terms:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || "Error creating terms"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API để xóa terms (theo ngôn ngữ)
|
||||
exports.delete = async (req, res) => {
|
||||
try {
|
||||
const language = req.params.lang;
|
||||
|
||||
if (!language) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Language is required"
|
||||
});
|
||||
}
|
||||
|
||||
// Không cho phép xóa tiếng Anh mặc định
|
||||
if (language === "en") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Cannot delete default English terms"
|
||||
});
|
||||
}
|
||||
|
||||
const result = await Terms.deleteOne({ name: "default", language: language });
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Terms not found for language: " + language
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Terms deleted successfully for language: " + language
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error deleting terms:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || "Error deleting terms"
|
||||
});
|
||||
}
|
||||
};
|
||||
232
controllers/travelController.js
Normal file
232
controllers/travelController.js
Normal file
@@ -0,0 +1,232 @@
|
||||
const Travel = require("../models/travel");
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
|
||||
/**
|
||||
* Hàm Helper: Trích xuất ID YouTube từ nhiều định dạng link khác nhau
|
||||
*/
|
||||
function extractYouTubeId(url) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
// Hỗ trợ: watch?v=, embed/, youtu.be/, shorts/
|
||||
const regex = /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/;
|
||||
const match = url.match(regex);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hàm Helper: Làm sạch danh sách blocks của Editor.js
|
||||
* Loại bỏ duplicate video (vừa embed vừa text link) và paragraph rỗng
|
||||
*/
|
||||
function sanitizeContentBlocks(blocks) {
|
||||
if (!blocks || !Array.isArray(blocks)) return [];
|
||||
|
||||
const seenVideoIds = new Set();
|
||||
|
||||
// Bước 1: Duyệt qua để chuẩn hóa block embed và lấy danh sách ID video
|
||||
const processedBlocks = blocks.map(block => {
|
||||
if (block.type === 'embed') {
|
||||
const url = block.data.source || block.data.embed || '';
|
||||
const videoId = extractYouTubeId(url);
|
||||
if (videoId) {
|
||||
seenVideoIds.add(videoId);
|
||||
// Cập nhật lại data chuẩn cho Editor.js
|
||||
block.data.embed = `https://www.youtube.com/embed/${videoId}`;
|
||||
block.data.source = url;
|
||||
block.data.videoId = videoId;
|
||||
block.data.service = 'youtube';
|
||||
}
|
||||
}
|
||||
return block;
|
||||
});
|
||||
|
||||
// Bước 2: Lọc bỏ paragraph rác
|
||||
return processedBlocks.filter(block => {
|
||||
if (block.type === 'paragraph') {
|
||||
const text = (block.data?.text || '').trim();
|
||||
|
||||
// Xóa paragraph rỗng
|
||||
if (text === '' || text === '<br>' || text === ' ') return false;
|
||||
|
||||
// Xóa paragraph nếu nó chỉ chứa 1 link YouTube đã được embed ở trên
|
||||
const videoIdInText = extractYouTubeId(text);
|
||||
if (videoIdInText && seenVideoIds.has(videoIdInText)) {
|
||||
console.log(`[Sanitizer] Removed duplicate text link for video: ${videoIdInText}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// GET: Show travel editor
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const travel = await Travel.findOne();
|
||||
|
||||
if (!travel) {
|
||||
return res.render("admin/travel/index", {
|
||||
title: "Travel Management",
|
||||
data: {
|
||||
page: { title: "Travel Information", description: "", metadata: { title: "", description: "" } },
|
||||
hero: { title: "Travel Information", backgroundImage: "" },
|
||||
content: { blocks: [] },
|
||||
enableScrollspy: false,
|
||||
},
|
||||
message: "No travel data found. Please run migration first.",
|
||||
});
|
||||
}
|
||||
|
||||
res.render("admin/travel/index", {
|
||||
title: "Travel Management",
|
||||
data: travel,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error loading travel page:", error);
|
||||
res.status(500).send("Error loading travel page");
|
||||
}
|
||||
};
|
||||
|
||||
// POST: Update travel information
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const { page, hero, content, enableScrollspy } = req.body;
|
||||
const updateData = {};
|
||||
|
||||
if (page) updateData.page = JSON.parse(page);
|
||||
if (hero) updateData.hero = JSON.parse(hero);
|
||||
|
||||
if (content) {
|
||||
let contentObj = JSON.parse(content);
|
||||
// Áp dụng bộ lọc dọn dẹp nội dung
|
||||
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
|
||||
updateData.content = contentObj;
|
||||
}
|
||||
|
||||
if (enableScrollspy !== undefined) {
|
||||
updateData.enableScrollspy = (enableScrollspy === "true" || enableScrollspy === true);
|
||||
}
|
||||
|
||||
await Travel.findOneAndUpdate({}, updateData, {
|
||||
upsert: true,
|
||||
new: true,
|
||||
});
|
||||
|
||||
req.flash("success", "Travel information updated and sanitized successfully");
|
||||
res.redirect("/admin/travel");
|
||||
} catch (error) {
|
||||
console.error("Error updating travel:", error);
|
||||
req.flash("error", "Error updating travel information");
|
||||
res.redirect("/admin/travel");
|
||||
}
|
||||
};
|
||||
|
||||
// GET: Travel data API (Sử dụng cho Frontend/Public)
|
||||
exports.api = exports.getTravelData = async (req, res) => {
|
||||
try {
|
||||
const travel = await Travel.findOne();
|
||||
if (!travel) {
|
||||
return res.status(404).json({ error: "Travel data not found" });
|
||||
}
|
||||
|
||||
const travelObj = travel.toObject();
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processed = addBaseUrlToImages(travelObj, baseUrl);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
hero: processed.hero,
|
||||
page: processed.page,
|
||||
content: processed.content,
|
||||
enableScrollspy: processed.enableScrollspy
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching travel data:", error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: Preview travel
|
||||
exports.preview = async (req, res) => {
|
||||
try {
|
||||
const { content, pageTitle, heroTitle, heroBackgroundImage, pageYear } = req.body;
|
||||
|
||||
// Preview cũng cần được sanitize để hiển thị đúng thực tế khi lưu
|
||||
let contentObj = JSON.parse(content);
|
||||
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
|
||||
|
||||
const previewData = {
|
||||
page: {
|
||||
title: pageTitle || "Travel Information",
|
||||
year: pageYear || ""
|
||||
},
|
||||
hero: {
|
||||
title: heroTitle || "Travel Information",
|
||||
backgroundImage: heroBackgroundImage || "",
|
||||
},
|
||||
content: contentObj,
|
||||
enableScrollspy: false,
|
||||
};
|
||||
|
||||
res.render("page/travel", {
|
||||
title: "Travel Preview",
|
||||
data: previewData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating preview:", error);
|
||||
res.status(500).send("Error generating preview");
|
||||
}
|
||||
};
|
||||
|
||||
// GET: Seed/Import from JSON
|
||||
exports.seed = async (req, res) => {
|
||||
try {
|
||||
const jsonPath = path.join(__dirname, "../data/travel.json");
|
||||
const jsonData = await fs.readFile(jsonPath, "utf-8");
|
||||
const jsonTravelData = JSON.parse(jsonData);
|
||||
|
||||
let contentBlocks = [];
|
||||
|
||||
// Trường hợp JSON đã có định dạng bài viết (blog format)
|
||||
if (Array.isArray(jsonTravelData.posts) && jsonTravelData.posts.length > 0) {
|
||||
const firstPost = jsonTravelData.posts[0];
|
||||
contentBlocks = (firstPost.content && firstPost.content.blocks) ? firstPost.content.blocks : [];
|
||||
}
|
||||
// Trường hợp format cũ (legacy)
|
||||
else {
|
||||
// ... (Logic chuyển đổi legacy format giữ nguyên nhưng bọc qua sanitize)
|
||||
// Ví dụ: push các header, paragraph từ locations vào contentBlocks
|
||||
}
|
||||
|
||||
// Luôn làm sạch dữ liệu trước khi seed vào DB
|
||||
const cleanedBlocks = sanitizeContentBlocks(contentBlocks);
|
||||
|
||||
const travelData = {
|
||||
page: {
|
||||
title: jsonTravelData.page?.title || "Go and Grow Camp Travel Information",
|
||||
year: jsonTravelData.page?.year || "",
|
||||
metadata: {
|
||||
title: "Travel Guide - Go and Grow Camp",
|
||||
description: "Everything you need to know about traveling to our camps",
|
||||
},
|
||||
},
|
||||
hero: {
|
||||
title: jsonTravelData.hero?.title || "Travel Information",
|
||||
backgroundImage: jsonTravelData.hero?.backgroundImage || "",
|
||||
},
|
||||
content: { blocks: cleanedBlocks },
|
||||
enableScrollspy: true,
|
||||
};
|
||||
|
||||
await Travel.findOneAndUpdate({}, travelData, { upsert: true, new: true });
|
||||
|
||||
req.flash("success", "Travel data seeded and sanitized successfully");
|
||||
res.redirect("/admin/travel");
|
||||
} catch (error) {
|
||||
console.error("Error seeding travel data:", error);
|
||||
req.flash("error", "Failed to seed travel data");
|
||||
res.redirect("/admin/travel");
|
||||
}
|
||||
};
|
||||
228
controllers/uploadController.js
Normal file
228
controllers/uploadController.js
Normal file
@@ -0,0 +1,228 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const jsonHelper = require('../utils/jsonHelper');
|
||||
|
||||
// Controller xử lý upload ảnh
|
||||
const uploadController = {
|
||||
// Upload ảnh và trả về đường dẫn
|
||||
uploadImage: async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, error: 'No file was uploaded' });
|
||||
}
|
||||
|
||||
// Lấy loại ảnh từ query params
|
||||
const imageType = req.query.imageType || 'general';
|
||||
|
||||
// Tạo đường dẫn tương đối để lưu vào database
|
||||
const relativePath = `/uploads/${imageType}/${req.file.filename}`;
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const fullUrl = `${baseUrl}${relativePath}`;
|
||||
|
||||
// Kiểm tra nếu file đã tồn tại từ trước
|
||||
const fileAlreadyExists = req.fileAlreadyExists || false;
|
||||
|
||||
// Nếu client yêu cầu cập nhật trực tiếp file JSON (ví dụ activities.json),
|
||||
// thì đồng bộ camps.image và camps.camp-detail.hero.bgImage
|
||||
try {
|
||||
const jsonFile = req.body && req.body.jsonFile;
|
||||
const campLink = req.body && req.body.campLink;
|
||||
|
||||
if (jsonFile && campLink) {
|
||||
// Đọc JSON và cập nhật camp có link khớp
|
||||
const jsonFilePath = require('path').join(__dirname, '../data', jsonFile);
|
||||
const jsonData = jsonHelper.readJsonFile(jsonFilePath);
|
||||
|
||||
if (jsonData && Array.isArray(jsonData.camps)) {
|
||||
// campLink có thể được gửi không có dấu / đầu, chuẩn hóa
|
||||
const normalizedLink = campLink.startsWith('/') ? campLink : `/${campLink}`;
|
||||
|
||||
const camp = jsonData.camps.find(c => (c.link || '').toString() === normalizedLink || (c.link || '').toString() === campLink);
|
||||
if (camp) {
|
||||
// Đồng bộ camps.image và camps.camp-detail.hero.bgImage - 2 trường này luôn giống nhau
|
||||
camp.image = relativePath;
|
||||
|
||||
// Đảm bảo camp-detail.hero tồn tại và sync bgImage
|
||||
if (!camp['camp-detail']) camp['camp-detail'] = {};
|
||||
if (!camp['camp-detail'].hero) camp['camp-detail'].hero = {};
|
||||
camp['camp-detail'].hero.bgImage = relativePath;
|
||||
|
||||
// Lưu thay đổi
|
||||
jsonHelper.writeJsonFile(jsonFilePath, jsonData);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to update JSON file after upload:', e && e.message ? e.message : e);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
path: relativePath,
|
||||
url: fullUrl,
|
||||
reused: fileAlreadyExists,
|
||||
message: fileAlreadyExists ? 'Using existing file' : 'File uploaded successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
return res.status(500).json({ success: false, error: 'Server error while uploading image' });
|
||||
}
|
||||
},
|
||||
|
||||
// Cập nhật đường dẫn ảnh trong file JSON
|
||||
updateImagePath: async (req, res) => {
|
||||
try {
|
||||
const { jsonFile, jsonPath, newImagePath } = req.body;
|
||||
|
||||
if (!jsonFile || !jsonPath || !newImagePath) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing required information (jsonFile, jsonPath, newImagePath)'
|
||||
});
|
||||
}
|
||||
|
||||
// Đọc file JSON
|
||||
const jsonFilePath = path.join(__dirname, '../data', jsonFile);
|
||||
const jsonData = jsonHelper.readJsonFile(jsonFilePath);
|
||||
|
||||
// Cập nhật đường dẫn ảnh theo jsonPath
|
||||
// jsonPath có định dạng như "banner.image" hoặc "partners[0].logo"
|
||||
const pathParts = jsonPath.split('.');
|
||||
let current = jsonData;
|
||||
|
||||
// Duyệt qua các phần của path trừ phần cuối
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
const part = pathParts[i];
|
||||
|
||||
// Kiểm tra nếu là mảng (ví dụ: partners[0])
|
||||
if (part.includes('[') && part.includes(']')) {
|
||||
const arrName = part.substring(0, part.indexOf('['));
|
||||
const index = parseInt(part.substring(part.indexOf('[') + 1, part.indexOf(']')));
|
||||
|
||||
if (!current[arrName] || !Array.isArray(current[arrName])) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Array ${arrName} not found in data`
|
||||
});
|
||||
}
|
||||
|
||||
current = current[arrName][index];
|
||||
} else {
|
||||
if (!current[part]) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Property ${part} not found in data`
|
||||
});
|
||||
}
|
||||
|
||||
current = current[part];
|
||||
}
|
||||
}
|
||||
|
||||
// Cập nhật giá trị
|
||||
const lastPart = pathParts[pathParts.length - 1];
|
||||
current[lastPart] = newImagePath;
|
||||
|
||||
// Lưu lại file JSON
|
||||
jsonHelper.writeJsonFile(jsonFilePath, jsonData);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Image path updated successfully',
|
||||
data: { jsonPath, newImagePath }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating image path:', error);
|
||||
return res.status(500).json({ success: false, message: 'Server error while updating image path' });
|
||||
}
|
||||
},
|
||||
|
||||
// Xóa ảnh
|
||||
deleteImage: async (req, res) => {
|
||||
try {
|
||||
const { imagePath } = req.body;
|
||||
|
||||
if (!imagePath) {
|
||||
return res.status(400).json({ success: false, message: 'Missing image path to delete' });
|
||||
}
|
||||
|
||||
// Chuyển đổi đường dẫn tương đối thành đường dẫn tuyệt đối
|
||||
const fullPath = path.join(__dirname, '../public', imagePath);
|
||||
|
||||
// Kiểm tra xem file có tồn tại không
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return res.status(404).json({ success: false, message: 'Image file not found' });
|
||||
}
|
||||
|
||||
// Xóa file
|
||||
fs.unlinkSync(fullPath);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Image deleted successfully',
|
||||
data: { imagePath }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting image:', error);
|
||||
return res.status(500).json({ success: false, message: 'Server error while deleting image' });
|
||||
}
|
||||
},
|
||||
|
||||
// List images in a folder
|
||||
listImages: async (req, res) => {
|
||||
try {
|
||||
const imageType = req.query.imageType || 'general';
|
||||
const dirPath = path.join(__dirname, '../public/uploads', imageType);
|
||||
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return res.status(200).json({ success: true, images: [] });
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(dirPath).filter(f => !f.startsWith('.'));
|
||||
|
||||
const images = files.map(name => ({
|
||||
name,
|
||||
path: `/uploads/${imageType}/${name}`,
|
||||
url: (process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`) + `/uploads/${imageType}/${name}`
|
||||
}));
|
||||
|
||||
return res.status(200).json({ success: true, images });
|
||||
} catch (error) {
|
||||
console.error('Error listing images:', error);
|
||||
return res.status(500).json({ success: false, error: 'Server error while listing images' });
|
||||
}
|
||||
},
|
||||
|
||||
// Upload video
|
||||
uploadVideo: async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, error: 'No file was uploaded' });
|
||||
}
|
||||
|
||||
// Kiểm tra loại file
|
||||
const fileType = req.file.mimetype;
|
||||
if (!fileType.startsWith('video/')) {
|
||||
// Xóa file nếu không phải video
|
||||
fs.unlinkSync(req.file.path);
|
||||
return res.status(400).json({ success: false, error: 'Uploaded file is not a video' });
|
||||
}
|
||||
|
||||
// Tạo đường dẫn tương đối để lưu vào database
|
||||
const relativePath = `/uploads/videos/${req.file.filename}`;
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const fullUrl = `${baseUrl}${relativePath}`;
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
path: relativePath,
|
||||
url: fullUrl
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading video:', error);
|
||||
return res.status(500).json({ success: false, error: 'Server error while uploading video' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = uploadController;
|
||||
Reference in New Issue
Block a user