mirror of
https://github.com/umami-software/umami.git
synced 2026-05-30 06:47:25 +00:00
Improvements to revenue screen
This commit is contained in:
@@ -36,6 +36,7 @@ pm2.yml
|
|||||||
.claude
|
.claude
|
||||||
.agents
|
.agents
|
||||||
tmpclaude*
|
tmpclaude*
|
||||||
|
CLAUDE.md
|
||||||
nul
|
nul
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
|
|||||||
@@ -338,6 +338,8 @@
|
|||||||
"aov": "AOV",
|
"aov": "AOV",
|
||||||
"arpu": "ARPU",
|
"arpu": "ARPU",
|
||||||
"uniqueCustomers": "Unique Customers",
|
"uniqueCustomers": "Unique Customers",
|
||||||
|
"customers": "Customers",
|
||||||
|
"orders": "Orders",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"untitled": "Untitled",
|
"untitled": "Untitled",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { Column, Grid, Heading, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
|
import { Column, Grid, Heading, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { DataGrid } from '@/components/common/DataGrid';
|
||||||
import { GridRow } from '@/components/common/GridRow';
|
import { GridRow } from '@/components/common/GridRow';
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
import { useDateRange, useMessages, useResultQuery } from '@/components/hooks';
|
import {
|
||||||
|
useDateRange,
|
||||||
|
useMessages,
|
||||||
|
useNavigation,
|
||||||
|
useResultQuery,
|
||||||
|
useRevenueSessionsQuery,
|
||||||
|
} from '@/components/hooks';
|
||||||
import { CurrencySelect } from '@/components/input/CurrencySelect';
|
import { CurrencySelect } from '@/components/input/CurrencySelect';
|
||||||
import { ListTable } from '@/components/metrics/ListTable';
|
import { ListTable } from '@/components/metrics/ListTable';
|
||||||
import { MetricCard } from '@/components/metrics/MetricCard';
|
import { MetricCard } from '@/components/metrics/MetricCard';
|
||||||
@@ -13,6 +20,31 @@ import { RevenueChart } from '@/components/metrics/RevenueChart';
|
|||||||
import { CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants';
|
import { CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants';
|
||||||
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
|
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
|
||||||
import { getItem, setItem } from '@/lib/storage';
|
import { getItem, setItem } from '@/lib/storage';
|
||||||
|
import { SessionModal } from '../../sessions/SessionModal';
|
||||||
|
import { SessionsTable } from '../../sessions/SessionsTable';
|
||||||
|
|
||||||
|
function RevenueSessionsDataTable({
|
||||||
|
websiteId,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
websiteId: string;
|
||||||
|
currency: string;
|
||||||
|
}) {
|
||||||
|
const { updateParams } = useNavigation();
|
||||||
|
const queryResult = useRevenueSessionsQuery(websiteId, currency);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGrid query={queryResult} allowPaging allowSearch>
|
||||||
|
{({ data }) => (
|
||||||
|
<SessionsTable
|
||||||
|
data={data}
|
||||||
|
websiteId={websiteId}
|
||||||
|
getSessionHref={row => updateParams({ session: row.id })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DataGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface RevenueProps {
|
export interface RevenueProps {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
@@ -56,18 +88,30 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
|
|||||||
{
|
{
|
||||||
value: average,
|
value: average,
|
||||||
label: t(labels.aov),
|
label: t(labels.aov),
|
||||||
|
tooltip: (
|
||||||
|
<>
|
||||||
|
<div>Average Order Value</div>
|
||||||
|
<div>(Total Revenue / Orders)</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
change: comparison ? average - comparison.average : 0,
|
change: comparison ? average - comparison.average : 0,
|
||||||
formatValue: (n: number) => formatLongCurrency(n, currency),
|
formatValue: (n: number) => formatLongCurrency(n, currency),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: arpu,
|
value: arpu,
|
||||||
label: t(labels.arpu),
|
label: t(labels.arpu),
|
||||||
|
tooltip: (
|
||||||
|
<>
|
||||||
|
<div>Average Revenue Per User</div>
|
||||||
|
<div>(Total Revenue / All Sessions)</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
change: comparison ? arpu - (comparison.arpu ?? 0) : 0,
|
change: comparison ? arpu - (comparison.arpu ?? 0) : 0,
|
||||||
formatValue: (n: number) => formatLongCurrency(n, currency),
|
formatValue: (n: number) => formatLongCurrency(n, currency),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: count,
|
value: count,
|
||||||
label: t(labels.transactions),
|
label: t(labels.orders),
|
||||||
change: comparison ? count - comparison.count : 0,
|
change: comparison ? count - comparison.count : 0,
|
||||||
formatValue: formatLongNumber,
|
formatValue: formatLongNumber,
|
||||||
},
|
},
|
||||||
@@ -91,12 +135,13 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
|
|||||||
{data && (
|
{data && (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<MetricsBar>
|
<MetricsBar>
|
||||||
{metrics?.map(({ label, value, change, formatValue }) => {
|
{metrics?.map(({ label, value, change, formatValue, tooltip }) => {
|
||||||
return (
|
return (
|
||||||
<MetricCard
|
<MetricCard
|
||||||
key={label}
|
key={label}
|
||||||
value={value}
|
value={value}
|
||||||
label={label}
|
label={label}
|
||||||
|
tooltip={tooltip}
|
||||||
change={change}
|
change={change}
|
||||||
formatValue={formatValue}
|
formatValue={formatValue}
|
||||||
showChange={!isAllTime}
|
showChange={!isAllTime}
|
||||||
@@ -204,9 +249,14 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
|
|||||||
</Panel>
|
</Panel>
|
||||||
</GridRow>
|
</GridRow>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Panel>
|
||||||
|
<Heading size="2xl">{t(labels.customers)}</Heading>
|
||||||
|
<RevenueSessionsDataTable websiteId={websiteId} currency={currency} />
|
||||||
|
</Panel>
|
||||||
</Column>
|
</Column>
|
||||||
)}
|
)}
|
||||||
</LoadingPanel>
|
</LoadingPanel>
|
||||||
|
<SessionModal websiteId={websiteId} />
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import { DateDistance } from '@/components/common/DateDistance';
|
|||||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
import { TypeIcon } from '@/components/common/TypeIcon';
|
||||||
import { useFormat, useMessages } from '@/components/hooks';
|
import { useFormat, useMessages } from '@/components/hooks';
|
||||||
|
|
||||||
export function SessionsTable({ websiteId, ...props }: DataTableProps & { websiteId: string }) {
|
export function SessionsTable({
|
||||||
|
websiteId,
|
||||||
|
getSessionHref,
|
||||||
|
...props
|
||||||
|
}: DataTableProps & { websiteId: string; getSessionHref?: (row: any) => string }) {
|
||||||
const { t, labels } = useMessages();
|
const { t, labels } = useMessages();
|
||||||
const { formatValue } = useFormat();
|
const { formatValue } = useFormat();
|
||||||
|
|
||||||
@@ -13,7 +17,11 @@ export function SessionsTable({ websiteId, ...props }: DataTableProps & { websit
|
|||||||
<DataTable {...props}>
|
<DataTable {...props}>
|
||||||
<DataColumn id="id" label={t(labels.session)} width="100px">
|
<DataColumn id="id" label={t(labels.session)} width="100px">
|
||||||
{(row: any) => (
|
{(row: any) => (
|
||||||
<Link href={`/websites/${websiteId}/sessions/${row.id}`}>
|
<Link
|
||||||
|
href={
|
||||||
|
getSessionHref ? getSessionHref(row) : `/websites/${websiteId}/sessions/${row.id}`
|
||||||
|
}
|
||||||
|
>
|
||||||
<Avatar seed={row.id} size={32} />
|
<Avatar seed={row.id} size={32} />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||||
|
import { json, unauthorized } from '@/lib/response';
|
||||||
|
import { filterParams, pagingParams, searchParams, withDateRange } from '@/lib/schema';
|
||||||
|
import { canViewWebsite } from '@/permissions';
|
||||||
|
import { getRevenueSessions } from '@/queries/sql/reports/getRevenueSessions';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = withDateRange({
|
||||||
|
currency: z.string(),
|
||||||
|
...filterParams,
|
||||||
|
...pagingParams,
|
||||||
|
...searchParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currency, ...rest } = query;
|
||||||
|
const filters = await getQueryFilters(rest, websiteId);
|
||||||
|
|
||||||
|
const data = await getRevenueSessions(websiteId, currency, filters);
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ export * from './queries/useReplaysQuery';
|
|||||||
export * from './queries/useReportQuery';
|
export * from './queries/useReportQuery';
|
||||||
export * from './queries/useReportsQuery';
|
export * from './queries/useReportsQuery';
|
||||||
export * from './queries/useResultQuery';
|
export * from './queries/useResultQuery';
|
||||||
|
export * from './queries/useRevenueSessionsQuery';
|
||||||
export * from './queries/useSessionActivityQuery';
|
export * from './queries/useSessionActivityQuery';
|
||||||
export * from './queries/useSessionDataPropertiesQuery';
|
export * from './queries/useSessionDataPropertiesQuery';
|
||||||
export * from './queries/useSessionDataQuery';
|
export * from './queries/useSessionDataQuery';
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useApi } from '../useApi';
|
||||||
|
import { useDateParameters } from '../useDateParameters';
|
||||||
|
import { useFilterParameters } from '../useFilterParameters';
|
||||||
|
import { useModified } from '../useModified';
|
||||||
|
import { usePagedQuery } from '../usePagedQuery';
|
||||||
|
|
||||||
|
export function useRevenueSessionsQuery(
|
||||||
|
websiteId: string,
|
||||||
|
currency: string,
|
||||||
|
params?: Record<string, string | number>,
|
||||||
|
) {
|
||||||
|
const { get } = useApi();
|
||||||
|
const { modified } = useModified(`revenue-sessions`);
|
||||||
|
const { startAt, endAt, unit, timezone } = useDateParameters();
|
||||||
|
const filters = useFilterParameters();
|
||||||
|
|
||||||
|
return usePagedQuery({
|
||||||
|
queryKey: [
|
||||||
|
'revenue-sessions',
|
||||||
|
{ websiteId, currency, modified, startAt, endAt, unit, timezone, ...params, ...filters },
|
||||||
|
],
|
||||||
|
queryFn: pageParams => {
|
||||||
|
return get(`/websites/${websiteId}/revenue/sessions`, {
|
||||||
|
currency,
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
unit,
|
||||||
|
timezone,
|
||||||
|
...filters,
|
||||||
|
...pageParams,
|
||||||
|
...params,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -258,6 +258,8 @@ export const labels: Record<string, string> = {
|
|||||||
aov: 'label.aov',
|
aov: 'label.aov',
|
||||||
arpu: 'label.arpu',
|
arpu: 'label.arpu',
|
||||||
uniqueCustomers: 'label.uniqueCustomers',
|
uniqueCustomers: 'label.uniqueCustomers',
|
||||||
|
customers: 'label.customers',
|
||||||
|
orders: 'label.orders',
|
||||||
viewedPage: 'message.viewed-page',
|
viewedPage: 'message.viewed-page',
|
||||||
collectedData: 'message.collected-data',
|
collectedData: 'message.collected-data',
|
||||||
triggeredEvent: 'message.triggered-event',
|
triggeredEvent: 'message.triggered-event',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useSpring } from '@react-spring/web';
|
import { useSpring } from '@react-spring/web';
|
||||||
import { Column, Text } from '@umami/react-zen';
|
import { Button, Column, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import { AnimatedDiv } from '@/components/common/AnimatedDiv';
|
import { AnimatedDiv } from '@/components/common/AnimatedDiv';
|
||||||
|
import { Info } from '@/components/icons';
|
||||||
import { ChangeLabel } from '@/components/metrics/ChangeLabel';
|
import { ChangeLabel } from '@/components/metrics/ChangeLabel';
|
||||||
import { formatNumber } from '@/lib/format';
|
import { formatNumber } from '@/lib/format';
|
||||||
|
|
||||||
@@ -9,6 +11,7 @@ export interface MetricCardProps {
|
|||||||
previousValue?: number;
|
previousValue?: number;
|
||||||
change?: number;
|
change?: number;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
tooltip?: ReactNode;
|
||||||
reverseColors?: boolean;
|
reverseColors?: boolean;
|
||||||
formatValue?: (n: any) => string;
|
formatValue?: (n: any) => string;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
@@ -19,6 +22,7 @@ export const MetricCard = ({
|
|||||||
value = 0,
|
value = 0,
|
||||||
change = 0,
|
change = 0,
|
||||||
label,
|
label,
|
||||||
|
tooltip,
|
||||||
reverseColors = false,
|
reverseColors = false,
|
||||||
formatValue = formatNumber,
|
formatValue = formatNumber,
|
||||||
showLabel = true,
|
showLabel = true,
|
||||||
@@ -40,9 +44,21 @@ export const MetricCard = ({
|
|||||||
gap="4"
|
gap="4"
|
||||||
>
|
>
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<Text weight="bold" wrap="nowrap">
|
<Row justifyContent="space-between" alignItems="flex-start">
|
||||||
{label}
|
<Text weight="bold" wrap="nowrap">
|
||||||
</Text>
|
{label}
|
||||||
|
</Text>
|
||||||
|
{tooltip && (
|
||||||
|
<TooltipTrigger delay={0}>
|
||||||
|
<Button size="sm" variant="quiet">
|
||||||
|
<Icon size="sm">
|
||||||
|
<Info />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
<Tooltip placement="top">{tooltip}</Tooltip>
|
||||||
|
</TooltipTrigger>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
)}
|
)}
|
||||||
<Text size="4xl" weight="bold" wrap="nowrap">
|
<Text size="4xl" weight="bold" wrap="nowrap">
|
||||||
<AnimatedDiv title={value?.toString()}>{props?.x?.to(x => formatValue(x))}</AnimatedDiv>
|
<AnimatedDiv title={value?.toString()}>{props?.x?.to(x => formatValue(x))}</AnimatedDiv>
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import clickhouse from '@/lib/clickhouse';
|
||||||
|
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
import type { QueryFilters } from '@/lib/types';
|
||||||
|
|
||||||
|
const FUNCTION_NAME = 'getRevenueSessions';
|
||||||
|
|
||||||
|
export async function getRevenueSessions(
|
||||||
|
...args: [websiteId: string, currency: string, filters: QueryFilters]
|
||||||
|
) {
|
||||||
|
return runQuery({
|
||||||
|
[PRISMA]: () => relationalQuery(...args),
|
||||||
|
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function relationalQuery(websiteId: string, currency: string, filters: QueryFilters) {
|
||||||
|
const { pagedRawQuery, parseFilters } = prisma;
|
||||||
|
const { search } = filters;
|
||||||
|
const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({
|
||||||
|
...filters,
|
||||||
|
websiteId,
|
||||||
|
currency,
|
||||||
|
search: search ? `%${search}%` : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchQuery = search
|
||||||
|
? `and (session.browser ilike {{search}}
|
||||||
|
or session.os ilike {{search}}
|
||||||
|
or session.device ilike {{search}}
|
||||||
|
or session.city ilike {{search}})`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return pagedRawQuery(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
session.session_id as "id",
|
||||||
|
session.website_id as "websiteId",
|
||||||
|
website_event.hostname,
|
||||||
|
session.browser,
|
||||||
|
session.os,
|
||||||
|
session.device,
|
||||||
|
session.screen,
|
||||||
|
session.language,
|
||||||
|
session.country,
|
||||||
|
session.region,
|
||||||
|
session.city,
|
||||||
|
min(website_event.created_at) as "firstAt",
|
||||||
|
max(website_event.created_at) as "lastAt",
|
||||||
|
count(distinct website_event.visit_id) as "visits",
|
||||||
|
sum(case when website_event.event_type = 1 then 1 else 0 end) as "views",
|
||||||
|
sum(case when website_event.event_type = 2 then 1 else 0 end) as "events",
|
||||||
|
max(website_event.created_at) as "createdAt"
|
||||||
|
from website_event
|
||||||
|
${cohortQuery}
|
||||||
|
join session
|
||||||
|
on session.session_id = website_event.session_id
|
||||||
|
and session.website_id = website_event.website_id
|
||||||
|
where website_event.website_id = {{websiteId::uuid}}
|
||||||
|
${dateQuery}
|
||||||
|
${filterQuery}
|
||||||
|
${searchQuery}
|
||||||
|
and website_event.session_id in (
|
||||||
|
select distinct session_id
|
||||||
|
from revenue
|
||||||
|
where website_id = {{websiteId::uuid}}
|
||||||
|
${dateQuery}
|
||||||
|
and upper(currency) = {{currency}}
|
||||||
|
)
|
||||||
|
group by
|
||||||
|
session.session_id,
|
||||||
|
session.website_id,
|
||||||
|
website_event.hostname,
|
||||||
|
session.browser,
|
||||||
|
session.os,
|
||||||
|
session.device,
|
||||||
|
session.screen,
|
||||||
|
session.language,
|
||||||
|
session.country,
|
||||||
|
session.region,
|
||||||
|
session.city
|
||||||
|
order by max(website_event.created_at) desc
|
||||||
|
`,
|
||||||
|
queryParams,
|
||||||
|
filters,
|
||||||
|
FUNCTION_NAME,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickhouseQuery(websiteId: string, currency: string, filters: QueryFilters) {
|
||||||
|
const { pagedRawQuery, parseFilters, getDateStringSQL } = clickhouse;
|
||||||
|
const { search } = filters;
|
||||||
|
const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({
|
||||||
|
...filters,
|
||||||
|
websiteId,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchQuery = search
|
||||||
|
? `and ((positionCaseInsensitive(browser, {search:String}) > 0)
|
||||||
|
or (positionCaseInsensitive(city, {search:String}) > 0)
|
||||||
|
or (positionCaseInsensitive(os, {search:String}) > 0)
|
||||||
|
or (positionCaseInsensitive(device, {search:String}) > 0))`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return pagedRawQuery(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
session_id as id,
|
||||||
|
website_id as websiteId,
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
region,
|
||||||
|
city,
|
||||||
|
${getDateStringSQL('min(created_at)')} as firstAt,
|
||||||
|
${getDateStringSQL('max(created_at)')} as lastAt,
|
||||||
|
uniq(visit_id) as visits,
|
||||||
|
sumIf(1, event_type = 1) as views,
|
||||||
|
sumIf(1, event_type = 2) as events,
|
||||||
|
max(created_at) as createdAt
|
||||||
|
from website_event
|
||||||
|
${cohortQuery}
|
||||||
|
where website_id = {websiteId:UUID}
|
||||||
|
${dateQuery}
|
||||||
|
${filterQuery}
|
||||||
|
${searchQuery}
|
||||||
|
and session_id in (
|
||||||
|
select distinct session_id
|
||||||
|
from website_revenue
|
||||||
|
where website_id = {websiteId:UUID}
|
||||||
|
${dateQuery}
|
||||||
|
and upper(currency) = {currency:String}
|
||||||
|
)
|
||||||
|
group by session_id, website_id, hostname, browser, os, device, screen, language, country, region, city
|
||||||
|
order by max(created_at) desc
|
||||||
|
`,
|
||||||
|
queryParams,
|
||||||
|
filters,
|
||||||
|
FUNCTION_NAME,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user