From 95f747937556e7b19d56d0e0d5a93b4c57665fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=D0=B9=20=D0=9B=D0=B0?= =?UTF-8?q?=D0=B2=D1=88=D0=BE=D0=BD=D0=BE=D0=BA?= <114582703+valavshonok@users.noreply.github.com> Date: Tue, 2 Dec 2025 22:34:28 +0300 Subject: [PATCH] add contest thunck and remove google button --- src/assets/icons/groups/index.ts | 1 + src/redux/slices/contests.ts | 554 +++++++++++++++++++----- src/views/home/auth/Login.tsx | 9 - src/views/home/auth/Register.tsx | 9 - src/views/home/contests/ModalCreate.tsx | 18 +- src/views/home/groups/Filter.tsx | 30 +- 6 files changed, 459 insertions(+), 162 deletions(-) diff --git a/src/assets/icons/groups/index.ts b/src/assets/icons/groups/index.ts index 1913817..392cf46 100644 --- a/src/assets/icons/groups/index.ts +++ b/src/assets/icons/groups/index.ts @@ -5,4 +5,5 @@ import Edit from './edit.svg'; import UserAdd from './user-profile-add.svg'; import ChevroneDown from './chevron-down.svg'; + export { Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown }; diff --git a/src/redux/slices/contests.ts b/src/redux/slices/contests.ts index 5ddff69..a8cf364 100644 --- a/src/redux/slices/contests.ts +++ b/src/redux/slices/contests.ts @@ -5,10 +5,6 @@ import axios from '../../axios'; // Типы // ===================== -// ===================== -// Типы для посылок -// ===================== - export interface Solution { id: number; missionId: number; @@ -73,11 +69,25 @@ export interface Contest { members?: Member[]; } +export interface Attempt { + attemptId: number; + contestId: number; + startedAt: string; + expiresAt: string; + finished: boolean; + results?: any[]; +} + interface ContestsResponse { hasNextPage: boolean; contests: Contest[]; } +interface MembersPage { + members: Member[]; + hasNextPage: boolean; +} + export interface CreateContestBody { name: string; description?: string; @@ -142,106 +152,106 @@ interface ContestsState { status: Status; error?: string; }; + + // NEW: + fetchContestMembers: { + members: Member[]; + hasNextPage: boolean; + status: Status; + error?: string; + }; + addOrUpdateMember: { + status: Status; + error?: string; + }; + deleteContestMember: { + status: Status; + error?: string; + }; + + startAttempt: { + attempt?: Attempt; + status: Status; + error?: string; + }; + fetchMyAttemptsInContest: { + attempts: Attempt[]; + status: Status; + error?: string; + }; + fetchMyAllAttempts: { + attempts: Attempt[]; + status: Status; + error?: string; + }; + fetchMyActiveAttempt: { + attempt?: Attempt | null; + status: Status; + error?: string; + }; + + checkRegistration: { + registered: boolean; + status: Status; + error?: string; + }; + + fetchUpcomingEligible: { + contests: Contest[]; + status: Status; + error?: string; + }; } -const initialState: ContestsState = { - fetchContests: { - contests: [], - hasNextPage: false, - status: 'idle', - error: undefined, - }, - fetchContestById: { - contest: { - id: 0, - name: '', - description: '', - scheduleType: 'AlwaysOpen', - visibility: 'Public', - startsAt: '', - endsAt: '', - attemptDurationMinutes: 0, - maxAttempts: 0, - allowEarlyFinish: false, - groupId: undefined, - groupName: undefined, - missions: [], - articles: [], - members: [], - }, - status: 'idle', - error: undefined, - }, - fetchMySubmissions: { - submissions: [], - status: 'idle', - error: undefined, - }, +const emptyContest: Contest = { + id: 0, + name: '', + description: '', + scheduleType: 'AlwaysOpen', + visibility: 'Public', + startsAt: '', + endsAt: '', + attemptDurationMinutes: 0, + maxAttempts: 0, + allowEarlyFinish: false, + missions: [], + articles: [], + members: [], +}; - createContest: { - contest: { - id: 0, - name: '', - description: '', - scheduleType: 'AlwaysOpen', - visibility: 'Public', - startsAt: '', - endsAt: '', - attemptDurationMinutes: 0, - maxAttempts: 0, - allowEarlyFinish: false, - groupId: undefined, - groupName: undefined, - missions: [], - articles: [], - members: [], - }, - status: 'idle', - error: undefined, - }, - updateContest: { - contest: { - id: 0, - name: '', - description: '', - scheduleType: 'AlwaysOpen', - visibility: 'Public', - startsAt: '', - endsAt: '', - attemptDurationMinutes: 0, - maxAttempts: 0, - allowEarlyFinish: false, - groupId: undefined, - groupName: undefined, - missions: [], - articles: [], - members: [], - }, - status: 'idle', - error: undefined, - }, - deleteContest: { - status: 'idle', - error: undefined, - }, - fetchMyContests: { - contests: [], - status: 'idle', - error: undefined, - }, +const initialState: ContestsState = { + fetchContests: { contests: [], hasNextPage: false, status: 'idle' }, + fetchContestById: { contest: emptyContest, status: 'idle' }, + createContest: { contest: emptyContest, status: 'idle' }, + fetchMySubmissions: { submissions: [], status: 'idle' }, + updateContest: { contest: emptyContest, status: 'idle' }, + deleteContest: { status: 'idle' }, + fetchMyContests: { contests: [], status: 'idle' }, fetchRegisteredContests: { contests: [], hasNextPage: false, status: 'idle', - error: undefined, }, + + fetchContestMembers: { members: [], hasNextPage: false, status: 'idle' }, + addOrUpdateMember: { status: 'idle' }, + deleteContestMember: { status: 'idle' }, + + startAttempt: { status: 'idle' }, + fetchMyAttemptsInContest: { attempts: [], status: 'idle' }, + fetchMyAllAttempts: { attempts: [], status: 'idle' }, + fetchMyActiveAttempt: { attempt: null, status: 'idle' }, + + checkRegistration: { registered: false, status: 'idle' }, + fetchUpcomingEligible: { contests: [], status: 'idle' }, }; // ===================== // Async Thunks // ===================== -// Мои посылки в контесте +// Existing ---------------------------- + export const fetchMySubmissions = createAsyncThunk( 'contests/fetchMySubmissions', async (contestId: number, { rejectWithValue }) => { @@ -258,7 +268,6 @@ export const fetchMySubmissions = createAsyncThunk( }, ); -// Все контесты export const fetchContests = createAsyncThunk( 'contests/fetchAll', async ( @@ -283,7 +292,6 @@ export const fetchContests = createAsyncThunk( }, ); -// Контест по ID export const fetchContestById = createAsyncThunk( 'contests/fetchById', async (id: number, { rejectWithValue }) => { @@ -298,7 +306,6 @@ export const fetchContestById = createAsyncThunk( }, ); -// Создание контеста export const createContest = createAsyncThunk( 'contests/create', async (contestData: CreateContestBody, { rejectWithValue }) => { @@ -316,7 +323,6 @@ export const createContest = createAsyncThunk( }, ); -// 🆕 Обновление контеста export const updateContest = createAsyncThunk( 'contests/update', async ( @@ -340,7 +346,6 @@ export const updateContest = createAsyncThunk( }, ); -// 🆕 Удаление контеста export const deleteContest = createAsyncThunk( 'contests/delete', async (contestId: number, { rejectWithValue }) => { @@ -355,7 +360,6 @@ export const deleteContest = createAsyncThunk( }, ); -// Контесты, созданные мной export const fetchMyContests = createAsyncThunk( 'contests/fetchMyContests', async (_, { rejectWithValue }) => { @@ -370,7 +374,6 @@ export const fetchMyContests = createAsyncThunk( }, ); -// Контесты, где я зарегистрирован export const fetchRegisteredContests = createAsyncThunk( 'contests/fetchRegisteredContests', async ( @@ -393,6 +396,182 @@ export const fetchRegisteredContests = createAsyncThunk( }, ); +// NEW ----------------------------------- + +// Add or update member +export const addOrUpdateContestMember = createAsyncThunk( + 'contests/addOrUpdateMember', + async ( + { + contestId, + member, + }: { contestId: number; member: { userId: number; role: string } }, + { rejectWithValue }, + ) => { + try { + const response = await axios.post( + `/contests/${contestId}/members`, + member, + ); + return { contestId, members: response.data }; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || + 'Failed to add or update contest member', + ); + } + }, +); + +// Delete member +export const deleteContestMember = createAsyncThunk( + 'contests/deleteContestMember', + async ( + { contestId, memberId }: { contestId: number; memberId: number }, + { rejectWithValue }, + ) => { + try { + await axios.delete(`/contests/${contestId}/members/${memberId}`); + return { contestId, memberId }; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || + 'Failed to delete contest member', + ); + } + }, +); + +// Start attempt +export const startContestAttempt = createAsyncThunk( + 'contests/startContestAttempt', + async (contestId: number, { rejectWithValue }) => { + try { + const response = await axios.post( + `/contests/${contestId}/attempts`, + ); + return response.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || + 'Failed to start contest attempt', + ); + } + }, +); + +// My attempts in contest +export const fetchMyAttemptsInContest = createAsyncThunk( + 'contests/fetchMyAttemptsInContest', + async (contestId: number, { rejectWithValue }) => { + try { + const response = await axios.get( + `/contests/${contestId}/attempts/my`, + ); + return response.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Failed to fetch my attempts', + ); + } + }, +); + +// Members with pagination +export const fetchContestMembers = createAsyncThunk( + 'contests/fetchContestMembers', + async ( + { + contestId, + page = 0, + pageSize = 25, + }: { contestId: number; page?: number; pageSize?: number }, + { rejectWithValue }, + ) => { + try { + const response = await axios.get( + `/contests/${contestId}/members`, + { params: { page, pageSize } }, + ); + return { contestId, ...response.data }; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || + 'Failed to fetch contest members', + ); + } + }, +); + +// Check registration +export const checkContestRegistration = createAsyncThunk( + 'contests/checkRegistration', + async (contestId: number, { rejectWithValue }) => { + try { + const response = await axios.get<{ registered: boolean }>( + `/contests/${contestId}/registered`, + ); + return { contestId, registered: response.data.registered }; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Failed to check registration', + ); + } + }, +); + +// Upcoming eligible contests +export const fetchUpcomingEligibleContests = createAsyncThunk( + 'contests/fetchUpcomingEligible', + async (_, { rejectWithValue }) => { + try { + const response = await axios.get( + '/contests/upcoming/eligible', + ); + return response.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || + 'Failed to fetch upcoming eligible contests', + ); + } + }, +); + +// All my attempts +export const fetchMyAllAttempts = createAsyncThunk( + 'contests/fetchMyAllAttempts', + async (_, { rejectWithValue }) => { + try { + const response = await axios.get( + '/contests/attempts/my', + ); + return response.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Failed to fetch my attempts', + ); + } + }, +); + +// Active attempt +export const fetchMyActiveAttempt = createAsyncThunk( + 'contests/fetchMyActiveAttempt', + async (contestId: number, { rejectWithValue }) => { + try { + const response = await axios.get( + `/contests/${contestId}/attempts/my/active`, + ); + return { contestId, attempt: response.data }; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Failed to fetch active attempt', + ); + } + }, +); + // ===================== // Slice // ===================== @@ -401,7 +580,6 @@ const contestsSlice = createSlice({ name: 'contests', initialState, reducers: { - // 🆕 Сброс статусов setContestStatus: ( state, action: PayloadAction<{ key: keyof ContestsState; status: Status }>, @@ -413,7 +591,8 @@ const contestsSlice = createSlice({ }, }, extraReducers: (builder) => { - // 🆕 fetchMySubmissions + // ——— YOUR EXISTING HANDLERS (unchanged) ——— + builder.addCase(fetchMySubmissions.pending, (state) => { state.fetchMySubmissions.status = 'loading'; state.fetchMySubmissions.error = undefined; @@ -430,10 +609,8 @@ const contestsSlice = createSlice({ state.fetchMySubmissions.error = action.payload; }); - // fetchContests builder.addCase(fetchContests.pending, (state) => { state.fetchContests.status = 'loading'; - state.fetchContests.error = undefined; }); builder.addCase( fetchContests.fulfilled, @@ -448,10 +625,8 @@ const contestsSlice = createSlice({ state.fetchContests.error = action.payload; }); - // fetchContestById builder.addCase(fetchContestById.pending, (state) => { state.fetchContestById.status = 'loading'; - state.fetchContestById.error = undefined; }); builder.addCase( fetchContestById.fulfilled, @@ -465,10 +640,8 @@ const contestsSlice = createSlice({ state.fetchContestById.error = action.payload; }); - // createContest builder.addCase(createContest.pending, (state) => { state.createContest.status = 'loading'; - state.createContest.error = undefined; }); builder.addCase( createContest.fulfilled, @@ -482,10 +655,8 @@ const contestsSlice = createSlice({ state.createContest.error = action.payload; }); - // 🆕 updateContest builder.addCase(updateContest.pending, (state) => { state.updateContest.status = 'loading'; - state.updateContest.error = undefined; }); builder.addCase( updateContest.fulfilled, @@ -499,16 +670,13 @@ const contestsSlice = createSlice({ state.updateContest.error = action.payload; }); - // 🆕 deleteContest builder.addCase(deleteContest.pending, (state) => { state.deleteContest.status = 'loading'; - state.deleteContest.error = undefined; }); builder.addCase( deleteContest.fulfilled, (state, action: PayloadAction) => { state.deleteContest.status = 'successful'; - // Удалим контест из списков state.fetchContests.contests = state.fetchContests.contests.filter( (c) => c.id !== action.payload, @@ -524,10 +692,8 @@ const contestsSlice = createSlice({ state.deleteContest.error = action.payload; }); - // fetchMyContests builder.addCase(fetchMyContests.pending, (state) => { state.fetchMyContests.status = 'loading'; - state.fetchMyContests.error = undefined; }); builder.addCase( fetchMyContests.fulfilled, @@ -541,10 +707,8 @@ const contestsSlice = createSlice({ state.fetchMyContests.error = action.payload; }); - // fetchRegisteredContests builder.addCase(fetchRegisteredContests.pending, (state) => { state.fetchRegisteredContests.status = 'loading'; - state.fetchRegisteredContests.error = undefined; }); builder.addCase( fetchRegisteredContests.fulfilled, @@ -563,6 +727,168 @@ const contestsSlice = createSlice({ state.fetchRegisteredContests.error = action.payload; }, ); + + // NEW HANDLERS + + builder.addCase(fetchContestMembers.pending, (state) => { + state.fetchContestMembers.status = 'loading'; + }); + builder.addCase( + fetchContestMembers.fulfilled, + ( + state, + action: PayloadAction<{ + contestId: number; + members: Member[]; + hasNextPage: boolean; + }>, + ) => { + state.fetchContestMembers.status = 'successful'; + state.fetchContestMembers.members = action.payload.members; + state.fetchContestMembers.hasNextPage = + action.payload.hasNextPage; + }, + ); + builder.addCase(fetchContestMembers.rejected, (state, action: any) => { + state.fetchContestMembers.status = 'failed'; + state.fetchContestMembers.error = action.payload; + }); + + builder.addCase(addOrUpdateContestMember.pending, (state) => { + state.addOrUpdateMember.status = 'loading'; + }); + builder.addCase(addOrUpdateContestMember.fulfilled, (state) => { + state.addOrUpdateMember.status = 'successful'; + }); + builder.addCase( + addOrUpdateContestMember.rejected, + (state, action: any) => { + state.addOrUpdateMember.status = 'failed'; + state.addOrUpdateMember.error = action.payload; + }, + ); + + builder.addCase(deleteContestMember.pending, (state) => { + state.deleteContestMember.status = 'loading'; + }); + builder.addCase(deleteContestMember.fulfilled, (state) => { + state.deleteContestMember.status = 'successful'; + }); + builder.addCase(deleteContestMember.rejected, (state, action: any) => { + state.deleteContestMember.status = 'failed'; + state.deleteContestMember.error = action.payload; + }); + + builder.addCase(startContestAttempt.pending, (state) => { + state.startAttempt.status = 'loading'; + }); + builder.addCase( + startContestAttempt.fulfilled, + (state, action: PayloadAction) => { + state.startAttempt.status = 'successful'; + state.startAttempt.attempt = action.payload; + }, + ); + builder.addCase(startContestAttempt.rejected, (state, action: any) => { + state.startAttempt.status = 'failed'; + state.startAttempt.error = action.payload; + }); + + builder.addCase(fetchMyAttemptsInContest.pending, (state) => { + state.fetchMyAttemptsInContest.status = 'loading'; + }); + builder.addCase( + fetchMyAttemptsInContest.fulfilled, + (state, action: PayloadAction) => { + state.fetchMyAttemptsInContest.status = 'successful'; + state.fetchMyAttemptsInContest.attempts = action.payload; + }, + ); + builder.addCase( + fetchMyAttemptsInContest.rejected, + (state, action: any) => { + state.fetchMyAttemptsInContest.status = 'failed'; + state.fetchMyAttemptsInContest.error = action.payload; + }, + ); + + builder.addCase(fetchMyAllAttempts.pending, (state) => { + state.fetchMyAllAttempts.status = 'loading'; + }); + builder.addCase( + fetchMyAllAttempts.fulfilled, + (state, action: PayloadAction) => { + state.fetchMyAllAttempts.status = 'successful'; + state.fetchMyAllAttempts.attempts = action.payload; + }, + ); + builder.addCase(fetchMyAllAttempts.rejected, (state, action: any) => { + state.fetchMyAllAttempts.status = 'failed'; + state.fetchMyAllAttempts.error = action.payload; + }); + + builder.addCase(fetchMyActiveAttempt.pending, (state) => { + state.fetchMyActiveAttempt.status = 'loading'; + }); + builder.addCase( + fetchMyActiveAttempt.fulfilled, + ( + state, + action: PayloadAction<{ + contestId: number; + attempt: Attempt | null; + }>, + ) => { + state.fetchMyActiveAttempt.status = 'successful'; + state.fetchMyActiveAttempt.attempt = action.payload.attempt; + }, + ); + builder.addCase(fetchMyActiveAttempt.rejected, (state, action: any) => { + state.fetchMyActiveAttempt.status = 'failed'; + state.fetchMyActiveAttempt.error = action.payload; + }); + + builder.addCase(checkContestRegistration.pending, (state) => { + state.checkRegistration.status = 'loading'; + }); + builder.addCase( + checkContestRegistration.fulfilled, + ( + state, + action: PayloadAction<{ + contestId: number; + registered: boolean; + }>, + ) => { + state.checkRegistration.status = 'successful'; + state.checkRegistration.registered = action.payload.registered; + }, + ); + builder.addCase( + checkContestRegistration.rejected, + (state, action: any) => { + state.checkRegistration.status = 'failed'; + state.checkRegistration.error = action.payload; + }, + ); + + builder.addCase(fetchUpcomingEligibleContests.pending, (state) => { + state.fetchUpcomingEligible.status = 'loading'; + }); + builder.addCase( + fetchUpcomingEligibleContests.fulfilled, + (state, action: PayloadAction) => { + state.fetchUpcomingEligible.status = 'successful'; + state.fetchUpcomingEligible.contests = action.payload; + }, + ); + builder.addCase( + fetchUpcomingEligibleContests.rejected, + (state, action: any) => { + state.fetchUpcomingEligible.status = 'failed'; + state.fetchUpcomingEligible.error = action.payload; + }, + ); }, }); diff --git a/src/views/home/auth/Login.tsx b/src/views/home/auth/Login.tsx index 90fabe1..4ca776b 100644 --- a/src/views/home/auth/Login.tsx +++ b/src/views/home/auth/Login.tsx @@ -120,15 +120,6 @@ const Login = () => { text={status === 'loading' ? 'Вход...' : 'Вход'} disabled={status === 'loading'} /> - {}}> -
- - Вход с Google -
-
diff --git a/src/views/home/auth/Register.tsx b/src/views/home/auth/Register.tsx index 58ef390..be971e7 100644 --- a/src/views/home/auth/Register.tsx +++ b/src/views/home/auth/Register.tsx @@ -237,15 +237,6 @@ const Register = () => { } disabled={status === 'loading'} /> - {}}> -
- - Регистрация с Google -
-
diff --git a/src/views/home/contests/ModalCreate.tsx b/src/views/home/contests/ModalCreate.tsx index 0c30465..bca1465 100644 --- a/src/views/home/contests/ModalCreate.tsx +++ b/src/views/home/contests/ModalCreate.tsx @@ -12,6 +12,16 @@ import { CreateContestBody } from '../../../redux/slices/contests'; import DateRangeInput from '../../../components/input/DateRangeInput'; import { useNavigate } from 'react-router-dom'; +function toUtc(localDateTime?: string): string { + if (!localDateTime) return ''; + + // Создаём дату (она автоматически считается как локальная) + const date = new Date(localDateTime); + + // Возвращаем ISO-строку с 'Z' (всегда в UTC) + return date.toISOString(); +} + interface ModalCreateContestProps { active: boolean; setActive: (value: boolean) => void; @@ -61,7 +71,13 @@ const ModalCreateContest: FC = ({ }; const handleSubmit = () => { - dispatch(createContest(form)); + dispatch( + createContest({ + ...form, + endsAt: toUtc(form.endsAt), + startsAt: toUtc(form.startsAt), + }), + ); }; return ( diff --git a/src/views/home/groups/Filter.tsx b/src/views/home/groups/Filter.tsx index d56a78b..d319f55 100644 --- a/src/views/home/groups/Filter.tsx +++ b/src/views/home/groups/Filter.tsx @@ -19,35 +19,7 @@ const Filters = () => { return (
- {}} placeholder="Поиск задачи" /> - - { - v; - }} - /> - - { - values; - }} - /> + {}} placeholder="Поиск группы" />
); };