mirror of
https://github.com/umami-software/umami.git
synced 2025-12-08 05:12:36 +01:00
Added revenue screen.
This commit is contained in:
parent
bce6737f29
commit
7662b77ce3
15 changed files with 351 additions and 29 deletions
|
|
@ -78,7 +78,7 @@
|
|||
"@react-spring/web": "^9.7.3",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@tanstack/react-query": "^5.28.6",
|
||||
"@umami/react-zen": "^0.116.0",
|
||||
"@umami/react-zen": "^0.117.0",
|
||||
"@umami/redis-client": "^0.27.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"chalk": "^4.1.1",
|
||||
|
|
|
|||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
|
|
@ -42,8 +42,8 @@ importers:
|
|||
specifier: ^5.28.6
|
||||
version: 5.76.1(react@19.1.0)
|
||||
'@umami/react-zen':
|
||||
specifier: ^0.116.0
|
||||
version: 0.116.0(@babel/core@7.27.1)(@types/react@19.1.4)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))
|
||||
specifier: ^0.117.0
|
||||
version: 0.117.0(@babel/core@7.27.1)(@types/react@19.1.4)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))
|
||||
'@umami/redis-client':
|
||||
specifier: ^0.27.0
|
||||
version: 0.27.0
|
||||
|
|
@ -3035,8 +3035,8 @@ packages:
|
|||
resolution: {integrity: sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@umami/react-zen@0.116.0':
|
||||
resolution: {integrity: sha512-/OjfYgwA9/4JpfKjf/b3HinVoeEoyOfLHJk8Uv0opBx+Jy2I6WPM4ZwvaRVxIbNwzpw/JZekC46TJs6bQNzbGg==}
|
||||
'@umami/react-zen@0.117.0':
|
||||
resolution: {integrity: sha512-vo1i25cBpMsAWNHJ4RPFDJzlaH93NXwx8VotmnoAWgP39Yy+fdUwmi+dDXNBOKvWzoHO9BFf0nIYiT16klnR6g==}
|
||||
|
||||
'@umami/redis-client@0.27.0':
|
||||
resolution: {integrity: sha512-SbHTpxhgeZyTBUSp2zdZM+XUtpsaSL4Tad8QXIEhEtjWhvvfoornyT5kLuyYCVtzSAT4daALeGmOO1z6EE1KcA==}
|
||||
|
|
@ -7459,8 +7459,8 @@ packages:
|
|||
react:
|
||||
optional: true
|
||||
|
||||
zustand@5.0.4:
|
||||
resolution: {integrity: sha512-39VFTN5InDtMd28ZhjLyuTnlytDr9HfwO512Ai4I8ZABCoyAj4F1+sr7sD1jP/+p7k77Iko0Pb5NhgBFDCX0kQ==}
|
||||
zustand@5.0.5:
|
||||
resolution: {integrity: sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=18.0.0'
|
||||
|
|
@ -10860,7 +10860,7 @@ snapshots:
|
|||
'@typescript-eslint/types': 8.32.1
|
||||
eslint-visitor-keys: 4.2.0
|
||||
|
||||
'@umami/react-zen@0.116.0(@babel/core@7.27.1)(@types/react@19.1.4)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))':
|
||||
'@umami/react-zen@0.117.0(@babel/core@7.27.1)(@types/react@19.1.4)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))':
|
||||
dependencies:
|
||||
'@fontsource/jetbrains-mono': 5.2.5
|
||||
'@internationalized/date': 3.8.1
|
||||
|
|
@ -10877,7 +10877,7 @@ snapshots:
|
|||
react-hook-form: 7.56.4(react@19.1.0)
|
||||
react-icons: 5.5.0(react@19.1.0)
|
||||
thenby: 1.3.4
|
||||
zustand: 5.0.4(@types/react@19.1.4)(immer@9.0.21)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0))
|
||||
zustand: 5.0.5(@types/react@19.1.4)(immer@9.0.21)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0))
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@opentelemetry/api'
|
||||
|
|
@ -16046,7 +16046,7 @@ snapshots:
|
|||
immer: 9.0.21
|
||||
react: 19.1.0
|
||||
|
||||
zustand@5.0.4(@types/react@19.1.4)(immer@9.0.21)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0)):
|
||||
zustand@5.0.5(@types/react@19.1.4)(immer@9.0.21)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0)):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.4
|
||||
immer: 9.0.21
|
||||
|
|
|
|||
|
|
@ -15,11 +15,6 @@ export function SideNav(props: any) {
|
|||
href: renderTeamUrl('/dashboard'),
|
||||
icon: <Lucide.Copy />,
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.reports),
|
||||
href: renderTeamUrl('/reports'),
|
||||
icon: <Lucide.ChartArea />,
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.websites),
|
||||
href: renderTeamUrl('/websites'),
|
||||
|
|
|
|||
|
|
@ -5,19 +5,19 @@ import { FilterBar } from '@/components/input/FilterBar';
|
|||
|
||||
export function WebsiteControls({
|
||||
websiteId,
|
||||
showFilter = true,
|
||||
showCompare,
|
||||
allowFilter = true,
|
||||
allowCompare,
|
||||
}: {
|
||||
websiteId: string;
|
||||
showFilter?: boolean;
|
||||
showCompare?: boolean;
|
||||
allowFilter?: boolean;
|
||||
allowCompare?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Column gap>
|
||||
<Row alignItems="center" justifyContent="space-between" gap="3">
|
||||
{showFilter && <WebsiteFilterButton websiteId={websiteId} />}
|
||||
{allowFilter && <WebsiteFilterButton websiteId={websiteId} />}
|
||||
<Row alignItems="center" gap="3">
|
||||
<WebsiteDateFilter websiteId={websiteId} showCompare={showCompare} />
|
||||
<WebsiteDateFilter websiteId={websiteId} allowCompare={allowCompare} />
|
||||
</Row>
|
||||
</Row>
|
||||
<FilterBar />
|
||||
|
|
|
|||
13
src/app/(main)/websites/[websiteId]/revenue/RevenuePage.tsx
Normal file
13
src/app/(main)/websites/[websiteId]/revenue/RevenuePage.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { RevenueView } from './RevenueView';
|
||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
|
||||
export function RevenuePage({ websiteId }: { websiteId: string }) {
|
||||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} allowCompare={false} />
|
||||
<RevenueView websiteId={websiteId} />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
27
src/app/(main)/websites/[websiteId]/revenue/RevenueTable.tsx
Normal file
27
src/app/(main)/websites/[websiteId]/revenue/RevenueTable.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { DataColumn, DataTable } from '@umami/react-zen';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { formatLongCurrency } from '@/lib/format';
|
||||
|
||||
export function RevenueTable({ data = [] }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<DataTable data={data}>
|
||||
<DataColumn id="currency" label={formatMessage(labels.currency)} align="end">
|
||||
{(row: any) => row.currency}
|
||||
</DataColumn>
|
||||
<DataColumn id="currency" label={formatMessage(labels.total)} align="end">
|
||||
{(row: any) => formatLongCurrency(row.sum, row.currency)}
|
||||
</DataColumn>
|
||||
<DataColumn id="currency" label={formatMessage(labels.average)} align="end">
|
||||
{(row: any) => formatLongCurrency(row.count ? row.sum / row.count : 0, row.currency)}
|
||||
</DataColumn>
|
||||
<DataColumn id="currency" label={formatMessage(labels.transactions)} align="end">
|
||||
{(row: any) => row.count}
|
||||
</DataColumn>
|
||||
<DataColumn id="currency" label={formatMessage(labels.uniqueCustomers)} align="end">
|
||||
{(row: any) => row.unique_count}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
169
src/app/(main)/websites/[websiteId]/revenue/RevenueView.tsx
Normal file
169
src/app/(main)/websites/[websiteId]/revenue/RevenueView.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import classNames from 'classnames';
|
||||
import { colord } from 'colord';
|
||||
import { BarChart } from '@/components/charts/BarChart';
|
||||
import { PieChart } from '@/components/charts/PieChart';
|
||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
||||
import {
|
||||
useCountryNames,
|
||||
useLocale,
|
||||
useMessages,
|
||||
useRevenueQuery,
|
||||
useDateRange,
|
||||
} from '@/components/hooks';
|
||||
import { GridRow } from '@/components/common/GridRow';
|
||||
import { ListTable } from '@/components/metrics/ListTable';
|
||||
import { MetricCard } from '@/components/metrics/MetricCard';
|
||||
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
||||
import { renderDateLabels } from '@/lib/charts';
|
||||
import { CHART_COLORS } from '@/lib/constants';
|
||||
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { RevenueTable } from './RevenueTable';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { Column } from '@umami/react-zen';
|
||||
|
||||
export interface RevenueViewProps {
|
||||
websiteId: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function RevenueView({ websiteId, isLoading }: RevenueViewProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { locale } = useLocale();
|
||||
const { countryNames } = useCountryNames(locale);
|
||||
|
||||
const { data } = useRevenueQuery(websiteId);
|
||||
const currency = 'USD';
|
||||
const { dateRange } = useDateRange(websiteId);
|
||||
|
||||
const renderCountryName = useCallback(
|
||||
({ x: code }) => (
|
||||
<span className={classNames(locale)}>
|
||||
<TypeIcon type="country" value={code?.toLowerCase()} />
|
||||
{countryNames[code]}
|
||||
</span>
|
||||
),
|
||||
[countryNames, locale],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
const map = (data.chart as any[]).reduce((obj, { x, t, y }) => {
|
||||
if (!obj[x]) {
|
||||
obj[x] = [];
|
||||
}
|
||||
|
||||
obj[x].push({ x: t, y });
|
||||
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
datasets: Object.keys(map).map((key, index) => {
|
||||
const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
|
||||
return {
|
||||
label: key,
|
||||
data: map[key],
|
||||
lineTension: 0,
|
||||
backgroundColor: color.alpha(0.6).toRgbString(),
|
||||
borderColor: color.alpha(0.7).toRgbString(),
|
||||
borderWidth: 1,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const countryData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
const labels = data.country.map(({ name }) => name);
|
||||
const datasets = [
|
||||
{
|
||||
data: data.country.map(({ value }) => value),
|
||||
backgroundColor: CHART_COLORS,
|
||||
borderWidth: 0,
|
||||
},
|
||||
];
|
||||
|
||||
return { labels, datasets };
|
||||
}, [data]);
|
||||
|
||||
const metricData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
const { sum, count, unique_count } = data.total;
|
||||
|
||||
return [
|
||||
{
|
||||
value: sum,
|
||||
label: formatMessage(labels.total),
|
||||
formatValue: n => formatLongCurrency(n, currency),
|
||||
},
|
||||
{
|
||||
value: count ? sum / count : 0,
|
||||
label: formatMessage(labels.average),
|
||||
formatValue: n => formatLongCurrency(n, currency),
|
||||
},
|
||||
{
|
||||
value: count,
|
||||
label: formatMessage(labels.transactions),
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
{
|
||||
value: unique_count,
|
||||
label: formatMessage(labels.uniqueCustomers),
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
] as any;
|
||||
}, [data, locale]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Column gap>
|
||||
<Panel>
|
||||
<MetricsBar isFetched={!!data}>
|
||||
{metricData?.map(({ label, value, formatValue }) => {
|
||||
return (
|
||||
<MetricCard key={label} value={value} label={label} formatValue={formatValue} />
|
||||
);
|
||||
})}
|
||||
</MetricsBar>
|
||||
</Panel>
|
||||
{data && (
|
||||
<>
|
||||
<Panel>
|
||||
<BarChart
|
||||
minDate={dateRange?.startDate}
|
||||
maxDate={dateRange?.endDate}
|
||||
data={chartData}
|
||||
unit={dateRange?.unit}
|
||||
stacked={true}
|
||||
currency={currency}
|
||||
renderXLabel={renderDateLabels(dateRange?.unit, locale)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel>
|
||||
<GridRow layout="two">
|
||||
<ListTable
|
||||
metric={formatMessage(labels.country)}
|
||||
data={data?.country.map(({ name, value }) => ({
|
||||
x: name,
|
||||
y: Number(value),
|
||||
z: (value / data?.total.sum) * 100,
|
||||
}))}
|
||||
renderLabel={renderCountryName}
|
||||
/>
|
||||
<PieChart type="doughnut" data={countryData} />
|
||||
</GridRow>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
<Panel>
|
||||
<RevenueTable data={data?.table} />
|
||||
</Panel>
|
||||
</Column>
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
src/app/(main)/websites/[websiteId]/revenue/page.tsx
Normal file
12
src/app/(main)/websites/[websiteId]/revenue/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Metadata } from 'next';
|
||||
import { RevenuePage } from './RevenuePage';
|
||||
|
||||
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||
const { websiteId } = await params;
|
||||
|
||||
return <RevenuePage websiteId={websiteId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Revenue UTM Parameters',
|
||||
};
|
||||
|
|
@ -6,7 +6,7 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
|
|||
export function UTMPage({ websiteId }: { websiteId: string }) {
|
||||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<WebsiteControls websiteId={websiteId} allowCompare={false} />
|
||||
<UTMView websiteId={websiteId} />
|
||||
</Column>
|
||||
);
|
||||
|
|
|
|||
67
src/app/api/websites/[websiteId]/revenue/route.ts
Normal file
67
src/app/api/websites/[websiteId]/revenue/route.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { z } from 'zod';
|
||||
import { canViewWebsite } from '@/lib/auth';
|
||||
import { unauthorized, json } from '@/lib/response';
|
||||
import { getRequestDateRange, parseRequest } from '@/lib/request';
|
||||
import { filterParams, unitParam, timezoneParam } from '@/lib/schema';
|
||||
import { getRevenue } from '@/queries/sql/reports/getRevenue';
|
||||
import { getRevenueValues } from '@/queries/sql/reports/getRevenueValues';
|
||||
|
||||
export async function __GET(request: Request) {
|
||||
const { auth, query, error } = await parseRequest(request);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId, startDate, endDate } = query;
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const data = await getRevenueValues(websiteId, {
|
||||
startDate: new Date(startDate),
|
||||
endDate: new Date(endDate),
|
||||
});
|
||||
|
||||
return json(data);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
currency: z.string(),
|
||||
startAt: z.coerce.number().int(),
|
||||
endAt: z.coerce.number().int(),
|
||||
unit: unitParam,
|
||||
timezone: timezoneParam,
|
||||
...filterParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId } = await params;
|
||||
const { currency, timezone, unit } = query;
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const { startDate, endDate } = await getRequestDateRange(query);
|
||||
|
||||
const data = await getRevenue(websiteId, {
|
||||
startDate,
|
||||
endDate,
|
||||
unit,
|
||||
timezone,
|
||||
currency,
|
||||
});
|
||||
|
||||
return json(data);
|
||||
}
|
||||
|
|
@ -28,8 +28,8 @@ export interface BarChartProps extends ChartProps {
|
|||
renderYLabel?: (label: string, index: number, values: any[]) => string;
|
||||
XAxisType?: string;
|
||||
YAxisType?: string;
|
||||
minDate?: number | string;
|
||||
maxDate?: number | string;
|
||||
minDate?: Date;
|
||||
maxDate?: Date;
|
||||
isAllTime?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { Loading, Box, Column } from '@umami/react-zen';
|
||||
import { Loading, Box, Column, BoxProps } from '@umami/react-zen';
|
||||
import ChartJS, { LegendItem, ChartOptions } from 'chart.js/auto';
|
||||
import { Legend } from '@/components/metrics/Legend';
|
||||
import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants';
|
||||
import type { BoxProps } from '@umami/react-zen/Box';
|
||||
|
||||
export interface ChartProps extends BoxProps {
|
||||
type?: 'bar' | 'bubble' | 'doughnut' | 'pie' | 'line' | 'polarArea' | 'radar' | 'scatter';
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export * from './queries/useRealtimeQuery';
|
|||
export * from './queries/useReportQuery';
|
||||
export * from './queries/useReportsQuery';
|
||||
export * from './queries/useRetentionQuery';
|
||||
export * from './queries/useRevenueQuery';
|
||||
export * from './queries/useSessionActivityQuery';
|
||||
export * from './queries/useSessionDataQuery';
|
||||
export * from './queries/useSessionDataPropertiesQuery';
|
||||
|
|
|
|||
39
src/components/hooks/queries/useRevenueQuery.ts
Normal file
39
src/components/hooks/queries/useRevenueQuery.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useApi } from '../useApi';
|
||||
import { useFilterParams } from '../useFilterParams';
|
||||
import { UseQueryOptions } from '@tanstack/react-query';
|
||||
|
||||
interface RevenueData {
|
||||
chart: any[];
|
||||
country: any[];
|
||||
total: {
|
||||
sum: number;
|
||||
count: number;
|
||||
unique_count: number;
|
||||
};
|
||||
table: any[];
|
||||
}
|
||||
|
||||
export function useRevenueQuery(
|
||||
websiteId: string,
|
||||
queryParams?: { type: string; limit?: number; search?: string; startAt?: number; endAt?: number },
|
||||
options?: Omit<
|
||||
UseQueryOptions<RevenueData, Error, RevenueData, any[]> & { onDataLoad?: (data: any) => void },
|
||||
'queryKey' | 'queryFn'
|
||||
>,
|
||||
) {
|
||||
const { get, useQuery } = useApi();
|
||||
const filterParams = useFilterParams(websiteId);
|
||||
const currency = 'USD';
|
||||
|
||||
return useQuery<RevenueData, Error, RevenueData, any[]>({
|
||||
queryKey: ['revenue', websiteId, { ...filterParams, ...queryParams }],
|
||||
queryFn: () =>
|
||||
get(`/websites/${websiteId}/revenue`, {
|
||||
currency,
|
||||
...filterParams,
|
||||
...queryParams,
|
||||
}),
|
||||
enabled: !!websiteId,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
|
@ -19,13 +19,13 @@ export function WebsiteDateFilter({
|
|||
websiteId,
|
||||
showAllTime = true,
|
||||
showButtons = true,
|
||||
showCompare = true,
|
||||
allowCompare = true,
|
||||
}: {
|
||||
websiteId: string;
|
||||
compare?: string;
|
||||
showAllTime?: boolean;
|
||||
showButtons?: boolean;
|
||||
showCompare?: boolean;
|
||||
allowCompare?: boolean;
|
||||
}) {
|
||||
const { dateRange, saveDateRange } = useDateRange(websiteId);
|
||||
const { value, startDate, endDate, offset } = dateRange;
|
||||
|
|
@ -92,7 +92,7 @@ export function WebsiteDateFilter({
|
|||
</Select>
|
||||
</Row>
|
||||
)}
|
||||
{!isAllTime && showCompare && (
|
||||
{!isAllTime && allowCompare && (
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button variant="quiet" onPress={handleCompare}>
|
||||
<Icon fillColor>{compare ? <Icons.Close /> : <Icons.Compare />}</Icon>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue