diff --git a/CHANGELOG.md b/CHANGELOG.md index 162497c61..a3701c400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,19 @@ ## Neste versjon +## versjon 2023.17.11 +- ✨ **QR kode**. Du kan nå generere dine egne QR kode. +- ✨ **Aktiviteter**. Det kan nå opprettes aktiviteter for interessegrupper. + ## versjon 2023.06.11 - ✨ **Arrangement**. Du kan nå se hvilken plass du har på ventelisten. - ✨ **Arrangement**. Admins kan nå manuelt legge til deltagere på et arrangement. - ⚡ **Kalender**. Kalender pop-up viser viser infromasjonen bedre. - ✨ **Bannere**. Tidsbegrensede bannere kan nå opprettes. +## versjon 2023.14.10 +- ✨ **QR-kode generator**. Brukere kan nå generere sine egne QR koder fra en url. + ## versjon 2023.24.09 - 🎨 **Fult navn**. Kontaktperson på arrangementer vil nå bli vist med fult navn. diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 7b592703c..6699285e4 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -48,6 +48,7 @@ const LogIn = lazy(() => import('pages/LogIn')); const NewsAdministration = lazy(() => import('pages/NewsAdministration')); const Wiki = lazy(() => import('pages/Wiki')); const ShortLinks = lazy(() => import('pages/ShortLinks')); +const QRCodes = lazy(() => import('pages/QRCodes')); const SignUp = lazy(() => import('pages/SignUp')); const StrikeAdmin = lazy(() => import('pages/StrikeAdmin')); const Toddel = lazy(() => import('pages/Toddel')); @@ -148,6 +149,7 @@ const AppRoutes = () => { } />} path={`${URLS.cheatsheet}:studyId/:classId/`} /> } />} path={`${URLS.cheatsheet}*`} /> } />} path={URLS.shortLinks} /> + } />} path={URLS.qrCodes} /> } />} path={URLS.bannerAdmin}> } /> diff --git a/src/URLS.tsx b/src/URLS.tsx index 5c490520e..c679fde0e 100644 --- a/src/URLS.tsx +++ b/src/URLS.tsx @@ -52,6 +52,7 @@ const URLS = { profile: '/profil/', signup: '/ny-bruker/', shortLinks: '/linker/', + qrCodes: '/qr-koder/', gallery: '/galleri/', userAdmin: '/admin/brukere/', strikeAdmin: '/admin/prikker/', diff --git a/src/api/api.tsx b/src/api/api.tsx index fa82760e8..8c3033b83 100644 --- a/src/api/api.tsx +++ b/src/api/api.tsx @@ -48,6 +48,7 @@ import { PaginationResponse, Picture, PublicRegistration, + QRCode, Registration, RequestResponse, ShortLink, @@ -96,6 +97,7 @@ export const NOTIFICATIONS_ENDPOINT = 'notifications'; export const NOTIFICATION_SETTINGS_ENDPOINT = 'notification-settings'; export const WIKI_ENDPOINT = 'pages'; export const SHORT_LINKS_ENDPOINT = 'short-links'; +export const QR_CODE_ENDPOINT = 'qr-codes'; export const STRIKES_ENDPOINT = 'strikes'; export const SUBMISSIONS_ENDPOINT = 'submissions'; export const USERS_ENDPOINT = 'users'; @@ -231,6 +233,11 @@ export default { createShortLink: (item: ShortLink) => IFetch({ method: 'POST', url: `${SHORT_LINKS_ENDPOINT}/`, data: item }), deleteShortLink: (slug: string) => IFetch({ method: 'DELETE', url: `${SHORT_LINKS_ENDPOINT}/${slug}/` }), + // QR codes + getQRCodes: (filters?: any) => IFetch>({ method: 'GET', url: `${QR_CODE_ENDPOINT}/`, data: filters || {} }), + createQRCode: (item: QRCode) => IFetch({ method: 'POST', url: `${QR_CODE_ENDPOINT}/`, data: item }), + deleteQRCode: (id: number) => IFetch({ method: 'DELETE', url: `${QR_CODE_ENDPOINT}/${String(id)}/` }), + // Gallery getGallery: (id: Gallery['id']) => IFetch({ method: 'GET', url: `${GALLERY_ENDPOINT}/${id}/` }), getGalleries: (filters?: any) => IFetch>({ method: 'GET', url: `${GALLERY_ENDPOINT}/`, data: filters || {} }), diff --git a/src/components/miscellaneous/EventListItem.tsx b/src/components/miscellaneous/EventListItem.tsx index 4d24a37b4..4746be243 100644 --- a/src/components/miscellaneous/EventListItem.tsx +++ b/src/components/miscellaneous/EventListItem.tsx @@ -81,8 +81,6 @@ const EventListItem = ({ event, sx }: EventListItemProps) => { const { observe, width } = useDimensions(); const theme = useTheme(); - const getColor = () => theme.palette.colors[event.organizer?.slug.toLowerCase() === Groups.NOK.toLowerCase() ? 'nok_event' : 'other_event']; - const [height, titleFontSize, contentFontSize] = useMemo(() => { if (width < 400) { return [68, 18, 13]; @@ -97,6 +95,18 @@ const EventListItem = ({ event, sx }: EventListItemProps) => { const { data: categories = [] } = useCategories(); const categoryLabel = `${event.organizer ? `${event.organizer.name} | ` : ''}${categories.find((c) => c.id === event.category)?.text || 'Laster...'}`; + const getColor = () => { + if (categories.find((c) => c.id === event.category)?.text === 'Aktivitet') { + return theme.palette.colors.activity_event; + } + + if (event.organizer?.slug.toLowerCase() === Groups.NOK.toLowerCase()) { + return theme.palette.colors.nok_event; + } + + return theme.palette.colors.other_event; + }; + return ( { items: [ { text: 'Kokebok', to: URLS.cheatsheet }, { text: 'Link-forkorter', to: URLS.shortLinks }, + { text: 'QR koder', to: URLS.qrCodes }, { text: 'Badges ledertavler', to: URLS.badges.index }, { text: 'Galleri', to: URLS.gallery }, ], diff --git a/src/hooks/QRCode.tsx b/src/hooks/QRCode.tsx new file mode 100644 index 000000000..58450d23e --- /dev/null +++ b/src/hooks/QRCode.tsx @@ -0,0 +1,29 @@ +import { useMutation, UseMutationResult, useQuery, useQueryClient } from 'react-query'; + +import { QRCode, RequestResponse } from 'types'; + +import API from 'api/api'; + +export const QR_CODE_QUERY_KEY = 'qr-code'; + +export const useQRCodes = () => { + return useQuery, RequestResponse>([QR_CODE_QUERY_KEY], () => API.getQRCodes()); +}; + +export const useCreateQRCode = (): UseMutationResult => { + const queryClient = useQueryClient(); + return useMutation((item) => API.createQRCode(item), { + onSuccess: () => { + queryClient.invalidateQueries(QR_CODE_QUERY_KEY); + }, + }); +}; + +export const useDeleteQRCode = (id: number): UseMutationResult => { + const queryClient = useQueryClient(); + return useMutation(() => API.deleteQRCode(id), { + onSuccess: () => { + queryClient.invalidateQueries(QR_CODE_QUERY_KEY); + }, + }); +}; diff --git a/src/pages/Events/components/ActivitiesDefaultView.tsx b/src/pages/Events/components/ActivitiesDefaultView.tsx new file mode 100644 index 000000000..bb1303061 --- /dev/null +++ b/src/pages/Events/components/ActivitiesDefaultView.tsx @@ -0,0 +1,151 @@ +import { Button, Divider, Stack, Theme, useMediaQuery } from '@mui/material'; +import { makeStyles } from 'makeStyles'; +import { useCallback, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; +import { argsToParams } from 'utils'; + +import { useEvents } from 'hooks/Event'; +import { useIsAuthenticated } from 'hooks/User'; +import { useAnalytics } from 'hooks/Utils'; + +import Bool from 'components/inputs/Bool'; +import SubmitButton from 'components/inputs/SubmitButton'; +import TextField from 'components/inputs/TextField'; +import Expand from 'components/layout/Expand'; +import Pagination from 'components/layout/Pagination'; +import Paper from 'components/layout/Paper'; +import EventListItem, { EventListItemLoading } from 'components/miscellaneous/EventListItem'; +import NotFoundIndicator from 'components/miscellaneous/NotFoundIndicator'; + +const useStyles = makeStyles()((theme) => ({ + grid: { + display: 'grid', + gridTemplateColumns: '3fr 1fr', + gridGap: theme.spacing(2), + alignItems: 'self-start', + paddingBottom: theme.spacing(2), + + [theme.breakpoints.down('lg')]: { + gridTemplateColumns: '1fr', + }, + }, + list: { + display: 'grid', + gridTemplateColumns: '1fr', + gap: theme.spacing(1), + [theme.breakpoints.down('lg')]: { + order: 1, + }, + }, + settings: { + display: 'grid', + gridGap: theme.spacing(1), + position: 'sticky', + top: 80, + + [theme.breakpoints.down('lg')]: { + order: 0, + position: 'static', + top: 0, + }, + }, +})); + +type Filters = { + activity: boolean; + search?: string; + open_for_sign_up?: boolean; + user_favorite?: boolean; + expired: boolean; +}; + +const ActivitiesDefaultView = () => { + const isAuthenticated = useIsAuthenticated(); + const { event } = useAnalytics(); + const getInitialFilters = useCallback((): Filters => { + const params = new URLSearchParams(location.search); + const activity = true; + const expired = params.get('expired') ? Boolean(params.get('expired') === 'true') : false; + const open_for_sign_up = params.get('open_for_sign_up') ? Boolean(params.get('open_for_sign_up') === 'true') : undefined; + const user_favorite = params.get('user_favorite') ? Boolean(params.get('user_favorite') === 'true') : undefined; + const search = params.get('search') || undefined; + return { activity, expired, search, open_for_sign_up, user_favorite }; + }, []); + const { classes } = useStyles(); + const navigate = useNavigate(); + const lgDown = useMediaQuery((theme: Theme) => theme.breakpoints.down('lg')); + const [filters, setFilters] = useState(getInitialFilters()); + const { data, error, hasNextPage, fetchNextPage, isLoading, isFetching } = useEvents(filters); + const events = useMemo(() => (data ? data.pages.map((page) => page.results).flat() : []), [data]); + const { register, control, handleSubmit, setValue, formState } = useForm({ defaultValues: getInitialFilters() }); + const isEmpty = useMemo(() => (data !== undefined ? !data.pages.some((page) => Boolean(page.results.length)) : false), [data]); + + const resetFilters = () => { + setValue('search', ''); + setValue('expired', false); + setValue('user_favorite', false); + setFilters({ activity: true, expired: false, open_for_sign_up: false, user_favorite: false }); + navigate(`${location.pathname}${argsToParams({ expired: false })}`, { replace: true }); + }; + + const search = (data: Filters) => { + event('search', 'events', JSON.stringify(data)); + setFilters(data); + navigate(`${location.pathname}${argsToParams(data)}`, { replace: true }); + !lgDown || setSearchFormExpanded((prev) => !prev); + }; + + const [searchFormExpanded, setSearchFormExpanded] = useState(false); + + const SearchForm = () => ( +
+ + + + {isAuthenticated && } + + Søk + + + + + ); + + return ( + <> +
+
+ {isLoading && } + {isEmpty && } + {error && {error.detail}} + {data !== undefined && ( + fetchNextPage()}> + + {events.map((event) => ( + + ))} + + + )} + {isFetching && } +
+ {lgDown ? ( +
+ setSearchFormExpanded((prev) => !prev)}> + + +
+ ) : ( + + + + )} +
+ + ); +}; + +export default ActivitiesDefaultView; diff --git a/src/pages/Events/components/EventsDefaultView.tsx b/src/pages/Events/components/EventsDefaultView.tsx index 00267f625..eef4fb763 100644 --- a/src/pages/Events/components/EventsDefaultView.tsx +++ b/src/pages/Events/components/EventsDefaultView.tsx @@ -88,6 +88,7 @@ const EventsDefaultView = () => { setValue('category', ''); setValue('search', ''); setValue('expired', false); + setValue('user_favorite', false); setFilters({ expired: false, open_for_sign_up: false, user_favorite: false }); navigate(`${location.pathname}${argsToParams({ expired: false })}`, { replace: true }); }; @@ -106,11 +107,13 @@ const EventsDefaultView = () => { {Boolean(categories.length) && ( )} diff --git a/src/pages/Events/index.tsx b/src/pages/Events/index.tsx index e82c92d39..bff3dd936 100644 --- a/src/pages/Events/index.tsx +++ b/src/pages/Events/index.tsx @@ -1,3 +1,4 @@ +import CelebrationIcon from '@mui/icons-material/Celebration'; import DateRange from '@mui/icons-material/DateRangeRounded'; import Reorder from '@mui/icons-material/ReorderRounded'; import { Collapse, Skeleton } from '@mui/material'; @@ -9,12 +10,15 @@ import Banner from 'components/layout/Banner'; import Tabs from 'components/layout/Tabs'; import Page from 'components/navigation/Page'; +import ActivitiesDefaultView from './components/ActivitiesDefaultView'; + const EventsCalendarView = lazy(() => import(/* webpackChunkName: "events_calendar" */ 'pages/Landing/components/EventsCalendarView')); const Events = () => { const listTab = { value: 'list', label: 'Liste', icon: Reorder }; + const activityTab = { value: 'activity', label: 'Aktiviteter', icon: CelebrationIcon }; const calendarTab = { value: 'calendar', label: 'Kalender', icon: DateRange }; - const tabs = [listTab, calendarTab]; + const tabs = [listTab, activityTab, calendarTab]; const [tab, setTab] = useState(listTab.value); return ( @@ -23,6 +27,9 @@ const Events = () => { + + + }> diff --git a/src/pages/Landing/components/ActivityEventsListView.tsx b/src/pages/Landing/components/ActivityEventsListView.tsx new file mode 100644 index 000000000..1d44747b2 --- /dev/null +++ b/src/pages/Landing/components/ActivityEventsListView.tsx @@ -0,0 +1,76 @@ +import { Stack, styled, Theme, Typography, useMediaQuery } from '@mui/material'; +import { useCallback, useState } from 'react'; + +import { useEvents } from 'hooks/Event'; + +import EventListItem, { EventListItemLoading } from 'components/miscellaneous/EventListItem'; + +const Container = styled('div')(({ theme }) => ({ + display: 'grid', + alignItems: 'self-start', + gridTemplateColumns: '1fr 1fr', + gap: theme.spacing(1), +})); + +const Text = styled(Typography)(({ theme }) => ({ + color: theme.palette.text.secondary, + p: 0.5, +})); + +const NO_OF_EVENTS_TO_SHOW = 6; +const NO_OF_EVENTS_TO_SHOW_MD_DOWN = 4; + +type Filters = { + activity: boolean; +}; + +const ActivityEventsListView = () => { + const mdDown = useMediaQuery((theme: Theme) => theme.breakpoints.down('md')); + const getInitialFilters = useCallback((): Filters => { + const activity = true; + return { activity }; + }, []); + const [filters] = useState(getInitialFilters()); + + const { data, isLoading } = useEvents(filters); + + if (isLoading) { + return ( + + + + + + ); + } else if (!data?.pages[0]?.results.length) { + return ( + + Ingen kommende arrangementer + + ); + } else if (mdDown) { + return ( + + {data?.pages[0]?.results.slice(0, NO_OF_EVENTS_TO_SHOW_MD_DOWN).map((event) => ( + + ))} + + ); + } + + return ( + + {data?.pages[0].results.length ? ( + data?.pages[0]?.results.slice(0, NO_OF_EVENTS_TO_SHOW).map((event) => ) + ) : ( + + + Ingen kommende aktiviteter + + + )} + + ); +}; + +export default ActivityEventsListView; diff --git a/src/pages/Landing/components/EventsCalendarView.tsx b/src/pages/Landing/components/EventsCalendarView.tsx index 3e6ad5095..63099125d 100644 --- a/src/pages/Landing/components/EventsCalendarView.tsx +++ b/src/pages/Landing/components/EventsCalendarView.tsx @@ -7,6 +7,7 @@ import { ReactNode, useEffect, useMemo, useState } from 'react'; import { Category, EventList } from 'types'; import { Groups } from 'types/Enums'; +import { useCategories } from 'hooks/Categories'; import { useEvents } from 'hooks/Event'; import { useAnalytics } from 'hooks/Utils'; @@ -40,7 +41,19 @@ const Appointment = ({ children, data }: AppointmentProps) => { setAnchorEl(null); }; - const getColor = (event: EventList) => theme.palette.colors[event.organizer?.slug.toLowerCase() === Groups.NOK.toLowerCase() ? 'nok_event' : 'other_event']; + const { data: categories = [] } = useCategories(); + + const getColor = (event: EventList) => { + if (categories.find((c) => c.id === event.category)?.text === 'Aktivitet') { + return theme.palette.colors.activity_event; + } + + if (event.organizer?.slug.toLowerCase() === Groups.NOK.toLowerCase()) { + return theme.palette.colors.nok_event; + } + + return theme.palette.colors.other_event; + }; return ( <> + + + setRemoveDialogOpen(false)} + onConfirm={remove} + open={removeDialogOpen} + titleText='Er du sikker?' + /> + + ); +}; + +export default QRCodeItem; diff --git a/src/pages/QRCodes/index.tsx b/src/pages/QRCodes/index.tsx new file mode 100644 index 000000000..1df2abd5b --- /dev/null +++ b/src/pages/QRCodes/index.tsx @@ -0,0 +1,77 @@ +import { Grid, Stack, Typography } from '@mui/material'; +import { useForm } from 'react-hook-form'; + +import { QRCode } from 'types'; + +import { useCreateQRCode, useQRCodes } from 'hooks/QRCode'; +import { useSnackbar } from 'hooks/Snackbar'; +import { useAnalytics } from 'hooks/Utils'; + +import SubmitButton from 'components/inputs/SubmitButton'; +import TextField from 'components/inputs/TextField'; +import Banner from 'components/layout/Banner'; +import Paper from 'components/layout/Paper'; +import NotFoundIndicator from 'components/miscellaneous/NotFoundIndicator'; +import Page from 'components/navigation/Page'; + +import QRCodeItem from './components/QRCodeItem'; + +const QRCodes = () => { + const { event } = useAnalytics(); + const { data, error, isFetching } = useQRCodes(); + const createQRCode = useCreateQRCode(); + const showSnackbar = useSnackbar(); + const { register, formState, handleSubmit, reset } = useForm(); + + const create = (data: QRCode) => { + createQRCode.mutate(data, { + onSuccess: () => { + showSnackbar('QR koden ble opprettet', 'success'); + reset(); + event('create', 'qr-code', `Created ${data.name}`); + }, + onError: (e) => { + showSnackbar(e.detail, 'error'); + }, + }); + }; + + return ( + } options={{ title: 'QR koder' }}> + + + {error && {error.detail}} + {data !== undefined && ( + <> + {!data.length && } + {data.map((qrCode) => ( + + ))} + + )} + + + +
+ Ny QR kode + + + + Opprett + + +
+
+
+ ); +}; + +export default QRCodes; diff --git a/src/theme.tsx b/src/theme.tsx index a64d3c5b1..0a35c4410 100644 --- a/src/theme.tsx +++ b/src/theme.tsx @@ -25,6 +25,7 @@ declare module '@mui/material/styles/createPalette' { tihlde: string; nok_event: string; other_event: string; + activity_event: string; gradient: { main: { top: string; @@ -59,6 +60,7 @@ declare module '@mui/material/styles/createPalette' { tihlde: string; nok_event: string; other_event: string; + activity_event: string; gradient: { main: { top: string; @@ -194,6 +196,7 @@ export const getTheme = (theme: ThemeTypes, prefersDarkMode: boolean) => { tihlde: '#1c458a', nok_event: get({ light: '#83C4F8', dark: '#83C4F8' }), other_event: get({ light: '#FFA675', dark: '#FFA675' }), + activity_event: get({ light: '#9778ce', dark: '#7e57c2' }), gradient: { main: { top: get({ light: '#16356e', dark: '#0d2339' }), diff --git a/src/types/Misc.tsx b/src/types/Misc.tsx index 61cd2c85d..d34d3ae1b 100644 --- a/src/types/Misc.tsx +++ b/src/types/Misc.tsx @@ -79,3 +79,10 @@ export interface Warning { type: WarningType; updated_at: string; } + +export interface QRCode { + id: number; + created_at: string; + name: string; + content: string; +}