/* ===== Test flow + cabinet subpages ===== */ const D5 = window.DATA; function Test({ id, go, onResult, enroll=[] }){ const c = D5.courses.find(x=>x.id===id)||D5.courses[0]; const qs = D5.tests[id] || []; const enrollment=enroll.find(item=>item.id===id)||{progress:0,completedLessons:null}; const lessonCount=(c.modules||[]).reduce((total,module)=>total+(module.lessons||[]).length,0); const completedCount=Array.isArray(enrollment.completedLessons) ? new Set(enrollment.completedLessons.filter(index=>Number.isInteger(index)&&index>=0&&index=100?lessonCount:0); const [stage,setStage]=useState('intro'); // intro | quiz | result const [idx,setIdx]=useState(0); const [answers,setAnswers]=useState({}); const [picked,setPicked]=useState(null); const [result,setResult]=useState(null); const [busy,setBusy]=useState(false); const [error,setError]=useState(''); const score = Number(result?.score||0); const passed = !!result?.passed; if(!qs.length) return

Тест пока не опубликован

Администратор ещё не добавил вопросы для этого курса.

; if(lessonCount>0&&completedCount

Сначала завершите уроки

До аттестации осталось пройти {lessonCount-completedCount} из {lessonCount} уроков курса.

; async function submit(next){ setBusy(true);setError(''); try{ const ordered=qs.map((_,i)=>Number.isInteger(next[i])?next[i]:-1); const response=await fetch('api/tests.php',{ method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'}, body:JSON.stringify({course_id:id,answers:ordered}), }); const data=await response.json().catch(()=>({ok:false,error:'Пустой ответ сервера'})); if(!response.ok||data.ok===false)throw new Error(data.error||`HTTP ${response.status}`); setResult(data); onResult&&onResult(id,data.score); setStage('result'); }catch(e){setError(e.message||'Не удалось проверить тест');} finally{setBusy(false);} } function choose(){ if(picked==null) return; const next={...answers,[idx]:picked}; setAnswers(next); if(idx

Аттестация по курсу

«{c.title}»

{qs.length}вопросов
70%порог сдачи
∞попыток

Выберите один правильный вариант в каждом вопросе. Результат сохранится в вашем личном кабинете.

); if(stage==='quiz'){ const q=qs[idx]; return (
Вопрос {idx+1} из {qs.length}

{q.q}

{q.image&&Изображение к вопросу}
{q.a.map((a,i)=>( ))}
{error&&
{error}
}
); } // result return (
{score}%

{passed?'Поздравляем, сдано!':'Почти получилось'}

{passed ? `Вы успешно прошли аттестацию по курсу «${c.title}». Сертификат доступен в личном кабинете.` : `Нужно набрать минимум 70%. Повторите материалы и попробуйте ещё раз.`}

{qs.map((q,i)=>{ const ok=!!result?.results?.[i]; return
{q.q}
; })}
{passed ? <> : <>}
); } /* ---- simple cabinet subpages ---- */ function CabMaterials({ enroll, go }){ const [viewing,setViewing]=useState(null); const open = enroll.filter(e=>e.access==='open').map(e=>D5.courses.find(c=>c.id===e.id)).filter(Boolean); const openWithMaterials = open.map(course=>({ ...course, materials: Array.isArray(course.materials) ? course.materials.filter(item=>item && item.title && item.src) : [], })); const viewingExtension=String(viewing?.material?.src||'').split(/[?#]/)[0].split('.').pop().toLowerCase(); const viewingPreviewPath=viewingExtension==='docx' || viewingExtension==='pptx' ? String(viewing.material.src).replace(/\.(?:docx|pptx)([?#].*)?$/i,'-preview.pdf') : String(viewing?.material?.src||''); const viewingSrc=viewing ? courseMediaUrl(viewing.course.id,viewingPreviewPath) : ''; return (

Учебные материалы

Конспекты, презентации и сценарии симулятора по вашим курсам.

{openWithMaterials.every(course=>course.materials.length===0) ? (
Пока нет загруженных материалов. Администратор сможет добавить их для ваших курсов в админке.
) : (
{openWithMaterials.map(course=>(

{course.title}

Материалы курса, доступные после подтверждения оплаты.

{course.materials.length} материал(ов)
{course.materials.length===0 ? (
Для этого курса материалы пока не добавлены.
) : (
{course.materials.map((material,index)=>{ const protectedSrc=material.type==='link' ? material.src : courseMediaUrl(course.id,material.src); const lessonOnly=/\.(?:pdf|mp4)(?:[#?].*)?$/i.test(String(material.src||'')); const cabinetPreview=/\.(?:m4a|docx|pptx)(?:[#?].*)?$/i.test(String(material.src||'')); return
{material.title}
{material.meta || (material.type==='link' ? 'Внешняя ссылка' : 'Файл курса')}
{lessonOnly ? : cabinetPreview ? : Открыть}
; })}
)}
))}
)} {viewing && (
setViewing(null)}>
e.stopPropagation()} style={{width:'min(1040px,calc(100vw - 32px))',maxWidth:1040,maxHeight:'calc(100vh - 32px)',overflow:'auto'}}>
{viewing.course.title}

{viewing.material.title}

{viewingExtension==='m4a' ?
e.preventDefault()}>
: }
)}
); } function CabCerts({ enroll }){ const done = enroll.filter(e=>e.testScore!=null && e.testScore>=70).map(e=>({...e,c:D5.courses.find(c=>c.id===e.id)})); const [downloading,setDownloading]=useState(''); const [downloadError,setDownloadError]=useState(''); async function downloadCertificate(courseId){ setDownloading(courseId); setDownloadError(''); try{ const response=await fetch(`api/certificate.php?course_id=${encodeURIComponent(courseId)}`,{credentials:'same-origin'}); if(!response.ok){ const data=await response.json().catch(()=>null); throw new Error(data?.error||`Не удалось скачать сертификат (HTTP ${response.status})`); } const blob=await response.blob(); const disposition=response.headers.get('Content-Disposition')||''; const encodedName=disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1]; const fallbackName=`certificate-${courseId}.svg`; const filename=encodedName ? decodeURIComponent(encodedName) : fallbackName; const url=URL.createObjectURL(blob); const link=document.createElement('a'); link.href=url; link.download=filename; document.body.appendChild(link); link.click(); link.remove(); setTimeout(()=>URL.revokeObjectURL(url),1000); }catch(error){ setDownloadError(error.message||'Не удалось скачать сертификат. Попробуйте ещё раз.'); }finally{ setDownloading(''); } } return (

Сертификаты

Документы, полученные после успешной аттестации.

{done.length===0 ?
Пока нет сертификатов. Завершите курс и сдайте тест.
: (
{done.map(d=>(
Аттестация пройдена

{d.c.title}

Сертификат Лаборатории БПЛА КРСУ

Результат
{d.testScore}%
))}
)} {downloadError&&
{downloadError}
}
); } const PROFILE_COUNTRIES = [ {value:'KG',label:'Кыргызстан',phone:'+996',phoneLength:9,phoneHint:'9 цифр',documentHint:'2 буквы и 7 цифр, например AN1234567'}, {value:'RU',label:'Россия',phone:'+7',phoneLength:10,phoneHint:'10 цифр',documentHint:'10 цифр'}, {value:'KZ',label:'Казахстан',phone:'+7',phoneLength:10,phoneHint:'10 цифр',documentHint:'9 цифр'}, {value:'UZ',label:'Узбекистан',phone:'+998',phoneLength:9,phoneHint:'9 цифр',documentHint:'2 буквы и 7 цифр'}, {value:'TJ',label:'Таджикистан',phone:'+992',phoneLength:9,phoneHint:'9 цифр',documentHint:'1 буква и 8 цифр'}, {value:'OTHER',label:'Другая страна',phone:'+',phoneLength:null,phoneHint:'от 7 до 15 цифр вместе с кодом страны',documentHint:'от 4 до 30 букв или цифр'}, ]; const PROFILE_DOCUMENT_PATTERNS = { KG:/^\p{L}{2}\d{7}$/u, RU:/^\d{10}$/, KZ:/^\d{9}$/, UZ:/^\p{L}{2}\d{7}$/u, TJ:/^\p{L}\d{8}$/u, OTHER:/^[\p{L}\p{N} -]{4,30}$/u, }; const PROFILE_MAX_BIRTH_DATE = '2009-12-31'; function profileDateYearsAgo(years){ const date=new Date(); date.setFullYear(date.getFullYear()-years); const month=String(date.getMonth()+1).padStart(2,'0'); const day=String(date.getDate()).padStart(2,'0'); return `${date.getFullYear()}-${month}-${day}`; } async function cabinetAuthRequest(payload){ const response=await fetch('api/auth.php',{ method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload), }); 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; } function CabSettings({ user, onUserChange }){ const userId = `KRSU-${String(user.id||1).padStart(6,'0')}`; const [profile,setProfile]=useState({ certificate_name:user.certificate_name||user.name||'', birth_date:user.birth_date||'', citizenship:user.citizenship||'KG', document_number:user.document_number||'', phone_country:user.phone_country||'KG', phone_number:user.phone_number||'', }); const [profileBusy,setProfileBusy]=useState(false); const [profileMessage,setProfileMessage]=useState(null); const [password,setPassword]=useState({current:'',next:'',repeat:''}); const [passwordBusy,setPasswordBusy]=useState(false); const [passwordMessage,setPasswordMessage]=useState(null); const maxBirthDate=PROFILE_MAX_BIRTH_DATE; const citizenship=PROFILE_COUNTRIES.find(item=>item.value===profile.citizenship)||PROFILE_COUNTRIES[0]; const phoneCountry=PROFILE_COUNTRIES.find(item=>item.value===profile.phone_country)||PROFILE_COUNTRIES[0]; function updateProfile(key,value){ setProfile(current=>({...current,[key]:value})); setProfileMessage(null); } function validateProfile(){ if(profile.certificate_name.trim().length<5)return 'Введите полное ФИО для сертификата.'; if(!profile.birth_date)return 'Укажите дату рождения.'; const today=profileDateYearsAgo(0); if(profile.birth_date>today)return 'Дата рождения не может превышать текущую дату.'; if(profile.birth_date>maxBirthDate)return 'Дата рождения должна быть раньше 2010 года.'; const documentNumber=profile.document_number.trim().toUpperCase(); if(!PROFILE_DOCUMENT_PATTERNS[profile.citizenship]?.test(documentNumber))return `Номер документа: ${citizenship.documentHint}.`; if(!/^\d+$/.test(profile.phone_number))return 'Телефон должен содержать только цифры.'; if(phoneCountry.phoneLength!==null&&profile.phone_number.length!==phoneCountry.phoneLength)return `Для страны «${phoneCountry.label}» телефон должен содержать ${phoneCountry.phoneHint}.`; if(phoneCountry.phoneLength===null&&(profile.phone_number.length<7||profile.phone_number.length>15))return 'Международный номер должен содержать от 7 до 15 цифр.'; return ''; } async function saveProfile(event){ event.preventDefault(); const validationError=validateProfile(); if(validationError){setProfileMessage({type:'error',text:validationError});return;} setProfileBusy(true);setProfileMessage(null); try{ const result=await cabinetAuthRequest({action:'save_profile_details',...profile,certificate_name:profile.certificate_name.trim(),document_number:profile.document_number.trim().toUpperCase()}); if(result.user)onUserChange&&onUserChange(result.user); setProfile(current=>result.user?{ certificate_name:result.user.certificate_name||current.certificate_name, birth_date:result.user.birth_date||current.birth_date, citizenship:result.user.citizenship||current.citizenship, document_number:result.user.document_number||current.document_number.trim().toUpperCase(), phone_country:result.user.phone_country||current.phone_country, phone_number:result.user.phone_number||current.phone_number, }:{...current,document_number:current.document_number.trim().toUpperCase()}); setProfileMessage({type:'success',text:result.message||'Данные профиля сохранены.'}); }catch(error){setProfileMessage({type:'error',text:error.message||'Не удалось сохранить данные.'});} finally{setProfileBusy(false);} } async function changePassword(event){ event.preventDefault(); if(password.next.length<10){setPasswordMessage({type:'error',text:'Новый пароль должен быть не короче 10 символов.'});return;} if(password.next!==password.repeat){setPasswordMessage({type:'error',text:'Новый пароль и повтор не совпадают.'});return;} if(password.current===password.next){setPasswordMessage({type:'error',text:'Новый пароль должен отличаться от текущего.'});return;} setPasswordBusy(true);setPasswordMessage(null); try{ await cabinetAuthRequest({action:'change_password',current_password:password.current,new_password:password.next}); setPassword({current:'',next:'',repeat:''}); setPasswordMessage({type:'success',text:'Пароль успешно изменён.'}); }catch(error){setPasswordMessage({type:'error',text:error.message||'Не удалось изменить пароль.'});} finally{setPasswordBusy(false);} } return (

Настройки профиля

Личные данные и параметры аккаунта. Эти данные используются для оформления сертификата и идентификации пользователя.

{user.name}
{user.email}
Обязательный блок для сертификата: заполните поля ниже полностью. Без них сертификат/диплом не выдаётся.
updateProfile('certificate_name',e.target.value)} placeholder="Иванов Иван Иванович" maxLength={190} autoComplete="name" required/>
updateProfile('birth_date',e.target.value)} max={maxBirthDate} required/>Можно указать дату не позднее 31.12.2009.
updateProfile('document_number',e.target.value)} placeholder={citizenship.documentHint} maxLength={30} autoCapitalize="characters" required/>Формат для страны «{citizenship.label}»: {citizenship.documentHint}.
Email используется как логин аккаунта.
{phoneCountry.phone}updateProfile('phone_number',e.target.value)} placeholder={phoneCountry.phoneHint} maxLength={15} autoComplete="tel-national" required/>
Введите только цифры: {phoneCountry.phoneHint}.
{profileMessage&&
{profileMessage.text}
}

Смена пароля

Новый пароль должен содержать не менее 10 символов.

{setPassword(v=>({...v,current:e.target.value}));setPasswordMessage(null);}} autoComplete="current-password" required/>
{setPassword(v=>({...v,next:e.target.value}));setPasswordMessage(null);}} minLength={10} autoComplete="new-password" required/>
{setPassword(v=>({...v,repeat:e.target.value}));setPasswordMessage(null);}} minLength={10} autoComplete="new-password" required/>
{passwordMessage&&
{passwordMessage.text}
}
); } Object.assign(window, { Test, CabMaterials, CabCerts, CabSettings });