/* ============================================================ course_data.jsx — учебная программа + прогресс (ПРОТОТИП) · автор редактирует программу в admin.html · ученик смотрит уроки в learn.html и продолжает с места остановки Данные в localStorage: al_curriculum_v1 — программа: модули → уроки (у урока — видео) al_progress_v1 — прогресс: { phone: { lessonId: {t, completed, at} } } ПРОДАКШЕН: программу и прогресс хранит сервер, видео — в видеохранилище/CDN. Видео по ссылке (YouTube/Vimeo/прямой MP4) работает по-настоящему и здесь; выбор локального файла — только демонстрация в пределах текущей сессии. ============================================================ */ const CURRICULUM_KEY = "al_curriculum_v1"; const PROGRESS_KEY = "al_progress_v1"; /* публичные демо-видео, чтобы плеер работал сразу «из коробки» */ const _SMP = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"; const _SMP2 = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4"; /* программа по умолчанию — 7 блоков курса; видео у первых уроков заполнены для демо */ const DEFAULT_CURRICULUM = [ { id: "m1", title: "Базовая подготовка", lessons: [ { id: "m1l1", title: "Знакомство с рынком и торговым терминалом", desc: "С чего начинается системная торговля.", video: _SMP }, { id: "m1l2", title: "Котировки и формирование цены", desc: "", video: _SMP2 }, { id: "m1l3", title: "Настройка торговой платформы", desc: "", video: "" }, { id: "m1l4", title: "Работа со Stop Loss и Take Profit", desc: "", video: "" }, ]}, { id: "m2", title: "Чтение рынка", lessons: [ { id: "m2l1", title: "Экстремумы и их значение", desc: "", video: "" }, { id: "m2l2", title: "Сильные ценовые уровни", desc: "", video: "" }, { id: "m2l3", title: "Трендовые линии и каналы", desc: "", video: "" }, { id: "m2l4", title: "Отскоки, пробои и импульсы", desc: "", video: "" }, ]}, { id: "m3", title: "Работа со свечным графиком", lessons: [ { id: "m3l1", title: "Как на самом деле работают свечи", desc: "", video: "" }, { id: "m3l2", title: "Чтение поведения участников рынка", desc: "", video: "" }, ]}, { id: "m4", title: "Индикаторы и инструменты анализа", lessons: [ { id: "m4l1", title: "Уровни Фибоначчи и кластеры", desc: "", video: "" }, { id: "m4l2", title: "Полосы Боллинджера и скользящие средние", desc: "", video: "" }, { id: "m4l3", title: "Осцилляторы и защита от ложных сигналов", desc: "", video: "" }, ]}, { id: "m5", title: "Продвинутый анализ", lessons: [ { id: "m5l1", title: "Фигуры технического анализа", desc: "", video: "" }, { id: "m5l2", title: "Smart Money и Price Action", desc: "", video: "" }, { id: "m5l3", title: "Сломы структуры и работа с ликвидностью", desc: "", video: "" }, ]}, { id: "m6", title: "Понимание рыночной среды", lessons: [ { id: "m6l1", title: "Торговые сессии и ликвидность", desc: "", video: "" }, { id: "m6l2", title: "Экономический календарь", desc: "", video: "" }, { id: "m6l3", title: "Ложные пробои и рыночные ловушки", desc: "", video: "" }, ]}, { id: "m7", title: "Практика и сопровождение", lessons: [ { id: "m7l1", title: "Разбор сделок на реальных примерах", desc: "", video: "" }, { id: "m7l2", title: "Ежедневный разбор рынка в закрытом канале", desc: "", video: "" }, ]}, ]; /* ---- программа ---- */ function loadCurriculum() { try { const s = localStorage.getItem(CURRICULUM_KEY); return s ? JSON.parse(s) : DEFAULT_CURRICULUM; } catch (e) { return DEFAULT_CURRICULUM; } } function saveCurriculum(cur) { try { localStorage.setItem(CURRICULUM_KEY, JSON.stringify(cur)); } catch (e) {} } function resetCurriculum() { try { localStorage.removeItem(CURRICULUM_KEY); } catch (e) {} } function flatLessons(cur) { const out = []; (cur || []).forEach((m) => (m.lessons || []).forEach((l) => out.push({ ...l, moduleId: m.id, moduleTitle: m.title }))); return out; } /* ---- прогресс (по пользователю) ---- */ function _loadProgress() { try { return JSON.parse(localStorage.getItem(PROGRESS_KEY) || "{}"); } catch (e) { return {}; } } function _saveProgress(p) { try { localStorage.setItem(PROGRESS_KEY, JSON.stringify(p)); } catch (e) {} } function userProgress(phone) { return _loadProgress()[phone] || {}; } function setLessonProgress(phone, lessonId, patch) { if (!phone) return; const all = _loadProgress(); const up = all[phone] || (all[phone] = {}); up[lessonId] = { ...(up[lessonId] || {}), ...patch, at: Date.now() }; _saveProgress(all); // если есть сервер и сессия — отправляем позицию/отметку туда (fire-and-forget) if (typeof backendUp === "function" && typeof getToken === "function" && getToken()) { backendUp().then((ok) => { if (!ok) return; const body = {}; if ("t" in patch) body.t = patch.t; if ("completed" in patch) body.completed = patch.completed; apiPut("/api/progress/" + encodeURIComponent(lessonId), body).catch(() => {}); }); } } /* подтянуть прогресс пользователя с сервера в локальный кэш */ async function syncProgressFromServer(phone) { if (!phone || typeof backendUp !== "function") return; if (!(await backendUp()) || !getToken()) return; try { const r = await apiGet("/api/progress"); if (r && r.progress) { const all = _loadProgress(); all[phone] = r.progress; _saveProgress(all); } } catch (e) {} } /* подтянуть программу с сервера (общую для всех учеников) */ async function syncCurriculumFromServer() { if (typeof backendUp !== "function" || !(await backendUp())) return null; try { const r = await apiGet("/api/curriculum", { auth: false }); if (r && Array.isArray(r.curriculum)) { saveCurriculum(r.curriculum); return r.curriculum; } } catch (e) {} return null; } function lastLessonId(phone) { const up = userProgress(phone); let best = null, bestAt = -1; Object.keys(up).forEach((id) => { if ((up[id].at || 0) > bestAt) { bestAt = up[id].at; best = id; } }); return best; } function completedCount(phone) { const up = userProgress(phone); return Object.keys(up).filter((id) => up[id].completed).length; } /* ---- определение типа видео ---- */ function parseVideo(url) { if (!url) return { type: "none", src: "" }; const yt = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]{6,})/); if (yt) return { type: "embed", src: "https://www.youtube.com/embed/" + yt[1] }; const vim = url.match(/vimeo\.com\/(?:video\/)?(\d+)/); if (vim) return { type: "embed", src: "https://player.vimeo.com/video/" + vim[1] }; // серверный /media/... → абсолютный адрес бэкенда; blob:/http(s) — как есть const src = (url[0] === "/" && typeof mediaUrl === "function") ? mediaUrl(url) : url; return { type: "file", src }; } Object.assign(window, { DEFAULT_CURRICULUM, loadCurriculum, saveCurriculum, resetCurriculum, flatLessons, userProgress, setLessonProgress, lastLessonId, completedCount, parseVideo, syncProgressFromServer, syncCurriculumFromServer, });