import type { IconDefinition } from '@fortawesome/fontawesome-common-types'; import { faCalendarAlt, faChartPie, faList, faMapMarkedAlt } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import { isEmpty, pipe, propEq, values } from 'ramda'; import type { FC, PropsWithChildren } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; import { Button, Progress, Row } from 'reactstrap'; import { Message, NavPillItem, NavPills, Result } from '../../shlink-frontend-kit/src'; import { ShlinkApiError } from '../common/ShlinkApiError'; import { ExportBtn } from '../utils/components/ExportBtn'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; import type { DateInterval, DateRange } from '../utils/dates/helpers/dateIntervals'; import { toDateRange } from '../utils/dates/helpers/dateIntervals'; import { prettify } from '../utils/helpers/numbers'; import { useSetting } from '../utils/settings'; import { DoughnutChartCard } from './charts/DoughnutChartCard'; import { LineChartCard } from './charts/LineChartCard'; import { SortableBarChartCard } from './charts/SortableBarChartCard'; import { useVisitsQuery } from './helpers/hooks'; import { OpenMapModalBtn } from './helpers/OpenMapModalBtn'; import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown'; import type { VisitsInfo } from './reducers/types'; import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser'; import type { NormalizedOrphanVisit, NormalizedVisit, VisitsParams } from './types'; import type { HighlightableProps } from './types/helpers'; import { highlightedVisitsToStats } from './types/helpers'; import { VisitsTable } from './VisitsTable'; export type VisitsStatsProps = PropsWithChildren<{ getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void; visitsInfo: VisitsInfo; cancelGetVisits: () => void; exportCsv: (visits: NormalizedVisit[]) => void; isOrphanVisits?: boolean; }>; interface VisitsNavLinkProps { title: string; subPath: string; icon: IconDefinition; } type Section = 'byTime' | 'byContext' | 'byLocation' | 'list'; const sections: Record = { byTime: { title: 'By time', subPath: 'by-time', icon: faCalendarAlt }, byContext: { title: 'By context', subPath: 'by-context', icon: faChartPie }, byLocation: { title: 'By location', subPath: 'by-location', icon: faMapMarkedAlt }, list: { title: 'List', subPath: 'list', icon: faList }, }; let selectedBar: string | undefined; export const VisitsStats: FC = ({ children, visitsInfo, getVisits, cancelGetVisits, exportCsv, isOrphanVisits = false, }) => { const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo; const [{ dateRange, visitsFilter }, updateFiltering] = useVisitsQuery(); const visitsSettings = useSetting('visits'); const setDates = pipe( ({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({ dateRange: { startDate: theStartDate ?? undefined, endDate: theEndDate ?? undefined, }, }), updateFiltering, ); const initialInterval = useRef( dateRange ?? fallbackInterval ?? visitsSettings?.defaultInterval ?? 'last30Days', ); const [highlightedVisits, setHighlightedVisits] = useState([]); const [highlightedLabel, setHighlightedLabel] = useState(); const isFirstLoad = useRef(true); const { search } = useLocation(); const buildSectionUrl = (subPath?: string) => (!subPath ? search : `${subPath}${search}`); const normalizedVisits = useMemo(() => normalizeVisits(visits), [visits]); const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo( () => processStatsFromVisits(normalizedVisits), [normalizedVisits], ); const resolvedFilter = useMemo(() => ({ ...visitsFilter, excludeBots: visitsFilter.excludeBots ?? visitsSettings?.excludeBots, }), [visitsFilter]); const mapLocations = values(citiesForMap); const setSelectedVisits = (selectedVisits: NormalizedVisit[]) => { selectedBar = undefined; setHighlightedVisits(selectedVisits); }; const highlightVisitsForProp = (prop: HighlightableProps) => (value: string) => { const newSelectedBar = `${prop}_${value}`; if (selectedBar === newSelectedBar) { setHighlightedVisits([]); setHighlightedLabel(undefined); selectedBar = undefined; } else { setHighlightedVisits((normalizedVisits as NormalizedOrphanVisit[]).filter(propEq(prop, value))); setHighlightedLabel(value); selectedBar = newSelectedBar; } }; useEffect(() => cancelGetVisits, []); useEffect(() => { const resolvedDateRange = !isFirstLoad.current ? dateRange : (dateRange ?? toDateRange(initialInterval.current)); getVisits({ dateRange: resolvedDateRange, filter: resolvedFilter }, isFirstLoad.current); isFirstLoad.current = false; }, [dateRange, visitsFilter]); useEffect(() => { // As soon as the fallback is loaded, if the initial interval used the settings one, we do fall back if (fallbackInterval && initialInterval.current === (visitsSettings?.defaultInterval ?? 'last30Days')) { initialInterval.current = fallbackInterval; } }, [fallbackInterval]); const renderVisitsContent = () => { if (loadingLarge) { return ( This is going to take a while... :S ); } if (loading) { return ; } if (error) { return ( ); } if (isEmpty(visits)) { return There are no visits matching current filter; } return ( <> {Object.values(sections).map(({ title, icon, subPath }, index) => ( {title} ))} )} />
{isOrphanVisits && (
)} )} />
mapLocations.length > 0 && ( )} sortingItems={{ name: 'City name', amount: 'Visits amount', }} onClick={highlightVisitsForProp('city')} />
)} /> )} /> } />
); }; return ( <> {children}
updateFiltering({ visitsFilter: newVisitsFilter })} />
{visits.length > 0 && (
exportCsv(normalizedVisits)} />
)}
{renderVisitsContent()}
); };