+ }>
+ }
+ />
+ }
+ />
+
+
} />
} />
- }
- />
- }
- />
+
} />
} />
diff --git a/src/components/router/ProtectedRoute.tsx b/src/components/router/ProtectedRoute.tsx
index 775a461..c78e309 100644
--- a/src/components/router/ProtectedRoute.tsx
+++ b/src/components/router/ProtectedRoute.tsx
@@ -1,11 +1,15 @@
// src/routes/ProtectedRoute.tsx
-import { Navigate, Outlet } from 'react-router-dom';
+import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAppSelector } from '../../redux/hooks';
export default function ProtectedRoute() {
const isAuthenticated = useAppSelector((state) => !!state.auth.jwt);
+ const location = useLocation();
+
+ console.log('location', location);
+
if (!isAuthenticated) {
- return
;
+ return
;
}
return
;
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 5b3114c..b133b62 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -1,4 +1,4 @@
-// import React from "react";
+// src/pages/Home.tsx
import { Route, Routes } from 'react-router-dom';
import Login from '../views/home/auth/Login';
import Register from '../views/home/auth/Register';
@@ -18,6 +18,7 @@ import ProtectedRoute from '../components/router/ProtectedRoute';
import { MissionsRightPanel } from '../views/home/rightpanel/Missions';
import { ArticlesRightPanel } from '../views/home/rightpanel/Articles';
import { GroupRightPanel } from '../views/home/rightpanel/Group';
+import GroupInvite from '../views/home/groupinviter/GroupInvite';
const Home = () => {
const name = useAppSelector((state) => state.auth.username);
@@ -37,14 +38,18 @@ const Home = () => {
}>
} />
+ }
+ />
+ } />
+ } />
} />
} />
} />
} />
- } />
- } />
} />
} />
{
+ async (_, { dispatch, getState, rejectWithValue }) => {
try {
const response = await axios.get('/authentication/whoami');
return response.data;
} catch (err: any) {
+ const state: any = getState();
+ const refresh = state.auth.refreshToken;
+
+ if (refresh) {
+ // пробуем refresh
+ const result = await dispatch(
+ refreshToken({ refreshToken: refresh }),
+ );
+
+ // если успешный, повторить whoami
+ if (refreshToken.fulfilled.match(result)) {
+ const retry = await axios.get('/authentication/whoami');
+ return retry.data;
+ }
+ }
return rejectWithValue(
err.response?.data?.message || 'Failed to fetch user info',
);
@@ -269,6 +284,23 @@ const authSlice = createSlice({
builder.addCase(fetchWhoAmI.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload as string;
+
+ // Если пользователь не авторизован (401), делаем logout и пытаемся refresh
+ console.log(action);
+ if (
+ action.payload === 'Unauthorized' ||
+ action.payload === 'Failed to fetch user info'
+ ) {
+ // Вызов logout
+ state.jwt = null;
+ state.refreshToken = null;
+ state.username = null;
+ state.email = null;
+ state.id = null;
+ localStorage.removeItem('jwt');
+ localStorage.removeItem('refreshToken');
+ delete axios.defaults.headers.common['Authorization'];
+ }
});
},
});
diff --git a/src/redux/slices/contests.ts b/src/redux/slices/contests.ts
index 1e2970c..5ddff69 100644
--- a/src/redux/slices/contests.ts
+++ b/src/redux/slices/contests.ts
@@ -33,8 +33,6 @@ export interface Submission {
sourceType: string;
}
-
-
export interface Mission {
id: number;
authorId: number;
@@ -124,8 +122,6 @@ interface ContestsState {
status: Status;
error?: string;
};
-
- // 🆕 Добавляем updateContest и deleteContest
updateContest: {
contest: Contest;
status: Status;
@@ -176,7 +172,7 @@ const initialState: ContestsState = {
status: 'idle',
error: undefined,
},
- fetchMySubmissions: {
+ fetchMySubmissions: {
submissions: [],
status: 'idle',
error: undefined,
@@ -262,7 +258,6 @@ export const fetchMySubmissions = createAsyncThunk(
},
);
-
// Все контесты
export const fetchContests = createAsyncThunk(
'contests/fetchAll',
@@ -435,8 +430,6 @@ const contestsSlice = createSlice({
state.fetchMySubmissions.error = action.payload;
});
-
-
// fetchContests
builder.addCase(fetchContests.pending, (state) => {
state.fetchContests.status = 'loading';
diff --git a/src/redux/slices/groups.ts b/src/redux/slices/groups.ts
index e9c586d..38350bb 100644
--- a/src/redux/slices/groups.ts
+++ b/src/redux/slices/groups.ts
@@ -1,7 +1,9 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
-// ─── Типы ────────────────────────────────────────────
+// =====================
+// Типы
+// =====================
type Status = 'idle' | 'loading' | 'successful' | 'failed';
@@ -19,39 +21,106 @@ export interface Group {
contests: any[];
}
+// =====================
+// Состояние
+// =====================
+
interface GroupsState {
- groups: Group[];
- currentGroup: Group | null;
- statuses: {
- create: Status;
- update: Status;
- delete: Status;
- fetchMy: Status;
- fetchById: Status;
- addMember: Status;
- removeMember: Status;
+ fetchMyGroups: {
+ groups: Group[];
+ status: Status;
+ error?: string;
+ };
+ fetchGroupById: {
+ group?: Group;
+ status: Status;
+ error?: string;
+ };
+ createGroup: {
+ group?: Group;
+ status: Status;
+ error?: string;
+ };
+ updateGroup: {
+ group?: Group;
+ status: Status;
+ error?: string;
+ };
+ deleteGroup: {
+ deletedId?: number;
+ status: Status;
+ error?: string;
+ };
+ addGroupMember: {
+ status: Status;
+ error?: string;
+ };
+ removeGroupMember: {
+ status: Status;
+ error?: string;
+ };
+ fetchGroupJoinLink: {
+ joinLink?: { token: string; expiresAt: string };
+ status: Status;
+ error?: string;
+ };
+ joinGroupByToken: {
+ group?: Group;
+ status: Status;
+ error?: string;
};
- error: string | null;
}
const initialState: GroupsState = {
- groups: [],
- currentGroup: null,
- statuses: {
- create: 'idle',
- update: 'idle',
- delete: 'idle',
- fetchMy: 'idle',
- fetchById: 'idle',
- addMember: 'idle',
- removeMember: 'idle',
+ fetchMyGroups: {
+ groups: [],
+ status: 'idle',
+ error: undefined,
+ },
+ fetchGroupById: {
+ group: undefined,
+ status: 'idle',
+ error: undefined,
+ },
+ createGroup: {
+ group: undefined,
+ status: 'idle',
+ error: undefined,
+ },
+ updateGroup: {
+ group: undefined,
+ status: 'idle',
+ error: undefined,
+ },
+ deleteGroup: {
+ deletedId: undefined,
+ status: 'idle',
+ error: undefined,
+ },
+ addGroupMember: {
+ status: 'idle',
+ error: undefined,
+ },
+ removeGroupMember: {
+ status: 'idle',
+ error: undefined,
+ },
+ fetchGroupJoinLink: {
+ joinLink: undefined,
+ status: 'idle',
+ error: undefined,
+ },
+ joinGroupByToken: {
+ group: undefined,
+ status: 'idle',
+ error: undefined,
},
- error: null,
};
-// ─── Async Thunks ─────────────────────────────────────
+// =====================
+// Async Thunks
+// =====================
-// POST /groups
export const createGroup = createAsyncThunk(
'groups/createGroup',
async (
@@ -69,7 +138,6 @@ export const createGroup = createAsyncThunk(
},
);
-// PUT /groups/{groupId}
export const updateGroup = createAsyncThunk(
'groups/updateGroup',
async (
@@ -94,7 +162,6 @@ export const updateGroup = createAsyncThunk(
},
);
-// DELETE /groups/{groupId}
export const deleteGroup = createAsyncThunk(
'groups/deleteGroup',
async (groupId: number, { rejectWithValue }) => {
@@ -109,7 +176,6 @@ export const deleteGroup = createAsyncThunk(
},
);
-// GET /groups/my
export const fetchMyGroups = createAsyncThunk(
'groups/fetchMyGroups',
async (_, { rejectWithValue }) => {
@@ -124,7 +190,6 @@ export const fetchMyGroups = createAsyncThunk(
},
);
-// GET /groups/{groupId}
export const fetchGroupById = createAsyncThunk(
'groups/fetchGroupById',
async (groupId: number, { rejectWithValue }) => {
@@ -139,16 +204,22 @@ export const fetchGroupById = createAsyncThunk(
},
);
-// POST /groups/members
export const addGroupMember = createAsyncThunk(
'groups/addGroupMember',
async (
- { userId, role }: { userId: number; role: string },
+ {
+ groupId,
+ userId,
+ role,
+ }: { groupId: number; userId: number; role: string },
{ rejectWithValue },
) => {
try {
- await axios.post('/groups/members', { userId, role });
- return { userId, role };
+ const response = await axios.post(`/groups/${groupId}/members`, {
+ userId,
+ role,
+ });
+ return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message ||
@@ -158,7 +229,6 @@ export const addGroupMember = createAsyncThunk(
},
);
-// DELETE /groups/{groupId}/members/{memberId}
export const removeGroupMember = createAsyncThunk(
'groups/removeGroupMember',
async (
@@ -176,147 +246,169 @@ export const removeGroupMember = createAsyncThunk(
},
);
-// ─── Slice ────────────────────────────────────────────
+// =====================
+// Новые Async Thunks
+// =====================
+
+// Получение актуальной ссылки для присоединения к группе
+export const fetchGroupJoinLink = createAsyncThunk(
+ 'groups/fetchGroupJoinLink',
+ async (groupId: number, { rejectWithValue }) => {
+ try {
+ 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 ||
+ 'Ошибка при получении ссылки для присоединения',
+ );
+ }
+ },
+);
+
+// Присоединение к группе по токену приглашения
+export const joinGroupByToken = createAsyncThunk(
+ 'groups/joinGroupByToken',
+ async (token: string, { rejectWithValue }) => {
+ try {
+ const response = await axios.post(`/groups/join/${token}`);
+ return response.data as Group;
+ } catch (err: any) {
+ return rejectWithValue(
+ err.response?.data?.message ||
+ 'Ошибка при присоединении к группе по ссылке',
+ );
+ }
+ },
+);
+
+// =====================
+// Slice
+// =====================
const groupsSlice = createSlice({
name: 'groups',
initialState,
reducers: {
- clearCurrentGroup: (state) => {
- state.currentGroup = null;
+ setGroupsStatus: (
+ state,
+ action: PayloadAction<{ key: keyof GroupsState; status: Status }>,
+ ) => {
+ const { key, status } = action.payload;
+ if (state[key]) {
+ (state[key] as any).status = status;
+ }
},
},
extraReducers: (builder) => {
- // ─── CREATE GROUP ───
- builder.addCase(createGroup.pending, (state) => {
- state.statuses.create = 'loading';
- state.error = null;
- });
- builder.addCase(
- createGroup.fulfilled,
- (state, action: PayloadAction) => {
- state.statuses.create = 'successful';
- state.groups.push(action.payload);
- },
- );
- builder.addCase(
- createGroup.rejected,
- (state, action: PayloadAction) => {
- state.statuses.create = 'failed';
- state.error = action.payload;
- },
- );
-
- // ─── UPDATE GROUP ───
- builder.addCase(updateGroup.pending, (state) => {
- state.statuses.update = 'loading';
- state.error = null;
- });
- builder.addCase(
- updateGroup.fulfilled,
- (state, action: PayloadAction) => {
- state.statuses.update = 'successful';
- const index = state.groups.findIndex(
- (g) => g.id === action.payload.id,
- );
- if (index !== -1) state.groups[index] = action.payload;
- if (state.currentGroup?.id === action.payload.id) {
- state.currentGroup = action.payload;
- }
- },
- );
- builder.addCase(
- updateGroup.rejected,
- (state, action: PayloadAction) => {
- state.statuses.update = 'failed';
- state.error = action.payload;
- },
- );
-
- // ─── DELETE GROUP ───
- builder.addCase(deleteGroup.pending, (state) => {
- state.statuses.delete = 'loading';
- state.error = null;
- });
- builder.addCase(
- deleteGroup.fulfilled,
- (state, action: PayloadAction) => {
- state.statuses.delete = 'successful';
- state.groups = state.groups.filter(
- (g) => g.id !== action.payload,
- );
- if (state.currentGroup?.id === action.payload)
- state.currentGroup = null;
- },
- );
- builder.addCase(
- deleteGroup.rejected,
- (state, action: PayloadAction) => {
- state.statuses.delete = 'failed';
- state.error = action.payload;
- },
- );
-
- // ─── FETCH MY GROUPS ───
+ // fetchMyGroups
builder.addCase(fetchMyGroups.pending, (state) => {
- state.statuses.fetchMy = 'loading';
- state.error = null;
+ state.fetchMyGroups.status = 'loading';
});
builder.addCase(
fetchMyGroups.fulfilled,
(state, action: PayloadAction) => {
- state.statuses.fetchMy = 'successful';
- state.groups = action.payload;
- },
- );
- builder.addCase(
- fetchMyGroups.rejected,
- (state, action: PayloadAction) => {
- state.statuses.fetchMy = 'failed';
- state.error = action.payload;
+ state.fetchMyGroups.status = 'successful';
+ state.fetchMyGroups.groups = action.payload;
},
);
+ builder.addCase(fetchMyGroups.rejected, (state, action: any) => {
+ state.fetchMyGroups.status = 'failed';
+ state.fetchMyGroups.error = action.payload;
+ });
- // ─── FETCH GROUP BY ID ───
+ // fetchGroupById
builder.addCase(fetchGroupById.pending, (state) => {
- state.statuses.fetchById = 'loading';
- state.error = null;
+ state.fetchGroupById.status = 'loading';
});
builder.addCase(
fetchGroupById.fulfilled,
(state, action: PayloadAction) => {
- state.statuses.fetchById = 'successful';
- state.currentGroup = action.payload;
- },
- );
- builder.addCase(
- fetchGroupById.rejected,
- (state, action: PayloadAction) => {
- state.statuses.fetchById = 'failed';
- state.error = action.payload;
+ state.fetchGroupById.status = 'successful';
+ state.fetchGroupById.group = action.payload;
},
);
+ builder.addCase(fetchGroupById.rejected, (state, action: any) => {
+ state.fetchGroupById.status = 'failed';
+ state.fetchGroupById.error = action.payload;
+ });
- // ─── ADD MEMBER ───
+ // createGroup
+ builder.addCase(createGroup.pending, (state) => {
+ state.createGroup.status = 'loading';
+ });
+ builder.addCase(
+ createGroup.fulfilled,
+ (state, action: PayloadAction) => {
+ state.createGroup.status = 'successful';
+ state.createGroup.group = action.payload;
+ state.fetchMyGroups.groups.push(action.payload);
+ },
+ );
+ builder.addCase(createGroup.rejected, (state, action: any) => {
+ state.createGroup.status = 'failed';
+ state.createGroup.error = action.payload;
+ });
+
+ // updateGroup
+ builder.addCase(updateGroup.pending, (state) => {
+ state.updateGroup.status = 'loading';
+ });
+ builder.addCase(
+ updateGroup.fulfilled,
+ (state, action: PayloadAction) => {
+ state.updateGroup.status = 'successful';
+ state.updateGroup.group = action.payload;
+ const index = state.fetchMyGroups.groups.findIndex(
+ (g) => g.id === action.payload.id,
+ );
+ if (index !== -1)
+ state.fetchMyGroups.groups[index] = action.payload;
+ if (state.fetchGroupById.group?.id === action.payload.id)
+ state.fetchGroupById.group = action.payload;
+ },
+ );
+ builder.addCase(updateGroup.rejected, (state, action: any) => {
+ state.updateGroup.status = 'failed';
+ state.updateGroup.error = action.payload;
+ });
+
+ // deleteGroup
+ builder.addCase(deleteGroup.pending, (state) => {
+ state.deleteGroup.status = 'loading';
+ });
+ builder.addCase(
+ deleteGroup.fulfilled,
+ (state, action: PayloadAction) => {
+ state.deleteGroup.status = 'successful';
+ state.deleteGroup.deletedId = action.payload;
+ state.fetchMyGroups.groups = state.fetchMyGroups.groups.filter(
+ (g) => g.id !== action.payload,
+ );
+ if (state.fetchGroupById.group?.id === action.payload)
+ state.fetchGroupById.group = undefined;
+ },
+ );
+ builder.addCase(deleteGroup.rejected, (state, action: any) => {
+ state.deleteGroup.status = 'failed';
+ state.deleteGroup.error = action.payload;
+ });
+
+ // addGroupMember
builder.addCase(addGroupMember.pending, (state) => {
- state.statuses.addMember = 'loading';
- state.error = null;
+ state.addGroupMember.status = 'loading';
});
builder.addCase(addGroupMember.fulfilled, (state) => {
- state.statuses.addMember = 'successful';
+ state.addGroupMember.status = 'successful';
+ });
+ builder.addCase(addGroupMember.rejected, (state, action: any) => {
+ state.addGroupMember.status = 'failed';
+ state.addGroupMember.error = action.payload;
});
- builder.addCase(
- addGroupMember.rejected,
- (state, action: PayloadAction) => {
- state.statuses.addMember = 'failed';
- state.error = action.payload;
- },
- );
- // ─── REMOVE MEMBER ───
+ // removeGroupMember
builder.addCase(removeGroupMember.pending, (state) => {
- state.statuses.removeMember = 'loading';
- state.error = null;
+ state.removeGroupMember.status = 'loading';
});
builder.addCase(
removeGroupMember.fulfilled,
@@ -324,27 +416,60 @@ const groupsSlice = createSlice({
state,
action: PayloadAction<{ groupId: number; memberId: number }>,
) => {
- state.statuses.removeMember = 'successful';
+ state.removeGroupMember.status = 'successful';
if (
- state.currentGroup &&
- state.currentGroup.id === action.payload.groupId
+ state.fetchGroupById.group &&
+ state.fetchGroupById.group.id === action.payload.groupId
) {
- state.currentGroup.members =
- state.currentGroup.members.filter(
+ state.fetchGroupById.group.members =
+ state.fetchGroupById.group.members.filter(
(m) => m.userId !== action.payload.memberId,
);
}
},
);
+ builder.addCase(removeGroupMember.rejected, (state, action: any) => {
+ state.removeGroupMember.status = 'failed';
+ state.removeGroupMember.error = action.payload;
+ });
+
+ // fetchGroupJoinLink
+ builder.addCase(fetchGroupJoinLink.pending, (state) => {
+ state.fetchGroupJoinLink.status = 'loading';
+ });
builder.addCase(
- removeGroupMember.rejected,
- (state, action: PayloadAction) => {
- state.statuses.removeMember = 'failed';
- state.error = action.payload;
+ fetchGroupJoinLink.fulfilled,
+ (
+ state,
+ action: PayloadAction<{ token: string; expiresAt: string }>,
+ ) => {
+ state.fetchGroupJoinLink.status = 'successful';
+ state.fetchGroupJoinLink.joinLink = action.payload;
},
);
+ builder.addCase(fetchGroupJoinLink.rejected, (state, action: any) => {
+ state.fetchGroupJoinLink.status = 'failed';
+ state.fetchGroupJoinLink.error = action.payload;
+ });
+
+ // joinGroupByToken
+ builder.addCase(joinGroupByToken.pending, (state) => {
+ state.joinGroupByToken.status = 'loading';
+ });
+ builder.addCase(
+ joinGroupByToken.fulfilled,
+ (state, action: PayloadAction) => {
+ state.joinGroupByToken.status = 'successful';
+ state.joinGroupByToken.group = action.payload;
+ state.fetchMyGroups.groups.push(action.payload); // добавим новую группу в список
+ },
+ );
+ builder.addCase(joinGroupByToken.rejected, (state, action: any) => {
+ state.joinGroupByToken.status = 'failed';
+ state.joinGroupByToken.error = action.payload;
+ });
},
});
-export const { clearCurrentGroup } = groupsSlice.actions;
+export const { setGroupsStatus } = groupsSlice.actions;
export const groupsReducer = groupsSlice.reducer;
diff --git a/src/redux/slices/missions.ts b/src/redux/slices/missions.ts
index 9125402..f3c84ee 100644
--- a/src/redux/slices/missions.ts
+++ b/src/redux/slices/missions.ts
@@ -147,9 +147,6 @@ const missionsSlice = createSlice({
name: 'missions',
initialState,
reducers: {
- clearCurrentMission: (state) => {
- state.currentMission = null;
- },
setMissionsStatus: (
state,
action: PayloadAction<{
@@ -251,5 +248,5 @@ const missionsSlice = createSlice({
},
});
-export const { clearCurrentMission, setMissionsStatus } = missionsSlice.actions;
+export const { setMissionsStatus } = missionsSlice.actions;
export const missionsReducer = missionsSlice.reducer;
diff --git a/src/views/home/auth/Login.tsx b/src/views/home/auth/Login.tsx
index 548be70..d83cea5 100644
--- a/src/views/home/auth/Login.tsx
+++ b/src/views/home/auth/Login.tsx
@@ -1,8 +1,9 @@
+// src/views/home/auth/Login.tsx
import { useState, useEffect } from 'react';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
-import { Link, useNavigate } from 'react-router-dom';
+import { Link, useLocation, useNavigate } from 'react-router-dom';
import { loginUser } from '../../../redux/slices/auth';
// import { cn } from "../../../lib/cn";
import { setMenuActivePage } from '../../../redux/slices/store';
@@ -13,6 +14,7 @@ import { googleLogo } from '../../../assets/icons/input';
const Login = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
+ const location = useLocation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -30,7 +32,9 @@ const Login = () => {
useEffect(() => {
if (jwt) {
- navigate('/home/account'); // или другая страница после входа
+ const from = location.state?.from;
+ const path = from ? from.pathname + from.search : '/home/account';
+ navigate(path, { replace: true });
}
}, [jwt]);
diff --git a/src/views/home/auth/Register.tsx b/src/views/home/auth/Register.tsx
index d341b39..d096422 100644
--- a/src/views/home/auth/Register.tsx
+++ b/src/views/home/auth/Register.tsx
@@ -1,8 +1,9 @@
+// src/views/home/auth/Register.tsx
import { useState, useEffect } from 'react';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
-import { useNavigate } from 'react-router-dom';
+import { useLocation, useNavigate } from 'react-router-dom';
import { registerUser } from '../../../redux/slices/auth';
// import { cn } from "../../../lib/cn";
import { setMenuActivePage } from '../../../redux/slices/store';
@@ -15,6 +16,7 @@ import { googleLogo } from '../../../assets/icons/input';
const Register = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
+ const location = useLocation();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
@@ -32,7 +34,9 @@ const Register = () => {
useEffect(() => {
if (jwt) {
- navigate('/home/account');
+ const from = location.state?.from;
+ const path = from ? from.pathname + from.search : '/home/account';
+ navigate(path, { replace: true });
}
console.log(submitClicked);
}, [jwt]);
diff --git a/src/views/home/groupinviter/GroupInvite.tsx b/src/views/home/groupinviter/GroupInvite.tsx
new file mode 100644
index 0000000..893d817
--- /dev/null
+++ b/src/views/home/groupinviter/GroupInvite.tsx
@@ -0,0 +1,111 @@
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
+import { setMenuActivePage } from '../../../redux/slices/store';
+import { useQuery } from '../../../hooks/useQuery';
+import { PrimaryButton } from '../../../components/button/PrimaryButton';
+import { SecondaryButton } from '../../../components/button/SecondaryButton';
+import {
+ joinGroupByToken,
+ setGroupsStatus,
+} from '../../../redux/slices/groups';
+
+const GroupInvite = () => {
+ const dispatch = useAppDispatch();
+ const navigate = useNavigate();
+
+ const query = useQuery();
+ const token = query.get('token') ?? undefined;
+ const expiresAt = query.get('expiresAt') ?? undefined;
+ const groupName = query.get('groupName') ?? undefined;
+ const groupId = Number(query.get('groupId') ?? undefined);
+
+ const username = useAppSelector((state) => state.auth.username);
+ const joinStatus = useAppSelector(
+ (state) => state.groups.joinGroupByToken.status,
+ );
+ const joinError = useAppSelector(
+ (state) => state.groups.joinGroupByToken.error,
+ );
+
+ useEffect(() => {
+ dispatch(setMenuActivePage('groups'));
+ }, []);
+
+ useEffect(() => {
+ if (joinStatus == 'successful') {
+ dispatch(
+ setGroupsStatus({ key: 'joinGroupByToken', status: 'idle' }),
+ );
+ navigate(`/group/${groupId}`);
+ }
+ }, [joinStatus]);
+
+ if (!token || !expiresAt || !groupName || !groupId) {
+ return (
+
+ Приглашение признано недействительным.
+
+ );
+ }
+
+ const isExpired = new Date(expiresAt) < new Date();
+
+ if (isExpired) {
+ return (
+
+ Период действия приглашения истек.
+
+ );
+ }
+
+ const handleJoin = async () => {
+ if (!token) return;
+ try {
+ await dispatch(joinGroupByToken(token)).unwrap();
+ } catch (err) {
+ console.error('Failed to join group', err);
+ }
+ };
+
+ const handleCancel = () => {
+ navigate('/home/account');
+ };
+
+ return (
+
+
+
+ Привет, {username}!
+
+
+ Вы действительно хотите присоединиться к группе:
+
+
+ "{groupName}"
+
+
+ {joinError && (
+
+ Ошибка присоединения: {joinError}
+
+ )}
+
+
+
+
+ );
+};
+
+export default GroupInvite;
diff --git a/src/views/home/groups/GroupItem.tsx b/src/views/home/groups/GroupItem.tsx
index d7f579f..4a979b0 100644
--- a/src/views/home/groups/GroupItem.tsx
+++ b/src/views/home/groups/GroupItem.tsx
@@ -7,7 +7,7 @@ import {
EyeOpen,
} from '../../../assets/icons/groups';
import { useNavigate } from 'react-router-dom';
-import { GroupUpdate } from './Groups';
+import { GroupInvite, GroupUpdate } from './Groups';
export interface GroupItemProps {
id: number;
@@ -17,6 +17,9 @@ export interface GroupItemProps {
description: string;
setUpdateActive: (value: any) => void;
setUpdateGroup: (value: GroupUpdate) => void;
+ setInviteActive: (value: any) => void;
+ setInviteGroup: (value: GroupInvite) => void;
+ type: 'manage' | 'member';
}
interface IconComponentProps {
@@ -45,6 +48,9 @@ const GroupItem: React.FC = ({
description,
setUpdateGroup,
setUpdateActive,
+ setInviteActive,
+ setInviteGroup,
+ type,
}) => {
const navigate = useNavigate();
@@ -63,10 +69,16 @@ const GroupItem: React.FC = ({
{name}
- {(role == 'menager' || role == 'owner') && (
-
+ {type == 'manage' && (
+
{
+ setInviteActive(true);
+ setInviteGroup({ id, name });
+ }}
+ />
)}
- {(role == 'menager' || role == 'owner') && (
+ {type == 'manage' && (
{
diff --git a/src/views/home/groups/Groups.tsx b/src/views/home/groups/Groups.tsx
index 51650e6..2752d3c 100644
--- a/src/views/home/groups/Groups.tsx
+++ b/src/views/home/groups/Groups.tsx
@@ -8,6 +8,7 @@ import { fetchMyGroups } from '../../../redux/slices/groups';
import ModalCreate from './ModalCreate';
import ModalUpdate from './ModalUpdate';
import Filters from './Filter';
+import ModalInvite from './ModalInvite';
export interface GroupUpdate {
id: number;
@@ -15,19 +16,35 @@ export interface GroupUpdate {
description: string;
}
+export interface GroupInvite {
+ id: number;
+ name: string;
+}
+
const Groups = () => {
- const [modalActive, setModalActive] = useState(false);
- const [modelUpdateActive, setModalUpdateActive] = useState(false);
+ const [modalActive, setModalActive] = useState(false);
+ const [modalUpdateActive, setModalUpdateActive] = useState(false);
const [updateGroup, setUpdateGroup] = useState({
id: 0,
name: '',
description: '',
});
+ const [modalInviteActive, setModalInviteActive] = useState(false);
+ const [inviteGroup, setInviteGroup] = useState({
+ id: 0,
+ name: '',
+ });
const dispatch = useAppDispatch();
- // Берём группы из стора
- const groups = useAppSelector((store) => store.groups.groups);
+ // ✅ Берём группы и статус из нового слайса
+ const groups = useAppSelector((store) => store.groups.fetchMyGroups.groups);
+ const groupsStatus = useAppSelector(
+ (store) => store.groups.fetchMyGroups.status,
+ );
+ const groupsError = useAppSelector(
+ (store) => store.groups.fetchMyGroups.error,
+ );
// Берём текущего пользователя
const currentUserName = useAppSelector((store) => store.auth.username);
@@ -52,8 +69,8 @@ const Groups = () => {
(m) => m.username === currentUserName,
);
if (!me) return;
-
- if (me.role === 'Administrator') {
+ const roles = me.role.split(',').map((r) => r.trim());
+ if (roles.includes('Administrator')) {
managed.push(group);
} else {
current.push(group);
@@ -68,7 +85,7 @@ const Groups = () => {
}, [groups, currentUserName]);
return (
-
+
{
Группы
{
- setModalActive(true);
- }}
+ onClick={() => setModalActive(true)}
text="Создать группу"
className="absolute right-0"
/>
@@ -89,37 +104,67 @@ const Groups = () => {
-
-
-
+ {groupsStatus === 'loading' && (
+
+ Загрузка групп...
+
+ )}
+ {groupsStatus === 'failed' && (
+
+ Ошибка: {groupsError || 'Не удалось загрузить группы'}
+
+ )}
+
+ {groupsStatus === 'successful' && (
+ <>
+
+
+
+ >
+ )}
+
);
};
diff --git a/src/views/home/groups/GroupsBlock.tsx b/src/views/home/groups/GroupsBlock.tsx
index e66d713..2f64f17 100644
--- a/src/views/home/groups/GroupsBlock.tsx
+++ b/src/views/home/groups/GroupsBlock.tsx
@@ -3,7 +3,7 @@ import GroupItem from './GroupItem';
import { cn } from '../../../lib/cn';
import { ChevroneDown } from '../../../assets/icons/groups';
import { Group } from '../../../redux/slices/groups';
-import { GroupUpdate } from './Groups';
+import { GroupInvite, GroupUpdate } from './Groups';
interface GroupsBlockProps {
groups: Group[];
@@ -11,6 +11,9 @@ interface GroupsBlockProps {
className?: string;
setUpdateActive: (value: any) => void;
setUpdateGroup: (value: GroupUpdate) => void;
+ setInviteActive: (value: any) => void;
+ setInviteGroup: (value: GroupInvite) => void;
+ type: 'manage' | 'member';
}
const GroupsBlock: FC
= ({
@@ -19,6 +22,9 @@ const GroupsBlock: FC = ({
className,
setUpdateActive,
setUpdateGroup,
+ setInviteActive,
+ setInviteGroup,
+ type,
}) => {
const [active, setActive] = useState(title != 'Скрытые');
@@ -63,8 +69,11 @@ const GroupsBlock: FC = ({
description={v.description}
setUpdateActive={setUpdateActive}
setUpdateGroup={setUpdateGroup}
+ setInviteActive={setInviteActive}
+ setInviteGroup={setInviteGroup}
role={'owner'}
name={v.name}
+ type={type}
/>
))}
diff --git a/src/views/home/groups/ModalCreate.tsx b/src/views/home/groups/ModalCreate.tsx
index 458c491..ecda215 100644
--- a/src/views/home/groups/ModalCreate.tsx
+++ b/src/views/home/groups/ModalCreate.tsx
@@ -14,7 +14,7 @@ interface ModalCreateProps {
const ModalCreate: FC
= ({ active, setActive }) => {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
- const status = useAppSelector((state) => state.groups.statuses.create);
+ const status = useAppSelector((state) => state.groups.createGroup.status);
const dispatch = useAppDispatch();
useEffect(() => {
diff --git a/src/views/home/groups/ModalInvite.tsx b/src/views/home/groups/ModalInvite.tsx
new file mode 100644
index 0000000..a00cfec
--- /dev/null
+++ b/src/views/home/groups/ModalInvite.tsx
@@ -0,0 +1,102 @@
+import { FC, useEffect, useMemo } from 'react';
+import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
+import { fetchGroupJoinLink } from '../../../redux/slices/groups';
+import { Modal } from '../../../components/modal/Modal';
+import { PrimaryButton } from '../../../components/button/PrimaryButton';
+import { SecondaryButton } from '../../../components/button/SecondaryButton';
+import { Input } from '../../../components/input/Input';
+
+interface ModalInviteProps {
+ active: boolean;
+ setActive: (value: boolean) => void;
+ groupId: number;
+ groupName: string;
+}
+
+const ModalInvite: FC = ({
+ active,
+ setActive,
+ groupId,
+ groupName,
+}) => {
+ const dispatch = useAppDispatch();
+ const baseUrl = window.location.origin;
+
+ // Получаем токен и дату из Redux
+ const { joinLink, status } = useAppSelector(
+ (state) => state.groups.fetchGroupJoinLink,
+ );
+
+ // При открытии модалки запрашиваем join link
+ useEffect(() => {
+ if (active) {
+ dispatch(fetchGroupJoinLink(groupId));
+ }
+ }, [active, groupId, dispatch]);
+
+ // Генерация полной ссылки с query параметрами
+ const inviteLink = useMemo(() => {
+ if (!joinLink) return '';
+ const params = new URLSearchParams({
+ token: joinLink.token,
+ expiresAt: joinLink.expiresAt,
+ groupName,
+ groupId: `${groupId}`,
+ });
+ return `${baseUrl}/home/group-invite?${params.toString()}`;
+ }, [joinLink, groupName, baseUrl, groupId]);
+
+ // Копирование и закрытие модалки
+ const handleCopy = async () => {
+ if (!inviteLink) return;
+ try {
+ await navigator.clipboard.writeText(inviteLink);
+ setActive(false);
+ } catch (err) {
+ console.error('Не удалось скопировать ссылку:', err);
+ }
+ };
+
+ return (
+
+
+
+ Приглашение в группу "{groupName}"
+
+
+
+
+ Ссылка для приглашения
+
+
+ {inviteLink}
+
+
+
+
+
+
setActive(false)}
+ text="Отмена"
+ />
+
+
+
+ );
+};
+
+export default ModalInvite;
diff --git a/src/views/home/groups/ModalUpdate.tsx b/src/views/home/groups/ModalUpdate.tsx
index 9233c9f..5d4ec0a 100644
--- a/src/views/home/groups/ModalUpdate.tsx
+++ b/src/views/home/groups/ModalUpdate.tsx
@@ -24,10 +24,10 @@ const ModalUpdate: FC = ({
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const statusUpdate = useAppSelector(
- (state) => state.groups.statuses.update,
+ (state) => state.groups.updateGroup.status,
);
const statusDelete = useAppSelector(
- (state) => state.groups.statuses.delete,
+ (state) => state.groups.deleteGroup.status,
);
const dispatch = useAppDispatch();