Merge branch 'develop' into fea/dat-20042026-CMS-Partnerships-Accreditation-History-Admissions-Policies

This commit is contained in:
Tống Thành Đạt
2026-04-21 12:33:11 +07:00
31 changed files with 4477 additions and 2325 deletions
+131 -364
View File
@@ -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;
}
};
+78
View File
@@ -0,0 +1,78 @@
const mongoose = require("mongoose");
const courseSchema = new mongoose.Schema(
{
id: { type: String, default: "" },
title: { type: String, default: "" },
description: { type: String, default: "" },
icon: { type: String, default: "" },
},
{ _id: false }
);
const outcomeSchema = new mongoose.Schema(
{
title: { type: String, default: "" },
description: { type: String, default: "" },
icon: { type: String, default: "" },
},
{ _id: false }
);
const faqSchema = new mongoose.Schema(
{
question: { type: String, default: "" },
answer: { type: String, default: "" },
},
{ _id: false }
);
const programmeSchema = new mongoose.Schema(
{
id: { type: String, required: true, unique: true, trim: true },
level: { type: String, default: "" },
title: { type: String, default: "" },
description: { type: String, default: "" },
duration: { type: String, default: "" },
cost: { type: String, default: "" },
selected: { type: Boolean, default: false },
link: { type: String, default: "" },
shortName: { type: String, default: "" },
detailBadge: { type: String, default: "" },
detailTitle: { type: String, default: "" },
detailDescription: { type: String, default: "" },
heroImage: { type: String, default: "" },
overview: { type: String, default: "" },
credits: { type: Number, default: 0 },
format: { type: String, default: "100% Online" },
nextStartDate: { type: String, default: "" },
monthlyCost: { type: String, default: "" },
perCourseCost: { type: String, default: "" },
coreCourses: { type: [courseSchema], default: [] },
electives: { type: [String], default: [] },
outcomes: { type: [outcomeSchema], default: [] },
faqs: { type: [faqSchema], default: [] },
},
{ timestamps: true }
);
/**
* Upsert from a JSON array. Used by migration scripts.
*/
programmeSchema.statics.migrateFromJson = async function (jsonArray) {
const results = [];
for (const item of jsonArray) {
const existing = await this.findOne({ id: item.id });
if (existing) {
existing.set(item);
await existing.save();
results.push({ action: "updated", id: item.id });
} else {
await this.create(item);
results.push({ action: "created", id: item.id });
}
}
return results;
};
module.exports = mongoose.model("Programme", programmeSchema);
+138
View File
@@ -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);
+148
View File
@@ -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);