Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

issue-1621/event-page #1627

Closed
73 changes: 73 additions & 0 deletions src/features/events/components/ActivistEventPage/Map.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<MapContainer
bounds={latLngBounds([[location.lat, location.lng]])}
boxZoom={interactive}
doubleClickZoom={interactive}
dragging={interactive}
keyboard={interactive}
scrollWheelZoom={interactive}
style={style}
touchZoom={interactive}
zoomControl={interactive}
>
<MapWrapper>
{(map) => {
map.setView({ lat: location.lat, lng: location.lng }, zoomLevel);

return (
<>
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker
key={location.id}
icon={divIcon({
className: '',
html: renderToStaticMarkup(
<SelectedMarker
style={{ transform: 'translate(-50%, -100%)' }}
/>
),
iconAnchor: [0, 0],
})}
position={[location.lat, location.lng]}
/>
</>
);
}}
</MapWrapper>
</MapContainer>
);
};

export default Map;
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { FC } from 'react';
import { CSSProperties, FC } from 'react';

const SelectedMarker: FC = () => {
const SelectedMarker: FC<{ style?: CSSProperties }> = ({ style }) => {
return (
<svg fill="none" height="50" viewBox="0 0 44 63" width="40">
<svg fill="none" height="50" style={style} viewBox="0 0 44 63" width="40">
<path
d="M22 61L21.6289 61.3351L22 61.7459L22.3711
61.3351L22 61ZM22 61C22.3711 61.3351 22.3712
Expand Down
22 changes: 22 additions & 0 deletions src/features/events/hooks/useEventBookings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { IFuture } from 'core/caching/futures';
import { loadListIfNecessary } from 'core/caching/cacheUtils';
import { ZetkinEvent } from 'utils/types/zetkin';
import { bookingsLoad, bookingsLoaded } from 'features/events/store';
import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks';

export default function useEventBookings(): IFuture<ZetkinEvent[]> {
const apiClient = useApiClient();
const dispatch = useAppDispatch();
const bookingsList = useAppSelector((state) => state.events.bookingsList);

return loadListIfNecessary(bookingsList, dispatch, {
actionOnLoad: () => dispatch(bookingsLoad()),
actionOnSuccess: (data) => dispatch(bookingsLoaded(data)),
loader: () =>
apiClient.get<ZetkinEvent[]>(
`/api/users/me/actions?filter=${encodeURIComponent(
'status!=cancelled'
)}`
),
});
}
104 changes: 104 additions & 0 deletions src/features/events/hooks/useEventSignup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import useEventBookings from './useEventBookings';
import useEventSignups from './useEventSignups';
import useMemberships from 'features/campaigns/hooks/useMemberships';
import {
ErrorFuture,
IFuture,
LoadingFuture,
ResolvedFuture,
} from 'core/caching/futures';
import { signupAdd, signupAdded, signupRemove, signupRemoved } from '../store';
import { useApiClient, useAppDispatch } from 'core/hooks';
import {
ZetkinEvent,
ZetkinEventResponse,
ZetkinMembership,
} from 'utils/types/zetkin';

type EventResponseState = 'notSignedUp' | 'signedUp' | 'booked' | 'notInOrgYet';

function eventResponseState(
eventId: number,
bookings: ZetkinEvent[],
membership: ZetkinMembership,
signups: ZetkinEventResponse[]
): EventResponseState {
if (!membership) {
return 'notInOrgYet';
}
if (bookings.some((b) => b.id == eventId)) {
return 'booked';
}
if (signups.some((b) => b.action_id == eventId)) {
return 'signedUp';
}
return 'notSignedUp';
}

type UseEventSignupReturn = {
myResponseState: EventResponseState;
signup: () => Promise<void>;
undoSignup: () => Promise<void>;
};

export default function useEventSignup(
orgId: number,
eventId: number
): IFuture<UseEventSignupReturn> {
const apiClient = useApiClient();
const dispatch = useAppDispatch();
const bookingsFuture = useEventBookings();
const membershipsFuture = useMemberships();
const signupsFuture = useEventSignups();

const allFutures: IFuture<unknown>[] = [
bookingsFuture,
membershipsFuture,
signupsFuture,
];

if (allFutures.some((f) => f.isLoading)) {
return new LoadingFuture();
}
if (allFutures.some((f) => f.error)) {
return new ErrorFuture('Error loading bookings');
}

// TODO: handle "missing" user
const membership = membershipsFuture.data!.find(
(m) => m.organization.id == orgId
);

const signup = async () => {
dispatch(signupAdd());
await apiClient.put<ZetkinEvent>(
`/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,
});
}
18 changes: 18 additions & 0 deletions src/features/events/hooks/useEventSignups.ts
Original file line number Diff line number Diff line change
@@ -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<ZetkinEventResponse[]> {
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<ZetkinEventResponse[]>(`/api/users/me/action_responses`),
});
}
16 changes: 16 additions & 0 deletions src/features/events/l10n/messageIds.ts
Original file line number Diff line number Diff line change
@@ -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'),
Expand Down
46 changes: 46 additions & 0 deletions src/features/events/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export type FilterCategoryType =
| 'selectedTypes';

export interface EventsStoreSlice {
bookingsList: RemoteList<ZetkinEvent>;
eventList: RemoteList<ZetkinEvent>;
eventsByCampaignId: Record<string, RemoteList<ZetkinEvent>>;
eventsByDate: Record<string, RemoteList<ZetkinEvent>>;
Expand All @@ -62,11 +63,13 @@ export interface EventsStoreSlice {
participantsByEventId: Record<number, RemoteList<ZetkinEventParticipant>>;
respondentsByEventId: Record<number, RemoteList<ZetkinEventResponse>>;
selectedEventIds: number[];
signupsList: RemoteList<ZetkinEventResponse>;
statsByEventId: Record<number, RemoteItem<EventStats>>;
typeList: RemoteList<ZetkinActivity>;
}

const initialState: EventsStoreSlice = {
bookingsList: remoteList(),
eventList: remoteList(),
eventsByCampaignId: {},
eventsByDate: {},
Expand All @@ -80,6 +83,7 @@ const initialState: EventsStoreSlice = {
participantsByEventId: {},
respondentsByEventId: {},
selectedEventIds: [],
signupsList: remoteList(),
statsByEventId: {},
typeList: remoteList(),
};
Expand All @@ -88,6 +92,13 @@ const eventsSlice = createSlice({
initialState,
name: 'events',
reducers: {
bookingsLoad: (state) => {
state.bookingsList.isLoading = true;
},
bookingsLoaded: (state, action: PayloadAction<ZetkinEvent[]>) => {
state.bookingsList = remoteList(action.payload);
state.bookingsList.loaded = new Date().toISOString();
},
campaignEventsLoad: (state, action: PayloadAction<number>) => {
const id = action.payload;
state.eventsByCampaignId[id] = remoteList<ZetkinEvent>();
Expand Down Expand Up @@ -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<ZetkinEventResponse>) => {
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<number>) => {
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<ZetkinEventResponse[]>) => {
state.signupsList = remoteList(action.payload);
state.signupsList.loaded = new Date().toISOString();
},
statsLoad: (state, action: PayloadAction<number>) => {
const eventId = action.payload;
state.statsByEventId[eventId] = remoteItem<EventStats>(eventId);
Expand Down Expand Up @@ -573,6 +611,8 @@ const eventsSlice = createSlice({

export default eventsSlice;
export const {
bookingsLoad,
bookingsLoaded,
campaignEventsLoad,
campaignEventsLoaded,
eventCreate,
Expand Down Expand Up @@ -609,6 +649,12 @@ export const {
resetSelection,
respondentsLoad,
respondentsLoaded,
signupAdd,
signupAdded,
signupRemove,
signupRemoved,
signupsLoad,
signupsLoaded,
statsLoad,
statsLoaded,
typeAdd,
Expand Down
6 changes: 2 additions & 4 deletions src/pages/api/[...path].ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -20,9 +19,8 @@ interface HttpVerbMethod {

const HTTP_VERBS_TO_ZETKIN_METHODS: Record<string, HttpVerbMethod> = {
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),
Expand Down
Loading