/* ===== Личный кабинет участника Лиги: пять модулей ===== */
const LEAGUE_ROUTES = {
'league-rating':'rating',
'league-infrastructure':'infrastructure',
'league-discounts':'discounts',
'league-pilot-card':'pilot',
'league-support':'support',
};
const LEAGUE_STATUS = {
pending:'Ожидает подтверждения',confirmed:'Подтверждено',cancelled:'Отменено',completed:'Завершено',no_show:'Неявка',
new:'Новое',diagnostics:'Диагностика',approval:'Ожидает согласования',repairing:'В ремонте',ready:'Готово',issued:'Выдано',
accepted:'Принято',working:'В работе',waiting_user:'Ожидает пользователя',workshop:'Передано в мастерскую',resolved:'Решено',closed:'Закрыто',
};
const LEAGUE_PRIORITY = {normal:'Обычный',high:'Высокий',urgent:'Срочный'};
async function leagueFetch(options={}){
const method=String(options.method||'GET').toUpperCase();
const url=method==='GET'?`api/league.php?_=${Date.now()}`:'api/league.php';
const res=await fetch(url,{credentials:'same-origin',cache:'no-store',...options,headers:{'Content-Type':'application/json',...(options.headers||{})}});
const data=await res.json().catch(()=>({ok:false,error:'Пустой ответ сервера'}));
if(!res.ok||data.ok===false) throw new Error(data.error||`HTTP ${res.status}`);
return data;
}
function leagueDate(value, time=true){
if(!value)return '—';
const date=new Date(String(value).replace(' ','T'));
return Number.isNaN(date.getTime())?String(value):date.toLocaleString('ru-RU',{day:'2-digit',month:'2-digit',year:'numeric',...(time?{hour:'2-digit',minute:'2-digit'}:{})});
}
function leagueLap(ms){
if(ms===null||ms===undefined||ms==='')return '—';
const n=Number(ms);return `${Math.floor(n/60000)}:${String(Math.floor((n%60000)/1000)).padStart(2,'0')}.${String(n%1000).padStart(3,'0')}`;
}
function LeagueNotice({message,type=''}){ return message?
{message}
:null; }
function LeagueEmpty({children='Данных пока нет.',action=null}){ return {children} {action&&{action}
} ; }
function LeagueStatus({value}){ return {LEAGUE_STATUS[value]||value} ; }
function LeagueMemberApp({module,go,user}){
const [data,setData]=useState(null),[error,setError]=useState(''),[loading,setLoading]=useState(true);
async function load(){
setLoading(true);setError('');
try{setData((await leagueFetch()).data);}catch(e){setError(e.message);}finally{setLoading(false);}
}
useEffect(()=>{load();},[]);
if(loading)return ;
if(error)return
Сервисы Лиги Повторить ;
const props={data,go,user,reload:load};
if(module==='infrastructure')return ;
if(module==='discounts')return ;
if(module==='pilot')return ;
if(module==='support')return ;
return ;
}
function LeagueHeading({eyebrow='Лига Дронов',title,subtitle,actions,icon='drone',stats=[]}){
return
{eyebrow} {title} {subtitle}
{stats.length>0&&{stats.map((item,index)=>
{item.value} {item.label}
)}
}{actions&&{actions}
} ;
}
function LeagueRating({data}){
const r=data.rating, current=r.level.current, next=r.level.next;
const [city,setCity]=useState(''),[discipline,setDiscipline]=useState('');
const cities=[...new Set(r.ranking.map(x=>x.city).filter(Boolean))];
const disciplines=[...new Set(r.ranking.map(x=>x.discipline).filter(Boolean))];
const rows=r.ranking.filter(x=>(!city||x.city===city)&&(!discipline||x.discipline===discipline));
const progress=next?Math.max(0,Math.min(100,Math.round((r.balance-current.minPoints)/(next.minPoints-current.minPoints)*100))):100;
return
{next?`До уровня ${next.name}`:'Максимальный уровень'} {next?`${Math.max(0,next.minPoints-r.balance)} баллов`:'Master'}
Общий рейтинг Фильтры по городу и дисциплине.
setDiscipline(e.target.value)}>Все дисциплины {disciplines.map(x=>{x} )} setCity(e.target.value)}>Все города {cities.map(x=>{x} )}
{rows.map(row=>
#{row.place} {row.nickname||row.name} {row.name}
{row.discipline} {row.city||'—'} {row.points} )}{!rows.length&&
}
История операций Записи не удаляются и содержат автора изменения.
{r.transactions.map(x=>
{leagueDate(x.createdAt)} =0?'var(--green-700)':'#b42318'}}>{Number(x.points)>0?'+':''}{x.points} {x.reason} {x.activityType}{x.actorName?` · ${x.actorName}`:''}
{x.balanceAfter} )}{!r.transactions.length&&
Операций пока нет. }
;
}
function LeagueInfrastructure({data,reload}){
const d=data.infrastructure,[trackId,setTrackId]=useState(''),[message,setMessage]=useState(''),[busy,setBusy]=useState(false),[reslot,setReslot]=useState({});
const [repair,setRepair]=useState({serviceId:'',droneModel:'',problem:'',attachments:[]});
async function act(payload){setBusy(true);setMessage('');try{await leagueFetch({method:'POST',body:JSON.stringify(payload)});setMessage('Готово. Данные обновлены.');await reload();}catch(e){setMessage(e.message);}finally{setBusy(false);}}
const visibleSlots=d.slots.filter(s=>!trackId||String(s.trackId)===String(trackId));
const selectedTrack=d.tracks.find(t=>String(t.id)===String(trackId));
return
!['issued','cancelled'].includes(x.status)).length,label:'заявок в работе'}]} actions={ Обновить }/>
Трассы Адреса, расписание и правила площадок.
{d.tracks.map(t=>
{t.photos?.[0]? :
}{t.name} {t.address}
{t.description}
{t.hours||'По расписанию'}
{t.rules&&
Правила {t.rules}
}
)}{!d.tracks.length&&
Трассы появятся после публикации администратором. }
Свободные слоты Бронирование создаётся со статусом ожидания.
setTrackId(e.target.value)}>Все трассы {d.tracks.map(t=>{t.name} )}
{visibleSlots.map(s=>{const t=d.tracks.find(x=>Number(x.id)===Number(s.trackId));return
{t?.name||'Трасса'} {t?.address}
{leagueDate(s.startsAt)} {s.durationMinutes} мин. act({action:'book',slotId:s.id})}>Забронировать })}{!visibleSlots.length&&
Обновить расписание}>{selectedTrack?`Для трассы «${selectedTrack.name}» свободное время пока не опубликовано.`:'Свободных слотов пока нет. Выберите трассу или обновите расписание.'} }
{d.bookings.map(b=>
{b.trackName}
{leagueDate(b.startsAt)} · {b.durationMinutes} мин.
{b.address}
{['pending','confirmed'].includes(b.status)&&
{b.status==='pending'&&
setReslot({...reslot,[b.id]:e.target.value})}>Перенести на… {d.slots.map(s=>{leagueDate(s.startsAt)} · {d.tracks.find(t=>Number(t.id)===Number(s.trackId))?.name} )} act({action:'reschedule_booking',id:b.id,slotId:reslot[b.id]})}>Перенести
}
act({action:'cancel_booking',id:b.id})}>Отменить бронирование }
)}{!d.bookings.length&&
У вас ещё нет бронирований. }
Ремонтная мастерская Создайте заявку и отслеживайте диагностику и ремонт.
Услуга setRepair({...repair,serviceId:e.target.value})}>Диагностика {d.services.map(s=>{s.name} · от {s.priceFrom} сом )}
Модель дрона setRepair({...repair,droneModel:e.target.value})}/>
Описание проблемы
setRepair({...repair,attachments:files})}/>act({action:'repair',...repair})}>Отправить заявку
{d.repairs.map(r=>
Заявка #{r.id} · {r.drone_model}
{r.problem}
{r.serviceName||'Диагностика'} {r.specialist&&Мастер: {r.specialist} }{r.cost!==null&&{r.cost} сом }
)}{!d.repairs.length&&
Заявок на ремонт пока нет. }
;
}
function LeagueFileUpload({files,onChange}){
const [busy,setBusy]=useState(false),[error,setError]=useState('');
async function upload(file){setBusy(true);setError('');try{const form=new FormData();form.append('file',file);form.append('purpose','league-attachment');const res=await fetch('api/upload.php',{method:'POST',credentials:'same-origin',body:form});const d=await res.json();if(!res.ok||!d.ok)throw new Error(d.error||'Ошибка загрузки');onChange([...files,d.path]);}catch(e){setError(e.message);}finally{setBusy(false);}}
return Фото, видео, DVR или конфигурация e.target.files?.[0]&&upload(e.target.files[0])}/>{files?.length>0&&
{files.map((x,i)=>{x.split('/').pop()} onChange(files.filter((_,n)=>n!==i))}>× )}
}{error&&
{error} }
;
}
function LeagueDiscounts({data,reload}){
const [category,setCategory]=useState(''),[region,setRegion]=useState(''),[copied,setCopied]=useState('');
const offers=data.discounts.offers,categories=[...new Set(offers.map(x=>x.category))],regions=[...new Set(offers.map(x=>x.region).filter(Boolean))];
const shown=offers.filter(x=>(!category||x.category===category)&&(!region||x.region===region));
async function copy(o){await navigator.clipboard.writeText(o.promo_code||'');setCopied(String(o.id));await leagueFetch({method:'POST',body:JSON.stringify({action:'use_offer',offerId:o.id})});setTimeout(()=>setCopied(''),1500);}
return Обновить}/>
setCategory(e.target.value)}>Все категории {categories.map(x=>{x} )} setRegion(e.target.value)}>Все регионы {regions.map(x=>{x} )}
{shown.map(o=>
{o.partnerLogo?
:
}
{o.discount_type==='percent'?`−${o.discount_value}%`:o.discount_value} {Number(o.is_league_shop)?'Магазин Лиги':o.partnerName||'Партнёр'} {o.title} {o.description}
{o.category}{o.region?` · ${o.region}`:''} {o.ends_at&&
Действует до {leagueDate(o.ends_at,false)} }{o.conditions&&
Условия {o.conditions}
}
{o.promo_code&&
copy(o)}>{copied===String(o.id)?'Скопировано':o.promo_code} }{o.shop_url&&
Перейти }
)}{!shown.length&&
Новых привилегий по выбранным фильтрам пока нет. }
;
}
function LeaguePilotCard({data,reload}){
const p=data.pilot,[editing,setEditing]=useState(false),[draft,setDraft]=useState({...p.profile}),[message,setMessage]=useState(''),[photoBusy,setPhotoBusy]=useState(false),[photoError,setPhotoError]=useState('');
async function save(){try{await leagueFetch({method:'POST',body:JSON.stringify({action:'profile',profile:draft})});setMessage('Профиль сохранён.');setEditing(false);await reload();}catch(e){setMessage(e.message);}}
async function uploadPhoto(file){
if(!file)return;
setPhotoError('');
if(!['image/jpeg','image/png','image/webp'].includes(file.type)){setPhotoError('Выберите фотографию JPG, PNG или WebP.');return;}
if(file.size>5*1024*1024){setPhotoError('Фотография должна быть не больше 5 МБ.');return;}
setPhotoBusy(true);
try{
const form=new FormData();form.append('file',file);form.append('purpose','pilot-photo');
const res=await fetch('api/upload.php',{method:'POST',credentials:'same-origin',body:form});
const result=await res.json().catch(()=>({ok:false,error:'Пустой ответ сервера'}));
if(!res.ok||!result.ok)throw new Error(result.error||'Не удалось загрузить фотографию');
setDraft(current=>({...current,photo:result.path}));
}catch(e){setPhotoError(e.message);}finally{setPhotoBusy(false);}
}
const m=p.metrics;
const recentFlights=[...p.flights].slice(0,10).reverse(),ratingTrend=[...data.rating.transactions].slice(0,10).reverse();
const timedFlights=recentFlights.filter(x=>Number(x.best_lap_ms)>0),maxMinutes=Math.max(1,...recentFlights.map(x=>Number(x.duration_minutes)||0)),maxBalance=Math.max(1,...ratingTrend.map(x=>Number(x.balanceAfter)||0)),maxLap=Math.max(1,...timedFlights.map(x=>Number(x.best_lap_ms)||0));
return setEditing(v=>!v)}> {editing?'Закрыть редактор':'Редактировать профиль'}}/>
{p.profile.photo?
:
} {p.profile.nickname||data.member.name} {data.member.name} · {p.profile.city||'Город не указан'}
{p.profile.discipline} {p.profile.team&&{p.profile.team} }{data.rating.level.current.name} #{data.rating.place||'—'}
{p.profile.bio&&
{p.profile.bio}
}
{editing&&}
{[['drone',m.flights,'полётов'],['clock',Math.round(m.minutes/60*10)/10,'часов'],['flag',m.trainings,'тренировок'],['trophy',m.competitions,'соревнований'],['star',m.wins,'побед'],['award',m.podiums,'призовых мест']].map(([i,v,t])=>
)}
Динамика рейтинга {ratingTrend.map(x=>
{x.balanceAfter}
)}{!ratingTrend.length&&
}
Активность полётов {recentFlights.map(x=>
{x.duration_minutes}
)}{!recentFlights.length&&
}
Лучшие круги {timedFlights.map(x=>
{Math.round(Number(x.best_lap_ms)/100)/10}с
)}{!timedFlights.length&&
}
{p.flights.map(f=>
{leagueDate(f.flown_at,false)} {f.track_name||'Тренировка'} {f.drone||'—'} {f.laps} круг. {leagueLap(f.best_lap_ms)} {f.result_place?`${f.result_place} место`:'—'}
)}{!p.flights.length&&
Подтверждённых полётов пока нет. }
Оборудование {p.equipment.map(x=>
{x.type} {x.name} {x.details}
)}{!p.equipment.length&&
}
Достижения {p.achievements.map(x=>
{x.title} {leagueDate(x.awarded_at,false)} · {x.reason}
)}{!p.achievements.length&&
}
;
}
function LeagueSupport({data,reload}){
const s=data.support,[draft,setDraft]=useState({category:'Другое',subject:'',description:'',priority:'normal',attachments:[]}),[message,setMessage]=useState(''),[open,setOpen]=useState(null),[reply,setReply]=useState('');
async function act(payload){setMessage('');try{await leagueFetch({method:'POST',body:JSON.stringify(payload)});setMessage('Сообщение отправлено.');setDraft({...draft,subject:'',description:'',attachments:[]});setReply('');await reload();}catch(e){setMessage(e.message);}}
return !['resolved','closed'].includes(x.status)).length,label:'открытых обращений'},{icon:'clock',value:s.settings.hours||'По графику',label:'режим работы'}]} actions={ Обновить }/>
График поддержки {s.settings.hours}
Правила обращения {s.settings.rules}
Быстрые решения Ответы по типовым неисправностям.
{s.articles.map(a=>
{a.category} {a.title} )}{!s.articles.length&& }
Категория setDraft({...draft,category:e.target.value})}>{['Видео','Управление','Betaflight','Ремонт','Оборудование','Другое'].map(x=>{x} )}
Приоритет setDraft({...draft,priority:e.target.value})}>{Object.entries(LEAGUE_PRIORITY).map(([k,v])=>{v} )}
Тема setDraft({...draft,subject:e.target.value})}/>
Подробное описание
setDraft({...draft,attachments:files})}/>act({action:'ticket',...draft})}>Создать обращение
{s.tickets.map(t=>
setOpen(open===t.id?null:t.id)}>#{t.id} · {t.subject} {LEAGUE_PRIORITY[t.priority]}
{t.category} · {leagueDate(t.updated_at)}
{open===t.id&&
{t.description}
{t.messages.map(m=>
{m.authorName} {leagueDate(m.createdAt)} {m.message}
)}{!['resolved','closed'].includes(t.status)&&
setReply(e.target.value)} placeholder="Написать специалисту…"/>act({action:'message',ticketId:t.id,message:reply})}>Отправить
}{t.solution&&
Решение: {t.solution}
}
}
)}{!s.tickets.length&&
Обращений пока нет. }
;
}
function LeaguePublicPilot({slug,go}){
const [data,setData]=useState(null),[error,setError]=useState('');
useEffect(()=>{fetch(`api/league-public.php?slug=${encodeURIComponent(slug||'')}`).then(r=>r.json().then(d=>{if(!r.ok||!d.ok)throw new Error(d.error);setData(d)})).catch(e=>setError(e.message));},[slug]);
if(error)return ;
if(!data)return Загружаю карточку пилота…
;
const p=data.pilot,m=data.metrics;
return go('pilot-rating')}> К рейтингу {p.photo?
:
}Публичная спортивная карточка {p.nickname||p.name} {p.name} · {p.city||'Кыргызстан'} · {p.discipline}
#{data.place||'—'} в рейтинге {p.points} баллов {p.team&&{p.team} }
{[['drone',m.flights,'полётов'],['clock',Math.round(m.minutes/60*10)/10,'часов'],['trophy',m.competitions,'соревнований'],['star',m.wins,'побед'],['award',m.podiums,'подиумов']].map(([i,v,t])=>
)}
{p.bio&&
}
Результаты {data.flights.map((f,i)=>
{leagueDate(f.flown_at,false)} {f.track_name} {f.drone} {f.laps} круг. {f.result_place?`#${f.result_place}`:leagueLap(f.best_lap_ms)}
)}{!data.flights.length&&
}
Достижения {data.achievements.map((x,i)=>
{x.title} {leagueDate(x.awarded_at,false)} · {x.reason}
)}{!data.achievements.length&&
}
;
}
Object.assign(window,{LeagueMemberApp,LeaguePublicPilot,LEAGUE_ROUTES});