/* ============================================================
Learn.jsx — личный кабинет обучения (плеер курса)
· дерево программы (модули → уроки) с отметками пройденного
· видео-плеер (файл/MP4 или встраиваемый YouTube/Vimeo)
· прогресс и «Продолжить обучение» с места остановки
============================================================ */
function LearnGate() {
const { t } = useT();
const [open, setOpen] = React.useState(false);
return (
{t("learn.gate")}
setOpen(true)}>{t("learn.gateBtn")}
setOpen(false)} initialMode="login" onAuthed={() => setOpen(false)} />
);
}
function LessonRow({ lesson, active, done, onClick }) {
return (
);
}
function VideoPane({ lesson, phone, onCompleted }) {
const { t } = useT();
const vidRef = React.useRef(null);
const lastSave = React.useRef(0);
const v = parseVideo(lesson.video);
// при смене урока — восстановить позицию для файла-видео
React.useEffect(() => {
if (v.type !== "file") return;
const el = vidRef.current; if (!el) return;
const saved = (userProgress(phone)[lesson.id] || {}).t || 0;
const onMeta = () => { if (saved > 0 && saved < (el.duration || 1e9)) { try { el.currentTime = saved; } catch (e) {} } };
el.addEventListener("loadedmetadata", onMeta);
return () => el.removeEventListener("loadedmetadata", onMeta);
}, [lesson.id, v.type, phone]);
const onTime = () => {
const el = vidRef.current; if (!el) return;
const now = Date.now();
if (now - lastSave.current > 4000) { // сохраняем позицию не чаще раза в 4с
lastSave.current = now;
setLessonProgress(phone, lesson.id, { t: Math.floor(el.currentTime) });
}
};
const onEnded = () => { setLessonProgress(phone, lesson.id, { completed: true, t: 0 }); onCompleted(); };
if (v.type === "none") {
return (
{t("learn.noVideo")}
);
}
if (v.type === "embed") {
return (
);
}
return (
);
}
/* карточка доступа к закрытому Telegram-каналу — только для купивших */
function SecretAccessCard() {
const { t } = useT();
// персональная одноразовая ссылка на закрытый канал — с бэкенда (по доступу).
// Фолбэк на общую ссылку из links.jsx, если сервера нет.
const [st, setSt] = React.useState({ loading: true });
React.useEffect(() => {
let alive = true;
const fallback = () => { if (alive) setSt({ loading: false, fallback: true }); };
if (typeof backendUp === "function" && typeof getToken === "function" && getToken()) {
backendUp().then((up) => {
if (!up) return fallback();
apiGet("/api/channel/link").then((r) => { if (alive) setSt({ loading: false, ...r }); }).catch(fallback);
});
} else fallback();
return () => { alive = false; };
}, []);
const staticUrl = (window.CHANNELS && window.CHANNELS.telegramClosed) || "";
const url = st.link || (st.fallback ? staticUrl : "");
const ready = !!url && url.indexOf("PLACEHOLDER") === -1;
const soon = !ready && st.configured === false; // доступ есть, но канал ещё не настроен автором
const inner = (
{t("learn.secretTitle")}
{ready ? "Персональная ссылка для входа в закрытый канал. Не передавайте её — она одноразовая." : t("learn.secretText")}
{st.loading ? "…" : ready ? "Вступить в канал" : soon ? "Скоро" : t("learn.secretSoon")}
{ready && }
);
const box = { display: "block", textDecoration: "none", marginBottom: 22, padding: "18px 18px",
borderRadius: 12, background: "var(--ink)" };
return ready
? {inner}
: {inner}
;
}
function LearnPage() {
const { t } = useT();
const { phone, user } = useAuth();
React.useEffect(() => { if (window.lucide) lucide.createIcons(); });
const [cur, setCur] = React.useState(loadCurriculum);
const flat = React.useMemo(() => flatLessons(cur), [cur]);
const [tick, setTick] = React.useState(0); // форсируем перерисовку прогресса
const [currentId, setCurrentId] = React.useState(() => (phone && lastLessonId(phone)) || (flat[0] && flat[0].id));
// программа — с сервера (общая для всех), если он доступен
React.useEffect(() => {
let alive = true;
if (window.syncCurriculumFromServer) syncCurriculumFromServer().then((c) => { if (alive && c) setCur(c); });
return () => { alive = false; };
}, []);
// прогресс — с сервера в кэш, затем встаём на последний открытый урок
React.useEffect(() => {
if (phone && window.syncProgressFromServer) {
syncProgressFromServer(phone).then(() => {
const last = lastLessonId(phone);
if (last) setCurrentId(last);
setTick((n) => n + 1);
});
}
}, [phone]);
// фиксируем последний открытый урок как точку возврата («Продолжить»)
React.useEffect(() => { if (phone && currentId) setLessonProgress(phone, currentId, {}); }, [currentId, phone]);
if (!phone) return ();
if (!flat.length) {
return (
);
}
const hasPurchase = !!(user && (user.purchased || []).length);
const prog = userProgress(phone);
const done = completedCount(phone);
const total = flat.length;
const pct = Math.round((done / total) * 100);
const current = flat.find((l) => l.id === currentId) || flat[0];
const idx = flat.findIndex((l) => l.id === current.id);
const go = (id) => { setCurrentId(id); window.scrollTo({ top: 0, behavior: "smooth" }); };
const resume = () => { const last = lastLessonId(phone); if (last) go(last); };
const markDone = () => { setLessonProgress(phone, current.id, { completed: true }); setTick((n) => n + 1); };
const isDone = (id) => !!(userProgress(phone)[id] || {}).completed;
const wrap = { maxWidth: 1200, margin: "0 auto", padding: "0 28px" };
return (
{t("learn.heading")}
{done} {t("learn.of")} {total} {t("learn.lessonsDone")} · {pct}%
{lastLessonId(phone) &&
{t("learn.resume")}}
{/* прогресс-бар */}
{/* ---- программа (сайдбар) ---- */}
{/* ---- плеер + описание ---- */}
setTick((n) => n + 1)} />
{current.title}
{current.desc && {current.desc}
}
{ if (idx > 0) go(flat[idx - 1].id); }}
style={{ opacity: idx > 0 ? 1 : 0.4, pointerEvents: idx > 0 ? "auto" : "none" }}>{t("learn.prev")}
{isDone(current.id) ? t("learn.done") : t("learn.markDone")}
{ if (idx < total - 1) go(flat[idx + 1].id); }}
style={{ opacity: idx < total - 1 ? 1 : 0.4, pointerEvents: idx < total - 1 ? "auto" : "none" }}>{t("learn.next")}
);
}
Object.assign(window, { LearnPage });