From ac5fa14c97a70c61a02b1da41e638b7d30e96b69 Mon Sep 17 00:00:00 2001 From: DuongNguyendev Date: Mon, 20 Apr 2026 16:22:37 +0700 Subject: [PATCH 1/2] Adding backend of Student Support, Contact, Request --- constants/auditAction.js | 6 + controllers/contactController.js | 304 +-- controllers/requestInfoController.js | 171 ++ controllers/studentSupportController.js | 186 ++ data/contact.json | 217 +- data/request-info.json | 70 + data/student-support.json | 134 ++ models/contact.js | 495 +--- models/requestInfo.js | 138 ++ models/studentSupport.js | 148 ++ routes/admin.js | 26 + routes/index.js | 8 + scripts/2026_02_05_190000_home.js | 2 +- scripts/2026_04_20_100000_student_support.js | 32 + scripts/2026_04_20_101500_seed_users.js | 58 + scripts/2026_04_20_120000_request_info.js | 33 + scripts/2026_04_20_130000_contact.js | 33 + utils/iconOptions.js | 47 + views/admin/contact/index.ejs | 2228 +++++------------- views/admin/dashboard.ejs | 80 +- views/admin/request-info/index.ejs | 481 ++++ views/admin/student-support/index.ejs | 590 +++++ views/layouts/main.ejs | 4 + 23 files changed, 3167 insertions(+), 2324 deletions(-) create mode 100644 controllers/requestInfoController.js create mode 100644 controllers/studentSupportController.js create mode 100644 data/request-info.json create mode 100644 data/student-support.json create mode 100644 models/requestInfo.js create mode 100644 models/studentSupport.js create mode 100644 scripts/2026_04_20_100000_student_support.js create mode 100644 scripts/2026_04_20_101500_seed_users.js create mode 100644 scripts/2026_04_20_120000_request_info.js create mode 100644 scripts/2026_04_20_130000_contact.js create mode 100644 utils/iconOptions.js create mode 100644 views/admin/request-info/index.ejs create mode 100644 views/admin/student-support/index.ejs diff --git a/constants/auditAction.js b/constants/auditAction.js index a7a8fd8..73b978e 100644 --- a/constants/auditAction.js +++ b/constants/auditAction.js @@ -32,6 +32,12 @@ const AUDIT_ACTIONS = Object.freeze({ // Contact UPDATE_CONTACT: "UPDATE_CONTACT", + // Student Support (LAMS static page) + UPDATE_STUDENT_SUPPORT: "UPDATE_STUDENT_SUPPORT", + + // Request Info (LAMS static page) + UPDATE_REQUEST_INFO: "UPDATE_REQUEST_INFO", + // Pricing UPDATE_PRICING: "UPDATE_PRICING", diff --git a/controllers/contactController.js b/controllers/contactController.js index a1c3c28..65e7708 100644 --- a/controllers/contactController.js +++ b/controllers/contactController.js @@ -1,110 +1,89 @@ const { addBaseUrlToImages } = require("../utils/imageHelper"); +const { ICON_OPTIONS, normalizeIconClass } = require("../utils/iconOptions"); const Contact = require("../models/contact"); const ContactSubmission = require("../models/contactSubmission"); const writeAuditLog = require("../audit/writeAuditLog"); const diffObject = require("../audit/diffObject"); const AUDIT_ACTIONS = require("../constants/auditAction"); -// Get contact data from MongoDB -const getContactData = async () => { - const contact = await Contact.findOne({ name: "default" }); - if (!contact) { - return null; - } - return contact.toObject(); -}; +const SLUG = "contact"; -// API to get contact data +function parseJsonField(raw) { + if (raw == null || raw === "") return null; + if (typeof raw === "object") return raw; + try { return JSON.parse(raw); } catch { return null; } +} + +async function getDocument() { + return Contact.findOne({ slug: SLUG }).lean(); +} + +/** + * GET /api/contact — public payload for frontend LAMS. + */ exports.api = async (req, res) => { try { - const contact = await getContactData(); - if (!contact) { + const doc = await getDocument(); + if (!doc) { 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); + const body = { + metadata: doc.metadata || {}, + hero: doc.hero || {}, + infoCards: doc.infoCards || {}, + form: doc.form || {}, + faq: doc.faq || {}, + cta: doc.cta || {}, + }; + res.json(addBaseUrlToImages(body, baseUrl)); } catch (err) { - console.error("API Error:", err); + console.error("contact.api:", 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" }); - } -}; +// Legacy alias +exports.getContactData = exports.api; -// Render admin view +/** + * GET /admin/contact — 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: "", - embedUrl: "", - tileLayer: { - url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - attribution: "", - maxZoom: 18, - minZoom: 0, - }, - }, - form: { - sectionLabel: "", - heading: "", - description: "", - fields: [], - submitButton: { - text: "Send Message", - icon: "fa-solid fa-arrow-right", - buttonClass: "theme-btn style-2", - }, - }, - }; + const doc = await getDocument(); + const data = doc + ? { + metadata: doc.metadata || {}, + hero: doc.hero || {}, + infoCards: doc.infoCards || {}, + form: doc.form || {}, + faq: doc.faq || {}, + cta: doc.cta || {}, + } + : { + metadata: {}, + hero: { badge: "", titleMain: "", titleHighlight: "", description: "" }, + infoCards: { contactInfo: { title: "", channels: [] }, supportHours: { title: "", hours: [], footer: {} } }, + form: { heading: "", description: "", fields: {}, submitLabel: "Send Message", successMessage: "", errorMessage: "" }, + faq: { title: "", subtitle: "", items: [] }, + cta: { title: "", description: "", primaryButton: {}, secondaryButton: {} }, + }; const { startDate, endDate } = req.query; const query = {}; - if (startDate || endDate) { query.createdAt = {}; - if (startDate) { - query.createdAt.$gte = new Date(startDate); - } + if (startDate) query.createdAt.$gte = new Date(startDate); if (endDate) { - // Set end date to end of day const end = new Date(endDate); end.setHours(23, 59, 59, 999); query.createdAt.$lte = end; } } - - const submissions = await ContactSubmission.find(query) - .sort({ createdAt: -1 }) - .limit(50); - const frontendUrl = process.env.FRONTEND_URL; + const submissions = await ContactSubmission.find(query).sort({ createdAt: -1 }).limit(50); + const frontendUrl = process.env.FRONTEND_URL || ""; res.render("admin/contact/index", { title: "Contact Management", @@ -114,124 +93,61 @@ exports.index = async (req, res) => { startDate, endDate, frontendUrl, + iconOptions: ICON_OPTIONS, currentPath: req.path, user: req.session.user, }); } catch (error) { - console.error("Error in contact index:", error); + console.error("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 +/** + * POST /admin/contact/update — Save & audit. + */ exports.update = async (req, res) => { try { - const { hero, contactCards, map, form } = req.body; + const metadata = parseJsonField(req.body.metadata); + const hero = parseJsonField(req.body.hero); + const infoCards = parseJsonField(req.body.infoCards); + const form = parseJsonField(req.body.form); + const faq = parseJsonField(req.body.faq); + const cta = parseJsonField(req.body.cta); - // 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" }); - - // ✅ Capture BEFORE state - const beforeData = contact - ? JSON.parse(JSON.stringify(contact.toObject())) - : {}; - - 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: "", - embedUrl: "", - tileLayer: { - url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - attribution: "", - maxZoom: 18, - minZoom: 0, - }, - }, - form: formData || { - sectionLabel: "", - heading: "", - description: "", - fields: [], - submitButton: { - text: "Send Message", - icon: "fa-solid fa-arrow-right", - buttonClass: "theme-btn style-2", - }, - }, - }); - } 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; + if (!metadata || !hero || !infoCards || !form || !faq || !cta) { + req.flash("error_msg", "Invalid form payload. Please try again."); + return res.redirect("/admin/contact"); } - await contact.save(); + // Normalize channel icons against whitelist + if (infoCards?.contactInfo?.channels?.length) { + infoCards.contactInfo.channels = infoCards.contactInfo.channels.map((ch) => ({ + ...ch, + icon: normalizeIconClass(ch.icon), + })); + } - // ✅ Capture AFTER state - const afterData = JSON.parse(JSON.stringify(contact.toObject())); + const payload = { metadata, hero, infoCards, form, faq, cta }; + + let doc = await Contact.findOne({ slug: SLUG }); + const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {}; + + if (!doc) { + doc = new Contact({ slug: SLUG, ...payload }); + } else { + doc.set(payload); + } + + await doc.save(); + const afterData = JSON.parse(JSON.stringify(doc.toObject())); - // ✅ AUDIT LOGGING - Contact Updated const changes = diffObject(beforeData, afterData); if (changes.length > 0) { await writeAuditLog({ model: "Contact", - documentId: contact._id, + documentId: doc._id, action: AUDIT_ACTIONS.UPDATE_CONTACT, before: beforeData, after: afterData, @@ -243,18 +159,20 @@ exports.update = async (req, res) => { req.flash("success_msg", "Contact updated successfully"); res.redirect("/admin/contact"); } catch (err) { - console.error("Error updating contact:", err); + console.error("contact.update:", err); req.flash("error_msg", err.message || "Error updating contact"); res.redirect("/admin/contact"); } }; -// API để submit contact form (từ frontend) +// ───────────────────────────────────────────── +// Form Submissions (unchanged) +// ───────────────────────────────────────────── + exports.submitForm = async (req, res) => { try { const { name, email, phone, address, date, message } = req.body; - // Validation if (!name || !email) { return res.status(400).json({ success: false, @@ -262,7 +180,6 @@ exports.submitForm = async (req, res) => { }); } - // Create new submission const submission = new ContactSubmission({ name: name.trim(), email: email.trim().toLowerCase(), @@ -288,13 +205,9 @@ exports.submitForm = async (req, res) => { } catch (err) { console.error("Error submitting contact form:", err); - // Handle validation errors if (err.name === "ValidationError") { const errors = Object.values(err.errors).map((e) => e.message); - return res.status(400).json({ - success: false, - error: errors.join(", "), - }); + return res.status(400).json({ success: false, error: errors.join(", ") }); } res.status(500).json({ @@ -304,7 +217,6 @@ exports.submitForm = async (req, res) => { } }; -// API để lấy danh sách submissions (cho admin) exports.getSubmissions = async (req, res) => { try { const { status, page = 1, limit = 20 } = req.query; @@ -336,14 +248,10 @@ exports.getSubmissions = async (req, res) => { }); } catch (err) { console.error("Error getting submissions:", err); - res.status(500).json({ - success: false, - error: "Error loading submissions", - }); + res.status(500).json({ success: false, error: "Error loading submissions" }); } }; -// API để cập nhật status của submission exports.updateSubmissionStatus = async (req, res) => { try { const { id } = req.params; @@ -351,10 +259,7 @@ exports.updateSubmissionStatus = async (req, res) => { const validStatuses = ["pending", "read", "replied", "archived"]; if (!validStatuses.includes(status)) { - return res.status(400).json({ - success: false, - error: "Invalid status", - }); + return res.status(400).json({ success: false, error: "Invalid status" }); } const updateData = { status }; @@ -364,25 +269,16 @@ exports.updateSubmissionStatus = async (req, res) => { const submission = await ContactSubmission.findByIdAndUpdate( id, updateData, - { new: true }, + { new: true } ); if (!submission) { - return res.status(404).json({ - success: false, - error: "Submission not found", - }); + return res.status(404).json({ success: false, error: "Submission not found" }); } - res.json({ - success: true, - data: submission, - }); + res.json({ success: true, data: submission }); } catch (err) { console.error("Error updating submission:", err); - res.status(500).json({ - success: false, - error: "Error updating submission", - }); + res.status(500).json({ success: false, error: "Error updating submission" }); } }; diff --git a/controllers/requestInfoController.js b/controllers/requestInfoController.js new file mode 100644 index 0000000..46ea37d --- /dev/null +++ b/controllers/requestInfoController.js @@ -0,0 +1,171 @@ +const { addBaseUrlToImages } = require("../utils/imageHelper"); +const { normalizeIconClass } = require("../utils/iconOptions"); +const RequestInfo = require("../models/requestInfo"); +const writeAuditLog = require("../audit/writeAuditLog"); +const diffObject = require("../audit/diffObject"); +const AUDIT_ACTIONS = require("../constants/auditAction"); + +const SLUG = "request-info"; + +function emptyPageShape() { + return { + metadata: { title: "", description: "", keywords: "", ogImage: "" }, + hero: { + badge: "", + titleMain: "", + titleHighlight: "", + description: "", + valueProps: [], + }, + form: { + heading: "", + description: "", + fields: { + firstName: { name: "", label: "", placeholder: "", required: true }, + lastName: { name: "", label: "", placeholder: "", required: true }, + email: { name: "", label: "", placeholder: "", required: true }, + phone: { name: "", label: "", placeholder: "", required: false }, + program: { name: "", label: "", placeholder: "", required: true, options: [] }, + timeline: { name: "", label: "", options: [] }, + }, + consentText: "", + submitLabel: "", + footerText: "", + successMessage: "", + errorMessage: "", + }, + }; +} + +function parseJsonField(raw) { + if (raw == null || raw === "") return null; + if (typeof raw === "object") return raw; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +/** Chuẩn hoá icon theo whitelist (valueProps). */ +function sanitizeIcons(payload) { + if (!payload) return payload; + if (payload.hero?.valueProps?.length) { + payload.hero.valueProps = payload.hero.valueProps.map((vp) => ({ + ...vp, + icon: normalizeIconClass(vp.icon), + })); + } + return payload; +} + +async function getDocument() { + return RequestInfo.findOne({ slug: SLUG }).lean(); +} + +/** + * GET /api/request-info — public payload cho frontend LAMS. + * Response: { metadata, hero, form } với ảnh đã gắn full URL. + */ +exports.api = async (req, res) => { + try { + const doc = await getDocument(); + if (!doc) { + return res.status(404).json({ error: "Request Info content not found" }); + } + const baseUrl = + process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`; + const body = { + metadata: doc.metadata || {}, + hero: doc.hero || {}, + form: doc.form || {}, + }; + res.json(addBaseUrlToImages(body, baseUrl)); + } catch (err) { + console.error("requestInfo.api:", err); + res.status(500).json({ error: "Error loading Request Info data" }); + } +}; + +/** + * GET /admin/request-info — Admin view. + */ +exports.index = async (req, res) => { + try { + const doc = await getDocument(); + const data = doc + ? { + metadata: doc.metadata || {}, + hero: doc.hero || {}, + form: doc.form || {}, + } + : emptyPageShape(); + + const { ICON_OPTIONS } = require("../utils/iconOptions"); + const frontendUrl = process.env.FRONTEND_URL || ""; + + res.render("admin/request-info/index", { + title: "Request Info", + layout: "layouts/main", + data, + iconOptions: ICON_OPTIONS, + frontendUrl, + currentPath: req.path, + user: req.session.user, + }); + } catch (error) { + console.error("requestInfo.index:", error); + req.flash("error_msg", "Could not load Request Info page"); + res.redirect("/admin/dashboard"); + } +}; + +/** + * POST /admin/request-info/update — Lưu dữ liệu, tạo audit log. + */ +exports.update = async (req, res) => { + try { + const metadata = parseJsonField(req.body.metadata); + const hero = parseJsonField(req.body.hero); + const form = parseJsonField(req.body.form); + + if (!metadata || !hero || !form) { + req.flash("error_msg", "Invalid form payload. Please try again."); + return res.redirect("/admin/request-info"); + } + + const payload = sanitizeIcons({ metadata, hero, form }); + + let doc = await RequestInfo.findOne({ slug: SLUG }); + const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {}; + + if (!doc) { + doc = new RequestInfo({ slug: SLUG, ...payload }); + } else { + doc.set(payload); + } + + await doc.save(); + const afterData = JSON.parse(JSON.stringify(doc.toObject())); + + const changes = diffObject(beforeData, afterData); + if (changes.length > 0) { + await writeAuditLog({ + model: "RequestInfo", + documentId: doc._id, + action: AUDIT_ACTIONS.UPDATE_REQUEST_INFO, + before: beforeData, + after: afterData, + changes, + req, + }); + } + + req.flash("success_msg", "Request Info updated successfully"); + res.redirect("/admin/request-info"); + } catch (err) { + console.error("requestInfo.update:", err); + req.flash("error_msg", err.message || "Error updating Request Info"); + res.redirect("/admin/request-info"); + } +}; diff --git a/controllers/studentSupportController.js b/controllers/studentSupportController.js new file mode 100644 index 0000000..c9e5931 --- /dev/null +++ b/controllers/studentSupportController.js @@ -0,0 +1,186 @@ +const { addBaseUrlToImages } = require("../utils/imageHelper"); +const { normalizeIconClass } = require("../utils/iconOptions"); +const StudentSupport = require("../models/studentSupport"); +const writeAuditLog = require("../audit/writeAuditLog"); +const diffObject = require("../audit/diffObject"); +const AUDIT_ACTIONS = require("../constants/auditAction"); + +const SLUG = "student-support"; + +function emptyPageShape() { + return { + metadata: { title: "", description: "" }, + hero: { + badge: "", + title: "", + description: "", + primaryButton: { label: "", href: "" }, + secondaryButton: { label: "", href: "" }, + image: "", + imageAlt: "", + }, + directory: { title: "", subtitle: "", services: [] }, + contactSection: { + heading: "", + description: "", + channels: [], + form: { + heading: "", + fields: { + firstName: { label: "", placeholder: "" }, + lastName: { label: "", placeholder: "" }, + studentId: { label: "", placeholder: "" }, + department: { label: "" }, + message: { label: "", placeholder: "" }, + }, + departments: [], + submitLabel: "", + successMessage: "", + }, + }, + }; +} + +function parseJsonField(raw) { + if (raw == null || raw === "") return null; + if (typeof raw === "object") return raw; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +/** Chuẩn hoá mọi field icon theo whitelist (dropdown admin). */ +function sanitizeIcons(payload) { + if (!payload) return payload; + if (payload.hero) { + /* hero không có icon list */ + } + if (payload.directory?.services?.length) { + payload.directory.services = payload.directory.services.map((s) => ({ + ...s, + icon: normalizeIconClass(s.icon), + hoursIcon: normalizeIconClass(s.hoursIcon), + })); + } + if (payload.contactSection?.channels?.length) { + payload.contactSection.channels = payload.contactSection.channels.map((c) => ({ + ...c, + icon: normalizeIconClass(c.icon), + })); + } + return payload; +} + +async function getDocument() { + return StudentSupport.findOne({ slug: SLUG }).lean(); +} + +/** + * GET /api/student-support — public payload giống file JSON frontend (không kèm _id/slug). + */ +exports.api = async (req, res) => { + try { + const doc = await getDocument(); + if (!doc) { + return res.status(404).json({ error: "Student Support content not found" }); + } + const baseUrl = + process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`; + const body = { + metadata: doc.metadata || {}, + hero: doc.hero || {}, + directory: doc.directory || { services: [] }, + contactSection: doc.contactSection || {}, + }; + res.json(addBaseUrlToImages(body, baseUrl)); + } catch (err) { + console.error("studentSupport.api:", err); + res.status(500).json({ error: "Error loading Student Support data" }); + } +}; + +exports.index = async (req, res) => { + try { + const doc = await getDocument(); + const data = doc + ? { + metadata: doc.metadata || {}, + hero: doc.hero || {}, + directory: doc.directory || { services: [] }, + contactSection: doc.contactSection || {}, + } + : emptyPageShape(); + + const { ICON_OPTIONS } = require("../utils/iconOptions"); + const frontendUrl = process.env.FRONTEND_URL || ""; + + res.render("admin/student-support/index", { + title: "Student Support", + layout: "layouts/main", + data, + iconOptions: ICON_OPTIONS, + frontendUrl, + currentPath: req.path, + user: req.session.user, + }); + } catch (error) { + console.error("studentSupport.index:", error); + req.flash("error_msg", "Could not load Student Support page"); + res.redirect("/admin/dashboard"); + } +}; + +exports.update = async (req, res) => { + try { + const metadata = parseJsonField(req.body.metadata); + const hero = parseJsonField(req.body.hero); + const directory = parseJsonField(req.body.directory); + const contactSection = parseJsonField(req.body.contactSection); + + if (!metadata || !hero || !directory || !contactSection) { + req.flash("error_msg", "Invalid form payload. Please try again."); + return res.redirect("/admin/student-support"); + } + + const payload = sanitizeIcons({ + metadata, + hero, + directory, + contactSection, + }); + + let doc = await StudentSupport.findOne({ slug: SLUG }); + const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {}; + + if (!doc) { + doc = new StudentSupport({ slug: SLUG, ...payload }); + } else { + doc.set(payload); + } + + await doc.save(); + const afterData = JSON.parse(JSON.stringify(doc.toObject())); + + const changes = diffObject(beforeData, afterData); + if (changes.length > 0) { + await writeAuditLog({ + model: "StudentSupport", + documentId: doc._id, + action: AUDIT_ACTIONS.UPDATE_STUDENT_SUPPORT, + before: beforeData, + after: afterData, + changes, + req, + }); + } + + req.flash("success_msg", "Student Support updated successfully"); + res.redirect("/admin/student-support"); + } catch (err) { + console.error("studentSupport.update:", err); + req.flash("error_msg", err.message || "Error updating Student Support"); + res.redirect("/admin/student-support"); + } +}; diff --git a/data/contact.json b/data/contact.json index 474c9e1..0351791 100644 --- a/data/contact.json +++ b/data/contact.json @@ -1,119 +1,106 @@ { - "hero": { - "title": "CONTACT US", - "backgroundImage": "/assets/img/inner-page/breadcrumb.jpg", - "overlayColor": "rgba(0, 0, 0, 0)", - "sectionClass": "breadcrumb-wrapper fix bg-cover", - "titleClass": "breadcrumb-title", - "enableScrollspy": false, - "backgroundPosition": "center" + "metadata": { + "title": "Contact Us | LAMS", + "description": "Get in touch with LAMS. Our team is ready to assist you with admissions, financial aid, or program information.", + "keywords": "contact LAMS, admissions support, university contact, online education help", + "ogImage": "/images/og-contact.jpg" + }, + "hero": { + "badge": "We're Here to Help", + "titleMain": "Get in Touch with ", + "titleHighlight": "LAMS", + "description": "Whether you have questions about our programs, admissions, or financial aid, our team is ready to assist you on your educational journey." + }, + "infoCards": { + "contactInfo": { + "title": "Contact Information", + "channels": [ + { + "icon": "fa-solid fa-phone", + "title": "Admissions Phone", + "detail": "123456789", + "subDetail": "Mon-Fri, 8am-8pm EST" + }, + { + "icon": "fa-regular fa-envelope", + "title": "Email Support", + "detail": "info@lams.ac", + "subDetail": "We aim to reply within 24 hours" + }, + { + "icon": "fa-solid fa-map-location-dot", + "title": "Administrative Office", + "detail": "207 Regent Street, London, England W1B3HH" + } + ] }, - "contactCards": [ - { - "type": "location", - "title": "Location", - "content": [ - "43 Sardinella, 3nd Land Walk,", - "Orchard view, London, UK" - ], - "iconType": "fa-solid fa-location-dot", - "iconSource": "fontawesome" - }, - { - "type": "email", - "title": "Email Address", - "content": [ - "supportinfo@gmail.com", - "arluxhotelinfo.com" - ], - "iconType": "fa-solid fa-envelope", - "iconSource": "fontawesome" - }, - { - "type": "phone", - "title": "Phone Number", - "content": [ - "+880 123 427 00", - "+000 938 809 12" - ], - "iconType": "fa-solid fa-phone", - "iconSource": "fontawesome" - } - ], - "map": { - "coordinates": { - "lat": -37.81450084255415, - "lng": 144.9618311901502 - }, - "zoom": 15, - "location": "Envato, Melbourne, Australia", - "markerTitle": "Our Office", - "embedUrl": "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d6678.7619084840835!2d144.9618311901502!3d-37.81450084255415!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x6ad642b4758afc1d%3A0x3119cc820fdfc62e!2sEnvato!5e0!3m2!1sen!2sbd!4v1641984054261!5m2!1sen!2sbd", - "tileLayer": { - "url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - "attribution": "", - "maxZoom": 18, - "minZoom": 0 - } - }, - "form": { - "sectionLabel": "", - "heading": "Send Us Message", - "description": "Have questions about visas or immigration? Send us a message today and our expert team will respond quickly.", - "fields": [ - { - "name": "name", - "label": "Your Name", - "type": "text", - "placeholder": "Your name", - "required": true, - "colClass": "col-lg-4" - }, - { - "name": "email", - "label": "Your Email", - "type": "email", - "placeholder": "Your email", - "required": true, - "colClass": "col-lg-4" - }, - { - "name": "phone", - "label": "Your Phone", - "type": "tel", - "placeholder": "Phone Number", - "required": true, - "colClass": "col-lg-4" - }, - { - "name": "address", - "label": "Your Address", - "type": "text", - "placeholder": "Address Now", - "required": false, - "colClass": "col-lg-6" - }, - { - "name": "date", - "label": "Your Date", - "type": "date", - "placeholder": "Date", - "required": false, - "colClass": "col-lg-6" - }, - { - "name": "message", - "label": "Your Message", - "type": "textarea", - "placeholder": "Type your message", - "required": false, - "colClass": "col-lg-12" - } - ], - "submitButton": { - "text": "SEND MESSAGE", - "icon": "fa-solid fa-arrow-right", - "buttonClass": "theme-btn style-2" - } + "supportHours": { + "title": "Student Support Hours", + "hours": [ + { "day": "Monday - Friday", "time": "8:00 AM - 8:00 PM EST" }, + { "day": "Saturday", "time": "10:00 AM - 4:00 PM EST" }, + { "day": "Sunday", "time": "Closed" } + ], + "footer": { + "text": "Current student looking for academic advising?", + "linkText": "Visit Student Portal", + "linkHref": "https://portal.lams.ac" + } } + }, + "form": { + "heading": "Send us a Message", + "description": "Fill out the form below and our team will get back to you shortly.", + "fields": { + "firstName": { "name": "first_name", "label": "First Name", "placeholder": "John", "required": true }, + "lastName": { "name": "last_name", "label": "Last Name", "placeholder": "Doe", "required": true }, + "email": { "name": "email", "label": "Email Address", "placeholder": "john.doe@example.com", "required": true }, + "phone": { "name": "phone_number", "label": "Phone Number (Optional)", "placeholder": "(555) 123-4567", "required": false }, + "inquiryType": { + "name": "inquiry_type", + "label": "How can we help you?", + "placeholder": "Select an inquiry type...", + "required": true, + "options": [ + { "label": "Admissions & Enrollment", "value": "admissions" }, + { "label": "Financial Aid & Tuition", "value": "financial_aid" }, + { "label": "Program Information", "value": "program_info" }, + { "label": "Technical Support", "value": "tech_support" }, + { "label": "Other", "value": "other" } + ] + }, + "message": { "name": "message_content", "label": "Message", "placeholder": "Please provide details about your inquiry...", "required": true } + }, + "submitLabel": "Send Message", + "successMessage": "Your message has been sent successfully! Our team will contact you soon.", + "errorMessage": "Something went wrong. Please try again later or contact us directly." + }, + "faq": { + "title": "Frequently Asked Questions", + "subtitle": "Find quick answers to common questions about admissions, tuition, and online learning.", + "items": [ + { + "question": "Are LAMS degrees accredited?", + "answer": "Yes, LAMS is regionally accredited by the Higher Learning Commission. Our programs meet rigorous academic standards and our degrees are recognized by employers and other educational institutions worldwide." + }, + { + "question": "How does the monthly subscription model work?", + "answer": "Our subscription model allows you to pay a flat monthly fee to access your coursework. You can move through the material at your own pace, potentially completing more courses per term compared to traditional models." + }, + { + "question": "Can I transfer credits from another institution?", + "answer": "Absolutely. We accept transfer credits from regionally or nationally accredited institutions. You can transfer up to 90 credits for a bachelor's degree program." + }, + { + "question": "Are there set class times I need to attend?", + "answer": "No, our programs are 100% online and asynchronous. While there are deadlines, you do not need to log in at specific times for live lectures." + } + ] + }, + "cta": { + "title": "Ready to take the next step?", + "description": "Join thousands of students who have advanced their careers through our affordable, flexible online programs.", + "primaryButton": { "label": "Request Information", "href": "/request" }, + "secondaryButton": { "label": "Apply Now", "href": "/apply" } + } } \ No newline at end of file diff --git a/data/request-info.json b/data/request-info.json new file mode 100644 index 0000000..b01d3a4 --- /dev/null +++ b/data/request-info.json @@ -0,0 +1,70 @@ +{ + "metadata": { + "title": "Request Information | LAMS", + "description": "Start your journey with LAMS. Request more information about our affordable, flexible online degree programs.", + "keywords": "request university info, LAMS programs, admissions inquiry, online degree steps", + "ogImage": "/images/og-request.jpg" + }, + "hero": { + "badge": "Your Future Starts Here", + "titleMain": "Take the Next Step in Your ", + "titleHighlight": "Career", + "description": "Join thousands of students achieving their goals through our flexible, affordable, and accredited online programs.", + "valueProps": [ + { + "icon": "fa-solid fa-piggy-bank", + "title": "Affordable Tuition", + "description": "Learn by monthly subscription or pay-per-course. We believe quality education should be accessible to everyone." + }, + { + "icon": "fa-solid fa-clock", + "title": "Flexible Schedules", + "description": "100% online courses designed to fit your busy life. Study when and where it works best for you." + }, + { + "icon": "fa-solid fa-certificate", + "title": "Accredited Programs", + "description": "Earn a recognized degree from an institution committed to academic excellence and industry relevance." + } + ] + }, + "form": { + "heading": "Request Information", + "description": "Fill out the form below and an admissions advisor will contact you within 24 hours.", + "fields": { + "firstName": { "name": "first_name", "label": "First Name", "placeholder": "John", "required": true }, + "lastName": { "name": "last_name", "label": "Last Name", "placeholder": "Doe", "required": true }, + "email": { "name": "email_address", "label": "Email Address", "placeholder": "john.doe@example.com", "required": true }, + "phone": { "name": "phone_number", "label": "Phone Number", "placeholder": "(555) 123-4567", "required": false }, + "program": { + "name": "program_id", + "label": "Program of Interest", + "placeholder": "Select a program...", + "required": true, + "options": [ + { "label": "B.S. Computer Science", "value": "bs-cs" }, + { "label": "B.S. Business Administration", "value": "bs-ba" }, + { "label": "B.A. Psychology", "value": "ba-psych" }, + { "label": "Master of Business Administration (MBA)", "value": "mba" }, + { "label": "M.S. Data Science", "value": "ms-ds" }, + { "label": "Digital Marketing Certificate", "value": "cert-dm" } + ] + }, + "timeline": { + "name": "start_timeline", + "label": "Start Timeline", + "options": [ + { "label": "Immediately", "value": "immediate" }, + { "label": "1-3 Months", "value": "1-3-months" }, + { "label": "3-6 Months", "value": "3-6-months" }, + { "label": "Undecided", "value": "undecided" } + ] + } + }, + "consentText": "By submitting this form, I consent to receive emails, texts, and calls from LAMS regarding educational programs. I understand consent is not required to enroll.", + "submitLabel": "Get Information Now", + "footerText": "Your information is secure and encrypted.", + "successMessage": "Your request has been submitted successfully! An advisor will reach out to you within 24 hours.", + "errorMessage": "There was an error submitting your request. Please check your information and try again." + } +} diff --git a/data/student-support.json b/data/student-support.json new file mode 100644 index 0000000..51f024b --- /dev/null +++ b/data/student-support.json @@ -0,0 +1,134 @@ +{ + "metadata": { + "title": "Student Support | LAMS", + "description": "Comprehensive student support at LAMS. Access academic advising, career services, and technical support to thrive in your online learning journey." + }, + "hero": { + "badge": "Student Success Center", + "title": "Comprehensive Student Support", + "description": "We are dedicated to your success. Access academic advising, tutoring, career services, and technical support designed to help you thrive in your online learning journey.", + "primaryButton": { + "label": "Explore Services", + "href": "#directory" + }, + "secondaryButton": { + "label": "Get Help Now", + "href": "#contact-support" + }, + "image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/5a10584b95-33a6a34a5a6353e69a2c.png", + "imageAlt": "Support advisor helping student online" + }, + "directory": { + "title": "Support Services Directory", + "subtitle": "Everything you need to succeed academically, professionally, and personally while studying at LAMS.", + "services": [ + { + "icon": "fa-solid fa-compass", + "title": "Academic Advising", + "description": "Personalized guidance to help you select courses, stay on track for graduation, and navigate academic policies.", + "hours": "Mon-Fri: 8am - 8pm EST", + "hoursIcon": "fa-regular fa-clock", + "buttonLabel": "Schedule Appointment", + "buttonHref": "#" + }, + { + "icon": "fa-solid fa-language", + "title": "Language Enhancement", + "description": "Enhance your language skills through our Language system, supporting both general and specialised language learning tailored to your academic and professional needs.", + "hours": "24/7 Online Support", + "hoursIcon": "fa-regular fa-clock", + "buttonLabel": "Start Learning", + "buttonHref": "#" + }, + { + "icon": "fa-solid fa-briefcase", + "title": "Career Services", + "description": "Resume reviews, interview preparation, networking events, and access to our exclusive job board.", + "hours": "Mon-Fri: 9am - 6pm EST", + "hoursIcon": "fa-regular fa-clock", + "buttonLabel": "Access Career Hub", + "buttonHref": "#" + }, + { + "icon": "fa-solid fa-laptop-code", + "title": "Tech Support", + "description": "Assistance with learning management systems, student email, software access, and general technical troubleshooting.", + "hours": "24/7 Help Desk", + "hoursIcon": "fa-regular fa-clock", + "buttonLabel": "Submit IT Ticket", + "buttonHref": "#" + }, + { + "icon": "fa-solid fa-universal-access", + "title": "Accessibility Services", + "description": "Ensuring equal access to educational opportunities through accommodations, assistive tech, and advocacy.", + "hours": "Mon-Fri: 8am - 5pm EST", + "hoursIcon": "fa-regular fa-clock", + "buttonLabel": "Request Accommodations", + "buttonHref": "#" + }, + { + "icon": "fa-solid fa-users", + "title": "Community Resources", + "description": "Mental health resources, student clubs, diversity initiatives, and online community forums.", + "hours": "Virtual Campus", + "hoursIcon": "fa-solid fa-globe", + "buttonLabel": "Join the Community", + "buttonHref": "#" + } + ] + }, + "contactSection": { + "heading": "Need Immediate Assistance?", + "description": "Our support team is ready to help you navigate any challenges. Reach out through your preferred channel.", + "channels": [ + { + "icon": "fa-solid fa-phone", + "title": "Call Us", + "detail": "123456789" + }, + { + "icon": "fa-solid fa-envelope", + "title": "Email Support", + "detail": "info@lams.ac" + }, + { + "icon": "fa-solid fa-comments", + "title": "Live Chat", + "detail": "Available 24/7 on Student Portal" + } + ], + "form": { + "heading": "Send a Message", + "fields": { + "firstName": { + "label": "First Name", + "placeholder": "John" + }, + "lastName": { + "label": "Last Name", + "placeholder": "Doe" + }, + "studentId": { + "label": "Student ID (Optional)", + "placeholder": "e.g. 12345678" + }, + "department": { + "label": "Department" + }, + "message": { + "label": "Message", + "placeholder": "How can we help you?" + } + }, + "departments": [ + "Academic Advising", + "Financial Aid", + "Tech Support", + "Other" + ], + "submitLabel": "Submit Request", + "successMessage": "Your message has been sent successfully!" + } + } +} diff --git a/models/contact.js b/models/contact.js index 26df236..9d4f894 100644 --- a/models/contact.js +++ b/models/contact.js @@ -1,422 +1,189 @@ const mongoose = require("mongoose"); -// Schema cho hero section +// ───────────────────────────────────────────── +// Sub-schemas +// ───────────────────────────────────────────── + +const metadataSchema = new mongoose.Schema( + { + title: { type: String, trim: true, default: "" }, + description: { type: String, trim: true, default: "" }, + keywords: { type: String, trim: true, default: "" }, + ogImage: { type: String, trim: true, default: "" }, + }, + { _id: false } +); + const heroSchema = new mongoose.Schema( { - title: { - type: String, - required: true, - trim: true, - }, - backgroundImage: { - type: String, - trim: true, - default: "", - }, - overlayColor: { - type: String, - trim: true, - default: "rgba(0, 0, 0, 0)", - }, - sectionClass: { - type: String, - trim: true, - default: "", - }, - titleClass: { - type: String, - trim: true, - default: "", - }, - enableScrollspy: { - type: Boolean, - default: false, - }, - backgroundPosition: { - type: String, - trim: true, - default: "center", - }, + badge: { type: String, trim: true, default: "" }, + titleMain: { type: String, trim: true, default: "" }, + titleHighlight: { type: String, trim: true, default: "" }, + description: { type: String, trim: true, default: "" }, }, { _id: false } ); -// Schema cho contact card -const contactCardSchema = new mongoose.Schema( +const channelSchema = new mongoose.Schema( { - type: { - type: String, - required: true, - trim: true, - enum: [ - "phone", - "email", - "location", - "hours", - "website", - "social", - "custom", - ], - }, - title: { - type: String, - required: true, - trim: true, - }, - content: { - type: [String], - default: [], - }, - iconType: { - type: String, - required: false, - trim: true, - default: "", - }, - iconSource: { - type: String, - required: false, - trim: true, - enum: ["fontawesome", "image"], - default: "fontawesome", - }, + icon: { type: String, trim: true, default: "" }, + title: { type: String, trim: true, default: "" }, + detail: { type: String, trim: true, default: "" }, + subDetail: { type: String, trim: true, default: "" }, }, { _id: false } ); -// Schema cho map coordinates -const coordinatesSchema = new mongoose.Schema( +const supportHourSchema = new mongoose.Schema( { - lat: { - type: Number, - required: true, - }, - lng: { - type: Number, - required: true, - }, + day: { type: String, trim: true, default: "" }, + time: { type: String, trim: true, default: "" }, }, { _id: false } ); -// Schema cho tile layer -const tileLayerSchema = new mongoose.Schema( +const infoCardsSchema = new mongoose.Schema( { - url: { - type: String, - required: true, - trim: true, + contactInfo: { + title: { type: String, trim: true, default: "" }, + channels: { type: [channelSchema], default: [] }, }, - attribution: { - type: String, - trim: true, - default: "", - }, - maxZoom: { - type: Number, - default: 18, - }, - minZoom: { - type: Number, - default: 0, + supportHours: { + title: { type: String, trim: true, default: "" }, + hours: { type: [supportHourSchema], default: [] }, + footer: { + text: { type: String, trim: true, default: "" }, + linkText: { type: String, trim: true, default: "" }, + linkHref: { type: String, trim: true, default: "" }, + }, }, }, { _id: false } ); -// Schema cho map -const mapSchema = new mongoose.Schema( +const formOptionSchema = new mongoose.Schema( { - coordinates: { - type: coordinatesSchema, - required: true, - }, - zoom: { - type: Number, - default: 15, - }, - location: { - type: String, - required: true, - trim: true, - }, - markerTitle: { - type: String, - trim: true, - default: "", - }, - embedUrl: { - type: String, - trim: true, - default: "", - }, - tileLayer: { - type: tileLayerSchema, - required: true, - }, + label: { type: String, trim: true, default: "" }, + value: { type: String, trim: true, default: "" }, }, { _id: false } ); -// Schema cho form field -const formFieldSchema = new mongoose.Schema( +const simpleFieldSchema = new mongoose.Schema( { - name: { - type: String, - required: true, - trim: true, - }, - label: { - type: String, - trim: true, - default: "", - }, - type: { - type: String, - required: true, - trim: true, - enum: ["text", "email", "tel", "textarea", "programme", "date"], - }, - placeholder: { - type: String, - trim: true, - default: "", - }, - required: { - type: Boolean, - default: false, - }, - colClass: { - type: String, - trim: true, - default: "col-lg-12", - }, - programmeName: { - type: String, - trim: true, - default: "", - }, + name: { type: String, trim: true, default: "" }, + label: { type: String, trim: true, default: "" }, + placeholder: { type: String, trim: true, default: "" }, + required: { type: Boolean, default: false }, }, { _id: false } ); -// Schema cho submit button -const submitButtonSchema = new mongoose.Schema( +const inquiryFieldSchema = new mongoose.Schema( { - text: { - type: String, - required: true, - trim: true, - }, - icon: { - type: String, - trim: true, - default: "fa-solid fa-arrow-right", - }, - buttonClass: { - type: String, - trim: true, - default: "theme-btn style-2", - }, + name: { type: String, trim: true, default: "" }, + label: { type: String, trim: true, default: "" }, + placeholder: { type: String, trim: true, default: "" }, + required: { type: Boolean, default: true }, + options: { type: [formOptionSchema], default: [] }, }, { _id: false } ); -// Schema cho form const formSchema = new mongoose.Schema( { - sectionLabel: { - type: String, - trim: true, - default: "", - }, - heading: { - type: String, - trim: true, - default: "", - }, - description: { - type: String, - trim: true, - default: "", - }, + heading: { type: String, trim: true, default: "" }, + description: { type: String, trim: true, default: "" }, fields: { - type: [formFieldSchema], - default: [], - }, - submitButton: { - type: submitButtonSchema, - required: true, + firstName: { type: simpleFieldSchema, default: () => ({}) }, + lastName: { type: simpleFieldSchema, default: () => ({}) }, + email: { type: simpleFieldSchema, default: () => ({}) }, + phone: { type: simpleFieldSchema, default: () => ({}) }, + inquiryType: { type: inquiryFieldSchema, default: () => ({}) }, + message: { type: simpleFieldSchema, default: () => ({}) }, }, + submitLabel: { type: String, trim: true, default: "Send Message" }, + successMessage: { type: String, trim: true, default: "" }, + errorMessage: { type: String, trim: true, default: "" }, }, { _id: false } ); -// Main Contact Schema -const contactSchema = new mongoose.Schema( +const faqItemSchema = new mongoose.Schema( { - name: { - type: String, - default: "default", - unique: true, - }, - hero: { - type: heroSchema, - required: true, - }, - contactCards: { - type: [contactCardSchema], - default: [], - }, - map: { - type: mapSchema, - required: true, - }, - form: { - type: formSchema, - required: true, - }, + question: { type: String, trim: true, default: "" }, + answer: { type: String, trim: true, default: "" }, }, - { - timestamps: true, - } + { _id: false } ); -// Mapping iconType cũ sang Font Awesome icon mới -const iconTypeMapping = { - phone: "fas fa-phone", - email: "fas fa-envelope", - location: "fas fa-map-marker-alt", - clock: "fas fa-clock", - hours: "fas fa-clock", -}; +const faqSchema = new mongoose.Schema( + { + title: { type: String, trim: true, default: "" }, + subtitle: { type: String, trim: true, default: "" }, + items: { type: [faqItemSchema], default: [] }, + }, + { _id: false } +); -// Tạo migration script để import dữ liệu từ contact-data.json +const ctaButtonSchema = new mongoose.Schema( + { + label: { type: String, trim: true, default: "" }, + href: { type: String, trim: true, default: "" }, + }, + { _id: false } +); + +const ctaSchema = new mongoose.Schema( + { + title: { type: String, trim: true, default: "" }, + description: { type: String, trim: true, default: "" }, + primaryButton: { type: ctaButtonSchema, default: () => ({}) }, + secondaryButton: { type: ctaButtonSchema, default: () => ({}) }, + }, + { _id: false } +); + +// ───────────────────────────────────────────── +// Main Contact Schema (slug-based, same pattern +// as StudentSupport / RequestInfo) +// ───────────────────────────────────────────── +const contactSchema = new mongoose.Schema( + { + slug: { type: String, default: "contact", unique: true }, + metadata: { type: metadataSchema, default: () => ({}) }, + hero: { type: heroSchema, default: () => ({}) }, + infoCards: { type: infoCardsSchema, default: () => ({}) }, + form: { type: formSchema, default: () => ({}) }, + faq: { type: faqSchema, default: () => ({}) }, + cta: { type: ctaSchema, default: () => ({}) }, + }, + { timestamps: true } +); + +// ───────────────────────────────────────────── +// Migration helper (idempotent upsert) +// ───────────────────────────────────────────── contactSchema.statics.migrateFromJson = async function (jsonData) { - try { - // Kiểm tra xem đã có contact mặc định chưa - const existingContact = await this.findOne({ name: "default" }); + const payload = { + metadata: jsonData.metadata || {}, + hero: jsonData.hero || {}, + infoCards: jsonData.infoCards || {}, + form: jsonData.form || {}, + faq: jsonData.faq || {}, + cta: jsonData.cta || {}, + }; - // Xử lý và chuẩn hóa dữ liệu từ JSON - const processedData = { - hero: { - title: jsonData.hero?.title || "Contact Us", - backgroundImage: jsonData.hero?.backgroundImage || "", - overlayColor: jsonData.hero?.overlayColor || "rgba(0, 0, 0, 0)", - sectionClass: jsonData.hero?.sectionClass || "", - titleClass: jsonData.hero?.titleClass || "", - enableScrollspy: jsonData.hero?.enableScrollspy || false, - backgroundPosition: jsonData.hero?.backgroundPosition || "center", - }, - contactCards: (jsonData.contactCards || []).map((card) => { - let iconType = card.iconType || ""; - let iconSource = card.iconSource; - - // Nếu không có iconSource, tự động detect từ iconType - if (!iconSource) { - // Nếu iconType là image path (bắt đầu bằng /uploads/ hoặc http) - if ( - iconType.startsWith("/uploads/") || - iconType.startsWith("http://") || - iconType.startsWith("https://") - ) { - iconSource = "image"; - } else { - // Nếu iconType là string cũ (phone, email, location, clock) - iconSource = "fontawesome"; - // Map iconType cũ sang Font Awesome icon mới - if (iconTypeMapping[iconType]) { - iconType = iconTypeMapping[iconType]; - } else if ( - iconType && - !iconType.startsWith("fas ") && - !iconType.startsWith("fab ") - ) { - // Nếu iconType không phải là Font Awesome class hợp lệ, thử map - iconType = iconTypeMapping[iconType] || iconType; - } - } - } else { - // Nếu đã có iconSource nhưng iconType là string cũ, map sang Font Awesome - if ( - iconSource === "fontawesome" && - iconType && - !iconType.startsWith("fas ") && - !iconType.startsWith("fab ") && - iconTypeMapping[iconType] - ) { - iconType = iconTypeMapping[iconType]; - } - } - - return { - type: card.type || "custom", - title: card.title || "", - content: Array.isArray(card.content) ? card.content : [], - iconType: iconType, - iconSource: iconSource || "fontawesome", - }; - }), - map: { - coordinates: { - lat: jsonData.map?.coordinates?.lat || 0, - lng: jsonData.map?.coordinates?.lng || 0, - }, - zoom: jsonData.map?.zoom || 15, - location: jsonData.map?.location || "", - markerTitle: jsonData.map?.markerTitle || "", - embedUrl: jsonData.map?.embedUrl || "", - tileLayer: { - url: - jsonData.map?.tileLayer?.url || - "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - attribution: jsonData.map?.tileLayer?.attribution || "", - maxZoom: jsonData.map?.tileLayer?.maxZoom || 18, - minZoom: jsonData.map?.tileLayer?.minZoom || 0, - }, - }, - form: { - sectionLabel: jsonData.form?.sectionLabel || "", - heading: jsonData.form?.heading || "", - description: jsonData.form?.description || "", - fields: (jsonData.form?.fields || []).map((field) => ({ - name: field.name || "", - label: field.label || "", - type: field.type || "text", - placeholder: field.placeholder || "", - required: field.required || false, - colClass: field.colClass || "col-lg-12", - programmeName: field.programmeName || "", - })), - submitButton: { - text: jsonData.form?.submitButton?.text || "Send Message", - icon: jsonData.form?.submitButton?.icon || "fa-solid fa-arrow-right", - buttonClass: jsonData.form?.submitButton?.buttonClass || "theme-btn style-2", - }, - }, - }; - - if (existingContact) { - // Cập nhật contact hiện có với dữ liệu đã xử lý - existingContact.hero = processedData.hero; - existingContact.contactCards = processedData.contactCards; - existingContact.map = processedData.map; - existingContact.form = processedData.form; - await existingContact.save(); - console.log("Contact data updated successfully"); - return existingContact; - } else { - // Tạo contact mới với dữ liệu đã xử lý - const newContact = await this.create({ - name: "default", - ...processedData, - }); - console.log("Contact data imported successfully"); - return newContact; - } - } catch (error) { - console.error("Error migrating contact data:", error); - throw error; + const existing = await this.findOne({ slug: "contact" }); + if (existing) { + existing.set(payload); + await existing.save(); + console.log("✅ Contact data updated via migration."); + return existing; + } else { + const created = await this.create({ slug: "contact", ...payload }); + console.log("✅ Contact data created via migration."); + return created; } }; diff --git a/models/requestInfo.js b/models/requestInfo.js new file mode 100644 index 0000000..4c45ad6 --- /dev/null +++ b/models/requestInfo.js @@ -0,0 +1,138 @@ +const mongoose = require("mongoose"); + +// ── Sub-schemas ────────────────────────────────────────────────────────────── + +const metadataSchema = new mongoose.Schema( + { + title: { type: String, default: "" }, + description: { type: String, default: "" }, + keywords: { type: String, default: "" }, + ogImage: { type: String, default: "" }, + }, + { _id: false }, +); + +const valuePropSchema = new mongoose.Schema( + { + icon: { type: String, default: "" }, + title: { type: String, default: "" }, + description: { type: String, default: "" }, + }, + { _id: false }, +); + +const heroSchema = new mongoose.Schema( + { + badge: { type: String, default: "" }, + titleMain: { type: String, default: "" }, + titleHighlight: { type: String, default: "" }, + description: { type: String, default: "" }, + valueProps: { type: [valuePropSchema], default: [] }, + }, + { _id: false }, +); + +const formOptionSchema = new mongoose.Schema( + { + label: { type: String, default: "" }, + value: { type: String, default: "" }, + }, + { _id: false }, +); + +const formFieldSchema = new mongoose.Schema( + { + name: { type: String, default: "" }, + label: { type: String, default: "" }, + placeholder: { type: String, default: "" }, + required: { type: Boolean, default: false }, + }, + { _id: false }, +); + +const formSelectFieldSchema = new mongoose.Schema( + { + name: { type: String, default: "" }, + label: { type: String, default: "" }, + placeholder: { type: String, default: "" }, + required: { type: Boolean, default: false }, + options: { type: [formOptionSchema], default: [] }, + }, + { _id: false }, +); + +const formTimelineFieldSchema = new mongoose.Schema( + { + name: { type: String, default: "" }, + label: { type: String, default: "" }, + options: { type: [formOptionSchema], default: [] }, + }, + { _id: false }, +); + +const formFieldsSchema = new mongoose.Schema( + { + firstName: { type: formFieldSchema, default: () => ({}) }, + lastName: { type: formFieldSchema, default: () => ({}) }, + email: { type: formFieldSchema, default: () => ({}) }, + phone: { type: formFieldSchema, default: () => ({}) }, + program: { type: formSelectFieldSchema, default: () => ({}) }, + timeline: { type: formTimelineFieldSchema, default: () => ({}) }, + }, + { _id: false }, +); + +const formSchema = new mongoose.Schema( + { + heading: { type: String, default: "" }, + description: { type: String, default: "" }, + fields: { type: formFieldsSchema, default: () => ({}) }, + consentText: { type: String, default: "" }, + submitLabel: { type: String, default: "" }, + footerText: { type: String, default: "" }, + successMessage: { type: String, default: "" }, + errorMessage: { type: String, default: "" }, + }, + { _id: false }, +); + +// ── Root schema ─────────────────────────────────────────────────────────────── + +const requestInfoSchema = new mongoose.Schema( + { + slug: { + type: String, + required: true, + unique: true, + default: "request-info", + }, + metadata: { type: metadataSchema, default: () => ({}) }, + hero: { type: heroSchema, default: () => ({}) }, + form: { type: formSchema, default: () => ({}) }, + }, + { timestamps: true }, +); + +/** + * Seed / migrate từ file JSON (cùng shape với API frontend). + * Idempotent — chạy lại không tạo trùng. + */ +requestInfoSchema.statics.migrateFromJson = async function (jsonData) { + const slug = "request-info"; + const payload = { + slug, + metadata: jsonData.metadata || {}, + hero: jsonData.hero || {}, + form: jsonData.form || {}, + }; + + const existing = await this.findOne({ slug }); + if (existing) { + existing.set(payload); + await existing.save(); + return existing; + } + return this.create(payload); +}; + +module.exports = mongoose.model("RequestInfo", requestInfoSchema); diff --git a/models/studentSupport.js b/models/studentSupport.js new file mode 100644 index 0000000..afabcdc --- /dev/null +++ b/models/studentSupport.js @@ -0,0 +1,148 @@ +const mongoose = require("mongoose"); + +const ctaButtonSchema = new mongoose.Schema( + { + label: { type: String, default: "" }, + href: { type: String, default: "" }, + }, + { _id: false }, +); + +const heroSchema = new mongoose.Schema( + { + badge: { type: String, default: "" }, + title: { type: String, default: "" }, + description: { type: String, default: "" }, + primaryButton: { type: ctaButtonSchema, default: () => ({}) }, + secondaryButton: { type: ctaButtonSchema, default: () => ({}) }, + image: { type: String, default: "" }, + imageAlt: { type: String, default: "" }, + }, + { _id: false }, +); + +const directoryServiceSchema = new mongoose.Schema( + { + icon: { type: String, default: "" }, + title: { type: String, default: "" }, + description: { type: String, default: "" }, + hours: { type: String, default: "" }, + hoursIcon: { type: String, default: "" }, + buttonLabel: { type: String, default: "" }, + buttonHref: { type: String, default: "" }, + }, + { _id: false }, +); + +const directorySchema = new mongoose.Schema( + { + title: { type: String, default: "" }, + subtitle: { type: String, default: "" }, + services: { type: [directoryServiceSchema], default: [] }, + }, + { _id: false }, +); + +const channelSchema = new mongoose.Schema( + { + icon: { type: String, default: "" }, + title: { type: String, default: "" }, + detail: { type: String, default: "" }, + }, + { _id: false }, +); + +const labelPlaceholderSchema = new mongoose.Schema( + { + label: { type: String, default: "" }, + placeholder: { type: String, default: "" }, + }, + { _id: false }, +); + +const departmentFieldSchema = new mongoose.Schema( + { + label: { type: String, default: "" }, + }, + { _id: false }, +); + +const supportFormFieldsSchema = new mongoose.Schema( + { + firstName: { type: labelPlaceholderSchema, default: () => ({}) }, + lastName: { type: labelPlaceholderSchema, default: () => ({}) }, + studentId: { type: labelPlaceholderSchema, default: () => ({}) }, + department: { type: departmentFieldSchema, default: () => ({}) }, + message: { type: labelPlaceholderSchema, default: () => ({}) }, + }, + { _id: false }, +); + +const contactFormSchema = new mongoose.Schema( + { + heading: { type: String, default: "" }, + fields: { type: supportFormFieldsSchema, default: () => ({}) }, + departments: { type: [String], default: [] }, + submitLabel: { type: String, default: "" }, + successMessage: { type: String, default: "" }, + }, + { _id: false }, +); + +const contactSectionSchema = new mongoose.Schema( + { + heading: { type: String, default: "" }, + description: { type: String, default: "" }, + channels: { type: [channelSchema], default: [] }, + form: { type: contactFormSchema, default: () => ({}) }, + }, + { _id: false }, +); + +const metadataSchema = new mongoose.Schema( + { + title: { type: String, default: "" }, + description: { type: String, default: "" }, + }, + { _id: false }, +); + +const studentSupportSchema = new mongoose.Schema( + { + slug: { + type: String, + required: true, + unique: true, + default: "student-support", + }, + metadata: { type: metadataSchema, default: () => ({}) }, + hero: { type: heroSchema, default: () => ({}) }, + directory: { type: directorySchema, default: () => ({}) }, + contactSection: { type: contactSectionSchema, default: () => ({}) }, + }, + { timestamps: true }, +); + +/** + * Seed / migrate từ file JSON (cùng shape với API frontend). + */ +studentSupportSchema.statics.migrateFromJson = async function (jsonData) { + const slug = "student-support"; + const payload = { + slug, + metadata: jsonData.metadata || {}, + hero: jsonData.hero || {}, + directory: jsonData.directory || { services: [] }, + contactSection: jsonData.contactSection || {}, + }; + + const existing = await this.findOne({ slug }); + if (existing) { + existing.set(payload); + await existing.save(); + return existing; + } + return this.create(payload); +}; + +module.exports = mongoose.model("StudentSupport", studentSupportSchema); diff --git a/routes/admin.js b/routes/admin.js index 5dce403..a45f02f 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -9,6 +9,8 @@ const footerController = require("../controllers/footerController"); const aboutUsController = require("../controllers/aboutUsController"); const formController = require("../controllers/formController"); const contactController = require("../controllers/contactController"); +const studentSupportController = require("../controllers/studentSupportController"); +const requestInfoController = require("../controllers/requestInfoController"); const pageController = require("../controllers/pageController"); const settingController = require("../controllers/settingController"); const faqController = require("../controllers/faqController"); // Thêm import này @@ -170,6 +172,30 @@ router.put( contactController.updateSubmissionStatus, ); +// Student Support (LAMS static page) +router.get( + "/student-support", + ensureAuthenticated, + studentSupportController.index, +); +router.post( + "/student-support/update", + ensureAuthenticated, + studentSupportController.update, +); + +// Request Info (LAMS static page) +router.get( + "/request-info", + ensureAuthenticated, + requestInfoController.index, +); +router.post( + "/request-info/update", + ensureAuthenticated, + requestInfoController.update, +); + // Appointment management const appointmentController = require("../controllers/appointmentController"); router.get( diff --git a/routes/index.js b/routes/index.js index 48e1d8c..88fe137 100644 --- a/routes/index.js +++ b/routes/index.js @@ -7,6 +7,8 @@ const headerController = require("../controllers/headerController"); const socialLinkController = require("../controllers/socialLinkController"); const footerController = require("../controllers/footerController"); const contactController = require("../controllers/contactController"); +const studentSupportController = require("../controllers/studentSupportController"); +const requestInfoController = require("../controllers/requestInfoController"); const faqController = require("../controllers/faqController"); const visaController = require("../controllers/visaController"); const headerMenuController = require("../controllers/headerMenuController"); @@ -64,6 +66,12 @@ router.put("/api/admin/footer", footerController.updateFooter); // Contact API route router.get("/api/contact", contactController.api); +// Student Support page (public API for frontend LAMS) +router.get("/api/student-support", studentSupportController.api); + +// Request Info page (public API for frontend LAMS) +router.get("/api/request-info", requestInfoController.api); + // Contact form submission (public) router.post("/api/contact/submit", contactController.submitForm); diff --git a/scripts/2026_02_05_190000_home.js b/scripts/2026_02_05_190000_home.js index b8f4dae..e657e03 100644 --- a/scripts/2026_02_05_190000_home.js +++ b/scripts/2026_02_05_190000_home.js @@ -21,7 +21,7 @@ async function migrate() { console.log("✅ Home model registered successfully"); // 3) Load JSON data - const dataPath = path.join(__dirname, "..", "..", "hailearning.edu.vn", "app", "home.json"); + const dataPath = path.join(__dirname, "..", "data", "home.json"); const raw = await fs.readFile(dataPath, "utf8"); const homeData = JSON.parse(raw); console.log("📖 Home data loaded from:", dataPath); diff --git a/scripts/2026_04_20_100000_student_support.js b/scripts/2026_04_20_100000_student_support.js new file mode 100644 index 0000000..1a8980b --- /dev/null +++ b/scripts/2026_04_20_100000_student_support.js @@ -0,0 +1,32 @@ +require("dotenv").config(); +const fs = require("fs").promises; +const path = require("path"); +const connectDB = require("../config/database"); +const StudentSupport = require("../models/studentSupport"); +const mongoose = require("mongoose"); + +/** + * Migration: Student Support page content (slug student-support) + * Reads data/student-support.json and upserts MongoDB. + */ +async function migrate() { + try { + await connectDB(); + const jsonPath = path.join(__dirname, "../data/student-support.json"); + const raw = await fs.readFile(jsonPath, "utf8"); + const data = JSON.parse(raw); + await StudentSupport.migrateFromJson(data); + console.log("Student Support migration completed successfully"); + await mongoose.disconnect(); + process.exit(0); + } catch (error) { + console.error("Student Support migration error:", error); + process.exit(1); + } +} + +if (require.main === module) { + migrate(); +} + +module.exports = { migrate }; diff --git a/scripts/2026_04_20_101500_seed_users.js b/scripts/2026_04_20_101500_seed_users.js new file mode 100644 index 0000000..3884675 --- /dev/null +++ b/scripts/2026_04_20_101500_seed_users.js @@ -0,0 +1,58 @@ +require("dotenv").config(); +const connectDB = require("../config/database"); +const User = require("../models/User"); +const mongoose = require("mongoose"); + +/** + * Migration: seed_users + * Description: Tạo sẵn tài khoản cứng cho login CMS + */ +async function migrate() { + try { + await connectDB(); + + const users = [ + { + username: process.env.ADMIN_USERNAME || "admin", + email: "admin@cms.local", + name: "System Admin", + password: process.env.ADMIN_PASSWORD || "admin1234", + role: "admin", + }, + { + username: "manager", + email: "manager@cms.local", + name: "Content Manager", + password: "manager1234", + role: "manager", + }, + ]; + + for (const item of users) { + const existing = await User.findOne({ username: item.username }); + if (existing) { + console.log(`SKIP user exists: ${item.username}`); + continue; + } + + await User.create(item); + console.log(`DONE created user: ${item.username}`); + } + + console.log("User seed migration completed."); + await mongoose.disconnect(); + process.exit(0); + } catch (error) { + console.error("User seed migration failed:", error); + if (mongoose.connection.readyState === 1) { + await mongoose.disconnect(); + } + process.exit(1); + } +} + +if (require.main === module) { + migrate(); +} + +module.exports = { migrate }; diff --git a/scripts/2026_04_20_120000_request_info.js b/scripts/2026_04_20_120000_request_info.js new file mode 100644 index 0000000..664cd93 --- /dev/null +++ b/scripts/2026_04_20_120000_request_info.js @@ -0,0 +1,33 @@ +require("dotenv").config(); +const fs = require("fs").promises; +const path = require("path"); +const connectDB = require("../config/database"); +const RequestInfo = require("../models/requestInfo"); +const mongoose = require("mongoose"); + +/** + * Migration: Request Info page content (slug: request-info) + * Reads data/request-info.json and upserts into MongoDB. + * Idempotent — safe to re-run. + */ +async function migrate() { + try { + await connectDB(); + const jsonPath = path.join(__dirname, "../data/request-info.json"); + const raw = await fs.readFile(jsonPath, "utf8"); + const data = JSON.parse(raw); + await RequestInfo.migrateFromJson(data); + console.log("Request Info migration completed successfully"); + await mongoose.disconnect(); + process.exit(0); + } catch (error) { + console.error("Request Info migration error:", error); + process.exit(1); + } +} + +if (require.main === module) { + migrate(); +} + +module.exports = { migrate }; diff --git a/scripts/2026_04_20_130000_contact.js b/scripts/2026_04_20_130000_contact.js new file mode 100644 index 0000000..2642d7c --- /dev/null +++ b/scripts/2026_04_20_130000_contact.js @@ -0,0 +1,33 @@ +require("dotenv").config(); +const fs = require("fs").promises; +const path = require("path"); +const connectDB = require("../config/database"); +const Contact = require("../models/contact"); +const mongoose = require("mongoose"); + +/** + * Migration: Contact page content (slug: contact) + * Reads data/contact.json and upserts into MongoDB. + * Idempotent — safe to re-run. + */ +async function migrate() { + try { + await connectDB(); + const jsonPath = path.join(__dirname, "../data/contact.json"); + const raw = await fs.readFile(jsonPath, "utf8"); + const data = JSON.parse(raw); + await Contact.migrateFromJson(data); + console.log("Contact migration completed successfully"); + await mongoose.disconnect(); + process.exit(0); + } catch (error) { + console.error("Contact migration error:", error); + process.exit(1); + } +} + +if (require.main === module) { + migrate(); +} + +module.exports = { migrate }; diff --git a/utils/iconOptions.js b/utils/iconOptions.js new file mode 100644 index 0000000..76e3f6d --- /dev/null +++ b/utils/iconOptions.js @@ -0,0 +1,47 @@ +/** + * Danh sách icon Font Awesome cho admin (dropdown) — tránh để user gõ class tự do. + * Giá trị lưu DB/API phải khớp một trong các `value` bên dưới (hoặc được map về default). + */ +const ICON_OPTIONS = [ + { value: "fa-solid fa-compass", label: "Compass (solid)" }, + { value: "fa-solid fa-language", label: "Language (solid)" }, + { value: "fa-solid fa-briefcase", label: "Briefcase (solid)" }, + { value: "fa-solid fa-laptop-code", label: "Laptop code (solid)" }, + { value: "fa-solid fa-universal-access", label: "Universal access (solid)" }, + { value: "fa-solid fa-users", label: "Users (solid)" }, + { value: "fa-solid fa-phone", label: "Phone (solid)" }, + { value: "fa-solid fa-envelope", label: "Envelope (solid)" }, + { value: "fa-solid fa-comments", label: "Comments (solid)" }, + { value: "fa-solid fa-globe", label: "Globe (solid)" }, + { value: "fa-regular fa-clock", label: "Clock (regular)" }, + { value: "fa-solid fa-clock", label: "Clock (solid)" }, + { value: "fa-solid fa-lightbulb", label: "Lightbulb (solid)" }, + { value: "fa-solid fa-circle-info", label: "Info (solid)" }, + { value: "fa-solid fa-headset", label: "Headset (solid)" }, + { value: "fa-solid fa-book", label: "Book (solid)" }, + { value: "fa-solid fa-graduation-cap", label: "Graduation cap (solid)" }, + { value: "fa-solid fa-handshake", label: "Handshake (solid)" }, + { value: "fa-solid fa-piggy-bank", label: "Piggy bank (solid)" }, + { value: "fa-solid fa-certificate", label: "Certificate (solid)" }, + { value: "fa-regular fa-envelope", label: "Envelope (regular)" }, + { value: "fa-solid fa-map-location-dot", label: "Map location dot (solid)" }, + { value: "fa-solid fa-map-marker-alt", label: "Map marker (solid)" }, + { value: "fa-solid fa-location-dot", label: "Location dot (solid)" }, + { value: "fa-solid fa-fax", label: "Fax (solid)" }, + { value: "fa-solid fa-mobile-alt", label: "Mobile (solid)" }, + { value: "fa-solid fa-building", label: "Building (solid)" }, +]; + +const ALLOWED = new Set(ICON_OPTIONS.map((o) => o.value)); + +function normalizeIconClass(value, fallback = "fa-solid fa-circle-info") { + if (!value || typeof value !== "string") return fallback; + const trimmed = value.trim(); + if (ALLOWED.has(trimmed)) return trimmed; + return fallback; +} + +module.exports = { + ICON_OPTIONS, + normalizeIconClass, +}; diff --git a/views/admin/contact/index.ejs b/views/admin/contact/index.ejs index ba96344..efc157e 100644 --- a/views/admin/contact/index.ejs +++ b/views/admin/contact/index.ejs @@ -1,1665 +1,615 @@
-
-
-

- <%= title %> -

-

Edit content displayed on Contact Us page

-
-
- - View Contact Us Page - -
+
+
+

<%= title %>

+

Edit content displayed on Contact Us page

- -
-
-
- - - - - - - -
- - -
-
- -
-
-
-
-
- -
- - -
- Recommended size: 1920x1080px -
-
-
- <% if (data.hero?.backgroundImage) { %> - <% let heroImgSrc=data.hero.backgroundImage; if (heroImgSrc && - !heroImgSrc.startsWith('http://') && - !heroImgSrc.startsWith('https://')) { - heroImgSrc=heroImgSrc.startsWith('/') ? heroImgSrc : '/' + - heroImgSrc; } %> - Background image preview - - <% } else { %> -
- Image preview -
- <% } %> -
-
-
-
-
- - -
-
- - - - - - - -
-
-
- - -
-
-
-
-
Contact Cards
- -
-
- <% if (data.contactCards && data.contactCards.length> 0) { %> - <% data.contactCards.forEach((card, index)=> { %> - <% const iconSource=card.iconSource || (card.iconType && - card.iconType.startsWith('/uploads/') ? 'image' : 'fontawesome' - ); const isImageIcon=iconSource==='image' ; const - faIconValue=!isImageIcon ? (card.iconType || '' ) : '' ; const - imageIconValue=isImageIcon ? (card.iconType || '' ) : '' ; %> -
-
-
-
- - -
-
- - -
-
- -
- - data-index="<%= index %>" - onchange="handleIconSourceChange(this)"> - - - - data-index="<%= index %>" - onchange="handleIconSourceChange(this)"> - -
- - -
- - - Choose a Font - Awesome icon from the list -
- <% if (faIconValue) { %> - - <% } %> -
-
- - -
- -
- - -
- <% if (imageIconValue) { %> - Icon preview - <% } else { %> - - <% } %> - Upload - a custom icon image for this - contact card -
-
-
- - - Enter each content - item on a new line -
-
- -
-
- <% }); %> - <% } %> -
-
-
-
- - -
-
-
-
Map Settings
-
-
- - -
-
- - - Enter address - map will be automatically - shown -
-
- - - Paste embed URL from Google Maps (Share -> - Embed a map) -
-
-
- <% if (data.map?.embedUrl) { %> - -
- Location: <%= - data.map?.markerTitle || data.map?.location - || 'Location' %> -
- <% } else if (data.map?.location && data.map?.coordinates?.lat - && data.map?.coordinates?.lng) { %> - <% var lat=data.map.coordinates.lat; var - lng=data.map.coordinates.lng; var zoom=data.map.zoom || - 15; var markerTitle=data.map.markerTitle || - data.map.location; var zoomDelta={ 10: 0.1, 11: 0.05, - 12: 0.025, 13: 0.0125, 14: 0.006, 15: 0.003, 16: 0.0015, - 17: 0.00075, 18: 0.000375 }; var delta=zoomDelta[zoom] - || 0.003; var latDelta=delta; var lngDelta=delta * 1.5; - %> - -
- 📍 <%= markerTitle %> - -
- <% } else { %> - Enter location above to see map preview - <% } %> -
-
-
- - - - - - - - -
-
-
- - -
-
-
-
Form Settings
-
-
- - -
-
- - -
-
- - -
-
- - -
- - - -
-
-
-
Form Fields
- -
-
- <% if (data.form?.fields && data.form.fields.length> 0) { %> - <% data.form.fields.forEach((field, index)=> { %> -
-
-
-
- - -
-
- - -
-
- - -
-
- -
- > -
-
-
- - - Internal name for the - programme -
- - - -
- -
-
- <% }); %> - <% } %> -
-
-
-
- - -
-
-
-
-
Recent Submissions
-
- - -
- -
- - -
-
- - -
-
- -
- -
- -
- - - - - - - - - - - - - - <% if (locals.submissions && submissions.length> 0) { %> - <% submissions.forEach(submission=> { %> - - - - - - - - - - <% }); %> - <% } else { %> - - - - <% } %> - -
DateNameEmailPhoneMessageStatusAction
- <%= new - Date(submission.createdAt).toLocaleDateString() - %> - <%= new - Date(submission.createdAt).toLocaleTimeString([], - {hour: '2-digit' , minute:'2-digit'}) %> - - <%= submission.name %> - - <%= submission.email %> - - <%= submission.phone || '-' %> - -
- <%= submission.message %> -
-
- <% let statusClass='bg-secondary' ; - if(submission.status==='pending' ) - statusClass='bg-warning text-dark' ; - if(submission.status==='read' ) - statusClass='bg-info text-dark' ; - if(submission.status==='replied' ) - statusClass='bg-success' ; - if(submission.status==='archived' ) - statusClass='bg-secondary' ; %> - - <%= submission.status %> - - - -
No - submissions found
-
-
- Showing last 50 submissions -
-
-
-
-
-
-
- - -
- - -
-
-
+
+ <% if (frontendUrl) { %> + + View Contact Page + + <% } %>
-
+
- -