forked from UKSOURCE/cms.lams
Merge branch 'fea/dat-24042026-submission-management' of https://gits.techvanguard.vn/UKSOURCE/cms.lams into merge/toan-24042026
This commit is contained in:
@@ -0,0 +1,194 @@
|
|||||||
|
const NewsletterSubscription = require("../models/newsletterSubscription");
|
||||||
|
|
||||||
|
const emailPattern = /^\S+@\S+\.\S+$/;
|
||||||
|
|
||||||
|
function cleanString(value) {
|
||||||
|
return String(value || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmail(value) {
|
||||||
|
return cleanString(value).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSubmissionShape(item) {
|
||||||
|
return {
|
||||||
|
_id: item._id,
|
||||||
|
source: "newsletter",
|
||||||
|
name: item.email,
|
||||||
|
email: item.email,
|
||||||
|
phone: "",
|
||||||
|
pageUrl: item.pageUrl || "",
|
||||||
|
status: item.status,
|
||||||
|
internalNote: item.internalNote || "",
|
||||||
|
ipAddress: item.ipAddress || "",
|
||||||
|
userAgent: item.userAgent || "",
|
||||||
|
payload: {
|
||||||
|
email: item.email,
|
||||||
|
},
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildListQuery(query) {
|
||||||
|
const mongoQuery = {};
|
||||||
|
const search = cleanString(query.search);
|
||||||
|
const status = cleanString(query.status);
|
||||||
|
|
||||||
|
if (status && NewsletterSubscription.STATUSES.includes(status)) {
|
||||||
|
mongoQuery.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.startDate || query.endDate) {
|
||||||
|
mongoQuery.createdAt = {};
|
||||||
|
if (query.startDate) {
|
||||||
|
mongoQuery.createdAt.$gte = new Date(query.startDate);
|
||||||
|
}
|
||||||
|
if (query.endDate) {
|
||||||
|
const end = new Date(query.endDate);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
mongoQuery.createdAt.$lte = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
mongoQuery.email = new RegExp(
|
||||||
|
search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||||
|
"i",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mongoQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.subscribe = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const email = normalizeEmail(req.body.email);
|
||||||
|
|
||||||
|
if (!email || !emailPattern.test(email)) {
|
||||||
|
return res.status(400).json({ success: false, error: "Please enter a valid email" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await NewsletterSubscription.findOneAndUpdate(
|
||||||
|
{ email },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
email,
|
||||||
|
status: "subscribed",
|
||||||
|
pageUrl: cleanString(req.body.pageUrl) || "/",
|
||||||
|
ipAddress: req.ip || req.connection?.remoteAddress || "",
|
||||||
|
userAgent: req.get("User-Agent") || "",
|
||||||
|
},
|
||||||
|
$setOnInsert: {
|
||||||
|
internalNote: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ new: true, upsert: true, runValidators: true, setDefaultsOnInsert: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "Subscription received",
|
||||||
|
data: {
|
||||||
|
id: subscription._id,
|
||||||
|
email: subscription.email,
|
||||||
|
status: subscription.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === "ValidationError") {
|
||||||
|
const errors = Object.values(error.errors).map((item) => item.message);
|
||||||
|
return res.status(400).json({ success: false, error: errors.join(", ") });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 11000) {
|
||||||
|
return res.status(409).json({ success: false, error: "Email is already subscribed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("newsletter.subscribe error:", error);
|
||||||
|
return res.status(500).json({ success: false, error: "Error subscribing email" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.list = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = Math.max(parseInt(req.query.page, 10) || 1, 1);
|
||||||
|
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const query = buildListQuery(req.query);
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
NewsletterSubscription.find(query)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.lean(),
|
||||||
|
NewsletterSubscription.countDocuments(query),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: items.map(toSubmissionShape),
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit) || 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("newsletter.list error:", error);
|
||||||
|
return res.status(500).json({ success: false, error: "Error loading subscriptions" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.detail = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const subscription = await NewsletterSubscription.findById(req.params.id).lean();
|
||||||
|
if (!subscription) {
|
||||||
|
return res.status(404).json({ success: false, error: "Subscription not found" });
|
||||||
|
}
|
||||||
|
return res.json({ success: true, data: toSubmissionShape(subscription) });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("newsletter.detail error:", error);
|
||||||
|
return res.status(500).json({ success: false, error: "Error loading subscription" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const update = {};
|
||||||
|
const status = cleanString(req.body.status);
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
if (!NewsletterSubscription.STATUSES.includes(status)) {
|
||||||
|
return res.status(400).json({ success: false, error: "Invalid status" });
|
||||||
|
}
|
||||||
|
update.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.internalNote !== undefined) {
|
||||||
|
update.internalNote = cleanString(req.body.internalNote);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await NewsletterSubscription.findByIdAndUpdate(
|
||||||
|
req.params.id,
|
||||||
|
update,
|
||||||
|
{ new: true, runValidators: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return res.status(404).json({ success: false, error: "Subscription not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: toSubmissionShape(subscription) });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === "ValidationError") {
|
||||||
|
const errors = Object.values(error.errors).map((item) => item.message);
|
||||||
|
return res.status(400).json({ success: false, error: errors.join(", ") });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("newsletter.update error:", error);
|
||||||
|
return res.status(500).json({ success: false, error: "Error updating subscription" });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,6 +4,14 @@ const partnershipsConfig = require("../utils/contentEditors/partnershipsConfig")
|
|||||||
const createPageContentController = require("./_createPageContentController");
|
const createPageContentController = require("./_createPageContentController");
|
||||||
const { ensureUniqueIds } = require("../utils/contentEditorIds");
|
const { ensureUniqueIds } = require("../utils/contentEditorIds");
|
||||||
|
|
||||||
|
const FIXED_INQUIRY_FIELDS = [
|
||||||
|
{ id: "firstName", type: "text", width: "half", label: "First name", placeholder: "First name", required: true },
|
||||||
|
{ id: "lastName", type: "text", width: "half", label: "Last name", placeholder: "Last name", required: true },
|
||||||
|
{ id: "organization", type: "text", width: "full", label: "Organization name", placeholder: "Company or Institution", required: true },
|
||||||
|
{ id: "partnershipType", type: "select", width: "full", label: "Partnership type", placeholder: "Select partnership type", required: true },
|
||||||
|
{ id: "message", type: "textarea", width: "full", label: "Message", placeholder: "Tell us how you'd like to collaborate...", required: true },
|
||||||
|
];
|
||||||
|
|
||||||
function getTabConfig(tabKey) {
|
function getTabConfig(tabKey) {
|
||||||
return (partnershipsConfig.tabs || []).find((tab) => tab.key === tabKey) || {};
|
return (partnershipsConfig.tabs || []).find((tab) => tab.key === tabKey) || {};
|
||||||
}
|
}
|
||||||
@@ -85,7 +93,25 @@ function normalizeInquiryForm(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(data.inquiryForm.fields)) {
|
if (Array.isArray(data.inquiryForm.fields)) {
|
||||||
return data;
|
const fieldsById = new Map(data.inquiryForm.fields.map((field) => [field.id, field]));
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
inquiryForm: {
|
||||||
|
...data.inquiryForm,
|
||||||
|
fields: FIXED_INQUIRY_FIELDS.map((fixedField) => {
|
||||||
|
const field = fieldsById.get(fixedField.id) || {};
|
||||||
|
return {
|
||||||
|
id: fixedField.id,
|
||||||
|
label: field.label || fixedField.label,
|
||||||
|
placeholder: field.placeholder || fixedField.placeholder,
|
||||||
|
type: fixedField.type,
|
||||||
|
width: ["half", "full"].includes(field.width) ? field.width : fixedField.width,
|
||||||
|
required: field.required === undefined ? fixedField.required : Boolean(field.required),
|
||||||
|
options: Array.isArray(field.options) ? field.options.filter(Boolean) : [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyFields = data.inquiryForm.fields || {};
|
const legacyFields = data.inquiryForm.fields || {};
|
||||||
@@ -110,7 +136,7 @@ function normalizeInquiryForm(data) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareInquiryPayload(payload) {
|
function prepareInquiryPayload(payload, context = {}) {
|
||||||
const normalized = normalizeInquiryForm(payload);
|
const normalized = normalizeInquiryForm(payload);
|
||||||
const fields = Array.isArray(normalized?.inquiryForm?.fields)
|
const fields = Array.isArray(normalized?.inquiryForm?.fields)
|
||||||
? normalized.inquiryForm.fields
|
? normalized.inquiryForm.fields
|
||||||
@@ -144,6 +170,14 @@ function prepareInquiryPayload(payload) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fieldsById = new Map(fields.map((field) => [field.id, field]));
|
||||||
|
const beforeData = context.beforeData || {};
|
||||||
|
const beforeNormalized = normalizeInquiryForm(beforeData || {});
|
||||||
|
const beforeFields = Array.isArray(beforeNormalized?.inquiryForm?.fields)
|
||||||
|
? beforeNormalized.inquiryForm.fields
|
||||||
|
: [];
|
||||||
|
const beforeFieldsById = new Map(beforeFields.map((field) => [field.id, field]));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
directory: {
|
directory: {
|
||||||
@@ -157,20 +191,18 @@ function prepareInquiryPayload(payload) {
|
|||||||
},
|
},
|
||||||
inquiryForm: {
|
inquiryForm: {
|
||||||
...normalized.inquiryForm,
|
...normalized.inquiryForm,
|
||||||
fields: ensureUniqueIds(
|
fields: FIXED_INQUIRY_FIELDS.map((fixedField) => {
|
||||||
fields,
|
const field = fieldsById.get(fixedField.id) || beforeFieldsById.get(fixedField.id) || {};
|
||||||
(field) => field.id,
|
return {
|
||||||
(field, index) => field.label || field.placeholder || `field-${index + 1}`,
|
id: fixedField.id,
|
||||||
"field",
|
label: field.label || fixedField.label,
|
||||||
).map((field) => ({
|
placeholder: field.placeholder || fixedField.placeholder,
|
||||||
id: field.id,
|
type: fixedField.type,
|
||||||
label: field.label || "",
|
width: ["half", "full"].includes(field.width) ? field.width : fixedField.width,
|
||||||
placeholder: field.placeholder || "",
|
required: field.required === undefined ? fixedField.required : Boolean(field.required),
|
||||||
type: field.type || "text",
|
options: Array.isArray(field.options) ? field.options.filter(Boolean) : [],
|
||||||
width: field.width || "full",
|
};
|
||||||
required: Boolean(field.required),
|
}),
|
||||||
options: Array.isArray(field.options) ? field.options.filter(Boolean) : [],
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -195,6 +227,9 @@ controller.index = async function index(req, res) {
|
|||||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||||
const defaultTab = partnershipsConfig.tabs[0]?.key;
|
const defaultTab = partnershipsConfig.tabs[0]?.key;
|
||||||
const requestedTab = req.query.tab;
|
const requestedTab = req.query.tab;
|
||||||
|
if (requestedTab === "submissions") {
|
||||||
|
return res.redirect("/admin/submissions?tab=partnership");
|
||||||
|
}
|
||||||
const activeTab = partnershipsConfig.tabs.some((tab) => tab.key === requestedTab)
|
const activeTab = partnershipsConfig.tabs.some((tab) => tab.key === requestedTab)
|
||||||
? requestedTab
|
? requestedTab
|
||||||
: defaultTab;
|
: defaultTab;
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
const Submission = require("../models/submission");
|
||||||
|
const NewsletterSubscription = require("../models/newsletterSubscription");
|
||||||
|
|
||||||
|
const SOURCE_LABELS = {
|
||||||
|
home: "Home",
|
||||||
|
request: "Request",
|
||||||
|
contact: "Contact",
|
||||||
|
partnership: "Partnerships",
|
||||||
|
newsletter: "Newsletter",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_URLS = {
|
||||||
|
home: "/",
|
||||||
|
request: "/request",
|
||||||
|
contact: "/contact",
|
||||||
|
partnership: "/about/partnerships",
|
||||||
|
newsletter: "Footer",
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanString(value) {
|
||||||
|
return String(value || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFirst(payload, keys) {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = cleanString(payload?.[key]);
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildName(payload) {
|
||||||
|
const directName = pickFirst(payload, ["name", "fullName", "full_name"]);
|
||||||
|
if (directName) return directName;
|
||||||
|
|
||||||
|
const firstName = pickFirst(payload, ["firstName", "first_name"]);
|
||||||
|
const lastName = pickFirst(payload, ["lastName", "last_name"]);
|
||||||
|
const combined = [firstName, lastName].filter(Boolean).join(" ").trim();
|
||||||
|
if (combined) return combined;
|
||||||
|
|
||||||
|
return pickFirst(payload, ["organization", "organisation", "company"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePayload(body) {
|
||||||
|
const payload =
|
||||||
|
body && typeof body.payload === "object" && body.payload !== null
|
||||||
|
? body.payload
|
||||||
|
: body || {};
|
||||||
|
|
||||||
|
return Object.entries(payload).reduce((acc, [key, value]) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
acc[key] = value.trim();
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
acc[key] = value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildListQuery(query) {
|
||||||
|
const mongoQuery = {};
|
||||||
|
const source = cleanString(query.source);
|
||||||
|
const status = cleanString(query.status);
|
||||||
|
const search = cleanString(query.search);
|
||||||
|
|
||||||
|
if (source && Submission.SOURCES.includes(source)) {
|
||||||
|
mongoQuery.source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && Submission.STATUSES.includes(status)) {
|
||||||
|
mongoQuery.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.startDate || query.endDate) {
|
||||||
|
mongoQuery.createdAt = {};
|
||||||
|
if (query.startDate) {
|
||||||
|
mongoQuery.createdAt.$gte = new Date(query.startDate);
|
||||||
|
}
|
||||||
|
if (query.endDate) {
|
||||||
|
const end = new Date(query.endDate);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
mongoQuery.createdAt.$lte = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const regex = new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
|
||||||
|
mongoQuery.$or = [
|
||||||
|
{ name: regex },
|
||||||
|
{ email: regex },
|
||||||
|
{ phone: regex },
|
||||||
|
{ "payload.firstName": regex },
|
||||||
|
{ "payload.lastName": regex },
|
||||||
|
{ "payload.organization": regex },
|
||||||
|
{ "payload.partnershipType": regex },
|
||||||
|
{ "payload.message": regex },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return mongoQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
const sources = ["home", "request", "contact", "partnership", "newsletter"];
|
||||||
|
const requestedTab = cleanString(req.query.tab);
|
||||||
|
const activeSource = sources.includes(requestedTab) ? requestedTab : sources[0];
|
||||||
|
|
||||||
|
res.render("admin/submissions/index", {
|
||||||
|
layout: "layouts/main",
|
||||||
|
title: "Submission Management",
|
||||||
|
subtitle: "Review and manage website form submissions",
|
||||||
|
sources,
|
||||||
|
activeSource,
|
||||||
|
sourceLabels: SOURCE_LABELS,
|
||||||
|
sourceUrls: SOURCE_URLS,
|
||||||
|
statuses: Submission.STATUSES,
|
||||||
|
newsletterStatuses: NewsletterSubscription.STATUSES,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.create = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const source = cleanString(req.body.source);
|
||||||
|
const payload = normalizePayload(req.body);
|
||||||
|
|
||||||
|
if (!Submission.SOURCES.includes(source)) {
|
||||||
|
return res.status(400).json({ success: false, error: "Invalid source" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = buildName(payload);
|
||||||
|
const email = pickFirst(payload, ["email", "email_address"]);
|
||||||
|
const phone = pickFirst(payload, ["phone", "phone_number"]);
|
||||||
|
|
||||||
|
if (source !== "partnership" && !email) {
|
||||||
|
return res.status(400).json({ success: false, error: "Email is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const submission = await Submission.create({
|
||||||
|
source,
|
||||||
|
pageUrl: cleanString(req.body.pageUrl) || SOURCE_URLS[source] || "",
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
payload,
|
||||||
|
ipAddress: req.ip || req.connection?.remoteAddress || "",
|
||||||
|
userAgent: req.get("User-Agent") || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "Submission received",
|
||||||
|
data: {
|
||||||
|
id: submission._id,
|
||||||
|
source: submission.source,
|
||||||
|
status: submission.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === "ValidationError") {
|
||||||
|
const errors = Object.values(error.errors).map((item) => item.message);
|
||||||
|
return res.status(400).json({ success: false, error: errors.join(", ") });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("submission.create error:", error);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, error: "Error submitting form" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.list = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = Math.max(parseInt(req.query.page, 10) || 1, 1);
|
||||||
|
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const query = buildListQuery(req.query);
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
Submission.find(query).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(),
|
||||||
|
Submission.countDocuments(query),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: items,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit) || 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("submission.list error:", error);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, error: "Error loading submissions" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.detail = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const submission = await Submission.findById(req.params.id).lean();
|
||||||
|
if (!submission) {
|
||||||
|
return res.status(404).json({ success: false, error: "Submission not found" });
|
||||||
|
}
|
||||||
|
return res.json({ success: true, data: submission });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("submission.detail error:", error);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, error: "Error loading submission" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const update = {};
|
||||||
|
const status = cleanString(req.body.status);
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
if (!Submission.STATUSES.includes(status)) {
|
||||||
|
return res.status(400).json({ success: false, error: "Invalid status" });
|
||||||
|
}
|
||||||
|
update.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.internalNote !== undefined) {
|
||||||
|
update.internalNote = cleanString(req.body.internalNote);
|
||||||
|
}
|
||||||
|
|
||||||
|
const submission = await Submission.findByIdAndUpdate(req.params.id, update, {
|
||||||
|
new: true,
|
||||||
|
runValidators: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!submission) {
|
||||||
|
return res.status(404).json({ success: false, error: "Submission not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: submission });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === "ValidationError") {
|
||||||
|
const errors = Object.values(error.errors).map((item) => item.message);
|
||||||
|
return res.status(400).json({ success: false, error: errors.join(", ") });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("submission.update error:", error);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, error: "Error updating submission" });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
const STATUSES = ["subscribed", "unsubscribed"];
|
||||||
|
const emailPattern = /^\S+@\S+\.\S+$/;
|
||||||
|
|
||||||
|
const newsletterSubscriptionSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
required: [true, "Email is required"],
|
||||||
|
trim: true,
|
||||||
|
lowercase: true,
|
||||||
|
validate: {
|
||||||
|
validator(value) {
|
||||||
|
return emailPattern.test(value);
|
||||||
|
},
|
||||||
|
message: "Please enter a valid email",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: STATUSES,
|
||||||
|
default: "subscribed",
|
||||||
|
},
|
||||||
|
pageUrl: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
ipAddress: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
userAgent: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
internalNote: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
maxlength: [2000, "Internal note cannot exceed 2000 characters"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collection: "newsletter_subscriptions",
|
||||||
|
timestamps: true,
|
||||||
|
autoCreate: false,
|
||||||
|
autoIndex: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
newsletterSubscriptionSchema.statics.STATUSES = STATUSES;
|
||||||
|
|
||||||
|
module.exports = mongoose.model(
|
||||||
|
"NewsletterSubscription",
|
||||||
|
newsletterSubscriptionSchema,
|
||||||
|
);
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
const SOURCES = ["home", "request", "contact", "partnership"];
|
||||||
|
const STATUSES = ["new", "contacted", "info_provided", "closed"];
|
||||||
|
|
||||||
|
const emailPattern = /^\S+@\S+\.\S+$/;
|
||||||
|
const phonePattern = /^[+()\d\s.-]{7,20}$/;
|
||||||
|
|
||||||
|
const submissionSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
source: {
|
||||||
|
type: String,
|
||||||
|
enum: SOURCES,
|
||||||
|
required: [true, "Source is required"],
|
||||||
|
},
|
||||||
|
pageUrl: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: [true, "Name is required"],
|
||||||
|
trim: true,
|
||||||
|
maxlength: [160, "Name cannot exceed 160 characters"],
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
trim: true,
|
||||||
|
lowercase: true,
|
||||||
|
validate: {
|
||||||
|
validator(value) {
|
||||||
|
return !value || emailPattern.test(value);
|
||||||
|
},
|
||||||
|
message: "Please enter a valid email",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
validate: {
|
||||||
|
validator(value) {
|
||||||
|
return !value || phonePattern.test(value);
|
||||||
|
},
|
||||||
|
message: "Please enter a valid phone number",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
type: mongoose.Schema.Types.Mixed,
|
||||||
|
default: {},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: STATUSES,
|
||||||
|
default: "new",
|
||||||
|
},
|
||||||
|
internalNote: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
maxlength: [2000, "Internal note cannot exceed 2000 characters"],
|
||||||
|
},
|
||||||
|
ipAddress: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
userAgent: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collection: "submissions",
|
||||||
|
timestamps: true,
|
||||||
|
autoCreate: false,
|
||||||
|
autoIndex: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
submissionSchema.statics.SOURCES = SOURCES;
|
||||||
|
submissionSchema.statics.STATUSES = STATUSES;
|
||||||
|
|
||||||
|
module.exports = mongoose.model("Submission", submissionSchema);
|
||||||
@@ -19,6 +19,8 @@ const formController = require("../controllers/formController");
|
|||||||
const contactController = require("../controllers/contactController");
|
const contactController = require("../controllers/contactController");
|
||||||
const studentSupportController = require("../controllers/studentSupportController");
|
const studentSupportController = require("../controllers/studentSupportController");
|
||||||
const requestInfoController = require("../controllers/requestInfoController");
|
const requestInfoController = require("../controllers/requestInfoController");
|
||||||
|
const submissionController = require("../controllers/submissionController");
|
||||||
|
const newsletterSubscriptionController = require("../controllers/newsletterSubscriptionController");
|
||||||
|
|
||||||
const pageController = require("../controllers/pageController");
|
const pageController = require("../controllers/pageController");
|
||||||
const settingController = require("../controllers/settingController");
|
const settingController = require("../controllers/settingController");
|
||||||
@@ -199,6 +201,27 @@ router.put(
|
|||||||
contactController.updateSubmissionStatus,
|
contactController.updateSubmissionStatus,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Unified submission management
|
||||||
|
router.get("/submissions", ensureAuthenticated, submissionController.index);
|
||||||
|
router.get("/submissions/data", ensureAuthenticated, submissionController.list);
|
||||||
|
router.get("/submissions/:id", ensureAuthenticated, submissionController.detail);
|
||||||
|
router.patch("/submissions/:id", ensureAuthenticated, submissionController.update);
|
||||||
|
router.get(
|
||||||
|
"/newsletter-subscriptions/data",
|
||||||
|
ensureAuthenticated,
|
||||||
|
newsletterSubscriptionController.list,
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/newsletter-subscriptions/:id",
|
||||||
|
ensureAuthenticated,
|
||||||
|
newsletterSubscriptionController.detail,
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/newsletter-subscriptions/:id",
|
||||||
|
ensureAuthenticated,
|
||||||
|
newsletterSubscriptionController.update,
|
||||||
|
);
|
||||||
|
|
||||||
// Student Support (LAMS static page)
|
// Student Support (LAMS static page)
|
||||||
router.get(
|
router.get(
|
||||||
"/student-support",
|
"/student-support",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const footerController = require("../controllers/footerController");
|
|||||||
const contactController = require("../controllers/contactController");
|
const contactController = require("../controllers/contactController");
|
||||||
const studentSupportController = require("../controllers/studentSupportController");
|
const studentSupportController = require("../controllers/studentSupportController");
|
||||||
const requestInfoController = require("../controllers/requestInfoController");
|
const requestInfoController = require("../controllers/requestInfoController");
|
||||||
|
const submissionController = require("../controllers/submissionController");
|
||||||
|
const newsletterSubscriptionController = require("../controllers/newsletterSubscriptionController");
|
||||||
|
|
||||||
const headerMenuController = require("../controllers/headerMenuController");
|
const headerMenuController = require("../controllers/headerMenuController");
|
||||||
const programmeController = require("../controllers/programmeController");
|
const programmeController = require("../controllers/programmeController");
|
||||||
@@ -64,6 +66,8 @@ router.get("/api/request-info", requestInfoController.api);
|
|||||||
|
|
||||||
// Contact form submission (public)
|
// Contact form submission (public)
|
||||||
router.post("/api/contact/submit", contactController.submitForm);
|
router.post("/api/contact/submit", contactController.submitForm);
|
||||||
|
router.post("/api/submissions", submissionController.create);
|
||||||
|
router.post("/api/newsletter/subscribe", newsletterSubscriptionController.subscribe);
|
||||||
|
|
||||||
// Blog API Routes
|
// Blog API Routes
|
||||||
router.get("/api/blog", blogController.api);
|
router.get("/api/blog", blogController.api);
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
const connectDB = require("../config/database");
|
||||||
|
|
||||||
|
const COLLECTION_NAME = "submissions";
|
||||||
|
|
||||||
|
const validator = {
|
||||||
|
$jsonSchema: {
|
||||||
|
bsonType: "object",
|
||||||
|
required: ["source", "name", "status", "createdAt", "updatedAt"],
|
||||||
|
properties: {
|
||||||
|
source: {
|
||||||
|
enum: ["home", "request", "contact", "partnership"],
|
||||||
|
description: "Source must be one of the supported submission sources",
|
||||||
|
},
|
||||||
|
pageUrl: {
|
||||||
|
bsonType: "string",
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
bsonType: "string",
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 160,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
bsonType: "string",
|
||||||
|
pattern: "^$|^\\S+@\\S+\\.\\S+$",
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
bsonType: "string",
|
||||||
|
pattern: "^$|^[+()\\d\\s.-]{7,20}$",
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
bsonType: "object",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
enum: ["new", "contacted", "info_provided", "closed"],
|
||||||
|
},
|
||||||
|
internalNote: {
|
||||||
|
bsonType: "string",
|
||||||
|
maxLength: 2000,
|
||||||
|
},
|
||||||
|
ipAddress: {
|
||||||
|
bsonType: "string",
|
||||||
|
},
|
||||||
|
userAgent: {
|
||||||
|
bsonType: "string",
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
bsonType: "date",
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
bsonType: "date",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function ensureCollection(db) {
|
||||||
|
const collections = await db
|
||||||
|
.listCollections({ name: COLLECTION_NAME }, { nameOnly: true })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
if (collections.length === 0) {
|
||||||
|
await db.createCollection(COLLECTION_NAME, {
|
||||||
|
validator,
|
||||||
|
validationLevel: "moderate",
|
||||||
|
validationAction: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.command({
|
||||||
|
collMod: COLLECTION_NAME,
|
||||||
|
validator,
|
||||||
|
validationLevel: "moderate",
|
||||||
|
validationAction: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureIndexes(collection) {
|
||||||
|
await dropLegacyIndex(collection, "source_1");
|
||||||
|
await dropLegacyIndex(collection, "status_1");
|
||||||
|
|
||||||
|
await collection.createIndex(
|
||||||
|
{ source: 1, status: 1, createdAt: -1 },
|
||||||
|
{ name: "source_1_status_1_createdAt_-1" },
|
||||||
|
);
|
||||||
|
await collection.createIndex({ email: 1 }, { name: "email_1" });
|
||||||
|
await collection.createIndex(
|
||||||
|
{ name: "text", email: "text", phone: "text" },
|
||||||
|
{ name: "name_text_email_text_phone_text" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropLegacyIndex(collection, name) {
|
||||||
|
try {
|
||||||
|
await collection.dropIndex(name);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.codeName !== "IndexNotFound") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
const db = mongoose.connection.db;
|
||||||
|
await ensureCollection(db);
|
||||||
|
await ensureIndexes(db.collection(COLLECTION_NAME));
|
||||||
|
console.log("Submissions collection migration completed successfully");
|
||||||
|
await mongoose.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Submissions migration error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { migrate };
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
const connectDB = require("../config/database");
|
||||||
|
|
||||||
|
const COLLECTION_NAME = "newsletter_subscriptions";
|
||||||
|
|
||||||
|
const validator = {
|
||||||
|
$jsonSchema: {
|
||||||
|
bsonType: "object",
|
||||||
|
required: ["email", "status", "createdAt", "updatedAt"],
|
||||||
|
properties: {
|
||||||
|
email: {
|
||||||
|
bsonType: "string",
|
||||||
|
pattern: "^\\S+@\\S+\\.\\S+$",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
enum: ["subscribed", "unsubscribed"],
|
||||||
|
},
|
||||||
|
pageUrl: {
|
||||||
|
bsonType: "string",
|
||||||
|
},
|
||||||
|
ipAddress: {
|
||||||
|
bsonType: "string",
|
||||||
|
},
|
||||||
|
userAgent: {
|
||||||
|
bsonType: "string",
|
||||||
|
},
|
||||||
|
internalNote: {
|
||||||
|
bsonType: "string",
|
||||||
|
maxLength: 2000,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
bsonType: "date",
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
bsonType: "date",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function ensureCollection(db) {
|
||||||
|
const collections = await db
|
||||||
|
.listCollections({ name: COLLECTION_NAME }, { nameOnly: true })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
if (collections.length === 0) {
|
||||||
|
await db.createCollection(COLLECTION_NAME, {
|
||||||
|
validator,
|
||||||
|
validationLevel: "moderate",
|
||||||
|
validationAction: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.command({
|
||||||
|
collMod: COLLECTION_NAME,
|
||||||
|
validator,
|
||||||
|
validationLevel: "moderate",
|
||||||
|
validationAction: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureIndexes(collection) {
|
||||||
|
await collection.createIndex({ email: 1 }, { name: "email_1", unique: true });
|
||||||
|
await collection.createIndex(
|
||||||
|
{ status: 1, createdAt: -1 },
|
||||||
|
{ name: "status_1_createdAt_-1" },
|
||||||
|
);
|
||||||
|
await collection.createIndex({ createdAt: -1 }, { name: "createdAt_-1" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
const db = mongoose.connection.db;
|
||||||
|
await ensureCollection(db);
|
||||||
|
await ensureIndexes(db.collection(COLLECTION_NAME));
|
||||||
|
console.log("Newsletter subscriptions migration completed successfully");
|
||||||
|
await mongoose.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Newsletter subscriptions migration error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { migrate };
|
||||||
@@ -144,7 +144,7 @@ app.use((req, res, next) => {
|
|||||||
|
|
||||||
if (isOriginAllowed) {
|
if (isOriginAllowed) {
|
||||||
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
|
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||||
res.setHeader("Access-Control-Allow-Credentials", "true");
|
res.setHeader("Access-Control-Allow-Credentials", "true");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,11 +55,20 @@
|
|||||||
|
|
||||||
<%- include("partials/templates", { editorUi }) %>
|
<%- include("partials/templates", { editorUi }) %>
|
||||||
|
|
||||||
|
<script type="application/json" id="partnershipsPageBootstrapJson"><%- JSON.stringify({
|
||||||
|
data,
|
||||||
|
backendUrl,
|
||||||
|
editorConfig,
|
||||||
|
editorUi,
|
||||||
|
}) %></script>
|
||||||
<script>
|
<script>
|
||||||
window.partnershipsPageData = <%- JSON.stringify(data) %>;
|
const partnershipsPageBootstrap = JSON.parse(
|
||||||
window.partnershipsBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
document.getElementById("partnershipsPageBootstrapJson").textContent || "{}",
|
||||||
window.partnershipsEditorConfig = <%- JSON.stringify(editorConfig) %>;
|
);
|
||||||
window.partnershipsEditorUi = <%- JSON.stringify(editorUi) %>;
|
window.partnershipsPageData = partnershipsPageBootstrap.data;
|
||||||
|
window.partnershipsBackendUrl = partnershipsPageBootstrap.backendUrl;
|
||||||
|
window.partnershipsEditorConfig = partnershipsPageBootstrap.editorConfig;
|
||||||
|
window.partnershipsEditorUi = partnershipsPageBootstrap.editorUi;
|
||||||
</script>
|
</script>
|
||||||
<%- include("partials/editor-script") %>
|
<%- include("partials/editor-script") %>
|
||||||
|
|
||||||
|
|||||||
@@ -97,12 +97,6 @@
|
|||||||
renderPartners();
|
renderPartners();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () {
|
|
||||||
state.inquiryForm.fields.push(getDefaultInquiryField());
|
|
||||||
ensurePartnershipIds();
|
|
||||||
renderInquiryFields();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("resetPartnershipsForm")?.addEventListener("click", function () {
|
document.getElementById("resetPartnershipsForm")?.addEventListener("click", function () {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
@@ -378,7 +372,7 @@
|
|||||||
input.value = field[key] || "";
|
input.value = field[key] || "";
|
||||||
input.addEventListener("input", function () {
|
input.addEventListener("input", function () {
|
||||||
field[key] = input.value;
|
field[key] = input.value;
|
||||||
if (key === "label") {
|
if (key === "label") {
|
||||||
title.textContent = input.value || `${inquiryFieldFields.label?.label || "Field"} ${index + 1}`;
|
title.textContent = input.value || `${inquiryFieldFields.label?.label || "Field"} ${index + 1}`;
|
||||||
}
|
}
|
||||||
if (key === "type") {
|
if (key === "type") {
|
||||||
@@ -401,11 +395,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
node.querySelector("[data-remove-item]").addEventListener("click", function () {
|
|
||||||
state.inquiryForm.fields.splice(index, 1);
|
|
||||||
renderInquiryFields();
|
|
||||||
});
|
|
||||||
|
|
||||||
node.querySelector("[data-add-option]").addEventListener("click", function () {
|
node.querySelector("[data-add-option]").addEventListener("click", function () {
|
||||||
field.options = Array.isArray(field.options) ? field.options : [];
|
field.options = Array.isArray(field.options) ? field.options : [];
|
||||||
field.options.push("");
|
field.options.push("");
|
||||||
@@ -444,7 +433,6 @@
|
|||||||
inquiryFieldsList.appendChild(node);
|
inquiryFieldsList.appendChild(node);
|
||||||
});
|
});
|
||||||
|
|
||||||
initSortable(inquiryFieldsList, state.inquiryForm.fields, renderInquiryFields, '[data-item="inquiry-field"]');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initSortable(container, list, rerender, draggableSelector) {
|
function initSortable(container, list, rerender, draggableSelector) {
|
||||||
|
|||||||
@@ -16,17 +16,13 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label fw-semibold mb-1"><%= inquiryUi.fields?.label || "Form fields" %></label>
|
<label class="form-label fw-semibold mb-1"><%= inquiryUi.fields?.label || "Form fields" %></label>
|
||||||
<div class="form-text mt-0"><%= inquiryUi.fieldsHelpText || inquiryUi.fields?.helpText || "" %></div>
|
<div class="form-text mt-0">
|
||||||
|
Field structure is fixed to match the inquiry form: First Name, Last Name, Organization Name, Partnership Type, and Message. You can edit field text, required state, width, and Partnership Type select options.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="inquiryFieldsList"></div>
|
<div id="inquiryFieldsList"></div>
|
||||||
<button type="button" class="cms-add-button mt-3" id="addInquiryFieldBtn">
|
|
||||||
<i class="fas fa-plus me-2"></i><%= inquiryUi.fields?.addLabel || "Add Field" %>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -100,9 +100,6 @@
|
|||||||
<div class="card cms-item-card mb-3" data-item="inquiry-field">
|
<div class="card cms-item-card mb-3" data-item="inquiry-field">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<button type="button" class="drag-handle" title="Drag to reorder">
|
|
||||||
<i class="fas fa-grip-vertical"></i>
|
|
||||||
</button>
|
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-semibold" data-title>Field</div>
|
<div class="fw-semibold" data-title>Field</div>
|
||||||
<div class="small text-muted" data-subtitle></div>
|
<div class="small text-muted" data-subtitle></div>
|
||||||
@@ -112,9 +109,6 @@
|
|||||||
<button type="button" class="cms-collapse-toggle" data-toggle-item title="Collapse item">
|
<button type="button" class="cms-collapse-toggle" data-toggle-item title="Collapse item">
|
||||||
<i class="fas fa-chevron-down"></i>
|
<i class="fas fa-chevron-down"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="cms-remove-button" data-remove-item title="Remove item">
|
|
||||||
<i class="fas fa-trash-alt"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -129,7 +123,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label fw-semibold"><%= inquiryFieldFields.type?.label || "Field type" %></label>
|
<label class="form-label fw-semibold"><%= inquiryFieldFields.type?.label || "Field type" %></label>
|
||||||
<select class="form-select" data-field="type">
|
<select class="form-select" data-field="type" disabled>
|
||||||
<% (inquiryFieldFields.type?.options || []).forEach((option) => { %>
|
<% (inquiryFieldFields.type?.options || []).forEach((option) => { %>
|
||||||
<option value="<%= option.value %>"><%= option.label %></option>
|
<option value="<%= option.value %>"><%= option.label %></option>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
@@ -144,10 +138,10 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 d-flex align-items-end">
|
<div class="col-md-4 d-flex align-items-end">
|
||||||
<div class="form-check mb-2">
|
<div class="form-check mb-2">
|
||||||
<input class="form-check-input" type="checkbox" data-field="required">
|
<input class="form-check-input" type="checkbox" data-field="required">
|
||||||
<label class="form-check-label fw-semibold"><%= inquiryFieldFields.required?.label || "Required field" %></label>
|
<label class="form-check-label fw-semibold"><%= inquiryFieldFields.required?.label || "Required field" %></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12" data-options-wrap>
|
<div class="col-12" data-options-wrap>
|
||||||
<div class="cms-editor-group">
|
<div class="cms-editor-group">
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<div class="container my-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
||||||
|
<p class="text-muted mb-0"><%= subtitle %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-bottom">
|
||||||
|
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||||
|
<% sources.forEach((source) => { %>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button
|
||||||
|
class="nav-link <%= activeSource === source ? 'active' : '' %>"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
data-submission-tab="<%= source %>"
|
||||||
|
>
|
||||||
|
<%= sourceLabels[source] %>
|
||||||
|
<small class="text-muted">(<%= sourceUrls[source] %>)</small>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 align-items-end mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small text-muted">Search</label>
|
||||||
|
<input type="search" class="form-control" id="submissionSearch" placeholder="Name, email, phone">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small text-muted">Status</label>
|
||||||
|
<select class="form-select" id="submissionStatus">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<% statuses.forEach((status) => { %>
|
||||||
|
<option value="<%= status %>"><%= status.replace("_", " ") %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small text-muted">Start Date</label>
|
||||||
|
<input type="date" class="form-control" id="submissionStartDate">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small text-muted">End Date</label>
|
||||||
|
<input type="date" class="form-control" id="submissionEndDate">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label class="form-label small text-muted">Show</label>
|
||||||
|
<select class="form-select" id="submissionPageSize">
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="20" selected>20</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-primary flex-fill" id="submissionApplyFilters">
|
||||||
|
<i class="fas fa-filter me-1"></i>Filter
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="submissionClearFilters">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th data-submission-name-column>Name</th>
|
||||||
|
<th data-submission-email-column>Email</th>
|
||||||
|
<th data-submission-phone-column>Phone</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="submissionRows">
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center text-muted py-5">Loading submissions...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||||
|
<small class="text-muted" id="submissionPaginationSummary"></small>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="submissionPrevPage">Previous</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="submissionNextPage">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include("partials/submission-modal", { statuses }) %>
|
||||||
|
|
||||||
|
<script type="application/json" id="submissionManagerConfigJson"><%- JSON.stringify({
|
||||||
|
sources,
|
||||||
|
statuses,
|
||||||
|
statusesBySource: {
|
||||||
|
newsletter: newsletterStatuses,
|
||||||
|
},
|
||||||
|
initialSource: activeSource,
|
||||||
|
urlTabParam: true,
|
||||||
|
hiddenFieldsBySource: {
|
||||||
|
partnership: {
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
},
|
||||||
|
newsletter: {
|
||||||
|
name: true,
|
||||||
|
phone: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
apiBySource: {
|
||||||
|
newsletter: {
|
||||||
|
list: "/admin/newsletter-subscriptions/data",
|
||||||
|
detail: "/admin/newsletter-subscriptions",
|
||||||
|
update: "/admin/newsletter-subscriptions",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
itemLabelBySource: {
|
||||||
|
newsletter: "subscriptions",
|
||||||
|
},
|
||||||
|
}) %></script>
|
||||||
|
<script>
|
||||||
|
window.submissionManagerConfig = JSON.parse(
|
||||||
|
document.getElementById("submissionManagerConfigJson").textContent || "{}",
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
<%- include("partials/submission-manager-script") %>
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const config = window.submissionManagerConfig || {};
|
||||||
|
const sources = config.sources || ["home", "request", "contact"];
|
||||||
|
const statuses = config.statuses || [];
|
||||||
|
const statusesBySource = config.statusesBySource || {};
|
||||||
|
const hiddenFieldsBySource = config.hiddenFieldsBySource || {};
|
||||||
|
const apiBySource = config.apiBySource || {};
|
||||||
|
const itemLabelBySource = config.itemLabelBySource || {};
|
||||||
|
const rows = document.getElementById("submissionRows");
|
||||||
|
const searchInput = document.getElementById("submissionSearch");
|
||||||
|
const statusInput = document.getElementById("submissionStatus");
|
||||||
|
const startDateInput = document.getElementById("submissionStartDate");
|
||||||
|
const endDateInput = document.getElementById("submissionEndDate");
|
||||||
|
const pageSizeInput = document.getElementById("submissionPageSize");
|
||||||
|
const summary = document.getElementById("submissionPaginationSummary");
|
||||||
|
const prevButton = document.getElementById("submissionPrevPage");
|
||||||
|
const nextButton = document.getElementById("submissionNextPage");
|
||||||
|
const saveButton = document.getElementById("submissionSaveDetail");
|
||||||
|
const detailModalNode = document.getElementById("submissionDetailModal");
|
||||||
|
if (detailModalNode && detailModalNode.parentElement !== document.body) {
|
||||||
|
document.body.appendChild(detailModalNode);
|
||||||
|
}
|
||||||
|
const detailModal = detailModalNode ? new bootstrap.Modal(detailModalNode) : null;
|
||||||
|
const state = {
|
||||||
|
source: config.initialSource || sources[0] || "home",
|
||||||
|
page: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
limit: parseInt(pageSizeInput?.value, 10) || 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
applyFieldVisibility();
|
||||||
|
renderStatusFilterOptions();
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-submission-tab]").forEach((tab) => {
|
||||||
|
tab.addEventListener("click", function () {
|
||||||
|
document.querySelectorAll("[data-submission-tab]").forEach((item) => item.classList.remove("active"));
|
||||||
|
tab.classList.add("active");
|
||||||
|
state.source = tab.dataset.submissionTab;
|
||||||
|
state.page = 1;
|
||||||
|
updateUrlTab(state.source);
|
||||||
|
applyFieldVisibility();
|
||||||
|
renderStatusFilterOptions();
|
||||||
|
loadSubmissions();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("submissionApplyFilters")?.addEventListener("click", function () {
|
||||||
|
state.page = 1;
|
||||||
|
loadSubmissions();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("submissionClearFilters")?.addEventListener("click", function () {
|
||||||
|
if (searchInput) searchInput.value = "";
|
||||||
|
if (statusInput) statusInput.value = "";
|
||||||
|
if (startDateInput) startDateInput.value = "";
|
||||||
|
if (endDateInput) endDateInput.value = "";
|
||||||
|
state.page = 1;
|
||||||
|
loadSubmissions();
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput?.addEventListener("keydown", function (event) {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
state.page = 1;
|
||||||
|
loadSubmissions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
prevButton?.addEventListener("click", function () {
|
||||||
|
if (state.page > 1) {
|
||||||
|
state.page -= 1;
|
||||||
|
loadSubmissions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nextButton?.addEventListener("click", function () {
|
||||||
|
if (state.page < state.totalPages) {
|
||||||
|
state.page += 1;
|
||||||
|
loadSubmissions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pageSizeInput?.addEventListener("change", function () {
|
||||||
|
state.limit = parseInt(pageSizeInput.value, 10) || 20;
|
||||||
|
state.page = 1;
|
||||||
|
loadSubmissions();
|
||||||
|
});
|
||||||
|
|
||||||
|
saveButton?.addEventListener("click", saveDetail);
|
||||||
|
|
||||||
|
async function loadSubmissions() {
|
||||||
|
if (!rows) return;
|
||||||
|
rows.innerHTML = `<tr><td colspan="${getTableColumnCount()}" class="text-center text-muted py-5">Loading submissions...</td></tr>`;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
source: state.source,
|
||||||
|
page: String(state.page),
|
||||||
|
limit: String(state.limit),
|
||||||
|
});
|
||||||
|
if (searchInput?.value) params.set("search", searchInput.value);
|
||||||
|
if (statusInput?.value) params.set("status", statusInput.value);
|
||||||
|
if (startDateInput?.value) params.set("startDate", startDateInput.value);
|
||||||
|
if (endDateInput?.value) params.set("endDate", endDateInput.value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getListUrl()}?${params.toString()}`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.error || "Unable to load submissions");
|
||||||
|
}
|
||||||
|
state.totalPages = result.pagination.totalPages || 1;
|
||||||
|
renderRows(result.data || []);
|
||||||
|
renderPagination(result.pagination);
|
||||||
|
} catch (error) {
|
||||||
|
rows.innerHTML = `<tr><td colspan="${getTableColumnCount()}" class="text-center text-danger py-5">${escapeHtml(error.message)}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows(items) {
|
||||||
|
if (!items.length) {
|
||||||
|
rows.innerHTML = `<tr><td colspan="${getTableColumnCount()}" class="text-center text-muted py-5">No submissions found</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.innerHTML = items.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td>${formatDate(item.createdAt)}</td>
|
||||||
|
${isNameHidden() ? "" : `<td>${escapeHtml(item.name || "-")}</td>`}
|
||||||
|
${isEmailHidden() ? "" : `<td><a href="mailto:${escapeHtml(item.email || "")}">${escapeHtml(item.email || "-")}</a></td>`}
|
||||||
|
${isPhoneHidden() ? "" : `<td>${escapeHtml(item.phone || "-")}</td>`}
|
||||||
|
<td><span class="badge ${statusClass(item.status)} rounded-pill">${escapeHtml((item.status || "").replace("_", " "))}</span></td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" data-view-submission="${item._id}">
|
||||||
|
<i class="fas fa-eye me-1"></i>View
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
rows.querySelectorAll("[data-view-submission]").forEach((button) => {
|
||||||
|
button.addEventListener("click", function () {
|
||||||
|
openDetail(button.dataset.viewSubmission);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination(pagination) {
|
||||||
|
if (summary) {
|
||||||
|
summary.textContent = `Page ${pagination.page} of ${pagination.totalPages || 1} - ${pagination.total} ${getItemLabel()}`;
|
||||||
|
}
|
||||||
|
if (prevButton) prevButton.disabled = pagination.page <= 1;
|
||||||
|
if (nextButton) nextButton.disabled = pagination.page >= (pagination.totalPages || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getDetailBaseUrl()}/${id}`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.error || "Unable to load submission");
|
||||||
|
}
|
||||||
|
fillDetail(result.data);
|
||||||
|
detailModal?.show();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillDetail(item) {
|
||||||
|
setText("submissionDetailMeta", `${item.source || ""} - ${formatDate(item.createdAt)}`);
|
||||||
|
setValue("submissionDetailId", item._id);
|
||||||
|
renderDetailStatusOptions(item.source, item.status);
|
||||||
|
setValue("submissionDetailNote", item.internalNote || "");
|
||||||
|
|
||||||
|
const email = document.getElementById("submissionDetailEmail");
|
||||||
|
applyFieldVisibility(item.source);
|
||||||
|
|
||||||
|
if (item.source === "partnership") {
|
||||||
|
setText("submissionDetailNameLabel", getPayloadLabel("firstName", item.source));
|
||||||
|
setText("submissionDetailName", item.payload?.firstName || "-");
|
||||||
|
if (!isEmailHidden(item.source)) {
|
||||||
|
setText("submissionDetailEmailLabel", getPayloadLabel("lastName", item.source));
|
||||||
|
if (email) {
|
||||||
|
email.textContent = item.payload?.lastName || "-";
|
||||||
|
email.href = "#";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isPhoneHidden(item.source)) {
|
||||||
|
setText("submissionDetailPhoneLabel", getPayloadLabel("organization", item.source));
|
||||||
|
setText("submissionDetailPhone", item.payload?.organization || "-");
|
||||||
|
}
|
||||||
|
setText("submissionDetailPageLabel", getPayloadLabel("partnershipType", item.source));
|
||||||
|
setText("submissionDetailPage", item.payload?.partnershipType || "-");
|
||||||
|
} else {
|
||||||
|
setText("submissionDetailNameLabel", "Name");
|
||||||
|
setText("submissionDetailName", item.name || "-");
|
||||||
|
setText("submissionDetailEmailLabel", "Email");
|
||||||
|
if (email) {
|
||||||
|
email.textContent = item.email || "-";
|
||||||
|
email.href = item.email ? `mailto:${item.email}` : "#";
|
||||||
|
}
|
||||||
|
setText("submissionDetailPhoneLabel", "Phone");
|
||||||
|
setText("submissionDetailPhone", item.phone || "-");
|
||||||
|
setText("submissionDetailPageLabel", "Page");
|
||||||
|
setText("submissionDetailPage", item.pageUrl || "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = document.getElementById("submissionPayload");
|
||||||
|
if (payload) {
|
||||||
|
payload.innerHTML = getPayloadEntries(item).map(([key, value]) => `
|
||||||
|
<div class="row py-1 border-bottom">
|
||||||
|
<div class="col-md-4 text-muted">${escapeHtml(getPayloadLabel(key, item.source))}</div>
|
||||||
|
<div class="col-md-8">${escapeHtml(formatValue(value))}</div>
|
||||||
|
</div>
|
||||||
|
`).join("") || '<span class="text-muted">No payload values</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTableColumnCount() {
|
||||||
|
return 6 - (isNameHidden() ? 1 : 0) - (isEmailHidden() ? 1 : 0) - (isPhoneHidden() ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFieldVisibility(source) {
|
||||||
|
const nameHidden = isNameHidden(source);
|
||||||
|
const emailHidden = isEmailHidden(source);
|
||||||
|
const phoneHidden = isPhoneHidden(source);
|
||||||
|
document.querySelectorAll("[data-submission-name-column], [data-submission-name-field]").forEach((node) => {
|
||||||
|
node.classList.toggle("d-none", nameHidden);
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-submission-email-column], [data-submission-email-field]").forEach((node) => {
|
||||||
|
node.classList.toggle("d-none", emailHidden);
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-submission-phone-column], [data-submission-phone-field]").forEach((node) => {
|
||||||
|
node.classList.toggle("d-none", phoneHidden);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNameHidden(source) {
|
||||||
|
return Boolean(hiddenFieldsBySource[source || state.source]?.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmailHidden(source) {
|
||||||
|
return Boolean(hiddenFieldsBySource[source || state.source]?.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPhoneHidden(source) {
|
||||||
|
return Boolean(hiddenFieldsBySource[source || state.source]?.phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatuses(source) {
|
||||||
|
return statusesBySource[source || state.source] || statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatusFilterOptions() {
|
||||||
|
if (!statusInput) return;
|
||||||
|
const previousValue = statusInput.value;
|
||||||
|
statusInput.innerHTML = '<option value="">All statuses</option>';
|
||||||
|
getStatuses().forEach((status) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = status;
|
||||||
|
option.textContent = status.replace("_", " ");
|
||||||
|
statusInput.appendChild(option);
|
||||||
|
});
|
||||||
|
statusInput.value = getStatuses().includes(previousValue) ? previousValue : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetailStatusOptions(source, selectedStatus) {
|
||||||
|
const node = document.getElementById("submissionDetailStatus");
|
||||||
|
if (!node) return;
|
||||||
|
node.innerHTML = "";
|
||||||
|
getStatuses(source).forEach((status) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = status;
|
||||||
|
option.textContent = status.replace("_", " ");
|
||||||
|
node.appendChild(option);
|
||||||
|
});
|
||||||
|
node.value = selectedStatus || getStatuses(source)[0] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getListUrl() {
|
||||||
|
return apiBySource[state.source]?.list || "/admin/submissions/data";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDetailBaseUrl() {
|
||||||
|
return apiBySource[state.source]?.detail || "/admin/submissions";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUpdateBaseUrl() {
|
||||||
|
return apiBySource[state.source]?.update || "/admin/submissions";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemLabel() {
|
||||||
|
return itemLabelBySource[state.source] || "submissions";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUrlTab(tabKey) {
|
||||||
|
if (!config.urlTabParam || !tabKey) return;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("tab", tabKey);
|
||||||
|
window.history.replaceState({}, "", `${url.pathname}?${url.searchParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDetail() {
|
||||||
|
const id = document.getElementById("submissionDetailId")?.value;
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getUpdateBaseUrl()}/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: document.getElementById("submissionDetailStatus")?.value,
|
||||||
|
internalNote: document.getElementById("submissionDetailNote")?.value || "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.error || "Unable to update submission");
|
||||||
|
}
|
||||||
|
detailModal?.hide();
|
||||||
|
loadSubmissions();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(id, value) {
|
||||||
|
const node = document.getElementById(id);
|
||||||
|
if (node) node.textContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setValue(id, value) {
|
||||||
|
const node = document.getElementById(id);
|
||||||
|
if (node) node.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return "-";
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKey(value) {
|
||||||
|
return String(value || "").replace(/_/g, " ").replace(/([A-Z])/g, " $1").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayloadEntries(item) {
|
||||||
|
const payload = item.payload || {};
|
||||||
|
if (item.source !== "partnership") {
|
||||||
|
return Object.entries(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedKeys = getPartnershipFieldOrder();
|
||||||
|
return orderedKeys
|
||||||
|
.filter((key) => Object.prototype.hasOwnProperty.call(payload, key))
|
||||||
|
.map((key) => [key, payload[key]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayloadLabel(key, source) {
|
||||||
|
if (source === "partnership") {
|
||||||
|
const field = getPartnershipFields().find((item) => item.id === key);
|
||||||
|
if (field?.label) {
|
||||||
|
return field.label;
|
||||||
|
}
|
||||||
|
const fallback = {
|
||||||
|
firstName: "First Name",
|
||||||
|
lastName: "Last Name",
|
||||||
|
organization: "Organization Name",
|
||||||
|
partnershipType: "Partnership Type",
|
||||||
|
message: "Message",
|
||||||
|
};
|
||||||
|
return fallback[key] || formatKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPartnershipFieldOrder() {
|
||||||
|
const fields = getPartnershipFields();
|
||||||
|
if (fields.length > 0) {
|
||||||
|
return fields.map((field) => field.id);
|
||||||
|
}
|
||||||
|
return ["firstName", "lastName", "organization", "partnershipType", "message"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPartnershipFields() {
|
||||||
|
return Array.isArray(window.partnershipsPageData?.inquiryForm?.fields)
|
||||||
|
? window.partnershipsPageData.inquiryForm.fields
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value) {
|
||||||
|
if (Array.isArray(value)) return value.join(", ");
|
||||||
|
if (value && typeof value === "object") return JSON.stringify(value);
|
||||||
|
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||||
|
return value == null || value === "" ? "-" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status) {
|
||||||
|
if (status === "new") return "bg-warning text-dark";
|
||||||
|
if (status === "contacted") return "bg-info text-dark";
|
||||||
|
if (status === "info_provided") return "bg-success";
|
||||||
|
if (status === "closed") return "bg-secondary";
|
||||||
|
if (status === "subscribed") return "bg-success";
|
||||||
|
if (status === "unsubscribed") return "bg-secondary";
|
||||||
|
return "bg-light text-dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSubmissions();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<div class="modal fade" id="submissionDetailModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable" style="max-height: calc(100vh - 3.5rem);">
|
||||||
|
<div class="modal-content" style="max-height: calc(100vh - 3.5rem);">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<h5 class="modal-title mb-0">Submission Detail</h5>
|
||||||
|
<small class="text-muted" id="submissionDetailMeta"></small>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="overflow-y: auto;">
|
||||||
|
<input type="hidden" id="submissionDetailId">
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6" data-submission-name-field>
|
||||||
|
<label class="form-label small text-muted" id="submissionDetailNameLabel">Name</label>
|
||||||
|
<div class="fw-semibold" id="submissionDetailName">-</div>
|
||||||
|
</div>
|
||||||
|
<%# Hidden by Partnerships submissions because that inquiry form has no email field. %>
|
||||||
|
<div class="col-md-6" data-submission-email-field>
|
||||||
|
<label class="form-label small text-muted" id="submissionDetailEmailLabel">Email</label>
|
||||||
|
<div><a id="submissionDetailEmail" href="#">-</a></div>
|
||||||
|
</div>
|
||||||
|
<%# Hidden by Partnerships submissions because that inquiry form has no phone field. %>
|
||||||
|
<div class="col-md-6" data-submission-phone-field>
|
||||||
|
<label class="form-label small text-muted" id="submissionDetailPhoneLabel">Phone</label>
|
||||||
|
<div id="submissionDetailPhone">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small text-muted" id="submissionDetailPageLabel">Page</label>
|
||||||
|
<div id="submissionDetailPage">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label small text-muted">Submitted Values</label>
|
||||||
|
<div class="border rounded p-3 bg-light" id="submissionPayload"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
<select class="form-select" id="submissionDetailStatus">
|
||||||
|
<% statuses.forEach((status) => { %>
|
||||||
|
<option value="<%= status %>"><%= status.replace("_", " ") %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Internal note</label>
|
||||||
|
<textarea class="form-control" id="submissionDetailNote" rows="4" maxlength="2000" placeholder="Add internal processing notes"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="submissionSaveDetail">
|
||||||
|
<i class="fas fa-save me-1"></i>Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1107,9 +1107,15 @@
|
|||||||
<a class="nav-link <%= currentPath === '/admin/contact' ? 'active' : '' %>" href="/admin/contact">Contact
|
<a class="nav-link <%= currentPath === '/admin/contact' ? 'active' : '' %>" href="/admin/contact">Contact
|
||||||
Us</a>
|
Us</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link <%= currentPath === '/admin/submissions' ? 'active' : '' %>"
|
||||||
|
href="/admin/submissions">Submission
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link <%= currentPath === '/admin/audit-logs' ? 'active' : '' %>"
|
<a class="nav-link <%= currentPath === '/admin/request-info' ? 'active' : '' %>"
|
||||||
href="/admin/request-info">Request Info
|
href="/admin/request-info">Request Info
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -1525,4 +1531,4 @@
|
|||||||
<%- script %>
|
<%- script %>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user