diff --git a/packages/api/src/handlers/favorite.ts b/packages/api/src/handlers/favorite.ts index 3e2cc28..1e514a4 100644 --- a/packages/api/src/handlers/favorite.ts +++ b/packages/api/src/handlers/favorite.ts @@ -1,10 +1,16 @@ import makeDebug from 'debug' import errors from 'http-errors' -import { addRiderFavorite } from '../queries/favorite.js' +import { + addRiderFavorite, + getRiderFavorites, + removeRiderFavorite +} from '../queries/favorite.js' import type { Request, Response } from 'express' -import type { Favorite } from '@busmap/common/types/favorites' +import type { Favorite, RiderFavoriteListItem } from '@busmap/common/types/favorites' +import type { RiderFavorite } from '../types.js' +import type { HttpError } from 'http-errors' const debug = makeDebug('busmap') const favorite = { @@ -16,7 +22,7 @@ const favorite = { if (favorite && typeof favorite === 'object' && req.session?.userId) { const { agency, route, stop } = favorite - if (agency?.id && route?.id && stop?.id) { + if (agency.id && route.id && stop.id) { const { userId } = req.session try { @@ -34,6 +40,48 @@ const favorite = { } } + return res.status(400).json(new errors.BadRequest()) + }, + + async remove( + req: Request, + res: Response | HttpError<400>> + ) { + const { favorite } = req.body + + if (favorite && typeof favorite === 'object' && req.session?.userId) { + const { agency, route, stop } = favorite + + if (agency.id && route.id && stop.id) { + try { + const removed = await removeRiderFavorite(req.session.userId, favorite) + + debug('removed rider favorite', removed) + + return res.json(removed[0]) + } catch (err) { + return res.status(500).json(new errors.InternalServerError()) + } + } + } + + return res.status(400).json(new errors.BadRequest()) + }, + + async list( + req: Request, + res: Response | HttpError<400>> + ) { + if (req.session?.userId) { + try { + const favorites = await getRiderFavorites(req.session.userId) + + return res.json(favorites) + } catch (err) { + return res.status(500).json(new errors.InternalServerError()) + } + } + return res.status(400).json(new errors.BadRequest()) } } diff --git a/packages/api/src/queries/favorite.ts b/packages/api/src/queries/favorite.ts index 2bc0482..a80b755 100644 --- a/packages/api/src/queries/favorite.ts +++ b/packages/api/src/queries/favorite.ts @@ -1,6 +1,6 @@ import { sql } from '../db.js' -import type { Favorite } from '@busmap/common/types/favorites' +import type { Favorite, RiderFavoriteListItem } from '@busmap/common/types/favorites' import type { RiderFavorite } from '../types.js' const addRiderFavorite = async (favorite: Favorite, userId: number) => { @@ -37,5 +37,30 @@ const addRiderFavorite = async (favorite: Favorite, userId: number) => { return riderFavoriteRow } +const getRiderFavorites = async (userId: number) => { + const favorites = await sql` + SELECT created, rank, agency_id, route_id, stop_id, id as favorite_id, ui + FROM rider_favorite + JOIN favorite ON favorite = favorite.id WHERE rider=${userId} + ` + + return favorites +} +const removeRiderFavorite = async (userId: number, favorite: Favorite) => { + const { agency, route, stop } = favorite + const deleted = await sql` + DELETE FROM rider_favorite + WHERE + favorite = ( + SELECT DISTINCT(id) + FROM favorite + WHERE agency_id=${agency.id} AND route_id=${route.id} AND stop_id=${stop.id} + ) + AND rider = ${userId} + RETURNING * + ` + + return deleted +} -export { addRiderFavorite } +export { addRiderFavorite, getRiderFavorites, removeRiderFavorite } diff --git a/packages/api/src/routes/favorite.ts b/packages/api/src/routes/favorite.ts index e50b409..6f4c4ae 100644 --- a/packages/api/src/routes/favorite.ts +++ b/packages/api/src/routes/favorite.ts @@ -5,5 +5,7 @@ import { favorite as handler } from '../handlers/favorite.js' const favorite = Router() favorite.put('/add', json(), handler.add) +favorite.delete('/remove', json(), handler.remove) +favorite.get('/list', handler.list) export { favorite } diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index b50e2e0..1d21957 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -1,9 +1,9 @@ interface RiderFavorite { rider: number favorite: number + rank: number created: string } - interface AddRiderFavorite { created: string } diff --git a/packages/common/src/types/favorites.ts b/packages/common/src/types/favorites.ts index cc073e5..f5045cb 100644 --- a/packages/common/src/types/favorites.ts +++ b/packages/common/src/types/favorites.ts @@ -11,4 +11,16 @@ interface Favorite { stop: Stop } -export type { RouteMeta, Favorite } +// API models + +interface RiderFavoriteListItem { + created: string + rank: number + agency_id: string + route_id: string + stop_id: string + favorte_id: number + ui: string +} + +export type { RouteMeta, Favorite, RiderFavoriteListItem } diff --git a/packages/ui/src/components/navigation.tsx b/packages/ui/src/components/navigation.tsx index be60a68..f56f60e 100644 --- a/packages/ui/src/components/navigation.tsx +++ b/packages/ui/src/components/navigation.tsx @@ -24,6 +24,7 @@ import { import { touch } from '@core/api/authn.js' import { useGlobals } from '@core/globals.js' import { useTheme } from '@module/settings/contexts/theme.js' +import { get as getFavorites } from '@module/favorites/api/get.js' import logoSvg from '../../assets/svg/logo.svg?raw' @@ -217,7 +218,12 @@ const Navigation: FC = ({ status }) => { useEffect(() => { if (status?.user) { + const getUserFavorites = async () => { + await getFavorites() + } + dispatch({ type: 'user', value: status.user }) + getUserFavorites() } }, [dispatch, status]) diff --git a/packages/ui/src/components/signIn.tsx b/packages/ui/src/components/signIn.tsx index dcbffeb..6bd2d2b 100644 --- a/packages/ui/src/components/signIn.tsx +++ b/packages/ui/src/components/signIn.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react' import styled from 'styled-components' +import { toast } from '@busmap/components/toast' import { login } from '@core/api/authn.js' import { useGlobals } from '@core/globals.js' @@ -23,10 +24,14 @@ const SignIn: FC = () => { client_id: import.meta.env.VITE_GOOG_CLIENT_ID, nonce: btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))), callback: async response => { - const user = await login(response.credential) - - dispatch({ type: 'user', value: user }) - dispatch({ type: 'page', value: 'profile' }) + try { + const user = await login(response.credential) + + dispatch({ type: 'user', value: user }) + dispatch({ type: 'page', value: 'profile' }) + } catch (err) { + toast({ type: 'error', message: 'Error signing in.' }) + } } }) google.accounts.id.renderButton(ref.current, { diff --git a/packages/ui/src/modules/favorites/api/delete.ts b/packages/ui/src/modules/favorites/api/delete.ts new file mode 100644 index 0000000..3424bc9 --- /dev/null +++ b/packages/ui/src/modules/favorites/api/delete.ts @@ -0,0 +1,21 @@ +import { transport } from '@core/api/transport.js' +import { errors } from '@core/api/errors.js' + +import type { Favorite } from '../types.js' + +const remove = async (favorite: Favorite) => { + if (!favorite) { + throw errors.create('GET', 400, 'Bad Request') + } + + const resp = await transport.fetch('/favorite/remove', { + method: 'DELETE', + body: JSON.stringify({ + favorite + }) + }) + + return resp +} + +export { remove } diff --git a/packages/ui/src/modules/favorites/api/get.ts b/packages/ui/src/modules/favorites/api/get.ts new file mode 100644 index 0000000..f5ae6f3 --- /dev/null +++ b/packages/ui/src/modules/favorites/api/get.ts @@ -0,0 +1,11 @@ +import { transport } from '@core/api/transport.js' + +import type { RiderFavoriteListItem } from '@busmap/common/types/favorites' + +const get = async () => { + const resp = await transport.fetch('/favorite/list') + + return resp +} + +export { get } diff --git a/packages/ui/src/modules/favorites/components/favoriteStop.tsx b/packages/ui/src/modules/favorites/components/favoriteStop.tsx index 9b9b277..32a19c4 100644 --- a/packages/ui/src/modules/favorites/components/favoriteStop.tsx +++ b/packages/ui/src/modules/favorites/components/favoriteStop.tsx @@ -12,11 +12,12 @@ import { same } from '@module/util.js' import { MAX_FAVORITES } from '../common.js' import { put } from '../api/put.js' +import { remove } from '../api/delete.js' import type { FC } from 'react' import type { RouteName, DirectionName } from '@core/types.js' import type { Selection } from '@module/util.js' -import type { Favorite } from '../types.js' +import type { Favorite } from '@busmap/common/types/favorites' interface SelectionMeta extends Selection { route: RouteName & { @@ -51,6 +52,9 @@ const FavoriteStop: FC = ({ selection, size = 'medium' }) => const mutation = useMutation({ mutationFn: (fav: Favorite) => put(fav) }) + const removal = useMutation({ + mutationFn: (fav: Favorite) => remove(fav) + }) const favorite = useMemo(() => { return favorites.find(fav => { if (route && direction && stop && agency) { @@ -61,6 +65,15 @@ const FavoriteStop: FC = ({ selection, size = 'medium' }) => const onClick = useCallback(async () => { if (favorite) { storageDispatch({ type: 'favoriteRemove', value: favorite }) + + if (user) { + try { + await removal.mutateAsync(favorite) + toast({ type: 'info', message: 'Favorite removed.' }) + } catch (err) { + toast({ type: 'error', message: 'Error removing favorite.' }) + } + } } else if (agency && route && direction && stop) { const add: Favorite = { stop: stop, @@ -85,7 +98,7 @@ const FavoriteStop: FC = ({ selection, size = 'medium' }) => } } } - }, [storageDispatch, mutation, agency, route, direction, stop, favorite, user]) + }, [storageDispatch, mutation, removal, agency, route, direction, stop, favorite, user]) const isFavoritable = Boolean(stop) && (favorites.length < MAX_FAVORITES || favorite) if (isFavoritable) { diff --git a/packages/ui/src/modules/favorites/components/favorites.tsx b/packages/ui/src/modules/favorites/components/favorites.tsx index b8ee295..01fb124 100644 --- a/packages/ui/src/modules/favorites/components/favorites.tsx +++ b/packages/ui/src/modules/favorites/components/favorites.tsx @@ -10,6 +10,7 @@ import { MapMarked } from '@busmap/components/icons/mapMarked' import { Trash } from '@busmap/components/icons/trash' import { PB50T } from '@busmap/components/colors' +import { useGlobals } from '@core/globals.js' import { useMap } from '@core/contexts/map.js' import { useStorage, useStorageDispatch } from '@core/contexts/storage.js' import { useHomeStop } from '@core/hooks/useHomeStop.js' @@ -30,8 +31,10 @@ import { groupBy } from '@module/util.js' import { getPredsKey } from '../util.js' import { MAX_FAVORITES } from '../common.js' +import { remove } from '../api/delete.js' import type { MouseEvent } from 'react' +import type { Favorite } from '@busmap/common/types/favorites' import type { ErrorsMap, WorkerMessage, @@ -57,6 +60,7 @@ const Section = styled(Page)` } ` const Favorites = memo(function Favorites() { + const { user } = useGlobals() const map = useMap() const workerRef = useRef() const homeStop = useHomeStop() @@ -88,6 +92,24 @@ const Favorites = memo(function Favorites() { }, [map] ) + const onClickDeleteFavorite = useCallback( + async (fav: Favorite) => { + storageDispatch({ + type: 'favoriteRemove', + value: fav + }) + + if (user) { + try { + await remove(fav) + toast({ type: 'info', message: 'Favorite removed.' }) + } catch (err) { + toast({ type: 'error', message: 'Error removing favorite.' }) + } + } + }, + [storageDispatch, user] + ) const PredFormat = format === 'minutes' ? Minutes : Time useEffect(() => { @@ -231,13 +253,7 @@ const Favorites = memo(function Favorites() { )}