Compare commits

...

2 Commits

Author SHA1 Message Date
Flatlogic Bot
8e4bc70d03 Auto commit: 2026-04-11T08:13:09.677Z 2026-04-11 08:13:09 +00:00
Flatlogic Bot
dfebf532cf تسويق مدرسة 2026-04-08 07:01:06 +00:00
20 changed files with 4441 additions and 148 deletions

0
.perm_test_apache Normal file
View File

0
.perm_test_exec Normal file
View File

View 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;
}
},
};

View File

@ -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;
}
},
};

View File

@ -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;
}
},
};

View File

@ -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;
}
},
};

View File

@ -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;
}
},
};

View 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;
};

View 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;
};

View 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;
};

View 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;
};

View 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;
};

View File

@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
const db = require('./db/models');
const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
@ -38,6 +37,8 @@ const audit_logsRoutes = require('./routes/audit_logs');
const app_settingsRoutes = require('./routes/app_settings');
const api_keysRoutes = require('./routes/api_keys');
const studentInquiriesRoutes = require('./routes/student_inquiries');
const schoolMarketingRoutes = require('./routes/school_marketing');
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/api_keys', passport.authenticate('jwt', {session: false}), api_keysRoutes);
app.use('/api/student-inquiries', studentInquiriesRoutes);
app.use('/api/school-marketing', schoolMarketingRoutes);
app.use(
'/api/openai',

View 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;

View 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;

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'

View File

@ -5,9 +5,15 @@ const menuAside: MenuAsideItem[] = [
{
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
label: 'لوحة التحكم',
},
{
href: '/school-marketing',
label: 'استوديو التسويق المدرسي',
icon: icon.mdiBullhornOutline,
},
{
href: '/users/users-list',
label: 'Users',

View File

@ -1,166 +1,401 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import {
mdiAccountPlus,
mdiCalendarClock,
mdiCheckCircleOutline,
mdiLogin,
mdiMessageTextOutline,
mdiOpenInNew,
mdiPlayCircleOutline,
mdiSchoolOutline,
mdiVideoOutline,
mdiViewDashboardOutline,
mdiWhatsapp,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import FormField from '../components/FormField';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
import LayoutGuest from '../layouts/Guest';
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 [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('right');
const textColor = useAppSelector((state) => state.style.linkColor);
const initialForm: LeadForm = {
studentName: '',
guardianName: '',
whatsappNumber: '',
gradeLevel: '',
campusPreference: '',
interestTrack: '',
referralSource: 'website',
referralName: '',
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
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const steps = [
{
icon: mdiSchoolOutline,
title: '1) ولي الأمر يرسل الطلب',
description: 'البيانات تصل إلى النظام مع رقم متابعة واضح ومصدر الإحالة.',
},
{
icon: mdiMessageTextOutline,
title: '2) فريق القبول يتابع',
description: 'فتح واتساب، تحديث الحالة، وإضافة ملاحظات المتابعة في نفس الشاشة.',
},
{
icon: mdiVideoOutline,
title: '3) جدولة وعرض',
description: 'إرسال فيديو تعريفي أو تحديد موعد عرض قبل الانتقال إلى مرحلة التسجيل.',
},
];
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
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>
);
export default function HomePage() {
const [formData, setFormData] = React.useState<LeadForm>(initialForm);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState('');
const [successLead, setSuccessLead] = React.useState<{ leadCode: string; status: string } | null>(null);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
const updateField = (field: keyof LeadForm, value: string) => {
setFormData((current) => ({ ...current, [field]: value }));
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setIsSubmitting(true);
setErrorMessage('');
try {
const response = await axios.post('/student-inquiries/public-submit', {
data: formData,
});
setSuccessLead(response.data);
setFormData(initialForm);
} catch (error: any) {
setErrorMessage(error?.response?.data || error?.message || 'تعذر إرسال الطلب الآن');
} finally {
setIsSubmitting(false);
}
};
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>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('نظام تسويق مدرسي')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
<main
dir="rtl"
className="min-h-screen bg-[#F8FAFC] text-slate-900"
style={{ fontFamily: 'Tajawal, Inter, sans-serif' }}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your App Preview app!"/>
<div className="space-y-3">
<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>
<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>
<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%)]" />
<div className="mx-auto flex max-w-7xl flex-col gap-16 px-6 py-6 lg:px-10">
<header className="rounded-[28px] border border-white/80 bg-white/80 px-5 py-4 shadow-lg shadow-slate-200/60 backdrop-blur">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-semibold tracking-[0.3em] text-violet-600">SCHOOL MARKETING</div>
<div className="mt-1 text-lg font-bold text-slate-900">نظام إدارة تسويق مدرسي عربي RTL</div>
</div>
<div className="flex flex-wrap items-center gap-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">
<BaseIcon path={mdiLogin} size={18} />
تسجيل الدخول
</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>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</header>
</BaseButtons>
</CardBox>
<section className="grid items-center gap-8 lg:grid-cols-[1.15fr_0.85fr]">
<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>
</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>
</main>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
HomePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

File diff suppressed because it is too large Load Diff