From 7a573aa82eb009df52bbd28728c8c0ff4248095e Mon Sep 17 00:00:00 2001 From: Sivritkin Dmitriy <129217598+velenyx@users.noreply.github.com> Date: Fri, 22 Dec 2023 08:58:08 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20create=20sidebar=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add: :bento: add unregistered user image to public * add: :bento: icons * fix: git stash * feat: ✨ add types for user, team, role, notification * git stash * git stash * feat: ✨ add sidebar * fix: :adhesive_bandage: fix build * fix: :adhesive_bandage: fix build * docs: :memo: add storybook for notification-item and notification-content * docs: :memo: JSDoc for notification-content * docs: :memo: JSDoc for notification-list, add stories, add tests for sort-notifications * fix: save changes * refactor: with new types `notification-item` * refactor: with new types `notification-list` * refactor: with new types `notification-modal` * refactor: with new types `sidebar` * refactor: with new types `sidebar-profile` * fix save * refactor(sidebar): 📦♻️ update typings to use `@teameights/types` * refactor code * save changes * refactor: :recycle: replace hardcoded mock data with generateMockUser and other mock data generators in sidebar and notification components * refactor(sidebar): :art: update styles and typography in sidebar components * feat(icons): :sparkles: update burger-clo se icon in shared assets * feat(sidebar): :sparkles: add user state and image loading skeleton in sidebar-profile * feat(sidebar-profile): :sparkles: replace Image with ImageLoader in sidebar-profile component * feat: merged develop * fix: storybook * feat: added server * fix: linter * fix: reading notifications issues * fix: small issues * feat: added websocket * fix: socket issue * fix: linter issue * fix: sockets / timings issues --------- Co-authored-by: Nikita Mashchenko --- client/next.config.js | 7 +- client/package.json | 5 +- .../images/user-images/unregistered.png | Bin 0 -> 1767 bytes client/src/app/(auth)/login/page.tsx | 2 +- client/src/app/(main)/layout.module.scss | 22 ++ client/src/app/(main)/layout.tsx | 22 ++ client/src/app/(main)/page.tsx | 22 ++ client/src/app/page.tsx | 59 ---- client/src/entities/README.md | 63 ---- .../session/api/useGetNotifications.tsx | 18 ++ .../session/api/useReadNotifications.tsx | 18 ++ client/src/shared/api/socket.ts | 21 ++ client/src/shared/assets/icons/bell.tsx | 27 ++ .../src/shared/assets/icons/burger-close.tsx | 48 +++ client/src/shared/assets/icons/checks.tsx | 20 ++ client/src/shared/assets/icons/index.ts | 10 + client/src/shared/assets/icons/lightning.tsx | 20 ++ client/src/shared/assets/icons/search.tsx | 29 +- client/src/shared/assets/icons/sign-in.tsx | 41 +++ client/src/shared/assets/icons/sign-out.tsx | 34 ++ client/src/shared/assets/icons/trophy.tsx | 11 + client/src/shared/assets/icons/user.tsx | 21 ++ client/src/shared/assets/icons/users.tsx | 35 ++ client/src/shared/constant/client-routes.ts | 8 + client/src/shared/constant/server-routes.ts | 2 + client/src/shared/lib/mock/index.ts | 1 - client/src/shared/lib/mock/notification.ts | 50 +-- client/src/shared/lib/mock/team.ts | 26 -- client/src/shared/lib/mock/user.ts | 21 +- client/src/shared/ui/drawer/drawer.tsx | 2 +- client/src/shared/ui/need-help/need-help.tsx | 7 +- .../ui/select-autocomplete/option/option.tsx | 11 +- .../select-autocomplete.module.scss | 7 - .../single-value/single-value.tsx | 11 +- client/src/widgets/modals/index.ts | 2 +- .../team/desktop/desktop.module.scss | 6 +- .../team/desktop/desktop.stories.tsx | 44 --- .../info-modal/team/desktop/desktop.tsx | 242 +++++++------- .../widgets/modals/info-modal/team/index.ts | 2 +- .../info-modal/team/interfaces/index.ts | 2 +- .../interfaces/info-modal-team-interface.ts | 18 +- .../info-modal/team/phone/phone.module.scss | 16 +- .../info-modal/team/phone/phone.stories.tsx | 44 --- .../modals/info-modal/team/phone/phone.tsx | 300 +++++++++--------- .../widgets/modals/info-modal/team/team.tsx | 62 ++-- .../info-modal/user/desktop/desktop.tsx | 24 +- .../modals/info-modal/user/phone/phone.tsx | 24 +- .../sidebar/config/getSidebarItems.tsx | 28 ++ client/src/widgets/sidebar/index.ts | 1 + .../lib/hooks/useListenToNotifications.tsx | 33 ++ .../notification-content.module.scss | 36 +++ .../notification-content.stories.tsx | 70 ++++ .../notification-content.tsx | 98 ++++++ .../notifications-count.tsx | 49 +++ .../notification-item.module.scss | 85 +++++ .../notification-item.stories.tsx | 41 +++ .../notification-item/notification-item.tsx | 72 +++++ .../notification-item/system-notification.tsx | 41 +++ .../team-invatition-notification.tsx | 58 ++++ .../lib/sort-notifications.test.ts | 51 +++ .../lib/sort-notifications.ts | 34 ++ .../notification-list.module.scss | 24 ++ .../notification-list.stories.tsx | 50 +++ .../notification-list/notification-list.tsx | 101 ++++++ .../desktop-modal-content.tsx | 52 +++ .../mobile-modal-content.tsx | 55 ++++ .../notification-modal.module.scss | 144 +++++++++ .../notification-modal.stories.tsx | 47 +++ .../notification-modal/notification-modal.tsx | 66 ++++ .../ui/sidebar-item/sidebar-item.module.scss | 43 +++ .../ui/sidebar-item/sidebar-item.stories.tsx | 57 ++++ .../sidebar/ui/sidebar-item/sidebar-item.tsx | 62 ++++ .../sidebar-profile.module.scss | 53 ++++ .../sidebar-profile.stories.tsx | 64 ++++ .../ui/sidebar-profile/sidebar-profile.tsx | 102 ++++++ .../sidebar/ui/sidebar/sidebar.module.scss | 203 ++++++++++++ .../sidebar/ui/sidebar/sidebar.stories.tsx | 20 ++ .../widgets/sidebar/ui/sidebar/sidebar.tsx | 142 +++++++++ client/yarn.lock | 117 ++++++- ...ateUser.ts => 1703217496755-CreateUser.ts} | 40 +-- .../libs/database/typeorm-config.service.ts | 3 +- server/src/modules/auth/base/auth.module.ts | 10 +- server/src/modules/auth/base/auth.service.ts | 16 + .../dto/create-notification.dto.ts | 12 +- .../dto/read-notifications.dto.ts | 8 + .../entities/notification.entity.ts | 22 +- .../notifications/notifications.controller.ts | 13 +- .../notifications/notifications.gateway.ts | 6 +- .../notifications/notifications.module.ts | 1 + .../notifications/notifications.service.ts | 61 ++-- .../src/modules/users/entities/user.entity.ts | 7 +- 91 files changed, 3053 insertions(+), 732 deletions(-) create mode 100644 client/public/images/user-images/unregistered.png create mode 100644 client/src/app/(main)/layout.module.scss create mode 100644 client/src/app/(main)/layout.tsx create mode 100644 client/src/app/(main)/page.tsx delete mode 100644 client/src/app/page.tsx delete mode 100644 client/src/entities/README.md create mode 100644 client/src/entities/session/api/useGetNotifications.tsx create mode 100644 client/src/entities/session/api/useReadNotifications.tsx create mode 100644 client/src/shared/api/socket.ts create mode 100644 client/src/shared/assets/icons/bell.tsx create mode 100644 client/src/shared/assets/icons/burger-close.tsx create mode 100644 client/src/shared/assets/icons/checks.tsx create mode 100644 client/src/shared/assets/icons/lightning.tsx create mode 100644 client/src/shared/assets/icons/sign-in.tsx create mode 100644 client/src/shared/assets/icons/sign-out.tsx create mode 100644 client/src/shared/assets/icons/trophy.tsx create mode 100644 client/src/shared/assets/icons/user.tsx create mode 100644 client/src/shared/assets/icons/users.tsx delete mode 100644 client/src/shared/lib/mock/team.ts delete mode 100644 client/src/shared/ui/select/ui/select-autocomplete/select-autocomplete.module.scss delete mode 100644 client/src/widgets/modals/info-modal/team/desktop/desktop.stories.tsx delete mode 100644 client/src/widgets/modals/info-modal/team/phone/phone.stories.tsx create mode 100644 client/src/widgets/sidebar/config/getSidebarItems.tsx create mode 100644 client/src/widgets/sidebar/index.ts create mode 100644 client/src/widgets/sidebar/lib/hooks/useListenToNotifications.tsx create mode 100644 client/src/widgets/sidebar/ui/notification-content/notification-content.module.scss create mode 100644 client/src/widgets/sidebar/ui/notification-content/notification-content.stories.tsx create mode 100644 client/src/widgets/sidebar/ui/notification-content/notification-content.tsx create mode 100644 client/src/widgets/sidebar/ui/notification-content/notifications-count.tsx create mode 100644 client/src/widgets/sidebar/ui/notification-item/notification-item.module.scss create mode 100644 client/src/widgets/sidebar/ui/notification-item/notification-item.stories.tsx create mode 100644 client/src/widgets/sidebar/ui/notification-item/notification-item.tsx create mode 100644 client/src/widgets/sidebar/ui/notification-item/system-notification.tsx create mode 100644 client/src/widgets/sidebar/ui/notification-item/team-invatition-notification.tsx create mode 100644 client/src/widgets/sidebar/ui/notification-list/lib/sort-notifications.test.ts create mode 100644 client/src/widgets/sidebar/ui/notification-list/lib/sort-notifications.ts create mode 100644 client/src/widgets/sidebar/ui/notification-list/notification-list.module.scss create mode 100644 client/src/widgets/sidebar/ui/notification-list/notification-list.stories.tsx create mode 100644 client/src/widgets/sidebar/ui/notification-list/notification-list.tsx create mode 100644 client/src/widgets/sidebar/ui/notification-modal/desktop-modal-content.tsx create mode 100644 client/src/widgets/sidebar/ui/notification-modal/mobile-modal-content.tsx create mode 100644 client/src/widgets/sidebar/ui/notification-modal/notification-modal.module.scss create mode 100644 client/src/widgets/sidebar/ui/notification-modal/notification-modal.stories.tsx create mode 100644 client/src/widgets/sidebar/ui/notification-modal/notification-modal.tsx create mode 100644 client/src/widgets/sidebar/ui/sidebar-item/sidebar-item.module.scss create mode 100644 client/src/widgets/sidebar/ui/sidebar-item/sidebar-item.stories.tsx create mode 100644 client/src/widgets/sidebar/ui/sidebar-item/sidebar-item.tsx create mode 100644 client/src/widgets/sidebar/ui/sidebar-profile/sidebar-profile.module.scss create mode 100644 client/src/widgets/sidebar/ui/sidebar-profile/sidebar-profile.stories.tsx create mode 100644 client/src/widgets/sidebar/ui/sidebar-profile/sidebar-profile.tsx create mode 100644 client/src/widgets/sidebar/ui/sidebar/sidebar.module.scss create mode 100644 client/src/widgets/sidebar/ui/sidebar/sidebar.stories.tsx create mode 100644 client/src/widgets/sidebar/ui/sidebar/sidebar.tsx rename server/src/libs/database/migrations/{1702842748127-CreateUser.ts => 1703217496755-CreateUser.ts} (88%) create mode 100644 server/src/modules/notifications/dto/read-notifications.dto.ts diff --git a/client/next.config.js b/client/next.config.js index 49efab5b2..36570ae1b 100644 --- a/client/next.config.js +++ b/client/next.config.js @@ -3,7 +3,12 @@ const path = require('path'); module.exports = { images: { - domains: ['picsum.photos', 'source.unsplash.com'], + domains: [ + 'teameights-production.s3.amazonaws.com', + 'localhost', + 'picsum.photos', + 'source.unsplash.com', + ], remotePatterns: [ { protocol: 'https', diff --git a/client/package.json b/client/package.json index 17a96d58a..226a7680b 100644 --- a/client/package.json +++ b/client/package.json @@ -26,7 +26,7 @@ "@storybook/addon-styling": "^1.3.6", "@tanstack/react-query": "^5.0.0", "@tanstack/react-query-devtools": "^5.0.1", - "@teameights/types": "^1.1.24", + "@teameights/types": "^1.1.27", "@types/js-cookie": "^3.0.5", "@types/lodash.debounce": "^4.0.7", "@types/node": "20.4.8", @@ -35,6 +35,7 @@ "@uiball/loaders": "^1.3.0", "add": "^2.0.6", "axios": "^1.5.1", + "bufferutil": "^4.0.8", "clsx": "^2.0.0", "eslint": "8.46.0", "eslint-config-next": "13.4.12", @@ -52,9 +53,11 @@ "react-select": "^5.7.4", "react-tooltip": "^5.21.3", "sass": "^1.64.2", + "socket.io-client": "^4.7.2", "sonner": "^1.0.3", "tsparticles": "^2.12.0", "typescript": "5.1.6", + "utf-8-validate": "^6.0.3", "yarn": "^1.22.19" }, "devDependencies": { diff --git a/client/public/images/user-images/unregistered.png b/client/public/images/user-images/unregistered.png new file mode 100644 index 0000000000000000000000000000000000000000..1430ddaa7e7595423a30da06635e1637da907dbe GIT binary patch literal 1767 zcmb7F=RX^Y8jji_)E>205)I0gHdRFuN{nz~wzWs2s`ro(+o_${gbGzHEoy{H&6A^) zs%X^sp*4?PGiG~x)xp(2;eL4DXS|=^=cPDcEkQteAOHXWVXVw?=Nxbje*wPp`Ap?B z=$r&YtZowl03nHA-~yDC%ASW@M4TlG&@iO1ac+3Zz!!@#M><7v zah@JKI$BEhcWJC}MFGTBE-Dqd@?7|@bh)$Kos+s5TuTr~k1i#!5XK{^tZ98A*|O(F z3@J5xS@`*EXT)&I$>5bC1a@$R%3Rx?B0f&HakUin_U;`zMOKUQkz}F5`Z!tNmsj3f z`H=H^E~=-f-U6aCB~$;OMd6-Gf*Ak~hm$ST1=4!=j$$WgX3#ZGJs&>sxg7Equmd8c zhOGqC(Cf`cxz;TUC=W$#ZSCxT+W7hTSwi}a^Dw{A4fxe-*A_Lk+as)8TwVRHjEUC0 zIB^rRY|`WD6eD=r+GSL~64-G_)0hFL@VMLl5K!gbJ3c|^nCu&I9?}m4+4u1J)TdN? zalD(gU^-3T$SBd@QQc`#CNz!b8vN?g{uq98YKqHsGQi8(*_lRbuXAFp85(MY?52g9 zzh|(dTyUs&Dm$}EXggyOFvVO=R=F$70m760IuE7q|LwEgc)HUUPz7o*!mkZmXp8_| zg363_1`+}XjM7uhZyIo)Ebs|I3;uJD@8|QR)3=7cO%TL86p(|~zWiMy(Fr80B+mnG zwkZ#;d(jCp!n7D>$o7ISjp_C=rdQjX!IIlL>;{YF^!fJ{bKj{PpZLs}20MKJrALNJB*e7ddDay;Km@QMP-bAEZ?(oQkBOE|jSDm^ znN5k)T7%uG+$R4k92;`oZ&W+L&rh**f#6>6Y^~Zgv>8Z(cL7BL3vt>-d-vg@B9g!d zlYd`j7x_m;$tk$SAB(t;=mz-sV2;Iaot@RC@P;6n1$53sn654oJl@=21J3MI{gSa! z-+GgMdTLTth3^)W#=WA4S(8V(;A`~8-ycZ?%Y(mu^;+sp`(sh-a)$!r4RL|*5>zty znQ>fd&$RsPb2r%1K6QTepbzBoDYB9vC#qyZau??PKH#NHVE`KqhE=zxUD{e(_t-1k zfBkXBg?-J)@3l9zuV6(QC$4I^);N9WoiAHBns=O>n@a%(7FUFl2l#U)rBGeT90*Sz zZK@**))}AlF2N)$th8Y(#S@R*PP)f-Z;i2_jdqA*L>hCitrF!u zZ(jI>s#4BJt+!N$o;IO@PS1eBa@=>Q^~9R#Lj=eMIxhL8Bq(p=cR%#flOJG>%=e@A zL?Z4~u|;&1puB2(lM{!q=YL~Ok@zk%GBc(VfVi%NO#C~2dH$&Aj0MY<2*&wF%NrEO z@8h{=9~r5xQX%mKWmBJMtt~e?$1pGs7Z}w+h;qZ?HoiX1Jy`P^Zc&cBKQlU3V$UAm zb4YbI)mOwgX2EXUXw-=rW5kYn57F~31qOxTYko?v3mF*FkVFGY_3-IMxze(d;<_jO z(PFIK`6u4c6{0vLdiE?7H8i!=4TtKe{o6KkK5xQP&+Puu9J8%2FX5QVZh(1(MNMaI zx!(5d2vrC>9A#x~sogzXoaj-T?b!#X>`Wh2K5dmr5m@E4 z-pxm22rW(NEZ^WT^W#3=+T1o6rCxt$uf;W4{TXe3mx@Y6dL~to%G1C7@Kev8S6?O< z*z}^8+8_5<^43!Nd>Oz+jgOTMC$-f~A$)NIR%oC~3z33@N}4u!cB~wApTgalq}x@4 z*yrDznM|YK9G7ENRsbFmH0+Na!2(SALiHBuhQ~LL?LrQ=_BX9cCfheArpD2MRJ`MS z)(kl>4vo2)q(iHSi0Xq*TE44IIxYgf%r1JG46F}JtP$758PQ3lcvxS!{tOP%-PZ0T zu>_o}u4hF1+y#294|CEXZRNLw3!h+SaQT=uQmdz0u~Wox`$OoT*|r^{xmIRUtuGyn zDgAy|$cpiX727{8?&6l3);nGi<8=0ECE`>a=qRKy<2d}qri|QVKEd-^re*o&;cLy+ zzb5{~CR*5rR-iTmPL;hG6dfqO5{ib3F7z?a<1Ijz` Ee-~yqg#Z8m literal 0 HcmV?d00001 diff --git a/client/src/app/(auth)/login/page.tsx b/client/src/app/(auth)/login/page.tsx index e4d91d126..d4511bf34 100644 --- a/client/src/app/(auth)/login/page.tsx +++ b/client/src/app/(auth)/login/page.tsx @@ -59,7 +59,7 @@ export default function LoginPage() { placeholder='Password' {...register('password', { required: 'Password is required!', - minLength: { value: 8, message: 'Minimum length is 8!' }, + minLength: { value: 6, message: 'Minimum length is 8!' }, })} error={errors?.password ? errors.password.message : undefined} value={password} diff --git a/client/src/app/(main)/layout.module.scss b/client/src/app/(main)/layout.module.scss new file mode 100644 index 000000000..e19c563e6 --- /dev/null +++ b/client/src/app/(main)/layout.module.scss @@ -0,0 +1,22 @@ +.container { + height: 100dvh; + width: 100%; + padding: 48px 55px; + + @media (width <= 1120px) { + padding: 48px 24px; + } + + @media (width <= 580px) { + padding: 24px; + } +} + +.children { + width: 100%; + min-height: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} diff --git a/client/src/app/(main)/layout.tsx b/client/src/app/(main)/layout.tsx new file mode 100644 index 000000000..f487f0259 --- /dev/null +++ b/client/src/app/(main)/layout.tsx @@ -0,0 +1,22 @@ +'use client'; +import styles from './layout.module.scss'; + +import { Sidebar } from '@/widgets/sidebar'; +import { useGetMe } from '@/entities/session'; +import { useGetNotifications } from '@/entities/session/api/useGetNotifications'; +import { useSocketConnection } from '@/widgets/sidebar/lib/hooks/useListenToNotifications'; +import { ReactNode } from 'react'; + +export default function AuthLayout({ children }: { children: ReactNode }) { + const { data: user } = useGetMe(); + const { data: notifications } = useGetNotifications(); + + useSocketConnection(user); + + return ( +
+ +
{children}
+
+ ); +} diff --git a/client/src/app/(main)/page.tsx b/client/src/app/(main)/page.tsx new file mode 100644 index 000000000..73ba35a47 --- /dev/null +++ b/client/src/app/(main)/page.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { Typography } from '@/shared/ui'; +import { useGetScreenWidth } from '@/shared/lib'; + +export default function Home() { + const width = useGetScreenWidth(); + + return ( + <> + + We are working hard to deliver teameights on NextJS/TS soon! + + +
The screen width is: {width}
+ + + Get to login + + + ); +} diff --git a/client/src/app/page.tsx b/client/src/app/page.tsx deleted file mode 100644 index ee65c07ce..000000000 --- a/client/src/app/page.tsx +++ /dev/null @@ -1,59 +0,0 @@ -'use client'; - -import { Button, SearchBar, Typography } from '@/shared/ui'; -import { useGetScreenWidth } from '@/shared/lib'; - -import { useGetMe, useLogout, useLogin, useUpdateMe, useRegister } from '@/entities/session'; -import { faker } from '@faker-js/faker'; - -export default function Home() { - const width = useGetScreenWidth(); - const { data, isFetching } = useGetMe(); - const { mutate: logout } = useLogout(); - const { mutate: login } = useLogin(); - const { mutate: update } = useUpdateMe(); - const { mutate: register, isPending } = useRegister(); - - return ( - <> - - We are working hard to deliver teameights on NextJS/TS soon! - - -
The screen width is: {width}
- - - Hello, {isFetching ? 'loading...' : data?.email ?? 'Failed to fetch'}! - - - - - - - - - Get to login - - - console.log(1)} - /> - - ); -} diff --git a/client/src/entities/README.md b/client/src/entities/README.md deleted file mode 100644 index 74997090b..000000000 --- a/client/src/entities/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# Entities - -![entities-themed-bordered](https://feature-sliced.design/assets/images/decompose-twitter-7b9a50f879d763c49305b3bf0751ee35.png) - -## Description - -There are usually placed: - -- business entities, for building the business logic of the application - > _For example: `user`, `order`, `post`, `journal`, `navigation`, ..._ -- components with the representation of entities, for building the UI of the overlying layers - > _For example: `UserCard`, `TweetCard`, ..._ - -## Can use - -`shared` - -## Structure - -```sh -└── entities/{slice} - ├── lib/ - ├── model/ - ├── ui/ - └── index.ts -``` - -## Examples - -### Using the Entity Model - -```tsx title=**/**/index.ts -import { viewerModel } from "entities/viewer"; - -export const Wallet = () => { - const viewer = viewerModel.useViewer(); - const { moneyCount } = viewer; - - ... -} -``` - -### Using Entity components - -```ts title=entities/book/index.ts -export { BookCard, ... } from "./ui"; -export * as bookModel from "./model"; -``` - -```tsx title=pages/**/index.ts -import { BookCard } from "entities/book"; - -export const CatalogPage = () => { - const bookQuery = ...; - return ( - ... - {bookQuery.map((book) => ( - - ))} - ... - ) -} -``` diff --git a/client/src/entities/session/api/useGetNotifications.tsx b/client/src/entities/session/api/useGetNotifications.tsx new file mode 100644 index 000000000..38b8e7f2b --- /dev/null +++ b/client/src/entities/session/api/useGetNotifications.tsx @@ -0,0 +1,18 @@ +'use client'; +import { useQuery } from '@tanstack/react-query'; +import { InfinityPaginationResultType, NotificationType } from '@teameights/types'; +import { API } from '@/shared/api'; +import { API_NOTIFICATIONS } from '@/shared/constant'; + +export const useGetNotifications = () => { + return useQuery({ + queryKey: ['useGetNotifications'], + queryFn: async () => { + const { data } = + await API.get>(API_NOTIFICATIONS); + return data; + }, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); +}; diff --git a/client/src/entities/session/api/useReadNotifications.tsx b/client/src/entities/session/api/useReadNotifications.tsx new file mode 100644 index 000000000..4f849d6c3 --- /dev/null +++ b/client/src/entities/session/api/useReadNotifications.tsx @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { API } from '@/shared/api'; +import { API_NOTIFICATIONS } from '@/shared/constant'; + +export const useReadNotifications = () => { + const queryClient = useQueryClient(); + return useMutation({ + // TODO: add types here and change to read all notifications + mutationFn: async (data: string[]) => + await API.patch(API_NOTIFICATIONS, { notification_ids: data }), + onSuccess: () => { + // Invalidate and refetch + queryClient + .invalidateQueries({ queryKey: ['useGetNotifications'] }) + .then(() => console.log('invalidated')); + }, + }); +}; diff --git a/client/src/shared/api/socket.ts b/client/src/shared/api/socket.ts new file mode 100644 index 000000000..070cc1048 --- /dev/null +++ b/client/src/shared/api/socket.ts @@ -0,0 +1,21 @@ +import { io, Socket } from 'socket.io-client'; + +class SocketSingleton { + private static instance: Socket | null = null; + + private constructor() { + // Private constructor to prevent instantiation + } + + public static getInstance(): Socket { + if (!this.instance) { + // Create a new socket instance if it doesn't exist + this.instance = io('ws://localhost:3001', { + autoConnect: false, + }); + } + return this.instance; + } +} + +export const socket = SocketSingleton.getInstance(); diff --git a/client/src/shared/assets/icons/bell.tsx b/client/src/shared/assets/icons/bell.tsx new file mode 100644 index 000000000..119a0dea3 --- /dev/null +++ b/client/src/shared/assets/icons/bell.tsx @@ -0,0 +1,27 @@ +import { FC, SVGProps } from 'react'; + +export const BellIcon: FC> = props => ( + + + + + + + + + + + +); diff --git a/client/src/shared/assets/icons/burger-close.tsx b/client/src/shared/assets/icons/burger-close.tsx new file mode 100644 index 000000000..9fa38c86d --- /dev/null +++ b/client/src/shared/assets/icons/burger-close.tsx @@ -0,0 +1,48 @@ +import { FC, SVGProps } from 'react'; + +export const BurgerCloseIcon: FC> = props => ( + + + + + + + + + + + + + +); diff --git a/client/src/shared/assets/icons/checks.tsx b/client/src/shared/assets/icons/checks.tsx new file mode 100644 index 000000000..d997129ac --- /dev/null +++ b/client/src/shared/assets/icons/checks.tsx @@ -0,0 +1,20 @@ +import React, { FC, SVGProps } from 'react'; + +export const ChecksIcon: FC> = props => ( + + + + + + + + + + +); diff --git a/client/src/shared/assets/icons/index.ts b/client/src/shared/assets/icons/index.ts index 9d1adb5b7..1c7bd2940 100644 --- a/client/src/shared/assets/icons/index.ts +++ b/client/src/shared/assets/icons/index.ts @@ -10,6 +10,16 @@ export { XIcon } from './x'; export { SearchIcon } from './search'; export { UserPlusIcon } from './user-plus'; export { ChatCircleDotsIcon } from './chat-circle-dots'; +export { BellIcon } from './bell'; +export { ChecksIcon } from './checks'; +export { LightningIcon } from './lightning'; +export { BurgerCloseIcon } from './burger-close'; +export { SignInIcon } from './sign-in'; +export { SignOutIcon } from './sign-out'; +export { TrophyIcon } from './trophy'; +export { UserIcon } from './user'; +export { UsersIcon } from './users'; + export * from './arrows'; export * from './caret'; export * from './crowns'; diff --git a/client/src/shared/assets/icons/lightning.tsx b/client/src/shared/assets/icons/lightning.tsx new file mode 100644 index 000000000..38d4aee79 --- /dev/null +++ b/client/src/shared/assets/icons/lightning.tsx @@ -0,0 +1,20 @@ +import { FC, SVGProps } from 'react'; + +export const LightningIcon: FC> = props => ( + + + + + + + + + + +); diff --git a/client/src/shared/assets/icons/search.tsx b/client/src/shared/assets/icons/search.tsx index 307ca3a61..5f55bc535 100644 --- a/client/src/shared/assets/icons/search.tsx +++ b/client/src/shared/assets/icons/search.tsx @@ -1,17 +1,36 @@ import { SVGPropsWithSize } from '@/shared/types/svg-props-with-size'; import { FC } from 'react'; -export const SearchIcon: FC = ({ size = '20', ...rest }) => { +interface WithColor { + color?: 'white' | '#5BD424'; +} + +export const SearchIcon: FC = ({ + size = '20', + color = 'white', + ...rest +}) => { return ( - + + ); }; diff --git a/client/src/shared/assets/icons/sign-in.tsx b/client/src/shared/assets/icons/sign-in.tsx new file mode 100644 index 000000000..ab5ff55b9 --- /dev/null +++ b/client/src/shared/assets/icons/sign-in.tsx @@ -0,0 +1,41 @@ +import { FC, SVGProps } from 'react'; + +export const SignInIcon: FC> = props => ( + + + + + + + + + + + + +); diff --git a/client/src/shared/assets/icons/sign-out.tsx b/client/src/shared/assets/icons/sign-out.tsx new file mode 100644 index 000000000..a078fc1f4 --- /dev/null +++ b/client/src/shared/assets/icons/sign-out.tsx @@ -0,0 +1,34 @@ +import { FC, SVGProps } from 'react'; + +export const SignOutIcon: FC> = props => ( + + + + + + + + + + + + +); diff --git a/client/src/shared/assets/icons/trophy.tsx b/client/src/shared/assets/icons/trophy.tsx new file mode 100644 index 000000000..0cd02cb15 --- /dev/null +++ b/client/src/shared/assets/icons/trophy.tsx @@ -0,0 +1,11 @@ +import React, { FC } from 'react'; +import { SVGPropsWithSize } from '@/shared/types/svg-props-with-size'; + +export const TrophyIcon: FC = ({ size = '24', ...rest }) => ( + + + +); diff --git a/client/src/shared/assets/icons/user.tsx b/client/src/shared/assets/icons/user.tsx new file mode 100644 index 000000000..96a6d54cd --- /dev/null +++ b/client/src/shared/assets/icons/user.tsx @@ -0,0 +1,21 @@ +import { FC } from 'react'; +import { SVGPropsWithSize } from '@/shared/types/svg-props-with-size'; + +export const UserIcon: FC = ({ size = '24', ...rest }) => ( + + + + +); diff --git a/client/src/shared/assets/icons/users.tsx b/client/src/shared/assets/icons/users.tsx new file mode 100644 index 000000000..0820c45ce --- /dev/null +++ b/client/src/shared/assets/icons/users.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react'; +import { SVGPropsWithSize } from '@/shared/types/svg-props-with-size'; + +export const UsersIcon: FC = ({ size = '24', ...rest }) => ( + + + + + + +); diff --git a/client/src/shared/constant/client-routes.ts b/client/src/shared/constant/client-routes.ts index 8a97b4223..87e35ea77 100644 --- a/client/src/shared/constant/client-routes.ts +++ b/client/src/shared/constant/client-routes.ts @@ -8,3 +8,11 @@ export const PASSWORD_EXPIRED = '/password/expired'; export const SIGNUP_CONFIRMATION = '/signup/confirmation'; export const LOGIN = '/login'; + +export const SIGNUP = '/signup'; + +export const TEAM = '/team'; + +export const PROFILE = '/profile'; + +export const TOURNAMENTS = '/tournaments'; diff --git a/client/src/shared/constant/server-routes.ts b/client/src/shared/constant/server-routes.ts index 555cb6fca..72e465222 100644 --- a/client/src/shared/constant/server-routes.ts +++ b/client/src/shared/constant/server-routes.ts @@ -10,3 +10,5 @@ export const API_LOGOUT = '/auth/logout'; export const API_GOOGLE_LOGIN = '/auth/google/login'; export const API_GITHUB_LOGIN = '/auth/github/login'; export const API_USERS = '/users'; + +export const API_NOTIFICATIONS = '/notifications'; diff --git a/client/src/shared/lib/mock/index.ts b/client/src/shared/lib/mock/index.ts index 1d72749fe..c8ea9378e 100644 --- a/client/src/shared/lib/mock/index.ts +++ b/client/src/shared/lib/mock/index.ts @@ -1,3 +1,2 @@ -export * from './team'; export * from './user'; export * from './notification'; diff --git a/client/src/shared/lib/mock/notification.ts b/client/src/shared/lib/mock/notification.ts index 0cbbddb95..01257b46b 100644 --- a/client/src/shared/lib/mock/notification.ts +++ b/client/src/shared/lib/mock/notification.ts @@ -1,44 +1,26 @@ import { faker } from '@faker-js/faker'; -import { getRandomItemFromArray } from './common'; -import { - ISystemNotification, - ITeam, - ITeamInvitationNotification, - IUserBase, - StatusType, -} from '@teameights/types'; -import { generateMockFileEntity, generateMockUser } from './user'; -import { generateMockTeam } from './team'; +import { ISystemNotification, IUserBase, IUserProtectedResponse } from '@teameights/types'; +import { generateMockUser } from './user'; -export const generateSystemNotification = (initialUser?: IUserBase): ISystemNotification => ({ +export const generateSystemNotification = ( + initialUser?: IUserBase | IUserProtectedResponse +): ISystemNotification => ({ id: faker.number.int(), - user: initialUser ? initialUser : generateMockUser(), + receiver: initialUser ? initialUser : generateMockUser(), type: 'system', read: faker.datatype.boolean(), - expiresAt: faker.date.future(), createdAt: faker.date.recent(), updatedAt: faker.date.recent(), - system_message: faker.lorem.sentence(), deletedAt: faker.date.recent(), + data: { + system_message: faker.lorem.sentence(), + }, }); -export const generateTeamInvitationNotification = ( - initialUser?: IUserBase, - initialTeam?: ITeam, - initialFromUser?: IUserBase -): ITeamInvitationNotification => ({ - id: faker.number.int(), - user: initialUser ? initialUser : generateMockUser(), - type: 'team_invite', - read: faker.datatype.boolean(), - expiresAt: faker.date.future(), - createdAt: faker.date.recent(), - updatedAt: faker.date.recent(), - team: initialTeam ? initialTeam : generateMockTeam(initialTeam), - from_user: initialFromUser ? initialFromUser : generateMockUser(), - to_user_email: faker.internet.email(), - status: getRandomItemFromArray(['pending', 'accepted', 'rejected']) as StatusType, - photo: generateMockFileEntity(), - message: faker.lorem.sentence(), - deletedAt: faker.date.recent(), -}); +export const generateMockNotifications = ( + count: number, + initialUser?: IUserBase | IUserProtectedResponse +): ISystemNotification[] => { + // todo: add support for other types + return Array.from({ length: count }).map(() => generateSystemNotification(initialUser)); +}; diff --git a/client/src/shared/lib/mock/team.ts b/client/src/shared/lib/mock/team.ts deleted file mode 100644 index cc11bd5da..000000000 --- a/client/src/shared/lib/mock/team.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { faker } from '@faker-js/faker'; -import { ITeam, IUserBase, TeamType } from '@teameights/types'; -import { generateMockFileEntity, generateMockUser, generateMockUsers } from './user'; -import { getRandomItemFromArray } from './common'; - -export const generateMockTeam = ( - initialLeader?: IUserBase, - initialMembers?: IUserBase[] -): ITeam => { - return { - id: faker.number.int(), - name: faker.word.words(), - description: faker.datatype.boolean() ? faker.lorem.sentence() : null, - leader: initialLeader ? initialLeader : generateMockUser(), - members: initialMembers ? initialMembers : generateMockUsers(5), - country: faker.location.country(), - tag: faker.lorem.word(), - type: getRandomItemFromArray(['invite_only', 'closed', 'open']) as TeamType, - wins: faker.number.int({ min: 0, max: 100 }), - points: faker.number.int({ min: 0, max: 1000 }), - photo: faker.datatype.boolean() ? generateMockFileEntity() : null, - createdAt: faker.date.recent(), - updatedAt: faker.date.recent(), - deletedAt: faker.datatype.boolean() ? faker.date.recent() : null, - }; -}; diff --git a/client/src/shared/lib/mock/user.ts b/client/src/shared/lib/mock/user.ts index 68a04bede..9960d2535 100644 --- a/client/src/shared/lib/mock/user.ts +++ b/client/src/shared/lib/mock/user.ts @@ -7,10 +7,9 @@ import { IProject, IRole, IStatus, - ITeam, IUniversity, IUserBase, - NotificationType, + IUserProtectedResponse, } from '@teameights/types'; import { getRandomItemFromArray, shuffleArray } from './common'; import { @@ -115,11 +114,7 @@ export const generateMockUniversity = (): IUniversity => ({ graduationDate: faker.datatype.boolean() ? faker.date.past() : null, }); -export const generateMockUser = ( - type: Speciality = 'developer', - initialTeam?: ITeam, - initialNotifications?: NotificationType[] -): IUserBase => { +export const generateMockUser = (type: Speciality = 'developer'): IUserBase => { const user: IUserBase = { id: faker.number.int(), username: faker.internet.userName(), @@ -150,9 +145,6 @@ export const generateMockUser = ( ), links: faker.datatype.boolean() ? generateMockLinks() : null, skills: null, - notifications: initialNotifications ? initialNotifications : [], - // to avoid dead lock we can't generate team here, we will need to add team - team: initialTeam ? initialTeam : null, createdAt: faker.date.recent(), updatedAt: faker.date.recent(), deletedAt: faker.datatype.boolean() ? faker.date.recent() : null, @@ -181,6 +173,15 @@ export const generateMockUser = ( return user; }; +export const addProtectedFields = (user: IUserBase): IUserProtectedResponse => { + return { + ...user, + email: faker.internet.email(), + provider: 'email', + socialId: null, + }; +}; + export const generateMockUsers = (count: number): IUserBase[] => { return Array.from({ length: count }).map(() => generateMockUser()); }; diff --git a/client/src/shared/ui/drawer/drawer.tsx b/client/src/shared/ui/drawer/drawer.tsx index 66bc5bdfd..45ad98ca0 100644 --- a/client/src/shared/ui/drawer/drawer.tsx +++ b/client/src/shared/ui/drawer/drawer.tsx @@ -66,7 +66,7 @@ export const Drawer: FC> = props => { return ( { > If you have any issues, please email
us at{' '} - - helpteameights@gmail.com - + + helpteameights@gmail.com +
diff --git a/client/src/shared/ui/select/ui/select-autocomplete/option/option.tsx b/client/src/shared/ui/select/ui/select-autocomplete/option/option.tsx index 03439bbe7..c85eeafe2 100644 --- a/client/src/shared/ui/select/ui/select-autocomplete/option/option.tsx +++ b/client/src/shared/ui/select/ui/select-autocomplete/option/option.tsx @@ -1,13 +1,18 @@ import { components, OptionProps } from 'react-select'; import { OptionType } from '../select-autocomplete'; -import { Flex } from '@/shared/ui'; -import styles from '../select-autocomplete.module.scss'; +import { Flex, ImageLoader } from '@/shared/ui'; export const Option = (props: OptionProps) => { return ( - image + {props.data.label} diff --git a/client/src/shared/ui/select/ui/select-autocomplete/select-autocomplete.module.scss b/client/src/shared/ui/select/ui/select-autocomplete/select-autocomplete.module.scss deleted file mode 100644 index 4b5f147b6..000000000 --- a/client/src/shared/ui/select/ui/select-autocomplete/select-autocomplete.module.scss +++ /dev/null @@ -1,7 +0,0 @@ -.image { - width: 24px; - height: 24px; - border-radius: 50px; - object-fit: cover; - user-select: none; -} diff --git a/client/src/shared/ui/select/ui/select-autocomplete/single-value/single-value.tsx b/client/src/shared/ui/select/ui/select-autocomplete/single-value/single-value.tsx index 8597385c7..51e5ca6b4 100644 --- a/client/src/shared/ui/select/ui/select-autocomplete/single-value/single-value.tsx +++ b/client/src/shared/ui/select/ui/select-autocomplete/single-value/single-value.tsx @@ -1,12 +1,17 @@ import { components, SingleValueProps } from 'react-select'; -import { Flex } from '@/shared/ui'; -import styles from '../select-autocomplete.module.scss'; +import { Flex, ImageLoader } from '@/shared/ui'; import { OptionType } from '../select-autocomplete'; export const SingleValue = ({ children, ...props }: SingleValueProps) => ( - image + {children} diff --git a/client/src/widgets/modals/index.ts b/client/src/widgets/modals/index.ts index dc058ce13..076709385 100644 --- a/client/src/widgets/modals/index.ts +++ b/client/src/widgets/modals/index.ts @@ -1,3 +1,3 @@ export { ActionModal } from './action-modal/action-modal'; -export { TeamInfoModal } from './info-modal/team'; +// export { TeamInfoModal } from './info-modal/team'; export { UserInfoModal } from './info-modal/user'; diff --git a/client/src/widgets/modals/info-modal/team/desktop/desktop.module.scss b/client/src/widgets/modals/info-modal/team/desktop/desktop.module.scss index dfa9a446b..49ac9522a 100644 --- a/client/src/widgets/modals/info-modal/team/desktop/desktop.module.scss +++ b/client/src/widgets/modals/info-modal/team/desktop/desktop.module.scss @@ -1,3 +1,3 @@ -.span_text { - color: var(--green-bright-color); -} +//.span_text { +// color: var(--green-bright-color); +//} diff --git a/client/src/widgets/modals/info-modal/team/desktop/desktop.stories.tsx b/client/src/widgets/modals/info-modal/team/desktop/desktop.stories.tsx deleted file mode 100644 index 1547f1d4b..000000000 --- a/client/src/widgets/modals/info-modal/team/desktop/desktop.stories.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta } from '@storybook/react'; -import { TeamDesktop } from './desktop'; -import { Button } from '@/shared/ui'; -import { useState } from 'react'; -import { generateMockTeam, generateMockUser } from '@/shared/lib/mock'; - -const meta: Meta = { - title: 'widgets/modals/info/team/desktop', - component: TeamDesktop, - tags: ['autodocs'], - argTypes: {}, -}; - -export default meta; - -const handleJoin = () => { - console.log('Join button clicked'); -}; - -export const InfoModalTeam_desktop = () => { - const [openModal, setOpenModal] = useState(false); - const team = generateMockTeam(); - const user = generateMockUser(); - const openModalNew = () => { - setOpenModal(true); - }; - const closeModalNew = () => { - setOpenModal(false); - }; - return ( -
- - -
- ); -}; diff --git a/client/src/widgets/modals/info-modal/team/desktop/desktop.tsx b/client/src/widgets/modals/info-modal/team/desktop/desktop.tsx index 6780827de..e8762ac0a 100644 --- a/client/src/widgets/modals/info-modal/team/desktop/desktop.tsx +++ b/client/src/widgets/modals/info-modal/team/desktop/desktop.tsx @@ -1,121 +1,121 @@ -import { FC } from 'react'; -import styles from './desktop.module.scss'; -import { Typography, Button, Modal, Flex } from '@/shared/ui'; -import { ArrowRightIcon } from '@/shared/assets'; -import { InfoModalTeamProps } from '../interfaces'; -import { ImageLoader } from '@/shared/ui/image-loader/image-loader'; -import { capitalize, getCountryFlag } from '@/shared/lib'; - -const mockNavigate = (path: string) => { - console.log(`navigate to ${path}`); - // TODO: add real navigation here -}; - -export const TeamDesktop: FC = ({ - user, - team, - handleJoin, - isOpenModal, - handleClose, -}) => { - return ( - <> - - - - - - - - - {team?.name} - - - - {capitalize(team?.type)} Type, {team?.country} - - - - - - - - - Tournaments: 0 - - - Wins: {team?.wins} - - - Points: {team?.points} - - - {team?.description && ( - - {team?.description} - - )} - - -
- {team?.members.length && ( - - {team?.members?.map((teammate, index) => ( - - ))} - - )} -
-
- - - - - -
-
- - ); -}; +// import { FC } from 'react'; +// import styles from './desktop.module.scss'; +// import { Typography, Button, Modal, Flex } from '@/shared/ui'; +// import { ArrowRightIcon } from '@/shared/assets'; +// import { InfoModalTeamProps } from '../interfaces'; +// import { ImageLoader } from '@/shared/ui/image-loader/image-loader'; +// import { capitalize, getCountryFlag } from '@/shared/lib'; +// +// const mockNavigate = (path: string) => { +// console.log(`navigate to ${path}`); +// // TODO: add real navigation here +// }; +// +// export const TeamDesktop: FC = ({ +// user, +// team, +// handleJoin, +// isOpenModal, +// handleClose, +// }) => { +// return ( +// <> +// +// +// +// +// +// +// +// +// {team?.name} +// +// +// +// {capitalize(team?.type)} Type, {team?.country} +// +// +// +// +// +// +// +// +// Tournaments: 0 +// +// +// Wins: {team?.wins} +// +// +// Points: {team?.points} +// +// +// {team?.description && ( +// +// {team?.description} +// +// )} +// +// +//
+// {team?.members.length && ( +// +// {team?.members?.map((teammate, index) => ( +// +// ))} +// +// )} +//
+//
+// +// +// +// +// +//
+//
+// +// ); +// }; diff --git a/client/src/widgets/modals/info-modal/team/index.ts b/client/src/widgets/modals/info-modal/team/index.ts index e13591428..307c226c5 100644 --- a/client/src/widgets/modals/info-modal/team/index.ts +++ b/client/src/widgets/modals/info-modal/team/index.ts @@ -1 +1 @@ -export { TeamInfoModal } from './team'; +// export { TeamInfoModal } from './team'; diff --git a/client/src/widgets/modals/info-modal/team/interfaces/index.ts b/client/src/widgets/modals/info-modal/team/interfaces/index.ts index 6ac33b33e..af8afc4c5 100644 --- a/client/src/widgets/modals/info-modal/team/interfaces/index.ts +++ b/client/src/widgets/modals/info-modal/team/interfaces/index.ts @@ -1 +1 @@ -export { type InfoModalTeamProps } from './info-modal-team-interface'; +// export { type InfoModalTeamProps } from './info-modal-team-interface'; diff --git a/client/src/widgets/modals/info-modal/team/interfaces/info-modal-team-interface.ts b/client/src/widgets/modals/info-modal/team/interfaces/info-modal-team-interface.ts index 143cf3fd0..8a8f67cc2 100644 --- a/client/src/widgets/modals/info-modal/team/interfaces/info-modal-team-interface.ts +++ b/client/src/widgets/modals/info-modal/team/interfaces/info-modal-team-interface.ts @@ -1,9 +1,9 @@ -import { ITeam, IUserResponse } from '@teameights/types'; - -export interface InfoModalTeamProps { - team?: ITeam; - user?: IUserResponse; - isOpenModal: boolean; - handleClose: () => void; - handleJoin: () => void; -} +// import { IUserResponse } from '@teameights/types'; +// +// export interface InfoModalTeamProps { +// team?: ITeam; +// user?: IUserResponse; +// isOpenModal: boolean; +// handleClose: () => void; +// handleJoin: () => void; +// } diff --git a/client/src/widgets/modals/info-modal/team/phone/phone.module.scss b/client/src/widgets/modals/info-modal/team/phone/phone.module.scss index f8722485b..85bfa6507 100644 --- a/client/src/widgets/modals/info-modal/team/phone/phone.module.scss +++ b/client/src/widgets/modals/info-modal/team/phone/phone.module.scss @@ -1,8 +1,8 @@ -.span_text { - color: var(--green-bright-color); -} - -.container { - overflow: auto; -} - +//.span_text { +// color: var(--green-bright-color); +//} +// +//.container { +// overflow: auto; +//} +// diff --git a/client/src/widgets/modals/info-modal/team/phone/phone.stories.tsx b/client/src/widgets/modals/info-modal/team/phone/phone.stories.tsx deleted file mode 100644 index 76b9bf282..000000000 --- a/client/src/widgets/modals/info-modal/team/phone/phone.stories.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta } from '@storybook/react'; -import { TeamPhone } from './phone'; -import { useState } from 'react'; -import { Button } from '@/shared/ui'; -import { generateMockTeam, generateMockUser } from '@/shared/lib/mock'; - -const meta: Meta = { - title: 'widgets/modals/info/team/phone', - component: TeamPhone, - tags: ['autodocs'], - argTypes: {}, -}; - -export default meta; - -const handleJoin = () => { - console.log('Join button clicked'); -}; - -export const InfoModalTeam_phone = () => { - const team = generateMockTeam(); - const user = generateMockUser(); - const [openModal, setOpenModal] = useState(false); - const openModalNew = () => { - setOpenModal(true); - }; - const closeModalNew = () => { - setOpenModal(false); - }; - return ( -
- - -
- ); -}; diff --git a/client/src/widgets/modals/info-modal/team/phone/phone.tsx b/client/src/widgets/modals/info-modal/team/phone/phone.tsx index 9927410be..87bfc5d3c 100644 --- a/client/src/widgets/modals/info-modal/team/phone/phone.tsx +++ b/client/src/widgets/modals/info-modal/team/phone/phone.tsx @@ -1,150 +1,150 @@ -import { ArrowLeftIcon, ArrowRightIcon } from '@/shared/assets'; -import { Button, Drawer, Flex, Typography } from '@/shared/ui'; -import { FC } from 'react'; -import styles from './phone.module.scss'; -import { InfoModalTeamProps } from '../interfaces'; -import { ImageLoader } from '@/shared/ui/image-loader/image-loader'; -import { capitalize, getCountryFlag } from '@/shared/lib'; - -export const TeamPhone: FC = ({ - team, - user, - isOpenModal, - handleJoin, - handleClose, -}) => { - return ( - <> - - - - - - - - - - - - - {team?.name} - - - - - {capitalize(team?.type)} Type, {team?.country} - - - - - - - - Tournaments: 0 - - - Wins: {team?.wins} - - - Points: {team?.points} - - - - - - {team?.description && ( - - {team.description} - - )} - - - - - - - - {team?.leader?.username} - - - - - {team?.leader?.speciality} - - - - {team?.members?.map((teammate, index) => ( - - - - - {teammate?.username} - - - - {teammate?.speciality} - - - - ))} - - - - - ); -}; +// import { ArrowLeftIcon, ArrowRightIcon } from '@/shared/assets'; +// import { Button, Drawer, Flex, Typography } from '@/shared/ui'; +// import { FC } from 'react'; +// import styles from './phone.module.scss'; +// import { InfoModalTeamProps } from '../interfaces'; +// import { ImageLoader } from '@/shared/ui/image-loader/image-loader'; +// import { capitalize, getCountryFlag } from '@/shared/lib'; +// +// export const TeamPhone: FC = ({ +// team, +// user, +// isOpenModal, +// handleJoin, +// handleClose, +// }) => { +// return ( +// <> +// +// +// +// +// +// +// +// +// +// +// +// +// {team?.name} +// +// +// +// +// {capitalize(team?.type)} Type, {team?.country} +// +// +// +// +// +// +// +// Tournaments: 0 +// +// +// Wins: {team?.wins} +// +// +// Points: {team?.points} +// +// +// +// +// +// {team?.description && ( +// +// {team.description} +// +// )} +// +// +// +// +// +// +// +// {team?.leader?.username} +// +// +// +// +// {team?.leader?.speciality} +// +// +// +// {team?.members?.map((teammate, index) => ( +// +// +// +// +// {teammate?.username} +// +// +// +// {teammate?.speciality} +// +// +// +// ))} +// +// +// +// +// ); +// }; diff --git a/client/src/widgets/modals/info-modal/team/team.tsx b/client/src/widgets/modals/info-modal/team/team.tsx index 4d0a96a52..97242e7dd 100644 --- a/client/src/widgets/modals/info-modal/team/team.tsx +++ b/client/src/widgets/modals/info-modal/team/team.tsx @@ -1,31 +1,31 @@ -import { FC } from 'react'; -import { InfoModalTeamProps } from './interfaces'; -import { useGetScreenWidth } from '@/shared/lib'; -import { TeamDesktop } from '../team/desktop/desktop'; -import { TeamPhone } from '../team/phone/phone'; -export const TeamInfoModal: FC = props => { - const { team, user, isOpenModal, handleClose, handleJoin } = props; - const width = useGetScreenWidth(); - - return ( - <> - {width > 600 ? ( - - ) : ( - - )} - - ); -}; +// import { FC } from 'react'; +// import { InfoModalTeamProps } from './interfaces'; +// import { useGetScreenWidth } from '@/shared/lib'; +// import { TeamDesktop } from '../team/desktop/desktop'; +// import { TeamPhone } from '../team/phone/phone'; +// export const TeamInfoModal: FC = props => { +// const { team, user, isOpenModal, handleClose, handleJoin } = props; +// const width = useGetScreenWidth(); +// +// return ( +// <> +// {width > 600 ? ( +// +// ) : ( +// +// )} +// +// ); +// }; diff --git a/client/src/widgets/modals/info-modal/user/desktop/desktop.tsx b/client/src/widgets/modals/info-modal/user/desktop/desktop.tsx index f482ae287..a95b4cd1a 100644 --- a/client/src/widgets/modals/info-modal/user/desktop/desktop.tsx +++ b/client/src/widgets/modals/info-modal/user/desktop/desktop.tsx @@ -7,15 +7,15 @@ import { InfoModalUserProps } from '../interfaces'; export const UserDesktop: FC = ({ user, isOpenModal, handleClose }) => { const age = user?.dateOfBirth ? calculateAge(user.dateOfBirth) : null; - const showInviteButton = () => { - if (user?.team) { - if (!user.team.members?.some(member => member.id === user.id)) { - return true; - } - } - - return false; - }; + // const showInviteButton = () => { + // if (user?.team) { + // if (!user.team.members?.some(member => member.id === user.id)) { + // return true; + // } + // } + // + // return false; + // }; return ( <> @@ -101,12 +101,12 @@ export const UserDesktop: FC = ({ user, isOpenModal, handleC )} - {showInviteButton() && ( + { - )} + } - )} + } diff --git a/client/src/widgets/sidebar/config/getSidebarItems.tsx b/client/src/widgets/sidebar/config/getSidebarItems.tsx new file mode 100644 index 000000000..b79069666 --- /dev/null +++ b/client/src/widgets/sidebar/config/getSidebarItems.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { DEFAULT, TOURNAMENTS, PROFILE } from '@/shared/constant'; +import { SearchIcon, TrophyIcon, UserIcon } from '@/shared/assets'; +import { IUserProtectedResponse } from '@teameights/types'; + +export const getSidebarItems = (user?: IUserProtectedResponse) => { + const data = [ + { + title: 'Teammates', + path: DEFAULT, + icon: , + }, + { + title: 'Tournaments', + path: TOURNAMENTS, + icon: , + }, + ]; + if (user) { + data.push({ + title: 'Profile', + path: `${PROFILE}/${user?.id}`, + icon: , + }); + } + return data; +}; diff --git a/client/src/widgets/sidebar/index.ts b/client/src/widgets/sidebar/index.ts new file mode 100644 index 000000000..2fa54f4c0 --- /dev/null +++ b/client/src/widgets/sidebar/index.ts @@ -0,0 +1 @@ +export { Sidebar } from './ui/sidebar/sidebar'; diff --git a/client/src/widgets/sidebar/lib/hooks/useListenToNotifications.tsx b/client/src/widgets/sidebar/lib/hooks/useListenToNotifications.tsx new file mode 100644 index 000000000..7d1db68bc --- /dev/null +++ b/client/src/widgets/sidebar/lib/hooks/useListenToNotifications.tsx @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { IUserProtectedResponse } from '@teameights/types'; +import { socket } from '@/shared/api/socket'; + +export const useSocketConnection = (user?: IUserProtectedResponse) => { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!user) { + return; + } + + socket.connect(); + + socket.on('connect', () => { + console.log('Connected to WebSocket server'); + }); + + const handleNotification = () => { + queryClient.invalidateQueries({ queryKey: ['useGetNotifications'] }); + }; + + socket.on(`notification-${user.id}`, handleNotification); + + // Cleanup socket listeners when the component unmounts + return () => { + console.log('Disconnecting from WebSocket server...'); + socket.off(`notification-${user.id}`, handleNotification); + socket.disconnect(); + }; + }, [user]); +}; diff --git a/client/src/widgets/sidebar/ui/notification-content/notification-content.module.scss b/client/src/widgets/sidebar/ui/notification-content/notification-content.module.scss new file mode 100644 index 000000000..753a20b09 --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-content/notification-content.module.scss @@ -0,0 +1,36 @@ +.notificationsContent { + position: relative; +} + +@keyframes scaleAnimation { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + } +} + +.notificationsCount { + position: absolute; + pointer-events: none; // default value + top: var(--top, auto); + right: var(--right, auto); + left: var(--left, auto); + min-width: 14px; + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5px 3.5px; + background: #5bd424; + border-radius: 50%; + font-weight: 500; + font-size: 11px; + line-height: 100%; + color: #1a1c22; + animation: scaleAnimation 1s infinite; +} diff --git a/client/src/widgets/sidebar/ui/notification-content/notification-content.stories.tsx b/client/src/widgets/sidebar/ui/notification-content/notification-content.stories.tsx new file mode 100644 index 000000000..a206bc236 --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-content/notification-content.stories.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import { Meta, StoryFn } from '@storybook/react'; +import { NotificationContentProps, SidebarNotificationsContent } from './notification-content'; +import { generateSystemNotification } from '@/shared/lib'; + +// Generate mock notifications using the provided mocs feature +const mockNotifications = Array.from({ length: 5 }).map(() => generateSystemNotification()); + +const defaultProps: NotificationContentProps = { + userNotifications: mockNotifications, + isSidebarExpanded: false, + notificationModal: false, + setNotificationModal: () => {}, +}; + +type Story = StoryFn; +const NotificationContentTemplate: Story = args => { + const [modalOpen, setModalOpen] = useState(args.notificationModal); + return ( + + ); +}; + +// Playground Story +export const Playground = { ...NotificationContentTemplate }; +Playground.args = { ...defaultProps }; + +// Sidebar Expanded +export const SidebarExpanded = { ...NotificationContentTemplate }; +SidebarExpanded.args = { + ...defaultProps, + isSidebarExpanded: true, +}; + +// Sidebar Collapsed +export const SidebarCollapsed = { ...NotificationContentTemplate }; +SidebarCollapsed.args = { + ...defaultProps, + isSidebarExpanded: false, +}; + +// With Notifications +export const WithNotifications = { ...NotificationContentTemplate }; +WithNotifications.args = { + ...defaultProps, + userNotifications: mockNotifications, +}; + +// Without Notifications +export const WithoutNotifications = { ...NotificationContentTemplate }; +WithoutNotifications.args = { + ...defaultProps, + userNotifications: [], +}; + +// With Unread Notifications +export const WithUnreadNotifications = { ...NotificationContentTemplate }; +WithUnreadNotifications.args = { + ...defaultProps, + userNotifications: mockNotifications.filter(notification => !notification.read), +}; + +export default { + title: 'widgets/Sidebar/Notifications/SidebarNotificationsContent', + component: SidebarNotificationsContent, +} as Meta; diff --git a/client/src/widgets/sidebar/ui/notification-content/notification-content.tsx b/client/src/widgets/sidebar/ui/notification-content/notification-content.tsx new file mode 100644 index 000000000..ef68b2421 --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-content/notification-content.tsx @@ -0,0 +1,98 @@ +import React, { Dispatch, SetStateAction } from 'react'; +import clsx from 'clsx'; + +import { IconWrapper } from '@/shared/ui'; +import { BellIcon } from '@/shared/assets'; + +import { SidebarNotificationsModal } from '../notification-modal/notification-modal'; +import { SidebarNotificationsCount } from './notifications-count'; + +import sidebarStyles from '../sidebar/sidebar.module.scss'; +import styles from './notification-content.module.scss'; +import { NotificationType } from '@teameights/types'; + +export interface NotificationContentProps { + /** + * An array of user notifications. Could be undefined. + */ + userNotifications: NotificationType[] | undefined; + /** + * Flag indicating whether the sidebar is expanded. + */ + isSidebarExpanded: boolean; + /** + * Function to toggle the notification modal. + */ + setNotificationModal: Dispatch>; + /** + * Flag indicating whether the notification modal is displayed. + */ + notificationModal: boolean; +} + +/** + * The `SidebarNotificationsContent` component is responsible for rendering the notification content + * in the sidebar. It displays a notification icon, the number of unread notifications, + * and toggles a modal containing the user's notifications. + * + * Example: + * + * ```tsx + * // Example usage of SidebarNotificationsContent + * const notifications = [ + * { read: false, / ...other properties / }, + * { read: true, / ...other properties / }, + * ]; + * {}} + * notificationModal={false} + * /> + * ``` + */ +export const SidebarNotificationsContent: React.FC = props => { + const { notificationModal, setNotificationModal, userNotifications, isSidebarExpanded } = props; + + const unreadMessages = React.useMemo(() => { + if (userNotifications?.length) { + return userNotifications?.filter(item => !item.read); + } + }, [userNotifications]); + + const toggleNotificationModal = () => { + setNotificationModal(prev => !prev); + }; + + return ( +
+
+ + + + Notifications + {!!unreadMessages?.length && !notificationModal && ( + + {unreadMessages?.length} + + )} +
+ +
+ ); +}; diff --git a/client/src/widgets/sidebar/ui/notification-content/notifications-count.tsx b/client/src/widgets/sidebar/ui/notification-content/notifications-count.tsx new file mode 100644 index 000000000..d5af468b6 --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-content/notifications-count.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import styles from './notification-content.module.scss'; + +interface NotificationsCountProps { + pointerEvents?: 'none' | 'all'; + top?: string; + right?: string; + left?: string; + children: React.ReactNode; +} + +/** + * The `SidebarNotificationsCount` component displays a counter badge showing the number of unread notifications. + * It allows customization of pointer events and positioning through props. + * + * @component + * + * @example + * // Example usage of SidebarNotificationsCount + * + * {5} + * + * + * @param {string} pointerEvents - Controls the CSS `pointer-events` property. Either 'none' or 'all'. + * @param {string} [top] - The top CSS position. + * @param {string} [right] - The right CSS position. + * @param {string} [left] - The left CSS position. + * @param {React.ReactNode} children - Content to display within the notification count badge. + */ +export const SidebarNotificationsCount: React.FC = props => { + const { pointerEvents = 'none', top = 'auto', right = 'auto', left = 'auto', children } = props; + + return ( +
+ {children} +
+ ); +}; diff --git a/client/src/widgets/sidebar/ui/notification-item/notification-item.module.scss b/client/src/widgets/sidebar/ui/notification-item/notification-item.module.scss new file mode 100644 index 000000000..efae86c22 --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-item/notification-item.module.scss @@ -0,0 +1,85 @@ +.notificationsItem { + --avatar-size: 36px; + --message-gap: 12px; + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 20px 16px 24px; + color: #fff; + border-top: 1px solid #2f3239; + width: 100%; + + &:last-child{ + border-bottom: 1px solid rgb(47, 50, 57); + } + &:hover { + background-color: #2f3239; + } +} + +.messageContentWrapper { + display: flex; + flex-direction: column; + gap: 12px; + width: calc(100% - (var(--avatar-size) + var(--message-gap))); +} + +.messageText { + margin: 0; + font-size: 14px; + line-height: 120%; + color: #fff; +} + +.messagePicture { + position: relative; + width: var(--avatar-size); + height: var(--avatar-size); + background-color: #D4AC0F; /* Default color, can be overridden in component */ + border-radius: 50%; + + img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + } + + svg { + margin: 8px; + } +} + +.sendingTime { + margin: 0; + font-weight: 500; + font-size: 11px; + line-height: 100%; + color: #86878b; + text-align: end; +} + +.messageCircle { + position: absolute; + top: 0; + left: -10px; + height: 6px; + width: 6px; + background-color: #5bd424; + border-radius: 50%; +} + +.messageButton { + cursor: pointer; + border: 2px solid #46a11b; /* Default color, can be overridden in component */ + background-color: transparent; /* Default color, can be overridden in component */ + padding: 6px 16px 4px; + line-height: 140%; + color: #fff; + border-radius: 10px; + transition: opacity 0.3s; + + &:hover { + opacity: 0.8; + } +} diff --git a/client/src/widgets/sidebar/ui/notification-item/notification-item.stories.tsx b/client/src/widgets/sidebar/ui/notification-item/notification-item.stories.tsx new file mode 100644 index 000000000..8245e9c35 --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-item/notification-item.stories.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { SidebarNotificationsItem } from './notification-item'; +import { generateSystemNotification } from '@/shared/lib'; + +const systemNotificationMock = generateSystemNotification(); + +type Story = StoryObj; +const NotificationItemTemplate: Story = { render: args => }; + +export const Playground = { ...NotificationItemTemplate }; +Playground.args = { + notification: systemNotificationMock, + closeNotificationsModal: () => console.log('Modal Closed'), +}; + +// // Team Invitation Notification +// export const TeamInvitation = { ...NotificationItemTemplate }; +// TeamInvitation.args = { +// notification: teamInvitationNotificationMock, +// closeNotificationsModal: () => console.log('Modal Closed'), +// }; + +// Read Notification +export const ReadNotification = { ...NotificationItemTemplate }; +ReadNotification.args = { + notification: { + ...systemNotificationMock, + read: true, + data: { + system_message: 'This is a read notification', + }, + }, + closeNotificationsModal: () => console.log('Modal Closed'), +}; + +export default { + title: 'widgets/Sidebar/Notifications/NotificationItem', + component: SidebarNotificationsItem, + tags: ['autodocs'], +} as Meta; diff --git a/client/src/widgets/sidebar/ui/notification-item/notification-item.tsx b/client/src/widgets/sidebar/ui/notification-item/notification-item.tsx new file mode 100644 index 000000000..c2fb2d006 --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-item/notification-item.tsx @@ -0,0 +1,72 @@ +import React from 'react'; + +import { SidebarSystemNotification } from './system-notification'; + +import styles from './notification-item.module.scss'; +import { NotificationType } from '@teameights/types'; + +export interface NotificationProps { + /** + * The notification object. + */ + notification: NotificationType; + /** + * A function to close the notifications modal. + */ + closeNotificationsModal: () => void; +} + +/** + * SidebarNotificationsItem component renders a list item for the notification. + * + * Example: + * + * ```tsx + * + * ``` + */ +export const SidebarNotificationsItem: React.FC = props => { + const { notification } = props; + + // const handleAccept = () => { + // // Mock the mutatios + // console.log('Accepted the invitation'); + // closeNotificationsModal(); + // }; + + // const handleReject = () => { + // // Mock the mutation + // console.log('Rejected the invitation'); + // }; + + const renderContent = () => { + switch (notification.type) { + case 'system': + return ; + // case 'team_invite': + // return ( + // + // ); + default: + console.error(`Unknown notification type: ${notification}`); + return null; + } + }; + + return ( +
  • + {renderContent()} +
  • + ); +}; diff --git a/client/src/widgets/sidebar/ui/notification-item/system-notification.tsx b/client/src/widgets/sidebar/ui/notification-item/system-notification.tsx new file mode 100644 index 000000000..72534c88f --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-item/system-notification.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { Flex } from '@/shared/ui'; +import { LightningIcon } from '@/shared/assets'; +import { getElapsedTime } from '@/shared/lib'; + +import styles from './notification-item.module.scss'; +import { ISystemNotification } from '@teameights/types'; + +interface SystemNotificationProps { + notification: ISystemNotification; +} + +/** + * SidebarSystemNotification component renders system notification content. + * + * @component + * @param {Object} props - The properties object. + * @param {SystemNotification} props.notification - The system notification object. + * + * @example + * + */ +export const SidebarSystemNotification: React.FC = props => { + const { notification } = props; + + return ( + <> + +
    + {!notification.read &&
    } + +
    +

    {notification.data.system_message}

    + +

    {getElapsedTime(notification.createdAt)}

    + + ); +}; diff --git a/client/src/widgets/sidebar/ui/notification-item/team-invatition-notification.tsx b/client/src/widgets/sidebar/ui/notification-item/team-invatition-notification.tsx new file mode 100644 index 000000000..1b394b3ed --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-item/team-invatition-notification.tsx @@ -0,0 +1,58 @@ +// import React from 'react'; +// import Image from 'next/image'; +// +// import { Flex } from '@/shared/ui'; +// import { getElapsedTime } from '@/shared/lib'; +// +// import styles from './notification-item.module.scss'; +// import { ITeamInvitationNotification } from '@teameights/types'; +// +// interface TeamInvatitionNotificationProps { +// notification: ITeamInvitationNotification; +// handleAccept: () => void; +// handleReject: () => void; +// } +// +// /** +// * SidebarTeamInvatitionNotification component renders team invitation notification content. +// * +// * @component +// * @param {Object} props - The properties object. +// * @param {TeamInvitationNotification} props.notification - The team invitation notification object. +// * @param {Function} props.handleAccept - The handler function for accept action. +// * @param {Function} props.handleReject - The handler function for reject action. +// * +// * @example +// * +// */ +// export const SidebarTeamInvatitionNotification: React.FC< +// TeamInvatitionNotificationProps +// > = props => { +// const { notification, handleReject, handleAccept } = props; +// return ( +// <> +// +//
    +// {!notification.read &&
    } +// Team invation icon +//
    +//
    +//

    {notification.message}

    +// +// +// +// +//
    +// +//

    {getElapsedTime(notification.createdAt)}

    +// +// ); +// }; diff --git a/client/src/widgets/sidebar/ui/notification-list/lib/sort-notifications.test.ts b/client/src/widgets/sidebar/ui/notification-list/lib/sort-notifications.test.ts new file mode 100644 index 000000000..f9a378d8f --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-list/lib/sort-notifications.test.ts @@ -0,0 +1,51 @@ +import { sortNotifications } from './sort-notifications'; +import { NotificationType } from '@teameights/types'; + +describe('sortNotifications', () => { + it('should return a sorted array of notifications by createdAt date in descending order', () => { + const notifications: NotificationType[] = [ + { createdAt: new Date('2021-01-01T00:00:00.000Z') } as NotificationType, + { createdAt: new Date('2021-01-02T00:00:00.000Z') } as NotificationType, + { createdAt: new Date('2021-01-03T00:00:00.000Z') } as NotificationType, + ]; + + const sortedNotifications = sortNotifications(notifications); + expect(sortedNotifications.map(n => n.createdAt?.toISOString())).toEqual([ + '2021-01-03T00:00:00.000Z', + '2021-01-02T00:00:00.000Z', + '2021-01-01T00:00:00.000Z', + ]); + }); + + it('should return an empty array when passed an empty array', () => { + const notifications: NotificationType[] = []; + const sortedNotifications = sortNotifications(notifications); + expect(sortedNotifications).toEqual([]); + }); + + it('should return a sorted array of notifications when passed an array with one notification', () => { + const notifications: NotificationType[] = [ + { createdAt: new Date('2021-01-01T00:00:00.000Z') } as NotificationType, + ]; + + const sortedNotifications = sortNotifications(notifications); + expect(sortedNotifications.map(n => n.createdAt?.toISOString())).toEqual([ + '2021-01-01T00:00:00.000Z', + ]); + }); + + it('should return a sorted array of notifications when passed an array with notifications that have the same createdAt date', () => { + const notifications: NotificationType[] = [ + { createdAt: new Date('2021-01-01T00:00:00.000Z') } as NotificationType, + { createdAt: new Date('2021-01-01T00:00:00.000Z') } as NotificationType, + { createdAt: new Date('2021-01-01T00:00:00.000Z') } as NotificationType, + ]; + + const sortedNotifications = sortNotifications(notifications); + expect(sortedNotifications.map(n => n.createdAt?.toISOString())).toEqual([ + '2021-01-01T00:00:00.000Z', + '2021-01-01T00:00:00.000Z', + '2021-01-01T00:00:00.000Z', + ]); + }); +}); diff --git a/client/src/widgets/sidebar/ui/notification-list/lib/sort-notifications.ts b/client/src/widgets/sidebar/ui/notification-list/lib/sort-notifications.ts new file mode 100644 index 000000000..ca5e273f0 --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-list/lib/sort-notifications.ts @@ -0,0 +1,34 @@ +import { NotificationType } from '@teameights/types'; + +/** + * Sort Notifications Function + * + * This function sorts an array of notifications in descending order based on their `createdAt` timestamp. + * It takes an array of Notification objects and returns a new array that is sorted. + * + * @example + * const notifications = [ + * { + * _id: '1', + * createdAt: new Date('2023-01-01T12:00:00Z'), + * // ... other properties + * }, + * { + * _id: '2', + * createdAt: new Date('2023-01-02T12:00:00Z'), + * // ... other properties + * } + * ]; + * + * const sortedNotifications = sortNotifications(notifications); + * console.log(sortedNotifications); + * + * @function + * @param {Notification[]} notifications - An array of Notification objects. + * @returns {Notification[]} - A new array of Notification objects sorted in descending order by `createdAt`. + */ +export const sortNotifications = (notifications: NotificationType[]) => { + return [...notifications].sort( + (a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf() + ); +}; diff --git a/client/src/widgets/sidebar/ui/notification-list/notification-list.module.scss b/client/src/widgets/sidebar/ui/notification-list/notification-list.module.scss new file mode 100644 index 000000000..064f2d098 --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-list/notification-list.module.scss @@ -0,0 +1,24 @@ +.notificationsList { + margin: 0; + padding: 0; + list-style: none; + max-height: calc(100% - 62px); + overflow-y: auto; + scrollbar-width: none; // Firefox + -ms-overflow-style: none; // Internet Explorer 10+ + + // WebKit + ::-webkit-scrollbar { + transition: all 0.2s; + width: 5px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background-color: #5d9d0b; + border-radius: 10px; + } +} diff --git a/client/src/widgets/sidebar/ui/notification-list/notification-list.stories.tsx b/client/src/widgets/sidebar/ui/notification-list/notification-list.stories.tsx new file mode 100644 index 000000000..73b69c67d --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-list/notification-list.stories.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { NotificationsListProps, SidebarNotificationsList } from './notification-list'; +import { generateMockUser, generateSystemNotification } from '@/shared/lib'; + +const mockUser = generateMockUser(); +const mockSystemNotification = generateSystemNotification(mockUser); + +const notificationsListProps: NotificationsListProps = { + userNotifications: [mockSystemNotification], + setUnreadIds: callback => new Set(callback(new Set())), + closeNotificationsModal: () => console.log('Modal Closed'), +}; + +type Story = StoryObj; +const SidebarNotificationsListTemplate: Story = { + render: args => , +}; + +// Default Playground +export const Playground = { ...SidebarNotificationsListTemplate }; +Playground.args = notificationsListProps; + +// Empty List +export const EmptyList = { ...SidebarNotificationsListTemplate }; +EmptyList.args = { + userNotifications: [], + setUnreadIds: () => {}, + closeNotificationsModal: () => {}, +}; + +// With Only System Notifications +export const OnlySystemNotifications = { ...SidebarNotificationsListTemplate }; +OnlySystemNotifications.args = { + ...notificationsListProps, + userNotifications: [mockSystemNotification], +}; + +// With Read Notifications +export const WithReadNotifications = { ...SidebarNotificationsListTemplate }; +WithReadNotifications.args = { + ...notificationsListProps, + userNotifications: [{ ...mockSystemNotification, read: true }], +}; + +export default { + title: 'widgets/Sidebar/Notifications/SidebarNotificationsList', + component: SidebarNotificationsList, + tags: ['autodocs'], +} as Meta; diff --git a/client/src/widgets/sidebar/ui/notification-list/notification-list.tsx b/client/src/widgets/sidebar/ui/notification-list/notification-list.tsx new file mode 100644 index 000000000..e5c49f758 --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-list/notification-list.tsx @@ -0,0 +1,101 @@ +import React, { FC, useEffect, useMemo, useRef } from 'react'; + +import styles from './notification-list.module.scss'; +import { SidebarNotificationsItem } from '../notification-item/notification-item'; +import { sortNotifications } from './lib/sort-notifications'; +import { NotificationType } from '@teameights/types'; + +export interface NotificationsListProps { + /** + * Array of user notifications + */ + userNotifications: NotificationType[]; + /** + * Function to set unread IDs based on intersection + */ + setUnreadIds: (callback: (prev: Set) => Set) => void; + /** + * Function to close notifications modal + */ + closeNotificationsModal: () => void; +} + +/** + * This component is used to display a list of notifications in a sidebar. + * It takes a list of user notifications, sorts them based on a specific logic, and + * provides an intersection observer to track which notifications are visible in the viewport. + * + * Here is a basic usage example: + * + * ```tsx + * const userNotifications = [ + * { + * _id: '1', + * type: 'SystemNotification', + * system_message: 'Your password will expire soon', + * read: false + * }, + * // ... more notifications + * ]; + * + * const setUnreadIds = (callback) => { + * // logic to set unread IDs + * }; + * + * const closeNotificationsModal = () => { + * // logic to close modal + * }; + * + * + * ``` + */ +export const SidebarNotificationsList: FC = props => { + const { userNotifications, setUnreadIds, closeNotificationsModal } = props; + + const listRef = useRef(null); + + const sortedNotifications = useMemo(() => { + if (userNotifications?.length) { + return sortNotifications(userNotifications); + } + }, [userNotifications]); + + useEffect(() => { + const handleIntersection = (entries: IntersectionObserverEntry[]) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const itemId = entry.target.getAttribute('data-notification-id'); + const isRead = entry.target.getAttribute('data-notification-read'); + + if (isRead === 'false' && itemId) { + setUnreadIds(prev => new Set([...Array.from(prev), itemId])); + } + } + }); + }; + + const observer = new IntersectionObserver(handleIntersection); + const listItems = listRef.current?.querySelectorAll('[data-notification-read]'); + listItems?.forEach(item => observer.observe(item)); + + return () => { + observer.disconnect(); + }; + }, [setUnreadIds, userNotifications]); + + return ( +
      + {sortedNotifications?.map(notification => ( + + ))} +
    + ); +}; diff --git a/client/src/widgets/sidebar/ui/notification-modal/desktop-modal-content.tsx b/client/src/widgets/sidebar/ui/notification-modal/desktop-modal-content.tsx new file mode 100644 index 000000000..056beeeff --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-modal/desktop-modal-content.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { clsx } from 'clsx'; + +import { Flex, IconWrapper } from '@/shared/ui'; +import { ChecksIcon, XIcon } from '@/shared/assets'; + +import { SidebarNotificationsList } from '../notification-list/notification-list'; +import { NotificationsModalProps } from './notification-modal'; + +import styles from './notification-modal.module.scss'; + +interface DesktopModalContentProps extends Omit { + notificationModalRef: React.RefObject; + markAllAsRead: () => void; + closeNotificationsModal: () => void; + setUnreadIds: React.Dispatch>>; +} + +export const SidebarDesktopModalContent: React.FC = props => { + const { + notificationModal, + notificationModalRef, + markAllAsRead, + closeNotificationsModal, + userNotifications, + setUnreadIds, + } = props; + return ( +
    e.stopPropagation()} + > + +
    + + + +

    Mark all as read

    +
    +
    + +
    +
    + +
    + ); +}; diff --git a/client/src/widgets/sidebar/ui/notification-modal/mobile-modal-content.tsx b/client/src/widgets/sidebar/ui/notification-modal/mobile-modal-content.tsx new file mode 100644 index 000000000..fcd5599d3 --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-modal/mobile-modal-content.tsx @@ -0,0 +1,55 @@ +import React, { Dispatch, FC, SetStateAction } from 'react'; + +import { ChecksIcon, XIcon } from '@/shared/assets'; +import { Button, Drawer, Flex, IconWrapper } from '@/shared/ui'; + +import { SidebarNotificationsList } from '../notification-list/notification-list'; +import { NotificationsModalProps } from './notification-modal'; + +import styles from './notification-modal.module.scss'; + +interface MobileModalContentProps extends Omit { + closeNotificationsModal: () => void; + markAllAsRead: () => void; + setUnreadIds: Dispatch>>; +} + +export const SidebarMobileModalContent: FC = props => { + const { + notificationModal, + userNotifications, + closeNotificationsModal, + markAllAsRead, + setUnreadIds, + } = props; + return ( + +
    + + +

    Notifications

    +
    + +
    +
    + +
    + +
    +
    + ); +}; diff --git a/client/src/widgets/sidebar/ui/notification-modal/notification-modal.module.scss b/client/src/widgets/sidebar/ui/notification-modal/notification-modal.module.scss new file mode 100644 index 000000000..ee3fed1c6 --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-modal/notification-modal.module.scss @@ -0,0 +1,144 @@ +.notificationsModal { + pointer-events: none; // default value + position: absolute; + left: calc(100% + 32px); + top: -16px; + width: 320px; + height: 354px; + background: linear-gradient(90.45deg, #1a1c22 62.8%, #2f3239 209.77%); + box-shadow: 0px 4px 24px rgba(17, 20, 27, 0.25); + border-radius: 5px; + overflow: hidden; + transition: clip-path 0.3s ease; // Add transition for clip-path + clip-path: inset(10% 50% 90% 50% round 10px); // Default to closed state + &.active { + clip-path: inset(0% 0% 0% 0% round 10px); // Open state + pointer-events: all; + } +} + +.notificationsModal.open { + animation: openAnimation; +} + +.notificationsModal.closed { + animation: closeAnimation; +} + +.notificationsHeader { + padding: 16px; + + @media screen and (max-width: 670px) { + padding: 24px; + } +} + +.crossBtn { + cursor: pointer; + &:hover { + path { + stroke: #d42422; + } + } +} + +.markAllBtn, .markAllBtnMobile { + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + p { + margin: 0; + color: #fff; + } + &:hover { + path { + stroke: #5bd424; + } + p { + color: #5bd424; + } + } +} + +.mobileNotificationsModal { + display: block; + transform: translateY(100%); /* Start from the bottom */ + transition: transform 0.3s ease-in-out; + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 9999; + + &.modalActive { + background: #26292b; + overflow: hidden; + inset: 0px; + pointer-events: all; + transform: translateY(0); + } +} + +.mobileWrapper { + width: 100%; + min-height: 100dvh; + background: #26292b !important; + display: flex; + justify-content: space-between; + flex-direction: column; + overflow-y: scroll; +} + +.text { + font-weight: 500; + font-size: 24px; + color: #5bd424; + margin: 0; + text-align: unset; // default value +} + +.markAllBtnMobile { + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + gap: 7px; + background: none; + outline: none; + border: 2px solid #46a11b; + border-radius: 10px; + padding: 10px 16px; + margin: 16px 24px 24px 24px; + p { + color: #fff; + font-size: 16px; + font-weight: 400; + } + &:hover { + path { + stroke: #5bd424; + } + p { + color: #5bd424; + } + } +} + +@keyframes openAnimation { + from { + clip-path: inset(10% 50% 90% 50% round 10px); + } + to { + clip-path: inset(0% 0% 0% 0% round 10px); + } +} + +@keyframes closeAnimation { + from { + clip-path: inset(0% 0% 0% 0% round 10px); + } + to { + clip-path: inset(10% 50% 90% 50% round 10px); + } +} diff --git a/client/src/widgets/sidebar/ui/notification-modal/notification-modal.stories.tsx b/client/src/widgets/sidebar/ui/notification-modal/notification-modal.stories.tsx new file mode 100644 index 000000000..c5eda6959 --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-modal/notification-modal.stories.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { NotificationType } from '@teameights/types'; +import { generateSystemNotification } from '@/shared/lib'; +import { NotificationsModalProps, SidebarNotificationsModal } from './notification-modal'; + +// Generating mock notifications to use in stories +const mockNotifications: NotificationType[] = [generateSystemNotification()]; + +const notificationsModalProps: NotificationsModalProps = { + userNotifications: mockNotifications, + notificationModal: false, + setNotificationModal: () => {}, // This should be replaced with an action +}; + +// Defining meta information for Storybook +type Story = StoryObj; +const NotificationsModalTemplate: Story = { + render: args => ( +
    + +
    + ), +}; + +export const Playground = { ...NotificationsModalTemplate }; +Playground.args = notificationsModalProps; + +// Notifications Modal Open +export const Open = { ...NotificationsModalTemplate }; +Open.args = { + ...notificationsModalProps, + notificationModal: true, +}; + +// Notifications Modal with No Notifications +export const Empty = { ...NotificationsModalTemplate }; +Empty.args = { + ...notificationsModalProps, + userNotifications: [], +}; + +export default { + title: 'widgets/Sidebar/NotificationsModal', + component: SidebarNotificationsModal, + tags: ['autodocs'], +} as Meta; diff --git a/client/src/widgets/sidebar/ui/notification-modal/notification-modal.tsx b/client/src/widgets/sidebar/ui/notification-modal/notification-modal.tsx new file mode 100644 index 000000000..44146d0e2 --- /dev/null +++ b/client/src/widgets/sidebar/ui/notification-modal/notification-modal.tsx @@ -0,0 +1,66 @@ +import React, { useCallback, useState } from 'react'; + +import { useClickOutside, useGetScreenWidth } from '@/shared/lib'; + +import { SidebarDesktopModalContent } from './desktop-modal-content'; +import { SidebarMobileModalContent } from './mobile-modal-content'; +import { NotificationType } from '@teameights/types'; +import { useReadNotifications } from '@/entities/session/api/useReadNotifications'; + +export interface NotificationsModalProps { + userNotifications: NotificationType[]; + notificationModal: boolean; + setNotificationModal: (value: boolean) => void; +} + +export const SidebarNotificationsModal: React.FC = props => { + const { userNotifications, notificationModal, setNotificationModal } = props; + const { mutate: readNotifications } = useReadNotifications(); + + const [unreadIds, setUnreadIds] = useState(new Set()); + const width = useGetScreenWidth(); + + const closeNotificationsModal = useCallback(() => { + if (notificationModal) { + setNotificationModal(false); + if (unreadIds.size) { + readNotifications(Array.from(unreadIds)); + setUnreadIds(new Set()); + } + } + }, [notificationModal, setNotificationModal, unreadIds, readNotifications]); + + const notificationModalRef = useClickOutside(closeNotificationsModal); + + const markAllAsRead = () => { + const unreadNotifications = userNotifications + .filter(notification => !notification.read) + .map(notification => String(notification.id)); + if (unreadNotifications.length) { + readNotifications(unreadNotifications); + } + }; + + return ( + <> + {width > 670 ? ( + + ) : ( + + )} + + ); +}; diff --git a/client/src/widgets/sidebar/ui/sidebar-item/sidebar-item.module.scss b/client/src/widgets/sidebar/ui/sidebar-item/sidebar-item.module.scss new file mode 100644 index 000000000..79fc6cf59 --- /dev/null +++ b/client/src/widgets/sidebar/ui/sidebar-item/sidebar-item.module.scss @@ -0,0 +1,43 @@ +.wrapper { + position: relative; + transition: opacity 0.2s; + border-radius: 10px; + text-decoration: none; + color: #fff; + padding: 8px 16px; + display: flex; + align-items: center; + gap: 8px; + overflow: hidden; + + &:hover { + background-color: var(--grey-dark-color); + } + + &.active { + background-color: var(--green-normal-color); + } +} + +.icon { + display: flex; + align-items: center; + justify-content: center; + + & svg { + width: 24px; + height: 24px; + } +} + +.title { + transition: opacity 0.2s; + opacity: 0; + pointer-events: none; + color: #fff; + + &.active { + opacity: 1; + pointer-events: all; + } +} diff --git a/client/src/widgets/sidebar/ui/sidebar-item/sidebar-item.stories.tsx b/client/src/widgets/sidebar/ui/sidebar-item/sidebar-item.stories.tsx new file mode 100644 index 000000000..e65533d73 --- /dev/null +++ b/client/src/widgets/sidebar/ui/sidebar-item/sidebar-item.stories.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { SidebarItem, SidebarItemProps } from './sidebar-item'; +import { LightningIcon, SearchIcon } from '@/shared/assets'; + +const sidebarItemProps: SidebarItemProps = { + active: false, + path: '/home', + icon: , + title: 'Home', + isActive: false, +}; + +// Defining meta information for Storybook + +type Story = StoryObj; +const SidebarItemTemplate: Story = { render: args => }; + +export const Playground = { ...SidebarItemTemplate }; +Playground.args = sidebarItemProps; + +// Active Sidebar Item +export const Active = { ...SidebarItemTemplate }; +Active.args = { + ...sidebarItemProps, + active: true, + isActive: true, +}; + +// Sidebar Item with Long Title +export const LongTitle = { ...SidebarItemTemplate }; +LongTitle.args = { + ...sidebarItemProps, + title: 'Long Title Example Here', +}; + +// Sidebar Item on Hover +export const Hover = { ...SidebarItemTemplate }; +Hover.args = { + ...sidebarItemProps, +}; +Hover.parameters = { + pseudo: { hover: true }, +}; + +// Sidebar Item with Different Icon +export const DifferentIcon = { ...SidebarItemTemplate }; +DifferentIcon.args = { + ...sidebarItemProps, + icon: , +}; + +export default { + title: 'widgets/Sidebar/SidebarItem', + component: SidebarItem, + tags: ['autodocs'], +} as Meta; diff --git a/client/src/widgets/sidebar/ui/sidebar-item/sidebar-item.tsx b/client/src/widgets/sidebar/ui/sidebar-item/sidebar-item.tsx new file mode 100644 index 000000000..88d9397e8 --- /dev/null +++ b/client/src/widgets/sidebar/ui/sidebar-item/sidebar-item.tsx @@ -0,0 +1,62 @@ +import Link from 'next/link'; +import clsx from 'clsx'; +import React from 'react'; + +import styles from './sidebar-item.module.scss'; +import { Typography } from '@/shared/ui'; + +export interface SidebarItemProps extends React.LiHTMLAttributes { + /** + * Determines the visual state of the title. If true, the title is fully visible. + */ + active: boolean; + /** + * The URL path to which the component's link should point. + */ + path: string; + /** + * The icon to be displayed alongside the title. + */ + icon: React.ReactNode; + /** + * The text to be displayed as the item's title. + */ + title: string; + /** + * Determines the visual state of the entire item. If true, a background color is applied. + */ + isActive: boolean; +} + +/** + * The SidebarItem component represents a navigational item within a sidebar. + * It encapsulates a link, an icon, and a title, providing a visual indication of its active state. + * + * Example for usage: + * + * ```tsx + * import { SidebarItem } from './SidebarItem'; + * import { HomeIcon } from 'shared/assets'; + * + * } + * title="Home" + * isActive={true} + * /> + * ``` + */ +export const SidebarItem: React.FC = props => { + const { active, path, icon, title, isActive, ...rest } = props; + return ( +
  • + + {icon} + + {title} + + +
  • + ); +}; diff --git a/client/src/widgets/sidebar/ui/sidebar-profile/sidebar-profile.module.scss b/client/src/widgets/sidebar/ui/sidebar-profile/sidebar-profile.module.scss new file mode 100644 index 000000000..ee00dc9ec --- /dev/null +++ b/client/src/widgets/sidebar/ui/sidebar-profile/sidebar-profile.module.scss @@ -0,0 +1,53 @@ +.userInfo { + margin-top: 28px; + width: 100%; + display: flex; + padding: 0 12px; + align-items: center; + gap: 12px; + overflow: hidden; +} + +.userContent { + display: flex; + flex-direction: column; + gap: 2px; +} + +.userRealName { + transition: opacity 0.2s; + white-space: nowrap; + pointer-events: none; + opacity: 0; + color: var(--white-color); + + &.active { + pointer-events: all; + opacity: 1; + } +} + +.userUsername { + transition: opacity 0.2s; + white-space: nowrap; + pointer-events: none; + opacity: 0; + margin: 0; + color: var(--grey-normal-color); + + &.active { + pointer-events: all; + opacity: 1; + } +} + +.notificationIconCenter { + padding-top: 5px; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.notificationToggle { + cursor: pointer; +} diff --git a/client/src/widgets/sidebar/ui/sidebar-profile/sidebar-profile.stories.tsx b/client/src/widgets/sidebar/ui/sidebar-profile/sidebar-profile.stories.tsx new file mode 100644 index 000000000..57d2ae40a --- /dev/null +++ b/client/src/widgets/sidebar/ui/sidebar-profile/sidebar-profile.stories.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { SidebarProfile, SidebarProfileProps } from './sidebar-profile'; +import { IUserResponse } from '@teameights/types'; +import { addProtectedFields, generateMockUser } from '@/shared/lib'; + +const mockUser: IUserResponse = generateMockUser(); +const mockUserWithProtectedFields = addProtectedFields(mockUser); + +const sidebarProfileProps: SidebarProfileProps = { + active: false, + user: mockUserWithProtectedFields, +}; + +type Story = StoryObj; +const SidebarProfileTemplate: Story = { render: args => }; + +export const Playground = { ...SidebarProfileTemplate }; +Playground.args = sidebarProfileProps; + +// Active User Profile +export const ActiveUserProfile = { ...SidebarProfileTemplate }; +ActiveUserProfile.args = { + ...sidebarProfileProps, + active: true, +}; + +// Inactive User Profile +export const InactiveUserProfile = { ...SidebarProfileTemplate }; +InactiveUserProfile.args = { + ...sidebarProfileProps, + active: false, +}; + +// Unregistered User Profile +export const UnregisteredUserProfile = { ...SidebarProfileTemplate }; +UnregisteredUserProfile.args = { + ...sidebarProfileProps, + user: addProtectedFields(generateMockUser()), +}; + +// Active Unregistered User Profile +export const ActiveUnregisteredUserProfile = { ...SidebarProfileTemplate }; +ActiveUnregisteredUserProfile.args = { + ...sidebarProfileProps, + user: addProtectedFields(generateMockUser()), + active: true, +}; + +// User Profile with No Image +export const UserProfileWithNoImage = { ...SidebarProfileTemplate }; +UserProfileWithNoImage.args = { + ...sidebarProfileProps, + user: { + ...addProtectedFields(generateMockUser()), + photo: null, + }, +}; + +export default { + title: 'widgets/Sidebar/SidebarProfile', + component: SidebarProfile, + tags: ['autodocs'], +} as Meta; diff --git a/client/src/widgets/sidebar/ui/sidebar-profile/sidebar-profile.tsx b/client/src/widgets/sidebar/ui/sidebar-profile/sidebar-profile.tsx new file mode 100644 index 000000000..8738f9bdd --- /dev/null +++ b/client/src/widgets/sidebar/ui/sidebar-profile/sidebar-profile.tsx @@ -0,0 +1,102 @@ +import React, { useEffect, useState } from 'react'; +import clsx from 'clsx'; + +import styles from './sidebar-profile.module.scss'; +import { IUserProtectedResponse } from '@teameights/types'; +import { ImageLoader, Typography } from '@/shared/ui'; + +const unregisteredImg = '/images/user-images/unregistered.png'; + +const defaultData = { + userRealName: 'Unknown', + userUsername: 'unknown@email.com', + notificationBell: false, + userImg: unregisteredImg, +}; + +interface UserData { + userRealName: string; + userUsername: string; + notificationBell: boolean; + userImg: string; +} + +export interface SidebarProfileProps { + /** + * Determines the visual state of the user's real name and username. If true, they are highlighted. + */ + active: boolean; + /** + * The user object containing user information. + */ + user?: IUserProtectedResponse; +} + +/** + * The SidebarProfile component displays the user's profile information in the sidebar. + * It shows the user's image, real name, and username. If the user is not registered, default data is shown. + * + * Example of usage: + * + * ```tsx + * import { SidebarProfile } from './SidebarProfile'; + * import { User } from 'entities/user'; + * + * const user = new User({ + * isRegistered: true, + * fullName: 'John Doe', + * username: 'john.doe', + * image: '/images/user/john.png' + * }); + * + * + * ``` + */ +export const SidebarProfile: React.FC = props => { + const { user, active } = props; + + const isUserRegistered = !!user?.username; + const [data, setData] = useState(defaultData); + + useEffect(() => { + if (isUserRegistered) { + setData({ + userRealName: user.fullName, + userUsername: user.username, + notificationBell: true, + userImg: user?.photo?.path || unregisteredImg, + }); + } else { + setData(defaultData); + } + }, [isUserRegistered, user]); + + return ( +
    + +
    + + {data?.userRealName} + + + @{data?.userUsername} + +
    +
    + ); +}; diff --git a/client/src/widgets/sidebar/ui/sidebar/sidebar.module.scss b/client/src/widgets/sidebar/ui/sidebar/sidebar.module.scss new file mode 100644 index 000000000..9c2d9731f --- /dev/null +++ b/client/src/widgets/sidebar/ui/sidebar/sidebar.module.scss @@ -0,0 +1,203 @@ +.iconContainer { + margin-right: 25px; + cursor: pointer; +} + +.sidebarWrapper { + pointer-events: none; + width: 100vw; + height: 100dvh; + background: rgba(0, 0, 0, 0); + backdrop-filter: none; + -webkit-backdrop-filter: none; + position: fixed; + top: 0; + left: 0; + z-index: 999; + + &.active { + pointer-events: all; + background: rgba(0, 0, 0, 0.50); + backdrop-filter: blur(5px); + -webkit-backdrop-filter: blur(5px); + } + + @media screen and (max-width: 768px) { + pointer-events: none; + + &.active { + pointer-events: all; + } + } +} + +.menu { + pointer-events: all; + width: 100%; + height: 100%; + max-width: 88px; + display: flex; + flex-direction: column; + align-items: center; + background: #1A1C22; + transition: max-width 0.6s ease-in-out, left 0.4s ease-in-out, transform 0.4s ease-in-out, box-shadow 0.4s ease-in-out; + padding: 48px 16px 0 16px; + + &.active { + max-width: 270px; + background: linear-gradient(90deg, #1A1C22 62.8%, #2F3239 209.77%); + } + + @media screen and (max-width: 768px) { + position: absolute; + max-width: none; + left: -270px; + top: 0; + width: 270px; + pointer-events: none; + + &.active { + left: 0; + pointer-events: all; + } + } +} + +.toggle { + position: relative; + justify-content: flex-end; + width: 100%; + display: flex; + align-items: center; +} + +.logo { + opacity: 0; + position: absolute; + left: 12px; + transition: opacity 0.3s ease-in-out; + + &.active { + opacity: 1; + } +} + +.close { + cursor: pointer; + transform: rotateY(180deg); + padding: 0 12px; + + &.active { + transform: rotateY(0deg); + } + + @media screen and (max-width: 768px) { + padding: 0 20px; + } +} + +.list { + list-style: none; + margin: 0; + padding: 36px 0; + border-bottom: 1px solid #2f3239; + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + overflow: hidden; +} + +.interactions { + padding-top: 16px; + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; +} + +.interactButton { + cursor: pointer; + font-size: 16px; + line-height: 140%; + color: #fff; + border-radius: 10px; + padding: 8px 16px; + display: flex; + gap: 8px; + align-items: center; + overflow: hidden; + transition: background-color 0.2s; + background-color: transparent; + + &:hover { + background-color: #2f3239; + } + + + + &.modalActive { + background-color: #5d9d0b; + } + + span { + transition: opacity 0.2s; + opacity: 0; + pointer-events: none; + margin: 0; + white-space: nowrap; + + + } + + &.active { + //background-color: #5d9d0b; + + span { + opacity: 1; + pointer-events: all; + } + } +} + +.copyright { + opacity: 0; + pointer-events: none; + display: block; + margin-top: auto; + margin-bottom: 32px; + font-weight: 500; + font-size: 11px; + text-transform: capitalize; + color: #86878b; + overflow: hidden; + white-space: nowrap; + transform: translate(-10px, 10px) scale(0.8); /* Move it slightly to the left and bottom */ + transition: transform 0.5s ease-in-out, opacity 0.3s ease-in-out; + + &.active { + transform: translate(0, 0) scale(1); /* Reset translation and scale when active */ + opacity: 1; + pointer-events: all; + } +} + +.mobileIcon { + position: absolute; + top: 47px; + left: 41px; + display: none; + cursor: pointer; + z-index: 999; + + @media screen and (max-width: 768px) { + display: block; + pointer-events: all; + top: 32px; + left: 0; + + &.active { + display: none; + } + } +} diff --git a/client/src/widgets/sidebar/ui/sidebar/sidebar.stories.tsx b/client/src/widgets/sidebar/ui/sidebar/sidebar.stories.tsx new file mode 100644 index 000000000..044ed55e3 --- /dev/null +++ b/client/src/widgets/sidebar/ui/sidebar/sidebar.stories.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { Sidebar } from './sidebar'; + +type Story = StoryObj; +const SidebarTemplate: Story = { render: args => }; + +export const Playground = { ...SidebarTemplate }; +Playground.args = {}; + +export default { + title: 'widgets/Sidebar/Sidebar', + component: Sidebar, + parameters: { + layout: 'fullscreen', + nextjs: { + appDirectory: true, + }, + }, +} as Meta; diff --git a/client/src/widgets/sidebar/ui/sidebar/sidebar.tsx b/client/src/widgets/sidebar/ui/sidebar/sidebar.tsx new file mode 100644 index 000000000..6ae1cf188 --- /dev/null +++ b/client/src/widgets/sidebar/ui/sidebar/sidebar.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { FC, useMemo, useState } from 'react'; +import clsx from 'clsx'; +import { usePathname, useRouter } from 'next/navigation'; + +import { IconWrapper } from '@/shared/ui'; +import { BurgerCloseIcon, LogoSmall, SignOutIcon, SignInIcon } from '@/shared/assets'; +import { useClickOutside } from '@/shared/lib'; +import { getSidebarItems } from '../../config/getSidebarItems'; +import { SidebarItem } from '../sidebar-item/sidebar-item'; +import { SidebarProfile } from '../sidebar-profile/sidebar-profile'; +import { SidebarNotificationsContent } from '../notification-content/notification-content'; + +import styles from './sidebar.module.scss'; +import { useLogout } from '@/entities/session'; +import { LOGIN } from '@/shared/constant'; +import { + InfinityPaginationResultType, + IUserProtectedResponse, + NotificationType, +} from '@teameights/types'; + +interface SidebarProps { + user?: IUserProtectedResponse; + notifications?: InfinityPaginationResultType; +} + +export const Sidebar: FC = ({ user, notifications }) => { + const router = useRouter(); + const pathname = usePathname(); + const { mutate: logoutUser } = useLogout(); + const [isSidebarExpanded, setIsSidebarExpanded] = useState(false); + const [notificationModal, setNotificationModal] = useState(false); + + const isSignedUp = !!user?.username; + + const sidebarItemsData = useMemo(() => { + return getSidebarItems(user); + }, [user]); + + const navigateToPath = (path: string) => { + router.push(path); + }; + + const navMenuRef = useClickOutside( + notificationModal ? () => '' : () => setIsSidebarExpanded(false) + ); + + const handleLogout = () => { + logoutUser(); + }; + + const handleShowSidebar = () => { + setIsSidebarExpanded(prev => !prev); + }; + + return ( + <> + + + + + ); +}; diff --git a/client/yarn.lock b/client/yarn.lock index c107af7c1..74ec62803 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -3427,6 +3427,13 @@ __metadata: languageName: node linkType: hard +"@socket.io/component-emitter@npm:~3.1.0": + version: 3.1.0 + resolution: "@socket.io/component-emitter@npm:3.1.0" + checksum: db069d95425b419de1514dffe945cc439795f6a8ef5b9465715acf5b8b50798e2c91b8719cbf5434b3fe7de179d6cdcd503c277b7871cb3dd03febb69bdd50fa + languageName: node + linkType: hard + "@storybook/addon-a11y@npm:^7.4.5": version: 7.4.5 resolution: "@storybook/addon-a11y@npm:7.4.5" @@ -5187,12 +5194,12 @@ __metadata: languageName: node linkType: hard -"@teameights/types@npm:^1.1.24": - version: 1.1.24 - resolution: "@teameights/types@npm:1.1.24" +"@teameights/types@npm:^1.1.27": + version: 1.1.27 + resolution: "@teameights/types@npm:1.1.27" dependencies: esno: ^0.17.0 - checksum: 4d3fabdddd6cefa9236653b7cbad7582cdf1230e746dc7b807830508b275a6db8d49b5ed7c94b1db0e9c637bd7705e4032174589ad108134b60f423bbbeab7cf + checksum: dc9478e04a3a1f26feddd08abd558a6e280af9d18e9465a9965c158fca11aecfb6d675ee4349b8e9947f69e4efac50318608d24f9db7a9729e16d076f4d25be2 languageName: node linkType: hard @@ -7393,6 +7400,16 @@ __metadata: languageName: node linkType: hard +"bufferutil@npm:^4.0.8": + version: 4.0.8 + resolution: "bufferutil@npm:4.0.8" + dependencies: + node-gyp: latest + node-gyp-build: ^4.3.0 + checksum: 7e9a46f1867dca72fda350966eb468eca77f4d623407b0650913fadf73d5750d883147d6e5e21c56f9d3b0bdc35d5474e80a600b9f31ec781315b4d2469ef087 + languageName: node + linkType: hard + "builtin-status-codes@npm:^3.0.0": version: 3.0.0 resolution: "builtin-status-codes@npm:3.0.0" @@ -7728,7 +7745,7 @@ __metadata: "@tanstack/eslint-plugin-query": ^5.0.0 "@tanstack/react-query": ^5.0.0 "@tanstack/react-query-devtools": ^5.0.1 - "@teameights/types": ^1.1.24 + "@teameights/types": ^1.1.27 "@testing-library/jest-dom": ^6.1.3 "@testing-library/react": ^14.0.0 "@types/jest": ^29.5.5 @@ -7743,6 +7760,7 @@ __metadata: "@uiball/loaders": ^1.3.0 add: ^2.0.6 axios: ^1.5.1 + bufferutil: ^4.0.8 clsx: ^2.0.0 eslint: 8.46.0 eslint-config-next: 13.4.12 @@ -7765,11 +7783,13 @@ __metadata: react-select: ^5.7.4 react-tooltip: ^5.21.3 sass: ^1.64.2 + socket.io-client: ^4.7.2 sonner: ^1.0.3 storybook: 7.2.1 storybook-addon-next-router: ^4.0.2 tsparticles: ^2.12.0 typescript: 5.1.6 + utf-8-validate: ^6.0.3 yarn: ^1.22.19 languageName: unknown linkType: soft @@ -8358,7 +8378,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": +"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:~4.3.1, debug@npm:~4.3.2": version: 4.3.4 resolution: "debug@npm:4.3.4" dependencies: @@ -8913,6 +8933,26 @@ __metadata: languageName: node linkType: hard +"engine.io-client@npm:~6.5.2": + version: 6.5.3 + resolution: "engine.io-client@npm:6.5.3" + dependencies: + "@socket.io/component-emitter": ~3.1.0 + debug: ~4.3.1 + engine.io-parser: ~5.2.1 + ws: ~8.11.0 + xmlhttprequest-ssl: ~2.0.0 + checksum: a72596fae99afbdb899926fccdb843f8fa790c69085b881dde121285a6935da2c2c665ebe88e0e6aa4285637782df84ac882084ff4892ad2430b059fc0045db0 + languageName: node + linkType: hard + +"engine.io-parser@npm:~5.2.1": + version: 5.2.1 + resolution: "engine.io-parser@npm:5.2.1" + checksum: 55b0e8e18500f50c1573675c53597c5552554ead08d3f30ff19fde6409e48f882a8e01f84e9772cd155c18a1d653d06f6bf57b4e1f8b834c63c9eaf3b657b88e + languageName: node + linkType: hard + "enhanced-resolve@npm:^5.12.0, enhanced-resolve@npm:^5.15.0, enhanced-resolve@npm:^5.7.0": version: 5.15.0 resolution: "enhanced-resolve@npm:5.15.0" @@ -13615,6 +13655,17 @@ __metadata: languageName: node linkType: hard +"node-gyp-build@npm:^4.3.0": + version: 4.7.1 + resolution: "node-gyp-build@npm:4.7.1" + bin: + node-gyp-build: bin.js + node-gyp-build-optional: optional.js + node-gyp-build-test: build-test.js + checksum: 2ef8248021489db03be3e8098977cdc797b80a9b12b77c6dcb89b0dc89b8c62e6a482672ee298f61021740ae7f080fb33154cfec8fb158cec620f57b0fae87c0 + languageName: node + linkType: hard + "node-gyp@npm:latest": version: 9.4.0 resolution: "node-gyp@npm:9.4.0" @@ -15874,6 +15925,28 @@ __metadata: languageName: node linkType: hard +"socket.io-client@npm:^4.7.2": + version: 4.7.2 + resolution: "socket.io-client@npm:4.7.2" + dependencies: + "@socket.io/component-emitter": ~3.1.0 + debug: ~4.3.2 + engine.io-client: ~6.5.2 + socket.io-parser: ~4.2.4 + checksum: 8f0ab6b623e014d889bae0cd847ef7826658e8f131bd9367ee5ae4404bb52a6d7b1755b8fbe8e68799b60e92149370a732b381f913b155e40094facb135cd088 + languageName: node + linkType: hard + +"socket.io-parser@npm:~4.2.4": + version: 4.2.4 + resolution: "socket.io-parser@npm:4.2.4" + dependencies: + "@socket.io/component-emitter": ~3.1.0 + debug: ~4.3.1 + checksum: 61540ef99af33e6a562b9effe0fad769bcb7ec6a301aba5a64b3a8bccb611a0abdbe25f469933ab80072582006a78ca136bf0ad8adff9c77c9953581285e2263 + languageName: node + linkType: hard + "socks-proxy-agent@npm:^7.0.0": version: 7.0.0 resolution: "socks-proxy-agent@npm:7.0.0" @@ -17693,6 +17766,16 @@ __metadata: languageName: node linkType: hard +"utf-8-validate@npm:^6.0.3": + version: 6.0.3 + resolution: "utf-8-validate@npm:6.0.3" + dependencies: + node-gyp: latest + node-gyp-build: ^4.3.0 + checksum: 5e21383c81ff7469c1912119ca69d07202d944c73ddd8a54b84dddcc546b939054e5101c78c294e494d206fe93bd43428adc635a0660816b3ec9c8ec89286ac4 + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -18155,6 +18238,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:~8.11.0": + version: 8.11.0 + resolution: "ws@npm:8.11.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 316b33aba32f317cd217df66dbfc5b281a2f09ff36815de222bc859e3424d83766d9eb2bd4d667de658b6ab7be151f258318fb1da812416b30be13103e5b5c67 + languageName: node + linkType: hard + "xml-name-validator@npm:^4.0.0": version: 4.0.0 resolution: "xml-name-validator@npm:4.0.0" @@ -18169,6 +18267,13 @@ __metadata: languageName: node linkType: hard +"xmlhttprequest-ssl@npm:~2.0.0": + version: 2.0.0 + resolution: "xmlhttprequest-ssl@npm:2.0.0" + checksum: 1e98df67f004fec15754392a131343ea92e6ab5ac4d77e842378c5c4e4fd5b6a9134b169d96842cc19422d77b1606b8df84a5685562b3b698cb68441636f827e + languageName: node + linkType: hard + "xtend@npm:^4.0.2, xtend@npm:~4.0.1": version: 4.0.2 resolution: "xtend@npm:4.0.2" diff --git a/server/src/libs/database/migrations/1702842748127-CreateUser.ts b/server/src/libs/database/migrations/1703217496755-CreateUser.ts similarity index 88% rename from server/src/libs/database/migrations/1702842748127-CreateUser.ts rename to server/src/libs/database/migrations/1703217496755-CreateUser.ts index 36e3932ba..8c7eec3df 100644 --- a/server/src/libs/database/migrations/1702842748127-CreateUser.ts +++ b/server/src/libs/database/migrations/1703217496755-CreateUser.ts @@ -1,7 +1,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class CreateUser1702842748127 implements MigrationInterface { - name = 'CreateUser1702842748127'; +export class CreateUser1703217496755 implements MigrationInterface { + name = 'CreateUser1703217496755'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( @@ -16,9 +16,6 @@ export class CreateUser1702842748127 implements MigrationInterface { await queryRunner.query( `CREATE TABLE "universities" ("id" SERIAL NOT NULL, "university" character varying NOT NULL, "degree" character varying NOT NULL, "major" character varying NOT NULL, "admissionDate" date NOT NULL, "graduationDate" date, "userId" integer, CONSTRAINT "PK_8da52f2cee6b407559fdbabf59e" PRIMARY KEY ("id"))` ); - await queryRunner.query( - `CREATE TABLE "jobs" ("id" SERIAL NOT NULL, "title" character varying NOT NULL, "company" character varying NOT NULL, "startDate" date NOT NULL, "endDate" date, "userId" integer, CONSTRAINT "PK_cf0a6c42b72fcc7f7c237def345" PRIMARY KEY ("id"))` - ); await queryRunner.query( `CREATE TABLE "projects" ("id" SERIAL NOT NULL, "title" character varying NOT NULL, "link" character varying NOT NULL, "userId" integer, CONSTRAINT "PK_6271df0a7aed1d6c0691ce6ac50" PRIMARY KEY ("id"))` ); @@ -29,7 +26,13 @@ export class CreateUser1702842748127 implements MigrationInterface { `CREATE TABLE "skills" ("id" SERIAL NOT NULL, "designerTools" text array, "projectManagerTools" text array, "fields" text array, "programmingLanguages" text array, "frameworks" text array, "methodologies" text array, CONSTRAINT "PK_0d3212120f4ecedf90864d7e298" PRIMARY KEY ("id"))` ); await queryRunner.query( - `CREATE TABLE "user" ("id" SERIAL NOT NULL, "email" character varying, "password" character varying, "username" character varying, "provider" character varying NOT NULL DEFAULT 'email', "socialId" character varying, "fullName" character varying, "isLeader" boolean, "country" character varying, "dateOfBirth" date, "speciality" character varying, "description" character varying, "experience" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, "photoId" uuid, "roleId" integer, "statusId" integer, "skillsId" integer, "linksId" integer, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"), CONSTRAINT "UQ_78a916df40e02a9deb1c4b75edb" UNIQUE ("username"), CONSTRAINT "REL_0e51e612eb9ed2fa5ac4f44c7e" UNIQUE ("skillsId"), CONSTRAINT "REL_c5a79824fd8a241f5a7ec428b3" UNIQUE ("linksId"), CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))` + `CREATE TYPE "public"."notification_type_enum" AS ENUM('system', 'team_invitation')` + ); + await queryRunner.query( + `CREATE TABLE "notification" ("id" SERIAL NOT NULL, "read" boolean NOT NULL DEFAULT false, "type" "public"."notification_type_enum" NOT NULL, "data" jsonb NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "receiverId" integer, CONSTRAINT "PK_705b6c7cdf9b2c2ff7ac7872cb7" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "user" ("id" SERIAL NOT NULL, "email" character varying, "password" character varying, "username" character varying, "provider" character varying NOT NULL DEFAULT 'email', "socialId" character varying, "fullName" character varying, "isLeader" boolean, "country" character varying, "dateOfBirth" date, "speciality" character varying, "description" character varying, "experience" character varying, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "photoId" uuid, "roleId" integer, "statusId" integer, "skillsId" integer, "linksId" integer, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"), CONSTRAINT "UQ_78a916df40e02a9deb1c4b75edb" UNIQUE ("username"), CONSTRAINT "REL_0e51e612eb9ed2fa5ac4f44c7e" UNIQUE ("skillsId"), CONSTRAINT "REL_c5a79824fd8a241f5a7ec428b3" UNIQUE ("linksId"), CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))` ); await queryRunner.query( `CREATE INDEX "IDX_9bd2fe7a8e694dedc4ec2f666f" ON "user" ("socialId") ` @@ -42,10 +45,7 @@ export class CreateUser1702842748127 implements MigrationInterface { ); await queryRunner.query(`CREATE INDEX "IDX_5cb2b3e0419a73a360d327d497" ON "user" ("country") `); await queryRunner.query( - `CREATE TYPE "public"."notification_type_enum" AS ENUM('system', 'team_invitation')` - ); - await queryRunner.query( - `CREATE TABLE "notification" ("id" SERIAL NOT NULL, "read" boolean NOT NULL DEFAULT false, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "type" "public"."notification_type_enum" NOT NULL, "data" jsonb NOT NULL, "receiverId" integer, CONSTRAINT "PK_705b6c7cdf9b2c2ff7ac7872cb7" PRIMARY KEY ("id"))` + `CREATE TABLE "jobs" ("id" SERIAL NOT NULL, "title" character varying NOT NULL, "company" character varying NOT NULL, "startDate" date NOT NULL, "endDate" date, "userId" integer, CONSTRAINT "PK_cf0a6c42b72fcc7f7c237def345" PRIMARY KEY ("id"))` ); await queryRunner.query( `CREATE TABLE "session" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, "userId" integer, CONSTRAINT "PK_f55da76ac1c3ac420f444d2ff11" PRIMARY KEY ("id"))` @@ -57,10 +57,10 @@ export class CreateUser1702842748127 implements MigrationInterface { `ALTER TABLE "universities" ADD CONSTRAINT "FK_a8ad75b47a153c0d91f8360c9fb" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` ); await queryRunner.query( - `ALTER TABLE "jobs" ADD CONSTRAINT "FK_79ae682707059d5f7655db4212a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + `ALTER TABLE "projects" ADD CONSTRAINT "FK_361a53ae58ef7034adc3c06f09f" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` ); await queryRunner.query( - `ALTER TABLE "projects" ADD CONSTRAINT "FK_361a53ae58ef7034adc3c06f09f" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + `ALTER TABLE "notification" ADD CONSTRAINT "FK_758d70a0e61243171e785989070" FOREIGN KEY ("receiverId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "user" ADD CONSTRAINT "FK_75e2be4ce11d447ef43be0e374f" FOREIGN KEY ("photoId") REFERENCES "file"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` @@ -78,7 +78,7 @@ export class CreateUser1702842748127 implements MigrationInterface { `ALTER TABLE "user" ADD CONSTRAINT "FK_c5a79824fd8a241f5a7ec428b3e" FOREIGN KEY ("linksId") REFERENCES "links"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` ); await queryRunner.query( - `ALTER TABLE "notification" ADD CONSTRAINT "FK_758d70a0e61243171e785989070" FOREIGN KEY ("receiverId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + `ALTER TABLE "jobs" ADD CONSTRAINT "FK_79ae682707059d5f7655db4212a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "session" ADD CONSTRAINT "FK_3d2f174ef04fb312fdebd0ddc53" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` @@ -89,34 +89,34 @@ export class CreateUser1702842748127 implements MigrationInterface { await queryRunner.query( `ALTER TABLE "session" DROP CONSTRAINT "FK_3d2f174ef04fb312fdebd0ddc53"` ); - await queryRunner.query( - `ALTER TABLE "notification" DROP CONSTRAINT "FK_758d70a0e61243171e785989070"` - ); + await queryRunner.query(`ALTER TABLE "jobs" DROP CONSTRAINT "FK_79ae682707059d5f7655db4212a"`); await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_c5a79824fd8a241f5a7ec428b3e"`); await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_0e51e612eb9ed2fa5ac4f44c7e1"`); await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_dc18daa696860586ba4667a9d31"`); await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_c28e52f758e7bbc53828db92194"`); await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_75e2be4ce11d447ef43be0e374f"`); + await queryRunner.query( + `ALTER TABLE "notification" DROP CONSTRAINT "FK_758d70a0e61243171e785989070"` + ); await queryRunner.query( `ALTER TABLE "projects" DROP CONSTRAINT "FK_361a53ae58ef7034adc3c06f09f"` ); - await queryRunner.query(`ALTER TABLE "jobs" DROP CONSTRAINT "FK_79ae682707059d5f7655db4212a"`); await queryRunner.query( `ALTER TABLE "universities" DROP CONSTRAINT "FK_a8ad75b47a153c0d91f8360c9fb"` ); await queryRunner.query(`DROP INDEX "public"."IDX_3d2f174ef04fb312fdebd0ddc5"`); await queryRunner.query(`DROP TABLE "session"`); - await queryRunner.query(`DROP TABLE "notification"`); - await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`); + await queryRunner.query(`DROP TABLE "jobs"`); await queryRunner.query(`DROP INDEX "public"."IDX_5cb2b3e0419a73a360d327d497"`); await queryRunner.query(`DROP INDEX "public"."IDX_8bceb9ec5c48c54f7a3f11f31b"`); await queryRunner.query(`DROP INDEX "public"."IDX_035190f70c9aff0ef331258d28"`); await queryRunner.query(`DROP INDEX "public"."IDX_9bd2fe7a8e694dedc4ec2f666f"`); await queryRunner.query(`DROP TABLE "user"`); + await queryRunner.query(`DROP TABLE "notification"`); + await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`); await queryRunner.query(`DROP TABLE "skills"`); await queryRunner.query(`DROP TABLE "links"`); await queryRunner.query(`DROP TABLE "projects"`); - await queryRunner.query(`DROP TABLE "jobs"`); await queryRunner.query(`DROP TABLE "universities"`); await queryRunner.query(`DROP TABLE "file"`); await queryRunner.query(`DROP TABLE "status"`); diff --git a/server/src/libs/database/typeorm-config.service.ts b/server/src/libs/database/typeorm-config.service.ts index c035d241d..5a3e3e072 100644 --- a/server/src/libs/database/typeorm-config.service.ts +++ b/server/src/libs/database/typeorm-config.service.ts @@ -21,7 +21,8 @@ export class TypeOrmConfigService implements TypeOrmOptionsFactory { }), dropSchema: false, keepConnectionAlive: true, - logging: this.configService.get('app.nodeEnv', { infer: true }) !== 'production', + logging: false, + // logging: this.configService.get('app.nodeEnv', { infer: true }) !== 'production', entities: [__dirname + '/../../**/*.entity{.ts,.js}'], migrations: [__dirname + '/migrations/**/*{.ts,.js}'], cli: { diff --git a/server/src/modules/auth/base/auth.module.ts b/server/src/modules/auth/base/auth.module.ts index 17785a2ff..7b239d455 100644 --- a/server/src/modules/auth/base/auth.module.ts +++ b/server/src/modules/auth/base/auth.module.ts @@ -11,9 +11,17 @@ import { IsExist } from 'src/utils/validators/is-exists.validator'; import { IsNotExist } from 'src/utils/validators/is-not-exists.validator'; import { SessionModule } from 'src/modules/session/session.module'; import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy'; +import { NotificationsModule } from '../../notifications/notifications.module'; @Module({ - imports: [UsersModule, SessionModule, PassportModule, MailModule, JwtModule.register({})], + imports: [ + UsersModule, + SessionModule, + PassportModule, + MailModule, + JwtModule.register({}), + NotificationsModule, + ], controllers: [AuthController], providers: [IsExist, IsNotExist, AuthService, JwtStrategy, JwtRefreshStrategy, AnonymousStrategy], exports: [AuthService], diff --git a/server/src/modules/auth/base/auth.service.ts b/server/src/modules/auth/base/auth.service.ts index 9b0c176cc..a0b66950f 100644 --- a/server/src/modules/auth/base/auth.service.ts +++ b/server/src/modules/auth/base/auth.service.ts @@ -23,6 +23,7 @@ import { SessionService } from 'src/modules/session/session.service'; import { JwtRefreshPayloadType } from './strategies/types/jwt-refresh-payload.type'; import { Session } from 'src/modules/session/entities/session.entity'; import { JwtPayloadType } from './strategies/types/jwt-payload.type'; +import { NotificationsService } from '../../notifications/notifications.service'; @Injectable() export class AuthService { @@ -31,6 +32,7 @@ export class AuthService { private usersService: UsersService, private sessionService: SessionService, private mailService: MailService, + private notificationsService: NotificationsService, private configService: ConfigService ) {} @@ -149,6 +151,8 @@ export class AuthService { status, }); + await this.sendWelcomeNotification(user); + user = await this.usersService.findOne({ id: user.id, }); @@ -214,6 +218,8 @@ export class AuthService { } ); + await this.sendWelcomeNotification(user); + await this.mailService.userSignUp({ to: dto.email, data: { @@ -480,4 +486,14 @@ export class AuthService { tokenExpires, }; } + + private async sendWelcomeNotification(user: User) { + await this.notificationsService.createNotification({ + receiver: user.id, + type: 'system', + data: { + system_message: 'Welcome to Teameights!', + }, + }); + } } diff --git a/server/src/modules/notifications/dto/create-notification.dto.ts b/server/src/modules/notifications/dto/create-notification.dto.ts index 0191db131..88bc110ff 100644 --- a/server/src/modules/notifications/dto/create-notification.dto.ts +++ b/server/src/modules/notifications/dto/create-notification.dto.ts @@ -1,7 +1,7 @@ import { ApiExtraModels, ApiProperty } from '@nestjs/swagger'; -import { IsIn, IsNotEmpty, IsObject, IsString, ValidateNested } from 'class-validator'; +import { IsIn, IsNotEmpty, IsNumber, IsObject, IsString, ValidateNested } from 'class-validator'; import { Transform, Type } from 'class-transformer'; -import { lowerCaseTransformer } from '../../../utils/transformers/lower-case.transformer'; + export class SystemNotificationDataDto { @IsString() @IsNotEmpty() @@ -27,10 +27,10 @@ export class SystemNotificationDataDto { @ApiExtraModels(SystemNotificationDataDto) export class CreateNotificationDto { - @ApiProperty({ example: 'nmashchenko' }) - @Transform(lowerCaseTransformer) - @IsNotEmpty() - receiver: string; + @ApiProperty({ example: '1' }) + @Transform(({ value }) => (value ? Number(value) : undefined)) + @IsNumber() + receiver: number; @ApiProperty({ enum: ['system', 'team_invitation'] }) @IsNotEmpty({ message: 'mustBeNotEmpty' }) diff --git a/server/src/modules/notifications/dto/read-notifications.dto.ts b/server/src/modules/notifications/dto/read-notifications.dto.ts new file mode 100644 index 000000000..28d46ff39 --- /dev/null +++ b/server/src/modules/notifications/dto/read-notifications.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty } from 'class-validator'; + +export class ReadNotificationsDto { + @ApiProperty({ example: ['1', '2'] }) + @ArrayNotEmpty() + notification_ids: string[]; +} diff --git a/server/src/modules/notifications/entities/notification.entity.ts b/server/src/modules/notifications/entities/notification.entity.ts index 53bd4fbb6..652052e23 100644 --- a/server/src/modules/notifications/entities/notification.entity.ts +++ b/server/src/modules/notifications/entities/notification.entity.ts @@ -1,4 +1,12 @@ -import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; import { User } from '../../users/entities/user.entity'; import { NotificationTypeData, NotificationTypesEnum } from '../types/notification.type'; @@ -16,12 +24,18 @@ export class Notification { @Column({ type: Boolean, default: false }) read: boolean; - @CreateDateColumn() - createdAt: Date; - @Column({ type: 'enum', enum: NotificationTypesEnum }) type: NotificationTypesEnum; @Column({ type: 'jsonb' }) data: NotificationTypeData; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ type: 'timestamptz' }) + deletedAt: Date; } diff --git a/server/src/modules/notifications/notifications.controller.ts b/server/src/modules/notifications/notifications.controller.ts index a00b0f5fa..7ae153ae2 100644 --- a/server/src/modules/notifications/notifications.controller.ts +++ b/server/src/modules/notifications/notifications.controller.ts @@ -24,6 +24,7 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Notification } from './entities/notification.entity'; import { infinityPagination } from '../../utils/infinity-pagination'; import { QueryNotificationDto } from './dto/query-notification.dto'; +import { ReadNotificationsDto } from './dto/read-notifications.dto'; @ApiTags('Notifications') @Controller({ @@ -51,10 +52,10 @@ export class NotificationsController { @Get('') async findAll(@Request() request, @Query() query: QueryNotificationDto) { const page = query?.page ?? 1; - let limit = query?.limit ?? 10; + let limit = query?.limit ?? 50; - if (limit > 10) { - limit = 10; + if (limit > 50) { + limit = 50; } return infinityPagination( @@ -80,13 +81,13 @@ export class NotificationsController { return this.notificationService.findOne({ id: +id }); } - @Patch(':id') + @Patch() @ApiBearerAuth() @Roles(RoleEnum.user) @UseGuards(AuthGuard('jwt')) @HttpCode(HttpStatus.OK) - async readUnreadNotification(@Request() request, @Param('id') id: number) { - await this.notificationService.readNotification(id, request.user); + async readUnreadNotification(@Request() request, @Body() dto: ReadNotificationsDto) { + await this.notificationService.readNotification(dto, request.user); } @Delete(':id') diff --git a/server/src/modules/notifications/notifications.gateway.ts b/server/src/modules/notifications/notifications.gateway.ts index a02cee128..692876287 100644 --- a/server/src/modules/notifications/notifications.gateway.ts +++ b/server/src/modules/notifications/notifications.gateway.ts @@ -8,7 +8,11 @@ import { Socket, Server } from 'socket.io'; import { Notification } from './entities/notification.entity'; import { InsertEvent } from 'typeorm'; -@WebSocketGateway() +@WebSocketGateway({ + cors: { + origin: '*', + }, +}) export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server: Server; diff --git a/server/src/modules/notifications/notifications.module.ts b/server/src/modules/notifications/notifications.module.ts index 12d078970..8abb59da1 100644 --- a/server/src/modules/notifications/notifications.module.ts +++ b/server/src/modules/notifications/notifications.module.ts @@ -12,5 +12,6 @@ import { NotificationSubscriber } from './subscribers/notification.subscriber'; imports: [TypeOrmModule.forFeature([Notification, User])], controllers: [NotificationsController], providers: [NotificationsService, UsersService, NotificationsGateway, NotificationSubscriber], + exports: [NotificationsService], }) export class NotificationsModule {} diff --git a/server/src/modules/notifications/notifications.service.ts b/server/src/modules/notifications/notifications.service.ts index 395087d9a..fb94471ca 100644 --- a/server/src/modules/notifications/notifications.service.ts +++ b/server/src/modules/notifications/notifications.service.ts @@ -10,6 +10,7 @@ import { NullableType } from '../../utils/types/nullable.type'; import { FilterNotificationDto, SortNotificationDto } from './dto/query-notification.dto'; import { IPaginationOptions } from '../../utils/types/pagination-options'; import { JwtPayloadType } from '../auth/base/strategies/types/jwt-payload.type'; +import { ReadNotificationsDto } from './dto/read-notifications.dto'; @Injectable() export class NotificationsService { @@ -19,35 +20,39 @@ export class NotificationsService { private readonly usersService: UsersService ) {} - public async readNotification(id: number, userJwtPayload: JwtPayloadType) { - const notification = await this.findOne({ id: id }); + public async readNotification(dto: ReadNotificationsDto, userJwtPayload: JwtPayloadType) { + const notificationIds = dto.notification_ids; - if (!notification) { - throw new HttpException( - { - status: HttpStatus.NOT_FOUND, - errors: { - notification: `notification with id: ${id} was not found`, + for (const id of notificationIds) { + const notification = await this.findOne({ id: Number(id) }); + + if (!notification) { + throw new HttpException( + { + status: HttpStatus.NOT_FOUND, + errors: { + notification: `notification with id: ${id} was not found`, + }, }, - }, - HttpStatus.NOT_FOUND - ); - } + HttpStatus.NOT_FOUND + ); + } - if (notification.receiver.id !== userJwtPayload.id) { - throw new HttpException( - { - status: HttpStatus.UNAUTHORIZED, - errors: { - notification: `current user can't update this notification. administrator was notified about this action.`, + if (notification.receiver.id !== userJwtPayload.id) { + throw new HttpException( + { + status: HttpStatus.UNAUTHORIZED, + errors: { + notification: `current user can't update this notification. administrator was notified about this action.`, + }, }, - }, - HttpStatus.UNAUTHORIZED - ); - } + HttpStatus.UNAUTHORIZED + ); + } - notification.read = !notification.read; - await this.notificationRepository.save(notification); + notification.read = true; + await this.notificationRepository.save(notification); + } } public async deleteNotification(id: number, userJwtPayload: JwtPayloadType) { @@ -56,12 +61,12 @@ export class NotificationsService { if (!notification) { throw new HttpException( { - status: HttpStatus.UNPROCESSABLE_ENTITY, + status: HttpStatus.NOT_FOUND, errors: { notification: `notification with id: ${id} was not found`, }, }, - HttpStatus.UNPROCESSABLE_ENTITY + HttpStatus.NOT_FOUND ); } @@ -134,14 +139,14 @@ export class NotificationsService { } async createNotification(dto: CreateNotificationDto) { - const user = await this.usersService.findOne({ username: dto.receiver }); + const user = await this.usersService.findOne({ id: dto.receiver }); if (!user) { throw new HttpException( { status: HttpStatus.NOT_FOUND, errors: { - user: `user ${dto.receiver} was not found`, + user: `user with id: ${dto.receiver} was not found`, }, }, HttpStatus.NOT_FOUND diff --git a/server/src/modules/users/entities/user.entity.ts b/server/src/modules/users/entities/user.entity.ts index 82a5e07bb..7b422f4e2 100644 --- a/server/src/modules/users/entities/user.entity.ts +++ b/server/src/modules/users/entities/user.entity.ts @@ -140,18 +140,19 @@ export class User extends EntityHelper { @JoinColumn() links?: Links; + @Exclude({ toPlainOnly: true }) @OneToMany(() => Notification, notifications => notifications.receiver) notifications: Notification[]; // @ManyToOne(() => Team, team => team.users) // team: Team; - @CreateDateColumn() + @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; - @UpdateDateColumn() + @UpdateDateColumn({ type: 'timestamptz' }) updatedAt: Date; - @DeleteDateColumn() + @DeleteDateColumn({ type: 'timestamptz' }) deletedAt: Date; }