all repos — caroster @ 7db1095303bf50aae8d79ba8389e7d1f05c81d19

[Octree] Group carpool to your event https://caroster.io

feat: ✨ Add notifications panel
Maylie maylie@octree.ch
Wed, 31 Jan 2024 12:06:56 +0000
commit

7db1095303bf50aae8d79ba8389e7d1f05c81d19

parent

2ab1b88d8d526f5d2b9ef0f2ad936dbfcd6aa2c9

M docker-compose.ymldocker-compose.yml

@@ -37,6 +37,8 @@ psql:

image: postgres volumes: - psql_data:/var/lib/postgresql/data + ports: + - 5432:5432 environment: POSTGRES_PASSWORD: 6Akfg28GAU POSTGRES_DB: caroster
A frontend/containers/DrawerNotification/CardNotification.tsx

@@ -0,0 +1,85 @@

+import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { + NotificationEntity, + useReadNotificationsMutation, +} from '../../generated/graphql'; +import Stack from '@mui/material/Stack'; +import Badge from '@mui/material/Badge'; +import {useRouter} from 'next/navigation'; +import {useTranslation} from 'react-i18next'; +import {formatDate} from './formatDate'; + +interface NotificationProps { + notification: NotificationEntity; + onClose: () => void; +} + +const CardNotification: React.FC<NotificationProps> = ({ + notification, + onClose, +}: NotificationProps) => { + const router = useRouter(); + const {t} = useTranslation(); + const [readNotifications] = useReadNotificationsMutation(); + + const eventName = notification.attributes.event.data?.attributes?.name; + + const handleClick = () => { + router.push(`/e/${notification.attributes.event.data.attributes.uuid}`); + readNotifications({ + refetchQueries: ['UserNotifications'], + variables: {id: notification.id}, + }); + onClose(); + }; + + const showBadge = !notification.attributes.read; + const notificationContentKey = `notification.type.${notification.attributes.type}.content`; + const notificationContent = t(notificationContentKey); + + return ( + <Box + padding={2} + bgcolor="white" + marginBottom={2} + onClick={handleClick} + sx={{cursor: 'pointer'}} + borderRadius={1} + > + <Box> + <Stack + paddingBottom={1} + direction="row" + display="flex" + justifyContent="space-between" + spacing={2} + > + <Box display="flex" alignItems="center" sx={{width: '168px'}}> + {showBadge && ( + <Badge + sx={{pr: 2}} + color="error" + variant="dot" + anchorOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + /> + )} + <Typography variant="subtitle1" noWrap> + {eventName} + </Typography> + </Box> + + <Typography variant="overline" color="text.secondary"> + {formatDate(notification.attributes.createdAt)} + </Typography> + </Stack> + <Typography>{notificationContent}</Typography> + </Box> + </Box> + ); +}; + +export default CardNotification;
A frontend/containers/DrawerNotification/DrawerContent.tsx

@@ -0,0 +1,91 @@

+import Drawer from '@mui/material/Drawer'; +import Box from '@mui/material/Box'; +import { + useUserNotificationsQuery, + useReadNotificationsMutation, +} from '../../generated/graphql'; +import CardNotification from './CardNotification'; +import DrawerHeader from './DrawerHeader'; +import Icon from '@mui/material/Icon'; +import Typography from '@mui/material/Typography'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import {useTranslation} from 'react-i18next'; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +const DrawerContent: React.FC<Props> = ({isOpen, onClose}) => { + const {data} = useUserNotificationsQuery(); + const [readNotifications] = useReadNotificationsMutation(); + const {t} = useTranslation(); + + const notifications = data?.me?.profile?.notifications?.data || []; + + const hasNotifications = notifications.length > 0; + + const markAllRead = () => { + readNotifications({refetchQueries: ['UserNotifications']}); + }; + const isAllRead = notifications.every( + notification => notification.attributes.read + ); + + const isMobile = useMediaQuery('(max-width:400px)'); + + return ( + <Drawer + anchor="right" + open={isOpen} + onClose={onClose} + hideBackdrop={true} + sx={{ + height: 'auto', + '& .MuiDrawer-paper': { + width: isMobile ? '100%' : '375px', + maxWidth: '100%', + }, + }} + > + <Box + bgcolor="background.default" + padding={2} + sx={{height: '100%', overflow: 'auto'}} + > + <DrawerHeader + onClose={onClose} + markAllRead={markAllRead} + disabled={isAllRead} + /> + + <Box> + {hasNotifications ? ( + notifications.map((notification, index) => ( + <CardNotification + key={notification.id} + onClose={onClose} + notification={notification} + isRead={readNotifications[index]} + /> + )) + ) : ( + <Box + display="flex" + alignItems="center" + justifyContent="center" + paddingY={4} + > + <Icon>inbox</Icon> + <Typography color="initial" sx={{pl: 2}}> + {t`notifications.content`} + </Typography> + </Box> + )} + </Box> + </Box> + </Drawer> + ); +}; + +export default DrawerContent;
A frontend/containers/DrawerNotification/DrawerHeader.tsx

@@ -0,0 +1,59 @@

+import React from 'react'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import Icon from '@mui/material/Icon'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import {useTranslation} from 'react-i18next'; +import useMediaQuery from '@mui/material/useMediaQuery'; + +const DrawerHeader = ({onClose, markAllRead, disabled}) => { + const {t} = useTranslation(); + const isMobile = useMediaQuery('(max-width:400px)'); + return ( + <Box> + {!isMobile && ( + <Box display="flex" justifyContent="flex-end" alignItems="flex-end"> + <IconButton + sx={{marginRight: 0}} + color="inherit" + edge="end" + aria-label="close" + onClick={onClose} + id="CloseBtn" + > + <Icon>close</Icon> + </IconButton> + </Box> + )} + <Box + display="flex" + alignItems="center" + justifyContent="space-between" + paddingBottom={2} + > + <Box display="flex" alignItems="center"> + {isMobile && ( + <IconButton + sx={{marginRight: 0}} + color="inherit" + edge="end" + aria-label="close" + onClick={onClose} + id="CloseBtn" + > + <Icon>chevron_left</Icon> + </IconButton> + )} + <Typography variant="h3">{`${t('notifications.title')}`}</Typography> + </Box> + <Button + onClick={markAllRead} + disabled={disabled} + >{t`notifications.markAllRead`}</Button> + </Box> + </Box> + ); +}; + +export default DrawerHeader;
A frontend/containers/DrawerNotification/formatDate.ts

@@ -0,0 +1,14 @@

+import moment from 'moment'; +import { useTranslation } from 'react-i18next'; + +export const formatDate = (dateString: string) => { + const momentDate = moment(dateString); + const isToday = momentDate.isSame(moment(), 'day'); + const { t } = useTranslation(); + + if (isToday) { + return `${t('date.today')}, ${momentDate.format('hh:mm')}`; + } else { + return momentDate.format('DD/MM/YY, H:mm'); + } +};
A frontend/containers/DrawerNotification/index.tsx

@@ -0,0 +1,48 @@

+import Icon from '@mui/material/Icon'; +import IconButton from '@mui/material/IconButton'; +import React, {useState} from 'react'; +import {useUserNotificationsQuery} from '../../generated/graphql'; +import Badge from '@mui/material/Badge'; +import DrawerContent from './DrawerContent'; + +const DrawerNotification = () => { + const POLL_INTERVAL = 30000; + const {data} = useUserNotificationsQuery({ + pollInterval: POLL_INTERVAL, + }); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + const notifications = data?.me?.profile?.notifications?.data || []; + + const hasUnreadNotifications = notifications.some( + notification => !notification.attributes.read + ); + + return ( + <> + <IconButton + sx={{marginRight: 0}} + color="inherit" + edge="end" + id="NotificationBtn" + aria-label="notifications" + onClick={() => setIsDrawerOpen(true)} + > + {hasUnreadNotifications ? ( + <Badge color="error" variant="dot"> + <Icon>notifications_none_outlined</Icon> + </Badge> + ) : ( + <Icon>notifications_none_outlined</Icon> + )} + </IconButton> + + <DrawerContent + isOpen={isDrawerOpen} + onClose={() => setIsDrawerOpen(false)} + /> + </> + ); +}; + +export default DrawerNotification;
M frontend/containers/EventBar/index.tsxfrontend/containers/EventBar/index.tsx

@@ -11,10 +11,14 @@ import useShare from '../../hooks/useShare';

import GenericMenu from '../GenericMenu'; import useActions from './useActions'; import UserIcon from './UserIcon'; +import {useSession} from 'next-auth/react'; +import DrawerNotification from '../DrawerNotification'; const EventBar = ({event, onAdd, goBack, title}) => { - const {share} = useShare(); + const session = useSession(); + const isAuthenticated = session.status === 'authenticated'; const theme = useTheme(); + const {share} = useShare(); const [anchorEl, setAnchorEl] = useState(null); const menuActions = useActions({onAdd, eventId: event?.id});

@@ -80,11 +84,7 @@ size="large"

> <Icon>share</Icon> </IconButton> - <GenericMenu - anchorEl={anchorEl} - setAnchorEl={setAnchorEl} - actions={menuActions} - /> + {isAuthenticated && <DrawerNotification />} <IconButton color="inherit" edge="end"

@@ -94,6 +94,11 @@ size="large"

> <UserIcon /> </IconButton> + <GenericMenu + anchorEl={anchorEl} + setAnchorEl={setAnchorEl} + actions={menuActions} + /> </> </Toolbar> </AppBar>
M frontend/containers/GenericToolbar/index.tsxfrontend/containers/GenericToolbar/index.tsx

@@ -11,6 +11,8 @@ import AppBar from '@mui/material/AppBar';

import useProfile from '../../hooks/useProfile'; import GenericMenu from '../GenericMenu'; import {ActionType} from '../GenericMenu/Action'; +import {useSession} from 'next-auth/react'; +import DrawerNotification from '../DrawerNotification'; const GenericToolbar = ({ title,

@@ -26,6 +28,9 @@ const theme = useTheme();

const [anchorEl, setAnchorEl] = useState(null); const {profile, connected} = useProfile(); + + const session = useSession(); + const isAuthenticated = session.status === 'authenticated'; useEffect(() => { window.scrollTo(0, 0);

@@ -73,37 +78,40 @@ <Typography variant="h2" noWrap sx={{pt: 3}}>

{title} </Typography> </Box> - {actions.length > 0 && ( - <> - <IconButton - color="inherit" - edge="end" - id="MenuMoreInfo" - onClick={e => setAnchorEl(e.currentTarget)} - size="large" - > - {connected && profile ? ( - <Avatar - sx={{ - width: theme.spacing(3), - height: theme.spacing(3), - fontSize: 16, - }} - > - {`${profile.username[0]}`.toUpperCase()} - </Avatar> - ) : ( - <Icon>more_vert</Icon> - )} - </IconButton> + <Box> + {isAuthenticated && <DrawerNotification />} + {actions.length > 0 && ( + <> + <IconButton + color="inherit" + edge="end" + id="MenuMoreInfo" + onClick={e => setAnchorEl(e.currentTarget)} + size="large" + > + {connected && profile ? ( + <Avatar + sx={{ + width: theme.spacing(3), + height: theme.spacing(3), + fontSize: 16, + }} + > + {`${profile.username[0]}`.toUpperCase()} + </Avatar> + ) : ( + <Icon>more_vert</Icon> + )} + </IconButton> - <GenericMenu - anchorEl={anchorEl} - setAnchorEl={setAnchorEl} - actions={[...actions, {divider: true}]} - /> - </> - )} + <GenericMenu + anchorEl={anchorEl} + setAnchorEl={setAnchorEl} + actions={[...actions, {divider: true}]} + /> + </> + )} + </Box> </Toolbar> </AppBar> );
M frontend/generated/graphql.tsxfrontend/generated/graphql.tsx

@@ -1886,6 +1886,20 @@

export type EventByUuidQuery = { __typename?: 'Query', eventByUUID?: { __typename?: 'EventEntityResponse', data?: { __typename?: 'EventEntity', id?: string | null, attributes?: { __typename?: 'Event', uuid?: string | null, name: string, description?: string | null, email: string, date?: any | null, address?: string | null, latitude?: number | null, longitude?: number | null, position?: any | null, waitingPassengers?: { __typename?: 'PassengerRelationResponseCollection', data: Array<{ __typename?: 'PassengerEntity', id?: string | null, attributes?: { __typename?: 'Passenger', name: string, email?: string | null, location?: string | null, user?: { __typename?: 'UsersPermissionsUserEntityResponse', data?: { __typename?: 'UsersPermissionsUserEntity', id?: string | null, attributes?: { __typename?: 'UsersPermissionsUser', firstName?: string | null, lastName?: string | null } | null } | null } | null } | null }> } | null, travels?: { __typename?: 'TravelRelationResponseCollection', data: Array<{ __typename?: 'TravelEntity', id?: string | null, attributes?: { __typename?: 'Travel', meeting?: string | null, meeting_latitude?: number | null, meeting_longitude?: number | null, departure?: any | null, details?: string | null, vehicleName?: string | null, phone_number?: string | null, seats?: number | null, passengers?: { __typename?: 'PassengerRelationResponseCollection', data: Array<{ __typename?: 'PassengerEntity', id?: string | null, attributes?: { __typename?: 'Passenger', name: string, location?: string | null, user?: { __typename?: 'UsersPermissionsUserEntityResponse', data?: { __typename?: 'UsersPermissionsUserEntity', id?: string | null, attributes?: { __typename?: 'UsersPermissionsUser', firstName?: string | null, lastName?: string | null } | null } | null } | null } | null }> } | null } | null }> } | null } | null } | null } | null }; +export type UserNotificationsQueryVariables = Exact<{ + maxItems?: InputMaybe<Scalars['Int']['input']>; +}>; + + +export type UserNotificationsQuery = { __typename?: 'Query', me?: { __typename?: 'UsersPermissionsMe', profile?: { __typename?: 'UsersPermissionsUser', notifications?: { __typename?: 'NotificationRelationResponseCollection', data: Array<{ __typename?: 'NotificationEntity', id?: string | null, attributes?: { __typename?: 'Notification', type: Enum_Notification_Type, read?: boolean | null, createdAt?: any | null, event?: { __typename?: 'EventEntityResponse', data?: { __typename?: 'EventEntity', id?: string | null, attributes?: { __typename?: 'Event', name: string, uuid?: string | null } | null } | null } | null } | null }> } | null } | null } | null }; + +export type ReadNotificationsMutationVariables = Exact<{ + id?: InputMaybe<Scalars['ID']['input']>; +}>; + + +export type ReadNotificationsMutation = { __typename?: 'Mutation', readNotifications?: { __typename?: 'NotificationEntityResponseCollection', data: Array<{ __typename?: 'NotificationEntity', id?: string | null, attributes?: { __typename?: 'Notification', type: Enum_Notification_Type, read?: boolean | null } | null }> } | null }; + export type PassengerFieldsFragment = { __typename?: 'PassengerEntity', id?: string | null, attributes?: { __typename?: 'Passenger', name: string, location?: string | null, email?: string | null, user?: { __typename?: 'UsersPermissionsUserEntityResponse', data?: { __typename?: 'UsersPermissionsUserEntity', id?: string | null, attributes?: { __typename?: 'UsersPermissionsUser', firstName?: string | null, lastName?: string | null } | null } | null } | null } | null }; export type CreatePassengerMutationVariables = Exact<{

@@ -2360,6 +2374,100 @@ }

export type EventByUuidQueryHookResult = ReturnType<typeof useEventByUuidQuery>; export type EventByUuidLazyQueryHookResult = ReturnType<typeof useEventByUuidLazyQuery>; export type EventByUuidQueryResult = Apollo.QueryResult<EventByUuidQuery, EventByUuidQueryVariables>; +export const UserNotificationsDocument = gql` + query UserNotifications($maxItems: Int = 20) { + me { + profile { + notifications(pagination: {limit: $maxItems}, sort: "createdAt:DESC") { + data { + id + attributes { + type + read + createdAt + event { + data { + id + attributes { + name + uuid + } + } + } + } + } + } + } + } +} + `; + +/** + * __useUserNotificationsQuery__ + * + * To run a query within a React component, call `useUserNotificationsQuery` and pass it any options that fit your needs. + * When your component renders, `useUserNotificationsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useUserNotificationsQuery({ + * variables: { + * maxItems: // value for 'maxItems' + * }, + * }); + */ +export function useUserNotificationsQuery(baseOptions?: Apollo.QueryHookOptions<UserNotificationsQuery, UserNotificationsQueryVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery<UserNotificationsQuery, UserNotificationsQueryVariables>(UserNotificationsDocument, options); + } +export function useUserNotificationsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<UserNotificationsQuery, UserNotificationsQueryVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery<UserNotificationsQuery, UserNotificationsQueryVariables>(UserNotificationsDocument, options); + } +export type UserNotificationsQueryHookResult = ReturnType<typeof useUserNotificationsQuery>; +export type UserNotificationsLazyQueryHookResult = ReturnType<typeof useUserNotificationsLazyQuery>; +export type UserNotificationsQueryResult = Apollo.QueryResult<UserNotificationsQuery, UserNotificationsQueryVariables>; +export const ReadNotificationsDocument = gql` + mutation readNotifications($id: ID) { + readNotifications(id: $id) { + data { + id + attributes { + type + read + } + } + } +} + `; +export type ReadNotificationsMutationFn = Apollo.MutationFunction<ReadNotificationsMutation, ReadNotificationsMutationVariables>; + +/** + * __useReadNotificationsMutation__ + * + * To run a mutation, you first call `useReadNotificationsMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useReadNotificationsMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [readNotificationsMutation, { data, loading, error }] = useReadNotificationsMutation({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useReadNotificationsMutation(baseOptions?: Apollo.MutationHookOptions<ReadNotificationsMutation, ReadNotificationsMutationVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation<ReadNotificationsMutation, ReadNotificationsMutationVariables>(ReadNotificationsDocument, options); + } +export type ReadNotificationsMutationHookResult = ReturnType<typeof useReadNotificationsMutation>; +export type ReadNotificationsMutationResult = Apollo.MutationResult<ReadNotificationsMutation>; +export type ReadNotificationsMutationOptions = Apollo.BaseMutationOptions<ReadNotificationsMutation, ReadNotificationsMutationVariables>; export const CreatePassengerDocument = gql` mutation createPassenger($passenger: PassengerInput!) { createPassenger(data: $passenger) {
A frontend/graphql/notifications.gql

@@ -0,0 +1,38 @@

+query UserNotifications($maxItems: Int = 20) { + me { + profile { + notifications(pagination: {limit: $maxItems}, sort: "createdAt:DESC") { + data { + id + attributes { + type + read + createdAt + event { + data { + id + attributes { + name + uuid + } + } + } + } + } + } + } + } +} + +mutation readNotifications($id: ID) { + readNotifications(id: $id) { + data { + id + attributes { + type + read + } + } + } +} +
M frontend/locales/en.jsonfrontend/locales/en.json

@@ -17,6 +17,7 @@ "dashboard.sections.noDate_plural": "Carosters without date",

"dashboard.sections.past": "Caroster passed", "dashboard.sections.past_plural": "Past carosters", "dashboard.title": "$t(menu.dashboard)", + "date.today": "Today", "drawer.information": "Information", "drawer.travels": "Travels", "drawer.waitingList": "Waiting list",

@@ -95,6 +96,11 @@ "menu.logout": "Logout",

"menu.new_event": "Create a caroster", "menu.profile": "My profile", "menu.register": "Sign-Up", + "notifications.title": "Notifications", + "notifications.markAllRead": "Mark all in read", + "notifications.content": "No notification", + "notification.type.NewPassengerInYourTrip.content": "A passenger has been added to your trip.", + "notification.type.NewTrip.content": "A departure close to you is available.", "passenger.actions.place": "Assign", "passenger.actions.remove_alert": "Are you sure you want to remove <italic> <bold> {{name}} </bold> </italic> from the waitlist?", "passenger.availability.seats": "{{count}} seat available",
M frontend/locales/fr.jsonfrontend/locales/fr.json

@@ -17,6 +17,7 @@ "dashboard.sections.noDate_plural": "Carosters sans date",

"dashboard.sections.past": "Caroster passé", "dashboard.sections.past_plural": "Carosters passés", "dashboard.title": "$t(menu.dashboard)", + "date.today": "Aujourd'hui", "drawer.information": "Information", "drawer.travels": "Trajets", "drawer.waitingList": "Liste d'attente",

@@ -95,6 +96,11 @@ "menu.logout": "Se déconnecter",

"menu.new_event": "Créer un caroster", "menu.profile": "Mon profil", "menu.register": "Créer un compte", + "notifications.title": "Notifications", + "notifications.markAllRead": "Tout marquer en lu", + "notifications.content": "Aucune notification", + "notification.type.NewPassengerInYourTrip.content": "Un passager a été ajouté à votre trajet.", + "notification.type.NewTrip.content": "Un départ proche de vous est disponible. ", "passenger.actions.place": "Placer", "passenger.actions.remove_alert": "Voulez-vous vraiment supprimer <italic><bold>{{name}}</bold></italic> de la liste d'attente ?", "passenger.availability.seats": "{{count}} place disponible",