Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e4bc70d03 | ||
|
|
dfebf532cf |
0
.perm_test_apache
Normal file
0
.perm_test_apache
Normal file
0
.perm_test_exec
Normal file
0
.perm_test_exec
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await queryInterface.sequelize.query(
|
||||||
|
"SELECT to_regclass('public.student_inquiries') AS regclass_name;",
|
||||||
|
{
|
||||||
|
transaction,
|
||||||
|
type: Sequelize.QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows[0].regclass_name) {
|
||||||
|
await transaction.commit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryInterface.createTable(
|
||||||
|
'student_inquiries',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
defaultValue: Sequelize.DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
leadCode: {
|
||||||
|
type: Sequelize.DataTypes.STRING(32),
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
studentName: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
guardianName: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
whatsappNumber: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
gradeLevel: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
campusPreference: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
interestTrack: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
referralSource: {
|
||||||
|
type: Sequelize.DataTypes.ENUM,
|
||||||
|
values: ['website', 'instagram', 'whatsapp', 'parent_referral', 'school_event', 'other'],
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'website',
|
||||||
|
},
|
||||||
|
referralName: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
preferredContactTime: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: Sequelize.DataTypes.ENUM,
|
||||||
|
values: ['new', 'contacted', 'demo_scheduled', 'application_started', 'enrolled', 'lost'],
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'new',
|
||||||
|
},
|
||||||
|
meetingAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
demoVideoUrl: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
counselorNotes: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
lastContactedAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
deletedAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
createdById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
},
|
||||||
|
updatedById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await queryInterface.sequelize.query(
|
||||||
|
"SELECT to_regclass('public.student_inquiries') AS regclass_name;",
|
||||||
|
{
|
||||||
|
transaction,
|
||||||
|
type: Sequelize.QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows[0].regclass_name) {
|
||||||
|
await transaction.commit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryInterface.dropTable('student_inquiries', { transaction });
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
'DROP TYPE IF EXISTS "enum_student_inquiries_referralSource";',
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
'DROP TYPE IF EXISTS "enum_student_inquiries_status";',
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,189 @@
|
|||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await queryInterface.sequelize.query(
|
||||||
|
"SELECT to_regclass('public.marketing_campaigns') AS regclass_name;",
|
||||||
|
{
|
||||||
|
transaction,
|
||||||
|
type: Sequelize.QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows[0].regclass_name) {
|
||||||
|
await transaction.commit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
'DROP TYPE IF EXISTS "enum_marketing_campaigns_status";',
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryInterface.createTable(
|
||||||
|
'marketing_campaigns',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
defaultValue: Sequelize.DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
campaignName: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
campaignType: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'تسجيل الطلاب',
|
||||||
|
},
|
||||||
|
objective: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
audience: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
gradeFocus: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
platform: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
tone: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
offer: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
callToAction: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
publishingDate: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
generatedContent: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
weeklyPlan: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
designerNotes: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
videoScript: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
messageTemplate: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
assetNames: {
|
||||||
|
type: Sequelize.DataTypes.JSONB,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: [],
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: Sequelize.DataTypes.ENUM,
|
||||||
|
values: ['draft', 'ready', 'scheduled', 'published', 'archived'],
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'draft',
|
||||||
|
},
|
||||||
|
isAutoGenerated: {
|
||||||
|
type: Sequelize.DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
lastGeneratedAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
deletedAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
createdById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
},
|
||||||
|
updatedById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await queryInterface.sequelize.query(
|
||||||
|
"SELECT to_regclass('public.marketing_campaigns') AS regclass_name;",
|
||||||
|
{
|
||||||
|
transaction,
|
||||||
|
type: Sequelize.QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows[0].regclass_name) {
|
||||||
|
await transaction.commit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryInterface.dropTable('marketing_campaigns', { transaction });
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
'DROP TYPE IF EXISTS "enum_marketing_campaigns_status";',
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await queryInterface.sequelize.query(
|
||||||
|
"SELECT to_regclass('public.marketing_schedules') AS regclass_name;",
|
||||||
|
{
|
||||||
|
transaction,
|
||||||
|
type: Sequelize.QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows[0].regclass_name) {
|
||||||
|
await transaction.commit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
'DROP TYPE IF EXISTS "enum_marketing_schedules_status";',
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryInterface.createTable(
|
||||||
|
'marketing_schedules',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
defaultValue: Sequelize.DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
publishAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: Sequelize.DataTypes.ENUM,
|
||||||
|
values: ['draft', 'scheduled', 'published', 'cancelled'],
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'scheduled',
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
type: Sequelize.DataTypes.JSONB,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: {},
|
||||||
|
},
|
||||||
|
campaignId: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'marketing_campaigns',
|
||||||
|
},
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
deletedAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
createdById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
},
|
||||||
|
updatedById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await queryInterface.sequelize.query(
|
||||||
|
"SELECT to_regclass('public.marketing_schedules') AS regclass_name;",
|
||||||
|
{
|
||||||
|
transaction,
|
||||||
|
type: Sequelize.QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows[0].regclass_name) {
|
||||||
|
await transaction.commit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryInterface.dropTable('marketing_schedules', { transaction });
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
'DROP TYPE IF EXISTS "enum_marketing_schedules_status";',
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,148 @@
|
|||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await queryInterface.sequelize.query(
|
||||||
|
"SELECT to_regclass('public.marketing_referrals') AS regclass_name;",
|
||||||
|
{
|
||||||
|
transaction,
|
||||||
|
type: Sequelize.QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows[0].regclass_name) {
|
||||||
|
await transaction.commit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
'DROP TYPE IF EXISTS "enum_marketing_referrals_status";',
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryInterface.createTable(
|
||||||
|
'marketing_referrals',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
defaultValue: Sequelize.DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
referrerName: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
referrerPhone: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
studentName: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
studentGrade: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
discountAmount: {
|
||||||
|
type: Sequelize.DataTypes.DECIMAL(10, 2),
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
rewardNotes: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: Sequelize.DataTypes.ENUM,
|
||||||
|
values: ['new', 'qualified', 'rewarded', 'cancelled'],
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'new',
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
inquiryId: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'student_inquiries',
|
||||||
|
},
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
deletedAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
createdById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
},
|
||||||
|
updatedById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await queryInterface.sequelize.query(
|
||||||
|
"SELECT to_regclass('public.marketing_referrals') AS regclass_name;",
|
||||||
|
{
|
||||||
|
transaction,
|
||||||
|
type: Sequelize.QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows[0].regclass_name) {
|
||||||
|
await transaction.commit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryInterface.dropTable('marketing_referrals', { transaction });
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
'DROP TYPE IF EXISTS "enum_marketing_referrals_status";',
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await queryInterface.sequelize.query(
|
||||||
|
"SELECT to_regclass('public.marketing_templates') AS regclass_name;",
|
||||||
|
{
|
||||||
|
transaction,
|
||||||
|
type: Sequelize.QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows[0].regclass_name) {
|
||||||
|
await transaction.commit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryInterface.createTable(
|
||||||
|
'marketing_templates',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
defaultValue: Sequelize.DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
previewText: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
isDefault: {
|
||||||
|
type: Sequelize.DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
deletedAt: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
createdById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
},
|
||||||
|
updatedById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await queryInterface.sequelize.query(
|
||||||
|
"SELECT to_regclass('public.marketing_templates') AS regclass_name;",
|
||||||
|
{
|
||||||
|
transaction,
|
||||||
|
type: Sequelize.QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows[0].regclass_name) {
|
||||||
|
await transaction.commit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryInterface.dropTable('marketing_templates', { transaction });
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
119
backend/src/db/models/marketing_campaigns.js
Normal file
119
backend/src/db/models/marketing_campaigns.js
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const marketing_campaigns = sequelize.define(
|
||||||
|
'marketing_campaigns',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
campaignName: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
campaignType: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'تسجيل الطلاب',
|
||||||
|
},
|
||||||
|
objective: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
audience: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
gradeFocus: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
platform: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
tone: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
offer: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
callToAction: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
publishingDate: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
generatedContent: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
weeklyPlan: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
designerNotes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
videoScript: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
messageTemplate: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
assetNames: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: [],
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM,
|
||||||
|
values: ['draft', 'ready', 'scheduled', 'published', 'archived'],
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'draft',
|
||||||
|
},
|
||||||
|
isAutoGenerated: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
lastGeneratedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: true,
|
||||||
|
freezeTableName: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
marketing_campaigns.associate = (db) => {
|
||||||
|
db.marketing_campaigns.hasMany(db.marketing_schedules, {
|
||||||
|
as: 'schedules',
|
||||||
|
foreignKey: 'campaignId',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.marketing_campaigns.belongsTo(db.users, {
|
||||||
|
as: 'createdBy',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.marketing_campaigns.belongsTo(db.users, {
|
||||||
|
as: 'updatedBy',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return marketing_campaigns;
|
||||||
|
};
|
||||||
68
backend/src/db/models/marketing_referrals.js
Normal file
68
backend/src/db/models/marketing_referrals.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const marketing_referrals = sequelize.define(
|
||||||
|
'marketing_referrals',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
referrerName: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
referrerPhone: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
studentName: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
studentGrade: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
discountAmount: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
rewardNotes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM,
|
||||||
|
values: ['new', 'qualified', 'rewarded', 'cancelled'],
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'new',
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: true,
|
||||||
|
freezeTableName: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
marketing_referrals.associate = (db) => {
|
||||||
|
db.marketing_referrals.belongsTo(db.student_inquiries, {
|
||||||
|
as: 'inquiry',
|
||||||
|
foreignKey: 'inquiryId',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.marketing_referrals.belongsTo(db.users, {
|
||||||
|
as: 'createdBy',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.marketing_referrals.belongsTo(db.users, {
|
||||||
|
as: 'updatedBy',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return marketing_referrals;
|
||||||
|
};
|
||||||
65
backend/src/db/models/marketing_schedules.js
Normal file
65
backend/src/db/models/marketing_schedules.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const marketing_schedules = sequelize.define(
|
||||||
|
'marketing_schedules',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
publishAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM,
|
||||||
|
values: ['draft', 'scheduled', 'published', 'cancelled'],
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'scheduled',
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: true,
|
||||||
|
freezeTableName: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
marketing_schedules.associate = (db) => {
|
||||||
|
db.marketing_schedules.belongsTo(db.marketing_campaigns, {
|
||||||
|
as: 'campaign',
|
||||||
|
foreignKey: 'campaignId',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.marketing_schedules.belongsTo(db.users, {
|
||||||
|
as: 'createdBy',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.marketing_schedules.belongsTo(db.users, {
|
||||||
|
as: 'updatedBy',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return marketing_schedules;
|
||||||
|
};
|
||||||
54
backend/src/db/models/marketing_templates.js
Normal file
54
backend/src/db/models/marketing_templates.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const marketing_templates = sequelize.define(
|
||||||
|
'marketing_templates',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
previewText: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
isDefault: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: true,
|
||||||
|
freezeTableName: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
marketing_templates.associate = (db) => {
|
||||||
|
db.marketing_templates.belongsTo(db.users, {
|
||||||
|
as: 'createdBy',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.marketing_templates.belongsTo(db.users, {
|
||||||
|
as: 'updatedBy',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return marketing_templates;
|
||||||
|
};
|
||||||
98
backend/src/db/models/student_inquiries.js
Normal file
98
backend/src/db/models/student_inquiries.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
module.exports = function (sequelize, DataTypes) {
|
||||||
|
const student_inquiries = sequelize.define(
|
||||||
|
'student_inquiries',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
leadCode: {
|
||||||
|
type: DataTypes.STRING(32),
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
studentName: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
guardianName: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
whatsappNumber: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
gradeLevel: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
campusPreference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
interestTrack: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
referralSource: {
|
||||||
|
type: DataTypes.ENUM,
|
||||||
|
values: ['website', 'instagram', 'whatsapp', 'parent_referral', 'school_event', 'other'],
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'website',
|
||||||
|
},
|
||||||
|
referralName: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
preferredContactTime: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM,
|
||||||
|
values: ['new', 'contacted', 'demo_scheduled', 'application_started', 'enrolled', 'lost'],
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'new',
|
||||||
|
},
|
||||||
|
meetingAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
demoVideoUrl: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
counselorNotes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
lastContactedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: true,
|
||||||
|
freezeTableName: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
student_inquiries.associate = (db) => {
|
||||||
|
db.student_inquiries.belongsTo(db.users, {
|
||||||
|
as: 'createdBy',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.student_inquiries.belongsTo(db.users, {
|
||||||
|
as: 'updatedBy',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return student_inquiries;
|
||||||
|
};
|
||||||
@ -6,7 +6,6 @@ const passport = require('passport');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
const db = require('./db/models');
|
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const swaggerUI = require('swagger-ui-express');
|
const swaggerUI = require('swagger-ui-express');
|
||||||
const swaggerJsDoc = require('swagger-jsdoc');
|
const swaggerJsDoc = require('swagger-jsdoc');
|
||||||
@ -38,6 +37,8 @@ const audit_logsRoutes = require('./routes/audit_logs');
|
|||||||
const app_settingsRoutes = require('./routes/app_settings');
|
const app_settingsRoutes = require('./routes/app_settings');
|
||||||
|
|
||||||
const api_keysRoutes = require('./routes/api_keys');
|
const api_keysRoutes = require('./routes/api_keys');
|
||||||
|
const studentInquiriesRoutes = require('./routes/student_inquiries');
|
||||||
|
const schoolMarketingRoutes = require('./routes/school_marketing');
|
||||||
|
|
||||||
|
|
||||||
const getBaseUrl = (url) => {
|
const getBaseUrl = (url) => {
|
||||||
@ -114,6 +115,8 @@ app.use('/api/audit_logs', passport.authenticate('jwt', {session: false}), audit
|
|||||||
app.use('/api/app_settings', passport.authenticate('jwt', {session: false}), app_settingsRoutes);
|
app.use('/api/app_settings', passport.authenticate('jwt', {session: false}), app_settingsRoutes);
|
||||||
|
|
||||||
app.use('/api/api_keys', passport.authenticate('jwt', {session: false}), api_keysRoutes);
|
app.use('/api/api_keys', passport.authenticate('jwt', {session: false}), api_keysRoutes);
|
||||||
|
app.use('/api/student-inquiries', studentInquiriesRoutes);
|
||||||
|
app.use('/api/school-marketing', schoolMarketingRoutes);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
'/api/openai',
|
'/api/openai',
|
||||||
|
|||||||
609
backend/src/routes/school_marketing.js
Normal file
609
backend/src/routes/school_marketing.js
Normal file
@ -0,0 +1,609 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const passport = require('passport');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
const db = require('../db/models');
|
||||||
|
const Helpers = require('../helpers');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const wrapAsync = Helpers.wrapAsync;
|
||||||
|
|
||||||
|
const DEFAULT_TEMPLATES = [
|
||||||
|
{
|
||||||
|
name: 'ترحيب واتساب لأولياء الأمور',
|
||||||
|
category: 'واتساب',
|
||||||
|
channel: 'واتساب',
|
||||||
|
content: 'مرحبًا بكم في {{schoolName}}. يسعدنا استقبالكم وتعريفكم ببرامجنا التعليمية. هل يناسبكم موعد زيارة هذا الأسبوع؟',
|
||||||
|
previewText: 'رسالة أول تواصل سريعة ومحترفة.',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'إعلان التسجيل المبكر',
|
||||||
|
category: 'إعلان',
|
||||||
|
channel: 'إنستغرام',
|
||||||
|
content: 'ابدأ مستقبل طفلك مع {{schoolName}}. التسجيل المبكر متاح الآن مع مزايا خاصة ومقاعد محدودة.',
|
||||||
|
previewText: 'نص إعلان قصير للحملات المدفوعة.',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'سكربت فيديو تعريفي',
|
||||||
|
category: 'فيديو',
|
||||||
|
channel: 'فيديو',
|
||||||
|
content: 'المشهد 1: لقطات للمدرسة. المشهد 2: الأنشطة. المشهد 3: رسالة ثقة للأهالي. المشهد 4: دعوة لحجز زيارة.',
|
||||||
|
previewText: 'هيكل أولي لفيديو قصير.',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CAMPAIGN_STATUSES = ['draft', 'ready', 'scheduled', 'published', 'archived'];
|
||||||
|
const SCHEDULE_STATUSES = ['draft', 'scheduled', 'published', 'cancelled'];
|
||||||
|
const REFERRAL_STATUSES = ['new', 'qualified', 'rewarded', 'cancelled'];
|
||||||
|
|
||||||
|
function makeBadRequest(message) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = 400;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNotFound(message) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = 404;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(value) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNullableText(value) {
|
||||||
|
const cleaned = cleanText(value);
|
||||||
|
return cleaned || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDate(value, fieldName, required = false) {
|
||||||
|
const cleaned = cleanText(value);
|
||||||
|
|
||||||
|
if (!cleaned) {
|
||||||
|
if (required) {
|
||||||
|
throw makeBadRequest(`${fieldName} is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(cleaned);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
throw makeBadRequest(`${fieldName} is invalid`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAssetNames(value) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((item) => cleanText(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCampaignPayload(data = {}) {
|
||||||
|
const campaignName = cleanText(data.campaignName);
|
||||||
|
if (!campaignName) {
|
||||||
|
throw makeBadRequest('Campaign name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = cleanText(data.status) || 'draft';
|
||||||
|
if (!CAMPAIGN_STATUSES.includes(status)) {
|
||||||
|
throw makeBadRequest('Campaign status is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
campaignName,
|
||||||
|
campaignType: cleanText(data.campaignType) || 'تسجيل الطلاب',
|
||||||
|
objective: normalizeNullableText(data.objective),
|
||||||
|
audience: normalizeNullableText(data.audience),
|
||||||
|
gradeFocus: normalizeNullableText(data.gradeFocus),
|
||||||
|
platform: normalizeNullableText(data.platform),
|
||||||
|
tone: normalizeNullableText(data.tone),
|
||||||
|
offer: normalizeNullableText(data.offer),
|
||||||
|
callToAction: normalizeNullableText(data.callToAction),
|
||||||
|
publishingDate: normalizeDate(data.publishingDate, 'Publishing date'),
|
||||||
|
notes: normalizeNullableText(data.notes),
|
||||||
|
generatedContent: normalizeNullableText(data.generatedContent),
|
||||||
|
weeklyPlan: normalizeNullableText(data.weeklyPlan),
|
||||||
|
designerNotes: normalizeNullableText(data.designerNotes),
|
||||||
|
videoScript: normalizeNullableText(data.videoScript),
|
||||||
|
messageTemplate: normalizeNullableText(data.messageTemplate),
|
||||||
|
assetNames: normalizeAssetNames(data.assetNames),
|
||||||
|
status,
|
||||||
|
isAutoGenerated: Boolean(data.isAutoGenerated),
|
||||||
|
lastGeneratedAt: data.generatedContent || data.weeklyPlan || data.videoScript ? new Date() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSchedulePayload(data = {}) {
|
||||||
|
const title = cleanText(data.title);
|
||||||
|
if (!title) {
|
||||||
|
throw makeBadRequest('Schedule title is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = cleanText(data.status) || 'scheduled';
|
||||||
|
if (!SCHEDULE_STATUSES.includes(status)) {
|
||||||
|
throw makeBadRequest('Schedule status is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
channel: normalizeNullableText(data.channel),
|
||||||
|
publishAt: normalizeDate(data.publishAt, 'Publish date', true),
|
||||||
|
type: normalizeNullableText(data.type),
|
||||||
|
status,
|
||||||
|
notes: normalizeNullableText(data.notes),
|
||||||
|
payload: typeof data.payload === 'object' && data.payload !== null ? data.payload : {},
|
||||||
|
campaignId: cleanText(data.campaignId) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTemplatePayload(data = {}) {
|
||||||
|
const name = cleanText(data.name);
|
||||||
|
const content = cleanText(data.content);
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
throw makeBadRequest('Template name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
throw makeBadRequest('Template content is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
category: normalizeNullableText(data.category),
|
||||||
|
channel: normalizeNullableText(data.channel),
|
||||||
|
content,
|
||||||
|
previewText: normalizeNullableText(data.previewText),
|
||||||
|
isDefault: Boolean(data.isDefault),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReferralPayload(data = {}) {
|
||||||
|
const referrerName = cleanText(data.referrerName);
|
||||||
|
const studentName = cleanText(data.studentName);
|
||||||
|
const status = cleanText(data.status) || 'new';
|
||||||
|
|
||||||
|
if (!referrerName) {
|
||||||
|
throw makeBadRequest('Referrer name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!studentName) {
|
||||||
|
throw makeBadRequest('Student name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!REFERRAL_STATUSES.includes(status)) {
|
||||||
|
throw makeBadRequest('Referral status is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
referrerName,
|
||||||
|
referrerPhone: normalizeNullableText(data.referrerPhone),
|
||||||
|
studentName,
|
||||||
|
studentGrade: normalizeNullableText(data.studentGrade),
|
||||||
|
discountAmount: data.discountAmount !== undefined && data.discountAmount !== null && `${data.discountAmount}` !== ''
|
||||||
|
? Number(data.discountAmount)
|
||||||
|
: null,
|
||||||
|
rewardNotes: normalizeNullableText(data.rewardNotes),
|
||||||
|
status,
|
||||||
|
notes: normalizeNullableText(data.notes),
|
||||||
|
inquiryId: cleanText(data.inquiryId) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDefaultTemplates(currentUserId) {
|
||||||
|
const count = await db.marketing_templates.count();
|
||||||
|
if (count > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await db.marketing_templates.bulkCreate(
|
||||||
|
DEFAULT_TEMPLATES.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
createdById: currentUserId || null,
|
||||||
|
updatedById: currentUserId || null,
|
||||||
|
createdAt: new Date(now.getTime() + index * 1000),
|
||||||
|
updatedAt: new Date(now.getTime() + index * 1000),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(passport.authenticate('jwt', { session: false }));
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/workspace',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
await ensureDefaultTemplates(req.currentUser?.id || null);
|
||||||
|
|
||||||
|
const [
|
||||||
|
summary,
|
||||||
|
inquiries,
|
||||||
|
campaigns,
|
||||||
|
schedules,
|
||||||
|
referrals,
|
||||||
|
templates,
|
||||||
|
latestDraft,
|
||||||
|
] = await Promise.all([
|
||||||
|
db.student_inquiries.count().then(async (total) => ({
|
||||||
|
total,
|
||||||
|
newLeads: await db.student_inquiries.count({ where: { status: 'new' } }),
|
||||||
|
scheduled: await db.student_inquiries.count({ where: { status: 'demo_scheduled' } }),
|
||||||
|
enrolled: await db.student_inquiries.count({ where: { status: 'enrolled' } }),
|
||||||
|
referrals: await db.marketing_referrals.count(),
|
||||||
|
campaigns: await db.marketing_campaigns.count(),
|
||||||
|
scheduledPosts: await db.marketing_schedules.count({ where: { status: 'scheduled' } }),
|
||||||
|
})),
|
||||||
|
db.student_inquiries.findAll({ order: [['createdAt', 'DESC']], limit: 100 }),
|
||||||
|
db.marketing_campaigns.findAll({ order: [['updatedAt', 'DESC']], limit: 20 }),
|
||||||
|
db.marketing_schedules.findAll({ order: [['publishAt', 'ASC']], limit: 20 }),
|
||||||
|
db.marketing_referrals.findAll({ order: [['createdAt', 'DESC']], limit: 20 }),
|
||||||
|
db.marketing_templates.findAll({ order: [['isDefault', 'DESC'], ['updatedAt', 'DESC']], limit: 20 }),
|
||||||
|
db.marketing_campaigns.findOne({
|
||||||
|
where: { status: 'draft' },
|
||||||
|
order: [['updatedAt', 'DESC']],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.status(200).send({
|
||||||
|
summary,
|
||||||
|
inquiries,
|
||||||
|
campaigns,
|
||||||
|
schedules,
|
||||||
|
referrals,
|
||||||
|
templates,
|
||||||
|
latestDraft,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/campaigns',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const status = cleanText(req.query.status);
|
||||||
|
const q = cleanText(req.query.q);
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
if (!CAMPAIGN_STATUSES.includes(status)) {
|
||||||
|
throw makeBadRequest('Campaign status filter is invalid');
|
||||||
|
}
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ campaignName: { [Op.iLike]: `%${q}%` } },
|
||||||
|
{ campaignType: { [Op.iLike]: `%${q}%` } },
|
||||||
|
{ platform: { [Op.iLike]: `%${q}%` } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.marketing_campaigns.findAll({
|
||||||
|
where,
|
||||||
|
order: [['updatedAt', 'DESC']],
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send({ rows, count: rows.length });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/campaigns',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const payload = buildCampaignPayload(req.body.data || {});
|
||||||
|
const campaign = await db.marketing_campaigns.create({
|
||||||
|
...payload,
|
||||||
|
createdById: req.currentUser?.id || null,
|
||||||
|
updatedById: req.currentUser?.id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(campaign);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/campaigns/save-draft',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const payload = buildCampaignPayload({ ...(req.body.data || {}), status: 'draft' });
|
||||||
|
const campaignId = cleanText(req.body.data?.id);
|
||||||
|
|
||||||
|
let campaign;
|
||||||
|
if (campaignId) {
|
||||||
|
campaign = await db.marketing_campaigns.findByPk(campaignId);
|
||||||
|
if (!campaign) {
|
||||||
|
throw makeNotFound('Campaign not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await campaign.update({
|
||||||
|
...payload,
|
||||||
|
updatedById: req.currentUser?.id || null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
campaign = await db.marketing_campaigns.create({
|
||||||
|
...payload,
|
||||||
|
createdById: req.currentUser?.id || null,
|
||||||
|
updatedById: req.currentUser?.id || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(campaign);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/campaigns/:id',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const campaign = await db.marketing_campaigns.findByPk(req.params.id);
|
||||||
|
if (!campaign) {
|
||||||
|
throw makeNotFound('Campaign not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildCampaignPayload(req.body.data || {});
|
||||||
|
await campaign.update({
|
||||||
|
...payload,
|
||||||
|
updatedById: req.currentUser?.id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(campaign);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/campaigns/:id',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const campaign = await db.marketing_campaigns.findByPk(req.params.id);
|
||||||
|
if (!campaign) {
|
||||||
|
throw makeNotFound('Campaign not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const relatedSchedules = await db.marketing_schedules.findAll({
|
||||||
|
where: { campaignId: campaign.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(relatedSchedules.map(async (schedule) => {
|
||||||
|
await schedule.update({ updatedById: req.currentUser?.id || null });
|
||||||
|
await schedule.destroy();
|
||||||
|
}));
|
||||||
|
|
||||||
|
await campaign.update({ updatedById: req.currentUser?.id || null });
|
||||||
|
await campaign.destroy();
|
||||||
|
|
||||||
|
res.status(200).send(true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/schedules',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const rows = await db.marketing_schedules.findAll({
|
||||||
|
order: [['publishAt', 'ASC']],
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send({ rows, count: rows.length });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/schedules',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const payload = buildSchedulePayload(req.body.data || {});
|
||||||
|
const schedule = await db.marketing_schedules.create({
|
||||||
|
...payload,
|
||||||
|
createdById: req.currentUser?.id || null,
|
||||||
|
updatedById: req.currentUser?.id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (payload.campaignId) {
|
||||||
|
const campaign = await db.marketing_campaigns.findByPk(payload.campaignId);
|
||||||
|
if (campaign) {
|
||||||
|
await campaign.update({
|
||||||
|
status: 'scheduled',
|
||||||
|
publishingDate: payload.publishAt,
|
||||||
|
updatedById: req.currentUser?.id || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(schedule);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/schedules/:id',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const schedule = await db.marketing_schedules.findByPk(req.params.id);
|
||||||
|
if (!schedule) {
|
||||||
|
throw makeNotFound('Schedule not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildSchedulePayload(req.body.data || {});
|
||||||
|
await schedule.update({
|
||||||
|
...payload,
|
||||||
|
updatedById: req.currentUser?.id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (payload.campaignId) {
|
||||||
|
const campaign = await db.marketing_campaigns.findByPk(payload.campaignId);
|
||||||
|
if (campaign) {
|
||||||
|
let nextStatus = 'scheduled';
|
||||||
|
|
||||||
|
if (payload.status === 'published') {
|
||||||
|
nextStatus = 'published';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.status === 'cancelled') {
|
||||||
|
nextStatus = 'ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
await campaign.update({
|
||||||
|
status: nextStatus,
|
||||||
|
publishingDate: payload.publishAt,
|
||||||
|
updatedById: req.currentUser?.id || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(schedule);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/schedules/:id',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const schedule = await db.marketing_schedules.findByPk(req.params.id);
|
||||||
|
if (!schedule) {
|
||||||
|
throw makeNotFound('Schedule not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await schedule.update({ updatedById: req.currentUser?.id || null });
|
||||||
|
await schedule.destroy();
|
||||||
|
res.status(200).send(true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/templates',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
await ensureDefaultTemplates(req.currentUser?.id || null);
|
||||||
|
const rows = await db.marketing_templates.findAll({
|
||||||
|
order: [['isDefault', 'DESC'], ['updatedAt', 'DESC']],
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send({ rows, count: rows.length });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/templates',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const payload = buildTemplatePayload(req.body.data || {});
|
||||||
|
const template = await db.marketing_templates.create({
|
||||||
|
...payload,
|
||||||
|
createdById: req.currentUser?.id || null,
|
||||||
|
updatedById: req.currentUser?.id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(template);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/templates/:id',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const template = await db.marketing_templates.findByPk(req.params.id);
|
||||||
|
if (!template) {
|
||||||
|
throw makeNotFound('Template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildTemplatePayload(req.body.data || {});
|
||||||
|
await template.update({
|
||||||
|
...payload,
|
||||||
|
updatedById: req.currentUser?.id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(template);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/templates/:id',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const template = await db.marketing_templates.findByPk(req.params.id);
|
||||||
|
if (!template) {
|
||||||
|
throw makeNotFound('Template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await template.update({ updatedById: req.currentUser?.id || null });
|
||||||
|
await template.destroy();
|
||||||
|
res.status(200).send(true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/referrals',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const rows = await db.marketing_referrals.findAll({
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.student_inquiries,
|
||||||
|
as: 'inquiry',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send({ rows, count: rows.length });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/referrals',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const payload = buildReferralPayload(req.body.data || {});
|
||||||
|
const referral = await db.marketing_referrals.create({
|
||||||
|
...payload,
|
||||||
|
createdById: req.currentUser?.id || null,
|
||||||
|
updatedById: req.currentUser?.id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(referral);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/referrals/:id',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const referral = await db.marketing_referrals.findByPk(req.params.id);
|
||||||
|
if (!referral) {
|
||||||
|
throw makeNotFound('Referral not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildReferralPayload(req.body.data || {});
|
||||||
|
await referral.update({
|
||||||
|
...payload,
|
||||||
|
updatedById: req.currentUser?.id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(referral);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/referrals/:id',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const referral = await db.marketing_referrals.findByPk(req.params.id);
|
||||||
|
if (!referral) {
|
||||||
|
throw makeNotFound('Referral not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await referral.update({ updatedById: req.currentUser?.id || null });
|
||||||
|
await referral.destroy();
|
||||||
|
|
||||||
|
res.status(200).send(true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.use('/', Helpers.commonErrorHandler);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
282
backend/src/routes/student_inquiries.js
Normal file
282
backend/src/routes/student_inquiries.js
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const passport = require('passport');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
const db = require('../db/models');
|
||||||
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const REFERRAL_SOURCES = ['website', 'instagram', 'whatsapp', 'parent_referral', 'school_event', 'other'];
|
||||||
|
const STATUSES = ['new', 'contacted', 'demo_scheduled', 'application_started', 'enrolled', 'lost'];
|
||||||
|
|
||||||
|
function makeBadRequest(message) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = 400;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(value) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWhatsapp(value) {
|
||||||
|
return cleanText(value).replace(/[^\d+]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(value) {
|
||||||
|
const url = cleanText(value);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^https?:\/\//i.test(url)) {
|
||||||
|
throw makeBadRequest('Video URL must start with http:// or https://');
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDate(value, fieldName) {
|
||||||
|
const raw = cleanText(value);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(raw);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
throw makeBadRequest(`${fieldName} is not a valid date`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateLeadCode() {
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
const suffix = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 90 + 10)}`;
|
||||||
|
const leadCode = `SM-${suffix}`;
|
||||||
|
const existing = await db.student_inquiries.findOne({ where: { leadCode } });
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return leadCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Could not generate a unique lead code');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicPayload(data) {
|
||||||
|
const studentName = cleanText(data.studentName);
|
||||||
|
const guardianName = cleanText(data.guardianName);
|
||||||
|
const whatsappNumber = normalizeWhatsapp(data.whatsappNumber);
|
||||||
|
const gradeLevel = cleanText(data.gradeLevel);
|
||||||
|
const campusPreference = cleanText(data.campusPreference);
|
||||||
|
const interestTrack = cleanText(data.interestTrack);
|
||||||
|
const referralSource = cleanText(data.referralSource) || 'website';
|
||||||
|
const referralName = cleanText(data.referralName);
|
||||||
|
const preferredContactTime = cleanText(data.preferredContactTime);
|
||||||
|
const notes = cleanText(data.notes);
|
||||||
|
|
||||||
|
if (!studentName) {
|
||||||
|
throw makeBadRequest('Student name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!guardianName) {
|
||||||
|
throw makeBadRequest('Guardian name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!whatsappNumber || whatsappNumber.length < 8) {
|
||||||
|
throw makeBadRequest('Please enter a valid WhatsApp number');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gradeLevel) {
|
||||||
|
throw makeBadRequest('Grade level is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!REFERRAL_SOURCES.includes(referralSource)) {
|
||||||
|
throw makeBadRequest('Referral source is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
studentName,
|
||||||
|
guardianName,
|
||||||
|
whatsappNumber,
|
||||||
|
gradeLevel,
|
||||||
|
campusPreference,
|
||||||
|
interestTrack,
|
||||||
|
referralSource,
|
||||||
|
referralName,
|
||||||
|
preferredContactTime,
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUpdatePayload(data) {
|
||||||
|
const payload = {};
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data, 'status')) {
|
||||||
|
const status = cleanText(data.status);
|
||||||
|
if (!STATUSES.includes(status)) {
|
||||||
|
throw makeBadRequest('Status is invalid');
|
||||||
|
}
|
||||||
|
payload.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data, 'meetingAt')) {
|
||||||
|
payload.meetingAt = normalizeDate(data.meetingAt, 'Meeting date');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data, 'lastContactedAt')) {
|
||||||
|
payload.lastContactedAt = normalizeDate(data.lastContactedAt, 'Last contacted at');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data, 'demoVideoUrl')) {
|
||||||
|
payload.demoVideoUrl = normalizeUrl(data.demoVideoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data, 'counselorNotes')) {
|
||||||
|
payload.counselorNotes = cleanText(data.counselorNotes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.status && payload.status !== 'new' && !payload.lastContactedAt) {
|
||||||
|
payload.lastContactedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/public-submit',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const data = buildPublicPayload(req.body.data || {});
|
||||||
|
|
||||||
|
const inquiry = await db.student_inquiries.create({
|
||||||
|
...data,
|
||||||
|
leadCode: await generateLeadCode(),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send({
|
||||||
|
id: inquiry.id,
|
||||||
|
leadCode: inquiry.leadCode,
|
||||||
|
status: inquiry.status,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.use(passport.authenticate('jwt', { session: false }));
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/summary',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const [total, newLeads, scheduled, enrolled, referrals] = await Promise.all([
|
||||||
|
db.student_inquiries.count(),
|
||||||
|
db.student_inquiries.count({ where: { status: 'new' } }),
|
||||||
|
db.student_inquiries.count({ where: { status: 'demo_scheduled' } }),
|
||||||
|
db.student_inquiries.count({ where: { status: 'enrolled' } }),
|
||||||
|
db.student_inquiries.count({
|
||||||
|
where: {
|
||||||
|
[Op.or]: [
|
||||||
|
{ referralSource: 'parent_referral' },
|
||||||
|
{
|
||||||
|
referralName: {
|
||||||
|
[Op.not]: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.status(200).send({
|
||||||
|
total,
|
||||||
|
newLeads,
|
||||||
|
scheduled,
|
||||||
|
enrolled,
|
||||||
|
referrals,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const status = cleanText(req.query.status);
|
||||||
|
const q = cleanText(req.query.q);
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
if (!STATUSES.includes(status)) {
|
||||||
|
throw makeBadRequest('Status filter is invalid');
|
||||||
|
}
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ studentName: { [Op.iLike]: `%${q}%` } },
|
||||||
|
{ guardianName: { [Op.iLike]: `%${q}%` } },
|
||||||
|
{ whatsappNumber: { [Op.iLike]: `%${q}%` } },
|
||||||
|
{ leadCode: { [Op.iLike]: `%${q}%` } },
|
||||||
|
{ referralName: { [Op.iLike]: `%${q}%` } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.student_inquiries.findAll({
|
||||||
|
where,
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send({
|
||||||
|
rows,
|
||||||
|
count: rows.length,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const inquiry = await db.student_inquiries.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!inquiry) {
|
||||||
|
const notFound = new Error('Lead not found');
|
||||||
|
notFound.code = 404;
|
||||||
|
throw notFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(inquiry);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const inquiry = await db.student_inquiries.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!inquiry) {
|
||||||
|
const notFound = new Error('Lead not found');
|
||||||
|
notFound.code = 404;
|
||||||
|
throw notFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = buildUpdatePayload(req.body.data || {});
|
||||||
|
|
||||||
|
await inquiry.update({
|
||||||
|
...data,
|
||||||
|
updatedById: req.currentUser?.id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.use('/', require('../helpers').commonErrorHandler);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -5,9 +5,15 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
{
|
{
|
||||||
href: '/dashboard',
|
href: '/dashboard',
|
||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
label: 'لوحة التحكم',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
href: '/school-marketing',
|
||||||
|
label: 'استوديو التسويق المدرسي',
|
||||||
|
icon: icon.mdiBullhornOutline,
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
label: 'Users',
|
label: 'Users',
|
||||||
|
|||||||
@ -1,166 +1,401 @@
|
|||||||
|
import {
|
||||||
import React, { useEffect, useState } from 'react';
|
mdiAccountPlus,
|
||||||
import type { ReactElement } from 'react';
|
mdiCalendarClock,
|
||||||
|
mdiCheckCircleOutline,
|
||||||
|
mdiLogin,
|
||||||
|
mdiMessageTextOutline,
|
||||||
|
mdiOpenInNew,
|
||||||
|
mdiPlayCircleOutline,
|
||||||
|
mdiSchoolOutline,
|
||||||
|
mdiVideoOutline,
|
||||||
|
mdiViewDashboardOutline,
|
||||||
|
mdiWhatsapp,
|
||||||
|
} from '@mdi/js';
|
||||||
|
import axios from 'axios';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import React, { ReactElement } from 'react';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
import FormField from '../components/FormField';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
type LeadForm = {
|
||||||
|
studentName: string;
|
||||||
|
guardianName: string;
|
||||||
|
whatsappNumber: string;
|
||||||
|
gradeLevel: string;
|
||||||
|
campusPreference: string;
|
||||||
|
interestTrack: string;
|
||||||
|
referralSource: string;
|
||||||
|
referralName: string;
|
||||||
|
preferredContactTime: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function Starter() {
|
const initialForm: LeadForm = {
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
studentName: '',
|
||||||
src: undefined,
|
guardianName: '',
|
||||||
photographer: undefined,
|
whatsappNumber: '',
|
||||||
photographer_url: undefined,
|
gradeLevel: '',
|
||||||
})
|
campusPreference: '',
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
interestTrack: '',
|
||||||
const [contentType, setContentType] = useState('video');
|
referralSource: 'website',
|
||||||
const [contentPosition, setContentPosition] = useState('right');
|
referralName: '',
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
preferredContactTime: '',
|
||||||
|
notes: '',
|
||||||
|
};
|
||||||
|
|
||||||
const title = 'App Preview'
|
const featureCards = [
|
||||||
|
{
|
||||||
|
icon: mdiAccountPlus,
|
||||||
|
title: 'تسجيل طالب جديد',
|
||||||
|
description: 'نموذج سريع يجمع بيانات الطالب وولي الأمر والمرحلة الدراسية في دقيقة واحدة.',
|
||||||
|
accent: 'from-[#8B5CF6] to-[#D946EF]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: mdiWhatsapp,
|
||||||
|
title: 'إحالات وواتساب',
|
||||||
|
description: 'تتبّع مصدر الإحالة وابدأ محادثة واتساب من لوحة الإدارة مباشرة.',
|
||||||
|
accent: 'from-[#10B981] to-[#22C55E]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: mdiCalendarClock,
|
||||||
|
title: 'جدولة وعرض فيديو',
|
||||||
|
description: 'سجّل الوقت الأنسب للتواصل وأرسل فيديو تعريفي أو حدّد موعد عرض.',
|
||||||
|
accent: 'from-[#F97316] to-[#FACC15]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: mdiCheckCircleOutline,
|
||||||
|
title: 'تحليلات أولية',
|
||||||
|
description: 'شاهد الطلبات الجديدة، العروض المجدولة، وعدد الإحالات في لمحة واحدة.',
|
||||||
|
accent: 'from-[#0EA5E9] to-[#14B8A6]',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
const steps = [
|
||||||
useEffect(() => {
|
{
|
||||||
async function fetchData() {
|
icon: mdiSchoolOutline,
|
||||||
const image = await getPexelsImage();
|
title: '1) ولي الأمر يرسل الطلب',
|
||||||
const video = await getPexelsVideo();
|
description: 'البيانات تصل إلى النظام مع رقم متابعة واضح ومصدر الإحالة.',
|
||||||
setIllustrationImage(image);
|
},
|
||||||
setIllustrationVideo(video);
|
{
|
||||||
}
|
icon: mdiMessageTextOutline,
|
||||||
fetchData();
|
title: '2) فريق القبول يتابع',
|
||||||
}, []);
|
description: 'فتح واتساب، تحديث الحالة، وإضافة ملاحظات المتابعة في نفس الشاشة.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: mdiVideoOutline,
|
||||||
|
title: '3) جدولة وعرض',
|
||||||
|
description: 'إرسال فيديو تعريفي أو تحديد موعد عرض قبل الانتقال إلى مرحلة التسجيل.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
export default function HomePage() {
|
||||||
<div
|
const [formData, setFormData] = React.useState<LeadForm>(initialForm);
|
||||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||||
style={{
|
const [errorMessage, setErrorMessage] = React.useState('');
|
||||||
backgroundImage: `${
|
const [successLead, setSuccessLead] = React.useState<{ leadCode: string; status: string } | null>(null);
|
||||||
image
|
|
||||||
? `url(${image?.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={image?.photographer_url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Photo by {image?.photographer} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
const updateField = (field: keyof LeadForm, value: string) => {
|
||||||
if (video?.video_files?.length > 0) {
|
setFormData((current) => ({ ...current, [field]: value }));
|
||||||
return (
|
};
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
|
||||||
<video
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
event.preventDefault();
|
||||||
autoPlay
|
setIsSubmitting(true);
|
||||||
loop
|
setErrorMessage('');
|
||||||
muted
|
|
||||||
>
|
try {
|
||||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
const response = await axios.post('/student-inquiries/public-submit', {
|
||||||
Your browser does not support the video tag.
|
data: formData,
|
||||||
</video>
|
});
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
setSuccessLead(response.data);
|
||||||
<a
|
setFormData(initialForm);
|
||||||
className='text-[8px]'
|
} catch (error: any) {
|
||||||
href={video?.user?.url}
|
setErrorMessage(error?.response?.data || error?.message || 'تعذر إرسال الطلب الآن');
|
||||||
target='_blank'
|
} finally {
|
||||||
rel='noreferrer'
|
setIsSubmitting(false);
|
||||||
>
|
}
|
||||||
Video by {video.user.name} on Pexels
|
};
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
style={
|
|
||||||
contentPosition === 'background'
|
|
||||||
? {
|
|
||||||
backgroundImage: `${
|
|
||||||
illustrationImage
|
|
||||||
? `url(${illustrationImage.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('نظام تسويق مدرسي')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
<main
|
||||||
<SectionFullScreen bg='violet'>
|
dir="rtl"
|
||||||
<div
|
className="min-h-screen bg-[#F8FAFC] text-slate-900"
|
||||||
className={`flex ${
|
style={{ fontFamily: 'Tajawal, Inter, sans-serif' }}
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
|
||||||
} min-h-screen w-full`}
|
|
||||||
>
|
>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
<div className="absolute inset-x-0 top-0 -z-10 h-[520px] bg-[radial-gradient(circle_at_top,_rgba(139,92,246,0.28),_transparent_55%),radial-gradient(circle_at_top_left,_rgba(14,165,233,0.18),_transparent_45%),linear-gradient(180deg,_#EEF2FF_0%,_#F8FAFC_60%)]" />
|
||||||
? imageBlock(illustrationImage)
|
|
||||||
: null}
|
<div className="mx-auto flex max-w-7xl flex-col gap-16 px-6 py-6 lg:px-10">
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
<header className="rounded-[28px] border border-white/80 bg-white/80 px-5 py-4 shadow-lg shadow-slate-200/60 backdrop-blur">
|
||||||
? videoBlock(illustrationVideo)
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
: null}
|
<div>
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
<div className="text-sm font-semibold tracking-[0.3em] text-violet-600">SCHOOL MARKETING</div>
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
<div className="mt-1 text-lg font-bold text-slate-900">نظام إدارة تسويق مدرسي عربي RTL</div>
|
||||||
<CardBoxComponentTitle title="Welcome to your App Preview app!"/>
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="space-y-3">
|
<Link href="/login" className="inline-flex items-center gap-2 rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-violet-200 hover:text-violet-700">
|
||||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
<BaseIcon path={mdiLogin} size={18} />
|
||||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
تسجيل الدخول
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
</Link>
|
||||||
|
<Link href="/school-marketing" className="inline-flex items-center gap-2 rounded-full bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-800">
|
||||||
|
<BaseIcon path={mdiViewDashboardOutline} size={18} />
|
||||||
|
واجهة الإدارة
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton
|
|
||||||
href='/login'
|
|
||||||
label='Login'
|
|
||||||
color='info'
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
|
|
||||||
</BaseButtons>
|
<section className="grid items-center gap-8 lg:grid-cols-[1.15fr_0.85fr]">
|
||||||
</CardBox>
|
<div className="space-y-6">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-violet-200 bg-violet-50 px-4 py-2 text-sm font-medium text-violet-700">
|
||||||
|
<BaseIcon path={mdiCheckCircleOutline} size={18} />
|
||||||
|
أول نسخة عملية: تسجيل + إحالة + واتساب + متابعة
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-4xl font-black leading-tight text-slate-950 md:text-6xl">
|
||||||
|
اجعل رحلة التسجيل المدرسي أسرع، أوضح، وأقرب لأولياء الأمور.
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-2xl text-base leading-8 text-slate-600 md:text-lg">
|
||||||
|
هذه الصفحة العامة تستقبل طلبات أولياء الأمور باللغة العربية وباتجاه RTL، ثم تنقلها مباشرة إلى لوحة
|
||||||
|
متابعة داخلية فيها إحالات، واتساب، فيديو، وجدولة وعرض تحليلات أولية لفريق القبول.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<a href="#lead-form" className="inline-flex items-center gap-2 rounded-full bg-violet-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-violet-200 transition hover:bg-violet-700">
|
||||||
|
<BaseIcon path={mdiAccountPlus} size={18} />
|
||||||
|
ابدأ تسجيل الطالب
|
||||||
|
</a>
|
||||||
|
<Link href="/login" className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-700 transition hover:border-violet-200 hover:text-violet-700">
|
||||||
|
<BaseIcon path={mdiOpenInNew} size={18} />
|
||||||
|
دخول فريق القبول
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-[24px] border border-white/80 bg-white/80 p-4 shadow-lg shadow-slate-200/40">
|
||||||
|
<div className="text-xs text-slate-500">سرعة الاستجابة</div>
|
||||||
|
<div className="mt-2 text-2xl font-bold">واتساب فوري</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-white/80 bg-white/80 p-4 shadow-lg shadow-slate-200/40">
|
||||||
|
<div className="text-xs text-slate-500">تحويل الإحالات</div>
|
||||||
|
<div className="mt-2 text-2xl font-bold">مصدر واضح</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-white/80 bg-white/80 p-4 shadow-lg shadow-slate-200/40">
|
||||||
|
<div className="text-xs text-slate-500">حجز العرض</div>
|
||||||
|
<div className="mt-2 text-2xl font-bold">جدولة سهلة</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardBox className="overflow-hidden border-0 bg-gradient-to-br from-[#111827] via-[#1E1B4B] to-[#312E81] text-white shadow-2xl shadow-indigo-200/60">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-indigo-200">رحلة القبول</div>
|
||||||
|
<div className="mt-2 text-3xl font-bold">من الطلب إلى الجدولة</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full border border-white/10 bg-white/10 p-3">
|
||||||
|
<BaseIcon path={mdiPlayCircleOutline} size={32} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<div key={step.title} className="rounded-[24px] border border-white/10 bg-white/10 p-4 backdrop-blur">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="rounded-2xl bg-white/10 p-3">
|
||||||
|
<BaseIcon path={step.icon} size={22} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">{step.title}</div>
|
||||||
|
<div className="mt-1 text-sm leading-6 text-indigo-100">{step.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{featureCards.map((feature) => (
|
||||||
|
<CardBox key={feature.title} className="border-0 bg-white/90 shadow-lg shadow-slate-200/60 backdrop-blur">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className={`inline-flex rounded-2xl bg-gradient-to-br p-3 text-white ${feature.accent}`}>
|
||||||
|
<BaseIcon path={feature.icon} size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900">{feature.title}</h2>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-slate-600">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="lead-form" className="grid gap-8 lg:grid-cols-[0.95fr_1.05fr]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-700">
|
||||||
|
<BaseIcon path={mdiMessageTextOutline} size={18} />
|
||||||
|
نموذج عملي جاهز للاستخدام الآن
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold text-slate-950 md:text-4xl">أرسل طلب التسجيل وسنتواصل عبر واتساب</h2>
|
||||||
|
<p className="mt-3 max-w-xl text-base leading-8 text-slate-600">
|
||||||
|
املأ البيانات الأساسية، اذكر مصدر الإحالة إن وجد، وحدّد الوقت المفضل للتواصل. بعد الإرسال سيظهر رقم متابعة يمكن لفريقنا الرجوع إليه بسرعة.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="rounded-[24px] border border-slate-200 bg-white p-4">
|
||||||
|
<div className="text-sm font-semibold text-slate-900">ما الذي يحدث بعد الإرسال؟</div>
|
||||||
|
<ul className="mt-3 space-y-3 text-sm leading-7 text-slate-600">
|
||||||
|
<li>• استلام الطلب في لوحة الإدارة مباشرة.</li>
|
||||||
|
<li>• بدء المحادثة عبر واتساب من رقم ولي الأمر.</li>
|
||||||
|
<li>• جدولة عرض أو مشاركة فيديو تعريفي حسب حالة الطالب.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{successLead ? (
|
||||||
|
<div className="rounded-[28px] border border-emerald-200 bg-emerald-50 p-5 shadow-sm">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="rounded-2xl bg-emerald-500 p-3 text-white">
|
||||||
|
<BaseIcon path={mdiCheckCircleOutline} size={22} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-emerald-900">تم استلام طلبكم بنجاح</div>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-emerald-800">
|
||||||
|
رقم المتابعة: <span className="font-black">{successLead.leadCode}</span>
|
||||||
|
{' '}— سنراجع الطلب ونتواصل معكم قريباً.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardBox className="border-0 bg-white/95 shadow-2xl shadow-slate-200/60">
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FormField label="اسم الطالب">
|
||||||
|
<input
|
||||||
|
value={formData.studentName}
|
||||||
|
onChange={(event) => updateField('studentName', event.target.value)}
|
||||||
|
placeholder="مثال: أحمد محمد"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="اسم ولي الأمر">
|
||||||
|
<input
|
||||||
|
value={formData.guardianName}
|
||||||
|
onChange={(event) => updateField('guardianName', event.target.value)}
|
||||||
|
placeholder="مثال: محمد علي"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FormField label="رقم واتساب" icons={[mdiWhatsapp]}>
|
||||||
|
<input
|
||||||
|
value={formData.whatsappNumber}
|
||||||
|
onChange={(event) => updateField('whatsappNumber', event.target.value)}
|
||||||
|
placeholder="9665xxxxxxx"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="المرحلة الدراسية">
|
||||||
|
<select value={formData.gradeLevel} onChange={(event) => updateField('gradeLevel', event.target.value)}>
|
||||||
|
<option value="">اختر المرحلة</option>
|
||||||
|
<option value="رياض أطفال">رياض أطفال</option>
|
||||||
|
<option value="ابتدائي">ابتدائي</option>
|
||||||
|
<option value="متوسط">متوسط</option>
|
||||||
|
<option value="ثانوي">ثانوي</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FormField label="الفرع المفضل">
|
||||||
|
<input
|
||||||
|
value={formData.campusPreference}
|
||||||
|
onChange={(event) => updateField('campusPreference', event.target.value)}
|
||||||
|
placeholder="مثال: فرع شمال الرياض"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="القسم أو البرنامج">
|
||||||
|
<input
|
||||||
|
value={formData.interestTrack}
|
||||||
|
onChange={(event) => updateField('interestTrack', event.target.value)}
|
||||||
|
placeholder="مثال: STEM / تحفيظ / لغات"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FormField label="مصدر الإحالة">
|
||||||
|
<select value={formData.referralSource} onChange={(event) => updateField('referralSource', event.target.value)}>
|
||||||
|
<option value="website">الموقع</option>
|
||||||
|
<option value="instagram">إنستغرام</option>
|
||||||
|
<option value="whatsapp">واتساب</option>
|
||||||
|
<option value="parent_referral">إحالة من ولي أمر</option>
|
||||||
|
<option value="school_event">فعالية مدرسية</option>
|
||||||
|
<option value="other">أخرى</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="اسم المحيل أو الملاحظة المرجعية">
|
||||||
|
<input
|
||||||
|
value={formData.referralName}
|
||||||
|
onChange={(event) => updateField('referralName', event.target.value)}
|
||||||
|
placeholder="اختياري"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="أفضل وقت للتواصل">
|
||||||
|
<input
|
||||||
|
value={formData.preferredContactTime}
|
||||||
|
onChange={(event) => updateField('preferredContactTime', event.target.value)}
|
||||||
|
placeholder="مثال: بعد 5 مساءً"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="ملاحظات إضافية" hasTextareaHeight>
|
||||||
|
<textarea
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(event) => updateField('notes', event.target.value)}
|
||||||
|
placeholder="أي معلومات تساعد فريق القبول على تجهيز الرد المناسب"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="text-sm text-slate-500">سنستخدم البيانات فقط للتواصل بخصوص التسجيل المدرسي.</div>
|
||||||
|
<BaseButton
|
||||||
|
type="submit"
|
||||||
|
color="info"
|
||||||
|
icon={mdiAccountPlus}
|
||||||
|
label={isSubmitting ? 'جاري الإرسال...' : 'إرسال طلب التسجيل'}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardBox>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</SectionFullScreen>
|
</>
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
1982
frontend/src/pages/school-marketing.tsx
Normal file
1982
frontend/src/pages/school-marketing.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user