diff --git a/package.json b/package.json index ea1351d99..a3ce722a9 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "axios": "1.6.0", "axios-hooks": "2.7.0", "chart.js": "^4.4.0", + "chartjs-plugin-annotation": "^3.0.1", "chartjs-plugin-datalabels": "^2.2.0", "date-fns": "2.24.0", "dompurify": "^3.0.3", diff --git a/public/locales/bg/campaigns.json b/public/locales/bg/campaigns.json index fdb2c6a18..a24885560 100644 --- a/public/locales/bg/campaigns.json +++ b/public/locales/bg/campaigns.json @@ -39,6 +39,25 @@ "deleteContent": "Това действие ще изтрие елемента завинаги!", "deleteAllContent": "Това действие ще изтрие избраните елементи завинаги!", "actions": "Действия", + "statistics": { + "button": "Статистика за даренията", + "backButton": "Обратно към кампанията", + "cachedMessage": "Графиките използват кеширани данни!", + "cumulativeTitle": "История на натрупване на сумата от даренията", + "cumulativeDesc": "Графиката показва как се е натрупвала сумата на дарения през времето от началото на кампанията досега. Периоди с равна графика показват затишие, когато е нямало нови дарения.", + "total": "Общо", + "groupedTitle": "Брой дарения в периода", + "groupedDesc": "Графиката показва колко броя дарения са се случили през периода групирани по ден, седмица или месец.", + "count": "Дарения", + "groupBy": "Групиране по", + "day": "Ден", + "week": "Седмица", + "month": "Месец", + "uniqueTitle": "Предпочитани суми за дарения", + "uniqueDesc": "Графиката показва уникалните суми и техния брой и дава видимост върху какви суми са склонни да даряват потребителите.", + "hourlyTitle": "Предпочитани часове за дарение", + "hourlyDesc": "Графиката показва колко дарения са направени в кои часове от деня и дава видимост за активността на дарителите през деня." + }, "alerts": { "selectRow": "Моля изберете ред", "create": "Кампанията беше създадено успешно!", diff --git a/public/locales/en/campaigns.json b/public/locales/en/campaigns.json index 77c5f77b6..a80f11c96 100644 --- a/public/locales/en/campaigns.json +++ b/public/locales/en/campaigns.json @@ -40,6 +40,25 @@ "deleteContent": "This action cannot be undone.", "deleteAllContent": "This action cannot be undone.", "actions": "Actions", + "statistics": { + "button": "Donation statistics", + "backButton": "Go back", + "cachedMessage": "Please note that the charts are using cached data!", + "cumulativeTitle": "Cumulative donations chart", + "cumulativeDesc": "Shows total donated amount to date", + "total": "Total", + "groupedTitle": "Grouped donations chart", + "groupedDesc": "Shows total donated amount per selected period", + "count": "Donations", + "groupBy": "Group by", + "day": "Day", + "week": "Week", + "month": "Month", + "uniqueTitle": "Unique donations chart", + "uniqueDesc": "Shows number of donations with the same unique amount", + "hourlyTitle": "Hourly donations chart", + "hourlyDesc": "Shows number of donations for each hour of the day" + }, "alerts": { "selectRow": "Please select a row", "create": "Campaign created successfully", diff --git a/src/common/hooks/campaigns.ts b/src/common/hooks/campaigns.ts index e11db3ba1..436062ae2 100644 --- a/src/common/hooks/campaigns.ts +++ b/src/common/hooks/campaigns.ts @@ -9,12 +9,16 @@ import { AdminCampaignResponse, AdminSingleCampaignResponse, CampaignDonationHistoryResponse, + CampaignGroupedDonations, + CampaignUniqueDonations, + CampaignHourlyDonations, } from 'gql/campaigns' import { DonationStatus } from 'gql/donations.enums' import { apiClient } from 'service/apiClient' import { useCurrentPerson } from 'common/util/useCurrentPerson' import { isAdmin } from 'common/util/roles' import { AxiosError } from 'axios' +import { StatisticsGroupBy } from 'components/client/campaigns/helpers/campaign.enums' // NOTE: shuffling the campaigns so that each gets its fair chance to be on top row export const campaignsOrderQueryFunction: QueryFunction = async ({ @@ -109,6 +113,25 @@ export function useCampaignDetailsPage(id: string) { ) } +export function useCampaignGroupedDonations( + campaignId: string, + groupBy: StatisticsGroupBy = StatisticsGroupBy.DAY, +) { + return useQuery([ + endpoints.statistics.getGroupedDonations(campaignId, groupBy).url, + ]) +} +export function useCampaignUniqueDonations(campaignId: string) { + return useQuery([ + endpoints.statistics.getUniqueDonations(campaignId).url, + ]) +} +export function useCampaignHourlyDonations(campaignId: string) { + return useQuery([ + endpoints.statistics.getHourlyDonations(campaignId).url, + ]) +} + export function useCampaignDonationHistory( campaignId?: string, pageindex?: number, diff --git a/src/common/routes.ts b/src/common/routes.ts index 0b4e1f4cf..9a159c207 100644 --- a/src/common/routes.ts +++ b/src/common/routes.ts @@ -107,6 +107,9 @@ export const routes = { slug ? `/campaigns/${slug}/news?page=${page}` : `/campaigns/news?page=${page}`, newsAdminPanel: (slug: string) => `/campaigns/${slug}/news/admin-panel`, }, + statistics: { + viewBySlug: (slug: string) => `/campaigns/${slug}/statistics`, + }, }, donation: { viewCertificate: (donationId: string) => `/api/pdf/certificate/${donationId}`, diff --git a/src/common/util/chartjs.d.ts b/src/common/util/chartjs.d.ts new file mode 100644 index 000000000..be6cb8413 --- /dev/null +++ b/src/common/util/chartjs.d.ts @@ -0,0 +1,8 @@ +import type { TooltipPositionerFunction } from 'chart.js' + +declare module 'chart.js' { + // Extend tooltip positioner map + interface TooltipPositionerMap { + cursor: TooltipPositionerFunction + } +} diff --git a/src/components/client/campaigns/CampaignInfo/CampaignInfoStatus.tsx b/src/components/client/campaigns/CampaignInfo/CampaignInfoStatus.tsx index 9a2a3566d..ccd74ef1b 100644 --- a/src/components/client/campaigns/CampaignInfo/CampaignInfoStatus.tsx +++ b/src/components/client/campaigns/CampaignInfo/CampaignInfoStatus.tsx @@ -7,6 +7,7 @@ import { Box, Chip, Grid } from '@mui/material' import { getExactDate } from 'common/util/date' import theme from 'common/theme' +import { routes } from 'common/routes' import { StatusText, StatusLabel, RowWrapper, InfoStatusWrapper } from './CampaignInfo.styled' @@ -55,6 +56,16 @@ export default function CampaignInfoStatus({ campaign, showExpensesLink }: Props size="small" sx={{ backgroundColor: theme.palette.primary.light }} /> + {!!campaign.summary.reachedAmount && ( + + )} diff --git a/src/components/client/campaigns/CampaignStatistics/CumulativeDonationsChart.tsx b/src/components/client/campaigns/CampaignStatistics/CumulativeDonationsChart.tsx new file mode 100644 index 000000000..06421042e --- /dev/null +++ b/src/components/client/campaigns/CampaignStatistics/CumulativeDonationsChart.tsx @@ -0,0 +1,170 @@ +import React from 'react' +import { useTranslation } from 'next-i18next' +import { addHours } from 'date-fns' + +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip as ChartTooltip, + Filler, + Legend, + ChartOptions, + TooltipItem, + Plugin, +} from 'chart.js' +import { Line } from 'react-chartjs-2' +import annotationPlugin from 'chartjs-plugin-annotation' +import ChartDataLabels from 'chartjs-plugin-datalabels' + +import { fromMoney, moneyPublic, toMoney } from 'common/util/money' +import { CampaignGroupedDonations } from 'gql/campaigns' +import { useCampaignGroupedDonations } from 'common/hooks/campaigns' +import { Box, Stack, Typography } from '@mui/material' + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + ChartTooltip, + Filler, + Legend, + annotationPlugin, + ChartDataLabels, +) + +ChartTooltip.positioners.cursor = function (chartElements, coordinates) { + return coordinates +} + +const plugins: Plugin[] = [ + { + id: 'tooltipLine', + afterDraw: (chart) => { + if (chart.tooltip?.opacity === 1) { + const { ctx } = chart + const { caretX } = chart.tooltip + const topY = chart.scales.y.top + const bottomY = chart.scales.y.bottom + + ctx.save() + ctx.setLineDash([3, 3]) + ctx.beginPath() + ctx.moveTo(caretX, topY - 5) + ctx.lineTo(caretX, bottomY) + ctx.lineWidth = 1 + ctx.stroke() + ctx.restore() + } + }, + }, +] + +type Props = { + campaignId: string + startDate?: Date + endDate?: Date + targetAmount: number +} + +export default function CumulativeDonationsChart({ + campaignId, + startDate, + endDate, + targetAmount, +}: Props) { + const { t } = useTranslation() + const { data: statistics } = useCampaignGroupedDonations(campaignId) + if (!statistics?.length) return
+ + const finalData: CampaignGroupedDonations[] = [] + let sum = 0 + + let previousDate = startDate ? new Date(startDate) : new Date(statistics[0].date) + + const end = endDate ? new Date(endDate) : new Date() + + statistics.forEach((donation) => { + while (previousDate < new Date(donation.date)) { + finalData.push({ date: previousDate.toISOString(), count: 0, sum }) + previousDate = addHours(previousDate, 24) + } + sum += donation.sum + finalData.push({ ...donation, sum }) + previousDate = addHours(previousDate, 24) + }) + + while (previousDate < end) { + finalData.push({ date: previousDate.toISOString(), count: 0, sum }) + previousDate = addHours(previousDate, 24) + } + + const options: ChartOptions<'line'> = { + elements: { + point: { + radius: 0, + }, + }, + responsive: true, + plugins: { + legend: { + display: false, + }, + tooltip: { + displayColors: false, + position: 'cursor', + callbacks: { + label: (context: TooltipItem<'line'>) => { + return ` ${context.dataset.label + ':' || ''} ${moneyPublic(toMoney(context.parsed.y))}` + }, + }, + }, + datalabels: { + display: false, + }, + annotation: { + annotations: [ + { + type: 'line', + yMin: fromMoney(targetAmount), + yMax: fromMoney(targetAmount), + borderColor: 'rgb(85, 195, 132)', + borderWidth: 2, + }, + ], + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + } + + const data = { + labels: finalData.map((donation) => donation.date.slice(0, 10)), + datasets: [ + { + fill: true, + label: t('campaigns:statistics.total'), + data: finalData.map((donation) => fromMoney(donation.sum)), + borderColor: 'rgb(53, 162, 235)', + backgroundColor: 'rgba(53, 162, 235, 0.5)', + }, + ], + } + + return ( + + + {t('campaigns:statistics.cumulativeTitle')} + {t('campaigns:statistics.cumulativeDesc')} + + + + ) +} diff --git a/src/components/client/campaigns/CampaignStatistics/GroupedDonationsChart.tsx b/src/components/client/campaigns/CampaignStatistics/GroupedDonationsChart.tsx new file mode 100644 index 000000000..b38cdec88 --- /dev/null +++ b/src/components/client/campaigns/CampaignStatistics/GroupedDonationsChart.tsx @@ -0,0 +1,169 @@ +import React, { useState } from 'react' + +import { useTranslation } from 'next-i18next' +import { addHours, addMonths, addWeeks } from 'date-fns' + +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + BarElement, + Title, + Tooltip as ChartTooltip, + Filler, + Legend, + ChartOptions, + TooltipItem, +} from 'chart.js' +import { Bar } from 'react-chartjs-2' +import ChartDataLabels, { Context } from 'chartjs-plugin-datalabels' + +import { fromMoney, moneyPublic } from 'common/util/money' +import { CampaignGroupedDonations } from 'gql/campaigns' +import { useCampaignGroupedDonations } from 'common/hooks/campaigns' +import { StatisticsGroupBy } from '../helpers/campaign.enums' +import { Box, Button, Stack, Typography } from '@mui/material' + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + BarElement, + Title, + ChartTooltip, + Filler, + Legend, + ChartDataLabels, +) + +type Props = { + campaignId: string + startDate?: Date + endDate?: Date +} + +export default function GroupedDonationsChart({ campaignId, startDate, endDate }: Props) { + const { t } = useTranslation() + const [timePeriod, setTimePeriod] = useState(StatisticsGroupBy.WEEK) + + const { data: statistics } = useCampaignGroupedDonations(campaignId, timePeriod) + if (!statistics?.length) return
+ + const finalData: CampaignGroupedDonations[] = [] + + let previousDate = startDate ? new Date(startDate) : new Date(statistics[0].date) + + const end = endDate ? new Date(endDate) : new Date() + + const addPeriod = (date: Date, period: StatisticsGroupBy) => { + switch (period) { + case StatisticsGroupBy.DAY: + return addHours(date, 24) + case StatisticsGroupBy.WEEK: + return addWeeks(date, 1) + case StatisticsGroupBy.MONTH: + return addMonths(date, 1) + } + } + + statistics.forEach((donation) => { + while (previousDate < new Date(donation.date)) { + finalData.push({ date: previousDate.toISOString(), count: 0, sum: 0 }) + previousDate = addHours(previousDate, 24) + } + finalData.push(donation) + previousDate = addPeriod(previousDate, timePeriod) + }) + + while (previousDate < end) { + finalData.push({ date: previousDate.toISOString(), count: 0, sum: 0 }) + previousDate = addPeriod(previousDate, timePeriod) + } + + const options: ChartOptions<'bar'> = { + responsive: true, + plugins: { + legend: { + display: false, + }, + tooltip: { + displayColors: false, + callbacks: { + title: (context: TooltipItem<'bar'>[]) => { + const label = context[0].label + if (timePeriod === StatisticsGroupBy.WEEK) + return `${label} / ${addPeriod(new Date(label), timePeriod) + .toISOString() + .slice(0, 10)}` + return label + }, + label: (context: TooltipItem<'bar'>) => { + const item = finalData[context.dataIndex] + return [ + `${t('campaigns:statistics.count')}: ${item.count}`, + `${t('campaigns:statistics.total')}: ${moneyPublic(item.sum)}`, + ] + }, + }, + }, + datalabels: { + formatter: function (value: number, context: Context) { + const item = finalData[context.dataIndex] + return item.count ? item.count : '' + }, + display: true, + color: 'black', + anchor: 'end', + offset: -20, + align: 'start', + }, + }, + } + const data = { + labels: finalData.map((donation) => + timePeriod === StatisticsGroupBy.MONTH + ? donation.date.slice(0, 7) + : donation.date.slice(0, 10), + ), + datasets: [ + { + fill: true, + data: finalData.map((donation) => fromMoney(donation.sum)), + borderWidth: 2, + borderColor: 'rgb(53, 162, 235)', + backgroundColor: 'rgba(53, 162, 235, 0.5)', + }, + ], + } + + return ( + + + {t('campaigns:statistics.groupedTitle')} + {t('campaigns:statistics.groupedDesc')} + + + + {t('campaigns:statistics.groupBy')}: + + + + + + + + ) +} diff --git a/src/components/client/campaigns/CampaignStatistics/HourlyDonationsChart.tsx b/src/components/client/campaigns/CampaignStatistics/HourlyDonationsChart.tsx new file mode 100644 index 000000000..78415c7e8 --- /dev/null +++ b/src/components/client/campaigns/CampaignStatistics/HourlyDonationsChart.tsx @@ -0,0 +1,111 @@ +import React from 'react' + +import { useTranslation } from 'next-i18next' + +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + BarElement, + Title, + Tooltip as ChartTooltip, + Filler, + Legend, + ChartOptions, +} from 'chart.js' +import { Bar } from 'react-chartjs-2' +import ChartDataLabels from 'chartjs-plugin-datalabels' + +import { useCampaignHourlyDonations } from 'common/hooks/campaigns' +import { Box, Stack, Typography } from '@mui/material' +import { CampaignHourlyDonations } from 'gql/campaigns' + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + BarElement, + Title, + ChartTooltip, + Filler, + Legend, + ChartDataLabels, +) + +ChartTooltip.positioners.cursor = function (chartElements, coordinates) { + return coordinates +} + +type Props = { + campaignId: string +} + +export default function HourlyDonationsChart({ campaignId }: Props) { + const { t } = useTranslation() + const { data: statistics } = useCampaignHourlyDonations(campaignId) + if (!statistics?.length) return
+ + const finalData: CampaignHourlyDonations[] = [] + + let lastHour = 0 + + statistics.forEach((period) => { + while (lastHour < period.hour) { + finalData.push({ hour: lastHour, count: 0 }) + lastHour += 1 + } + finalData.push(period) + lastHour += 1 + }) + + while (lastHour < 24) { + finalData.push({ hour: lastHour, count: 0 }) + lastHour += 1 + } + + const options: ChartOptions<'bar'> = { + responsive: true, + plugins: { + legend: { + display: false, + }, + tooltip: { + displayColors: false, + position: 'cursor', + }, + datalabels: { + formatter: function (value: number) { + return value ? value : '' + }, + display: true, + color: 'black', + }, + }, + } + const data = { + labels: finalData.map((donation) => + new Date(1000 * 60 * 60 * donation.hour).toISOString().slice(11, 16), + ), + datasets: [ + { + fill: true, + label: t('campaigns:statistics.count'), + data: finalData.map((donation) => donation.count), + borderWidth: 2, + borderColor: 'rgb(53, 162, 235)', + backgroundColor: 'rgba(53, 162, 235, 0.5)', + }, + ], + } + + return ( + + + {t('campaigns:statistics.hourlyTitle')} + {t('campaigns:statistics.hourlyDesc')} + + + + ) +} diff --git a/src/components/client/campaigns/CampaignStatistics/UniqueDonationsChart.tsx b/src/components/client/campaigns/CampaignStatistics/UniqueDonationsChart.tsx new file mode 100644 index 000000000..19e05932f --- /dev/null +++ b/src/components/client/campaigns/CampaignStatistics/UniqueDonationsChart.tsx @@ -0,0 +1,83 @@ +import React from 'react' + +import { useTranslation } from 'next-i18next' + +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + BarElement, + Title, + Tooltip as ChartTooltip, + Filler, + Legend, + ChartOptions, +} from 'chart.js' +import { Bar } from 'react-chartjs-2' +import ChartDataLabels from 'chartjs-plugin-datalabels' + +import { moneyPublic } from 'common/util/money' +import { useCampaignUniqueDonations } from 'common/hooks/campaigns' +import { Box, Stack, Typography } from '@mui/material' + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + BarElement, + Title, + ChartTooltip, + Filler, + Legend, + ChartDataLabels, +) + +type Props = { + campaignId: string +} + +export default function UniqueDonationsChart({ campaignId }: Props) { + const { t } = useTranslation() + const { data: statistics } = useCampaignUniqueDonations(campaignId) + if (!statistics?.length) return
+ + const options: ChartOptions<'bar'> = { + responsive: true, + plugins: { + legend: { + display: false, + }, + tooltip: { + displayColors: false, + }, + datalabels: { + display: true, + color: 'black', + }, + }, + } + const data = { + labels: statistics.map((donation) => moneyPublic(donation.amount)), + datasets: [ + { + fill: true, + label: t('campaigns:statistics.count'), + data: statistics.map((donation) => donation.count), + borderWidth: 2, + borderColor: 'rgb(53, 162, 235)', + backgroundColor: 'rgba(53, 162, 235, 0.5)', + }, + ], + } + + return ( + + + {t('campaigns:statistics.uniqueTitle')} + {t('campaigns:statistics.uniqueDesc')} + + + + ) +} diff --git a/src/components/client/campaigns/StatisticsPage.tsx b/src/components/client/campaigns/StatisticsPage.tsx new file mode 100644 index 000000000..1ed58a902 --- /dev/null +++ b/src/components/client/campaigns/StatisticsPage.tsx @@ -0,0 +1,66 @@ +import React from 'react' + +import { Container, Stack, Typography } from '@mui/material' +import { useTranslation } from 'next-i18next' + +import { useViewCampaign } from 'common/hooks/campaigns' +import Layout from 'components/client/layout/Layout' +import CenteredSpinner from 'components/common/CenteredSpinner' +import NotFoundPage from 'pages/404' +import CumulativeDonationsChart from './CampaignStatistics/CumulativeDonationsChart' +import GroupedDonationsChart from './CampaignStatistics/GroupedDonationsChart' +import UniqueDonationsChart from './CampaignStatistics/UniqueDonationsChart' +import HourlyDonationsChart from './CampaignStatistics/HourlyDonationsChart' +import Link from 'components/common/Link' +import { routes } from 'common/routes' + +type Props = { slug: string } + +export default function StatisticsPage({ slug }: Props) { + const { t } = useTranslation() + const { data: campaignResponse, isLoading, isError } = useViewCampaign(slug) + if (isLoading || !campaignResponse) + return ( + <> + {/* {isLoading && } */} + {isError && } + + ) + + const campaign = campaignResponse.campaign + const campaignTitle = campaign.title + const hasDonations = !!campaign.summary.reachedAmount + + return ( + + + + {t('campaigns:statistics.backButton')} + + + + {campaignTitle} + + {t('campaigns:statistics.cachedMessage')} + + {hasDonations && ( + + + + + + + )} + + + ) +} diff --git a/src/components/client/campaigns/helpers/campaign.enums.ts b/src/components/client/campaigns/helpers/campaign.enums.ts index 8bc9e7981..e2ef32d30 100644 --- a/src/components/client/campaigns/helpers/campaign.enums.ts +++ b/src/components/client/campaigns/helpers/campaign.enums.ts @@ -12,3 +12,9 @@ export enum CampaignState { error = 'error', deleted = 'deleted', } + +export enum StatisticsGroupBy { + DAY = 'day', + WEEK = 'week', + MONTH = 'month', +} diff --git a/src/gql/campaigns.ts b/src/gql/campaigns.ts index d58202c3e..cf9f231f1 100644 --- a/src/gql/campaigns.ts +++ b/src/gql/campaigns.ts @@ -188,6 +188,22 @@ export type CampaignDonation = { } } +export type CampaignGroupedDonations = { + sum: number + count: number + date: string +} + +export type CampaignUniqueDonations = { + amount: number + count: number +} + +export type CampaignHourlyDonations = { + hour: number + count: number +} + export type CampaignDonationHistoryResponse = { items: CampaignDonation[] total: number diff --git a/src/pages/campaigns/[slug]/statistics.tsx b/src/pages/campaigns/[slug]/statistics.tsx new file mode 100644 index 000000000..dd8dee6d9 --- /dev/null +++ b/src/pages/campaigns/[slug]/statistics.tsx @@ -0,0 +1,16 @@ +import { GetServerSideProps } from 'next' +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' + +import StatisticsPage from 'components/client/campaigns/StatisticsPage' + +export const getServerSideProps: GetServerSideProps = async ({ query, locale }) => { + const { slug } = query + return { + props: { + slug, + ...(await serverSideTranslations(locale ?? 'bg', ['common', 'campaigns'])), + }, + } +} + +export default StatisticsPage diff --git a/src/service/apiEndpoints.ts b/src/service/apiEndpoints.ts index c5de99b86..0e5c27735 100644 --- a/src/service/apiEndpoints.ts +++ b/src/service/apiEndpoints.ts @@ -1,5 +1,6 @@ import { Method } from 'axios' import { DonationStatus } from 'gql/donations.enums' +import { StatisticsGroupBy } from 'components/client/campaigns/helpers/campaign.enums' import { FilterData, PaginationData, SortData } from 'gql/types' type Endpoint = { @@ -86,6 +87,14 @@ export const endpoints = { supportRequestList: { url: '/support/support-request/list', method: 'GET' }, infoRequestList: { url: '/support/info-request/list', method: 'GET' }, }, + statistics: { + getGroupedDonations: (id: string, groupBy: StatisticsGroupBy) => + { url: `statistics/donations/${id}?groupBy=${groupBy}`, method: 'GET' }, + getUniqueDonations: (id: string) => + { url: `statistics/unique-donations/${id}`, method: 'GET' }, + getHourlyDonations: (id: string) => + { url: `statistics/hourly-donations/${id}`, method: 'GET' }, + }, donation: { prices: { url: '/donation/prices', method: 'GET' }, singlePrices: { url: '/donation/prices/single', method: 'GET' }, diff --git a/yarn.lock b/yarn.lock index fe063bad4..e193dfa34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5320,6 +5320,15 @@ __metadata: languageName: node linkType: hard +"chartjs-plugin-annotation@npm:^3.0.1": + version: 3.0.1 + resolution: "chartjs-plugin-annotation@npm:3.0.1" + peerDependencies: + chart.js: ">=4.0.0" + checksum: 97dd4868951314b263dc6baa34884fcd1267d597c0981c8d322fa1f1896b4e7b881c474e9c3b46e40ef281ec003917e0bbcbe64c2c3084599146a251f92deca1 + languageName: node + linkType: hard + "chartjs-plugin-datalabels@npm:^2.2.0": version: 2.2.0 resolution: "chartjs-plugin-datalabels@npm:2.2.0" @@ -11712,6 +11721,7 @@ __metadata: axios: 1.6.0 axios-hooks: 2.7.0 chart.js: ^4.4.0 + chartjs-plugin-annotation: ^3.0.1 chartjs-plugin-datalabels: ^2.2.0 date-fns: 2.24.0 depcheck: ^1.4.3