('/articles/my'); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || - 'Ошибка при получении моих статей', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -146,9 +188,7 @@ export const fetchArticleById = createAsyncThunk( const response = await axios.get(`/articles/${articleId}`); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при получении статьи', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -172,9 +212,7 @@ export const createArticle = createAsyncThunk( }); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при создании статьи', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -202,9 +240,7 @@ export const updateArticle = createAsyncThunk( ); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при обновлении статьи', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -217,9 +253,7 @@ export const deleteArticle = createAsyncThunk( await axios.delete(`/articles/${articleId}`); return articleId; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при удалении статьи', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -258,7 +292,35 @@ const articlesSlice = createSlice({ ); builder.addCase(fetchArticles.rejected, (state, action: any) => { state.fetchArticles.status = 'failed'; - state.fetchArticles.error = action.payload; + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }); + + // fetchNewArticles + builder.addCase(fetchNewArticles.pending, (state) => { + state.fetchNewArticles.status = 'loading'; + state.fetchNewArticles.error = undefined; + }); + builder.addCase( + fetchNewArticles.fulfilled, + (state, action: PayloadAction) => { + state.fetchNewArticles.status = 'successful'; + state.fetchNewArticles.articles = action.payload.articles; + state.fetchNewArticles.hasNextPage = action.payload.hasNextPage; + }, + ); + builder.addCase(fetchNewArticles.rejected, (state, action: any) => { + state.fetchNewArticles.status = 'failed'; + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // fetchMyArticles @@ -275,7 +337,12 @@ const articlesSlice = createSlice({ ); builder.addCase(fetchMyArticles.rejected, (state, action: any) => { state.fetchMyArticles.status = 'failed'; - state.fetchMyArticles.error = action.payload; + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // fetchArticleById @@ -292,7 +359,12 @@ const articlesSlice = createSlice({ ); builder.addCase(fetchArticleById.rejected, (state, action: any) => { state.fetchArticleById.status = 'failed'; - state.fetchArticleById.error = action.payload; + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // createArticle @@ -309,7 +381,14 @@ const articlesSlice = createSlice({ ); builder.addCase(createArticle.rejected, (state, action: any) => { state.createArticle.status = 'failed'; - state.createArticle.error = action.payload; + state.createArticle.error = action.payload.title; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // updateArticle @@ -326,7 +405,14 @@ const articlesSlice = createSlice({ ); builder.addCase(updateArticle.rejected, (state, action: any) => { state.updateArticle.status = 'failed'; - state.updateArticle.error = action.payload; + state.createArticle.error = action.payload.title; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // deleteArticle @@ -350,7 +436,12 @@ const articlesSlice = createSlice({ ); builder.addCase(deleteArticle.rejected, (state, action: any) => { state.deleteArticle.status = 'failed'; - state.deleteArticle.error = action.payload; + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); }, }); diff --git a/src/redux/slices/auth.ts b/src/redux/slices/auth.ts index eeabf20..0aebac2 100644 --- a/src/redux/slices/auth.ts +++ b/src/redux/slices/auth.ts @@ -1,6 +1,8 @@ -import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from '../../axios'; +type Status = 'idle' | 'loading' | 'successful' | 'failed'; + // 🔹 Декодирование JWT function decodeJwt(token: string) { const [, payload] = token.split('.'); @@ -15,8 +17,12 @@ interface AuthState { username: string | null; email: string | null; id: string | null; - status: 'idle' | 'loading' | 'successful' | 'failed'; + status: Status; error: string | null; + register: { + errors?: Record; + status: Status; + }; } // 🔹 Инициализация состояния с синхронной загрузкой из localStorage @@ -31,6 +37,9 @@ const initialState: AuthState = { id: null, status: 'idle', error: null, + register: { + status: 'idle', + }, }; // Если токен есть, подставляем в axios и декодируем @@ -76,9 +85,7 @@ export const registerUser = createAsyncThunk( }); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Registration failed', - ); + return rejectWithValue(err.response?.data?.errors); } }, ); @@ -165,6 +172,15 @@ const authSlice = createSlice({ localStorage.removeItem('refreshToken'); delete axios.defaults.headers.common['Authorization']; }, + setAuthStatus: ( + state, + action: PayloadAction<{ key: keyof AuthState; status: Status }>, + ) => { + const { key, status } = action.payload; + if (state[key]) { + (state[key] as any).status = status; + } + }, }, extraReducers: (builder) => { // ----------------- Register ----------------- @@ -199,7 +215,7 @@ const authSlice = createSlice({ }); builder.addCase(registerUser.rejected, (state, action) => { state.status = 'failed'; - state.error = action.payload as string; + state.register.errors = action.payload as Record; }); // ----------------- Login ----------------- @@ -304,5 +320,5 @@ const authSlice = createSlice({ }, }); -export const { logout } = authSlice.actions; +export const { logout, setAuthStatus } = authSlice.actions; export const authReducer = authSlice.reducer; diff --git a/src/redux/slices/contests.ts b/src/redux/slices/contests.ts index 5ddff69..3bf6bcc 100644 --- a/src/redux/slices/contests.ts +++ b/src/redux/slices/contests.ts @@ -1,14 +1,11 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from '../../axios'; +import { toastError } from '../../lib/toastNotification'; // ===================== // Типы // ===================== -// ===================== -// Типы для посылок -// ===================== - export interface Solution { id: number; missionId: number; @@ -73,11 +70,26 @@ export interface Contest { members?: Member[]; } +export interface Attempt { + attemptId: number; + contestId: number; + startedAt: string; + expiresAt: string; + finished: boolean; + submissions?: Submission[]; + results?: any[]; +} + interface ContestsResponse { hasNextPage: boolean; contests: Contest[]; } +interface MembersPage { + members: Member[]; + hasNextPage: boolean; +} + export interface CreateContestBody { name: string; description?: string; @@ -142,94 +154,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; + }; + + fetchParticipating: { + contests: Contest[]; + hasNextPage: boolean; + status: Status; + error?: string; + }; } +const emptyContest: Contest = { + id: 0, + name: '', + description: '', + scheduleType: 'AlwaysOpen', + visibility: 'Public', + startsAt: '', + endsAt: '', + attemptDurationMinutes: 0, + maxAttempts: 0, + allowEarlyFinish: false, + missions: [], + articles: [], + members: [], +}; + const initialState: ContestsState = { - fetchContests: { + 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, - }, - 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, }, - 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, - }, - fetchRegisteredContests: { + 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' }, + fetchParticipating: { contests: [], hasNextPage: false, status: 'idle', @@ -241,7 +265,27 @@ const initialState: ContestsState = { // Async Thunks // ===================== -// Мои посылки в контесте +// Existing ---------------------------- + +export const fetchParticipatingContests = createAsyncThunk( + 'contests/fetchParticipating', + async ( + params: { page?: number; pageSize?: number } = {}, + { rejectWithValue }, + ) => { + try { + const { page = 0, pageSize = 100 } = params; + const response = await axios.get( + '/contests/participating', + { params: { page, pageSize } }, + ); + return response.data; + } catch (err: any) { + return rejectWithValue(err.response?.data); + } + }, +); + export const fetchMySubmissions = createAsyncThunk( 'contests/fetchMySubmissions', async (contestId: number, { rejectWithValue }) => { @@ -251,14 +295,11 @@ export const fetchMySubmissions = createAsyncThunk( ); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Failed to fetch my submissions', - ); + return rejectWithValue(err.response?.data); } }, ); -// Все контесты export const fetchContests = createAsyncThunk( 'contests/fetchAll', async ( @@ -270,20 +311,17 @@ export const fetchContests = createAsyncThunk( { rejectWithValue }, ) => { try { - const { page = 0, pageSize = 10, groupId } = params; + const { page = 0, pageSize = 100, groupId } = params; const response = await axios.get('/contests', { params: { page, pageSize, groupId }, }); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Failed to fetch contests', - ); + return rejectWithValue(err.response?.data); } }, ); -// Контест по ID export const fetchContestById = createAsyncThunk( 'contests/fetchById', async (id: number, { rejectWithValue }) => { @@ -291,14 +329,11 @@ export const fetchContestById = createAsyncThunk( const response = await axios.get(`/contests/${id}`); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Failed to fetch contest', - ); + return rejectWithValue(err.response?.data); } }, ); -// Создание контеста export const createContest = createAsyncThunk( 'contests/create', async (contestData: CreateContestBody, { rejectWithValue }) => { @@ -309,14 +344,11 @@ export const createContest = createAsyncThunk( ); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Failed to create contest', - ); + return rejectWithValue(err.response?.data); } }, ); -// 🆕 Обновление контеста export const updateContest = createAsyncThunk( 'contests/update', async ( @@ -333,14 +365,11 @@ export const updateContest = createAsyncThunk( ); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Failed to update contest', - ); + return rejectWithValue(err.response?.data); } }, ); -// 🆕 Удаление контеста export const deleteContest = createAsyncThunk( 'contests/delete', async (contestId: number, { rejectWithValue }) => { @@ -348,14 +377,11 @@ export const deleteContest = createAsyncThunk( await axios.delete(`/contests/${contestId}`); return contestId; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Failed to delete contest', - ); + return rejectWithValue(err.response?.data); } }, ); -// Контесты, созданные мной export const fetchMyContests = createAsyncThunk( 'contests/fetchMyContests', async (_, { rejectWithValue }) => { @@ -363,14 +389,11 @@ export const fetchMyContests = createAsyncThunk( const response = await axios.get('/contests/my'); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Failed to fetch my contests', - ); + return rejectWithValue(err.response?.data); } }, ); -// Контесты, где я зарегистрирован export const fetchRegisteredContests = createAsyncThunk( 'contests/fetchRegisteredContests', async ( @@ -378,17 +401,167 @@ export const fetchRegisteredContests = createAsyncThunk( { rejectWithValue }, ) => { try { - const { page = 0, pageSize = 10 } = params; + const { page = 0, pageSize = 100 } = params; const response = await axios.get( '/contests/registered', { params: { page, pageSize } }, ); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || - 'Failed to fetch registered contests', + return rejectWithValue(err.response?.data); + } + }, +); + +// 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); + } + }, +); + +// 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); + } + }, +); + +// 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); + } + }, +); + +// 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); + } + }, +); + +// Members with pagination +export const fetchContestMembers = createAsyncThunk( + 'contests/fetchContestMembers', + async ( + { + contestId, + page = 0, + pageSize = 100, + }: { 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); + } + }, +); + +// 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); + } + }, +); + +// 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); + } + }, +); + +// 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); + } + }, +); + +// 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); } }, ); @@ -401,7 +574,6 @@ const contestsSlice = createSlice({ name: 'contests', initialState, reducers: { - // 🆕 Сброс статусов setContestStatus: ( state, action: PayloadAction<{ key: keyof ContestsState; status: Status }>, @@ -413,7 +585,8 @@ const contestsSlice = createSlice({ }, }, extraReducers: (builder) => { - // 🆕 fetchMySubmissions + // ——— YOUR EXISTING HANDLERS (unchanged) ——— + builder.addCase(fetchMySubmissions.pending, (state) => { state.fetchMySubmissions.status = 'loading'; state.fetchMySubmissions.error = undefined; @@ -427,13 +600,17 @@ const contestsSlice = createSlice({ ); builder.addCase(fetchMySubmissions.rejected, (state, action: any) => { state.fetchMySubmissions.status = 'failed'; - state.fetchMySubmissions.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); - // fetchContests builder.addCase(fetchContests.pending, (state) => { state.fetchContests.status = 'loading'; - state.fetchContests.error = undefined; }); builder.addCase( fetchContests.fulfilled, @@ -445,13 +622,17 @@ const contestsSlice = createSlice({ ); builder.addCase(fetchContests.rejected, (state, action: any) => { state.fetchContests.status = 'failed'; - state.fetchContests.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); - // fetchContestById builder.addCase(fetchContestById.pending, (state) => { state.fetchContestById.status = 'loading'; - state.fetchContestById.error = undefined; }); builder.addCase( fetchContestById.fulfilled, @@ -462,13 +643,17 @@ const contestsSlice = createSlice({ ); builder.addCase(fetchContestById.rejected, (state, action: any) => { state.fetchContestById.status = 'failed'; - state.fetchContestById.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); - // createContest builder.addCase(createContest.pending, (state) => { state.createContest.status = 'loading'; - state.createContest.error = undefined; }); builder.addCase( createContest.fulfilled, @@ -479,13 +664,17 @@ const contestsSlice = createSlice({ ); builder.addCase(createContest.rejected, (state, action: any) => { state.createContest.status = 'failed'; - state.createContest.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); - // 🆕 updateContest builder.addCase(updateContest.pending, (state) => { state.updateContest.status = 'loading'; - state.updateContest.error = undefined; }); builder.addCase( updateContest.fulfilled, @@ -496,19 +685,22 @@ const contestsSlice = createSlice({ ); builder.addCase(updateContest.rejected, (state, action: any) => { state.updateContest.status = 'failed'; - state.updateContest.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); - // 🆕 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, @@ -521,13 +713,17 @@ const contestsSlice = createSlice({ ); builder.addCase(deleteContest.rejected, (state, action: any) => { state.deleteContest.status = 'failed'; - state.deleteContest.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); - // fetchMyContests builder.addCase(fetchMyContests.pending, (state) => { state.fetchMyContests.status = 'loading'; - state.fetchMyContests.error = undefined; }); builder.addCase( fetchMyContests.fulfilled, @@ -538,13 +734,17 @@ const contestsSlice = createSlice({ ); builder.addCase(fetchMyContests.rejected, (state, action: any) => { state.fetchMyContests.status = 'failed'; - state.fetchMyContests.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); - // fetchRegisteredContests builder.addCase(fetchRegisteredContests.pending, (state) => { state.fetchRegisteredContests.status = 'loading'; - state.fetchRegisteredContests.error = undefined; }); builder.addCase( fetchRegisteredContests.fulfilled, @@ -560,7 +760,269 @@ const contestsSlice = createSlice({ fetchRegisteredContests.rejected, (state, action: any) => { state.fetchRegisteredContests.status = 'failed'; - state.fetchRegisteredContests.error = action.payload; + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }, + ); + + // 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'; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }); + + 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'; + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }, + ); + + 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'; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }); + + 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'; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }); + + 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'; + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }, + ); + + 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'; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }); + + 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'; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }); + + 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'; + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }, + ); + + 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'; + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }, + ); + + builder.addCase(fetchParticipatingContests.pending, (state) => { + state.fetchParticipating.status = 'loading'; + }); + + builder.addCase( + fetchParticipatingContests.fulfilled, + (state, action: PayloadAction) => { + state.fetchParticipating.status = 'successful'; + state.fetchParticipating.contests = action.payload.contests; + state.fetchParticipating.hasNextPage = + action.payload.hasNextPage; + }, + ); + + builder.addCase( + fetchParticipatingContests.rejected, + (state, action: any) => { + state.fetchParticipating.status = 'failed'; + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }, ); }, diff --git a/src/redux/slices/groupChat.ts b/src/redux/slices/groupChat.ts new file mode 100644 index 0000000..a37d91d --- /dev/null +++ b/src/redux/slices/groupChat.ts @@ -0,0 +1,203 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from '../../axios'; +import { toastError } from '../../lib/toastNotification'; + +// ========================================= +// Типы +// ========================================= + +export type Status = 'idle' | 'loading' | 'successful' | 'failed'; + +export interface ChatMessage { + id: number; + groupId: number; + authorId: number; + authorUsername: string; + content: string; + createdAt: string; +} + +interface FetchMessagesParams { + groupId: number; + limit?: number; + afterMessageId?: number; + timeoutSeconds?: number; +} + +interface SendMessageParams { + groupId: number; + content: string; +} + +// ========================================= +// State +// ========================================= + +interface GroupChatState { + messages: Record; // по группам + lastMessage: Record; + + fetchMessages: { + status: Status; + error?: string; + }; + sendMessage: { + status: Status; + error?: string; + }; +} + +const initialState: GroupChatState = { + messages: {}, + lastMessage: {}, + fetchMessages: { + status: 'idle', + error: undefined, + }, + sendMessage: { + status: 'idle', + error: undefined, + }, +}; + +// ========================================= +// Thunks +// ========================================= + +// Получение сообщений +export const fetchGroupMessages = createAsyncThunk( + 'groupChat/fetchGroupMessages', + async (params: FetchMessagesParams, { rejectWithValue }) => { + try { + const response = await axios.get(`/groups/${params.groupId}/chat`, { + params: { + limit: params.limit, + afterMessageId: params.afterMessageId, + timeoutSeconds: params.timeoutSeconds, + }, + }); + + return { + groupId: params.groupId, + messages: response.data as ChatMessage[], + }; + } catch (err: any) { + return rejectWithValue(err.response?.data); + } + }, +); + +// Отправка +export const sendGroupMessage = createAsyncThunk( + 'groupChat/sendGroupMessage', + async ({ groupId, content }: SendMessageParams, { rejectWithValue }) => { + try { + const response = await axios.post(`/groups/${groupId}/chat`, { + content, + }); + return response.data as ChatMessage; + } catch (err: any) { + return rejectWithValue(err.response?.data); + } + }, +); + +// ========================================= +// Slice +// ========================================= + +const groupChatSlice = createSlice({ + name: 'groupChat', + initialState, + reducers: { + clearChat(state, action: PayloadAction) { + delete state.messages[action.payload]; + }, + setGroupChatStatus: ( + state, + action: PayloadAction<{ + key: keyof GroupChatState; + status: Status; + }>, + ) => { + const { key, status } = action.payload; + if (state[key]) { + (state[key] as any).status = status; + } + }, + }, + extraReducers: (builder) => { + // fetch messages + builder.addCase(fetchGroupMessages.pending, (state) => { + state.fetchMessages.status = 'loading'; + }); + + builder.addCase( + fetchGroupMessages.fulfilled, + ( + state, + action: PayloadAction<{ + groupId: number; + messages: ChatMessage[]; + }>, + ) => { + const { groupId, messages } = action.payload; + const existing = state.messages[groupId] || []; + + const ids = new Set(existing.map((m) => m.id)); + const filtered = messages.filter((m) => !ids.has(m.id)); + + state.messages[groupId] = [...existing, ...filtered].sort( + (a, b) => a.id - b.id, + ); + if (state.messages[groupId].length) { + state.lastMessage[groupId] = + state.messages[groupId][ + state.messages[groupId].length - 1 + ].id; + } + + state.fetchMessages.status = 'successful'; + }, + ); + + builder.addCase(fetchGroupMessages.rejected, (state, action: any) => { + state.fetchMessages.status = 'failed'; + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }); + + // send message + builder.addCase(sendGroupMessage.pending, (state) => { + state.sendMessage.status = 'loading'; + }); + + builder.addCase( + sendGroupMessage.fulfilled, + (state, action: PayloadAction) => { + const msg = action.payload; + if (!state.messages[msg.groupId]) + state.messages[msg.groupId] = []; + state.messages[msg.groupId].push(msg); + state.sendMessage.status = 'successful'; + }, + ); + + builder.addCase(sendGroupMessage.rejected, (state, action: any) => { + state.sendMessage.status = 'failed'; + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }); + }, +}); + +export const { clearChat, setGroupChatStatus } = groupChatSlice.actions; +export const groupChatReducer = groupChatSlice.reducer; diff --git a/src/redux/slices/groupfeed.ts b/src/redux/slices/groupfeed.ts index 05cfdbb..9c65cf0 100644 --- a/src/redux/slices/groupfeed.ts +++ b/src/redux/slices/groupfeed.ts @@ -1,5 +1,6 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from '../../axios'; +import { toastError } from '../../lib/toastNotification'; // ===================== // Типы @@ -94,7 +95,7 @@ export const fetchGroupPosts = createAsyncThunk( { groupId, page = 0, - pageSize = 20, + pageSize = 100, }: { groupId: number; page?: number; pageSize?: number }, { rejectWithValue }, ) => { @@ -104,9 +105,7 @@ export const fetchGroupPosts = createAsyncThunk( ); return { page, data: response.data as PostsPage }; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка загрузки постов', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -124,9 +123,7 @@ export const fetchPostById = createAsyncThunk( ); return response.data as Post; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка загрузки поста', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -149,9 +146,7 @@ export const createPost = createAsyncThunk( }); return response.data as Post; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка создания поста', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -183,9 +178,7 @@ export const updatePost = createAsyncThunk( ); return response.data as Post; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка обновления поста', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -201,9 +194,7 @@ export const deletePost = createAsyncThunk( await axios.delete(`/groups/${groupId}/feed/${postId}`); return postId; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка удаления поста', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -244,7 +235,13 @@ const postsSlice = createSlice({ ); builder.addCase(fetchGroupPosts.rejected, (state, action: any) => { state.fetchPosts.status = 'failed'; - state.fetchPosts.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // fetchPostById @@ -260,7 +257,13 @@ const postsSlice = createSlice({ ); builder.addCase(fetchPostById.rejected, (state, action: any) => { state.fetchPostById.status = 'failed'; - state.fetchPostById.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // createPost @@ -281,7 +284,13 @@ const postsSlice = createSlice({ ); builder.addCase(createPost.rejected, (state, action: any) => { state.createPost.status = 'failed'; - state.createPost.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // updatePost @@ -310,7 +319,13 @@ const postsSlice = createSlice({ ); builder.addCase(updatePost.rejected, (state, action: any) => { state.updatePost.status = 'failed'; - state.updatePost.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // deletePost @@ -338,7 +353,13 @@ const postsSlice = createSlice({ ); builder.addCase(deletePost.rejected, (state, action: any) => { state.deletePost.status = 'failed'; - state.deletePost.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); }, }); diff --git a/src/redux/slices/groups.ts b/src/redux/slices/groups.ts index 38350bb..58898a2 100644 --- a/src/redux/slices/groups.ts +++ b/src/redux/slices/groups.ts @@ -1,5 +1,6 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from '../../axios'; +import { toastError } from '../../lib/toastNotification'; // ===================== // Типы @@ -131,9 +132,7 @@ export const createGroup = createAsyncThunk( const response = await axios.post('/groups', { name, description }); return response.data as Group; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при создании группы', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -155,9 +154,7 @@ export const updateGroup = createAsyncThunk( }); return response.data as Group; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при обновлении группы', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -169,9 +166,7 @@ export const deleteGroup = createAsyncThunk( await axios.delete(`/groups/${groupId}`); return groupId; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при удалении группы', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -183,9 +178,7 @@ export const fetchMyGroups = createAsyncThunk( const response = await axios.get('/groups/my'); return response.data.groups as Group[]; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при получении групп', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -197,9 +190,7 @@ export const fetchGroupById = createAsyncThunk( const response = await axios.get(`/groups/${groupId}`); return response.data as Group; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при получении группы', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -221,10 +212,7 @@ export const addGroupMember = createAsyncThunk( }); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || - 'Ошибка при добавлении участника', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -239,9 +227,7 @@ export const removeGroupMember = createAsyncThunk( await axios.delete(`/groups/${groupId}/members/${memberId}`); return { groupId, memberId }; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при удалении участника', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -258,10 +244,7 @@ export const fetchGroupJoinLink = createAsyncThunk( const response = await axios.get(`/groups/${groupId}/join-link`); return response.data as { token: string; expiresAt: string }; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || - 'Ошибка при получении ссылки для присоединения', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -274,10 +257,7 @@ export const joinGroupByToken = createAsyncThunk( const response = await axios.post(`/groups/join/${token}`); return response.data as Group; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || - 'Ошибка при присоединении к группе по ссылке', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -314,7 +294,13 @@ const groupsSlice = createSlice({ ); builder.addCase(fetchMyGroups.rejected, (state, action: any) => { state.fetchMyGroups.status = 'failed'; - state.fetchMyGroups.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // fetchGroupById @@ -330,7 +316,13 @@ const groupsSlice = createSlice({ ); builder.addCase(fetchGroupById.rejected, (state, action: any) => { state.fetchGroupById.status = 'failed'; - state.fetchGroupById.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // createGroup @@ -347,7 +339,13 @@ const groupsSlice = createSlice({ ); builder.addCase(createGroup.rejected, (state, action: any) => { state.createGroup.status = 'failed'; - state.createGroup.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // updateGroup @@ -370,7 +368,13 @@ const groupsSlice = createSlice({ ); builder.addCase(updateGroup.rejected, (state, action: any) => { state.updateGroup.status = 'failed'; - state.updateGroup.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // deleteGroup @@ -391,7 +395,13 @@ const groupsSlice = createSlice({ ); builder.addCase(deleteGroup.rejected, (state, action: any) => { state.deleteGroup.status = 'failed'; - state.deleteGroup.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // addGroupMember @@ -403,7 +413,13 @@ const groupsSlice = createSlice({ }); builder.addCase(addGroupMember.rejected, (state, action: any) => { state.addGroupMember.status = 'failed'; - state.addGroupMember.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // removeGroupMember @@ -430,7 +446,13 @@ const groupsSlice = createSlice({ ); builder.addCase(removeGroupMember.rejected, (state, action: any) => { state.removeGroupMember.status = 'failed'; - state.removeGroupMember.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // fetchGroupJoinLink @@ -449,7 +471,13 @@ const groupsSlice = createSlice({ ); builder.addCase(fetchGroupJoinLink.rejected, (state, action: any) => { state.fetchGroupJoinLink.status = 'failed'; - state.fetchGroupJoinLink.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // joinGroupByToken @@ -466,7 +494,13 @@ const groupsSlice = createSlice({ ); builder.addCase(joinGroupByToken.rejected, (state, action: any) => { state.joinGroupByToken.status = 'failed'; - state.joinGroupByToken.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); }, }); diff --git a/src/redux/slices/missions.ts b/src/redux/slices/missions.ts index f3c84ee..d314f7b 100644 --- a/src/redux/slices/missions.ts +++ b/src/redux/slices/missions.ts @@ -1,5 +1,6 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from '../../axios'; +import { toastError } from '../../lib/toastNotification'; // ─── Типы ──────────────────────────────────────────── @@ -27,13 +28,18 @@ export interface Mission { interface MissionsState { missions: Mission[]; + newMissions: Mission[]; currentMission: Mission | null; hasNextPage: boolean; + create: { + errors?: Record; + }; statuses: { fetchList: Status; fetchById: Status; upload: Status; fetchMy: Status; + delete: Status; }; error: string | null; } @@ -42,13 +48,16 @@ interface MissionsState { const initialState: MissionsState = { missions: [], + newMissions: [], currentMission: null, hasNextPage: false, + create: {}, statuses: { fetchList: 'idle', fetchById: 'idle', upload: 'idle', fetchMy: 'idle', + delete: 'idle', }, error: null, }; @@ -58,6 +67,33 @@ const initialState: MissionsState = { // GET /missions export const fetchMissions = createAsyncThunk( 'missions/fetchMissions', + async ( + { + page = 0, + pageSize = 100, + tags = [], + }: { page?: number; pageSize?: number; tags?: string[] }, + { rejectWithValue }, + ) => { + try { + const params: any = { page, pageSize }; + if (tags.length) params.tags = tags; + const response = await axios.get('/missions', { + params, + paramsSerializer: { + indexes: null, + }, + }); + return response.data; // { missions, hasNextPage } + } catch (err: any) { + return rejectWithValue(err.response?.data); + } + }, +); + +// GET /missions +export const fetchNewMissions = createAsyncThunk( + 'missions/fetchNewMissions', async ( { page = 0, @@ -69,12 +105,15 @@ export const fetchMissions = createAsyncThunk( try { const params: any = { page, pageSize }; if (tags.length) params.tags = tags; - const response = await axios.get('/missions', { params }); + const response = await axios.get('/missions', { + params, + paramsSerializer: { + indexes: null, + }, + }); return response.data; // { missions, hasNextPage } } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при получении миссий', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -87,9 +126,7 @@ export const fetchMissionById = createAsyncThunk( const response = await axios.get(`/missions/${id}`); return response.data; // Mission } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при получении миссии', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -102,10 +139,7 @@ export const fetchMyMissions = createAsyncThunk( const response = await axios.get('/missions/my'); return response.data as Mission[]; // массив миссий пользователя } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || - 'Ошибка при получении моих миссий', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -134,9 +168,20 @@ export const uploadMission = createAsyncThunk( }); return response.data; // Mission } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при загрузке миссии', - ); + return rejectWithValue(err.response?.data); + } + }, +); + +// DELETE /missions/{id} +export const deleteMission = createAsyncThunk( + 'missions/deleteMission', + async (id: number, { rejectWithValue }) => { + try { + await axios.delete(`/missions/${id}`); + return id; // возвращаем id удалённой миссии + } catch (err: any) { + return rejectWithValue(err.response?.data); } }, ); @@ -182,7 +227,52 @@ const missionsSlice = createSlice({ fetchMissions.rejected, (state, action: PayloadAction) => { state.statuses.fetchList = 'failed'; - state.error = action.payload; + + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }, + ); + + // ─── FETCH NEW MISSIONS ─── + builder.addCase(fetchNewMissions.pending, (state) => { + state.statuses.fetchList = 'loading'; + state.error = null; + }); + builder.addCase( + fetchNewMissions.fulfilled, + ( + state, + action: PayloadAction<{ + missions: Mission[]; + hasNextPage: boolean; + }>, + ) => { + state.statuses.fetchList = 'successful'; + state.newMissions = action.payload.missions; + state.hasNextPage = action.payload.hasNextPage; + }, + ); + builder.addCase( + fetchNewMissions.rejected, + (state, action: PayloadAction) => { + state.statuses.fetchList = 'failed'; + + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }, ); @@ -202,7 +292,16 @@ const missionsSlice = createSlice({ fetchMissionById.rejected, (state, action: PayloadAction) => { state.statuses.fetchById = 'failed'; - state.error = action.payload; + + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }, ); @@ -222,7 +321,16 @@ const missionsSlice = createSlice({ fetchMyMissions.rejected, (state, action: PayloadAction) => { state.statuses.fetchMy = 'failed'; - state.error = action.payload; + + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }, ); @@ -242,7 +350,53 @@ const missionsSlice = createSlice({ uploadMission.rejected, (state, action: PayloadAction) => { state.statuses.upload = 'failed'; - state.error = action.payload; + + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + + state.create.errors = errors; + }, + ); + + // ─── DELETE MISSION ─── + builder.addCase(deleteMission.pending, (state) => { + state.statuses.delete = 'loading'; + state.error = null; + }); + builder.addCase( + deleteMission.fulfilled, + (state, action: PayloadAction) => { + state.statuses.delete = 'successful'; + state.missions = state.missions.filter( + (m) => m.id !== action.payload, + ); + + if (state.currentMission?.id === action.payload) { + state.currentMission = null; + } + }, + ); + builder.addCase( + deleteMission.rejected, + (state, action: PayloadAction) => { + state.statuses.delete = 'failed'; + + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }, ); }, diff --git a/src/redux/slices/profile.ts b/src/redux/slices/profile.ts new file mode 100644 index 0000000..399b900 --- /dev/null +++ b/src/redux/slices/profile.ts @@ -0,0 +1,395 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from '../../axios'; + +// ===================== +// Типы +// ===================== + +type Status = 'idle' | 'loading' | 'successful' | 'failed'; + +// Основной профиль +export interface ProfileIdentity { + userId: number; + username: string; + email: string; + createdAt: string; +} + +export interface ProfileSolutions { + totalSolved: number; + solvedLast7Days: number; +} + +export interface ProfileContestsInfo { + totalParticipations: number; + participationsLast7Days: number; +} + +export interface ProfileCreationStats { + missions: { total: number; last7Days: number }; + contests: { total: number; last7Days: number }; + articles: { total: number; last7Days: number }; +} + +export interface ProfileResponse { + identity: ProfileIdentity; + solutions: ProfileSolutions; + contests: ProfileContestsInfo; + creation: ProfileCreationStats; +} + +// Missions +export interface MissionsBucket { + key: string; + label: string; + solved: number; + total: number; +} + +export interface MissionItem { + missionId: number; + missionName: string; + difficultyLabel: string; + difficultyValue: number; + createdAt: string; + timeLimitMilliseconds: number; + memoryLimitBytes: number; +} + +export interface MissionsResponse { + summary: { + total: MissionsBucket; + buckets: MissionsBucket[]; + }; + recent: { + items: MissionItem[]; + page: number; + pageSize: number; + hasNextPage: boolean; + }; + authored: { + items: MissionItem[]; + page: number; + pageSize: number; + hasNextPage: boolean; + }; +} + +// Articles +export interface ProfileArticleItem { + articleId: number; + title: string; + createdAt: string; + updatedAt: string; +} +export interface ProfileArticlesResponse { + articles: { + items: ProfileArticleItem[]; + page: number; + pageSize: number; + hasNextPage: boolean; + }; +} + +// Contests +export interface ContestItem { + contestId: number; + name: string; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; + visibility: string; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + role: 'None' | 'Participant' | 'Organizer'; +} + +export interface ContestsList { + items: ContestItem[]; + page: number; + pageSize: number; + hasNextPage: boolean; +} + +export interface ProfileContestsResponse { + upcoming: ContestsList; + past: ContestsList; + mine: ContestsList; +} + +// ===================== +// Состояние +// ===================== + +interface ProfileState { + profile: { + data?: ProfileResponse; + status: Status; + error?: string; + }; + + missions: { + data?: MissionsResponse; + status: Status; + error?: string; + }; + + articles: { + data?: ProfileArticlesResponse; + status: Status; + error?: string; + }; + + contests: { + data?: ProfileContestsResponse; + status: Status; + error?: string; + }; +} + +const initialState: ProfileState = { + profile: { + data: undefined, + status: 'idle', + error: undefined, + }, + missions: { + data: undefined, + status: 'idle', + error: undefined, + }, + articles: { + data: undefined, + status: 'idle', + error: undefined, + }, + contests: { + data: undefined, + status: 'idle', + error: undefined, + }, +}; + +// ===================== +// Async Thunks +// ===================== + +// Основной профиль +export const fetchProfile = createAsyncThunk( + 'profile/fetch', + async (username: string, { rejectWithValue }) => { + try { + const res = await axios.get( + `/profile/${username}`, + ); + return res.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка загрузки профиля', + ); + } + }, +); + +// Missions +export const fetchProfileMissions = createAsyncThunk( + 'profile/fetchMissions', + async ( + { + username, + recentPage = 0, + recentPageSize = 100, + authoredPage = 0, + authoredPageSize = 100, + }: { + username: string; + recentPage?: number; + recentPageSize?: number; + authoredPage?: number; + authoredPageSize?: number; + }, + { rejectWithValue }, + ) => { + try { + const res = await axios.get( + `/profile/${username}/missions`, + { + params: { + recentPage, + recentPageSize, + authoredPage, + authoredPageSize, + }, + }, + ); + return res.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка загрузки задач', + ); + } + }, +); + +// Articles +export const fetchProfileArticles = createAsyncThunk( + 'profile/fetchArticles', + async ( + { + username, + page = 0, + pageSize = 100, + }: { username: string; page?: number; pageSize?: number }, + { rejectWithValue }, + ) => { + try { + const res = await axios.get( + `/profile/${username}/articles`, + { params: { page, pageSize } }, + ); + return res.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка загрузки статей', + ); + } + }, +); + +// Contests +export const fetchProfileContests = createAsyncThunk( + 'profile/fetchContests', + async ( + { + username, + upcomingPage = 0, + upcomingPageSize = 100, + pastPage = 0, + pastPageSize = 100, + minePage = 0, + minePageSize = 100, + }: { + username: string; + upcomingPage?: number; + upcomingPageSize?: number; + pastPage?: number; + pastPageSize?: number; + minePage?: number; + minePageSize?: number; + }, + { rejectWithValue }, + ) => { + try { + const res = await axios.get( + `/profile/${username}/contests`, + { + params: { + upcomingPage, + upcomingPageSize, + pastPage, + pastPageSize, + minePage, + minePageSize, + }, + }, + ); + return res.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка загрузки контестов', + ); + } + }, +); + +// ===================== +// Slice +// ===================== + +const profileSlice = createSlice({ + name: 'profile', + initialState, + reducers: { + setProfileStatus: ( + state, + action: PayloadAction<{ + key: keyof ProfileState; + status: Status; + }>, + ) => { + state[action.payload.key].status = action.payload.status; + }, + }, + + extraReducers: (builder) => { + // PROFILE + builder.addCase(fetchProfile.pending, (state) => { + state.profile.status = 'loading'; + state.profile.error = undefined; + }); + builder.addCase( + fetchProfile.fulfilled, + (state, action: PayloadAction) => { + state.profile.status = 'successful'; + state.profile.data = action.payload; + }, + ); + builder.addCase(fetchProfile.rejected, (state, action: any) => { + state.profile.status = 'failed'; + state.profile.error = action.payload; + }); + + // MISSIONS + builder.addCase(fetchProfileMissions.pending, (state) => { + state.missions.status = 'loading'; + state.missions.error = undefined; + }); + builder.addCase( + fetchProfileMissions.fulfilled, + (state, action: PayloadAction) => { + state.missions.status = 'successful'; + state.missions.data = action.payload; + }, + ); + builder.addCase(fetchProfileMissions.rejected, (state, action: any) => { + state.missions.status = 'failed'; + state.missions.error = action.payload; + }); + + // ARTICLES + builder.addCase(fetchProfileArticles.pending, (state) => { + state.articles.status = 'loading'; + state.articles.error = undefined; + }); + builder.addCase( + fetchProfileArticles.fulfilled, + (state, action: PayloadAction) => { + state.articles.status = 'successful'; + state.articles.data = action.payload; + }, + ); + builder.addCase(fetchProfileArticles.rejected, (state, action: any) => { + state.articles.status = 'failed'; + state.articles.error = action.payload; + }); + + // CONTESTS + builder.addCase(fetchProfileContests.pending, (state) => { + state.contests.status = 'loading'; + state.contests.error = undefined; + }); + builder.addCase( + fetchProfileContests.fulfilled, + (state, action: PayloadAction) => { + state.contests.status = 'successful'; + state.contests.data = action.payload; + }, + ); + builder.addCase(fetchProfileContests.rejected, (state, action: any) => { + state.contests.status = 'failed'; + state.contests.error = action.payload; + }); + }, +}); + +export const { setProfileStatus } = profileSlice.actions; +export const profileReducer = profileSlice.reducer; diff --git a/src/redux/slices/store.ts b/src/redux/slices/store.ts index cc713a0..c6ff185 100644 --- a/src/redux/slices/store.ts +++ b/src/redux/slices/store.ts @@ -7,6 +7,21 @@ interface StorState { activeProfilePage: string; activeGroupPage: string; }; + group: { + groupFilter: string; + }; + articles: { + articleTagFilter: string[]; + filterName: string; + }; + contests: { + contestsTagFilter: string[]; + filterName: string; + }; + missions: { + missionsTagFilter: string[]; + filterName: string; + }; } // Инициализация состояния @@ -16,6 +31,21 @@ const initialState: StorState = { activeProfilePage: '', activeGroupPage: '', }, + group: { + groupFilter: '', + }, + articles: { + articleTagFilter: [], + filterName: '', + }, + contests: { + contestsTagFilter: [], + filterName: '', + }, + missions: { + missionsTagFilter: [], + filterName: '', + }, }; // Slice @@ -23,28 +53,63 @@ const storeSlice = createSlice({ name: 'store', initialState, reducers: { - setMenuActivePage: (state, activePage: PayloadAction) => { - state.menu.activePage = activePage.payload; + setMenuActivePage: (state, action: PayloadAction) => { + state.menu.activePage = action.payload; }, - setMenuActiveProfilePage: ( - state, - activeProfilePage: PayloadAction, - ) => { - state.menu.activeProfilePage = activeProfilePage.payload; + setMenuActiveProfilePage: (state, action: PayloadAction) => { + state.menu.activeProfilePage = action.payload; }, - setMenuActiveGroupPage: ( - state, - activeGroupPage: PayloadAction, - ) => { - state.menu.activeGroupPage = activeGroupPage.payload; + setMenuActiveGroupPage: (state, action: PayloadAction) => { + state.menu.activeGroupPage = action.payload; + }, + setGroupFilter: (state, action: PayloadAction) => { + state.group.groupFilter = action.payload; + }, + + // ---------- ARTICLES ---------- + setArticlesTagFilter: (state, action: PayloadAction) => { + state.articles.articleTagFilter = action.payload; + }, + setArticlesNameFilter: (state, action: PayloadAction) => { + state.articles.filterName = action.payload; + }, + + // ---------- CONTESTS ---------- + setContestsTagFilter: (state, action: PayloadAction) => { + state.contests.contestsTagFilter = action.payload; + }, + setContestsNameFilter: (state, action: PayloadAction) => { + state.contests.filterName = action.payload; + }, + + // ---------- MISSIONS ---------- + setMissionsTagFilter: (state, action: PayloadAction) => { + state.missions.missionsTagFilter = action.payload; + }, + setMissionsNameFilter: (state, action: PayloadAction) => { + state.missions.filterName = action.payload; }, }, }); export const { + // menu setMenuActivePage, setMenuActiveProfilePage, setMenuActiveGroupPage, + setGroupFilter, + + // articles + setArticlesTagFilter, + setArticlesNameFilter, + + // contests + setContestsTagFilter, + setContestsNameFilter, + + // missions + setMissionsTagFilter, + setMissionsNameFilter, } = storeSlice.actions; export const storeReducer = storeSlice.reducer; diff --git a/src/redux/slices/submit.ts b/src/redux/slices/submit.ts index 21522b1..6cf53d7 100644 --- a/src/redux/slices/submit.ts +++ b/src/redux/slices/submit.ts @@ -8,7 +8,7 @@ export interface Submit { language: string; languageVersion: string; sourceCode: string; - contestId?: number; + contestAttemptId?: number; } export interface Solution { diff --git a/src/redux/store.ts b/src/redux/store.ts index 84a6fb3..79f8154 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -7,6 +7,8 @@ import { contestsReducer } from './slices/contests'; import { groupsReducer } from './slices/groups'; import { articlesReducer } from './slices/articles'; import { groupFeedReducer } from './slices/groupfeed'; +import { groupChatReducer } from './slices/groupChat'; +import { profileReducer } from './slices/profile'; // использование // import { useAppDispatch, useAppSelector } from '../redux/hooks'; @@ -27,6 +29,8 @@ export const store = configureStore({ groups: groupsReducer, articles: articlesReducer, groupfeed: groupFeedReducer, + groupchat: groupChatReducer, + profile: profileReducer, }, }); diff --git a/src/views/articleeditor/Editor.tsx b/src/views/articleeditor/Editor.tsx index 8fd18ad..f109cef 100644 --- a/src/views/articleeditor/Editor.tsx +++ b/src/views/articleeditor/Editor.tsx @@ -68,14 +68,14 @@ function greet(user: User) { return \`Привет, \${user.name}! 👋 Роль: \${user.role}\`; } -console.log(greet({ name: "Ты", role: "Разработчик" })); +consol.log(greet({ name: "Ты", role: "Разработчик" })); \`\`\` Пример **JavaScript**: \`\`\`js const sum = (a, b) => a + b; -console.log(sum(2, 3)); // 5 +consol.log(sum(2, 3)); // 5 \`\`\` Пример **Python**: @@ -256,9 +256,7 @@ const MarkdownEditor: FC = ({ markdown.slice(cursorPos); setMarkdown(newText); - } catch (err) { - console.error('Ошибка загрузки изображения:', err); - } + } catch (err) {} } } }; diff --git a/src/views/home/account/Account.tsx b/src/views/home/account/Account.tsx index 527dc5c..d1c2d0a 100644 --- a/src/views/home/account/Account.tsx +++ b/src/views/home/account/Account.tsx @@ -4,19 +4,51 @@ import RightPanel from './RightPanel'; import Missions from './missions/Missions'; import Contests from './contests/Contests'; import ArticlesBlock from './articles/ArticlesBlock'; -import { useAppDispatch } from '../../../redux/hooks'; +import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; import { useEffect } from 'react'; import { setMenuActivePage } from '../../../redux/slices/store'; +import { useQuery } from '../../../hooks/useQuery'; +import { + fetchProfile, + fetchProfileArticles, + fetchProfileContests, + fetchProfileMissions, +} from '../../../redux/slices/profile'; const Account = () => { const dispatch = useAppDispatch(); + const myname = useAppSelector((state) => state.auth.username); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; useEffect(() => { - dispatch(setMenuActivePage('account')); - }, []); + if (username == myname) { + dispatch(setMenuActivePage('account')); + } else { + dispatch(setMenuActivePage('')); + } + dispatch( + fetchProfileMissions({ + username: username, + recentPageSize: 1, + authoredPageSize: 100, + }), + ); + dispatch(fetchProfileArticles({ username: username, pageSize: 100 })); + dispatch( + fetchProfileContests({ + username: username, + pastPageSize: 100, + minePageSize: 100, + upcomingPageSize: 100, + }), + ); + dispatch(fetchProfile(username)); + }, [username]); return ( - + diff --git a/src/views/home/account/RightPanel.tsx b/src/views/home/account/RightPanel.tsx index b1b63d8..819e202 100644 --- a/src/views/home/account/RightPanel.tsx +++ b/src/views/home/account/RightPanel.tsx @@ -1,9 +1,9 @@ -import { PrimaryButton } from '../../../components/button/PrimaryButton'; import { ReverseButton } from '../../../components/button/ReverseButton'; import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; import { logout } from '../../../redux/slices/auth'; import { OpenBook, Clipboard, Cup } from '../../../assets/icons/account'; import { FC } from 'react'; +import { useQuery } from '../../../hooks/useQuery'; interface StatisticItemProps { icon: string; @@ -34,32 +34,55 @@ const StatisticItem: FC = ({ ); }; +export const formatDate = (isoDate?: string): string => { + if (!isoDate) return ''; + const date = new Date(isoDate); + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + + return `${day}.${month}.${year}`; +}; + const RightPanel = () => { const dispatch = useAppDispatch(); - const name = useAppSelector((state) => state.auth.username); - const email = useAppSelector((state) => state.auth.email); + + const { data: profileData } = useAppSelector( + (state) => state.profile.profile, + ); + + const myname = useAppSelector((state) => state.auth.username); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; + return ( - {name} + {profileData?.identity.username} - {email} - - - Топ 50% + {profileData?.identity.email} - {}} - text="Редактировать" - className="w-full" - /> + + {`Зарегистрирован ${formatDate( + profileData?.identity.createdAt, + )}`} + + + {/* {username == myname && ( + {}} + text="Редактировать" + className="w-full" + /> + )} */} @@ -70,14 +93,14 @@ const RightPanel = () => { @@ -87,30 +110,32 @@ const RightPanel = () => { - { - dispatch(logout()); - }} - text="Выход" - color="error" - /> + {username == myname && ( + { + dispatch(logout()); + }} + text="Выход" + color="error" + /> + )} ); }; diff --git a/src/views/home/account/articles/ArticlesBlock.tsx b/src/views/home/account/articles/ArticlesBlock.tsx index eb91f9b..6325347 100644 --- a/src/views/home/account/articles/ArticlesBlock.tsx +++ b/src/views/home/account/articles/ArticlesBlock.tsx @@ -3,16 +3,25 @@ import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { setMenuActiveProfilePage } from '../../../../redux/slices/store'; import { cn } from '../../../../lib/cn'; import { ChevroneDown, Edit } from '../../../../assets/icons/groups'; -import { fetchMyArticles } from '../../../../redux/slices/articles'; import { useNavigate } from 'react-router-dom'; export interface ArticleItemProps { id: number; name: string; - tags: string[]; + createdAt: string; } -const ArticleItem: FC = ({ id, name, tags }) => { +export const formatDate = (isoDate?: string): string => { + if (!isoDate) return ''; + const date = new Date(isoDate); + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + + return `${day}.${month}.${year}`; +}; + +const ArticleItem: FC = ({ id, name, createdAt }) => { const navigate = useNavigate(); return ( @@ -35,18 +44,8 @@ const ArticleItem: FC = ({ id, name, tags }) => { - - {tags.map((v, i) => ( - - {v} - - ))} + + {`Опубликована ${formatDate(createdAt)}`} = ({ className = '' }) => { const dispatch = useAppDispatch(); const [active, setActive] = useState(true); - // ✅ Берём только "мои статьи" - const articles = useAppSelector( - (state) => state.articles.fetchMyArticles.articles, - ); - const status = useAppSelector( - (state) => state.articles.fetchMyArticles.status, - ); - const error = useAppSelector( - (state) => state.articles.fetchMyArticles.error, + const { data: articleData } = useAppSelector( + (state) => state.profile.articles, ); useEffect(() => { dispatch(setMenuActiveProfilePage('articles')); - dispatch(fetchMyArticles()); }, [dispatch]); return ( @@ -130,19 +121,21 @@ const ArticlesBlock: FC = ({ className = '' }) => { )} {status === 'failed' && ( - - Ошибка:{' '} - {error || 'Не удалось загрузить статьи'} - + Ошибка: )} {status === 'successful' && - articles.length === 0 && ( + articleData?.articles.items.length === 0 && ( У вас пока нет статей )} - {articles.map((v) => ( - + {articleData?.articles.items.map((v, i) => ( + ))} diff --git a/src/views/home/account/contests/Contests.tsx b/src/views/home/account/contests/Contests.tsx index 0c62c1a..eb38061 100644 --- a/src/views/home/account/contests/Contests.tsx +++ b/src/views/home/account/contests/Contests.tsx @@ -1,25 +1,18 @@ import { useEffect } from 'react'; import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { setMenuActiveProfilePage } from '../../../../redux/slices/store'; -import { - fetchMyContests, - fetchRegisteredContests, -} from '../../../../redux/slices/contests'; import ContestsBlock from './ContestsBlock'; const Contests = () => { const dispatch = useAppDispatch(); - // Redux-состояния - const myContestsState = useAppSelector( - (state) => state.contests.fetchMyContests, + const { data: constestData } = useAppSelector( + (state) => state.profile.contests, ); // При загрузке страницы — выставляем вкладку и подгружаем контесты useEffect(() => { dispatch(setMenuActiveProfilePage('contests')); - dispatch(fetchMyContests()); - dispatch(fetchRegisteredContests({})); }, []); return ( @@ -29,30 +22,38 @@ const Contests = () => { v.role != 'Organizer') + .filter((v) => v.scheduleType != 'AlwaysOpen')} + /> + + + + v.role != 'Organizer', + ) ?? []), + ...(constestData?.upcoming.items + .filter((v) => v.role != 'Organizer') + .filter((v) => v.scheduleType == 'AlwaysOpen') ?? + []), + ]} /> {/* Контесты, которые я создал */} - {myContestsState.status === 'loading' ? ( - - Загрузка ваших контестов... - - ) : myContestsState.error ? ( - - Ошибка: {myContestsState.error} - - ) : ( - - )} + ); diff --git a/src/views/home/account/contests/ContestsBlock.tsx b/src/views/home/account/contests/ContestsBlock.tsx index f732ffe..1862461 100644 --- a/src/views/home/account/contests/ContestsBlock.tsx +++ b/src/views/home/account/contests/ContestsBlock.tsx @@ -1,22 +1,23 @@ import { useState, FC } from 'react'; import { cn } from '../../../../lib/cn'; import { ChevroneDown } from '../../../../assets/icons/groups'; -import MyContestItem from './MyContestItem'; -import RegisterContestItem from './RegisterContestItem'; -import { Contest } from '../../../../redux/slices/contests'; +import { ContestItem } from '../../../../redux/slices/profile'; +import PastContestItem from './PastContestItem'; +import UpcoingContestItem from './UpcomingContestItem'; +import EditContestItem from './EditContestItem'; interface ContestsBlockProps { - contests: Contest[]; + contests?: ContestItem[]; title: string; className?: string; - type?: 'my' | 'reg'; + type?: 'edit' | 'upcoming' | 'past'; } const ContestsBlock: FC = ({ contests, title, className, - type = 'my', + type = 'edit', }) => { const [active, setActive] = useState(title != 'Скрытые'); @@ -36,11 +37,11 @@ const ContestsBlock: FC = ({ setActive(!active); }} > - {title} + {title} @@ -53,35 +54,38 @@ const ContestsBlock: FC = ({ > - {contests.map((v, i) => { - return type == 'my' ? ( - - ) : ( - - ); + {contests?.map((v, i) => { + if (type == 'past') { + return ( + + ); + } + + if (type == 'upcoming') { + return ( + + ); + } + + if (type == 'edit') { + return ( + + ); + } + + return <>>; })} diff --git a/src/views/home/account/contests/EditContestItem.tsx b/src/views/home/account/contests/EditContestItem.tsx new file mode 100644 index 0000000..eab249f --- /dev/null +++ b/src/views/home/account/contests/EditContestItem.tsx @@ -0,0 +1,146 @@ +import { cn } from '../../../../lib/cn'; +import { useNavigate } from 'react-router-dom'; +import { useAppSelector } from '../../../../redux/hooks'; +import { useQuery } from '../../../../hooks/useQuery'; +import { toastWarning } from '../../../../lib/toastNotification'; +import { Edit } from '../../../../assets/icons/input'; + +export interface EditContestItemProps { + name: string; + contestId: number; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; + visibility: string; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + role: string; + type: 'first' | 'second'; +} + +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} мин`; + } +} + +const EditContestItem: React.FC = ({ + name, + contestId, + scheduleType, + startsAt, + endsAt, + attemptDurationMinutes, + type, +}) => { + const navigate = useNavigate(); + + const myname = useAppSelector((state) => state.auth.username); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; + + const started = new Date(startsAt) <= new Date(); + + return ( + { + if (!started && username != myname) { + toastWarning('Контест еще не начался'); + return; + } + + const params = new URLSearchParams({ + back: '/home/account/contests', + }); + navigate(`/contest/${contestId}?${params}`); + }} + > + {name} + + {username} + + {scheduleType == 'AlwaysOpen' ? ( + + Всегда открыт + + ) : ( + + + {formatDate(startsAt)} + + - + + {formatDate(endsAt)} + + + )} + + + {formatDurationTime(attemptDurationMinutes)} + + + + {new Date() < new Date(startsAt) ? ( + <>{'Не начался'}> + ) : ( + <> + {scheduleType == 'AlwaysOpen' + ? 'Открыт' + : new Date() < new Date(endsAt) + ? 'Идет' + : 'Завершен'} + > + )} + + {username == myname && ( + { + e.stopPropagation(); + navigate( + `/contest/create?back=/home/account/contests&contestId=${contestId}`, + ); + }} + /> + )} + + ); +}; + +export default EditContestItem; diff --git a/src/views/home/account/contests/MyContestItem.tsx b/src/views/home/account/contests/MyContestItem.tsx deleted file mode 100644 index eef1bf4..0000000 --- a/src/views/home/account/contests/MyContestItem.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { cn } from '../../../../lib/cn'; -import { Account } from '../../../../assets/icons/auth'; -import { useNavigate } from 'react-router-dom'; -import { Edit } from '../../../../assets/icons/input'; - -export interface ContestItemProps { - id: number; - name: string; - startAt: string; - duration: number; - members: number; - type: 'first' | 'second'; -} - -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 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 ContestItem: React.FC = ({ - id, - name, - startAt, - duration, - members, - type, -}) => { - const navigate = useNavigate(); - - return ( - { - navigate(`/contest/${id}`); - }} - > - {name} - - {/* {authors.map((v, i) => {v})} */} - valavshonok - - - {formatDate(startAt)} - - {formatWaitTime(duration)} - - {members} - - - - { - e.stopPropagation(); - navigate( - `/contest/create?back=/home/account/contests&contestId=${id}`, - ); - }} - /> - - ); -}; - -export default ContestItem; diff --git a/src/views/home/account/contests/PastContestItem.tsx b/src/views/home/account/contests/PastContestItem.tsx new file mode 100644 index 0000000..a05d5dc --- /dev/null +++ b/src/views/home/account/contests/PastContestItem.tsx @@ -0,0 +1,112 @@ +import { cn } from '../../../../lib/cn'; +import { useNavigate } from 'react-router-dom'; +import { useAppSelector } from '../../../../redux/hooks'; +import { useQuery } from '../../../../hooks/useQuery'; + +export interface PastContestItemProps { + name: string; + contestId: number; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; + visibility: string; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + role: string; + type: 'first' | 'second'; +} + +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} мин`; + } +} + +const PastContestItem: React.FC = ({ + name, + contestId, + scheduleType, + startsAt, + endsAt, + attemptDurationMinutes, + type, +}) => { + const navigate = useNavigate(); + + const myname = useAppSelector((state) => state.auth.username); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; + + return ( + { + const params = new URLSearchParams({ + back: '/home/account/contests', + }); + navigate(`/contest/${contestId}?${params}`); + }} + > + {name} + + {username} + + {scheduleType == 'AlwaysOpen' ? ( + + Всегда открыт + + ) : ( + + + {formatDate(startsAt)} + + - + + {formatDate(endsAt)} + + + )} + + + {formatDurationTime(attemptDurationMinutes)} + + + + {scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Завершен'} + + + ); +}; + +export default PastContestItem; diff --git a/src/views/home/account/contests/RegisterContestItem.tsx b/src/views/home/account/contests/RegisterContestItem.tsx deleted file mode 100644 index f8cbf6c..0000000 --- a/src/views/home/account/contests/RegisterContestItem.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { cn } from '../../../../lib/cn'; -import { Account } from '../../../../assets/icons/auth'; -import { PrimaryButton } from '../../../../components/button/PrimaryButton'; -import { ReverseButton } from '../../../../components/button/ReverseButton'; -import { useNavigate } from 'react-router-dom'; - -export interface ContestItemProps { - id: number; - name: string; - startAt: string; - duration: number; - members: number; - statusRegister: 'reg' | 'nonreg'; - type: 'first' | 'second'; -} - -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 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 ContestItem: React.FC = ({ - id, - name, - startAt, - duration, - members, - statusRegister, - type, -}) => { - const navigate = useNavigate(); - - const now = new Date(); - - const waitTime = new Date(startAt).getTime() - now.getTime(); - - return ( - { - navigate(`/contest/${id}`); - }} - > - {name} - - {/* {authors.map((v, i) => {v})} */} - valavshonok - - - {formatDate(startAt)} - - {formatWaitTime(duration)} - {waitTime > 0 && ( - - {'До начала\n' + formatWaitTime(waitTime)} - - )} - - {members} - - - - {statusRegister == 'reg' ? ( - <> - {' '} - {}} text="Регистрация" /> - > - ) : ( - <> - {' '} - {}} text="Вы записаны" /> - > - )} - - - ); -}; - -export default ContestItem; diff --git a/src/views/home/account/contests/UpcomingContestItem.tsx b/src/views/home/account/contests/UpcomingContestItem.tsx new file mode 100644 index 0000000..0bb8adf --- /dev/null +++ b/src/views/home/account/contests/UpcomingContestItem.tsx @@ -0,0 +1,160 @@ +import { cn } from '../../../../lib/cn'; +import { useNavigate } from 'react-router-dom'; +import { useAppSelector } from '../../../../redux/hooks'; +import { useQuery } from '../../../../hooks/useQuery'; +import { toastWarning } from '../../../../lib/toastNotification'; + +export interface UpcoingContestItemProps { + name: string; + contestId: number; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; + visibility: string; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + role: string; + type: 'first' | 'second'; +} + +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 = ({ + name, + contestId, + scheduleType, + startsAt, + endsAt, + attemptDurationMinutes, + type, +}) => { + const navigate = useNavigate(); + + const myname = useAppSelector((state) => state.auth.username); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; + + 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(); + + return ( + { + if (!started) { + toastWarning('Контест еще не начался'); + return; + } + + const params = new URLSearchParams({ + back: '/home/account/contests', + }); + navigate(`/contest/${contestId}?${params}`); + }} + > + {name} + + {username} + + {scheduleType == 'AlwaysOpen' ? ( + + Всегда открыт + + ) : ( + + + {formatDate(startsAt)} + + - + + {formatDate(endsAt)} + + + )} + + + {formatDurationTime(attemptDurationMinutes)} + + + {!started ? ( + + {'До начала\n' + formatWaitTime(waitTime)} + + ) : ( + !finished && ( + + {'До конца\n' + formatWaitTime(waitTime)} + + ) + )} + + + {new Date() < new Date(startsAt) ? ( + <>{'Не начался'}> + ) : ( + <>{scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Идет'}> + )} + + + ); +}; + +export default UpcoingContestItem; diff --git a/src/views/home/account/missions/Missions.tsx b/src/views/home/account/missions/Missions.tsx index 1d8d2fb..b06c5e4 100644 --- a/src/views/home/account/missions/Missions.tsx +++ b/src/views/home/account/missions/Missions.tsx @@ -1,12 +1,15 @@ -import { FC, useEffect } from 'react'; +import { FC, useEffect, useState } from 'react'; import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { setMenuActiveProfilePage } from '../../../../redux/slices/store'; import { cn } from '../../../../lib/cn'; import MissionsBlock from './MissionsBlock'; import { - fetchMyMissions, + deleteMission, setMissionsStatus, } from '../../../../redux/slices/missions'; +import ConfirmModal from '../../../../components/modal/ConfirmModal'; +import { fetchProfileMissions } from '../../../../redux/slices/profile'; +import { useQuery } from '../../../../hooks/useQuery'; interface ItemProps { count: number; @@ -41,12 +44,20 @@ const Item: FC = ({ const Missions = () => { const dispatch = useAppDispatch(); - const missions = useAppSelector((state) => state.missions.missions); - const status = useAppSelector((state) => state.missions.statuses.fetchMy); + + const [modalDeleteTask, setModalDeleteTask] = useState(false); + const [taskdeleteId, setTaskDeleteId] = useState(0); + + const { data: missionData } = useAppSelector( + (state) => state.profile.missions, + ); + + const myname = useAppSelector((state) => state.auth.username); + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; useEffect(() => { dispatch(setMenuActiveProfilePage('missions')); - dispatch(fetchMyMissions()); }, []); useEffect(() => { @@ -62,46 +73,67 @@ const Missions = () => {
(`/articles/${articleId}`); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при получении статьи', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -172,9 +212,7 @@ export const createArticle = createAsyncThunk( }); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при создании статьи', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -202,9 +240,7 @@ export const updateArticle = createAsyncThunk( ); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при обновлении статьи', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -217,9 +253,7 @@ export const deleteArticle = createAsyncThunk( await axios.delete(`/articles/${articleId}`); return articleId; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при удалении статьи', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -258,7 +292,35 @@ const articlesSlice = createSlice({ ); builder.addCase(fetchArticles.rejected, (state, action: any) => { state.fetchArticles.status = 'failed'; - state.fetchArticles.error = action.payload; + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }); + + // fetchNewArticles + builder.addCase(fetchNewArticles.pending, (state) => { + state.fetchNewArticles.status = 'loading'; + state.fetchNewArticles.error = undefined; + }); + builder.addCase( + fetchNewArticles.fulfilled, + (state, action: PayloadAction) => { + state.fetchNewArticles.status = 'successful'; + state.fetchNewArticles.articles = action.payload.articles; + state.fetchNewArticles.hasNextPage = action.payload.hasNextPage; + }, + ); + builder.addCase(fetchNewArticles.rejected, (state, action: any) => { + state.fetchNewArticles.status = 'failed'; + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // fetchMyArticles @@ -275,7 +337,12 @@ const articlesSlice = createSlice({ ); builder.addCase(fetchMyArticles.rejected, (state, action: any) => { state.fetchMyArticles.status = 'failed'; - state.fetchMyArticles.error = action.payload; + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // fetchArticleById @@ -292,7 +359,12 @@ const articlesSlice = createSlice({ ); builder.addCase(fetchArticleById.rejected, (state, action: any) => { state.fetchArticleById.status = 'failed'; - state.fetchArticleById.error = action.payload; + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // createArticle @@ -309,7 +381,14 @@ const articlesSlice = createSlice({ ); builder.addCase(createArticle.rejected, (state, action: any) => { state.createArticle.status = 'failed'; - state.createArticle.error = action.payload; + state.createArticle.error = action.payload.title; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // updateArticle @@ -326,7 +405,14 @@ const articlesSlice = createSlice({ ); builder.addCase(updateArticle.rejected, (state, action: any) => { state.updateArticle.status = 'failed'; - state.updateArticle.error = action.payload; + state.createArticle.error = action.payload.title; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // deleteArticle @@ -350,7 +436,12 @@ const articlesSlice = createSlice({ ); builder.addCase(deleteArticle.rejected, (state, action: any) => { state.deleteArticle.status = 'failed'; - state.deleteArticle.error = action.payload; + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); }, }); diff --git a/src/redux/slices/auth.ts b/src/redux/slices/auth.ts index eeabf20..0aebac2 100644 --- a/src/redux/slices/auth.ts +++ b/src/redux/slices/auth.ts @@ -1,6 +1,8 @@ -import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from '../../axios'; +type Status = 'idle' | 'loading' | 'successful' | 'failed'; + // 🔹 Декодирование JWT function decodeJwt(token: string) { const [, payload] = token.split('.'); @@ -15,8 +17,12 @@ interface AuthState { username: string | null; email: string | null; id: string | null; - status: 'idle' | 'loading' | 'successful' | 'failed'; + status: Status; error: string | null; + register: { + errors?: Record; + status: Status; + }; } // 🔹 Инициализация состояния с синхронной загрузкой из localStorage @@ -31,6 +37,9 @@ const initialState: AuthState = { id: null, status: 'idle', error: null, + register: { + status: 'idle', + }, }; // Если токен есть, подставляем в axios и декодируем @@ -76,9 +85,7 @@ export const registerUser = createAsyncThunk( }); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Registration failed', - ); + return rejectWithValue(err.response?.data?.errors); } }, ); @@ -165,6 +172,15 @@ const authSlice = createSlice({ localStorage.removeItem('refreshToken'); delete axios.defaults.headers.common['Authorization']; }, + setAuthStatus: ( + state, + action: PayloadAction<{ key: keyof AuthState; status: Status }>, + ) => { + const { key, status } = action.payload; + if (state[key]) { + (state[key] as any).status = status; + } + }, }, extraReducers: (builder) => { // ----------------- Register ----------------- @@ -199,7 +215,7 @@ const authSlice = createSlice({ }); builder.addCase(registerUser.rejected, (state, action) => { state.status = 'failed'; - state.error = action.payload as string; + state.register.errors = action.payload as Record; }); // ----------------- Login ----------------- @@ -304,5 +320,5 @@ const authSlice = createSlice({ }, }); -export const { logout } = authSlice.actions; +export const { logout, setAuthStatus } = authSlice.actions; export const authReducer = authSlice.reducer; diff --git a/src/redux/slices/contests.ts b/src/redux/slices/contests.ts index 5ddff69..3bf6bcc 100644 --- a/src/redux/slices/contests.ts +++ b/src/redux/slices/contests.ts @@ -1,14 +1,11 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from '../../axios'; +import { toastError } from '../../lib/toastNotification'; // ===================== // Типы // ===================== -// ===================== -// Типы для посылок -// ===================== - export interface Solution { id: number; missionId: number; @@ -73,11 +70,26 @@ export interface Contest { members?: Member[]; } +export interface Attempt { + attemptId: number; + contestId: number; + startedAt: string; + expiresAt: string; + finished: boolean; + submissions?: Submission[]; + results?: any[]; +} + interface ContestsResponse { hasNextPage: boolean; contests: Contest[]; } +interface MembersPage { + members: Member[]; + hasNextPage: boolean; +} + export interface CreateContestBody { name: string; description?: string; @@ -142,94 +154,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; + }; + + fetchParticipating: { + contests: Contest[]; + hasNextPage: boolean; + status: Status; + error?: string; + }; } +const emptyContest: Contest = { + id: 0, + name: '', + description: '', + scheduleType: 'AlwaysOpen', + visibility: 'Public', + startsAt: '', + endsAt: '', + attemptDurationMinutes: 0, + maxAttempts: 0, + allowEarlyFinish: false, + missions: [], + articles: [], + members: [], +}; + const initialState: ContestsState = { - fetchContests: { + 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, - }, - 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, }, - 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, - }, - fetchRegisteredContests: { + 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' }, + fetchParticipating: { contests: [], hasNextPage: false, status: 'idle', @@ -241,7 +265,27 @@ const initialState: ContestsState = { // Async Thunks // ===================== -// Мои посылки в контесте +// Existing ---------------------------- + +export const fetchParticipatingContests = createAsyncThunk( + 'contests/fetchParticipating', + async ( + params: { page?: number; pageSize?: number } = {}, + { rejectWithValue }, + ) => { + try { + const { page = 0, pageSize = 100 } = params; + const response = await axios.get( + '/contests/participating', + { params: { page, pageSize } }, + ); + return response.data; + } catch (err: any) { + return rejectWithValue(err.response?.data); + } + }, +); + export const fetchMySubmissions = createAsyncThunk( 'contests/fetchMySubmissions', async (contestId: number, { rejectWithValue }) => { @@ -251,14 +295,11 @@ export const fetchMySubmissions = createAsyncThunk( ); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Failed to fetch my submissions', - ); + return rejectWithValue(err.response?.data); } }, ); -// Все контесты export const fetchContests = createAsyncThunk( 'contests/fetchAll', async ( @@ -270,20 +311,17 @@ export const fetchContests = createAsyncThunk( { rejectWithValue }, ) => { try { - const { page = 0, pageSize = 10, groupId } = params; + const { page = 0, pageSize = 100, groupId } = params; const response = await axios.get('/contests', { params: { page, pageSize, groupId }, }); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Failed to fetch contests', - ); + return rejectWithValue(err.response?.data); } }, ); -// Контест по ID export const fetchContestById = createAsyncThunk( 'contests/fetchById', async (id: number, { rejectWithValue }) => { @@ -291,14 +329,11 @@ export const fetchContestById = createAsyncThunk( const response = await axios.get(`/contests/${id}`); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Failed to fetch contest', - ); + return rejectWithValue(err.response?.data); } }, ); -// Создание контеста export const createContest = createAsyncThunk( 'contests/create', async (contestData: CreateContestBody, { rejectWithValue }) => { @@ -309,14 +344,11 @@ export const createContest = createAsyncThunk( ); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Failed to create contest', - ); + return rejectWithValue(err.response?.data); } }, ); -// 🆕 Обновление контеста export const updateContest = createAsyncThunk( 'contests/update', async ( @@ -333,14 +365,11 @@ export const updateContest = createAsyncThunk( ); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Failed to update contest', - ); + return rejectWithValue(err.response?.data); } }, ); -// 🆕 Удаление контеста export const deleteContest = createAsyncThunk( 'contests/delete', async (contestId: number, { rejectWithValue }) => { @@ -348,14 +377,11 @@ export const deleteContest = createAsyncThunk( await axios.delete(`/contests/${contestId}`); return contestId; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Failed to delete contest', - ); + return rejectWithValue(err.response?.data); } }, ); -// Контесты, созданные мной export const fetchMyContests = createAsyncThunk( 'contests/fetchMyContests', async (_, { rejectWithValue }) => { @@ -363,14 +389,11 @@ export const fetchMyContests = createAsyncThunk( const response = await axios.get('/contests/my'); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Failed to fetch my contests', - ); + return rejectWithValue(err.response?.data); } }, ); -// Контесты, где я зарегистрирован export const fetchRegisteredContests = createAsyncThunk( 'contests/fetchRegisteredContests', async ( @@ -378,17 +401,167 @@ export const fetchRegisteredContests = createAsyncThunk( { rejectWithValue }, ) => { try { - const { page = 0, pageSize = 10 } = params; + const { page = 0, pageSize = 100 } = params; const response = await axios.get( '/contests/registered', { params: { page, pageSize } }, ); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || - 'Failed to fetch registered contests', + return rejectWithValue(err.response?.data); + } + }, +); + +// 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); + } + }, +); + +// 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); + } + }, +); + +// 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); + } + }, +); + +// 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); + } + }, +); + +// Members with pagination +export const fetchContestMembers = createAsyncThunk( + 'contests/fetchContestMembers', + async ( + { + contestId, + page = 0, + pageSize = 100, + }: { 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); + } + }, +); + +// 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); + } + }, +); + +// 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); + } + }, +); + +// 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); + } + }, +); + +// 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); } }, ); @@ -401,7 +574,6 @@ const contestsSlice = createSlice({ name: 'contests', initialState, reducers: { - // 🆕 Сброс статусов setContestStatus: ( state, action: PayloadAction<{ key: keyof ContestsState; status: Status }>, @@ -413,7 +585,8 @@ const contestsSlice = createSlice({ }, }, extraReducers: (builder) => { - // 🆕 fetchMySubmissions + // ——— YOUR EXISTING HANDLERS (unchanged) ——— + builder.addCase(fetchMySubmissions.pending, (state) => { state.fetchMySubmissions.status = 'loading'; state.fetchMySubmissions.error = undefined; @@ -427,13 +600,17 @@ const contestsSlice = createSlice({ ); builder.addCase(fetchMySubmissions.rejected, (state, action: any) => { state.fetchMySubmissions.status = 'failed'; - state.fetchMySubmissions.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); - // fetchContests builder.addCase(fetchContests.pending, (state) => { state.fetchContests.status = 'loading'; - state.fetchContests.error = undefined; }); builder.addCase( fetchContests.fulfilled, @@ -445,13 +622,17 @@ const contestsSlice = createSlice({ ); builder.addCase(fetchContests.rejected, (state, action: any) => { state.fetchContests.status = 'failed'; - state.fetchContests.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); - // fetchContestById builder.addCase(fetchContestById.pending, (state) => { state.fetchContestById.status = 'loading'; - state.fetchContestById.error = undefined; }); builder.addCase( fetchContestById.fulfilled, @@ -462,13 +643,17 @@ const contestsSlice = createSlice({ ); builder.addCase(fetchContestById.rejected, (state, action: any) => { state.fetchContestById.status = 'failed'; - state.fetchContestById.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); - // createContest builder.addCase(createContest.pending, (state) => { state.createContest.status = 'loading'; - state.createContest.error = undefined; }); builder.addCase( createContest.fulfilled, @@ -479,13 +664,17 @@ const contestsSlice = createSlice({ ); builder.addCase(createContest.rejected, (state, action: any) => { state.createContest.status = 'failed'; - state.createContest.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); - // 🆕 updateContest builder.addCase(updateContest.pending, (state) => { state.updateContest.status = 'loading'; - state.updateContest.error = undefined; }); builder.addCase( updateContest.fulfilled, @@ -496,19 +685,22 @@ const contestsSlice = createSlice({ ); builder.addCase(updateContest.rejected, (state, action: any) => { state.updateContest.status = 'failed'; - state.updateContest.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); - // 🆕 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, @@ -521,13 +713,17 @@ const contestsSlice = createSlice({ ); builder.addCase(deleteContest.rejected, (state, action: any) => { state.deleteContest.status = 'failed'; - state.deleteContest.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); - // fetchMyContests builder.addCase(fetchMyContests.pending, (state) => { state.fetchMyContests.status = 'loading'; - state.fetchMyContests.error = undefined; }); builder.addCase( fetchMyContests.fulfilled, @@ -538,13 +734,17 @@ const contestsSlice = createSlice({ ); builder.addCase(fetchMyContests.rejected, (state, action: any) => { state.fetchMyContests.status = 'failed'; - state.fetchMyContests.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); - // fetchRegisteredContests builder.addCase(fetchRegisteredContests.pending, (state) => { state.fetchRegisteredContests.status = 'loading'; - state.fetchRegisteredContests.error = undefined; }); builder.addCase( fetchRegisteredContests.fulfilled, @@ -560,7 +760,269 @@ const contestsSlice = createSlice({ fetchRegisteredContests.rejected, (state, action: any) => { state.fetchRegisteredContests.status = 'failed'; - state.fetchRegisteredContests.error = action.payload; + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }, + ); + + // 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'; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }); + + 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'; + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }, + ); + + 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'; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }); + + 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'; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }); + + 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'; + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }, + ); + + 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'; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }); + + 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'; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }); + + 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'; + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }, + ); + + 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'; + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }, + ); + + builder.addCase(fetchParticipatingContests.pending, (state) => { + state.fetchParticipating.status = 'loading'; + }); + + builder.addCase( + fetchParticipatingContests.fulfilled, + (state, action: PayloadAction) => { + state.fetchParticipating.status = 'successful'; + state.fetchParticipating.contests = action.payload.contests; + state.fetchParticipating.hasNextPage = + action.payload.hasNextPage; + }, + ); + + builder.addCase( + fetchParticipatingContests.rejected, + (state, action: any) => { + state.fetchParticipating.status = 'failed'; + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }, ); }, diff --git a/src/redux/slices/groupChat.ts b/src/redux/slices/groupChat.ts new file mode 100644 index 0000000..a37d91d --- /dev/null +++ b/src/redux/slices/groupChat.ts @@ -0,0 +1,203 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from '../../axios'; +import { toastError } from '../../lib/toastNotification'; + +// ========================================= +// Типы +// ========================================= + +export type Status = 'idle' | 'loading' | 'successful' | 'failed'; + +export interface ChatMessage { + id: number; + groupId: number; + authorId: number; + authorUsername: string; + content: string; + createdAt: string; +} + +interface FetchMessagesParams { + groupId: number; + limit?: number; + afterMessageId?: number; + timeoutSeconds?: number; +} + +interface SendMessageParams { + groupId: number; + content: string; +} + +// ========================================= +// State +// ========================================= + +interface GroupChatState { + messages: Record; // по группам + lastMessage: Record; + + fetchMessages: { + status: Status; + error?: string; + }; + sendMessage: { + status: Status; + error?: string; + }; +} + +const initialState: GroupChatState = { + messages: {}, + lastMessage: {}, + fetchMessages: { + status: 'idle', + error: undefined, + }, + sendMessage: { + status: 'idle', + error: undefined, + }, +}; + +// ========================================= +// Thunks +// ========================================= + +// Получение сообщений +export const fetchGroupMessages = createAsyncThunk( + 'groupChat/fetchGroupMessages', + async (params: FetchMessagesParams, { rejectWithValue }) => { + try { + const response = await axios.get(`/groups/${params.groupId}/chat`, { + params: { + limit: params.limit, + afterMessageId: params.afterMessageId, + timeoutSeconds: params.timeoutSeconds, + }, + }); + + return { + groupId: params.groupId, + messages: response.data as ChatMessage[], + }; + } catch (err: any) { + return rejectWithValue(err.response?.data); + } + }, +); + +// Отправка +export const sendGroupMessage = createAsyncThunk( + 'groupChat/sendGroupMessage', + async ({ groupId, content }: SendMessageParams, { rejectWithValue }) => { + try { + const response = await axios.post(`/groups/${groupId}/chat`, { + content, + }); + return response.data as ChatMessage; + } catch (err: any) { + return rejectWithValue(err.response?.data); + } + }, +); + +// ========================================= +// Slice +// ========================================= + +const groupChatSlice = createSlice({ + name: 'groupChat', + initialState, + reducers: { + clearChat(state, action: PayloadAction) { + delete state.messages[action.payload]; + }, + setGroupChatStatus: ( + state, + action: PayloadAction<{ + key: keyof GroupChatState; + status: Status; + }>, + ) => { + const { key, status } = action.payload; + if (state[key]) { + (state[key] as any).status = status; + } + }, + }, + extraReducers: (builder) => { + // fetch messages + builder.addCase(fetchGroupMessages.pending, (state) => { + state.fetchMessages.status = 'loading'; + }); + + builder.addCase( + fetchGroupMessages.fulfilled, + ( + state, + action: PayloadAction<{ + groupId: number; + messages: ChatMessage[]; + }>, + ) => { + const { groupId, messages } = action.payload; + const existing = state.messages[groupId] || []; + + const ids = new Set(existing.map((m) => m.id)); + const filtered = messages.filter((m) => !ids.has(m.id)); + + state.messages[groupId] = [...existing, ...filtered].sort( + (a, b) => a.id - b.id, + ); + if (state.messages[groupId].length) { + state.lastMessage[groupId] = + state.messages[groupId][ + state.messages[groupId].length - 1 + ].id; + } + + state.fetchMessages.status = 'successful'; + }, + ); + + builder.addCase(fetchGroupMessages.rejected, (state, action: any) => { + state.fetchMessages.status = 'failed'; + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }); + + // send message + builder.addCase(sendGroupMessage.pending, (state) => { + state.sendMessage.status = 'loading'; + }); + + builder.addCase( + sendGroupMessage.fulfilled, + (state, action: PayloadAction) => { + const msg = action.payload; + if (!state.messages[msg.groupId]) + state.messages[msg.groupId] = []; + state.messages[msg.groupId].push(msg); + state.sendMessage.status = 'successful'; + }, + ); + + builder.addCase(sendGroupMessage.rejected, (state, action: any) => { + state.sendMessage.status = 'failed'; + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }); + }, +}); + +export const { clearChat, setGroupChatStatus } = groupChatSlice.actions; +export const groupChatReducer = groupChatSlice.reducer; diff --git a/src/redux/slices/groupfeed.ts b/src/redux/slices/groupfeed.ts index 05cfdbb..9c65cf0 100644 --- a/src/redux/slices/groupfeed.ts +++ b/src/redux/slices/groupfeed.ts @@ -1,5 +1,6 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from '../../axios'; +import { toastError } from '../../lib/toastNotification'; // ===================== // Типы @@ -94,7 +95,7 @@ export const fetchGroupPosts = createAsyncThunk( { groupId, page = 0, - pageSize = 20, + pageSize = 100, }: { groupId: number; page?: number; pageSize?: number }, { rejectWithValue }, ) => { @@ -104,9 +105,7 @@ export const fetchGroupPosts = createAsyncThunk( ); return { page, data: response.data as PostsPage }; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка загрузки постов', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -124,9 +123,7 @@ export const fetchPostById = createAsyncThunk( ); return response.data as Post; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка загрузки поста', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -149,9 +146,7 @@ export const createPost = createAsyncThunk( }); return response.data as Post; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка создания поста', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -183,9 +178,7 @@ export const updatePost = createAsyncThunk( ); return response.data as Post; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка обновления поста', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -201,9 +194,7 @@ export const deletePost = createAsyncThunk( await axios.delete(`/groups/${groupId}/feed/${postId}`); return postId; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка удаления поста', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -244,7 +235,13 @@ const postsSlice = createSlice({ ); builder.addCase(fetchGroupPosts.rejected, (state, action: any) => { state.fetchPosts.status = 'failed'; - state.fetchPosts.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // fetchPostById @@ -260,7 +257,13 @@ const postsSlice = createSlice({ ); builder.addCase(fetchPostById.rejected, (state, action: any) => { state.fetchPostById.status = 'failed'; - state.fetchPostById.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // createPost @@ -281,7 +284,13 @@ const postsSlice = createSlice({ ); builder.addCase(createPost.rejected, (state, action: any) => { state.createPost.status = 'failed'; - state.createPost.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // updatePost @@ -310,7 +319,13 @@ const postsSlice = createSlice({ ); builder.addCase(updatePost.rejected, (state, action: any) => { state.updatePost.status = 'failed'; - state.updatePost.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // deletePost @@ -338,7 +353,13 @@ const postsSlice = createSlice({ ); builder.addCase(deletePost.rejected, (state, action: any) => { state.deletePost.status = 'failed'; - state.deletePost.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); }, }); diff --git a/src/redux/slices/groups.ts b/src/redux/slices/groups.ts index 38350bb..58898a2 100644 --- a/src/redux/slices/groups.ts +++ b/src/redux/slices/groups.ts @@ -1,5 +1,6 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from '../../axios'; +import { toastError } from '../../lib/toastNotification'; // ===================== // Типы @@ -131,9 +132,7 @@ export const createGroup = createAsyncThunk( const response = await axios.post('/groups', { name, description }); return response.data as Group; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при создании группы', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -155,9 +154,7 @@ export const updateGroup = createAsyncThunk( }); return response.data as Group; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при обновлении группы', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -169,9 +166,7 @@ export const deleteGroup = createAsyncThunk( await axios.delete(`/groups/${groupId}`); return groupId; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при удалении группы', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -183,9 +178,7 @@ export const fetchMyGroups = createAsyncThunk( const response = await axios.get('/groups/my'); return response.data.groups as Group[]; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при получении групп', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -197,9 +190,7 @@ export const fetchGroupById = createAsyncThunk( const response = await axios.get(`/groups/${groupId}`); return response.data as Group; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при получении группы', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -221,10 +212,7 @@ export const addGroupMember = createAsyncThunk( }); return response.data; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || - 'Ошибка при добавлении участника', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -239,9 +227,7 @@ export const removeGroupMember = createAsyncThunk( await axios.delete(`/groups/${groupId}/members/${memberId}`); return { groupId, memberId }; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при удалении участника', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -258,10 +244,7 @@ export const fetchGroupJoinLink = createAsyncThunk( const response = await axios.get(`/groups/${groupId}/join-link`); return response.data as { token: string; expiresAt: string }; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || - 'Ошибка при получении ссылки для присоединения', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -274,10 +257,7 @@ export const joinGroupByToken = createAsyncThunk( const response = await axios.post(`/groups/join/${token}`); return response.data as Group; } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || - 'Ошибка при присоединении к группе по ссылке', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -314,7 +294,13 @@ const groupsSlice = createSlice({ ); builder.addCase(fetchMyGroups.rejected, (state, action: any) => { state.fetchMyGroups.status = 'failed'; - state.fetchMyGroups.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // fetchGroupById @@ -330,7 +316,13 @@ const groupsSlice = createSlice({ ); builder.addCase(fetchGroupById.rejected, (state, action: any) => { state.fetchGroupById.status = 'failed'; - state.fetchGroupById.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // createGroup @@ -347,7 +339,13 @@ const groupsSlice = createSlice({ ); builder.addCase(createGroup.rejected, (state, action: any) => { state.createGroup.status = 'failed'; - state.createGroup.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // updateGroup @@ -370,7 +368,13 @@ const groupsSlice = createSlice({ ); builder.addCase(updateGroup.rejected, (state, action: any) => { state.updateGroup.status = 'failed'; - state.updateGroup.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // deleteGroup @@ -391,7 +395,13 @@ const groupsSlice = createSlice({ ); builder.addCase(deleteGroup.rejected, (state, action: any) => { state.deleteGroup.status = 'failed'; - state.deleteGroup.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // addGroupMember @@ -403,7 +413,13 @@ const groupsSlice = createSlice({ }); builder.addCase(addGroupMember.rejected, (state, action: any) => { state.addGroupMember.status = 'failed'; - state.addGroupMember.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // removeGroupMember @@ -430,7 +446,13 @@ const groupsSlice = createSlice({ ); builder.addCase(removeGroupMember.rejected, (state, action: any) => { state.removeGroupMember.status = 'failed'; - state.removeGroupMember.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // fetchGroupJoinLink @@ -449,7 +471,13 @@ const groupsSlice = createSlice({ ); builder.addCase(fetchGroupJoinLink.rejected, (state, action: any) => { state.fetchGroupJoinLink.status = 'failed'; - state.fetchGroupJoinLink.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); // joinGroupByToken @@ -466,7 +494,13 @@ const groupsSlice = createSlice({ ); builder.addCase(joinGroupByToken.rejected, (state, action: any) => { state.joinGroupByToken.status = 'failed'; - state.joinGroupByToken.error = action.payload; + + const errors = action.payload.errors as Record; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }); }, }); diff --git a/src/redux/slices/missions.ts b/src/redux/slices/missions.ts index f3c84ee..d314f7b 100644 --- a/src/redux/slices/missions.ts +++ b/src/redux/slices/missions.ts @@ -1,5 +1,6 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from '../../axios'; +import { toastError } from '../../lib/toastNotification'; // ─── Типы ──────────────────────────────────────────── @@ -27,13 +28,18 @@ export interface Mission { interface MissionsState { missions: Mission[]; + newMissions: Mission[]; currentMission: Mission | null; hasNextPage: boolean; + create: { + errors?: Record; + }; statuses: { fetchList: Status; fetchById: Status; upload: Status; fetchMy: Status; + delete: Status; }; error: string | null; } @@ -42,13 +48,16 @@ interface MissionsState { const initialState: MissionsState = { missions: [], + newMissions: [], currentMission: null, hasNextPage: false, + create: {}, statuses: { fetchList: 'idle', fetchById: 'idle', upload: 'idle', fetchMy: 'idle', + delete: 'idle', }, error: null, }; @@ -58,6 +67,33 @@ const initialState: MissionsState = { // GET /missions export const fetchMissions = createAsyncThunk( 'missions/fetchMissions', + async ( + { + page = 0, + pageSize = 100, + tags = [], + }: { page?: number; pageSize?: number; tags?: string[] }, + { rejectWithValue }, + ) => { + try { + const params: any = { page, pageSize }; + if (tags.length) params.tags = tags; + const response = await axios.get('/missions', { + params, + paramsSerializer: { + indexes: null, + }, + }); + return response.data; // { missions, hasNextPage } + } catch (err: any) { + return rejectWithValue(err.response?.data); + } + }, +); + +// GET /missions +export const fetchNewMissions = createAsyncThunk( + 'missions/fetchNewMissions', async ( { page = 0, @@ -69,12 +105,15 @@ export const fetchMissions = createAsyncThunk( try { const params: any = { page, pageSize }; if (tags.length) params.tags = tags; - const response = await axios.get('/missions', { params }); + const response = await axios.get('/missions', { + params, + paramsSerializer: { + indexes: null, + }, + }); return response.data; // { missions, hasNextPage } } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при получении миссий', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -87,9 +126,7 @@ export const fetchMissionById = createAsyncThunk( const response = await axios.get(`/missions/${id}`); return response.data; // Mission } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при получении миссии', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -102,10 +139,7 @@ export const fetchMyMissions = createAsyncThunk( const response = await axios.get('/missions/my'); return response.data as Mission[]; // массив миссий пользователя } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || - 'Ошибка при получении моих миссий', - ); + return rejectWithValue(err.response?.data); } }, ); @@ -134,9 +168,20 @@ export const uploadMission = createAsyncThunk( }); return response.data; // Mission } catch (err: any) { - return rejectWithValue( - err.response?.data?.message || 'Ошибка при загрузке миссии', - ); + return rejectWithValue(err.response?.data); + } + }, +); + +// DELETE /missions/{id} +export const deleteMission = createAsyncThunk( + 'missions/deleteMission', + async (id: number, { rejectWithValue }) => { + try { + await axios.delete(`/missions/${id}`); + return id; // возвращаем id удалённой миссии + } catch (err: any) { + return rejectWithValue(err.response?.data); } }, ); @@ -182,7 +227,52 @@ const missionsSlice = createSlice({ fetchMissions.rejected, (state, action: PayloadAction) => { state.statuses.fetchList = 'failed'; - state.error = action.payload; + + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + }, + ); + + // ─── FETCH NEW MISSIONS ─── + builder.addCase(fetchNewMissions.pending, (state) => { + state.statuses.fetchList = 'loading'; + state.error = null; + }); + builder.addCase( + fetchNewMissions.fulfilled, + ( + state, + action: PayloadAction<{ + missions: Mission[]; + hasNextPage: boolean; + }>, + ) => { + state.statuses.fetchList = 'successful'; + state.newMissions = action.payload.missions; + state.hasNextPage = action.payload.hasNextPage; + }, + ); + builder.addCase( + fetchNewMissions.rejected, + (state, action: PayloadAction) => { + state.statuses.fetchList = 'failed'; + + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }, ); @@ -202,7 +292,16 @@ const missionsSlice = createSlice({ fetchMissionById.rejected, (state, action: PayloadAction) => { state.statuses.fetchById = 'failed'; - state.error = action.payload; + + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }, ); @@ -222,7 +321,16 @@ const missionsSlice = createSlice({ fetchMyMissions.rejected, (state, action: PayloadAction) => { state.statuses.fetchMy = 'failed'; - state.error = action.payload; + + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }, ); @@ -242,7 +350,53 @@ const missionsSlice = createSlice({ uploadMission.rejected, (state, action: PayloadAction) => { state.statuses.upload = 'failed'; - state.error = action.payload; + + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); + + state.create.errors = errors; + }, + ); + + // ─── DELETE MISSION ─── + builder.addCase(deleteMission.pending, (state) => { + state.statuses.delete = 'loading'; + state.error = null; + }); + builder.addCase( + deleteMission.fulfilled, + (state, action: PayloadAction) => { + state.statuses.delete = 'successful'; + state.missions = state.missions.filter( + (m) => m.id !== action.payload, + ); + + if (state.currentMission?.id === action.payload) { + state.currentMission = null; + } + }, + ); + builder.addCase( + deleteMission.rejected, + (state, action: PayloadAction) => { + state.statuses.delete = 'failed'; + + const errors = action.payload.errors as Record< + string, + string[] + >; + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); + }); }, ); }, diff --git a/src/redux/slices/profile.ts b/src/redux/slices/profile.ts new file mode 100644 index 0000000..399b900 --- /dev/null +++ b/src/redux/slices/profile.ts @@ -0,0 +1,395 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from '../../axios'; + +// ===================== +// Типы +// ===================== + +type Status = 'idle' | 'loading' | 'successful' | 'failed'; + +// Основной профиль +export interface ProfileIdentity { + userId: number; + username: string; + email: string; + createdAt: string; +} + +export interface ProfileSolutions { + totalSolved: number; + solvedLast7Days: number; +} + +export interface ProfileContestsInfo { + totalParticipations: number; + participationsLast7Days: number; +} + +export interface ProfileCreationStats { + missions: { total: number; last7Days: number }; + contests: { total: number; last7Days: number }; + articles: { total: number; last7Days: number }; +} + +export interface ProfileResponse { + identity: ProfileIdentity; + solutions: ProfileSolutions; + contests: ProfileContestsInfo; + creation: ProfileCreationStats; +} + +// Missions +export interface MissionsBucket { + key: string; + label: string; + solved: number; + total: number; +} + +export interface MissionItem { + missionId: number; + missionName: string; + difficultyLabel: string; + difficultyValue: number; + createdAt: string; + timeLimitMilliseconds: number; + memoryLimitBytes: number; +} + +export interface MissionsResponse { + summary: { + total: MissionsBucket; + buckets: MissionsBucket[]; + }; + recent: { + items: MissionItem[]; + page: number; + pageSize: number; + hasNextPage: boolean; + }; + authored: { + items: MissionItem[]; + page: number; + pageSize: number; + hasNextPage: boolean; + }; +} + +// Articles +export interface ProfileArticleItem { + articleId: number; + title: string; + createdAt: string; + updatedAt: string; +} +export interface ProfileArticlesResponse { + articles: { + items: ProfileArticleItem[]; + page: number; + pageSize: number; + hasNextPage: boolean; + }; +} + +// Contests +export interface ContestItem { + contestId: number; + name: string; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; + visibility: string; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + role: 'None' | 'Participant' | 'Organizer'; +} + +export interface ContestsList { + items: ContestItem[]; + page: number; + pageSize: number; + hasNextPage: boolean; +} + +export interface ProfileContestsResponse { + upcoming: ContestsList; + past: ContestsList; + mine: ContestsList; +} + +// ===================== +// Состояние +// ===================== + +interface ProfileState { + profile: { + data?: ProfileResponse; + status: Status; + error?: string; + }; + + missions: { + data?: MissionsResponse; + status: Status; + error?: string; + }; + + articles: { + data?: ProfileArticlesResponse; + status: Status; + error?: string; + }; + + contests: { + data?: ProfileContestsResponse; + status: Status; + error?: string; + }; +} + +const initialState: ProfileState = { + profile: { + data: undefined, + status: 'idle', + error: undefined, + }, + missions: { + data: undefined, + status: 'idle', + error: undefined, + }, + articles: { + data: undefined, + status: 'idle', + error: undefined, + }, + contests: { + data: undefined, + status: 'idle', + error: undefined, + }, +}; + +// ===================== +// Async Thunks +// ===================== + +// Основной профиль +export const fetchProfile = createAsyncThunk( + 'profile/fetch', + async (username: string, { rejectWithValue }) => { + try { + const res = await axios.get( + `/profile/${username}`, + ); + return res.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка загрузки профиля', + ); + } + }, +); + +// Missions +export const fetchProfileMissions = createAsyncThunk( + 'profile/fetchMissions', + async ( + { + username, + recentPage = 0, + recentPageSize = 100, + authoredPage = 0, + authoredPageSize = 100, + }: { + username: string; + recentPage?: number; + recentPageSize?: number; + authoredPage?: number; + authoredPageSize?: number; + }, + { rejectWithValue }, + ) => { + try { + const res = await axios.get( + `/profile/${username}/missions`, + { + params: { + recentPage, + recentPageSize, + authoredPage, + authoredPageSize, + }, + }, + ); + return res.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка загрузки задач', + ); + } + }, +); + +// Articles +export const fetchProfileArticles = createAsyncThunk( + 'profile/fetchArticles', + async ( + { + username, + page = 0, + pageSize = 100, + }: { username: string; page?: number; pageSize?: number }, + { rejectWithValue }, + ) => { + try { + const res = await axios.get( + `/profile/${username}/articles`, + { params: { page, pageSize } }, + ); + return res.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка загрузки статей', + ); + } + }, +); + +// Contests +export const fetchProfileContests = createAsyncThunk( + 'profile/fetchContests', + async ( + { + username, + upcomingPage = 0, + upcomingPageSize = 100, + pastPage = 0, + pastPageSize = 100, + minePage = 0, + minePageSize = 100, + }: { + username: string; + upcomingPage?: number; + upcomingPageSize?: number; + pastPage?: number; + pastPageSize?: number; + minePage?: number; + minePageSize?: number; + }, + { rejectWithValue }, + ) => { + try { + const res = await axios.get( + `/profile/${username}/contests`, + { + params: { + upcomingPage, + upcomingPageSize, + pastPage, + pastPageSize, + minePage, + minePageSize, + }, + }, + ); + return res.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка загрузки контестов', + ); + } + }, +); + +// ===================== +// Slice +// ===================== + +const profileSlice = createSlice({ + name: 'profile', + initialState, + reducers: { + setProfileStatus: ( + state, + action: PayloadAction<{ + key: keyof ProfileState; + status: Status; + }>, + ) => { + state[action.payload.key].status = action.payload.status; + }, + }, + + extraReducers: (builder) => { + // PROFILE + builder.addCase(fetchProfile.pending, (state) => { + state.profile.status = 'loading'; + state.profile.error = undefined; + }); + builder.addCase( + fetchProfile.fulfilled, + (state, action: PayloadAction) => { + state.profile.status = 'successful'; + state.profile.data = action.payload; + }, + ); + builder.addCase(fetchProfile.rejected, (state, action: any) => { + state.profile.status = 'failed'; + state.profile.error = action.payload; + }); + + // MISSIONS + builder.addCase(fetchProfileMissions.pending, (state) => { + state.missions.status = 'loading'; + state.missions.error = undefined; + }); + builder.addCase( + fetchProfileMissions.fulfilled, + (state, action: PayloadAction) => { + state.missions.status = 'successful'; + state.missions.data = action.payload; + }, + ); + builder.addCase(fetchProfileMissions.rejected, (state, action: any) => { + state.missions.status = 'failed'; + state.missions.error = action.payload; + }); + + // ARTICLES + builder.addCase(fetchProfileArticles.pending, (state) => { + state.articles.status = 'loading'; + state.articles.error = undefined; + }); + builder.addCase( + fetchProfileArticles.fulfilled, + (state, action: PayloadAction) => { + state.articles.status = 'successful'; + state.articles.data = action.payload; + }, + ); + builder.addCase(fetchProfileArticles.rejected, (state, action: any) => { + state.articles.status = 'failed'; + state.articles.error = action.payload; + }); + + // CONTESTS + builder.addCase(fetchProfileContests.pending, (state) => { + state.contests.status = 'loading'; + state.contests.error = undefined; + }); + builder.addCase( + fetchProfileContests.fulfilled, + (state, action: PayloadAction) => { + state.contests.status = 'successful'; + state.contests.data = action.payload; + }, + ); + builder.addCase(fetchProfileContests.rejected, (state, action: any) => { + state.contests.status = 'failed'; + state.contests.error = action.payload; + }); + }, +}); + +export const { setProfileStatus } = profileSlice.actions; +export const profileReducer = profileSlice.reducer; diff --git a/src/redux/slices/store.ts b/src/redux/slices/store.ts index cc713a0..c6ff185 100644 --- a/src/redux/slices/store.ts +++ b/src/redux/slices/store.ts @@ -7,6 +7,21 @@ interface StorState { activeProfilePage: string; activeGroupPage: string; }; + group: { + groupFilter: string; + }; + articles: { + articleTagFilter: string[]; + filterName: string; + }; + contests: { + contestsTagFilter: string[]; + filterName: string; + }; + missions: { + missionsTagFilter: string[]; + filterName: string; + }; } // Инициализация состояния @@ -16,6 +31,21 @@ const initialState: StorState = { activeProfilePage: '', activeGroupPage: '', }, + group: { + groupFilter: '', + }, + articles: { + articleTagFilter: [], + filterName: '', + }, + contests: { + contestsTagFilter: [], + filterName: '', + }, + missions: { + missionsTagFilter: [], + filterName: '', + }, }; // Slice @@ -23,28 +53,63 @@ const storeSlice = createSlice({ name: 'store', initialState, reducers: { - setMenuActivePage: (state, activePage: PayloadAction) => { - state.menu.activePage = activePage.payload; + setMenuActivePage: (state, action: PayloadAction) => { + state.menu.activePage = action.payload; }, - setMenuActiveProfilePage: ( - state, - activeProfilePage: PayloadAction, - ) => { - state.menu.activeProfilePage = activeProfilePage.payload; + setMenuActiveProfilePage: (state, action: PayloadAction) => { + state.menu.activeProfilePage = action.payload; }, - setMenuActiveGroupPage: ( - state, - activeGroupPage: PayloadAction, - ) => { - state.menu.activeGroupPage = activeGroupPage.payload; + setMenuActiveGroupPage: (state, action: PayloadAction) => { + state.menu.activeGroupPage = action.payload; + }, + setGroupFilter: (state, action: PayloadAction) => { + state.group.groupFilter = action.payload; + }, + + // ---------- ARTICLES ---------- + setArticlesTagFilter: (state, action: PayloadAction) => { + state.articles.articleTagFilter = action.payload; + }, + setArticlesNameFilter: (state, action: PayloadAction) => { + state.articles.filterName = action.payload; + }, + + // ---------- CONTESTS ---------- + setContestsTagFilter: (state, action: PayloadAction) => { + state.contests.contestsTagFilter = action.payload; + }, + setContestsNameFilter: (state, action: PayloadAction) => { + state.contests.filterName = action.payload; + }, + + // ---------- MISSIONS ---------- + setMissionsTagFilter: (state, action: PayloadAction) => { + state.missions.missionsTagFilter = action.payload; + }, + setMissionsNameFilter: (state, action: PayloadAction) => { + state.missions.filterName = action.payload; }, }, }); export const { + // menu setMenuActivePage, setMenuActiveProfilePage, setMenuActiveGroupPage, + setGroupFilter, + + // articles + setArticlesTagFilter, + setArticlesNameFilter, + + // contests + setContestsTagFilter, + setContestsNameFilter, + + // missions + setMissionsTagFilter, + setMissionsNameFilter, } = storeSlice.actions; export const storeReducer = storeSlice.reducer; diff --git a/src/redux/slices/submit.ts b/src/redux/slices/submit.ts index 21522b1..6cf53d7 100644 --- a/src/redux/slices/submit.ts +++ b/src/redux/slices/submit.ts @@ -8,7 +8,7 @@ export interface Submit { language: string; languageVersion: string; sourceCode: string; - contestId?: number; + contestAttemptId?: number; } export interface Solution { diff --git a/src/redux/store.ts b/src/redux/store.ts index 84a6fb3..79f8154 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -7,6 +7,8 @@ import { contestsReducer } from './slices/contests'; import { groupsReducer } from './slices/groups'; import { articlesReducer } from './slices/articles'; import { groupFeedReducer } from './slices/groupfeed'; +import { groupChatReducer } from './slices/groupChat'; +import { profileReducer } from './slices/profile'; // использование // import { useAppDispatch, useAppSelector } from '../redux/hooks'; @@ -27,6 +29,8 @@ export const store = configureStore({ groups: groupsReducer, articles: articlesReducer, groupfeed: groupFeedReducer, + groupchat: groupChatReducer, + profile: profileReducer, }, }); diff --git a/src/views/articleeditor/Editor.tsx b/src/views/articleeditor/Editor.tsx index 8fd18ad..f109cef 100644 --- a/src/views/articleeditor/Editor.tsx +++ b/src/views/articleeditor/Editor.tsx @@ -68,14 +68,14 @@ function greet(user: User) { return \`Привет, \${user.name}! 👋 Роль: \${user.role}\`; } -console.log(greet({ name: "Ты", role: "Разработчик" })); +consol.log(greet({ name: "Ты", role: "Разработчик" })); \`\`\` Пример **JavaScript**: \`\`\`js const sum = (a, b) => a + b; -console.log(sum(2, 3)); // 5 +consol.log(sum(2, 3)); // 5 \`\`\` Пример **Python**: @@ -256,9 +256,7 @@ const MarkdownEditor: FC = ({ markdown.slice(cursorPos); setMarkdown(newText); - } catch (err) { - console.error('Ошибка загрузки изображения:', err); - } + } catch (err) {} } } }; diff --git a/src/views/home/account/Account.tsx b/src/views/home/account/Account.tsx index 527dc5c..d1c2d0a 100644 --- a/src/views/home/account/Account.tsx +++ b/src/views/home/account/Account.tsx @@ -4,19 +4,51 @@ import RightPanel from './RightPanel'; import Missions from './missions/Missions'; import Contests from './contests/Contests'; import ArticlesBlock from './articles/ArticlesBlock'; -import { useAppDispatch } from '../../../redux/hooks'; +import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; import { useEffect } from 'react'; import { setMenuActivePage } from '../../../redux/slices/store'; +import { useQuery } from '../../../hooks/useQuery'; +import { + fetchProfile, + fetchProfileArticles, + fetchProfileContests, + fetchProfileMissions, +} from '../../../redux/slices/profile'; const Account = () => { const dispatch = useAppDispatch(); + const myname = useAppSelector((state) => state.auth.username); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; useEffect(() => { - dispatch(setMenuActivePage('account')); - }, []); + if (username == myname) { + dispatch(setMenuActivePage('account')); + } else { + dispatch(setMenuActivePage('')); + } + dispatch( + fetchProfileMissions({ + username: username, + recentPageSize: 1, + authoredPageSize: 100, + }), + ); + dispatch(fetchProfileArticles({ username: username, pageSize: 100 })); + dispatch( + fetchProfileContests({ + username: username, + pastPageSize: 100, + minePageSize: 100, + upcomingPageSize: 100, + }), + ); + dispatch(fetchProfile(username)); + }, [username]); return ( - + diff --git a/src/views/home/account/RightPanel.tsx b/src/views/home/account/RightPanel.tsx index b1b63d8..819e202 100644 --- a/src/views/home/account/RightPanel.tsx +++ b/src/views/home/account/RightPanel.tsx @@ -1,9 +1,9 @@ -import { PrimaryButton } from '../../../components/button/PrimaryButton'; import { ReverseButton } from '../../../components/button/ReverseButton'; import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; import { logout } from '../../../redux/slices/auth'; import { OpenBook, Clipboard, Cup } from '../../../assets/icons/account'; import { FC } from 'react'; +import { useQuery } from '../../../hooks/useQuery'; interface StatisticItemProps { icon: string; @@ -34,32 +34,55 @@ const StatisticItem: FC = ({ ); }; +export const formatDate = (isoDate?: string): string => { + if (!isoDate) return ''; + const date = new Date(isoDate); + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + + return `${day}.${month}.${year}`; +}; + const RightPanel = () => { const dispatch = useAppDispatch(); - const name = useAppSelector((state) => state.auth.username); - const email = useAppSelector((state) => state.auth.email); + + const { data: profileData } = useAppSelector( + (state) => state.profile.profile, + ); + + const myname = useAppSelector((state) => state.auth.username); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; + return ( - {name} + {profileData?.identity.username} - {email} - - - Топ 50% + {profileData?.identity.email} - {}} - text="Редактировать" - className="w-full" - /> + + {`Зарегистрирован ${formatDate( + profileData?.identity.createdAt, + )}`} + + + {/* {username == myname && ( + {}} + text="Редактировать" + className="w-full" + /> + )} */} @@ -70,14 +93,14 @@ const RightPanel = () => { @@ -87,30 +110,32 @@ const RightPanel = () => { - { - dispatch(logout()); - }} - text="Выход" - color="error" - /> + {username == myname && ( + { + dispatch(logout()); + }} + text="Выход" + color="error" + /> + )} ); }; diff --git a/src/views/home/account/articles/ArticlesBlock.tsx b/src/views/home/account/articles/ArticlesBlock.tsx index eb91f9b..6325347 100644 --- a/src/views/home/account/articles/ArticlesBlock.tsx +++ b/src/views/home/account/articles/ArticlesBlock.tsx @@ -3,16 +3,25 @@ import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { setMenuActiveProfilePage } from '../../../../redux/slices/store'; import { cn } from '../../../../lib/cn'; import { ChevroneDown, Edit } from '../../../../assets/icons/groups'; -import { fetchMyArticles } from '../../../../redux/slices/articles'; import { useNavigate } from 'react-router-dom'; export interface ArticleItemProps { id: number; name: string; - tags: string[]; + createdAt: string; } -const ArticleItem: FC = ({ id, name, tags }) => { +export const formatDate = (isoDate?: string): string => { + if (!isoDate) return ''; + const date = new Date(isoDate); + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + + return `${day}.${month}.${year}`; +}; + +const ArticleItem: FC = ({ id, name, createdAt }) => { const navigate = useNavigate(); return ( @@ -35,18 +44,8 @@ const ArticleItem: FC = ({ id, name, tags }) => { - - {tags.map((v, i) => ( - - {v} - - ))} + + {`Опубликована ${formatDate(createdAt)}`} = ({ className = '' }) => { const dispatch = useAppDispatch(); const [active, setActive] = useState(true); - // ✅ Берём только "мои статьи" - const articles = useAppSelector( - (state) => state.articles.fetchMyArticles.articles, - ); - const status = useAppSelector( - (state) => state.articles.fetchMyArticles.status, - ); - const error = useAppSelector( - (state) => state.articles.fetchMyArticles.error, + const { data: articleData } = useAppSelector( + (state) => state.profile.articles, ); useEffect(() => { dispatch(setMenuActiveProfilePage('articles')); - dispatch(fetchMyArticles()); }, [dispatch]); return ( @@ -130,19 +121,21 @@ const ArticlesBlock: FC = ({ className = '' }) => { )} {status === 'failed' && ( - - Ошибка:{' '} - {error || 'Не удалось загрузить статьи'} - + Ошибка: )} {status === 'successful' && - articles.length === 0 && ( + articleData?.articles.items.length === 0 && ( У вас пока нет статей )} - {articles.map((v) => ( - + {articleData?.articles.items.map((v, i) => ( + ))} diff --git a/src/views/home/account/contests/Contests.tsx b/src/views/home/account/contests/Contests.tsx index 0c62c1a..eb38061 100644 --- a/src/views/home/account/contests/Contests.tsx +++ b/src/views/home/account/contests/Contests.tsx @@ -1,25 +1,18 @@ import { useEffect } from 'react'; import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { setMenuActiveProfilePage } from '../../../../redux/slices/store'; -import { - fetchMyContests, - fetchRegisteredContests, -} from '../../../../redux/slices/contests'; import ContestsBlock from './ContestsBlock'; const Contests = () => { const dispatch = useAppDispatch(); - // Redux-состояния - const myContestsState = useAppSelector( - (state) => state.contests.fetchMyContests, + const { data: constestData } = useAppSelector( + (state) => state.profile.contests, ); // При загрузке страницы — выставляем вкладку и подгружаем контесты useEffect(() => { dispatch(setMenuActiveProfilePage('contests')); - dispatch(fetchMyContests()); - dispatch(fetchRegisteredContests({})); }, []); return ( @@ -29,30 +22,38 @@ const Contests = () => { v.role != 'Organizer') + .filter((v) => v.scheduleType != 'AlwaysOpen')} + /> + + + + v.role != 'Organizer', + ) ?? []), + ...(constestData?.upcoming.items + .filter((v) => v.role != 'Organizer') + .filter((v) => v.scheduleType == 'AlwaysOpen') ?? + []), + ]} /> {/* Контесты, которые я создал */} - {myContestsState.status === 'loading' ? ( - - Загрузка ваших контестов... - - ) : myContestsState.error ? ( - - Ошибка: {myContestsState.error} - - ) : ( - - )} + ); diff --git a/src/views/home/account/contests/ContestsBlock.tsx b/src/views/home/account/contests/ContestsBlock.tsx index f732ffe..1862461 100644 --- a/src/views/home/account/contests/ContestsBlock.tsx +++ b/src/views/home/account/contests/ContestsBlock.tsx @@ -1,22 +1,23 @@ import { useState, FC } from 'react'; import { cn } from '../../../../lib/cn'; import { ChevroneDown } from '../../../../assets/icons/groups'; -import MyContestItem from './MyContestItem'; -import RegisterContestItem from './RegisterContestItem'; -import { Contest } from '../../../../redux/slices/contests'; +import { ContestItem } from '../../../../redux/slices/profile'; +import PastContestItem from './PastContestItem'; +import UpcoingContestItem from './UpcomingContestItem'; +import EditContestItem from './EditContestItem'; interface ContestsBlockProps { - contests: Contest[]; + contests?: ContestItem[]; title: string; className?: string; - type?: 'my' | 'reg'; + type?: 'edit' | 'upcoming' | 'past'; } const ContestsBlock: FC = ({ contests, title, className, - type = 'my', + type = 'edit', }) => { const [active, setActive] = useState(title != 'Скрытые'); @@ -36,11 +37,11 @@ const ContestsBlock: FC = ({ setActive(!active); }} > - {title} + {title} @@ -53,35 +54,38 @@ const ContestsBlock: FC = ({ > - {contests.map((v, i) => { - return type == 'my' ? ( - - ) : ( - - ); + {contests?.map((v, i) => { + if (type == 'past') { + return ( + + ); + } + + if (type == 'upcoming') { + return ( + + ); + } + + if (type == 'edit') { + return ( + + ); + } + + return <>>; })} diff --git a/src/views/home/account/contests/EditContestItem.tsx b/src/views/home/account/contests/EditContestItem.tsx new file mode 100644 index 0000000..eab249f --- /dev/null +++ b/src/views/home/account/contests/EditContestItem.tsx @@ -0,0 +1,146 @@ +import { cn } from '../../../../lib/cn'; +import { useNavigate } from 'react-router-dom'; +import { useAppSelector } from '../../../../redux/hooks'; +import { useQuery } from '../../../../hooks/useQuery'; +import { toastWarning } from '../../../../lib/toastNotification'; +import { Edit } from '../../../../assets/icons/input'; + +export interface EditContestItemProps { + name: string; + contestId: number; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; + visibility: string; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + role: string; + type: 'first' | 'second'; +} + +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} мин`; + } +} + +const EditContestItem: React.FC = ({ + name, + contestId, + scheduleType, + startsAt, + endsAt, + attemptDurationMinutes, + type, +}) => { + const navigate = useNavigate(); + + const myname = useAppSelector((state) => state.auth.username); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; + + const started = new Date(startsAt) <= new Date(); + + return ( + { + if (!started && username != myname) { + toastWarning('Контест еще не начался'); + return; + } + + const params = new URLSearchParams({ + back: '/home/account/contests', + }); + navigate(`/contest/${contestId}?${params}`); + }} + > + {name} + + {username} + + {scheduleType == 'AlwaysOpen' ? ( + + Всегда открыт + + ) : ( + + + {formatDate(startsAt)} + + - + + {formatDate(endsAt)} + + + )} + + + {formatDurationTime(attemptDurationMinutes)} + + + + {new Date() < new Date(startsAt) ? ( + <>{'Не начался'}> + ) : ( + <> + {scheduleType == 'AlwaysOpen' + ? 'Открыт' + : new Date() < new Date(endsAt) + ? 'Идет' + : 'Завершен'} + > + )} + + {username == myname && ( + { + e.stopPropagation(); + navigate( + `/contest/create?back=/home/account/contests&contestId=${contestId}`, + ); + }} + /> + )} + + ); +}; + +export default EditContestItem; diff --git a/src/views/home/account/contests/MyContestItem.tsx b/src/views/home/account/contests/MyContestItem.tsx deleted file mode 100644 index eef1bf4..0000000 --- a/src/views/home/account/contests/MyContestItem.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { cn } from '../../../../lib/cn'; -import { Account } from '../../../../assets/icons/auth'; -import { useNavigate } from 'react-router-dom'; -import { Edit } from '../../../../assets/icons/input'; - -export interface ContestItemProps { - id: number; - name: string; - startAt: string; - duration: number; - members: number; - type: 'first' | 'second'; -} - -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 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 ContestItem: React.FC = ({ - id, - name, - startAt, - duration, - members, - type, -}) => { - const navigate = useNavigate(); - - return ( - { - navigate(`/contest/${id}`); - }} - > - {name} - - {/* {authors.map((v, i) => {v})} */} - valavshonok - - - {formatDate(startAt)} - - {formatWaitTime(duration)} - - {members} - - - - { - e.stopPropagation(); - navigate( - `/contest/create?back=/home/account/contests&contestId=${id}`, - ); - }} - /> - - ); -}; - -export default ContestItem; diff --git a/src/views/home/account/contests/PastContestItem.tsx b/src/views/home/account/contests/PastContestItem.tsx new file mode 100644 index 0000000..a05d5dc --- /dev/null +++ b/src/views/home/account/contests/PastContestItem.tsx @@ -0,0 +1,112 @@ +import { cn } from '../../../../lib/cn'; +import { useNavigate } from 'react-router-dom'; +import { useAppSelector } from '../../../../redux/hooks'; +import { useQuery } from '../../../../hooks/useQuery'; + +export interface PastContestItemProps { + name: string; + contestId: number; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; + visibility: string; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + role: string; + type: 'first' | 'second'; +} + +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} мин`; + } +} + +const PastContestItem: React.FC = ({ + name, + contestId, + scheduleType, + startsAt, + endsAt, + attemptDurationMinutes, + type, +}) => { + const navigate = useNavigate(); + + const myname = useAppSelector((state) => state.auth.username); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; + + return ( + { + const params = new URLSearchParams({ + back: '/home/account/contests', + }); + navigate(`/contest/${contestId}?${params}`); + }} + > + {name} + + {username} + + {scheduleType == 'AlwaysOpen' ? ( + + Всегда открыт + + ) : ( + + + {formatDate(startsAt)} + + - + + {formatDate(endsAt)} + + + )} + + + {formatDurationTime(attemptDurationMinutes)} + + + + {scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Завершен'} + + + ); +}; + +export default PastContestItem; diff --git a/src/views/home/account/contests/RegisterContestItem.tsx b/src/views/home/account/contests/RegisterContestItem.tsx deleted file mode 100644 index f8cbf6c..0000000 --- a/src/views/home/account/contests/RegisterContestItem.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { cn } from '../../../../lib/cn'; -import { Account } from '../../../../assets/icons/auth'; -import { PrimaryButton } from '../../../../components/button/PrimaryButton'; -import { ReverseButton } from '../../../../components/button/ReverseButton'; -import { useNavigate } from 'react-router-dom'; - -export interface ContestItemProps { - id: number; - name: string; - startAt: string; - duration: number; - members: number; - statusRegister: 'reg' | 'nonreg'; - type: 'first' | 'second'; -} - -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 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 ContestItem: React.FC = ({ - id, - name, - startAt, - duration, - members, - statusRegister, - type, -}) => { - const navigate = useNavigate(); - - const now = new Date(); - - const waitTime = new Date(startAt).getTime() - now.getTime(); - - return ( - { - navigate(`/contest/${id}`); - }} - > - {name} - - {/* {authors.map((v, i) => {v})} */} - valavshonok - - - {formatDate(startAt)} - - {formatWaitTime(duration)} - {waitTime > 0 && ( - - {'До начала\n' + formatWaitTime(waitTime)} - - )} - - {members} - - - - {statusRegister == 'reg' ? ( - <> - {' '} - {}} text="Регистрация" /> - > - ) : ( - <> - {' '} - {}} text="Вы записаны" /> - > - )} - - - ); -}; - -export default ContestItem; diff --git a/src/views/home/account/contests/UpcomingContestItem.tsx b/src/views/home/account/contests/UpcomingContestItem.tsx new file mode 100644 index 0000000..0bb8adf --- /dev/null +++ b/src/views/home/account/contests/UpcomingContestItem.tsx @@ -0,0 +1,160 @@ +import { cn } from '../../../../lib/cn'; +import { useNavigate } from 'react-router-dom'; +import { useAppSelector } from '../../../../redux/hooks'; +import { useQuery } from '../../../../hooks/useQuery'; +import { toastWarning } from '../../../../lib/toastNotification'; + +export interface UpcoingContestItemProps { + name: string; + contestId: number; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; + visibility: string; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + role: string; + type: 'first' | 'second'; +} + +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 = ({ + name, + contestId, + scheduleType, + startsAt, + endsAt, + attemptDurationMinutes, + type, +}) => { + const navigate = useNavigate(); + + const myname = useAppSelector((state) => state.auth.username); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; + + 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(); + + return ( + { + if (!started) { + toastWarning('Контест еще не начался'); + return; + } + + const params = new URLSearchParams({ + back: '/home/account/contests', + }); + navigate(`/contest/${contestId}?${params}`); + }} + > + {name} + + {username} + + {scheduleType == 'AlwaysOpen' ? ( + + Всегда открыт + + ) : ( + + + {formatDate(startsAt)} + + - + + {formatDate(endsAt)} + + + )} + + + {formatDurationTime(attemptDurationMinutes)} + + + {!started ? ( + + {'До начала\n' + formatWaitTime(waitTime)} + + ) : ( + !finished && ( + + {'До конца\n' + formatWaitTime(waitTime)} + + ) + )} + + + {new Date() < new Date(startsAt) ? ( + <>{'Не начался'}> + ) : ( + <>{scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Идет'}> + )} + + + ); +}; + +export default UpcoingContestItem; diff --git a/src/views/home/account/missions/Missions.tsx b/src/views/home/account/missions/Missions.tsx index 1d8d2fb..b06c5e4 100644 --- a/src/views/home/account/missions/Missions.tsx +++ b/src/views/home/account/missions/Missions.tsx @@ -1,12 +1,15 @@ -import { FC, useEffect } from 'react'; +import { FC, useEffect, useState } from 'react'; import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { setMenuActiveProfilePage } from '../../../../redux/slices/store'; import { cn } from '../../../../lib/cn'; import MissionsBlock from './MissionsBlock'; import { - fetchMyMissions, + deleteMission, setMissionsStatus, } from '../../../../redux/slices/missions'; +import ConfirmModal from '../../../../components/modal/ConfirmModal'; +import { fetchProfileMissions } from '../../../../redux/slices/profile'; +import { useQuery } from '../../../../hooks/useQuery'; interface ItemProps { count: number; @@ -41,12 +44,20 @@ const Item: FC = ({ const Missions = () => { const dispatch = useAppDispatch(); - const missions = useAppSelector((state) => state.missions.missions); - const status = useAppSelector((state) => state.missions.statuses.fetchMy); + + const [modalDeleteTask, setModalDeleteTask] = useState(false); + const [taskdeleteId, setTaskDeleteId] = useState(0); + + const { data: missionData } = useAppSelector( + (state) => state.profile.missions, + ); + + const myname = useAppSelector((state) => state.auth.username); + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; useEffect(() => { dispatch(setMenuActiveProfilePage('missions')); - dispatch(fetchMyMissions()); }, []); useEffect(() => { @@ -62,46 +73,67 @@ const Missions = () => {