diff --git a/package.json b/package.json
index 34f12cc97..63929838c 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
"@tryghost/content-api": "^1.11.4",
"axios": "0.21.4",
"axios-hooks": "2.7.0",
+ "chart.js": "^4.4.0",
"date-fns": "2.24.0",
"dompurify": "^3.0.3",
"formik": "2.2.9",
@@ -62,6 +63,7 @@
"quill-blot-formatter": "^1.0.5",
"quill-html-edit-button": "^2.2.12",
"react": "18.2.0",
+ "react-chartjs-2": "^5.2.0",
"react-dom": "18.2.0",
"react-gtm-module": "2.0.11",
"react-i18next": "^11.17.1",
diff --git a/src/components/client/campaigns/CampaignDetails.tsx b/src/components/client/campaigns/CampaignDetails.tsx
index 4d14b40a2..33be22a54 100644
--- a/src/components/client/campaigns/CampaignDetails.tsx
+++ b/src/components/client/campaigns/CampaignDetails.tsx
@@ -26,6 +26,7 @@ import { routes } from 'common/routes'
import { useCanEditCampaign } from 'common/hooks/campaigns'
import { moneyPublic } from 'common/util/money'
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong'
+import CampaignPublicExpensesChart from './CampaignPublicExpensesChart'
const ReactQuill = dynamic(() => import('react-quill'), { ssr: false })
const CampaignNewsSection = dynamic(() => import('./CampaignNewsSection'), { ssr: false })
@@ -143,6 +144,13 @@ export default function CampaignDetails({ campaign }: Props) {
)}
+
+
+
{t('expenses:reported')}:{' '}
diff --git a/src/components/client/campaigns/CampaignPublicExpensesChart.tsx b/src/components/client/campaigns/CampaignPublicExpensesChart.tsx
new file mode 100644
index 000000000..1dad5a43b
--- /dev/null
+++ b/src/components/client/campaigns/CampaignPublicExpensesChart.tsx
@@ -0,0 +1,100 @@
+import React from 'react'
+import { observer } from 'mobx-react'
+import { useTranslation } from 'next-i18next'
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ BarElement,
+ Title,
+ Colors,
+ Tooltip,
+ Legend,
+ TooltipItem,
+} from 'chart.js'
+import { Bar } from 'react-chartjs-2'
+
+import { useCampaignApprovedExpensesList } from 'common/hooks/expenses'
+import { fromMoney, moneyPublic, toMoney } from 'common/util/money'
+
+ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Colors, Tooltip, Legend)
+
+type ExpenseDataset = {
+ type: string
+ total: number
+}
+
+type Props = {
+ slug: string
+ reachedAmount: number
+ currency?: string
+}
+
+export default observer(function CampaignPublicExpensesChart({
+ slug,
+ reachedAmount,
+ currency,
+}: Props) {
+ const { t } = useTranslation('')
+ const { data: campaignExpenses } = useCampaignApprovedExpensesList(slug)
+
+ const expenses: ExpenseDataset[] = []
+
+ campaignExpenses?.forEach(({ type, amount }) => {
+ const exists = expenses.find((e) => e.type === type)
+ if (exists) exists.total += fromMoney(amount)
+ else expenses.push({ type, total: fromMoney(amount) })
+ })
+
+ expenses.sort((a, b) => b.total - a.total)
+
+ const options = {
+ indexAxis: 'y' as const,
+ scales: {
+ x: {
+ stacked: true,
+ min: 0,
+ max: fromMoney(reachedAmount),
+ },
+ y: {
+ stacked: true,
+ display: false,
+ },
+ },
+ elements: {
+ bar: {
+ borderWidth: 1,
+ },
+ },
+ responsive: true,
+ plugins: {
+ legend: {
+ position: 'bottom' as const,
+ },
+ colors: {
+ enabled: true,
+ },
+ tooltip: {
+ callbacks: {
+ label: (context: TooltipItem<'bar'>) =>
+ ` ${context.dataset.label + ':' || ''} ${moneyPublic(
+ toMoney(context.parsed.x),
+ currency,
+ )}`,
+ },
+ },
+ },
+ }
+
+ const data = {
+ labels: [''],
+ datasets: expenses.map((expense) => {
+ return {
+ label: t('expenses:field-types.' + expense.type),
+ data: [expense.total],
+ }
+ }),
+ }
+
+ return
+})
diff --git a/yarn.lock b/yarn.lock
index 6c7c5de8f..961b43692 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1296,6 +1296,13 @@ __metadata:
languageName: node
linkType: hard
+"@kurkle/color@npm:^0.3.0":
+ version: 0.3.2
+ resolution: "@kurkle/color@npm:0.3.2"
+ checksum: 79e97b31f8f6efb28c69d373f94b0c7480226fe8ec95221f518ac998e156444a496727ce47de6d728eb5c3369288e794cba82cae34253deb0d472d3bfe080e49
+ languageName: node
+ linkType: hard
+
"@lexical/clipboard@npm:0.11.3, @lexical/clipboard@npm:^0.11.1":
version: 0.11.3
resolution: "@lexical/clipboard@npm:0.11.3"
@@ -4960,6 +4967,15 @@ __metadata:
languageName: node
linkType: hard
+"chart.js@npm:^4.4.0":
+ version: 4.4.0
+ resolution: "chart.js@npm:4.4.0"
+ dependencies:
+ "@kurkle/color": ^0.3.0
+ checksum: 5ee2d99b78608025525b5790af17178fdaa5adc3294e082deba2718029b0496109ba124f1b08dd1e4c8a04d6e842be7f384f2cfe9a11df8d1c6fe884acece52b
+ languageName: node
+ linkType: hard
+
"chokidar@npm:>=3.0.0 <4.0.0":
version: 3.5.3
resolution: "chokidar@npm:3.5.3"
@@ -11314,6 +11330,7 @@ __metadata:
all-contributors-cli: ^6.20.0
axios: 0.21.4
axios-hooks: 2.7.0
+ chart.js: ^4.4.0
date-fns: 2.24.0
depcheck: ^1.4.3
dompurify: ^3.0.3
@@ -11342,6 +11359,7 @@ __metadata:
quill-blot-formatter: ^1.0.5
quill-html-edit-button: ^2.2.12
react: 18.2.0
+ react-chartjs-2: ^5.2.0
react-dom: 18.2.0
react-gtm-module: 2.0.11
react-i18next: ^11.17.1
@@ -11785,6 +11803,16 @@ __metadata:
languageName: node
linkType: hard
+"react-chartjs-2@npm:^5.2.0":
+ version: 5.2.0
+ resolution: "react-chartjs-2@npm:5.2.0"
+ peerDependencies:
+ chart.js: ^4.1.1
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ checksum: ace702185be1450e5888a8bcd8b5fc1995067e3b11d236764a67f5567a3d7c32ff16923b8d48d3d39bda6e45135da6c044c9b43fbe8e1978f95aca9d2c0ce348
+ languageName: node
+ linkType: hard
+
"react-devtools-inline@npm:4.4.0":
version: 4.4.0
resolution: "react-devtools-inline@npm:4.4.0"