diff --git a/src/features/events/components/ActivistEventPage/Map.tsx b/src/features/events/components/ActivistEventPage/Map.tsx new file mode 100644 index 0000000000..65511b4ff6 --- /dev/null +++ b/src/features/events/components/ActivistEventPage/Map.tsx @@ -0,0 +1,73 @@ +import 'leaflet/dist/leaflet.css'; +import { CSSProperties } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { divIcon, latLngBounds, Map as MapType } from 'leaflet'; +import { MapContainer, Marker, TileLayer, useMap } from 'react-leaflet'; + +import SelectedMarker from 'features/events/components/LocationModal/SelectedMarker'; + +const MapWrapper = ({ + children, +}: { + children: (map: MapType) => JSX.Element; +}) => { + const map = useMap(); + return children(map); +}; + +type MapProps = { + interactive: boolean; + location: { + id: number; + lat: number; + lng: number; + }; + style: CSSProperties; + zoomLevel: number; +}; + +const Map = ({ interactive, location, style, zoomLevel }: MapProps) => { + return ( + + + {(map) => { + map.setView({ lat: location.lat, lng: location.lng }, zoomLevel); + + return ( + <> + + + ), + iconAnchor: [0, 0], + })} + position={[location.lat, location.lng]} + /> + + ); + }} + + + ); +}; + +export default Map; diff --git a/src/features/events/components/LocationModal/SelectedMarker.tsx b/src/features/events/components/LocationModal/SelectedMarker.tsx index 9142524c0b..ad3ce22063 100644 --- a/src/features/events/components/LocationModal/SelectedMarker.tsx +++ b/src/features/events/components/LocationModal/SelectedMarker.tsx @@ -1,8 +1,8 @@ -import { FC } from 'react'; +import { CSSProperties, FC } from 'react'; -const SelectedMarker: FC = () => { +const SelectedMarker: FC<{ style?: CSSProperties }> = ({ style }) => { return ( - + m.organization.id == orgId + ); + + const signup = async () => { + dispatch(signupAdd()); + await apiClient.put( + `/api/orgs/${orgId}/actions/${eventId}/responses/${membership?.profile.id}` + ); + dispatch( + signupAdded({ + action_id: eventId, + id: membership!.profile.id, + person: membership!.profile, + response_date: new Date().toUTCString(), + }) + ); + }; + const undoSignup = async () => { + dispatch(signupRemove()); + await apiClient.delete( + `/api/orgs/${orgId}/actions/${eventId}/responses/${membership?.profile.id}` + ); + dispatch(signupRemoved(eventId)); + }; + + return new ResolvedFuture({ + myResponseState: eventResponseState( + eventId, + bookingsFuture.data!, + membership!, + signupsFuture.data! + ), + signup, + undoSignup, + }); +} diff --git a/src/features/events/hooks/useEventSignups.ts b/src/features/events/hooks/useEventSignups.ts new file mode 100644 index 0000000000..a16ff5c1bf --- /dev/null +++ b/src/features/events/hooks/useEventSignups.ts @@ -0,0 +1,18 @@ +import { IFuture } from 'core/caching/futures'; +import { loadListIfNecessary } from 'core/caching/cacheUtils'; +import { ZetkinEventResponse } from 'utils/types/zetkin'; +import { signupsLoad, signupsLoaded } from 'features/events/store'; +import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; + +export default function useEventSignups(): IFuture { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + const signupsList = useAppSelector((state) => state.events.signupsList); + + return loadListIfNecessary(signupsList, dispatch, { + actionOnLoad: () => dispatch(signupsLoad()), + actionOnSuccess: (data) => dispatch(signupsLoaded(data)), + loader: () => + apiClient.get(`/api/users/me/action_responses`), + }); +} diff --git a/src/features/events/l10n/messageIds.ts b/src/features/events/l10n/messageIds.ts index 8a5efb80e0..6b935dc3a9 100644 --- a/src/features/events/l10n/messageIds.ts +++ b/src/features/events/l10n/messageIds.ts @@ -1,6 +1,22 @@ import { m, makeMessages } from 'core/i18n'; export default makeMessages('feat.events', { + activistPortal: { + bookedMessage: m( + 'You are booked! If you want to cancel, reach out to contact person' + ), + contactPerson: m('Responsible for this event'), + joinOrgButton: m('Join organization'), + linkText: m('Link to event information'), + loadingButton: m('Loading...'), + loadingTitle: m('Loading event...'), + missingTitle: m('Nameless event'), + notInOrgMessage: m('You are not member of this org yet'), + showBigMap: m('Show on map'), + signedUp: m('Signed up'), + signupButton: m('Sign up'), + undoSignupButton: m('Undo signup'), + }, addPerson: { addButton: m('Add person'), addPlaceholder: m('Start typing to add participant'), diff --git a/src/features/events/store.ts b/src/features/events/store.ts index 1dabcfe17b..131b65c4de 100644 --- a/src/features/events/store.ts +++ b/src/features/events/store.ts @@ -49,6 +49,7 @@ export type FilterCategoryType = | 'selectedTypes'; export interface EventsStoreSlice { + bookingsList: RemoteList; eventList: RemoteList; eventsByCampaignId: Record>; eventsByDate: Record>; @@ -62,11 +63,13 @@ export interface EventsStoreSlice { participantsByEventId: Record>; respondentsByEventId: Record>; selectedEventIds: number[]; + signupsList: RemoteList; statsByEventId: Record>; typeList: RemoteList; } const initialState: EventsStoreSlice = { + bookingsList: remoteList(), eventList: remoteList(), eventsByCampaignId: {}, eventsByDate: {}, @@ -80,6 +83,7 @@ const initialState: EventsStoreSlice = { participantsByEventId: {}, respondentsByEventId: {}, selectedEventIds: [], + signupsList: remoteList(), statsByEventId: {}, typeList: remoteList(), }; @@ -88,6 +92,13 @@ const eventsSlice = createSlice({ initialState, name: 'events', reducers: { + bookingsLoad: (state) => { + state.bookingsList.isLoading = true; + }, + bookingsLoaded: (state, action: PayloadAction) => { + state.bookingsList = remoteList(action.payload); + state.bookingsList.loaded = new Date().toISOString(); + }, campaignEventsLoad: (state, action: PayloadAction) => { const id = action.payload; state.eventsByCampaignId[id] = remoteList(); @@ -515,6 +526,33 @@ const eventsSlice = createSlice({ state.respondentsByEventId[eventId] = remoteList(respondents); state.respondentsByEventId[eventId].loaded = new Date().toISOString(); }, + signupAdd: (state) => { + state.signupsList.isLoading = true; + }, + signupAdded: (state, action: PayloadAction) => { + const eventResponse = action.payload; + state.signupsList.isLoading = false; + state.signupsList.items.push( + remoteItem(eventResponse.action_id, { data: eventResponse }) + ); + }, + signupRemove: (state) => { + state.signupsList.isLoading = true; + }, + signupRemoved: (state, action: PayloadAction) => { + const eventId = action.payload; + state.signupsList.isLoading = false; + state.signupsList.items = state.signupsList.items.filter( + (s) => s.data?.action_id != eventId + ); + }, + signupsLoad: (state) => { + state.signupsList.isLoading = true; + }, + signupsLoaded: (state, action: PayloadAction) => { + state.signupsList = remoteList(action.payload); + state.signupsList.loaded = new Date().toISOString(); + }, statsLoad: (state, action: PayloadAction) => { const eventId = action.payload; state.statsByEventId[eventId] = remoteItem(eventId); @@ -573,6 +611,8 @@ const eventsSlice = createSlice({ export default eventsSlice; export const { + bookingsLoad, + bookingsLoaded, campaignEventsLoad, campaignEventsLoaded, eventCreate, @@ -609,6 +649,12 @@ export const { resetSelection, respondentsLoad, respondentsLoaded, + signupAdd, + signupAdded, + signupRemove, + signupRemoved, + signupsLoad, + signupsLoaded, statsLoad, statsLoaded, typeAdd, diff --git a/src/pages/api/[...path].ts b/src/pages/api/[...path].ts index 2ebadd286f..9535cadefe 100644 --- a/src/pages/api/[...path].ts +++ b/src/pages/api/[...path].ts @@ -4,7 +4,6 @@ import { applySession } from 'next-session'; import type { NextApiRequest, NextApiResponse } from 'next'; import { AppSession } from 'utils/types'; -import getFilters from 'utils/getFilters'; import { stringToBool } from 'utils/stringUtils'; import { ZetkinZResource, ZetkinZResult } from 'utils/types/sdk'; @@ -20,9 +19,8 @@ interface HttpVerbMethod { const HTTP_VERBS_TO_ZETKIN_METHODS: Record = { DELETE: (resource: ZetkinZResource) => resource.del(), - GET: (resource: ZetkinZResource, req: NextApiRequest) => { - const filters = getFilters(req); - return resource.get(null, null, filters); + GET: (resource: ZetkinZResource) => { + return resource.get(); }, PATCH: (resource: ZetkinZResource, req: NextApiRequestWithSession) => resource.patch(req.body), diff --git a/src/pages/o/[orgId]/events/[eventId]/index.tsx b/src/pages/o/[orgId]/events/[eventId]/index.tsx index de17609a0b..ddff97d5b8 100644 --- a/src/pages/o/[orgId]/events/[eventId]/index.tsx +++ b/src/pages/o/[orgId]/events/[eventId]/index.tsx @@ -1,7 +1,37 @@ -import { FC } from 'react'; +import 'leaflet/dist/leaflet.css'; +import dynamic from 'next/dynamic'; +import Head from 'next/head'; +import NextLink from 'next/link'; +import { + Avatar, + Box, + Button, + Drawer, + Link, + Modal, + Typography, +} from '@mui/material'; +import { + BeachAccess, + CalendarToday, + Done, + Link as LinkIcon, + Place, +} from '@mui/icons-material'; +import { FC, useState } from 'react'; + +import messageIds from 'features/events/l10n/messageIds'; +import { Msg } from 'core/i18n'; import { scaffold } from 'utils/next'; +import useEvent from 'features/events/hooks/useEvent'; +import useEventSignup from 'features/events/hooks/useEventSignup'; +import useServerSide from 'core/useServerSide'; +import { ZetkinEvent } from 'utils/types/zetkin'; +import ZUIDateTime from 'zui/ZUIDateTime'; +import ZUIFuture from 'zui/ZUIFuture'; const scaffoldOptions = { + allowNonMembers: true, allowNonOfficials: true, authLevelRequired: 1, }; @@ -22,11 +52,204 @@ type PageProps = { orgId: string; }; +const Map = dynamic( + () => import('features/events/components/ActivistEventPage/Map'), + { ssr: false } +); + +const ContactPerson: FC> = ({ contact = null }) => + contact && ( + + + + + + + {contact.name} + + + ); + const Page: FC = ({ orgId, eventId }) => { + const eventFuture = useEvent(parseInt(orgId, 10), parseInt(eventId, 10)); + const onServer = useServerSide(); + const eventSignupFuture = useEventSignup( + parseInt(orgId, 10), + parseInt(eventId, 10) + ); + const [showBigMap, setShowBigMap] = useState(false); + + if (onServer) { + return null; + } + return ( -

- Page for org {orgId}, event {eventId} -

+ <> + + + <Msg id={messageIds.activistPortal.loadingTitle} /> + + + + {(event) => { + const location = event.location; + return ( + <> + + + {event.title || ( + <Msg id={messageIds.activistPortal.missingTitle} /> + )} + + + + + {event.title || ( + + )} + + + + + {event.organization.title} + + + + {event.activity && ( + + + {event.activity.title} + + )} + + + + + {location && ( + + + + {location.title} + + setShowBigMap(true)} + underline="hover" + > + + + + )} + {event.url && ( + + + + + + + )} + + {event.info_text && {event.info_text}} + {location && ( + + + + )} + + + + + + + } + > + {({ myResponseState, signup, undoSignup }) => + myResponseState == 'notSignedUp' ? ( + + ) : myResponseState == 'signedUp' ? ( + + ) : myResponseState == 'booked' ? ( + <> + + + + + + + ) : ( + <> + + + + + + + + ) + } + + + + setShowBigMap(false)} open={showBigMap}> + + + + + + ); + }} + + ); }; diff --git a/src/utils/next.ts b/src/utils/next.ts index db62bd8b73..7959d8f612 100644 --- a/src/utils/next.ts +++ b/src/utils/next.ts @@ -49,6 +49,7 @@ interface ResultWithProps { interface ScaffoldOptions { // Level can be 1 (simple sign-in) or 2 (two-factor authentication) authLevelRequired?: number; + allowNonMembers?: boolean; allowNonOfficials?: boolean; localeScope?: string[]; } @@ -155,7 +156,7 @@ export const scaffold = if (!ctx.user?.is_superuser) { //if the org is in your memberships, come in //if not, more checks - if (!hasOrg(reqWithSession, orgId)) { + if (!options?.allowNonMembers && !hasOrg(reqWithSession, orgId)) { //fetch your orgs again to see if they've been updated try { const allowNonOfficials = !!options?.allowNonOfficials;