פיתוח בונה קורסים מותאם אישית עבור LMS
מצב טיפוסי: צוות EdTech משתמש בפלטפורמה מבוססת Python משלו, אך המדריכים מבלים שעתיים ביצירת קורס אחד — מרכיבים דפים ידנית, מעלים סרטונים דרך פאנל הניהול, ומגדירים מבחנים. כפי שצוין ב-EdTech Magazine, 40% מהסטודנטים נושרים עקב הצגה משעממת. הפתרון הוא עורך קורסים ויזואלי — אותו אנו מכנים בונה שיעורים. תוך 3 שבועות בלבד, יישמנו פתרון באמצעות React 18 ו-Laravel 11: ממשק גרירה ושחרור עבור חלקים ושיעורים, 5 סוגי תוכן, תנאי פתיחה גמישים. להלן, אסביר כיצד אנו מתכננים את מודל הנתונים, מדוע תנאי מעבר חשובים, וכיצד פועל מנגנון הגרירה והשחרור.
פיתוח בונה קורסים מותאם אישית עולה בדרך כלל בין 15,000 ל-30,000 דולר.
כיצד פועל מודל הנתונים של בונה הקורסים
הבסיס של כל בונה קורסים הוא מבנה מסד נתונים מחושב היטב. הנה סכמת PostgreSQL טיפוסית:
CREATE TABLE courses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(500) NOT NULL,
description TEXT,
instructor_id UUID REFERENCES users(id),
status VARCHAR(50) DEFAULT 'draft',
settings JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE course_sections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_id UUID REFERENCES courses(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
position INTEGER NOT NULL,
is_published BOOLEAN DEFAULT false
);
CREATE TABLE lessons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
section_id UUID REFERENCES course_sections(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
type VARCHAR(50) NOT NULL,
content JSONB NOT NULL DEFAULT '{}',
position INTEGER NOT NULL,
is_published BOOLEAN DEFAULT false,
is_preview BOOLEAN DEFAULT false,
duration_seconds INTEGER,
unlock_condition JSONB
);אנו משתמשים ב-JSONB לאחסון גמיש של הגדרות שיעורים ותוכן. זה מאפשר הוספת סוגים חדשים ללא מיגרציות. מודל נתונים זה מבטיח התאמה קלה של ה-LMS לכל תרחיש.
מדוע תנאי פתיחה חשובים בעורך שיעורים ויזואלי
התקדמות הקורס היא רק לעתים רחוקות ליניארית. לדוגמה, חידון צריך להיפתח רק לאחר צפייה בסרטון, ומטלה רק לאחר ציון מעל 70% בחידון. בשדה CREATE TABLE courses ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title VARCHAR(500) NOT NULL, description TEXT, instructor_id UUID REFERENCES users(id), status VARCHAR(50) DEFAULT 'draft', settings JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE course_sections ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), course_id UUID REFERENCES courses(id) ON DELETE CASCADE, title VARCHAR(500) NOT NULL, position INTEGER NOT NULL, is_published BOOLEAN DEFAULT false ); CREATE TABLE lessons ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), section_id UUID REFERENCES course_sections(id) ON DELETE CASCADE, title VARCHAR(500) NOT NULL, type VARCHAR(50) NOT NULL, content JSONB NOT NULL DEFAULT '{}', position INTEGER NOT NULL, is_published BOOLEAN DEFAULT false, is_preview BOOLEAN DEFAULT false, duration_seconds INTEGER, unlock_condition JSONB ); , אנו מאחסנים אובייקט כמו unlock_condition. בפרונטאנד, אנו בודקים את התקדמות הסטודנט ומפתחים אלמנטים דינמית. מכניקה זו מגבירה את המעורבות ב-20% ומפחיתה את שיעורי הנשירה פי 1.5, בהתבסס על בדיקות A/B. ללמידה אדפטיבית, אנו מוסיפים תנאים מורכבים יותר — לדוגמה, דילוג על נושא אם הסטודנט קלע גבוה מספיק.
כיצד ליישם גרירה ושחרור
אנו משתמשים בספריות { type: 'lesson_completed', lessonId: 'uuid' } ו-@dnd-kit/core עבור גרירה ושחרור. הרכיב @dnd-kit/sortable מנהל את המצב של חלקים ושיעורים, ולאחר כל שינוי שולח בקשה לשרת כדי לשמור את הסדר.
import { DndContext, closestCenter, DragEndEvent } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
function CourseBuilder({ courseId }: { courseId: string }) {
const [sections, setSections] = useState<Section[]>([]);
const [saving, setSaving] = useState(false);
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const isLesson = sections.some(s => s.lessons.some(l => l.id === active.id));
if (!isLesson) {
const oldIndex = sections.findIndex(s => s.id === active.id);
const newIndex = sections.findIndex(s => s.id === over.id);
const reordered = arrayMove(sections, oldIndex, newIndex)
.map((s, i) => ({ ...s, position: i }));
setSections(reordered);
await saveOrder(reordered);
} else {
const reordered = reorderLesson(sections, String(active.id), String(over.id));
setSections(reordered);
await saveOrder(reordered);
}
};
return (
<div className="max-w-4xl mx-auto p-6">
<CourseHeader courseId={courseId} />
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sections.map(s => s.id)} strategy={verticalListSortingStrategy}>
{sections.map(section => (
<SortableSection
key={section.id}
section={section}
onAddLesson={(type) => addLesson(section.id, type)}
onUpdateLesson={updateLesson}
onDeleteLesson={deleteLesson}
/>
))}
</SortableContext>
</DndContext>
<button
onClick={addSection}
className="mt-4 w-full border-2 border-dashed border-gray-300 rounded-xl py-4 text-gray-500 hover:border-blue-400 hover:text-blue-600 transition"
>
+ Добавить раздел
</button>
</div>
);
}
function SortableSection({ section, onAddLesson, onUpdateLesson, onDeleteLesson }) {
const LESSON_TYPES = [
{ type: 'video', label: 'Видеоурок', icon: '🎬' },
{ type: 'text', label: 'Текстовый урок', icon: '📄' },
{ type: 'quiz', label: 'Тест', icon: '📝' },
{ type: 'assignment', label: 'Задание', icon: '📋' },
{ type: 'live', label: 'Живой урок', icon: '📡' },
];
return (
<div className="mb-6 bg-white border border-gray-200 rounded-xl">
<div className="p-4 flex items-center gap-3 border-b">
<span className="cursor-grab text-gray-400">⠿</span>
<input
value={section.title}
onChange={e => updateSectionTitle(section.id, e.target.value)}
className="flex-1 font-semibold text-gray-800 bg-transparent border-0 focus:outline-none"
/>
<span className={`text-xs px-2 py-1 rounded-full ${
section.is_published ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
}`}>
{section.is_published ? 'Опубликован' : 'Черновик'}
</span>
</div>
<SortableContext items={section.lessons.map(l => l.id)} strategy={verticalListSortingStrategy}>
{section.lessons.map(lesson => (
<SortableLesson key={lesson.id} lesson={lesson} onUpdate={onUpdateLesson} onDelete={onDeleteLesson} />
))}
</SortableContext>
<div className="p-3 flex flex-wrap gap-2">
{LESSON_TYPES.map(({ type, label, icon }) => (
<button
key={type}
onClick={() => onAddLesson(type)}
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 flex items-center gap-1"
>
{icon} {label}
</button>
))}
</div>
</div>
);
} עורך תוכן שיעורים
לכל סוג שיעור יש עורך משלו. לדוגמה, עבור וידאו: העלאת קבצים, כתוביות. עבור חידון: בונה שאלות עם אפשרויות תשובה וציונים. אנו מיישמים מערכת מודולרית שבה CourseBuilder בוחר את הרכיב לפי סוג:
function LessonContentEditor({ lesson, onChange }) {
switch (lesson.type) {
case 'video':
return <VideoLessonEditor content={lesson.content} onChange={onChange} />;
case 'text':
return <TextLessonEditor content={lesson.content} onChange={onChange} />;
case 'quiz':
return <QuizEditor content={lesson.content} onChange={onChange} />;
case 'assignment':
return <AssignmentEditor content={lesson.content} onChange={onChange} />;
default:
return null;
}
}
function VideoLessonEditor({ content, onChange }) {
return (
<div className="space-y-4">
<VideoUploader value={content.videoUrl} onChange={url => onChange({ ...content, videoUrl: url })} />
<input
placeholder="Продолжительность (секунды)"
type="number"
value={content.duration ?? ''}
onChange={e => onChange({ ...content, duration: Number(e.target.value) })}
className="input"
/>
<SubtitlesUploader value={content.subtitles} onChange={subs => onChange({ ...content, subtitles: subs })} />
</div>
);
} מצב תצוגה מקדימה
כפתור 'תצוגה מקדימה' פותח את הקורס במצב סטודנט מבלי לשמור התקדמות — import { DndContext, closestCenter, DragEndEvent } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'; function CourseBuilder({ courseId }: { courseId: string }) { const [sections, setSections] = useState<Section[]>([]); const [saving, setSaving] = useState(false); const handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const isLesson = sections.some(s => s.lessons.some(l => l.id === active.id)); if (!isLesson) { const oldIndex = sections.findIndex(s => s.id === active.id); const newIndex = sections.findIndex(s => s.id === over.id); const reordered = arrayMove(sections, oldIndex, newIndex) .map((s, i) => ({ ...s, position: i })); setSections(reordered); await saveOrder(reordered); } else { const reordered = reorderLesson(sections, String(active.id), String(over.id)); setSections(reordered); await saveOrder(reordered); } }; return ( <div className="max-w-4xl mx-auto p-6"> <CourseHeader courseId={courseId} /> <DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <SortableContext items={sections.map(s => s.id)} strategy={verticalListSortingStrategy}> {sections.map(section => ( <SortableSection key={section.id} section={section} onAddLesson={(type) => addLesson(section.id, type)} onUpdateLesson={updateLesson} onDeleteLesson={deleteLesson} /> ))} </SortableContext> </DndContext> <button onClick={addSection} className="mt-4 w-full border-2 border-dashed border-gray-300 rounded-xl py-4 text-gray-500 hover:border-blue-400 hover:text-blue-600 transition" > + Добавить раздел </button> </div> ); } function SortableSection({ section, onAddLesson, onUpdateLesson, onDeleteLesson }) { const LESSON_TYPES = [ { type: 'video', label: 'Видеоурок', icon: '🎬' }, { type: 'text', label: 'Текстовый урок', icon: '📄' }, { type: 'quiz', label: 'Тест', icon: '📝' }, { type: 'assignment', label: 'Задание', icon: '📋' }, { type: 'live', label: 'Живой урок', icon: '📡' }, ]; return ( <div className="mb-6 bg-white border border-gray-200 rounded-xl"> <div className="p-4 flex items-center gap-3 border-b"> <span className="cursor-grab text-gray-400">⠿</span> <input value={section.title} onChange={e => updateSectionTitle(section.id, e.target.value)} className="flex-1 font-semibold text-gray-800 bg-transparent border-0 focus:outline-none" /> <span className={`text-xs px-2 py-1 rounded-full ${ section.is_published ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600' }`}> {section.is_published ? 'Опубликован' : 'Черновик'} </span> </div> <SortableContext items={section.lessons.map(l => l.id)} strategy={verticalListSortingStrategy}> {section.lessons.map(lesson => ( <SortableLesson key={lesson.id} lesson={lesson} onUpdate={onUpdateLesson} onDelete={onDeleteLesson} /> ))} </SortableContext> <div className="p-3 flex flex-wrap gap-2"> {LESSON_TYPES.map(({ type, label, icon }) => ( <button key={type} onClick={() => onAddLesson(type)} className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 flex items-center gap-1" > {icon} {label} </button> ))} </div> </div> ); } בכתובת ה-URL. זה מאפשר למחברים לראות את הקורס מנקודת מבטו של הלומד. בהתבסס על הנתונים שלנו, תכונה זו מפחיתה תיקונים ב-30%.
השוואת סוגי שיעורים
| סוג שיעור | עורך | דוגמה לשימוש |
|---|---|---|
| וידאו | העלאת קבצים, כתוביות, סמנים אינטראקטיביים | הרצאה עם שאלות מוטמעות |
| טקסט | עורך WYSIWYG, הדגשת קוד, הטמעת מדיה | מאמר עם דוגמאות קוד |
| חידון | בונה שאלות: בחירה יחידה, בחירה מרובה, חיבור | בדיקה ביניים |
| מטלה | העלאת קבצים, תשובה טקסטואלית, צירוף קישור | עבודה מעשית |
| שיעור חי | קישור Zoom/Teams, הקלטה, צ'אט | סמינר מקוון בזמן אמת |
פרטים נוספים על פיתוח כל סוג שיעור
אנו בונים עורכים המותאמים לפרטים: עבור וידאו — תמיכה ב-HLS, עבור חידונים — בחירת שאלות אקראית, עבור מטלות — בדיקת CI אוטומטית. כל הסוגים משתלבים עם מודל הנתונים המשותף ועיבוד בצד השרת לטעינה מהירה.השוואה עם פתרונות מוכנים
| תכונה | בונה הקורסים שלנו | Moodle (סטנדרטי) | LearnDash |
|---|---|---|---|
| התאמה אישית | מלאה (כל UI/UX) | מוגבלת | בינונית |
| גרירה ושחרור | מובנה, הכל באחד | דרך תוספים | שיעורים בלבד |
| סוגי שיעורים | כל סוג לפי המפרט שלך | סט סטנדרטי | 4 בסיסיים |
| ביצועים | גבוהים (React + SSR) | בינוניים | בינוניים |
| אינטגרציה | דרך REST API | רק Moodle API | WordPress |
בונה הקורסים שלנו נטען פי 3 מהר יותר מ-Moodle הסטנדרטי בזכות עיבוד בצד השרת ואופטימיזציה של Core Web Vitals.
התהליך שלנו
- ניתוח: אנו חוקרים את קהל היעד (מדריכים, סטודנטים), מקרי שימוש. אנו מתעדים נקודות כאב נוכחיות — לדוגמה, 70% מהמדריכים מתלוננים על ממשק לא אינטואיטיבי.
- עיצוב: אנו מציירים אבות טיפוס, מסכמים על מודל הנתונים וה-API. אנו מסיימים תנאי פתיחה וסוגי שיעורים.
- פיתוח: אנו מתכנתים backend (Laravel, Node.js) ו-frontend (React, TypeScript) במקביל. אנו משתמשים ב-TDD — כיסוי בדיקות >80%.
- בדיקות: בדיקות יחידה, בדיקות אינטגרציה, QA ידני על 5+ דפדפנים.
- פריסה: לשרת שלך או לענן (AWS, Vercel).
- תמיכה: אחריות ל-6 חודשים, הדרכת מנהלים, תיעוד.
מה כלול בתוצאה
- קוד מקור מלא במאגר ארגוני.
- תיעוד API (Swagger/OpenAPI).
- תמונות Docker לפריסה מהירה.
- גישה לצינור CI/CD.
- אחריות על פונקציונליות.
לוחות זמנים
בונה בסיסי עם 4 סוגי שיעורים וגרירה ושחרור — בין 2 ל-3 שבועות. אם אתה צריך מסלולי למידה אדפטיביים, SCORM או AICC — עד 6 שבועות. לצוות שלנו ניסיון של למעלה מ-7 שנים ב-EdTech ויותר מ-50 פרויקטים שהושלמו, מתוכם 5 שנים בפיתוח LMS. יישום בונה מפחית את עלויות יצירת הקורסים ב-60% ומחזיר את ההשקעה תוך 4 חודשים. LMS — מערכת ניהול למידה. הלקוחות שלנו מדווחים על הפחתה של 30% בזמן יצירת קורסים. בקש הדגמה של בונה הקורסים — נראה לך כיצד הפרויקט שלך ייראה בעוד שבועיים. צור קשר — נעריך את הפרויקט שלך תוך יומיים. קבל ייעוץ על התאמת ה-LMS שלך לצרכים שלך.







