Merge pull request 'hoanganh-03022026-appointment-contact-pricing' (#32) from hoanganh-03022026-appointment-contact-pricing into main

Reviewed-on: UKSOURCE/cms.hailearning.edu.vn#32
This commit is contained in:
2026-02-07 03:39:27 +00:00
14 changed files with 1294 additions and 2199 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,5 @@
# dependencies # dependencies
node_modules/ node_modules/
/public
# environment # environment
.env .env

View File

@@ -1,345 +1,58 @@
// controllers/faqController.js const Home = require("../models/home");
const FAQ = require("../models/faq");
const { addBaseUrlToImages } = require("../utils/imageHelper");
// Helper function để lấy FAQ data // Helper to get FAQ data from Home model
const getFAQData = async () => { const getFaqData = async () => {
try { const home = await Home.findOne().sort({ updatedAt: -1 });
const faq = await FAQ.findOne({ name: "default" }); if (!home || !home.faq) {
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 { return {
name: "default", heading: "",
hero: { subheading: "",
title: "Go and Grow Camp", description: "",
backgroundImage: "yootheme/cache/18/faqs_header_new.jpg", items: [],
overlayColor: "rgba(0, 0, 0, 0)", ctaButton: { label: "", href: "" }
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"
}
}; };
}
return home.faq.toObject ? home.faq.toObject() : home.faq;
}; };
// API để lấy FAQ data // API to get FAQ data for frontend
exports.api = async (req, res) => { exports.api = async (req, res) => {
try { try {
const faqData = await getFAQData(); const faqData = await getFaqData();
return res.json(faqData);
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) { } catch (err) {
console.error("API Error:", err); console.error("API Error:", err);
res.status(500).json({ error: "Error loading FAQ data" }); res.status(500).json({ error: "Error loading FAQ data" });
} }
}; };
// API để lấy toàn bộ FAQ data // Method for legacy route compatibility or internal use
exports.getFAQData = async (req, res) => { exports.getFAQData = async (req, res) => {
try { return exports.api(req, res);
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 // Render admin view
exports.index = async (req, res) => { exports.index = async (req, res) => {
try { try {
// Lấy FAQ data hoặc sử dụng mặc định const data = await getFaqData();
const data = await getFAQData(); // Ensure default structure if data is partial
const faqData = data || getDefaultFAQData(); const safeData = {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; heading: data.heading || "",
subheading: data.subheading || "",
description: data.description || "",
ctaButton: data.ctaButton || { label: "", href: "" },
items: data.items || []
};
res.render("admin/faq/index", { const frontendUrl = process.env.FRONTEND_URL;
title: "FAQ Management",
res.render("admin/home/faq/index", {
title: "FAQ Section Management",
layout: "layouts/main", layout: "layouts/main",
data: faqData, data: safeData,
frontendUrl, frontendUrl,
currentPath: req.path, currentPath: req.path,
user: req.session.user, 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) { } catch (error) {
console.error("Error in FAQ index:", error); console.error("Error in FAQ index:", error);
@@ -348,373 +61,57 @@ exports.index = async (req, res) => {
} }
}; };
// Cập nhật toàn bộ FAQ data // Update FAQ data
exports.update = async (req, res) => { exports.update = async (req, res) => {
try { try {
const { const { heading, subheading, description, ctaLabel, ctaHref, items } = req.body;
hero,
sidebarNav,
contactBox,
faqSections,
video
} = req.body;
// Parse JSON strings nếu cần let parsedItems = [];
const parseJson = (data) => { if (items) {
if (!data) return null;
if (typeof data === "string") {
try { try {
return JSON.parse(data); parsedItems = typeof items === 'string' ? JSON.parse(items) : items;
} catch (e) { } catch (e) {
console.error("JSON parse error:", e); console.error("Error parsing items JSON:", e);
return null; parsedItems = [];
} }
} }
return data;
let home = await Home.findOne().sort({ updatedAt: -1 });
if (!home) {
home = new Home({});
}
home.faq = {
heading: heading || "",
subheading: subheading || "",
description: description || "",
ctaButton: {
label: ctaLabel || "",
href: ctaHref || ""
},
items: parsedItems.map(item => ({
question: item.question || "",
answer: item.answer || ""
}))
}; };
const heroData = parseJson(hero); await home.save();
const sidebarNavData = parseJson(sidebarNav);
const contactBoxData = parseJson(contactBox);
const faqSectionsData = parseJson(faqSections);
const videoData = parseJson(video);
// Tìm hoặc tạo FAQ req.flash("success_msg", "FAQ section updated successfully");
let faq = await FAQ.findOne({ name: "default" }); res.redirect("/admin/home/faq");
// 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) { } catch (err) {
console.error("Error updating FAQ:", err); console.error("Error updating FAQ:", err);
req.flash("error_msg", err.message || "Error updating FAQ"); req.flash("error_msg", err.message || "Error updating FAQ");
res.redirect("/admin/faq"); res.redirect("/admin/home/faq");
} }
}; };
// API: Reset về mặc định // Placeholder methods to prevent route crashes if routes are not cleaned up immediately
exports.reset = async (req, res) => { exports.addFAQ = (req, res) => res.status(404).json({ error: "Endpoint deprecated" });
try { exports.updateFAQItem = (req, res) => res.status(404).json({ error: "Endpoint deprecated" });
let faq = await FAQ.findOne({ name: "default" }); exports.deleteFAQItem = (req, res) => res.status(404).json({ error: "Endpoint deprecated" });
exports.addFAQSection = (req, res) => res.status(404).json({ error: "Endpoint deprecated" });
if (!faq) { exports.updateFAQSection = (req, res) => res.status(404).json({ error: "Endpoint deprecated" });
faq = new FAQ(getDefaultFAQData()); exports.deleteFAQSection = (req, res) => res.status(404).json({ error: "Endpoint deprecated" });
} else { exports.reorderFAQSection = (req, res) => res.status(404).json({ error: "Endpoint deprecated" });
// Cập nhật tất cả các trường về mặc định exports.updateSidebarNav = (req, res) => res.status(404).json({ error: "Endpoint deprecated" });
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"
});
}
};

View File

@@ -0,0 +1,103 @@
const { addBaseUrlToImages } = require("../utils/imageHelper");
const Home = require("../models/home");
// Get testimonial data from Home model
const getTestimonialData = async () => {
const home = await Home.findOne().sort({ updatedAt: -1 });
if (!home || !home.testimonials) {
return null;
}
return home.testimonials.toObject ? home.testimonials.toObject() : home.testimonials;
};
// API to get testimonial data
exports.api = async (req, res) => {
try {
const testimonial = await getTestimonialData();
if (!testimonial) {
return res.status(404).json({ error: "Testimonial data not found" });
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(testimonial, baseUrl);
res.json(processedData);
} catch (err) {
console.error("API Error:", err);
res.status(500).json({ error: "Error loading testimonial data" });
}
};
// Render admin view
exports.index = async (req, res) => {
try {
const data = (await getTestimonialData()) || {
heading: "Student Reviews & Testimonials",
subheading: "What Our Students Say",
videoUrl: "",
videoThumbnail: "",
items: [],
};
const frontendUrl = process.env.FRONTEND_URL;
res.render("admin/home/testimonial/index", {
title: "Testimonials Management",
layout: "layouts/main",
data,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
} catch (error) {
console.error("Error in testimonial index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
};
// Cập nhật dữ liệu testimonial (chỉ update phần testimonials của Home)
exports.update = async (req, res) => {
try {
const { heading, subheading, videoUrl, videoThumbnail, items } = 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 itemsData = parseJson(items);
// Tìm hoặc tạo Home document
let home = await Home.findOne().sort({ updatedAt: -1 });
if (!home) {
home = new Home({});
}
// Cập nhật chỉ phần testimonials
home.testimonials = {
heading: heading || "Student Reviews & Testimonials",
subheading: subheading || "What Our Students Say",
videoUrl: videoUrl || "",
videoThumbnail: videoThumbnail || "",
items: itemsData || [],
};
await home.save();
req.flash("success_msg", "Testimonials updated successfully");
res.redirect("/admin/home/testimonials");
} catch (err) {
console.error("Error updating testimonials:", err);
req.flash("error_msg", err.message || "Error updating testimonials");
res.redirect("/admin/home/testimonials");
}
};

View File

@@ -0,0 +1,84 @@
const { addBaseUrlToImages } = require("../utils/imageHelper");
const Home = require("../models/home");
// Get videoGallery data from Home model
const getVideoGalleryData = async () => {
const home = await Home.findOne().sort({ updatedAt: -1 });
if (!home || !home.videoGallery) {
return null;
}
return home.videoGallery.toObject ? home.videoGallery.toObject() : home.videoGallery;
};
// API to get videoGallery data
exports.api = async (req, res) => {
try {
const videoGallery = await getVideoGalleryData();
if (!videoGallery) {
return res.status(404).json({ error: "Video Gallery data not found" });
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(videoGallery, baseUrl);
res.json(processedData);
} catch (err) {
console.error("API Error:", err);
res.status(500).json({ error: "Error loading video gallery data" });
}
};
// Render admin view
exports.index = async (req, res) => {
try {
const data = (await getVideoGalleryData()) || {
heading: "",
videoUrl: "",
thumbnail: "",
};
const frontendUrl = process.env.FRONTEND_URL;
res.render("admin/home/videoGallery/index", {
title: "Video Gallery Management",
layout: "layouts/main",
data,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
} catch (error) {
console.error("Error in videoGallery index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
};
// Cập nhật dữ liệu videoGallery
exports.update = async (req, res) => {
try {
const { heading, videoUrl, thumbnail } = req.body;
// Tìm hoặc tạo Home document
let home = await Home.findOne().sort({ updatedAt: -1 });
if (!home) {
home = new Home({});
}
// Cập nhật chỉ phần videoGallery
home.videoGallery = {
heading: heading || "",
videoUrl: videoUrl || "",
thumbnail: thumbnail || "",
};
await home.save();
req.flash("success_msg", "Video Gallery updated successfully");
res.redirect("/admin/home/video-gallery");
} catch (err) {
console.error("Error updating video gallery:", err);
req.flash("error_msg", err.message || "Error updating video gallery");
res.redirect("/admin/home/video-gallery");
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -29,6 +29,9 @@ const blogController = require("../controllers/blogController");
const blogCategoryController = require("../controllers/blogCategoryController"); const blogCategoryController = require("../controllers/blogCategoryController");
const blogTagController = require("../controllers/blogTagController"); const blogTagController = require("../controllers/blogTagController");
const socialLinkController = require("../controllers/socialLinkController"); const socialLinkController = require("../controllers/socialLinkController");
const testimonialController = require("../controllers/testimonialController");
const videoGalleryController = require("../controllers/videoGalleryController");
// Dashboard // Dashboard
router.get("/dashboard", ensureAuthenticated, dashboardController.getDashboard); router.get("/dashboard", ensureAuthenticated, dashboardController.getDashboard);
@@ -150,11 +153,13 @@ router.delete("/bookings/:bookingId", ensureAuthenticated, bookingSubmissionCont
// Preview activity // Preview activity
router.get("/activity/:id/preview", ensureAuthenticated, activityController.preview); router.get("/activity/:id/preview", ensureAuthenticated, activityController.preview);
// FAQ routes - Thêm vào đây // FAQ routes
router.get("/faq", ensureAuthenticated, faqController.index); router.get("/home/faq", ensureAuthenticated, faqController.index);
router.post("/faq/update", ensureAuthenticated, faqController.update); router.post("/home/faq/update", ensureAuthenticated, faqController.update);
router.get("/faq/data", ensureAuthenticated, faqController.getFAQData); router.get("/home/faq/data", ensureAuthenticated, faqController.getFAQData);
router.get("/faq/api", faqController.api); router.get("/home/faq/api", faqController.api);
// Deprecated FAQ API routes removed
// API routes cho quản lý FAQ items (AJAX calls) // API routes cho quản lý FAQ items (AJAX calls)
router.post("/faq/api/add-faq", ensureAuthenticated, faqController.addFAQ); router.post("/faq/api/add-faq", ensureAuthenticated, faqController.addFAQ);
@@ -174,6 +179,8 @@ router.get("/travel/data", ensureAuthenticated, travelController.getTravelData);
router.get("/travel/api", travelController.api); router.get("/travel/api", travelController.api);
router.get("/travel/seed", ensureAuthenticated, travelController.seed); router.get("/travel/seed", ensureAuthenticated, travelController.seed);
// Deprecated FAQ API routes removed
// API routes cho quản lý FAQ sections (AJAX calls) // API routes cho quản lý FAQ sections (AJAX calls)
router.post("/faq/api/add-section", ensureAuthenticated, faqController.addFAQSection); router.post("/faq/api/add-section", ensureAuthenticated, faqController.addFAQSection);
router.put("/faq/api/update-section/:sectionId", ensureAuthenticated, faqController.updateFAQSection); router.put("/faq/api/update-section/:sectionId", ensureAuthenticated, faqController.updateFAQSection);
@@ -308,4 +315,14 @@ router.post("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.update
router.post("/blog/tags/:id/delete", ensureAuthenticated, blogTagController.destroy); router.post("/blog/tags/:id/delete", ensureAuthenticated, blogTagController.destroy);
router.post("/blog/tags/quick-create", ensureAuthenticated, blogTagController.quickCreate); router.post("/blog/tags/quick-create", ensureAuthenticated, blogTagController.quickCreate);
// Testimonials management
router.get("/home/testimonials", ensureAuthenticated, testimonialController.index);
router.post("/home/testimonials/update", ensureAuthenticated, testimonialController.update);
// Video Gallery management
router.get("/home/video-gallery", ensureAuthenticated, videoGalleryController.index);
router.post("/home/video-gallery/update", ensureAuthenticated, videoGalleryController.update);
module.exports = router; module.exports = router;

View File

@@ -175,6 +175,13 @@ router.get("/api/service-slugs", serviceController.getServiceSlugs);
router.get("/api/visa", visaController.api); router.get("/api/visa", visaController.api);
router.get("/api/visa/country", visaController.apiCountries); router.get("/api/visa/country", visaController.apiCountries);
// Testimonials API
const testimonialController = require("../controllers/testimonialController");
router.get("/api/testimonials", testimonialController.api);
// Video Gallery API
const videoGalleryController = require("../controllers/videoGalleryController");
router.get("/api/video-gallery", videoGalleryController.api);
// Test route for footer // Test route for footer
router.get("/test-footer", (req, res) => { router.get("/test-footer", (req, res) => {
res.render("test-footer", { res.render("test-footer", {
@@ -184,3 +191,5 @@ router.get("/test-footer", (req, res) => {
}); });
module.exports = router; module.exports = router;

View File

@@ -103,24 +103,7 @@
</div> </div>
</div> </div>
<!-- FAQ -->
<div class="col-md-4 border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
<div class="rounded-circle d-flex align-items-center justify-content-center me-3"
style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-question-circle fa-lg" style="color: var(--primary-color);"></i>
</div>
<div>
<h5 class="mb-0">FAQ</h5>
<p class="text-muted mb-0 small">Manage FAQ</p>
</div>
</div>
<a href="/admin/faq" class="btn btn-sm btn-primary w-100 mt-2">
<i class="fas fa-edit me-2"></i>Edit
</a>
</div>
</div>
<!-- Appointment --> <!-- Appointment -->
<div class="col-md-4 border-end border-top"> <div class="col-md-4 border-end border-top">
@@ -159,83 +142,6 @@
</a> </a>
</div> </div>
</div> </div>
<!-- Terms & Conditions -->
<div class="col-md-4 border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
<div class="rounded-circle d-flex align-items-center justify-content-center me-3"
style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-file-contract fa-lg" style="color: var(--primary-color);"></i>
</div>
<div>
<h5 class="mb-0">Terms & Conditions</h5>
<p class="text-muted mb-0 small">Manage terms</p>
</div>
</div>
<a href="/admin/terms-conditions" class="btn btn-sm btn-primary w-100 mt-2">
<i class="fas fa-edit me-2"></i>Edit
</a>
</div>
</div>
<!-- Travel -->
<div class="col-md-4 border-end border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
<div class="rounded-circle d-flex align-items-center justify-content-center me-3"
style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-plane fa-lg" style="color: var(--primary-color);"></i>
</div>
<div>
<h5 class="mb-0">Travel</h5>
<p class="text-muted mb-0 small">Manage travel</p>
</div>
</div>
<a href="/admin/travel" class="btn btn-sm btn-primary w-100 mt-2">
<i class="fas fa-edit me-2"></i>Edit
</a>
</div>
</div>
<!-- Safety -->
<div class="col-md-4 border-end border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
<div class="rounded-circle d-flex align-items-center justify-content-center me-3"
style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-shield-alt fa-lg" style="color: var(--primary-color);"></i>
</div>
<div>
<h5 class="mb-0">Safety</h5>
<p class="text-muted mb-0 small">Manage safety</p>
</div>
</div>
<a href="/admin/safety" class="btn btn-sm btn-primary w-100 mt-2">
<i class="fas fa-edit me-2"></i>Edit
</a>
</div>
</div>
<!-- Activities -->
<div class="col-md-4 border-end border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
<div class="rounded-circle d-flex align-items-center justify-content-center me-3"
style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-running fa-lg" style="color: var(--primary-color);"></i>
</div>
<div>
<h5 class="mb-0">Activities</h5>
<p class="text-muted mb-0 small">Manage activities</p>
</div>
</div>
<a href="/admin/activity" class="btn btn-sm btn-primary w-100 mt-2">
<i class="fas fa-edit me-2"></i>Edit
</a>
</div>
</div>
<!-- Services --> <!-- Services -->
<div class="col-md-4 border-end border-top"> <div class="col-md-4 border-end border-top">
<div class="p-4"> <div class="p-4">
@@ -300,7 +206,7 @@
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">API Endpoints</h5> <h5 class="mb-0">API Endpoints</h5>
<span class="badge bg-primary">12 APIs</span> <span class="badge bg-primary">6 APIs</span>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
@@ -327,19 +233,11 @@
</td> </td>
<td><code>/api/header</code></td> <td><code>/api/header</code></td>
<td> <td>
<span <span class="badge" style="background-color: var(--primary-color)">GET</span>
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td> </td>
<td>API to get menu header data</td> <td>API to get menu header data</td>
<td> <td>
<a <a href="/api/header" class="btn btn-sm btn-outline-primary" target="_blank">
href="/api/header"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
@@ -356,19 +254,11 @@
</td> </td>
<td><code>/api/home</code></td> <td><code>/api/home</code></td>
<td> <td>
<span <span class="badge" style="background-color: var(--primary-color)">GET</span>
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td> </td>
<td>API to get homepage data</td> <td>API to get homepage data</td>
<td> <td>
<a <a href="/api/home" class="btn btn-sm btn-outline-primary" target="_blank">
href="/api/home"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
@@ -385,19 +275,11 @@
</td> </td>
<td><code>/api/about</code></td> <td><code>/api/about</code></td>
<td> <td>
<span <span class="badge" style="background-color: var(--primary-color)">GET</span>
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td> </td>
<td>API to get about page data</td> <td>API to get about page data</td>
<td> <td>
<a <a href="/api/about" class="btn btn-sm btn-outline-primary" target="_blank">
href="/api/about"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
@@ -414,198 +296,18 @@
</td> </td>
<td><code>/api/about-us</code></td> <td><code>/api/about-us</code></td>
<td> <td>
<span <span class="badge" style="background-color: var(--primary-color)">GET</span>
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td> </td>
<td>API to get about us data</td> <td>API to get about us data</td>
<td> <td>
<a <a href="/api/about-us" class="btn btn-sm btn-outline-primary" target="_blank">
href="/api/about-us"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View
</a>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-question-circle" style="color: var(--primary-color);"></i>
</div>
<span>FAQ API</span>
</div>
</td>
<td><code>/api/faq</code></td>
<td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get FAQ data</td>
<td>
<a
href="/api/faq"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View
</a>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-file-contract" style="color: var(--primary-color);"></i>
</div>
<span>Terms & Conditions API</span>
</div>
</td>
<td><code>/api/terms</code></td>
<td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get terms & conditions data</td>
<td>
<a
href="/api/terms"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View
</a>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-plane" style="color: var(--primary-color);"></i>
</div>
<span>Travel API</span>
</div>
</td>
<td><code>/api/travel</code></td>
<td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get travel data</td>
<td>
<a
href="/api/travel"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View
</a>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-shield-alt" style="color: var(--primary-color);"></i>
</div>
<span>Safety API</span>
</div>
</td>
<td><code>/api/safety</code></td>
<td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get safety data</td>
<td>
<a
href="/api/safety"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View
</a>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-campground" style="color: var(--primary-color);"></i>
</div>
<span>Camp Location API</span>
</div>
</td>
<td><code>/api/camp-location</code></td>
<td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get camp location data</td>
<td>
<a
href="/api/camp-location"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
</tr> </tr>
<tr>
<td>
<div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-sitemap" style="color: var(--primary-color);"></i>
</div>
<span>Menu Tree API</span>
</div>
</td>
<td><code>/api/menu-tree</code></td>
<td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get menu tree data</td>
<td>
<a
href="/api/menu-tree"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View
</a>
</td>
</tr>
<tr> <tr>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
@@ -618,19 +320,11 @@
</td> </td>
<td><code>/api/contact</code></td> <td><code>/api/contact</code></td>
<td> <td>
<span <span class="badge" style="background-color: var(--primary-color)">GET</span>
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td> </td>
<td>API to get contact data</td> <td>API to get contact data</td>
<td> <td>
<a <a href="/api/contact" class="btn btn-sm btn-outline-primary" target="_blank">
href="/api/contact"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
@@ -647,19 +341,11 @@
</td> </td>
<td><code>/api/blog</code></td> <td><code>/api/blog</code></td>
<td> <td>
<span <span class="badge" style="background-color: var(--primary-color)">GET</span>
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td> </td>
<td>API to get blog posts</td> <td>API to get blog posts</td>
<td> <td>
<a <a href="/api/blog" class="btn btn-sm btn-outline-primary" target="_blank">
href="/api/blog"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
@@ -672,13 +358,10 @@
<!-- System Info --> <!-- System Info -->
<div class="card"> <div class="card">
<div <div class="card-header" style="
class="card-header"
style="
background: linear-gradient(135deg, #0048b4, #0028b4); background: linear-gradient(135deg, #0048b4, #0028b4);
color: white; color: white;
" ">
>
<h5 class="mb-0">System Information</h5> <h5 class="mb-0">System Information</h5>
</div> </div>
<div class="card-body" style="background-color: #f8faf8"> <div class="card-body" style="background-color: #f8faf8">
@@ -719,10 +402,7 @@
style="background-color: rgba(184, 183, 106, 0.05); border-left: 4px solid var(--primary-color);" role="alert"> style="background-color: rgba(184, 183, 106, 0.05); border-left: 4px solid var(--primary-color);" role="alert">
<div class="d-flex"> <div class="d-flex">
<div class="me-3"> <div class="me-3">
<i <i class="fas fa-lightbulb fa-lg" style="color: var(--primary-color)"></i>
class="fas fa-lightbulb fa-lg"
style="color: var(--primary-color)"
></i>
</div> </div>
<div> <div>
<h6 class="mb-1" style="color: var(--primary-color)">Quick Tip</h6> <h6 class="mb-1" style="color: var(--primary-color)">Quick Tip</h6>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,216 @@
<div class="container">
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
<div>
<h1 class="h3 mb-0" style="color: var(--primary-dark);">
<%= title %>
</h1>
<p class="text-muted mb-0">Manage FAQ section content</p>
</div>
<div>
<a href="<%= frontendUrl %>/" class="btn btn-outline-primary" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>View Homepage
</a>
</div>
</div>
<div class="row">
<div class="col-12">
<form method="POST" class="content-with-fixed-buttons" id="faqForm" action="/admin/faq/update">
<!-- Hidden input for items JSON data -->
<input type="hidden" name="items" id="itemsJson">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom">
<h5 class="mb-0"><i class="fas fa-question-circle me-2"></i>FAQ Settings</h5>
</div>
<div class="card-body">
<!-- Header Section -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label fw-medium">Heading</label>
<input type="text" class="form-control" name="heading" id="heading"
value="<%= data.heading || '' %>">
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Subheading</label>
<input type="text" class="form-control" name="subheading" id="subheading"
value="<%= data.subheading || '' %>">
</div>
<div class="col-md-12">
<label class="form-label fw-medium">Description</label>
<textarea class="form-control" name="description" id="description"
rows="2"><%= data.description || '' %></textarea>
</div>
</div>
<!-- CTA Button Section -->
<h6 class="fw-medium mb-3">CTA Button</h6>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label fw-medium">Button Label</label>
<input type="text" class="form-control" name="ctaLabel"
value="<%= data.ctaButton && data.ctaButton.label ? data.ctaButton.label : '' %>">
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Button Link</label>
<input type="text" class="form-control" name="ctaHref"
value="<%= data.ctaButton && data.ctaButton.href ? data.ctaButton.href : '' %>">
</div>
</div>
<hr class="my-4">
<!-- FAQ Items -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="fw-medium mb-0">Frequently Asked Questions</h6>
<button type="button" class="btn btn-primary btn-sm" onclick="addFaqItem()">
<i class="fas fa-plus"></i> Add Question
</button>
</div>
<div id="faqItemsContainer">
<% if (data.items && data.items.length> 0) { %>
<% data.items.forEach((item, index)=> { %>
<div class="card mb-3 faq-item">
<div class="card-body">
<div class="row g-3">
<div class="col-md-12">
<label class="form-label">Question</label>
<input type="text" class="form-control"
name="itemQuestion_<%= index %>"
value="<%= item.question || '' %>">
</div>
<div class="col-md-12">
<label class="form-label">Answer</label>
<textarea class="form-control" name="itemAnswer_<%= index %>"
rows="3"><%= item.answer || '' %></textarea>
</div>
</div>
<button type="button" class="btn btn-outline-danger btn-sm mt-3"
onclick="removeFaqItem(this)">
<i class="fas fa-trash me-2"></i>Remove
</button>
</div>
</div>
<% }); %>
<% } %>
</div>
</div>
</div>
<!-- Fixed Bottom Buttons -->
<div class="fixed-bottom-buttons">
<button type="reset" class="btn btn-secondary" onclick="resetForm()">
<i class="fas fa-undo me-2"></i>Reset
</button>
<button type="submit" class="btn btn-primary" id="submitBtn">
<i class="fas fa-save me-2"></i>Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
<script type="application/json" id="faqDataJson"><%- JSON.stringify(data) %></script>
<script>
let originalFormData = null;
document.addEventListener('DOMContentLoaded', function () {
try {
var jsonScript = document.getElementById('faqDataJson');
originalFormData = JSON.parse(jsonScript.textContent);
} catch (e) {
console.error('Error parsing originalFormData:', e);
originalFormData = { items: [] };
}
initializeFormHandlers();
});
function initializeFormHandlers() {
const form = document.getElementById('faqForm');
form.addEventListener('submit', async function (e) {
e.preventDefault();
const submitBtn = document.getElementById('submitBtn');
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving...';
try {
updateItemsJson();
this.submit();
} catch (error) {
console.error('Error updating data:', error);
alert('Failed to process form data. Please try again.');
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
}
});
}
function updateItemsJson() {
const items = [];
document.querySelectorAll('.faq-item').forEach((item, index) => {
const question = item.querySelector(`[name="itemQuestion_${index}"]`)?.value ||
item.querySelector('[name^="itemQuestion_"]')?.value || '';
const answer = item.querySelector(`[name="itemAnswer_${index}"]`)?.value ||
item.querySelector('[name^="itemAnswer_"]')?.value || '';
items.push({ question, answer });
});
document.getElementById('itemsJson').value = JSON.stringify(items);
}
function addFaqItem() {
const container = document.getElementById('faqItemsContainer');
const index = container.querySelectorAll('.faq-item').length;
const html = `
<div class="card mb-3 faq-item">
<div class="card-body">
<div class="row g-3">
<div class="col-md-12">
<label class="form-label">Question</label>
<input type="text" class="form-control" name="itemQuestion_${index}" value="">
</div>
<div class="col-md-12">
<label class="form-label">Answer</label>
<textarea class="form-control" name="itemAnswer_${index}" rows="3"></textarea>
</div>
</div>
<button type="button" class="btn btn-outline-danger btn-sm mt-3"
onclick="removeFaqItem(this)">
<i class="fas fa-trash me-2"></i>Remove
</button>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
}
function removeFaqItem(button) {
if (confirm('Are you sure you want to remove this question?')) {
button.closest('.faq-item').remove();
reindexItems();
}
}
function reindexItems() {
document.querySelectorAll('.faq-item').forEach((item, index) => {
item.querySelectorAll('[name^="item"]').forEach(input => {
const baseName = input.name.replace(/_\d+$/, '');
input.name = `${baseName}_${index}`;
});
});
}
function resetForm() {
if (confirm('Are you sure you want to reset all changes?')) {
location.reload();
}
}
</script>

View File

@@ -0,0 +1,438 @@
<div class="container">
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
<div>
<h1 class="h3 mb-0" style="color: var(--primary-dark);">
<%= title %>
</h1>
<p class="text-muted mb-0">Manage testimonials section</p>
</div>
<div>
<a href="<%= frontendUrl %>/" class="btn btn-outline-primary" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>View Homepage
</a>
</div>
</div>
<div class="row">
<div class="col-12">
<form method="POST" class="content-with-fixed-buttons" id="testimonialForm"
action=" /update">
<!-- Hidden input for items JSON data -->
<input type="hidden" name="items" id="itemsJson">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom">
<h5 class="mb-0"><i class="fas fa-quote-left me-2"></i>Testimonials Settings</h5>
</div>
<div class="card-body">
<!-- Header Section -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label fw-medium">Heading</label>
<input type="text" class="form-control" name="heading" id="heading"
value="<%= data.heading || '' %>">
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Subheading</label>
<input type="text" class="form-control" name="subheading" id="subheading"
value="<%= data.subheading || '' %>">
</div>
</div>
<!-- Video Section -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label fw-medium">Video URL</label>
<input type="text" class="form-control" name="videoUrl" id="videoUrl"
value="<%= data.videoUrl || '' %>"
placeholder="https://www.youtube.com/watch?v=...">
<small class="text-muted">YouTube video URL</small>
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Video Thumbnail</label>
<div class="input-group">
<input type="text" class="form-control" name="videoThumbnail" id="videoThumbnail"
value="<%= data.videoThumbnail || '' %>">
<button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="videoThumbnail" data-image-type="testimonial">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
<% if (data.videoThumbnail) { %>
<img src="<%= data.videoThumbnail %>" class="img-thumbnail mt-2"
style="max-height: 100px;" alt="Thumbnail preview">
<% } %>
</div>
</div>
<hr class="my-4">
<!-- Testimonial Items -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="fw-medium mb-0">Testimonial Items</h6>
<button type="button" class="btn btn-primary btn-sm" onclick="addTestimonialItem()">
<i class="fas fa-plus"></i> Add Testimonial
</button>
</div>
<div id="testimonialItemsContainer">
<% if (data.items && data.items.length> 0) { %>
<% data.items.forEach((item, index)=> { %>
<div class="card mb-3 testimonial-item">
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="itemName_<%= index %>"
value="<%= item.name || '' %>">
</div>
<div class="col-md-3">
<label class="form-label">Role</label>
<input type="text" class="form-control" name="itemRole_<%= index %>"
value="<%= item.role || '' %>">
</div>
<div class="col-md-3">
<label class="form-label">Country</label>
<input type="text" class="form-control"
name="itemCountry_<%= index %>"
value="<%= item.country || '' %>">
</div>
<div class="col-md-3">
<label class="form-label">Rating (1-5)</label>
<select class="form-select" name="itemRating_<%= index %>">
<% for (let i=1; i <=5; i++) { %>
<option value="<%= i %>" <%=item.rating===i ? 'selected'
: '' %>>
<%= i %> Star<%= i> 1 ? 's' : '' %>
</option>
<% } %>
</select>
</div>
<div class="col-md-8">
<label class="form-label">Comment</label>
<textarea class="form-control" name="itemComment_<%= index %>"
rows="3"><%= item.comment || '' %></textarea>
</div>
<div class="col-md-4">
<label class="form-label">Avatar</label>
<div class="input-group">
<input type="text" class="form-control"
name="itemAvatar_<%= index %>"
value="<%= item.avatar || '' %>">
<button type="button"
class="btn btn-outline-primary btn-upload-image"
data-target-input="itemAvatar_<%= index %>"
data-image-type="testimonial">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
<% if (item.avatar) { %>
<img src="<%= item.avatar %>"
class="img-thumbnail mt-2 avatar-preview"
style="max-height: 60px; max-width: 60px; border-radius: 50%;"
alt="Avatar">
<% } %>
</div>
</div>
<button type="button" class="btn btn-outline-danger btn-sm mt-3"
onclick="removeTestimonialItem(this)">
<i class="fas fa-trash me-2"></i>Remove
</button>
</div>
</div>
<% }); %>
<% } %>
</div>
</div>
</div>
<!-- Fixed Bottom Buttons -->
<div class="fixed-bottom-buttons">
<button type="reset" class="btn btn-secondary" onclick="resetForm()">
<i class="fas fa-undo me-2"></i>Reset
</button>
<button type="submit" class="btn btn-primary" id="submitBtn">
<i class="fas fa-save me-2"></i>Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
<script type="application/json" id="testimonialDataJson"><%- JSON.stringify(data) %></script>
<script>
let originalFormData = null;
document.addEventListener('DOMContentLoaded', function () {
try {
var jsonScript = document.getElementById('testimonialDataJson');
originalFormData = JSON.parse(jsonScript.textContent);
} catch (e) {
console.error('Error parsing originalFormData:', e);
originalFormData = { items: [] };
}
initializeFormHandlers();
});
function initializeFormHandlers() {
const form = document.getElementById('testimonialForm');
form.addEventListener('submit', async function (e) {
e.preventDefault();
const submitBtn = document.getElementById('submitBtn');
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving...';
try {
updateItemsJson();
this.submit();
} catch (error) {
console.error('Error updating data:', error);
alert('Failed to process form data. Please try again.');
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
}
});
document.querySelectorAll('.btn-upload-image').forEach(button => {
button.addEventListener('click', function () {
const targetInput = this.dataset.targetInput;
const imageType = this.dataset.imageType;
openImageUploader(targetInput, imageType);
});
});
}
function updateItemsJson() {
const items = [];
document.querySelectorAll('.testimonial-item').forEach((item, index) => {
const name = item.querySelector(`[name="itemName_${index}"]`)?.value ||
item.querySelector('[name^="itemName_"]')?.value || '';
const role = item.querySelector(`[name="itemRole_${index}"]`)?.value ||
item.querySelector('[name^="itemRole_"]')?.value || '';
const country = item.querySelector(`[name="itemCountry_${index}"]`)?.value ||
item.querySelector('[name^="itemCountry_"]')?.value || '';
const rating = parseInt(item.querySelector(`[name="itemRating_${index}"]`)?.value ||
item.querySelector('[name^="itemRating_"]')?.value || '5');
const comment = item.querySelector(`[name="itemComment_${index}"]`)?.value ||
item.querySelector('[name^="itemComment_"]')?.value || '';
const avatar = item.querySelector(`[name="itemAvatar_${index}"]`)?.value ||
item.querySelector('[name^="itemAvatar_"]')?.value || '';
items.push({ name, role, country, rating, comment, avatar });
});
document.getElementById('itemsJson').value = JSON.stringify(items);
}
function addTestimonialItem() {
const container = document.getElementById('testimonialItemsContainer');
const index = container.querySelectorAll('.testimonial-item').length;
const html = `
<div class="card mb-3 testimonial-item">
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="itemName_${index}" value="">
</div>
<div class="col-md-3">
<label class="form-label">Role</label>
<input type="text" class="form-control" name="itemRole_${index}" value="">
</div>
<div class="col-md-3">
<label class="form-label">Country</label>
<input type="text" class="form-control" name="itemCountry_${index}" value="">
</div>
<div class="col-md-3">
<label class="form-label">Rating (1-5)</label>
<select class="form-select" name="itemRating_${index}">
<option value="1">1 Star</option>
<option value="2">2 Stars</option>
<option value="3">3 Stars</option>
<option value="4">4 Stars</option>
<option value="5" selected>5 Stars</option>
</select>
</div>
<div class="col-md-8">
<label class="form-label">Comment</label>
<textarea class="form-control" name="itemComment_${index}" rows="3"></textarea>
</div>
<div class="col-md-4">
<label class="form-label">Avatar</label>
<div class="input-group">
<input type="text" class="form-control" name="itemAvatar_${index}" value="">
<button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="itemAvatar_${index}" data-image-type="testimonial">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
</div>
</div>
<button type="button" class="btn btn-outline-danger btn-sm mt-3"
onclick="removeTestimonialItem(this)">
<i class="fas fa-trash me-2"></i>Remove
</button>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
// Re-attach upload handlers for new item
container.querySelector('.testimonial-item:last-child .btn-upload-image').addEventListener('click', function () {
const targetInput = this.dataset.targetInput;
const imageType = this.dataset.imageType;
openImageUploader(targetInput, imageType);
});
}
function removeTestimonialItem(button) {
if (confirm('Are you sure you want to remove this testimonial?')) {
button.closest('.testimonial-item').remove();
reindexItems();
}
}
function reindexItems() {
document.querySelectorAll('.testimonial-item').forEach((item, index) => {
item.querySelectorAll('[name^="item"]').forEach(input => {
const baseName = input.name.replace(/_\d+$/, '');
input.name = `${baseName}_${index}`;
});
});
}
function resetForm() {
if (confirm('Are you sure you want to reset all changes?')) {
location.reload();
}
}
function openImageUploader(targetInput, imageType) {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
fileInput.onchange = async function (e) {
const file = e.target.files[0];
if (!file) return;
try {
const formData = new FormData();
formData.append('image', file);
const uploadBtn = document.querySelector(`[data-target-input="${targetInput}"]`);
const originalBtnHtml = uploadBtn ? uploadBtn.innerHTML : 'Upload';
if (uploadBtn) {
uploadBtn.disabled = true;
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Uploading...';
}
const response = await fetch(`/admin/upload/image?imageType=${imageType}`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Upload failed');
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Upload failed');
}
const input = document.getElementById(targetInput) || document.querySelector(`[name="${targetInput}"]`);
if (!input) {
throw new Error('Target input not found');
}
input.value = result.path;
// Update preview if exists
const previewUrl = (result.path && (result.path.startsWith('http://') || result.path.startsWith('https://')))
? result.path
: (window.location.origin + result.path);
const parent = input.closest('.col-md-4') || input.closest('div');
let previewImg = parent.querySelector('.avatar-preview, .img-thumbnail');
if (previewImg) {
previewImg.src = previewUrl;
} else {
const img = document.createElement('img');
img.src = previewUrl;
img.className = 'img-thumbnail mt-2';
img.style.maxHeight = '60px';
img.alt = 'Preview';
input.closest('.input-group').after(img);
}
if (uploadBtn) {
uploadBtn.disabled = false;
uploadBtn.innerHTML = originalBtnHtml;
}
showToast('Success', 'Image uploaded successfully', 'success');
} catch (error) {
console.error('Upload error:', error);
showToast('Error', 'Failed to upload image: ' + error.message, 'error');
const uploadBtn = document.querySelector(`[data-target-input="${targetInput}"]`);
if (uploadBtn) {
uploadBtn.disabled = false;
uploadBtn.innerHTML = '<i class="fas fa-upload me-1"></i>Upload';
}
} finally {
document.body.removeChild(fileInput);
}
};
fileInput.click();
}
function showToast(title, message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type === 'error' ? 'danger' : type} border-0`;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
toast.setAttribute('aria-atomic', 'true');
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">
<strong>${title}:</strong> ${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
`;
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container position-fixed top-0 end-0 p-3';
document.body.appendChild(container);
}
container.appendChild(toast);
const bsToast = new bootstrap.Toast(toast, {
animation: true,
autohide: true,
delay: 3000
});
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => {
toast.remove();
});
}
</script>

View File

@@ -0,0 +1,206 @@
<div class="container">
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
<div>
<h1 class="h3 mb-0" style="color: var(--primary-dark);">
<%= title %>
</h1>
<p class="text-muted mb-0">Manage video gallery section on homepage</p>
</div>
<div>
<a href="<%= frontendUrl %>/" class="btn btn-outline-primary" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>View Homepage
</a>
</div>
</div>
<div class="row">
<div class="col-12">
<form method="POST" class="content-with-fixed-buttons" id="videoGalleryForm"
action="/admin/video-gallery/update">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom">
<h5 class="mb-0"><i class="fas fa-video me-2"></i>Video Gallery Settings</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-12">
<label class="form-label fw-medium">Heading</label>
<input type="text" class="form-control" name="heading" id="heading"
value="<%= data.heading || '' %>" placeholder="e.g., Explore World">
</div>
<div class="col-md-12">
<label class="form-label fw-medium">Video URL</label>
<div class="input-group">
<input type="text" class="form-control" name="videoUrl" id="videoUrl"
value="<%= data.videoUrl || '' %>" placeholder="/assets/video/sample.mp4">
<button type="button" class="btn btn-outline-primary btn-upload-video"
data-target-input="videoUrl">
<i class="fas fa-upload me-1"></i>Upload Video
</button>
</div>
<small class="text-muted">MP4 video file path or URL</small>
<% if (data.videoUrl) { %>
<div class="mt-2">
<video width="100%" height="200" controls style="border-radius: 8px;">
<source src="<%= data.videoUrl %>" type="video/mp4">
Your browser does not support the video tag.
</video>
</div>
<% } %>
</div>
</div>
</div>
</div>
<!-- Fixed Bottom Buttons -->
<div class="fixed-bottom-buttons">
<button type="reset" class="btn btn-secondary" onclick="resetForm()">
<i class="fas fa-undo me-2"></i>Reset
</button>
<button type="submit" class="btn btn-primary" id="submitBtn">
<i class="fas fa-save me-2"></i>Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
initializeFormHandlers();
});
function initializeFormHandlers() {
document.querySelectorAll('.btn-upload-image').forEach(button => {
button.addEventListener('click', function () {
const targetInput = this.dataset.targetInput;
const imageType = this.dataset.imageType;
openImageUploader(targetInput, imageType);
});
});
document.querySelectorAll('.btn-upload-video').forEach(button => {
button.addEventListener('click', function () {
const targetInput = this.dataset.targetInput;
openVideoUploader(targetInput);
});
});
}
function resetForm() {
if (confirm('Are you sure you want to reset all changes?')) {
location.reload();
}
}
function openImageUploader(targetInput, imageType) {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
fileInput.onchange = async function (e) {
const file = e.target.files[0];
if (!file) return;
try {
const formData = new FormData();
formData.append('image', file);
const uploadBtn = document.querySelector(`[data-target-input="${targetInput}"]`);
const originalBtnHtml = uploadBtn ? uploadBtn.innerHTML : 'Upload';
if (uploadBtn) {
uploadBtn.disabled = true;
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Uploading...';
}
const response = await fetch(`/admin/upload/image?imageType=${imageType}`, {
method: 'POST',
body: formData
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Upload failed');
}
document.getElementById(targetInput).value = result.path;
if (uploadBtn) {
uploadBtn.disabled = false;
uploadBtn.innerHTML = originalBtnHtml;
}
alert('Image uploaded successfully!');
location.reload();
} catch (error) {
console.error('Upload error:', error);
alert('Failed to upload image: ' + error.message);
} finally {
document.body.removeChild(fileInput);
}
};
fileInput.click();
}
function openVideoUploader(targetInput) {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'video/mp4,video/webm,video/ogg';
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
fileInput.onchange = async function (e) {
const file = e.target.files[0];
if (!file) return;
try {
const formData = new FormData();
formData.append('video', file);
const uploadBtn = document.querySelector(`[data-target-input="${targetInput}"]`);
const originalBtnHtml = uploadBtn ? uploadBtn.innerHTML : 'Upload';
if (uploadBtn) {
uploadBtn.disabled = true;
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Uploading...';
}
const response = await fetch('/admin/upload/video', {
method: 'POST',
body: formData
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Upload failed');
}
document.getElementById(targetInput).value = result.path;
if (uploadBtn) {
uploadBtn.disabled = false;
uploadBtn.innerHTML = originalBtnHtml;
}
alert('Video uploaded successfully!');
location.reload();
} catch (error) {
console.error('Upload error:', error);
alert('Failed to upload video: ' + error.message);
} finally {
document.body.removeChild(fileInput);
}
};
fileInput.click();
}
</script>

View File

@@ -1,9 +1,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %> | CMS-SIMS</title> <title>
<%= title %> | CMS-SIMS
</title>
<!-- Bootstrap CSS --> <!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome --> <!-- Font Awesome -->
@@ -11,9 +14,9 @@
<!-- Custom CSS --> <!-- Custom CSS -->
<style> <style>
:root { :root {
--primary-color: #b8b76a; --primary-color: #2563eb;
--primary-light: #c9c88a; --primary-light: #3b82f6;
--primary-dark: #9a994a; --primary-dark: #1d4ed8;
--text-on-primary: #ffffff; --text-on-primary: #ffffff;
} }
@@ -24,7 +27,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative; position: relative;
background-color: #3e1e00; background-color: #0f172a;
} }
body::before { body::before {
@@ -103,7 +106,7 @@
.form-control:focus { .form-control:focus {
outline: none; outline: none;
border-color: var(--primary-color) !important; border-color: var(--primary-color) !important;
box-shadow: 0 0 0 2px rgba(255,106,0,0.12); box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
} }
.form-label { .form-label {
@@ -114,7 +117,7 @@
} }
.btn-shine { .btn-shine {
background: linear-gradient(90deg, #9a994a, #b8b76a, #c9c88a, #d9d8aa); background: linear-gradient(90deg, #1d4ed8, #2563eb, #3b82f6, #60a5fa);
color: white; color: white;
border: none; border: none;
padding: 12px 30px; padding: 12px 30px;
@@ -132,7 +135,7 @@
.btn-shine:hover { .btn-shine:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(255,106,0,0.28); box-shadow: 0 5px 15px rgba(37, 99, 235, 0.35);
} }
.btn-shine::after { .btn-shine::after {
@@ -161,20 +164,19 @@
} }
</style> </style>
</head> </head>
<body> <body>
<div class="main-content"> <div class="main-content">
<!-- Flash Messages Data (Hidden) --> <!-- Flash Messages Data (Hidden) -->
<% if(typeof success_msg !=='undefined' || typeof error_msg !=='undefined' || typeof error !=='undefined' ) { %> <% if(typeof success_msg !=='undefined' || typeof error_msg !=='undefined' || typeof error !=='undefined' ) { %>
<div id="flash-messages-data" style="display: none;"><%- JSON.stringify({ <div id="flash-messages-data" style="display: none;"><%- JSON.stringify({ success_msg: typeof success_msg
success_msg: typeof success_msg !== 'undefined' && success_msg ? success_msg : null, !=='undefined' && success_msg ? success_msg : null, error_msg: typeof error_msg !=='undefined' && error_msg ?
error_msg: typeof error_msg !== 'undefined' && error_msg ? error_msg : null, error_msg : null, error: typeof error !=='undefined' && error ? error : null }) %></div>
error: typeof error !== 'undefined' && error ? error : null
}) %></div>
<% } %> <% } %>
<div class="login-container"> <div class="login-container">
<div class="logo-container img-shine"> <div class="logo-container img-shine">
<img src="/images/Logo_round.jpg" alt="Logo"> <img src="/img/logo/logo-hai-learning.png" alt="Logo">
</div> </div>
<div style="text-align: center; margin-bottom: 30px;"> <div style="text-align: center; margin-bottom: 30px;">
@@ -185,14 +187,17 @@
<form action="/auth/login" method="POST" class="login-form"> <form action="/auth/login" method="POST" class="login-form">
<div class="form-group" style="margin-bottom: 20px;"> <div class="form-group" style="margin-bottom: 20px;">
<label for="username" class="form-label">Username</label> <label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required autocomplete="username" autofocus> <input type="text" class="form-control" id="username" name="username" required autocomplete="username"
autofocus>
</div> </div>
<div class="form-group" style="margin-bottom: 20px;"> <div class="form-group" style="margin-bottom: 20px;">
<label for="password" class="form-label">Password</label> <label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required autocomplete="current-password"> <input type="password" class="form-control" id="password" name="password" required
autocomplete="current-password">
<a href="/auth/forgot-password" style="display: block; text-align: right; margin-top: 8px; color: #b8b76a; text-decoration: none; font-size: 13px;"> <a href="/auth/forgot-password"
style="display: block; text-align: right; margin-top: 8px; color: #2563eb; text-decoration: none; font-size: 13px;">
Forgot Your Password? Forgot Your Password?
</a> </a>
</div> </div>
@@ -206,7 +211,8 @@
</div> </div>
<div style="margin-top: 20px; text-align: center;"> <div style="margin-top: 20px; text-align: center;">
<p style="font-size: 12px; color: #fff;">© 2024 Swiss Institute of Management and Sciences. All rights reserved.</p> <p style="font-size: 12px; color: #fff;">© 2024 Swiss Institute of Management and Sciences. All rights
reserved.</p>
</div> </div>
</div> </div>
@@ -219,4 +225,5 @@
<!-- Flash Handler JS --> <!-- Flash Handler JS -->
<script src="/js/flash-handler.js"></script> <script src="/js/flash-handler.js"></script>
</body> </body>
</html> </html>

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<link rel="icon" type="image/png" href="/uploads/layout/favicon.png" /> <link rel="icon" type="image/png" href="/img/favicon.png" />
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title> <title>
@@ -720,65 +720,23 @@
</li> </li>
</ul> </ul>
</li> </li>
<li class="nav-item dropdown"> <li class="nav-item">
<a class="nav-link dropdown-toggle <%= currentPath === '/admin/about-us' || currentPath === '/admin/safety' || currentPath === '/admin/faq' || currentPath === '/admin/insurance' || currentPath === '/admin/travel' || currentPath === '/admin/terms-conditions' ? 'active' : '' %>" <a class="nav-link <%= currentPath === '/admin/about-us' ? 'active' : '' %>"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false"> href="/admin/about-us">About</a>
About
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item <%= currentPath === '/admin/about-us' ? 'active' : '' %>"
href="/admin/about-us">About us</a>
</li>
<li>
<a class="dropdown-item <%= currentPath === '/admin/safety' ? 'active' : '' %>"
href="/admin/safety">Safety</a>
</li>
<li>
<a class="dropdown-item <%= currentPath === '/admin/faq' ? 'active' : '' %>"
href="/admin/faq">FAQ</a>
</li>
<li>
<a class="dropdown-item <%= currentPath === '/admin/insurance' ? 'active' : '' %>"
href="/admin/insurance">Insurance</a>
</li>
<li>
<a class="dropdown-item <%= currentPath === '/admin/travel' ? 'active' : '' %>"
href="/admin/travel">Travel</a>
</li>
<li>
<a class="dropdown-item <%= currentPath === '/admin/terms-conditions' ? 'active' : '' %>"
href="/admin/terms-conditions">Terms & Conditions</a>
</li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle <%= currentPath === '/admin/service' || currentPath === '/admin/activity' ? 'active' : '' %>"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Services
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item <%= currentPath === '/admin/service' ? 'active' : '' %>"
href="/admin/service">Service</a>
</li>
<li>
<a class="dropdown-item <%= currentPath === '/admin/activity' ? 'active' : '' %>"
href="/admin/activity">Activity & Booking</a>
</li>
</ul>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link <%= currentPath === '/admin/blog' ? 'active' : '' %>" <a class="nav-link <%= currentPath === '/admin/service' ? 'active' : '' %>"
href="/admin/blog">Blog</a> href="/admin/service">Services</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link <%= currentPath === '/admin/visa' ? 'active' : '' %>" <a class="nav-link <%= currentPath === '/admin/blog' ? 'active' : '' %>" href="/admin/blog">Blog</a>
href="/admin/visa">Visa</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link <%= currentPath === '/admin/contact' ? 'active' : '' %>" <a class="nav-link <%= currentPath === '/admin/visa' ? 'active' : '' %>" href="/admin/visa">Visa</a>
href="/admin/contact">Contact Us</a> </li>
<li class="nav-item">
<a class="nav-link <%= currentPath === '/admin/contact' ? 'active' : '' %>" href="/admin/contact">Contact
Us</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link <%= currentPath === '/admin/appointment' ? 'active' : '' %>" <a class="nav-link <%= currentPath === '/admin/appointment' ? 'active' : '' %>"