تسويق مدرسة
This commit is contained in:
parent
2f3a401454
commit
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
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 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,7 @@ 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 getBaseUrl = (url) => {
|
||||
@ -114,6 +114,7 @@ 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/openai',
|
||||
|
||||
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 { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -8,6 +8,12 @@ const menuAside: MenuAsideItem[] = [
|
||||
label: 'Dashboard',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/school-marketing',
|
||||
label: 'التسويق المدرسي',
|
||||
icon: icon.mdiBullhornOutline,
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
label: 'Users',
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
|
||||
583
frontend/src/pages/school-marketing.tsx
Normal file
583
frontend/src/pages/school-marketing.tsx
Normal file
@ -0,0 +1,583 @@
|
||||
import {
|
||||
mdiBullhornOutline,
|
||||
mdiCalendarClock,
|
||||
mdiCheckCircleOutline,
|
||||
mdiMagnify,
|
||||
mdiMessageTextOutline,
|
||||
mdiOpenInNew,
|
||||
mdiSchoolOutline,
|
||||
mdiVideoOutline,
|
||||
mdiWhatsapp,
|
||||
} from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement } from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import FormField from '../components/FormField';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../config';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
|
||||
type Inquiry = {
|
||||
id: string;
|
||||
leadCode: string;
|
||||
studentName: string;
|
||||
guardianName: string;
|
||||
whatsappNumber: string;
|
||||
gradeLevel: string;
|
||||
campusPreference?: string | null;
|
||||
interestTrack?: string | null;
|
||||
referralSource: string;
|
||||
referralName?: string | null;
|
||||
preferredContactTime?: string | null;
|
||||
notes?: string | null;
|
||||
status: string;
|
||||
meetingAt?: string | null;
|
||||
demoVideoUrl?: string | null;
|
||||
counselorNotes?: string | null;
|
||||
lastContactedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type Summary = {
|
||||
total: number;
|
||||
newLeads: number;
|
||||
scheduled: number;
|
||||
enrolled: number;
|
||||
referrals: number;
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'all', label: 'كل الحالات' },
|
||||
{ value: 'new', label: 'جديد' },
|
||||
{ value: 'contacted', label: 'تم التواصل' },
|
||||
{ value: 'demo_scheduled', label: 'عرض مجدول' },
|
||||
{ value: 'application_started', label: 'بدأ التسجيل' },
|
||||
{ value: 'enrolled', label: 'تم القبول' },
|
||||
{ value: 'lost', label: 'غير مهتم' },
|
||||
];
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
new: 'جديد',
|
||||
contacted: 'تم التواصل',
|
||||
demo_scheduled: 'عرض مجدول',
|
||||
application_started: 'بدأ التسجيل',
|
||||
enrolled: 'تم القبول',
|
||||
lost: 'غير مهتم',
|
||||
};
|
||||
|
||||
const referralLabels: Record<string, string> = {
|
||||
website: 'الموقع',
|
||||
instagram: 'إنستغرام',
|
||||
whatsapp: 'واتساب',
|
||||
parent_referral: 'إحالة ولي أمر',
|
||||
school_event: 'فعالية مدرسية',
|
||||
other: 'أخرى',
|
||||
};
|
||||
|
||||
const emptySummary: Summary = {
|
||||
total: 0,
|
||||
newLeads: 0,
|
||||
scheduled: 0,
|
||||
enrolled: 0,
|
||||
referrals: 0,
|
||||
};
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return date.toLocaleString('ar-EG', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
function toDateTimeLocal(value?: string | null) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const pad = (number: number) => number.toString().padStart(2, '0');
|
||||
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
export default function SchoolMarketingPage() {
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [summary, setSummary] = React.useState<Summary>(emptySummary);
|
||||
const [inquiries, setInquiries] = React.useState<Inquiry[]>([]);
|
||||
const [selectedId, setSelectedId] = React.useState<string>('');
|
||||
const [selectedInquiry, setSelectedInquiry] = React.useState<Inquiry | null>(null);
|
||||
const [statusFilter, setStatusFilter] = React.useState('all');
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [detailLoading, setDetailLoading] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState('');
|
||||
const [saveMessage, setSaveMessage] = React.useState('');
|
||||
const [formState, setFormState] = React.useState({
|
||||
status: 'new',
|
||||
meetingAt: '',
|
||||
demoVideoUrl: '',
|
||||
counselorNotes: '',
|
||||
});
|
||||
|
||||
const loadSummary = React.useCallback(async () => {
|
||||
const response = await axios.get('/student-inquiries/summary');
|
||||
setSummary(response.data || emptySummary);
|
||||
}, []);
|
||||
|
||||
const loadInquiries = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const response = await axios.get('/student-inquiries', {
|
||||
params: {
|
||||
status: statusFilter,
|
||||
q: search,
|
||||
},
|
||||
});
|
||||
const rows = Array.isArray(response.data?.rows) ? response.data.rows : [];
|
||||
setInquiries(rows);
|
||||
|
||||
if (!rows.length) {
|
||||
setSelectedId('');
|
||||
setSelectedInquiry(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextId = rows.some((item: Inquiry) => item.id === selectedId) ? selectedId : rows[0].id;
|
||||
setSelectedId(nextId);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error?.response?.data || error?.message || 'تعذر تحميل الطلبات');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, selectedId, statusFilter]);
|
||||
|
||||
const loadInquiryDetails = React.useCallback(async (id: string) => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDetailLoading(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/student-inquiries/${id}`);
|
||||
const inquiry = response.data as Inquiry;
|
||||
setSelectedInquiry(inquiry);
|
||||
setFormState({
|
||||
status: inquiry.status,
|
||||
meetingAt: toDateTimeLocal(inquiry.meetingAt),
|
||||
demoVideoUrl: inquiry.demoVideoUrl || '',
|
||||
counselorNotes: inquiry.counselorNotes || '',
|
||||
});
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error?.response?.data || error?.message || 'تعذر تحميل التفاصيل');
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
loadSummary().catch((error: any) => {
|
||||
setErrorMessage(error?.response?.data || error?.message || 'تعذر تحميل الملخص');
|
||||
});
|
||||
loadInquiries().catch((error: any) => {
|
||||
setErrorMessage(error?.response?.data || error?.message || 'تعذر تحميل الطلبات');
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [loadInquiries, loadSummary]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedId) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadInquiryDetails(selectedId).catch((error: any) => {
|
||||
setErrorMessage(error?.response?.data || error?.message || 'تعذر تحميل التفاصيل');
|
||||
});
|
||||
}, [loadInquiryDetails, selectedId]);
|
||||
|
||||
const selectedWhatsappLink = React.useMemo(() => {
|
||||
if (!selectedInquiry?.whatsappNumber) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `https://wa.me/${selectedInquiry.whatsappNumber.replace(/[^\d]/g, '')}`;
|
||||
}, [selectedInquiry?.whatsappNumber]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedInquiry) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setSaveMessage('');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
await axios.put(`/student-inquiries/${selectedInquiry.id}`, {
|
||||
data: {
|
||||
status: formState.status,
|
||||
meetingAt: formState.meetingAt,
|
||||
demoVideoUrl: formState.demoVideoUrl,
|
||||
counselorNotes: formState.counselorNotes,
|
||||
},
|
||||
});
|
||||
|
||||
setSaveMessage('تم حفظ المتابعة بنجاح');
|
||||
await Promise.all([loadSummary(), loadInquiries(), loadInquiryDetails(selectedInquiry.id)]);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error?.response?.data || error?.message || 'تعذر حفظ التحديثات');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'كل الطلبات',
|
||||
value: summary.total,
|
||||
accent: 'from-[#8B5CF6] to-[#D946EF]',
|
||||
icon: mdiBullhornOutline,
|
||||
},
|
||||
{
|
||||
label: 'طلبات جديدة',
|
||||
value: summary.newLeads,
|
||||
accent: 'from-[#0EA5E9] to-[#14B8A6]',
|
||||
icon: mdiSchoolOutline,
|
||||
},
|
||||
{
|
||||
label: 'عروض مجدولة',
|
||||
value: summary.scheduled,
|
||||
accent: 'from-[#F97316] to-[#FACC15]',
|
||||
icon: mdiCalendarClock,
|
||||
},
|
||||
{
|
||||
label: 'إحالات',
|
||||
value: summary.referrals,
|
||||
accent: 'from-[#10B981] to-[#22C55E]',
|
||||
icon: mdiCheckCircleOutline,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('School Marketing')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<div dir="rtl" className="space-y-6 text-right" style={{ fontFamily: 'Tajawal, Inter, sans-serif' }}>
|
||||
<SectionTitleLineWithButton icon={mdiBullhornOutline} title="لوحة التسويق المدرسي" main>
|
||||
<div className="text-sm text-slate-500">
|
||||
{currentUser?.firstName ? `مرحباً ${currentUser.firstName}` : 'متابعة يومية للطلاب المحتملين'}
|
||||
</div>
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<CardBox className="overflow-hidden border-0 bg-gradient-to-br from-[#111827] via-[#1E1B4B] to-[#312E81] text-white shadow-2xl">
|
||||
<div className="grid gap-6 lg:grid-cols-[1.4fr_1fr]">
|
||||
<div className="space-y-4">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm text-slate-100">
|
||||
<BaseIcon path={mdiMessageTextOutline} size={18} />
|
||||
متابعة واتساب + إحالات + مواعيد عرض
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold leading-tight md:text-4xl">من أول رسالة إلى القبول النهائي</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-200 md:text-base">
|
||||
هذه أول نسخة عملية لمسار القبول: استلام الطلب من الصفحة العامة، تجميع الإحالات، جدولة العرض،
|
||||
وتوثيق المتابعة في مكان واحد.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-[28px] border border-white/10 bg-white/10 p-4 backdrop-blur">
|
||||
<div className="rounded-2xl bg-white/10 p-4">
|
||||
<div className="text-xs text-slate-300">الطلاب المحتملون اليوم</div>
|
||||
<div className="mt-2 text-3xl font-semibold">{summary.newLeads}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white/10 p-4">
|
||||
<div className="text-xs text-slate-300">المسجلون فعلياً</div>
|
||||
<div className="mt-2 text-3xl font-semibold">{summary.enrolled}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white/10 p-4 text-sm leading-6 text-slate-200">
|
||||
استخدم البحث والتصفية على اليسار، ثم حدّث حالة الطالب وأضف رابط الفيديو أو موعد العرض من لوحة التفاصيل.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{stats.map((item) => (
|
||||
<CardBox key={item.label} className="border-0 bg-white/90 shadow-lg shadow-slate-200/70 dark:bg-dark-900">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-slate-500">{item.label}</div>
|
||||
<div className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">{item.value}</div>
|
||||
</div>
|
||||
<div className={`rounded-2xl bg-gradient-to-br p-3 text-white ${item.accent}`}>
|
||||
<BaseIcon path={item.icon} size={26} />
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[420px_1fr]">
|
||||
<CardBox className="border-0 bg-white/90 shadow-lg shadow-slate-200/70 dark:bg-dark-900">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<div className="flex-1">
|
||||
<FormField label="بحث سريع" icons={[mdiMagnify]}>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="اسم الطالب أو ولي الأمر أو رقم واتساب"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="w-full md:w-44">
|
||||
<FormField label="الحالة">
|
||||
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
|
||||
{statusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="rounded-3xl border border-dashed border-slate-200 bg-slate-50 px-5 py-12 text-center text-slate-500">
|
||||
جاري تحميل الطلبات...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !inquiries.length ? (
|
||||
<div className="rounded-3xl border border-dashed border-slate-200 bg-slate-50 px-5 py-12 text-center text-slate-500">
|
||||
لا توجد طلبات تطابق الفلتر الحالي. جرّب إزالة البحث أو اطلب تسجيل طالب جديد من الصفحة الرئيسية.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-[720px] space-y-3 overflow-y-auto pr-1">
|
||||
{inquiries.map((item) => {
|
||||
const active = item.id === selectedId;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedId(item.id);
|
||||
setSaveMessage('');
|
||||
}}
|
||||
className={`w-full rounded-[24px] border p-4 text-right transition ${
|
||||
active
|
||||
? 'border-[#7C3AED] bg-violet-50 shadow-md shadow-violet-100'
|
||||
: 'border-slate-200 bg-slate-50 hover:border-violet-200 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-slate-900 dark:text-white">{item.studentName}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{item.guardianName} • {item.gradeLevel}</div>
|
||||
</div>
|
||||
<span className="rounded-full bg-slate-900 px-3 py-1 text-[11px] text-white">
|
||||
{statusLabels[item.status] || item.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-slate-500">{item.leadCode}</div>
|
||||
<div className="mt-2 text-xs text-slate-500">{referralLabels[item.referralSource] || 'أخرى'} • {formatDate(item.createdAt)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="border-0 bg-white/90 shadow-lg shadow-slate-200/70 dark:bg-dark-900">
|
||||
{!selectedInquiry || detailLoading ? (
|
||||
<div className="flex min-h-[420px] items-center justify-center rounded-[28px] border border-dashed border-slate-200 bg-slate-50 px-6 text-center text-slate-500">
|
||||
{detailLoading ? 'جاري تحميل التفاصيل...' : 'اختر طالباً من القائمة لعرض بطاقة المتابعة'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 border-b border-slate-200 pb-5 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-violet-100 px-3 py-1 text-xs font-semibold text-violet-700">
|
||||
<BaseIcon path={mdiSchoolOutline} size={15} />
|
||||
{selectedInquiry.leadCode}
|
||||
</div>
|
||||
<h3 className="mt-3 text-2xl font-bold text-slate-900 dark:text-white">{selectedInquiry.studentName}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">ولي الأمر: {selectedInquiry.guardianName}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BaseButton
|
||||
color="success"
|
||||
icon={mdiWhatsapp}
|
||||
label="واتساب"
|
||||
onClick={() => {
|
||||
if (selectedWhatsappLink) {
|
||||
window.open(selectedWhatsappLink, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<BaseButton
|
||||
color="info"
|
||||
icon={mdiOpenInNew}
|
||||
label="فتح الفيديو"
|
||||
onClick={() => {
|
||||
if (formState.demoVideoUrl) {
|
||||
window.open(formState.demoVideoUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}}
|
||||
disabled={!formState.demoVideoUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-[24px] bg-slate-50 p-4">
|
||||
<div className="text-xs text-slate-500">رقم واتساب</div>
|
||||
<div className="mt-1 font-semibold text-slate-900 dark:text-white">{selectedInquiry.whatsappNumber}</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] bg-slate-50 p-4">
|
||||
<div className="text-xs text-slate-500">المرحلة الدراسية</div>
|
||||
<div className="mt-1 font-semibold text-slate-900 dark:text-white">{selectedInquiry.gradeLevel}</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] bg-slate-50 p-4">
|
||||
<div className="text-xs text-slate-500">القسم / البرنامج</div>
|
||||
<div className="mt-1 font-semibold text-slate-900 dark:text-white">{selectedInquiry.interestTrack || 'لم يحدد بعد'}</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] bg-slate-50 p-4">
|
||||
<div className="text-xs text-slate-500">مصدر الإحالة</div>
|
||||
<div className="mt-1 font-semibold text-slate-900 dark:text-white">
|
||||
{referralLabels[selectedInquiry.referralSource] || 'أخرى'}
|
||||
{selectedInquiry.referralName ? ` — ${selectedInquiry.referralName}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-[24px] border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-500">أفضل وقت للتواصل</div>
|
||||
<div className="mt-1 text-sm font-medium text-slate-900 dark:text-white">
|
||||
{selectedInquiry.preferredContactTime || 'غير محدد'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-500">آخر تواصل</div>
|
||||
<div className="mt-1 text-sm font-medium text-slate-900 dark:text-white">
|
||||
{formatDate(selectedInquiry.lastContactedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-500">ملاحظات ولي الأمر</div>
|
||||
<div className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-700 dark:text-slate-200">
|
||||
{selectedInquiry.notes || 'لا توجد ملاحظات مضافة من النموذج.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label="حالة المتابعة">
|
||||
<select
|
||||
value={formState.status}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, status: event.target.value }))}
|
||||
>
|
||||
{statusOptions
|
||||
.filter((option) => option.value !== 'all')
|
||||
.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="موعد العرض أو المكالمة" icons={[mdiCalendarClock]}>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formState.meetingAt}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, meetingAt: event.target.value }))}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="رابط الفيديو التعريفي" icons={[mdiVideoOutline]}>
|
||||
<input
|
||||
type="url"
|
||||
value={formState.demoVideoUrl}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, demoVideoUrl: event.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="ملاحظات فريق القبول" hasTextareaHeight>
|
||||
<textarea
|
||||
value={formState.counselorNotes}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, counselorNotes: event.target.value }))}
|
||||
placeholder="أضف نتيجة المكالمة، الخطوات القادمة، أو سبب الاهتمام"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{saveMessage ? (
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||
{saveMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-4">
|
||||
<div className="text-xs text-slate-500">
|
||||
تم إنشاء الطلب في {formatDate(selectedInquiry.createdAt)}
|
||||
</div>
|
||||
<BaseButton
|
||||
color="info"
|
||||
icon={mdiCheckCircleOutline}
|
||||
label={saving ? 'جاري الحفظ...' : 'حفظ التحديثات'}
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
SchoolMarketingPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user