diff --git a/src/components/CopiedFromDSContentLayout.tsx b/src/components/CopiedFromDSContentLayout.tsx new file mode 100644 index 0000000..b310d70 --- /dev/null +++ b/src/components/CopiedFromDSContentLayout.tsx @@ -0,0 +1,79 @@ +import styled from 'styled-components'; +import { JSX } from 'react'; +import { AppRoute, device } from '@aplinkosministerija/design-system'; +interface Props { + children: any; + title?: string; + customSubTitle?: any; + customTitle?: any; + currentRoute?: AppRoute; + pageActions?: JSX.Element; + limitWidth?: boolean; +} +const CopiedFromDSContentLayout = ({ + children, + title, + customSubTitle, + customTitle, + currentRoute, + pageActions, + limitWidth = false, +}: Props) => { + const pageTitle = title || currentRoute?.title; + return ( + + {pageActions} + + {customTitle || (pageTitle && {pageTitle})} + {customSubTitle || + (currentRoute?.description && {currentRoute?.description})} + {children} + + + ); +}; +export default CopiedFromDSContentLayout; + +const SubTitle = styled.div` + color: ${({ theme }) => theme.colors.text?.primary || '#101010'}; + margin-bottom: 16px; +`; + +const Container = styled.div<{ $limitWidth: boolean }>` + display: flex; + gap: 12px; + width: 100%; + min-height: 100%; + padding: 0 12px; + ${({ $limitWidth }) => + $limitWidth && + `@media ${device.desktop} { + max-width: 700px; + }`} +`; + +const InnerContainer = styled.div` + display: flex; + flex-direction: column; + width: 100%; + min-height: 100%; + align-self: center; + align-items: center; + padding: 0 12px; + background-color: white; + @media ${device.desktop} { + border-radius: 16px; + margin: 0 auto; + padding: 40px; + overflow-y: auto; + height: fit-content; + } +`; + +const Title = styled.div` + color: ${({ theme }) => theme.colors.text?.primary || '#101010'}; + font-size: 3.2rem; + font-weight: 800; + margin: 16px 0; + text-align: center; +`; diff --git a/src/components/EventFilterModal.tsx b/src/components/EventFilterModal.tsx index 1d8f570..bb9b793 100644 --- a/src/components/EventFilterModal.tsx +++ b/src/components/EventFilterModal.tsx @@ -8,6 +8,7 @@ import { App, Filters, IconName, + Subscription, TimeRangeItem, buttonsTitles, subtitle, @@ -16,9 +17,9 @@ import { import { useQuery } from '@tanstack/react-query'; import api from '../utils/api'; import { UserContext, UserContextType } from './UserProvider'; +import Loader from './Loader'; -const EventFilterModal = ({ onClose, visible = false }: any) => { - const { loggedIn } = useContext(UserContext); +const EventFilterModal = ({ isMyEvents = false, onClose, visible = false }: any) => { const { value: filters, setValue: setFilters, @@ -26,14 +27,22 @@ const EventFilterModal = ({ onClose, visible = false }: any) => { } = useStorage('filters', {}, true); const [selectedApps, setSelectedApps] = useState([]); + const [selectedSubs, setSelectedSubs] = useState([]); const [selectedTimeRange, setSelectedTimeRange] = useState([]); + const { loggedIn } = useContext(UserContext); + + const { data: appsResponse, isLoading: loadingApps } = useQuery({ + queryKey: ['apps', 'all'], + queryFn: () => api.getAllApps(), + }); + const apps = appsResponse ?? []; - const { data: appsResponse } = useQuery({ - queryKey: ['apps'], - queryFn: () => api.getApps({ page: 1 }), + const { data: subsResponse, isLoading: loadingSubs } = useQuery({ + queryKey: ['subscriptions', 'all'], + queryFn: () => api.getAllSubscriptions(), enabled: loggedIn, }); - const apps: App[] = appsResponse?.rows || []; + const subs = subsResponse ?? []; const clearFilter = () => { resetFilters(); @@ -43,6 +52,7 @@ const EventFilterModal = ({ onClose, visible = false }: any) => { useEffect(() => { if (visible) { setSelectedApps(filters.apps || []); + setSelectedSubs(filters.subscriptions || []); setSelectedTimeRange(filters.timeRange ? [filters.timeRange] : []); } }, [visible]); @@ -50,11 +60,52 @@ const EventFilterModal = ({ onClose, visible = false }: any) => { const onFilterClick = () => { setFilters({ ...(selectedApps.length > 0 ? { apps: selectedApps } : null), + ...(selectedSubs.length > 0 ? { subscriptions: selectedSubs } : null), ...(selectedTimeRange ? { timeRange: selectedTimeRange[0] } : null), }); onClose(); }; + const renderSubs = () => { + if (loadingSubs) { + return ; + } + if (isMyEvents && subs?.length > 0) { + return ( + + {subtitle.subscriptions} + item.id} + data={subs} + selectedItems={selectedSubs} + setSelectedItems={(items) => setSelectedSubs(items)} + /> + + ); + } + }; + + const renderApps = () => { + if (loadingApps) { + return ; + } + if (apps?.length > 0) { + return ( + + {subtitle.category} + item.key} + data={apps} + selectedItems={selectedApps} + setSelectedItems={(items) => setSelectedApps(items)} + /> + + ); + } + }; + return ( @@ -66,21 +117,14 @@ const EventFilterModal = ({ onClose, visible = false }: any) => { {buttonsTitles.filter} - {apps.length > 0 && ( - - {subtitle.category} - setSelectedApps(items)} - /> - - )} + + {renderSubs()} + {renderApps()} {subtitle.date} item.key} data={timeRangeItems} selectedItems={selectedTimeRange} setSelectedItems={(items) => setSelectedTimeRange(items)} diff --git a/src/components/EventsContainer.tsx b/src/components/EventsContainer.tsx index 90ee14a..8432900 100644 --- a/src/components/EventsContainer.tsx +++ b/src/components/EventsContainer.tsx @@ -1,6 +1,11 @@ -import { ContentLayout, useStorage } from '@aplinkosministerija/design-system'; +import { + Button, + ButtonVariants, + ContentLayout, + useStorage, +} from '@aplinkosministerija/design-system'; import React, { useContext, useRef, useState } from 'react'; -import styled, { useTheme } from 'styled-components'; +import styled from 'styled-components'; import { device } from '../styles'; import { IconName, @@ -11,45 +16,90 @@ import { Event, buttonsTitles, Filters, + Subscription, } from '../utils'; import EmptyState from './EmptyState'; import EventCard from './EventCard'; import LoaderComponent from './LoaderComponent'; import Icon from './Icons'; import EventFilterModal from './EventFilterModal'; +import MapView from './MapView'; +import CopiedFromDSContentLayout from './CopiedFromDSContentLayout'; +import { useQuery } from '@tanstack/react-query'; +import api from '../utils/api'; +import { UserContext, UserContextType } from './UserProvider'; const EventsContainer = ({ + isMyEvents = false, apiEndpoint, + countEndpoint, queryKey, emptyStateDescription, emptyStateTitle, }: { + isMyEvents?: boolean; apiEndpoint: any; + countEndpoint: any; queryKey: string; emptyStateDescription?: string; emptyStateTitle: string; }) => { const filters = useStorage('filters', {}, true); + const [isListView, setIsListView] = useState(true); + const [showFilterModal, setShowFilterModal] = useState(false); + + const { loggedIn } = useContext(UserContext); const currentRoute = useGetCurrentRoute(); const observerRef = useRef(null); - const [showFilterModal, setShowFilterModal] = useState(false); + + const { data: subsResponse } = useQuery({ + queryKey: ['allSubscriptions'], + queryFn: () => api.getAllSubscriptions(), + enabled: loggedIn && isMyEvents, + }); + const allSubscriptions = subsResponse ?? []; const getFilter = () => { - const { apps, timeRange } = filters.value; + const { apps, timeRange, subscriptions } = filters.value; + let filterSubs: Subscription[] = []; + if (isMyEvents) { + filterSubs = subscriptions && subscriptions.length ? subscriptions : allSubscriptions; + } return { ...(apps ? { app: { $in: apps.map((app) => app.id) } } : null), + ...(filterSubs.length ? { subscription: { $in: filterSubs.map((sub) => sub.id) } } : null), ...(timeRange ? { startAt: timeRange.query } : null), }; }; + const { data: eventsCount } = useQuery({ + queryKey: [queryKey, 'count', getFilter()], + queryFn: () => countEndpoint({ filter: getFilter() }), + }); + + const getMapGeom = () => { + if (!allSubscriptions.length) return null; + + return { + type: 'FeatureCollection', + features: allSubscriptions.map((sub) => sub?.geom?.features[0]), + }; + }; + const { data: events, isFetching, isLoading, - } = useInfinityLoad([queryKey, filters], apiEndpoint, observerRef, { filter: getFilter() }); + } = useInfinityLoad( + [queryKey, filters], + apiEndpoint, + observerRef, + { filter: getFilter() }, + isListView, + ); - const renderContent = () => { + const renderListContent = () => { if (isLoading) return ; if (isEmpty(events?.pages?.[0]?.data)) { @@ -61,7 +111,6 @@ const EventsContainer = ({ /> ); } - return ( {events?.pages.map((page, pageIndex) => { @@ -79,11 +128,21 @@ const EventsContainer = ({ ); }; + const renderListOrMap = () => { + if (isListView) { + return renderListContent(); + } else { + return ; + } + }; + return ( - + {!isLoading && ( - {events && `${subtitle.foundRecords} ${events.pages[0].total}`} + + {`${subtitle.foundRecords} ${Number.isInteger(eventsCount) ? eventsCount : ''}`} + setShowFilterModal(true)}> {!isEmpty(filters.value) && } @@ -93,9 +152,31 @@ const EventsContainer = ({ )} - {renderContent()} - setShowFilterModal(false)} /> - + {renderListOrMap()} + { + setIsListView((view) => !view); + }} + > + {isListView ? ( + <> + {buttonsTitles.showMap} + + + ) : ( + <> + {buttonsTitles.showList} + + + )} + + setShowFilterModal(false)} + /> + ); }; @@ -108,12 +189,14 @@ const Invisible = styled.div` const Container = styled.div` display: flex; + flex-grow: 1; + position: relative; overflow-y: auto; align-items: center; flex-direction: column; padding: 20px 0px; width: 100%; - + height: 100%; @media ${device.mobileL} { padding: 12px 0px; } @@ -122,7 +205,6 @@ const Container = styled.div` const InnerContainer = styled.div` display: flex; max-width: 800px; - margin: auto; width: 100%; gap: 12px; @@ -180,3 +262,13 @@ const FilterBadge = styled.div` border: 1px solid #ffffff; background-color: ${({ theme }) => theme.colors.primary}; `; + +const MapAndListButton = styled(Button)` + position: absolute; + z-index: 10; + bottom: 30px; + width: auto; + @media ${device.mobileL} { + bottom: 15px; + } +`; diff --git a/src/components/FilterPicker.tsx b/src/components/FilterPicker.tsx index 8a4044e..65b506d 100644 --- a/src/components/FilterPicker.tsx +++ b/src/components/FilterPicker.tsx @@ -6,7 +6,7 @@ import HeadlessItemPicker, { } from './HeadlessItemPicker'; import styled from 'styled-components'; -export interface FilterItem extends HeadlessItemT { +export interface FilterItem { name: string; } @@ -19,10 +19,10 @@ export type PodcastPickerProps = Omit< const FilterPicker = (props: PodcastPickerProps) => { const renderItem = (item: RenderItemProps) => { - const { name, key } = item.item; + const { name } = item.item; const { isActive, onClick } = item; return ( - + {name} ); diff --git a/src/components/HeadlessItemPicker.tsx b/src/components/HeadlessItemPicker.tsx index edbca99..4d900cd 100644 --- a/src/components/HeadlessItemPicker.tsx +++ b/src/components/HeadlessItemPicker.tsx @@ -1,8 +1,6 @@ import React from 'react'; -export interface HeadlessItemT { - key: string; -} +export interface HeadlessItemT {} export interface RenderItemProps { item: T; @@ -17,6 +15,7 @@ export interface HeadlessItemPickerProps { selectedItems: Array; setSelectedItems: (items: Array) => void; renderItem: (props: RenderItemProps) => React.ReactNode; + getItemKey: (item: T) => string | number; allowMultipleSelection?: boolean; } @@ -27,11 +26,12 @@ const ItemPicker = ({ selectedItems = [], setSelectedItems, allowMultipleSelection = false, + getItemKey, }: HeadlessItemPickerProps) => { const onItemPress = (item: T) => { if (allowMultipleSelection) { const foundSelectedIndex = selectedItems.findIndex((selected) => { - return selected.key === item.key; + return getItemKey(selected) === getItemKey(item); }); const newSelected = [...selectedItems]; if (foundSelectedIndex !== -1) { @@ -47,7 +47,7 @@ const ItemPicker = ({ }; const renderPickerItem = ({ item, index }) => { - const isActive = !!selectedItems.find((selected) => selected.key === item.key); + const isActive = !!selectedItems.find((selected) => getItemKey(selected) === getItemKey(item)); return renderItem({ item, isActive, index, onClick: () => onItemPress(item) }); }; diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index de16513..459ec81 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -6,7 +6,7 @@ import { HiOutlineUsers } from 'react-icons/hi'; import { IoMdCalendar } from 'react-icons/io'; import { IoLocationOutline, IoPersonOutline, IoSearch, IoFilter } from 'react-icons/io5'; import { LiaBalanceScaleSolid } from 'react-icons/lia'; - +import { PiMapTrifoldBold } from 'react-icons/pi'; import { MdDone, MdExitToApp, @@ -112,7 +112,6 @@ const Icon = ({ name, className, ...rest }: IconProps) => { ); - case IconName.sidebarLogo: return ( ; + case IconName.map: + return ; default: return null; } diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx new file mode 100644 index 0000000..36b772f --- /dev/null +++ b/src/components/MapView.tsx @@ -0,0 +1,154 @@ +import { useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { device } from '../styles'; +import Icon from './Icons'; +import { IconName } from '../utils'; + +const mapsHost = import.meta.env.VITE_MAPS_HOST; + +interface MapProps { + onSave?: (data: any) => void; + error?: string; + preview?: boolean; + filters?: any; + geom?: any; +} + +const MapView = ({ error, filters, geom }: MapProps) => { + const iframeRef = useRef(null); + const [showModal, setShowModal] = useState(false); + + const src = `${mapsHost}/smalsuolis?preview=1`; + + const handleLoadMap = () => { + if (filters || geom) { + // smalsuolio maps negaudo geom jei paduot is karto + setTimeout(() => { + iframeRef?.current?.contentWindow?.postMessage({ filters, geom }, '*'); + }, 100); + } + }; + + useEffect(() => { + iframeRef?.current?.contentWindow?.postMessage({ filters }, '*'); + }, [filters]); + + useEffect(() => { + iframeRef?.current?.contentWindow?.postMessage({ geom }, '*'); + }, [geom]); + + return ( + + + { + e.preventDefault(); + + setShowModal(!showModal); + }} + > + + + + + + + + ); +}; + +const Container = styled.div<{ + $showModal: boolean; + $error: boolean; +}>` + width: 100%; + ${({ $showModal }) => + $showModal && + ` + display: flex; + position: fixed; + height: 100%; + width: 100%; + top: 0; + left: 0; + background-color: rgba(0, 0, 0, 0.4); + justify-content: center; + align-items: center; + overflow-y: auto; + z-index: 1001; + `} + ${({ theme, $error }) => $error && `border: 1px solid ${theme.colors.error};`} +`; + +const InnerContainer = styled.div<{ + $showModal: boolean; +}>` + display: flex; + position: relative; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + ${({ $showModal }) => + $showModal && + ` + padding: 16px; + `} + + @media ${device.mobileL} { + padding: 0; + } +`; + +const StyledIframe = styled.iframe<{ + $height: string; + $width: string; +}>` + width: ${({ $width }) => $width}; + height: ${({ $height }) => $height}; +`; + +const StyledButton = styled.div<{ $popup: boolean }>` + position: absolute; + z-index: 10; + top: ${({ $popup }) => ($popup ? 30 : 15)}px; + left: ${({ $popup }) => ($popup ? 28 : 11)}px; + min-width: 28px; + height: 28px; + @media ${device.mobileL} { + top: 10px; + left: 10px; + } + + border-color: #e5e7eb; + background-color: white !important; + width: 30px; + height: 30px; + padding: 0; + box-shadow: 0px 18px 41px #121a5529; +`; + +const StyledIcon = styled(Icon)` + font-size: 3rem; + color: #6b7280; +`; + +const StyledIconContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; +`; + +export default MapView; diff --git a/src/components/PreviewMap.tsx b/src/components/PreviewMap.tsx index e7bd4ac..b501e28 100644 --- a/src/components/PreviewMap.tsx +++ b/src/components/PreviewMap.tsx @@ -1,7 +1,7 @@ import { useRef, useState } from 'react'; import styled from 'styled-components'; import { device } from '../styles'; -import { FieldWrapper, FeatureCollection, Button } from '@aplinkosministerija/design-system'; +import { FieldWrapper, FeatureCollection } from '@aplinkosministerija/design-system'; import Icon from './Icons'; import { IconName } from '../utils'; @@ -33,7 +33,7 @@ const PreviewMap = ({ height = '230px', error, value, showError = true, label }: { e.preventDefault(); @@ -113,11 +113,11 @@ const StyledIframe = styled.iframe<{ height: ${({ $height }) => $height}; `; -const StyledButton = styled.div<{ popup: boolean }>` +const StyledButton = styled.div<{ $popup: boolean }>` position: absolute; z-index: 10; - top: ${({ popup }) => (popup ? 30 : 15)}px; - left: ${({ popup }) => (popup ? 28 : 11)}px; + top: ${({ $popup }) => ($popup ? 30 : 15)}px; + left: ${({ $popup }) => ($popup ? 28 : 11)}px; min-width: 28px; height: 28px; @media ${device.mobileL} { diff --git a/src/pages/Events.tsx b/src/pages/Events.tsx index f35d620..9e5ece2 100644 --- a/src/pages/Events.tsx +++ b/src/pages/Events.tsx @@ -6,6 +6,7 @@ const Events = () => { return ( diff --git a/src/pages/MyEvents.tsx b/src/pages/MyEvents.tsx index e375927..2188f21 100644 --- a/src/pages/MyEvents.tsx +++ b/src/pages/MyEvents.tsx @@ -5,6 +5,8 @@ import api from '../utils/api'; const MyEvents = () => { return ( { const futureApps = subscription?.apps && subscription.apps?.length === 0; const initialValues: SubscriptionForm = { + id: subscription?.id ?? 0, name: subscription?.name ?? '', active: typeof subscription?.active === 'boolean' ? subscription?.active : true, apps: noSubscription || futureApps ? allApps : subscription?.apps || [], diff --git a/src/styles/index.ts b/src/styles/index.ts index 7f6883b..6e6efcf 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5,7 +5,7 @@ export const theme: Theme = { colors: { primary: '#73DC8C', secondary: '#121A55', - tertiary: '#73DC8C', + tertiary: '#1B4C28', transparent: 'transparent', label: '#4B5565', danger: '#FE5B78', @@ -30,10 +30,10 @@ export const theme: Theme = { hover: '#121A55', }, tertiary: { - background: '#73DC8C', + background: '#14532D', text: 'white', - border: '#73DC8C', - hover: '#F7F7F7', + border: '#14532D', + hover: '#14532D', }, success: { background: '#258800', diff --git a/src/utils/api.ts b/src/utils/api.ts index a41d77b..a6b28bd 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -40,6 +40,7 @@ interface GetOne { id?: string | any; populate?: string[]; scope?: string; + filter?: string | any; } interface UpdateOne { resource?: string; @@ -113,9 +114,9 @@ class Api { ); }; - getOne = async ({ resource, id, populate }: GetOne) => { + getOne = async ({ resource, id, populate, ...rest }: GetOne) => { const config = { - params: { ...(!!populate && { populate }) }, + params: { ...(!!populate && { populate }), ...rest }, }; return this.errorWrapper(() => @@ -218,6 +219,13 @@ class Api { }); }; + getEventsCount = async ({ filter }: { filter: any }): Promise => { + return this.getOne({ + resource: Resources.EVENTS + '/count', + filter, + }); + }; + getEvent = async ({ id }: { id: any }): Promise => { return this.getOne({ resource: Resources.EVENTS, @@ -242,6 +250,13 @@ class Api { }); }; + getNewsfeedCount = async ({ filter }: { filter: any }): Promise => { + return this.getOne({ + resource: Resources.NEWSFEED + '/count', + filter, + }); + }; + getSubscriptions = async ({ page }: { page: number }): Promise> => { return this.get({ resource: Resources.SUBSCRIPTIONS, @@ -249,6 +264,14 @@ class Api { page, }); }; + + getAllSubscriptions = async (): Promise => { + return this.getOne({ + resource: Resources.SUBSCRIPTIONS + '/all', + populate: ['geom'], + }); + }; + getSubscription = async ({ id }: { id: string }): Promise => { return this.getOne({ resource: Resources.SUBSCRIPTIONS, @@ -306,6 +329,12 @@ class Api { page, }); }; + + getAllApps = async (): Promise => { + return this.getOne({ + resource: Resources.APPS + '/all', + }); + }; } const api = new Api(); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f921ea0..81e9958 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,5 +1,6 @@ export enum IconName { book = 'book', + map = 'map', airBallon = 'airBallon', remove = 'remove', download = 'download', diff --git a/src/utils/hooks.tsx b/src/utils/hooks.tsx index db3eba5..44a4741 100644 --- a/src/utils/hooks.tsx +++ b/src/utils/hooks.tsx @@ -109,6 +109,7 @@ export const useInfinityLoad = ( fn: (params: { page: number }) => any, observerRef: any, filters = {}, + enabled = true, ) => { const queryFn = async (page: number) => { const data = await fn({ @@ -122,6 +123,7 @@ export const useInfinityLoad = ( }; const result = useInfiniteQuery({ + enabled, queryKey: queryKeys, initialPageParam: 1, queryFn: ({ pageParam }: any) => queryFn(pageParam), diff --git a/src/utils/texts.ts b/src/utils/texts.ts index 8eeb5b6..dcd8802 100644 --- a/src/utils/texts.ts +++ b/src/utils/texts.ts @@ -92,6 +92,8 @@ export const buttonsTitles = { filter: 'Filtruoti', close: 'Uždaryti', clearFilter: 'Išvalyti filtrą', + showMap: 'Rodyti žemėlapį', + showList: 'Rodyti sąrašą', visitWebsite: 'Aplankykite svetainę', register: 'Registruotis', }; diff --git a/src/utils/types.ts b/src/utils/types.ts index 23f78ec..963cde2 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -4,6 +4,7 @@ import { AppType } from './constants'; import { Frequency } from './constants'; import { subMonths, subWeeks } from 'date-fns/fp'; import { formatDateAndTime, formatDateFrom, formatDateTo, formatToZonedDate } from './functions'; +import { FeatureCollection } from '@aplinkosministerija/design-system'; export interface App { id: number; @@ -14,11 +15,11 @@ export interface App { } export interface Subscription { - id?: number; - name?: string; + id: number; + name: string; user?: number; apps?: T[]; - geom?: any; + geom?: FeatureCollection; frequency?: Frequency; active?: boolean; eventsCount?: { allTime: number; new: number }; @@ -138,5 +139,6 @@ export const timeRangeItems: TimeRangeItem[] = [ export interface Filters { apps?: App[]; + subscriptions?: Subscription[]; timeRange?: TimeRangeItem; }