From 5c6edae417e59c388d2f433ab69886c3bf7cd997 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 6 Aug 2026 15:43:30 +0000 Subject: [PATCH] =?UTF-8?q?=D9=842?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/marketing_plans.js | 110 ++++++ frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 6 + frontend/src/pages/index.tsx | 245 +++++------- frontend/src/pages/marketing-studio.tsx | 483 ++++++++++++++++++++++++ 6 files changed, 691 insertions(+), 159 deletions(-) create mode 100644 frontend/src/pages/marketing-studio.tsx diff --git a/backend/src/routes/marketing_plans.js b/backend/src/routes/marketing_plans.js index 21eed09..2d9e308 100644 --- a/backend/src/routes/marketing_plans.js +++ b/backend/src/routes/marketing_plans.js @@ -3,6 +3,10 @@ const express = require('express'); const Marketing_plansService = require('../services/marketing_plans'); const Marketing_plansDBApi = require('../db/api/marketing_plans'); +const Plan_channelsDBApi = require('../db/api/plan_channels'); +const Campaign_tasksDBApi = require('../db/api/campaign_tasks'); +const db = require('../db/models'); +const ValidationError = require('../services/notifications/errors/validation'); const wrapAsync = require('../helpers').wrapAsync; @@ -95,6 +99,112 @@ router.post('/', wrapAsync(async (req, res) => { res.status(200).send(payload); })); +const allowedWorkflowChannels = ['social', 'ads', 'email', 'seo']; + +const channelCopy = { + social: { + label: 'المحتوى الاجتماعي', + objective: 'بناء حضور مستمر وجدولة منشورات الحملة', + task: 'اكتب تقويم منشورات الأسبوع الأول', + }, + ads: { + label: 'الإعلانات المدفوعة', + objective: 'إطلاق حملة مدفوعة برسالة واضحة وميزانية مضبوطة', + task: 'جهّز مسودة الإعلان والجمهور المستهدف', + }, + email: { + label: 'البريد الإلكتروني', + objective: 'تحويل الجمهور المهتم عبر رسالة بريدية مركزة', + task: 'اكتب أول رسالة بريدية للحملة', + }, + seo: { + label: 'تحسين محركات البحث', + objective: 'رفع قابلية اكتشاف المشروع عبر كلمات مفتاحية مناسبة', + task: 'اختر 5 كلمات مفتاحية وصفحة هبوط مناسبة', + }, +}; + +/** + * Create the first complete marketing planning slice: plan + channel budgets + starter tasks. + */ +router.post('/workflow', wrapAsync(async (req, res) => { + const data = req.body.data || {}; + const selectedChannels = Array.isArray(data.selected_channels) + ? data.selected_channels.filter((channel) => allowedWorkflowChannels.includes(channel)) + : []; + const totalBudget = Number(data.total_budget); + + if (!data.project_name || !data.marketing_goal || !data.target_audience || !Number.isFinite(totalBudget) || totalBudget <= 0 || !selectedChannels.length) { + throw new ValidationError('errors.validation.message'); + } + + const transaction = await db.sequelize.transaction(); + + try { + const marketingPlan = await Marketing_plansDBApi.create( + { + project_name: data.project_name.trim(), + marketing_goal: data.marketing_goal.trim(), + target_audience: data.target_audience.trim(), + total_budget: totalBudget, + status: data.status || 'draft', + notes: data.notes || 'تم إنشاؤها من معمل خطوات التسويق.', + }, + { currentUser: req.currentUser, transaction }, + ); + + const baseAllocation = Number((totalBudget / selectedChannels.length).toFixed(2)); + let assignedBudget = 0; + const channels = []; + const tasks = []; + + for (let index = 0; index < selectedChannels.length; index += 1) { + const channel = selectedChannels[index]; + const allocatedBudget = index === selectedChannels.length - 1 + ? Number((totalBudget - assignedBudget).toFixed(2)) + : baseAllocation; + + assignedBudget += allocatedBudget; + + const planChannel = await Plan_channelsDBApi.create( + { + channel, + allocated_budget: allocatedBudget, + objective: channelCopy[channel].objective, + marketing_plan: marketingPlan.id, + }, + { currentUser: req.currentUser, transaction }, + ); + + const campaignTask = await Campaign_tasksDBApi.create( + { + title: channelCopy[channel].task, + description: `مهمة بداية لقناة ${channelCopy[channel].label} ضمن خطة ${data.project_name.trim()}.`, + stage: index === 0 ? 'doing' : 'to_do', + channel, + priority: index + 1, + marketing_plan: marketingPlan.id, + }, + { currentUser: req.currentUser, transaction }, + ); + + channels.push(planChannel); + tasks.push(campaignTask); + } + + await transaction.commit(); + + res.status(200).send({ + plan: marketingPlan, + channels, + tasks, + }); + } catch (error) { + await transaction.rollback(); + throw error; + } +})); + /** * @swagger * /api/budgets/bulk-import: diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -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' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -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' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 97ca4db..c7269d8 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -32,6 +32,12 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiShieldAccountOutline ?? icon.mdiTable, permissions: 'READ_PERMISSIONS' }, + { + href: '/marketing-studio', + label: 'خطوات التسويق', + icon: icon.mdiChartTimelineVariant, + permissions: 'READ_MARKETING_PLANS' + }, { href: '/marketing_plans/marketing_plans-list', label: 'Marketing plans', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 51f9e0b..e7abec2 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,101 @@ +import React, { ReactElement } from 'react' +import Head from 'next/head' +import Link from 'next/link' +import LayoutGuest from '../layouts/Guest' +import { getPageTitle } from '../config' -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; -import Head from 'next/head'; -import Link from 'next/link'; -import BaseButton from '../components/BaseButton'; -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 { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; - - -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('image'); - const [contentPosition, setContentPosition] = useState('left'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'خطوات التسويق' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; +const IndexPage = () => { + const channels = ['التواصل الاجتماعي', 'الإعلانات', 'البريد الإلكتروني', 'SEO'] return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('خطوات التسويق')} - - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+
+
+ - - -
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+
+
+ + تطبيق عربي حديث لبناء خطط التسويق خطوة بخطوة + +
+

+ خطط حملتك، وزّع ميزانيتك، وتابع التنفيذ في مكان واحد. +

+

+ صُمم خطوات التسويق للمسوقين وأصحاب المشاريع الصغيرة: نموذج خطة واضح، جدول خطط محفوظ، كانبان مهام، ولوحة توزيع ميزانية حسب القنوات. +

+
+
+ + ابدأ بناء خطة + + + تسجيل الدخول + +
+
+ {channels.map((channel) => ( +
+ {channel} +
+ ))} +
+
-
- ); +
+
+
+
+
+
+
+

لوحة الخطة

+

إطلاق متجر العطور

+
+ قيد التنفيذ +
+
+ {[['الإعلانات', '42%', '#F97316'], ['التواصل', '28%', '#7C3AED'], ['البريد', '18%', '#0891B2'], ['SEO', '12%', '#16A34A']].map(([label, value, color]) => ( +
+
{label}{value}
+
+
+ ))} +
+
+
+ {['للعمل', 'قيد التنفيذ', 'تم'].map((stage, index) => ( +
+

{stage}

+
+
+ {index === 1 &&
} +
+
+ ))} +
+
+
+
+ + + + ) } -Starter.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; +IndexPage.getLayout = function getLayout(page: ReactElement) { + return {page} +} +export default IndexPage diff --git a/frontend/src/pages/marketing-studio.tsx b/frontend/src/pages/marketing-studio.tsx new file mode 100644 index 0000000..435f38a --- /dev/null +++ b/frontend/src/pages/marketing-studio.tsx @@ -0,0 +1,483 @@ +import React, { ReactElement, useEffect, useMemo, useState } from 'react' +import Head from 'next/head' +import axios from 'axios' +import * as icon from '@mdi/js' +import BaseButton from '../components/BaseButton' +import CardBox from '../components/CardBox' +import SectionMain from '../components/SectionMain' +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' +import LayoutAuthenticated from '../layouts/Authenticated' +import { getPageTitle } from '../config' + +type MarketingPlan = { + id: string + project_name: string + marketing_goal: string + target_audience: string + total_budget: string | number + status: 'draft' | 'in_progress' | 'completed' | 'archived' + createdAt?: string +} + +type PlanChannel = { + id: string + channel: ChannelKey + allocated_budget: string | number + objective?: string + marketing_plan?: MarketingPlan +} + +type CampaignTask = { + id: string + title: string + description?: string + stage: StageKey + channel: ChannelKey | 'general' + priority?: number + marketing_plan?: MarketingPlan +} + +type ChannelKey = 'social' | 'ads' | 'email' | 'seo' +type StageKey = 'to_do' | 'doing' | 'done' + +const channels: { key: ChannelKey; label: string; accent: string }[] = [ + { key: 'social', label: 'التواصل الاجتماعي', accent: '#7C3AED' }, + { key: 'ads', label: 'الإعلانات', accent: '#F97316' }, + { key: 'email', label: 'البريد الإلكتروني', accent: '#0891B2' }, + { key: 'seo', label: 'SEO', accent: '#16A34A' }, +] + +const statusLabels: Record = { + draft: 'مسودة', + in_progress: 'قيد التنفيذ', + completed: 'مكتملة', + archived: 'مؤرشفة', +} + +const stageLabels: Record = { + to_do: 'للعمل', + doing: 'قيد التنفيذ', + done: 'تم', +} + +const defaultForm = { + project_name: '', + marketing_goal: '', + target_audience: '', + total_budget: '', + selected_channels: ['social', 'ads'] as ChannelKey[], +} + +const formatBudget = (value: string | number) => + new Intl.NumberFormat('ar', { maximumFractionDigits: 0 }).format(Number(value || 0)) + +const MarketingStudio = () => { + const [form, setForm] = useState(defaultForm) + const [plans, setPlans] = useState([]) + const [planChannels, setPlanChannels] = useState([]) + const [tasks, setTasks] = useState([]) + const [selectedPlanId, setSelectedPlanId] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; text: string } | null>(null) + + const selectedPlan = useMemo( + () => plans.find((plan) => plan.id === selectedPlanId) || plans[0], + [plans, selectedPlanId], + ) + + const visibleTasks = useMemo(() => { + if (!selectedPlan) return tasks + return tasks.filter((task) => task.marketing_plan?.id === selectedPlan.id) + }, [selectedPlan, tasks]) + + const visibleChannels = useMemo(() => { + if (!selectedPlan) return planChannels + return planChannels.filter((channel) => channel.marketing_plan?.id === selectedPlan.id) + }, [selectedPlan, planChannels]) + + const loadWorkspace = async (planToSelect?: string) => { + setLoading(true) + setFeedback(null) + + try { + const [plansResponse, channelsResponse, tasksResponse] = await Promise.all([ + axios.get('marketing_plans?limit=50&page=0'), + axios.get('plan_channels?limit=200&page=0'), + axios.get('campaign_tasks?limit=200&page=0'), + ]) + + const loadedPlans = plansResponse.data.rows || [] + setPlans(loadedPlans) + setPlanChannels(channelsResponse.data.rows || []) + setTasks(tasksResponse.data.rows || []) + + if (planToSelect) { + setSelectedPlanId(planToSelect) + } else if (!selectedPlanId && loadedPlans[0]) { + setSelectedPlanId(loadedPlans[0].id) + } + } catch (error) { + console.error('Failed to load marketing workspace', error) + setFeedback({ type: 'error', text: 'تعذر تحميل مساحة العمل. تحقق من الاتصال أو الصلاحيات.' }) + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadWorkspace() + }, []) + + const toggleChannel = (channel: ChannelKey) => { + setForm((current) => ({ + ...current, + selected_channels: current.selected_channels.includes(channel) + ? current.selected_channels.filter((item) => item !== channel) + : [...current.selected_channels, channel], + })) + } + + const submitPlan = async (event: React.FormEvent) => { + event.preventDefault() + const budget = Number(form.total_budget) + + if (!form.project_name.trim() || !form.marketing_goal.trim() || !form.target_audience.trim() || !budget || budget <= 0 || !form.selected_channels.length) { + setFeedback({ type: 'error', text: 'أكمل اسم المشروع والهدف والجمهور والميزانية واختر قناة واحدة على الأقل.' }) + return + } + + setSaving(true) + setFeedback(null) + + try { + const response = await axios.post('marketing_plans/workflow', { + data: { + ...form, + total_budget: budget, + status: 'draft', + }, + }) + + setForm(defaultForm) + setFeedback({ type: 'success', text: 'تم إنشاء الخطة مع تقسيم الميزانية ومهام البداية.' }) + await loadWorkspace(response.data.plan.id) + } catch (error) { + console.error('Failed to create marketing plan workflow', error) + setFeedback({ type: 'error', text: 'تعذر إنشاء الخطة. راجع البيانات وحاول مرة أخرى.' }) + } finally { + setSaving(false) + } + } + + const updatePlanStatus = async (plan: MarketingPlan, status: MarketingPlan['status']) => { + try { + await axios.put(`marketing_plans/${plan.id}`, { id: plan.id, data: { status } }) + await loadWorkspace(plan.id) + } catch (error) { + console.error('Failed to update plan status', error) + setFeedback({ type: 'error', text: 'لم يتم تحديث حالة الخطة.' }) + } + } + + const moveTask = async (task: CampaignTask, stage: StageKey) => { + try { + await axios.put(`campaign_tasks/${task.id}`, { + id: task.id, + data: { stage, completed_at: stage === 'done' ? new Date().toISOString() : null }, + }) + await loadWorkspace(selectedPlan?.id) + } catch (error) { + console.error('Failed to move campaign task', error) + setFeedback({ type: 'error', text: 'تعذر نقل المهمة بين الأعمدة.' }) + } + } + + const totalVisibleBudget = visibleChannels.reduce((sum, channel) => sum + Number(channel.allocated_budget || 0), 0) + + return ( + <> + + {getPageTitle('خطوات التسويق')} + + +
+
+
+
+ + معمل التخطيط التسويقي خطوة بخطوة + +
+

خطوات التسويق

+

+ أنشئ خطة تسويق واضحة، وزّع الميزانية تلقائياً على القنوات، ثم تابع مهام التنفيذ على لوحة كانبان واحدة. +

+
+
+
+
{plans.length}
+
خطط محفوظة
+
+
+
{tasks.length}
+
مهام حملة
+
+
+
{formatBudget(totalVisibleBudget)}
+
ميزانية معروضة
+
+
+
+
+

الخطة النشطة

+ {selectedPlan ? ( +
+

{selectedPlan.project_name}

+

{selectedPlan.marketing_goal}

+
+ {statusLabels[selectedPlan.status]} + {formatBudget(selectedPlan.total_budget)} ر.س +
+
+ ) : ( +

ابدأ بإنشاء أول خطة لتظهر تفاصيلها هنا.

+ )} +
+
+
+ + + + + + {feedback && ( +
+ {feedback.text} +
+ )} + +
+ +
+
+

١. أنشئ خطة جديدة

+

بيانات المشروع التسويقي

+
+ + + +