New properties screens. New website nav.

This commit is contained in:
Mike Cao 2025-07-17 01:18:31 -07:00
parent a9a9b57f80
commit 01bfd7f52e
17 changed files with 536 additions and 557 deletions

View file

@ -1,4 +1,3 @@
import { ReactNode } from 'react';
import Link from 'next/link';
import {
Sidebar,
@ -18,35 +17,13 @@ import {
Settings,
LockKeyhole,
PanelLeft,
Eye,
Lightning,
User,
Clock,
Target,
Funnel,
Path,
Magnet,
Tag,
Money,
Network,
Arrow,
Sheet,
} from '@/components/icons';
import { useMessages, useNavigation, useGlobalState } from '@/components/hooks';
import { LinkButton } from '@/components/common/LinkButton';
type NavLink = {
id: string;
label: string;
path: string;
icon: ReactNode;
};
export function SideNav(props: any) {
const { formatMessage, labels } = useMessages();
const { websiteId, pathname, renderUrl } = useNavigation();
const { pathname, renderUrl } = useNavigation();
const [isCollapsed, setCollapsed] = useGlobalState('sidenav-collapsed');
const isWebsite = websiteId && !pathname.includes('/settings');
const links = [
{
@ -75,81 +52,6 @@ export function SideNav(props: any) {
},
];
const websiteLinks = [
{
id: 'overview',
label: formatMessage(labels.overview),
icon: <Eye />,
path: '/',
},
{
id: 'events',
label: formatMessage(labels.events),
icon: <Lightning />,
path: '/events',
},
{
id: 'sessions',
label: formatMessage(labels.sessions),
icon: <User />,
path: '/sessions',
},
{
id: 'realtime',
label: formatMessage(labels.realtime),
icon: <Clock />,
path: '/realtime',
},
{
id: 'breakdown',
label: formatMessage(labels.breakdown),
icon: <Sheet />,
path: '/breakdown',
},
{
id: 'goals',
label: formatMessage(labels.goals),
icon: <Target />,
path: '/goals',
},
{
id: 'funnel',
label: formatMessage(labels.funnels),
icon: <Funnel />,
path: '/funnels',
},
{
id: 'journeys',
label: formatMessage(labels.journeys),
icon: <Path />,
path: '/journeys',
},
{
id: 'retention',
label: formatMessage(labels.retention),
icon: <Magnet />,
path: '/retention',
},
{
id: 'utm',
label: formatMessage(labels.utm),
icon: <Tag />,
path: '/utm',
},
{
id: 'revenue',
label: formatMessage(labels.revenue),
icon: <Money />,
path: '/revenue',
},
{
id: 'attribution',
label: formatMessage(labels.attribution),
icon: <Network />,
path: '/attribution',
},
];
const bottomLinks = [
{
id: 'settings',
@ -171,22 +73,22 @@ export function SideNav(props: any) {
<SidebarHeader label="umami" icon={<Logo />} />
</SidebarSection>
<SidebarSection flexGrow={1}>
{!isWebsite && <NavItems items={links} params={false} />}
{isWebsite && (
<>
<Row>
<LinkButton href={renderUrl('/websites', false)} variant="outline">
<Icon rotate={180}>
<Arrow />
</Icon>
</LinkButton>
</Row>
<NavItems items={websiteLinks} prefix={`/websites/${websiteId}`} />
</>
)}
{links.map(({ id, path, label, icon }) => {
return (
<Link key={id} href={renderUrl(path, false)} role="button">
<SidebarItem label={label} icon={icon} isSelected={pathname.endsWith(path)} />
</Link>
);
})}
</SidebarSection>
<SidebarSection>
{!isWebsite && <NavItems items={bottomLinks} params={false} />}
{bottomLinks.map(({ id, path, label, icon }) => {
return (
<Link key={id} href={path} role="button">
<SidebarItem label={label} icon={icon} isSelected={pathname.startsWith(path)} />
</Link>
);
})}
</SidebarSection>
<SidebarSection>
<Row>
@ -200,26 +102,3 @@ export function SideNav(props: any) {
</Sidebar>
);
}
const NavItems = ({
items,
prefix = '',
params,
}: {
items: NavLink[];
prefix?: string;
params?: Record<string, string | number> | false;
}) => {
const { renderUrl, pathname, websiteId } = useNavigation();
return items.map(({ id, path, label, icon }) => {
const isSelected = websiteId
? (path === '/' && pathname.endsWith(websiteId)) || pathname.endsWith(path)
: pathname.startsWith(path);
return (
<Link key={id} href={renderUrl(`${prefix}${path}`, params)} role="button">
<SidebarItem label={label} icon={icon} isSelected={isSelected} />
</Link>
);
});
};

View file

@ -40,7 +40,7 @@ export function AdminLayout({ children }: { children: ReactNode }) {
<PageBody>
<Column gap="6">
<PageHeader title={formatMessage(labels.admin)} />
<Grid columns="160px 1fr" gap>
<Grid columns="200px 1fr" gap>
<Column>
<SideMenu items={items} selectedKey={value} />
</Column>

View file

@ -37,7 +37,7 @@ export function SettingsLayout({ children }: { children: ReactNode }) {
<PageBody>
<Column gap="6">
<PageHeader title={formatMessage(labels.settings)} />
<Grid columns="160px 1fr" gap>
<Grid columns="200px 1fr" gap>
<Column>
<SideMenu items={items} selectedKey={value} />
</Column>

View file

@ -7,6 +7,7 @@ import { Panel } from '@/components/common/Panel';
import { Breakdown } from './Breakdown';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { FieldSelectForm } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm';
import { SectionHeader } from '@/components/common/SectionHeader';
export function BreakdownPage({ websiteId }: { websiteId: string }) {
const {
@ -17,7 +18,9 @@ export function BreakdownPage({ websiteId }: { websiteId: string }) {
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<FieldsButton value={fields} onChange={setFields} />
<SectionHeader>
<FieldsButton value={fields} onChange={setFields} />
</SectionHeader>
<Panel height="900px" overflow="auto" allowFullscreen>
<Breakdown
websiteId={websiteId}
@ -36,7 +39,7 @@ const FieldsButton = ({ value, onChange }) => {
return (
<Box>
<DialogTrigger>
<Button>
<Button variant="primary">
<Icon>
<ListCheck />
</Icon>

View file

@ -23,7 +23,7 @@ export function UTM({ websiteId, startDate, endDate }: UTMProps) {
});
return (
<LoadingPanel data={data} isLoading={isLoading} error={error}>
<LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="300px">
{data && (
<Column gap>
{UTM_PARAMS.map(param => {

View file

@ -12,7 +12,7 @@ export function WebsiteHeader() {
const website = useWebsite();
return (
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />} showBorder={false}>
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />}>
<Row alignItems="center" gap="6">
<ActiveUsers websiteId={website.id} />
<Row alignItems="center" gap>

View file

@ -1,17 +1,25 @@
'use client';
import { ReactNode } from 'react';
import { Column } from '@umami/react-zen';
import { Column, Grid } from '@umami/react-zen';
import { WebsiteProvider } from './WebsiteProvider';
import { PageBody } from '@/components/common/PageBody';
import { WebsiteHeader } from './WebsiteHeader';
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
return (
<WebsiteProvider websiteId={websiteId}>
<PageBody>
<WebsiteHeader />
<Column>{children}</Column>
</PageBody>
<Grid columns="200px 1fr" gap width="100%">
<Column padding>
<WebsiteNav websiteId={websiteId} />
</Column>
<PageBody>
<Column gap>
<WebsiteHeader />
<Column>{children}</Column>
</Column>
</PageBody>
</Grid>
</WebsiteProvider>
);
}

View file

@ -1,10 +1,10 @@
import { Icon, Text, Row, NavMenu, NavMenuItem } from '@umami/react-zen';
import { Icon, Text, Row, NavMenu, NavMenuItem, NavMenuGroup } from '@umami/react-zen';
import {
Eye,
Lightning,
User,
Clock,
Lightbulb,
Sheet,
Target,
Funnel,
Path,
@ -22,95 +22,123 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
const links = [
{
id: 'overview',
label: formatMessage(labels.overview),
icon: <Eye />,
path: '',
label: formatMessage(labels.core),
items: [
{
id: 'overview',
label: formatMessage(labels.overview),
icon: <Eye />,
path: '',
},
{
id: 'events',
label: formatMessage(labels.events),
icon: <Lightning />,
path: '/events',
},
{
id: 'sessions',
label: formatMessage(labels.sessions),
icon: <User />,
path: '/sessions',
},
{
id: 'realtime',
label: formatMessage(labels.realtime),
icon: <Clock />,
path: '/realtime',
},
],
},
{
id: 'events',
label: formatMessage(labels.events),
icon: <Lightning />,
path: '/events',
label: formatMessage(labels.behavior),
items: [
{
id: 'goals',
label: formatMessage(labels.goals),
icon: <Target />,
path: '/goals',
},
{
id: 'funnel',
label: formatMessage(labels.funnels),
icon: <Funnel />,
path: '/funnels',
},
{
id: 'journeys',
label: formatMessage(labels.journeys),
icon: <Path />,
path: '/journeys',
},
{
id: 'retention',
label: formatMessage(labels.retention),
icon: <Magnet />,
path: '/retention',
},
],
},
{
id: 'sessions',
label: formatMessage(labels.sessions),
icon: <User />,
path: '/sessions',
label: formatMessage(labels.segments),
items: [
{
id: 'breakdown',
label: formatMessage(labels.breakdown),
icon: <Sheet />,
path: '/breakdown',
},
],
},
{
id: 'realtime',
label: formatMessage(labels.realtime),
icon: <Clock />,
path: '/realtime',
},
{
id: 'insights',
label: formatMessage(labels.insights),
icon: <Lightbulb />,
path: '/insights',
},
{
id: 'goals',
label: formatMessage(labels.goals),
icon: <Target />,
path: '/goals',
},
{
id: 'funnel',
label: formatMessage(labels.funnels),
icon: <Funnel />,
path: '/funnels',
},
{
id: 'journeys',
label: formatMessage(labels.journeys),
icon: <Path />,
path: '/journeys',
},
{
id: 'retention',
label: formatMessage(labels.retention),
icon: <Magnet />,
path: '/retention',
},
{
id: 'utm',
label: formatMessage(labels.utm),
icon: <Tag />,
path: '/utm',
},
{
id: 'revenue',
label: formatMessage(labels.revenue),
icon: <Money />,
path: '/revenue',
},
{
id: 'attribution',
label: formatMessage(labels.attribution),
icon: <Network />,
path: '/attribution',
label: formatMessage(labels.growth),
items: [
{
id: 'utm',
label: formatMessage(labels.utm),
icon: <Tag />,
path: '/utm',
},
{
id: 'revenue',
label: formatMessage(labels.revenue),
icon: <Money />,
path: '/revenue',
},
{
id: 'attribution',
label: formatMessage(labels.attribution),
icon: <Network />,
path: '/attribution',
},
],
},
];
const selected = links.find(({ path }) => path && pathname.endsWith(path))?.id || 'overview';
const selected =
links.flatMap(e => e.items).find(({ path }) => path && pathname.endsWith(path))?.id ||
'overview';
return (
<NavMenu highlightColor="3">
{links.map(({ id, label, icon, path }) => {
const isSelected = selected === id;
<NavMenu highlightColor="3" style={{ position: 'sticky', top: 'var(--spacing-2)' }}>
{links.map(({ label, items }) => {
return (
<Link key={id} href={renderUrl(`/websites/${websiteId}${path}`)}>
<NavMenuItem isSelected={isSelected}>
<Row alignItems="center" gap>
<Icon>{icon}</Icon>
<Text>{label}</Text>
</Row>
</NavMenuItem>
</Link>
<NavMenuGroup title={label} key={label} gap="2">
{items.map(({ id, label, icon, path }) => {
const isSelected = selected === id;
return (
<Link key={id} href={renderUrl(`/websites/${websiteId}${path}`)}>
<NavMenuItem isSelected={isSelected}>
<Row alignItems="center" gap>
<Icon>{icon}</Icon>
<Text>{label}</Text>
</Row>
</NavMenuItem>
</Link>
);
})}
</NavMenuGroup>
);
})}
</NavMenu>

View file

@ -1,14 +1,5 @@
import { useMemo, useState } from 'react';
import {
DataColumn,
DataTable,
Row,
Loading,
Column,
ToggleGroup,
ToggleGroupItem,
Text,
} from '@umami/react-zen';
import { Select, ListItem, Grid } from '@umami/react-zen';
import {
useEventDataPropertiesQuery,
useEventDataValuesQuery,
@ -22,11 +13,69 @@ import { ListTable } from '@/components/metrics/ListTable';
export function EventProperties({ websiteId }: { websiteId: string }) {
const [propertyName, setPropertyName] = useState('');
const [eventName, setEventName] = useState('');
const [propertyView, setPropertyView] = useState('table');
const { formatMessage, labels } = useMessages();
const { data, isLoading, isFetching, error } = useEventDataPropertiesQuery(websiteId);
const { data: values } = useEventDataValuesQuery(websiteId, eventName, propertyName);
const events: string[] = data
? data.reduce((arr: string | any[], e: { eventName: any }) => {
return !arr.includes(e.eventName) ? arr.concat(e.eventName) : arr;
}, [])
: [];
const properties: string[] = eventName
? data?.filter(e => e.eventName === eventName).map(e => e.propertyName)
: [];
return (
<LoadingPanel
isLoading={isLoading}
isFetching={isFetching}
data={data}
error={error}
minHeight="300px"
gap="6"
>
<Grid columns="repeat(auto-fill, minmax(300px, 1fr))" gap>
<Select
label={formatMessage(labels.event)}
value={eventName}
onChange={setEventName}
placeholder=""
>
{events?.map(p => (
<ListItem key={p} id={p}>
{p}
</ListItem>
))}
</Select>
<Select
label={formatMessage(labels.property)}
value={propertyName}
onChange={setPropertyName}
isDisabled={!eventName}
placeholder=""
>
{properties?.map(p => (
<ListItem key={p} id={p}>
{p}
</ListItem>
))}
</Select>
</Grid>
{propertyName && (
<EventValues websiteId={websiteId} eventName={eventName} propertyName={propertyName} />
)}
</LoadingPanel>
);
}
const EventValues = ({ websiteId, eventName, propertyName }) => {
const {
data: values,
isLoading,
isFetching,
error,
} = useEventDataValuesQuery(websiteId, eventName, propertyName);
const propertySum = useMemo(() => {
return values?.reduce((sum, { total }) => sum + total, 0) ?? 0;
@ -55,43 +104,19 @@ export function EventProperties({ websiteId }: { websiteId: string }) {
}));
}, [propertyName, values, propertySum]);
const handleRowClick = row => {
setEventName(row.eventName);
setPropertyName(row.propertyName);
};
return (
<LoadingPanel isLoading={isLoading} isFetching={isFetching} data={data} error={error}>
<Column>
<DataTable data={data}>
<DataColumn id="eventName" label={formatMessage(labels.name)}>
{(row: any) => <Row onClick={() => handleRowClick(row)}>{row.eventName}</Row>}
</DataColumn>
<DataColumn id="propertyName" label={formatMessage(labels.property)}>
{(row: any) => <Row onClick={() => handleRowClick(row)}>{row.propertyName}</Row>}
</DataColumn>
<DataColumn id="total" label={formatMessage(labels.count)} align="end" />
</DataTable>
{propertyName && (
<Column>
<Row gap justifyContent="space-between">
<Text>{`${eventName}: ${propertyName}`}</Text>
<ToggleGroup value={[propertyView]} onChange={value => setPropertyView(value[0])}>
<ToggleGroupItem id="table">{formatMessage(labels.table)}</ToggleGroupItem>
<ToggleGroupItem id="chart">{formatMessage(labels.chart)}</ToggleGroupItem>
</ToggleGroup>
</Row>
{!values ? (
<Loading icon="dots" />
) : propertyView === 'table' ? (
<ListTable data={tableData} />
) : (
<PieChart key={propertyName + eventName} type="doughnut" chartData={chartData} />
)}
</Column>
)}
</Column>
<LoadingPanel
isLoading={isLoading}
isFetching={isFetching}
data={values}
error={error}
minHeight="300px"
gap="6"
>
<Grid columns="1fr 1fr" gap>
{values && <ListTable title={propertyName} data={tableData} />}
<PieChart key={propertyName + eventName} type="doughnut" chartData={chartData} />
</Grid>
</LoadingPanel>
);
}
};

View file

@ -5,7 +5,6 @@ import { useState } from 'react';
import { EventsDataTable } from './EventsDataTable';
import { Panel } from '@/components/common/Panel';
import { EventsChart } from '@/components/metrics/EventsChart';
import { GridRow } from '@/components/common/GridRow';
import { useMessages } from '@/components/hooks';
import { EventProperties } from './EventProperties';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
@ -22,29 +21,28 @@ export function EventsPage({ websiteId }) {
return (
<Column gap="3">
<WebsiteControls websiteId={websiteId} />
<GridRow layout="two-one">
<Panel gridColumn="span 2">
<EventsChart websiteId={websiteId} focusLabel={label} />
</Panel>
<Panel>
<EventsTable
websiteId={websiteId}
type="event"
title={formatMessage(labels.events)}
metric={formatMessage(labels.actions)}
onLabelClick={handleLabelClick}
/>
</Panel>
</GridRow>
<Panel>
<Tabs selectedKey={tab} onSelectionChange={(value: any) => setTab(value)}>
<TabList>
<Tab id="activity">{formatMessage(labels.activity)}</Tab>
<Tab id="chart">{formatMessage(labels.chart)}</Tab>
<Tab id="properties">{formatMessage(labels.properties)}</Tab>
</TabList>
<TabPanel id="activity">
<EventsDataTable websiteId={websiteId} />
</TabPanel>
<TabPanel id="chart">
<Column gap="6">
<EventsChart websiteId={websiteId} focusLabel={label} />
<EventsTable
websiteId={websiteId}
type="event"
title={formatMessage(labels.events)}
metric={formatMessage(labels.actions)}
onLabelClick={handleLabelClick}
/>
</Column>
</TabPanel>
<TabPanel id="properties">
<EventProperties websiteId={websiteId} />
</TabPanel>

View file

@ -1,51 +1,93 @@
import { Grid, DataColumn, DataTable } from '@umami/react-zen';
import { useMemo, useState } from 'react';
import { Select, ListItem, Grid } from '@umami/react-zen';
import {
useMessages,
useSessionDataPropertiesQuery,
useSessionDataValuesQuery,
useMessages,
} from '@/components/hooks';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { PieChart } from '@/components/charts/PieChart';
import { useState } from 'react';
import { CHART_COLORS } from '@/lib/constants';
import { ListTable } from '@/components/metrics/ListTable';
export function SessionProperties({ websiteId }: { websiteId: string }) {
const [propertyName, setPropertyName] = useState('');
const { formatMessage, labels } = useMessages();
const { data, isLoading, isFetching, error } = useSessionDataPropertiesQuery(websiteId);
const { data: values } = useSessionDataValuesQuery(websiteId, propertyName);
const chartData =
propertyName && values
? {
labels: values.map(({ value }) => value),
datasets: [
{
data: values.map(({ total }) => total),
backgroundColor: CHART_COLORS,
borderWidth: 0,
},
],
}
: null;
const properties: string[] = data?.map(e => e.propertyName);
return (
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
<Grid>
<DataTable data={data}>
<DataColumn id="propertyName" label={formatMessage(labels.property)}>
{(row: any) => (
<div onClick={() => setPropertyName(row.propertyName)}>{row.propertyName}</div>
)}
</DataColumn>
<DataColumn id="total" label={formatMessage(labels.count)} align="end" />
</DataTable>
{propertyName && (
<div>
<div>{propertyName}</div>
<PieChart key={propertyName} type="doughnut" chartData={chartData} />
</div>
)}
<LoadingPanel
isLoading={isLoading}
isFetching={isFetching}
data={data}
error={error}
minHeight="300px"
gap="6"
>
<Grid columns="repeat(auto-fill, minmax(300px, 1fr))" gap>
<Select
label={formatMessage(labels.event)}
value={propertyName}
onChange={setPropertyName}
placeholder=""
>
{properties?.map(p => (
<ListItem key={p} id={p}>
{p}
</ListItem>
))}
</Select>
</Grid>
{propertyName && <SessionValues websiteId={websiteId} propertyName={propertyName} />}
</LoadingPanel>
);
}
const SessionValues = ({ websiteId, propertyName }) => {
const { data, isLoading, isFetching, error } = useSessionDataValuesQuery(websiteId, propertyName);
const propertySum = useMemo(() => {
return data?.reduce((sum, { total }) => sum + total, 0) ?? 0;
}, [data]);
const chartData = useMemo(() => {
if (!propertyName || !data) return null;
return {
labels: data.map(({ value }) => value),
datasets: [
{
data: data.map(({ total }) => total),
backgroundColor: CHART_COLORS,
borderWidth: 0,
},
],
};
}, [propertyName, data]);
const tableData = useMemo(() => {
if (!propertyName || !data || propertySum === 0) return [];
return data.map(({ value, total }) => ({
x: value,
y: total,
z: 100 * (total / propertySum),
}));
}, [propertyName, data, propertySum]);
return (
<LoadingPanel
isLoading={isLoading}
isFetching={isFetching}
data={data}
error={error}
minHeight="300px"
gap="6"
>
<Grid columns="1fr 1fr" gap>
{data && <ListTable title={propertyName} data={tableData} />}
<PieChart key={propertyName} type="doughnut" chartData={chartData} />
</Grid>
</LoadingPanel>
);
};

View file

@ -1,6 +1,6 @@
'use client';
import { ReactNode } from 'react';
import { AlertBanner, Loading, Column } from '@umami/react-zen';
import { AlertBanner, Loading, Column, ColumnProps } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
export function PageBody({
@ -14,7 +14,7 @@ export function PageBody({
error?: unknown;
isLoading?: boolean;
children?: ReactNode;
}) {
} & ColumnProps) {
const { formatMessage, messages } = useMessages();
if (error) {

View file

@ -7,7 +7,6 @@ export function useFields() {
{ name: 'path', type: 'string', label: formatMessage(labels.path) },
// { name: 'cohort', type: 'string', label: formatMessage(labels.cohort) },
// { name: 'segment', type: 'string', label: formatMessage(labels.segment) },
{ name: 'url', type: 'string', label: formatMessage(labels.url) },
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
//{ name: 'query', type: 'string', label: formatMessage(labels.query) },

View file

@ -338,6 +338,9 @@ export const labels = defineMessages({
location: { id: 'label.location', defaultMessage: 'Location' },
chart: { id: 'label.chart', defaultMessage: 'Chart' },
table: { id: 'label.table', defaultMessage: 'Table' },
core: { id: 'label.core', defaultMessage: 'Core' },
behavior: { id: 'label.behavior', defaultMessage: 'Behavior' },
growth: { id: 'label.growth', defaultMessage: 'Growth' },
});
export const messages = defineMessages({

View file

@ -17,7 +17,7 @@ async function relationalQuery(
filters: QueryFilters & { propertyName?: string },
) {
const { rawQuery, parseFilters, getDateSQL } = prisma;
const { filterQuery, cohortQuery, queryParams } = parseFilters(filters);
const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
return rawQuery(
`
@ -49,7 +49,7 @@ async function clickhouseQuery(
filters: QueryFilters & { propertyName?: string },
): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> {
const { rawQuery, parseFilters } = clickhouse;
const { filterQuery, cohortQuery, queryParams } = parseFilters(filters);
const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
return rawQuery(
`