add contests

This commit is contained in:
Виталий Лавшонок
2025-12-05 23:42:18 +03:00
parent 358c7def78
commit fd34761745
36 changed files with 2518 additions and 710 deletions

View File

@@ -0,0 +1,228 @@
import { cn } from '../../../../lib/cn';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import { useQuery } from '../../../../hooks/useQuery';
import { toastWarning } from '../../../../lib/toastNotification';
import { useEffect, useState } from 'react';
import { ReverseButton } from '../../../../components/button/ReverseButton';
import { PrimaryButton } from '../../../../components/button/PrimaryButton';
import {
addOrUpdateContestMember,
fetchParticipatingContests,
} from '../../../../redux/slices/contests';
type Role = 'None' | 'Participant' | 'Organizer';
export interface UpcoingContestItemProps {
name: string;
contestId: number;
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
startsAt: string;
endsAt: string;
attemptDurationMinutes: number;
type: 'first' | 'second';
groupId: number;
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${day}/${month}/${year}\n${hours}:${minutes}`;
}
function formatDurationTime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
const remainder = days % 10;
let suffix = 'дней';
if (remainder === 1 && days !== 11) suffix = 'день';
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20))
suffix = 'дня';
return `${days} ${suffix}`;
} else if (hours > 0) {
const mins = minutes % 60;
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
} else {
return `${minutes} мин`;
}
}
function formatWaitTime(ms: number): string {
const minutes = Math.floor(ms / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
const remainder = days % 10;
let suffix = 'дней';
if (remainder === 1 && days !== 11) suffix = 'день';
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20))
suffix = 'дня';
return `${days} ${suffix}`;
} else if (hours > 0) {
const mins = minutes % 60;
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
} else {
return `${minutes} мин`;
}
}
const UpcoingContestItem: React.FC<UpcoingContestItemProps> = ({
name,
contestId,
scheduleType,
startsAt,
endsAt,
attemptDurationMinutes,
type,
groupId,
}) => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [role, setRole] = useState<Role>('None');
const myname = useAppSelector((state) => state.auth.username);
const { contests: myContests } = useAppSelector(
(state) => state.contests.fetchMyContests,
);
const { contests: participantContests } = useAppSelector(
(state) => state.contests.fetchParticipating,
);
const query = useQuery();
const username = query.get('username') ?? myname ?? '';
const userId = useAppSelector((state) => state.auth.id);
const started = new Date(startsAt) <= new Date();
const finished = new Date(endsAt) <= new Date();
const waitTime = !started
? new Date(startsAt).getTime() - new Date().getTime()
: new Date(endsAt).getTime() - new Date().getTime();
useEffect(() => {
setRole(
(() => {
if (myContests?.some((c) => c.id === contestId)) {
return 'Organizer';
}
if (participantContests?.some((c) => c.id === contestId)) {
return 'Participant';
}
return 'None';
})(),
);
}, [myContests, participantContests]);
return (
<div
className={cn(
'w-full box-border relative rounded-[10px] px-[20px] py-[14px] text-liquid-white text-[16px] leading-[20px] cursor-pointer items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300 grid grid-flow-col',
userId
? 'grid-cols-[1fr,1fr,190px,130px,130px,150px]'
: 'grid-cols-[1fr,1fr,190px,130px,130px]',
type == 'first'
? ' bg-liquid-lighter'
: ' bg-liquid-background',
)}
onClick={() => {
if (!started) {
toastWarning('Контест еще не начался');
return;
}
const params = new URLSearchParams({
back: `/group/${groupId}/contests`,
});
navigate(`/contest/${contestId}?${params}`);
}}
>
<div className="text-left font-bold text-[18px]">{name}</div>
<div className="text-center text-liquid-brightmain font-normal flex items-center justify-center">
{username}
</div>
{scheduleType == 'AlwaysOpen' ? (
<div className="text-center text-nowrap whitespace-pre-line text-[14px]">
Всегда открыт
</div>
) : (
<div className="flex items-center gap-[5px] text-[14px]">
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(startsAt)}
</div>
<div>-</div>
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(endsAt)}
</div>
</div>
)}
<div className="text-center">
{formatDurationTime(attemptDurationMinutes)}
</div>
{!started ? (
<div className="text-center whitespace-pre-line ">
{'До начала\n' + formatWaitTime(waitTime)}
</div>
) : (
!finished && (
<div className="text-center whitespace-pre-line ">
{'До конца\n' + formatWaitTime(waitTime)}
</div>
)
)}
{userId && (
<div className="flex items-center justify-center">
{role == 'Organizer' || role == 'Participant' ? (
<ReverseButton
onClick={() => {
const params = new URLSearchParams({
back: `/group/${groupId}/contests`,
});
navigate(`/contest/${contestId}?${params}`);
}}
text="Войти"
/>
) : (
<PrimaryButton
onClick={() => {
dispatch(
addOrUpdateContestMember({
contestId: contestId,
member: {
userId: Number(userId),
role: 'Participant',
},
}),
)
.unwrap()
.then(() =>
dispatch(
fetchParticipatingContests({}),
),
);
}}
text="Регистрация"
/>
)}
</div>
)}
</div>
);
};
export default UpcoingContestItem;