/* ===== App shell + router ===== */
const { useState:uS, useEffect:uE } = React;
const DEFAULT_ROUTE = {n:'home', p:null};
const DEFAULT_USER = { name:'Айбек Усенов', email:'aibek.u@mail.kg', role:'user', cabinet_access:false, league_member:false };
const AUTH_CACHE_KEY = 'krsu_auth_user';
const LANG_CACHE_KEY = 'krsu_site_lang';
const STAFF_ROLES = ['admin','manager','editor','league_sport','league_infrastructure','league_partners','league_support'];
const APP_BASE_PATH = (() => {
const raw = window.__APP_BASE_PATH__ || '/';
if(raw === '/') return '/';
return `${String(raw).replace(/\/+$/,'')}/`;
})();
function readPreferredLang(){
try{
return localStorage.getItem(LANG_CACHE_KEY)==='ky' ? 'ky' : 'ru';
}catch(e){
return 'ru';
}
}
function writePreferredLang(lang){
try{
localStorage.setItem(LANG_CACHE_KEY, lang==='ky' ? 'ky' : 'ru');
}catch(e){}
}
function setGoogleTranslateCookie(targetLang){
const value = `/ru/${targetLang}`;
const host = location.hostname;
const domain = host.includes('.') ? `.${host}` : host;
document.cookie = `googtrans=${value}; path=/`;
document.cookie = `googtrans=${value}; path=/; domain=${host}`;
if(domain !== host) document.cookie = `googtrans=${value}; path=/; domain=${domain}`;
}
function clearGoogleTranslateCookie(){
const host = location.hostname;
const domain = host.includes('.') ? `.${host}` : host;
const expire = 'Thu, 01 Jan 1970 00:00:00 GMT';
[
`googtrans=; expires=${expire}; path=/`,
`googtrans=; expires=${expire}; path=/; domain=${host}`,
domain !== host ? `googtrans=; expires=${expire}; path=/; domain=${domain}` : '',
].filter(Boolean).forEach(cookie=>{ document.cookie = cookie; });
}
function initGoogleTranslateElement(){
if(window.__googleTranslateInitialized) return true;
if(!window.google?.translate?.TranslateElement) return false;
const holder = document.getElementById('google_translate_element');
if(!holder) return false;
holder.innerHTML = '';
new window.google.translate.TranslateElement({
pageLanguage:'ru',
includedLanguages:'ru,ky',
autoDisplay:false,
}, 'google_translate_element');
window.__googleTranslateInitialized = true;
return true;
}
function ensureGoogleTranslateScript(){
if(window.google?.translate?.TranslateElement){
initGoogleTranslateElement();
return Promise.resolve();
}
if(window.__googleTranslatePromise) return window.__googleTranslatePromise;
const promise = new Promise((resolve, reject)=>{
let settled = false;
const finish = (error=null)=>{
if(settled) return;
settled = true;
clearTimeout(timeout);
if(error) reject(error);
else resolve();
};
const timeout = setTimeout(()=>finish(new Error('Google Translate не ответил вовремя')), 15000);
window.googleTranslateElementInit = ()=>{
try{
if(initGoogleTranslateElement()) finish();
else finish(new Error('Не удалось создать переключатель Google Translate'));
}catch(error){
finish(error);
}
};
const existing = document.querySelector('script[data-google-translate]');
if(existing) existing.remove();
const script = document.createElement('script');
script.src = 'https://translate.google.com/translate_a/element.js?cb=googleTranslateElementInit';
script.async = true;
script.dataset.googleTranslate = 'true';
script.onerror = ()=>finish(new Error('Не удалось загрузить Google Translate'));
document.head.appendChild(script);
});
window.__googleTranslatePromise = promise.catch(error=>{
window.__googleTranslatePromise = null;
window.__googleTranslateInitialized = false;
throw error;
});
return window.__googleTranslatePromise;
}
function triggerGoogleLanguage(targetLang){
const combo = document.querySelector('.goog-te-combo');
if(!combo) return false;
combo.value = targetLang;
combo.dispatchEvent(new Event('change', { bubbles:true }));
return true;
}
function applyGoogleTranslate(targetLang){
setGoogleTranslateCookie(targetLang);
return ensureGoogleTranslateScript().then(()=>new Promise((resolve,reject)=>{
let attempts = 0;
const timer = setInterval(()=>{
attempts += 1;
if(triggerGoogleLanguage(targetLang)){
clearInterval(timer);
resolve();
}else if(attempts > 40){
clearInterval(timer);
reject(new Error('Переключатель Google Translate не появился'));
}
}, 250);
}));
}
function readCachedAuthUser(){
try{
const raw = localStorage.getItem(AUTH_CACHE_KEY) || sessionStorage.getItem(AUTH_CACHE_KEY);
if(!raw) return null;
const parsed = JSON.parse(raw);
if(parsed && parsed.email){
localStorage.setItem(AUTH_CACHE_KEY, JSON.stringify(parsed));
sessionStorage.removeItem(AUTH_CACHE_KEY);
}
return parsed && parsed.email ? parsed : null;
}catch(e){
return null;
}
}
function writeCachedAuthUser(nextUser){
try{
if(nextUser && nextUser.email){
const cacheUser={
id:nextUser.id,
name:nextUser.name,
email:nextUser.email,
role:nextUser.role,
league_member:!!nextUser.league_member,
cabinet_access:!!nextUser.cabinet_access,
};
localStorage.setItem(AUTH_CACHE_KEY, JSON.stringify(cacheUser));
}
else localStorage.removeItem(AUTH_CACHE_KEY);
sessionStorage.removeItem(AUTH_CACHE_KEY);
}catch(e){}
}
function parseRouteString(rawValue=''){
const raw = String(rawValue || '').replace(/^#/, '');
if(!raw) return DEFAULT_ROUTE;
const [name, ...rest] = raw.split('/');
return {n:name || 'home', p:rest.length ? decodeURIComponent(rest.join('/')) : null};
}
function parseRouteFromHash(){
return parseRouteString(location.hash);
}
function parseRouteFromPathname(pathname=location.pathname){
let localPath = String(pathname || '/');
if(APP_BASE_PATH !== '/' && localPath.startsWith(APP_BASE_PATH.slice(0, -1))){
localPath = localPath.slice(APP_BASE_PATH.length - 1) || '/';
}
const trimmed = localPath.replace(/^\/+|\/+$/g, '');
if(!trimmed) return DEFAULT_ROUTE;
const parts = trimmed.split('/');
if(parts[0] === 'news' && parts[1]) return {n:'news-item', p:decodeURIComponent(parts.slice(1).join('/'))};
if(parts[0] === 'news') return {n:'news', p:null};
if(parts[0] === 'races' && parts[1]) return {n:'race-item', p:decodeURIComponent(parts.slice(1).join('/'))};
if(parts[0] === 'races') return {n:'races', p:null};
if(parts[0] === 'league') return {n:'league', p:null};
if(parts[0] === 'rating') return {n:'pilot-rating', p:null};
if(parts[0] === 'pilots' && parts[1]) return {n:'public-pilot', p:decodeURIComponent(parts.slice(1).join('/'))};
if(parts[0] === 'services' && parts[1]) return {n:'service-detail', p:decodeURIComponent(parts.slice(1).join('/'))};
if(parts[0] === 'services') return {n:'services', p:null};
if(parts[0] === 'trial-lesson') return {n:'trial-lesson', p:null};
if(parts[0] === 'autumn-school') return {n:'autumn-school', p:null};
if(parts[0] === 'courses' && parts[1]) return {n:'course', p:decodeURIComponent(parts.slice(1).join('/'))};
if(parts[0] === 'docs' && parts[1]) return {n:'page', p:decodeURIComponent(parts.slice(1).join('/'))};
return DEFAULT_ROUTE;
}
function parseRouteFromLocation(){
if(location.hash) return parseRouteFromHash();
return parseRouteFromPathname(location.pathname);
}
function buildHashFromRoute(name, param=null){
return param==null ? name : [name, ...String(param).split('/').map(part=>encodeURIComponent(part))].join('/');
}
function isPathRoute(name){
return ['home','news','news-item','races','race-item','league','pilot-rating','public-pilot','services','service-detail','trial-lesson','autumn-school','course','page'].includes(name);
}
function buildUrlFromRoute(name, param=null){
const root = APP_BASE_PATH;
if(name === 'home') return root;
if(name === 'news') return `${root}news`;
if(name === 'news-item') return `${root}news/${String(param || '').split('/').map(part=>encodeURIComponent(part)).join('/')}`;
if(name === 'races') return `${root}races`;
if(name === 'race-item') return `${root}races/${String(param || '').split('/').map(part=>encodeURIComponent(part)).join('/')}`;
if(name === 'league') return `${root}league`;
if(name === 'pilot-rating') return `${root}rating`;
if(name === 'public-pilot') return `${root}pilots/${encodeURIComponent(String(param||''))}`;
if(name === 'services') return `${root}services`;
if(name === 'service-detail') return `${root}services/${encodeURIComponent(String(param||''))}`;
if(name === 'trial-lesson') return `${root}trial-lesson`;
if(name === 'autumn-school') return `${root}autumn-school`;
if(name === 'course') return `${root}courses/${String(param || '').split('/').map(part=>encodeURIComponent(part)).join('/')}`;
if(name === 'page') return `${root}docs/${String(param || '').split('/').map(part=>encodeURIComponent(part)).join('/')}`;
return `${root}#${buildHashFromRoute(name, param)}`;
}
function isStudentAreaRoute(name){
return ['cabinet','cab-materials','cab-certs','cab-settings','player','test','payment',
'league-rating','league-infrastructure','league-discounts','league-pilot-card','league-support'].includes(name);
}
function isStaffRole(role){
return STAFF_ROLES.includes(String(role || ''));
}
function hasCabinetAccess(user){
return isStaffRole(user?.role) || !!user?.cabinet_access;
}
function isLeagueMemberUser(user){
return !!(user?.league_member || user?.leagueMember);
}
function ensureMetaTag(attrName, attrValue){
let tag = document.head.querySelector(`meta[${attrName}="${attrValue}"]`);
if(!tag){
tag = document.createElement('meta');
tag.setAttribute(attrName, attrValue);
document.head.appendChild(tag);
}
return tag;
}
function updateMetaTag(attrName, attrValue, content){
const tag = ensureMetaTag(attrName, attrValue);
tag.setAttribute('content', content || '');
}
function Header({ go, route, lang, setLang, onDark, loggedIn=false, user=null }){
const [scrolled,setScrolled]=uS(false);
const [openM,setOpenM]=uS(false);
uE(()=>{ const f=()=>setScrolled(window.scrollY>12); f(); window.addEventListener('scroll',f); return ()=>window.removeEventListener('scroll',f); },[]);
const activeRoute = route==='news-item' ? 'news' : route==='race-item' || route==='pilot-rating' || route==='league' ? 'races' : route==='course' || route==='trial-lesson' || route==='autumn-school' ? 'education' : route==='service-detail' ? 'services' : route;
const links=[['home','Главная'],['services','Услуги'],['education','Образование'],['races','Гонки'],['news','Новости'],['faq','FAQ'],['contacts','Контакты']];
const dark = onDark && !scrolled;
const accountLabel = loggedIn ? (user?.name || user?.email || 'Мой кабинет') : 'Личный кабинет';
const accountRoute = loggedIn ? (isStaffRole(user?.role) ? 'admin' : 'cabinet') : 'login';
const openAccount = ()=>go(accountRoute);
return (
go('home')}>
{links.map(([k,t])=>(go(k)}>{t} ))}
setLang('ru')}>RU
setLang('ky')} title="Кыргызский">KG
{accountLabel}
setOpenM(true)}>
{openM && (
setOpenM(false)}>
e.stopPropagation()}>
setOpenM(false)}>
{links.map(([k,t])=>(
{go(k);setOpenM(false);}}>{t} ))}
{openAccount();setOpenM(false);}} title={loggedIn && user?.email ? user.email : accountLabel}>{accountLabel}
)}
);
}
function Footer({ go }){
return (
Научно-исследовательская лаборатория беспилотных летательных аппаратов Славянского Университета им. Б.Н. Ельцина
Обучение
go('education')}>Каталог курсов
go('education')}>FPV-пилотирование
go('education')}>Аэросъёмка
go('races')}>Гонки на дронах
Лаборатория
go('services')}>Услуги
go('news')}>Новости
go('faq')}>FAQ
go('contacts')}>Контакты
go('login')}>Личный кабинет
Документы
go('page','offer')}>Договор оферты
go('page','payment')}>Условия оплаты
go('page','privacy')}>Политика конфиденциальности
© 2026 КРСУ · Лаборатория БПЛА. Все права защищены. г. Бишкек, Кыргызская Республика
);
}
function AwaitingApproval({ go, enroll=[], latestRejectedPayment=null }){
const pendingItem = enroll.find(item=>item.access==='pending');
const rejectedItem = !pendingItem && latestRejectedPayment ? latestRejectedPayment : null;
const title = rejectedItem ? 'Оплата не подтверждена' : 'Оплата ещё не подтверждена';
const lead = rejectedItem
? 'Мы проверили заявку и пока не смогли подтвердить платёж. Ниже указали причину и оставили быстрый переход к повторной оплате.'
: 'Как только администратор подтвердит оплату в панели управления, доступ в личный кабинет откроется автоматически.';
return (
{rejectedItem ? 'Статус оплаты' : 'Проверка заявки'}
{rejectedItem ? 'Платёж требует повторной проверки или новой отправки' : 'Мы получили вашу оплату и уже передали её на подтверждение'}
{rejectedItem
? 'Откройте оплату ещё раз, проверьте реквизиты и отправьте новую заявку. После подтверждения кабинет и материалы откроются автоматически.'
: 'Обычно проверка занимает немного времени. Как только администратор подтвердит заявку, кабинет и материалы откроются автоматически.'}
Статус {rejectedItem ? 'Оплата отклонена' : 'Ожидает подтверждения'}
Следующий шаг {rejectedItem ? 'Проверить причину и повторить оплату' : 'Открытие доступа после проверки'}
{title}
{rejectedItem ? 'Оплата отклонена' : 'Ожидает подтверждения'}
{pendingItem?.pendingDate && Заявка от {pendingItem.pendingDate} }
{rejectedItem?.date && Проверено {rejectedItem.date} }
{lead}
{rejectedItem ? (
<>
Продукт {rejectedItem.courseTitle || 'Оплата курса'}
{rejectedItem.amount > 0 &&
Сумма {window.DATA?.fmt ? `${window.DATA.fmt(rejectedItem.amount)} сом` : `${rejectedItem.amount} сом`}
}
{rejectedItem.reason &&
Причина отказа {rejectedItem.reason}
}
{rejectedItem.reason
? `Проверьте данные платежа и отправьте новую заявку с учётом этой причины: ${rejectedItem.reason}`
: 'Проверьте данные платежа и отправьте новую заявку ещё раз. Если нужна помощь, свяжитесь с администрацией.'}
>
) : (
Заявка получена Мы сохранили вашу оплату в системе.
Идёт проверка Администратор сверяет платёж и подтверждает доступ.
Кабинет откроется После подтверждения станут доступны курсы и материалы.
)}
{!rejectedItem && (
<>
Ваша оплата на подтверждении. Ожидайте, пожалуйста. Пока можно вернуться на главную или открыть страницу оплаты, если нужно проверить заявку.
Мы автоматически проверяем статус оплаты каждые 30 секунд.
>
)}
{(pendingItem || rejectedItem) && go('payment', pendingItem?.id || rejectedItem.courseId)}>{rejectedItem ? 'Повторить оплату' : 'Открыть оплату'} }
go('home')}>На главную
);
}
const RACE_MONTHS_RU = {
'января':0, 'февраля':1, 'марта':2, 'апреля':3, 'мая':4, 'июня':5,
'июля':6, 'августа':7, 'сентября':8, 'октября':9, 'ноября':10, 'декабря':11,
};
function raceExpiryTimestamp(value){
const source = String(value || '').trim().toLowerCase().replace(/ё/g, 'е');
if(!source) return null;
let day;
let month;
let year;
let hour;
let minute;
let match = source.match(/^(\d{1,2})\s+([а-я]+)\s+(\d{4})(?:\s*(?:г(?:ода)?\.?)?\s*(?:,|·|в)?\s*(\d{1,2}):(\d{2}))?/i);
if(match && Object.prototype.hasOwnProperty.call(RACE_MONTHS_RU, match[2])){
day = Number(match[1]);
month = RACE_MONTHS_RU[match[2]];
year = Number(match[3]);
hour = match[4] == null ? null : Number(match[4]);
minute = match[5] == null ? 0 : Number(match[5]);
}else{
match = source.match(/^(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{4})(?:[ t,]+(\d{1,2}):(\d{2}))?/);
if(match){
day = Number(match[1]);
month = Number(match[2]) - 1;
year = Number(match[3]);
hour = match[4] == null ? null : Number(match[4]);
minute = match[5] == null ? 0 : Number(match[5]);
}else{
match = source.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ t]+(\d{1,2}):(\d{2}))?/);
if(!match) return null;
year = Number(match[1]);
month = Number(match[2]) - 1;
day = Number(match[3]);
hour = match[4] == null ? null : Number(match[4]);
minute = match[5] == null ? 0 : Number(match[5]);
}
}
const daysInMonth = year >= 1970 && month >= 0 && month <= 11 ? new Date(Date.UTC(year, month + 1, 0)).getUTCDate() : 0;
if(!daysInMonth || day < 1 || day > daysInMonth || (hour != null && (hour < 0 || hour > 23)) || minute < 0 || minute > 59) return null;
// A date without a time remains current through 23:59 in Bishkek (UTC+6).
const localHour = hour == null ? 24 : hour;
return Date.UTC(year, month, day, localHour, minute) - 6 * 60 * 60 * 1000;
}
function raceIsUpcoming(race, now=Date.now()){
if(race?.active === false) return false;
const status = String(race?.status || '').trim().toLowerCase().replace(/ё/g, 'е');
if(/(заверш|оконч|прошел|отмен|не состо)/i.test(status)) return false;
const expiresAt = raceExpiryTimestamp(race?.date);
return expiresAt == null || expiresAt > now;
}
function Races({ go, loggedIn=false }){
const league = window.DATA?.leagueMembership || {};
const allRaces = DATA.races || [];
const [raceClock,setRaceClock] = uS(()=>Date.now());
uE(()=>{
const timer = setInterval(()=>setRaceClock(Date.now()), 60000);
return ()=>clearInterval(timer);
},[]);
const upcomingRaces = allRaces.filter(r=>raceIsUpcoming(r, raceClock));
const byDate = (a,b)=>(raceExpiryTimestamp(a.date) ?? Number.MAX_SAFE_INTEGER) - (raceExpiryTimestamp(b.date) ?? Number.MAX_SAFE_INTEGER);
const pinnedRaces = upcomingRaces.filter(r=>r.pinned).sort(byDate);
const regularRaces = upcomingRaces.filter(r=>!r.pinned).sort(byDate);
const statusColor = s => s==='Регистрация открыта'||s==='Набор открыт' ? 'green' : s==='Скоро' ? 'amber' : 'gray';
return (
Дрон рейсинг
Дрон рейсинг
Гонки дронов — спорт будущего
Анонсы соревнований и расписание. Вся основная информация, трансляции и результаты публикуются на наших каналах в социальных сетях.
Рейтинг пилотов
Таблица лучших пилотов Лаборатории БПЛА и результаты последних стартов.
go('pilot-rating')}>Открыть рейтинг
{league.title || 'Членство в Лиге'}
{league.subtitle || 'Платная регистрация для участия в Лиге Дронов и официальных заездах.'}
{window.DATA?.fmt ? `${window.DATA.fmt(Number(league.yearlyPrice || 8760))} сом в год` : ''}
go('league')}>{loggedIn ? (league.cta || 'Оформить членство') : 'Войти и оформить'}
{pinnedRaces.length>0 && (
{pinnedRaces.map(pinnedRace=>(
go('race-item', raceRouteParam(pinnedRace))}>
Главное событие
{pinnedRace.date.split(' ')[0]} {pinnedRace.date.split(' ').slice(1).join(' ')}
{pinnedRace.title}
{pinnedRace.place}
{!!pinnedRace.text &&
{excerptFromHtml(pinnedRace.text, 200)}
}
{pinnedRace.status}
{e.stopPropagation();go('race-item', raceRouteParam(pinnedRace));}}>Подробнее
))}
)}
Ближайшие старты
{!upcomingRaces.length &&
Ближайших стартов пока нет. Новые анонсы появятся здесь.
}
{regularRaces.map(r=>(
{r.date.split(' ')[0]} {r.date.split(' ').slice(1).join(' ')}
{r.img &&
}
{r.title}
{r.place}
{!!r.text &&
{excerptFromHtml(r.text, 160)}
}
{r.status}
go('race-item', raceRouteParam(r))}>Подробнее
))}
);
}
function App(){
const cachedUser = readCachedAuthUser();
const [route,setRoute]=uS(()=>parseRouteFromLocation());
const [lang,setLang]=uS(()=>readPreferredLang());
const [loggedIn,setLoggedIn]=uS(()=>!!cachedUser);
const [authChecked,setAuthChecked]=uS(false);
const [siteDataLoading,setSiteDataLoading]=uS(true);
const [dataVersion,setDataVersion]=uS(0);
const [enroll,setEnroll]=uS([]);
const [latestRejectedPayment,setLatestRejectedPayment]=uS(null);
const [user,setUser]=uS(()=>cachedUser || DEFAULT_USER);
const [tw,setTw]=uS(()=>{ try{return JSON.parse(localStorage.getItem('krsu_tw'))||{}}catch(e){return {}} });
const heroVariant = tw.hero || 'video';
uE(()=>{
fetch('api/bootstrap.php',{credentials:'same-origin'})
.then(res=>res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`)))
.then(payload=>{
if(payload && payload.ok && payload.data){
Object.assign(DATA, payload.data);
setDataVersion(v=>v+1);
}
})
.catch(()=>{})
.finally(()=>setSiteDataLoading(false));
},[]);
uE(()=>{
if(lang==='ky'){
applyGoogleTranslate('ky').catch(()=>{});
}
},[lang]);
uE(()=>{
const syncRoute = ()=>setRoute(parseRouteFromLocation());
window.addEventListener('hashchange', syncRoute);
window.addEventListener('popstate', syncRoute);
return ()=>{
window.removeEventListener('hashchange', syncRoute);
window.removeEventListener('popstate', syncRoute);
};
},[]);
uE(()=>{
let cancelled = false;
const fallbackUser = readCachedAuthUser();
function applyGuest(){
writeCachedAuthUser(null);
setLoggedIn(false);
setUser(DEFAULT_USER);
setLatestRejectedPayment(null);
}
function applyUser(nextUser){
writeCachedAuthUser(nextUser);
setUser(nextUser);
setLoggedIn(true);
setLatestRejectedPayment(null);
if(!isStaffRole(nextUser.role) && !isLeagueMemberUser(nextUser)) loadEnroll(nextUser.email);
}
function checkAuth(attempt=0){
fetch('api/auth.php',{credentials:'same-origin'})
.then(res=>res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`)))
.then(payload=>{
if(cancelled) return;
if(payload && payload.ok && payload.user){
applyUser(payload.user);
setAuthChecked(true);
return;
}
if(attempt===0 && fallbackUser){
setTimeout(()=>{ if(!cancelled) checkAuth(1); }, 350);
return;
}
applyGuest();
setAuthChecked(true);
})
.catch(()=>{
if(cancelled) return;
if(attempt===0){
setTimeout(()=>{ if(!cancelled) checkAuth(1); }, 350);
return;
}
if(fallbackUser){
setUser(fallbackUser);
setLoggedIn(true);
}else{
applyGuest();
}
setAuthChecked(true);
});
}
checkAuth();
return ()=>{ cancelled = true; };
},[]);
function loadEnroll(email=user.email){
if(!email) return;
return fetch(`api/my-courses.php?email=${encodeURIComponent(email)}`,{credentials:'same-origin'})
.then(res=>res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`)))
.then(payload=>{
if(payload && payload.ok){
if(payload.user) setUser(prev=>({...prev, ...payload.user, role:payload.user.role || prev.role || 'user', cabinet_access:typeof payload.user.cabinet_access === 'boolean' ? payload.user.cabinet_access : !!prev.cabinet_access}));
setLatestRejectedPayment(payload.latestRejectedPayment || null);
return fetch('api/courses.php',{credentials:'same-origin'})
.then(res=>res.ok?res.json():Promise.reject(new Error(`HTTP ${res.status}`)))
.then(coursePayload=>{
if(coursePayload?.ok&&Array.isArray(coursePayload.courses)) window.DATA.courses=coursePayload.courses;
setEnroll(payload.enroll || []);
});
}
})
.catch(()=>{});
}
uE(()=>{ window.scrollTo(0,0); },[route]);
uE(()=>{
const baseTitle = 'КРСУ · Лаборатория БПЛА';
const newsList = window.DATA?.news || [];
const courseItem = route.n==='course' ? (window.DATA?.courses || []).find(item=>item.id===route.p) : null;
const newsArticle = route.n==='news-item' ? findNewsByParam(route.p, newsList) : null;
const raceList = window.DATA?.races || [];
const raceArticle = route.n==='race-item' ? findRaceByParam(route.p, raceList) : null;
const staticPage = route.n==='page' ? (window.DATA?.pages || {})[String(route.p || '')] : null;
const serviceItem = route.n==='service-detail' ? (window.DATA?.services || []).find(item=>item.slug===route.p) : null;
const pageTitle = newsArticle
? `${newsArticle.title} — ${baseTitle}`
: raceArticle
? `${raceArticle.title} — ${baseTitle}`
: staticPage
? `${staticPage.title} — ${baseTitle}`
: serviceItem
? (serviceItem.seo_title || `${serviceItem.name} — ${baseTitle}`)
: route.n==='news'
? `Новости — ${baseTitle}`
: route.n==='races'
? `Гонки дронов — ${baseTitle}`
: route.n==='league'
? `Членство в Лиге — ${baseTitle}`
: route.n==='pilot-rating'
? `Рейтинг пилотов — ${baseTitle}`
: route.n==='education'
? `Каталог курсов — ${baseTitle}`
: route.n==='services'
? `Услуги — ${baseTitle}`
: route.n==='trial-lesson'
? `Пробный урок по аэронавигации — ${baseTitle}`
: route.n==='autumn-school'
? `Осенняя школа дронов 2026 — ${baseTitle}`
: route.n==='course'
? `${courseItem?.title || 'Курс'} — ${baseTitle}`
: route.n==='faq'
? `FAQ — ${baseTitle}`
: `${baseTitle} — обучение полётам на дронах`;
const description = newsArticle
? excerptFromHtml(newsArticle.text, 180)
: raceArticle
? excerptFromHtml(raceArticle.text || '', 180) || `Старт ${raceArticle.title} — ${raceArticle.place}.`
: staticPage
? excerptFromHtml(staticPage.text || '', 180)
: serviceItem
? (serviceItem.seo_description || excerptFromHtml(serviceItem.description || '', 180))
: route.n==='news'
? 'Новости КРСУ, партнёров и лаборатории БПЛА в одной ленте.'
: route.n==='races'
? 'Анонсы соревнований, регистрации и старты дрон-рейсинга Лаборатории БПЛА.'
: route.n==='league'
? 'Членство в Лиге Дронов: годовая подписка для участия в официальных заездах.'
: route.n==='pilot-rating'
? 'Рейтинг пилотов Лаборатории БПЛА, очки, победы и лучшие круги сезона.'
: route.n==='education'
? 'Каталог курсов Лаборатории БПЛА: пилотирование, инженерия, гонки и аэросъёмка.'
: route.n==='services'
? 'Цифровые услуги Лаборатории БПЛА КРСУ для строительства, агросектора, инфраструктуры, обучения и продакшена.'
: route.n==='trial-lesson'
? 'Бесплатный первый урок курса «Оператор БПЛА»: как беспилотник ориентируется в пространстве.'
: route.n==='autumn-school'
? 'Осенняя школа дронов КРСУ для подростков 12–17 лет: онлайн-теория и лётная практика на Иссык-Куле.'
: route.n==='course'
? (courseItem?.short || 'Курс Лаборатории БПЛА КРСУ.')
: route.n==='faq'
? 'Часто задаваемые вопросы о программе подготовки операторов БПЛА в КРСУ.'
: 'Лаборатория БПЛА КРСУ: обучение, новости, гонки, личный кабинет и курсы по беспилотникам.';
const image = new URL(newsArticle?.img || raceArticle?.img || serviceItem?.main_image || courseItem?.img || (route.n==='autumn-school' ? 'assets/autumn-school/autumn-school-01.jpg' : 'assets/logo-full.png'), document.baseURI).href;
const url = location.href;
document.title = pageTitle;
updateMetaTag('name', 'description', description);
updateMetaTag('property', 'og:title', pageTitle);
updateMetaTag('property', 'og:description', description);
updateMetaTag('property', 'og:type', newsArticle || raceArticle ? 'article' : route.n==='course' ? 'product' : 'website');
updateMetaTag('property', 'og:url', url);
updateMetaTag('property', 'og:image', image);
updateMetaTag('name', 'twitter:card', newsArticle?.img || raceArticle?.img || route.n==='autumn-school' ? 'summary_large_image' : 'summary');
updateMetaTag('name', 'twitter:title', pageTitle);
updateMetaTag('name', 'twitter:description', description);
updateMetaTag('name', 'twitter:image', image);
},[route, dataVersion]);
uE(()=>{
if(loggedIn && user.email && !isStaffRole(user.role) && !isLeagueMemberUser(user)) loadEnroll(user.email);
},[dataVersion, loggedIn, route.n, user.email, user.role, user.league_member, user.leagueMember]);
uE(()=>{
const waitingRoutes = ['cabinet','cab-materials','cab-certs','cab-settings','player','test'];
if(!authChecked || !loggedIn || isStaffRole(user.role) || hasCabinetAccess(user) || !waitingRoutes.includes(route.n) || !user.email){
return;
}
let cancelled = false;
async function refreshPendingAccess(){
try{
const [authRes] = await Promise.all([
fetch('api/auth.php',{credentials:'same-origin'}),
loadEnroll(user.email),
]);
const authPayload = await authRes.json().catch(()=>null);
if(cancelled) return;
if(authRes.ok && authPayload?.ok && authPayload.user){
setUser(prev=>({
...prev,
...authPayload.user,
role:authPayload.user.role || prev.role || 'user',
cabinet_access:typeof authPayload.user.cabinet_access === 'boolean' ? authPayload.user.cabinet_access : !!prev.cabinet_access,
}));
}
}catch(err){}
}
const timer = setInterval(refreshPendingAccess, 30000);
return ()=>{
cancelled = true;
clearInterval(timer);
};
},[authChecked, loggedIn, route.n, user.email, user.role, user.cabinet_access]);
uE(()=>{
const onTw=(e)=>{ if(e.data&&e.data.type==='tweaks-update'){ setTw(e.data.values||{}); localStorage.setItem('krsu_tw',JSON.stringify(e.data.values||{})); } };
window.addEventListener('message',onTw); return ()=>window.removeEventListener('message',onTw);
},[]);
uE(()=>{
const r=document.documentElement;
if(tw.accent==='green') r.style.setProperty('--brand-cta','var(--green)');
document.body.style.setProperty('--radius', (tw.radius||16)+'px');
},[tw]);
function go(n,p=null){
const nextRoute = {n,p};
const nextUrl = buildUrlFromRoute(n,p);
const currentUrl = `${location.pathname}${location.hash}`;
const targetUrl = nextUrl.replace(location.origin, '');
if(currentUrl === targetUrl){
setRoute(nextRoute);
return;
}
history.pushState({}, '', nextUrl);
setRoute(nextRoute);
}
function changeLang(nextLang){
if(nextLang==='ky'){
writePreferredLang('ky');
setLang('ky');
applyGoogleTranslate('ky').catch(()=>{});
return;
}
writePreferredLang('ru');
setLang('ru');
if(document.cookie.includes('googtrans=')){
clearGoogleTranslateCookie();
location.reload();
}
}
function onPaid(cid){
const now = new Date().toLocaleString('ru-RU', {
timeZone:'Asia/Bishkek',
day:'2-digit',
month:'2-digit',
year:'numeric',
hour:'2-digit',
minute:'2-digit',
});
setEnroll(e=> e.find(x=>x.id===cid)
? e.map(x=>x.id===cid?{...x,access:'pending',status:'Ожидает оплаты',pendingDate:now}:x)
: [...e,{id:cid,access:'pending',progress:0,status:'Ожидает оплаты',testScore:null,pendingDate:now}]);
setLatestRejectedPayment(prev=>prev?.courseId===cid ? null : prev);
setTimeout(()=>loadEnroll(),300);
}
function onResult(cid,score){
setEnroll(e=>e.map(x=>x.id===cid?{...x,testScore:score,progress:100,status:'Завершён'}:x));
loadEnroll();
}
function onProgress(cid,lessonIndex,totalLessons,completed=true){
setEnroll(items=>items.map(item=>{
if(item.id!==cid)return item;
const legacyCount=Array.isArray(item.completedLessons)?0:Math.max(0,Math.min(totalLessons,Math.round(((item.progress||0)/100)*totalLessons)));
const lessons=Array.isArray(item.completedLessons)?[...item.completedLessons]:Array.from({length:legacyCount},(_,index)=>index);
const next=completed
? [...new Set([...lessons,lessonIndex])]
: lessons.filter(index=>index!==lessonIndex);
next.sort((a,b)=>a-b);
const progress=Math.round(next.length/totalLessons*100);
return {...item,completedLessons:next,progress,status:item.testScore!=null?item.status:(progress===100?'Материалы пройдены':progress>0?'В процессе':'Открыт')};
}));
return fetch('api/my-courses.php',{
method:'PATCH',
credentials:'same-origin',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({email:user.email,course_id:cid,lesson_index:lessonIndex,total_lessons:totalLessons,completed}),
}).then(async response=>{
const data=await response.json().catch(()=>({ok:false,error:'Пустой ответ сервера'}));
if(!response.ok||data.ok===false)throw new Error(data.error||`Не удалось сохранить прогресс (HTTP ${response.status})`);
return data;
}).then(data=>{
if(Array.isArray(data.completedLessons)){
setEnroll(items=>items.map(item=>item.id===cid?{...item,completedLessons:data.completedLessons,progress:Math.round(data.completedLessons.length/totalLessons*100)}:item));
}
return data;
}).catch(error=>{loadEnroll();throw error;});
}
function onLogin(nextUser){
writeCachedAuthUser(nextUser);
setUser(nextUser);
setLoggedIn(true);
setLatestRejectedPayment(null);
if(!isStaffRole(nextUser.role) && !isLeagueMemberUser(nextUser)) loadEnroll(nextUser.email);
if(route.n === 'admin' && isStaffRole(nextUser.role)){
go('admin', route.p);
return;
}
if(isStaffRole(nextUser.role)){
go('admin');
return;
}
if(isLeagueMemberUser(nextUser)){
const leagueRoute = ['league-rating','league-infrastructure','league-discounts','league-pilot-card','league-support','cab-settings'].includes(route.n);
go(leagueRoute ? route.n : 'league-rating', leagueRoute ? route.p : null);
return;
}
if(isStudentAreaRoute(route.n)){
const studentRoute = ['cabinet','cab-materials','cab-certs','cab-settings','player','test','payment'].includes(route.n);
go(studentRoute ? route.n : 'cabinet', studentRoute ? route.p : null);
return;
}
go('cabinet');
}
function logout(){
fetch('api/auth.php',{
method:'POST',
credentials:'same-origin',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({action:'logout'}),
}).catch(()=>{});
writeCachedAuthUser(null);
setLoggedIn(false);
setUser(DEFAULT_USER);
setEnroll([]);
setLatestRejectedPayment(null);
go('home');
}
function protectedView(content){
if(!authChecked){
return ;
}
return loggedIn ? content : ;
}
function publicView(content){
return publicLoading ? content : {content}
;
}
const n=route.n;
const fullscreen = ['login','cabinet','cab-materials','cab-certs','cab-settings','player','test','admin',
'league-rating','league-infrastructure','league-discounts','league-pilot-card','league-support'].includes(n);
const publicLoading = siteDataLoading && ['home','services','service-detail','education','trial-lesson','autumn-school','course','news','news-item','media','races','race-item','league','pilot-rating','page','contacts','faq'].includes(n);
const articleLoading = publicLoading && ['news-item','race-item','page'].includes(n);
let view;
if(n==='home') view=publicLoading ? : publicView();
else if(n==='services') view=publicLoading ? : publicView();
else if(n==='service-detail') view=publicLoading ? : publicView();
else if(n==='education') view=publicLoading ? : publicView();
else if(n==='trial-lesson') view=publicLoading ? : publicView();
else if(n==='autumn-school') view=publicView();
else if(n==='course') view=publicLoading ? : publicView();
else if(n==='news') view=publicLoading ? : publicView();
else if(n==='news-item') view=articleLoading ? : publicView();
else if(n==='media') view=publicLoading ? : publicView();
else if(n==='races') view=publicLoading ? : publicView();
else if(n==='race-item') view=articleLoading ? : publicView();
else if(n==='league') view=publicLoading ? : publicView();
else if(n==='pilot-rating') view=publicLoading ? : publicView();
else if(n==='public-pilot') view=publicView();
else if(n==='page') view=articleLoading ? : publicView();
else if(n==='contacts') view=publicLoading ? : publicView( );
else if(n==='faq') view=publicLoading ? : publicView();
else if(n==='payment'){
if(!authChecked) view=protectedView(null);
else if(!loggedIn) view= ;
else view= ;
}
else if(n==='login') {
const loginParts = String(route.p || '').split('/').filter(Boolean);
const loginMode = loginParts[0]==='reg' ? 'reg' : loginParts[0]==='forgot' ? 'forgot' : loginParts[0]==='reset' ? 'reset' : 'login';
const resetToken = loginMode==='reset' ? decodeURIComponent(loginParts.slice(1).join('/')) : '';
view=;
}
else if(n==='admin') view=protectedView(isStaffRole(user.role) ? : );
else if(n==='player'){
if(!authChecked) view=protectedView(null);
else if(!loggedIn) view=;
else if(isLeagueMemberUser(user)) view= ;
else view=hasCabinetAccess(user) ? : ;
}
else if(n==='test'){
if(!authChecked) view=protectedView(null);
else if(!loggedIn) view=;
else if(isLeagueMemberUser(user)) view= ;
else view=hasCabinetAccess(user) ? : ;
}
else if(['cabinet','cab-materials','cab-certs','cab-settings',
'league-rating','league-infrastructure','league-discounts','league-pilot-card','league-support'].includes(n)){
if(!authChecked) view=protectedView(null);
else if(!loggedIn) view=;
else if(!hasCabinetAccess(user)) view=;
else {
let inner;
const leagueMember = isLeagueMemberUser(user);
const studentOnlyRoute = ['cabinet','cab-materials','cab-certs'].includes(n);
const leagueRoute = ['league-rating','league-infrastructure','league-discounts','league-pilot-card','league-support'].includes(n);
let activeRoute = n;
if(leagueMember && studentOnlyRoute){
inner=;
activeRoute='league-rating';
}else if(!leagueMember && leagueRoute){
inner=;
activeRoute='cabinet';
}else if(n==='cabinet') inner=;
else if(n==='cab-materials') inner=;
else if(n==='cab-certs') inner=;
else if(n==='cab-settings') inner={writeCachedAuthUser(nextUser);setUser(nextUser);}}/>;
else inner=;
view={inner} ;
}
}
else view=;
return (
<>
{!fullscreen && }
{view}
{!fullscreen && }
>
);
}
ReactDOM.createRoot(document.getElementById('root')).render( );