Merge branch 'dev' into boards
Some checks failed
Node.js CI / build (push) Has been cancelled

# Conflicts:
#	.gitignore
#	package.json
#	pnpm-lock.yaml
#	prisma/migrations/16_boards/migration.sql
#	prisma/schema.prisma
#	src/app/(main)/MobileNav.tsx
#	src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx
#	src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx
#	src/components/common/SideMenu.tsx
#	src/lib/types.ts
This commit is contained in:
Mike Cao 2026-02-05 20:05:25 -08:00
commit c3e0290e65
150 changed files with 3028 additions and 787 deletions

View file

@ -31,6 +31,7 @@ export function PageBody({
<Column
{...props}
width="100%"
minHeight="100vh"
paddingBottom="6"
maxWidth={maxWidth}
paddingX={{ xs: '3', md: '6' }}

View file

@ -0,0 +1,6 @@
import { useContext } from 'react';
import { ShareContext } from '@/app/share/ShareProvider';
export function useShare() {
return useContext(ShareContext);
}

View file

@ -4,6 +4,7 @@
export * from './context/useBoard';
export * from './context/useLink';
export * from './context/usePixel';
export * from './context/useShare';
export * from './context/useTeam';
export * from './context/useUser';
export * from './context/useWebsite';
@ -54,6 +55,7 @@ export * from './queries/useWebsiteSegmentsQuery';
export * from './queries/useWebsiteSessionQuery';
export * from './queries/useWebsiteSessionStatsQuery';
export * from './queries/useWebsiteSessionsQuery';
export * from './queries/useWebsiteSharesQuery';
export * from './queries/useWebsiteStatsQuery';
export * from './queries/useWebsitesQuery';
export * from './queries/useWebsiteValuesQuery';

View file

@ -0,0 +1,37 @@
import type { UseQueryOptions } from '@tanstack/react-query';
import { useDateParameters } from '@/components/hooks/useDateParameters';
import { useApi } from '../useApi';
import { useFilterParameters } from '../useFilterParameters';
export interface EventStatsData {
events: number;
visitors: number;
visits: number;
uniqueEvents: number;
}
type EventStatsApiResponse = {
data: EventStatsData;
};
export function useEventStatsQuery(
{ websiteId }: { websiteId: string },
options?: UseQueryOptions<EventStatsApiResponse, Error, EventStatsData>,
) {
const { get, useQuery } = useApi();
const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters();
return useQuery<EventStatsApiResponse, Error, EventStatsData>({
queryKey: ['websites:events:stats', { websiteId, startAt, endAt, ...filters }],
queryFn: () =>
get(`/websites/${websiteId}/events/stats`, {
startAt,
endAt,
...filters,
}),
select: response => response.data,
enabled: !!websiteId,
...options,
});
}

View file

@ -10,7 +10,7 @@ export function useResultQuery<T = any>(
) {
const { websiteId, ...parameters } = params;
const { post, useQuery } = useApi();
const { startDate, endDate, timezone } = useDateParameters();
const { startDate, endDate, timezone, unit } = useDateParameters();
const filters = useFilterParameters();
return useQuery<T>({
@ -22,6 +22,7 @@ export function useResultQuery<T = any>(
startDate,
endDate,
timezone,
unit,
...params,
...filters,
},
@ -35,6 +36,7 @@ export function useResultQuery<T = any>(
startDate,
endDate,
timezone,
unit,
...parameters,
},
}),

View file

@ -1,25 +1,22 @@
import { setShareToken, useApp } from '@/store/app';
import { setShare, setShareToken, useApp } from '@/store/app';
import { useApi } from '../useApi';
const selector = (state: { shareToken: string }) => state.shareToken;
const selector = state => state.share;
export function useShareTokenQuery(slug: string): {
shareToken: any;
isLoading?: boolean;
error?: Error;
} {
const shareToken = useApp(selector);
export function useShareTokenQuery(slug: string) {
const share = useApp(selector);
const { get, useQuery } = useApi();
const { isLoading, error } = useQuery({
const query = useQuery({
queryKey: ['share', slug],
queryFn: async () => {
const data = await get(`/share/${slug}`);
setShareToken(data);
setShare(data);
setShareToken({ token: data?.token });
return data;
},
});
return { shareToken, isLoading, error };
return { share, ...query };
}

View file

@ -19,7 +19,7 @@ export function useWebsiteExpandedMetricsQuery(
options?: ReactQueryOptions<WebsiteExpandedMetricsData>,
) {
const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters();
const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters();
return useQuery<WebsiteExpandedMetricsData>({
@ -29,8 +29,6 @@ export function useWebsiteExpandedMetricsQuery(
websiteId,
startAt,
endAt,
unit,
timezone,
...filters,
...params,
},
@ -39,8 +37,6 @@ export function useWebsiteExpandedMetricsQuery(
get(`/websites/${websiteId}/metrics/expanded`, {
startAt,
endAt,
unit,
timezone,
...filters,
...params,
}),

View file

@ -15,7 +15,7 @@ export function useWebsiteMetricsQuery(
options?: ReactQueryOptions<WebsiteMetricsData>,
) {
const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters();
const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters();
return useQuery<WebsiteMetricsData>({
@ -25,8 +25,6 @@ export function useWebsiteMetricsQuery(
websiteId,
startAt,
endAt,
unit,
timezone,
...filters,
...params,
},
@ -35,8 +33,6 @@ export function useWebsiteMetricsQuery(
get(`/websites/${websiteId}/metrics`, {
startAt,
endAt,
unit,
timezone,
...filters,
...params,
}),

View file

@ -0,0 +1,20 @@
import type { ReactQueryOptions } from '@/lib/types';
import { useApi } from '../useApi';
import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery';
export function useWebsiteSharesQuery(
{ websiteId }: { websiteId: string },
options?: ReactQueryOptions,
) {
const { modified } = useModified('shares');
const { get } = useApi();
return usePagedQuery({
queryKey: ['websiteShares', { websiteId, modified }],
queryFn: pageParams => {
return get(`/websites/${websiteId}/shares`, pageParams);
},
...options,
});
}

View file

@ -1,6 +1,5 @@
import type { UseQueryOptions } from '@tanstack/react-query';
import { useDateParameters } from '@/components/hooks/useDateParameters';
import { useDateRange } from '@/components/hooks/useDateRange';
import { useApi } from '../useApi';
import { useFilterParameters } from '../useFilterParameters';
@ -20,21 +19,16 @@ export interface WebsiteStatsData {
}
export function useWebsiteStatsQuery(
websiteId: string,
{ websiteId, compare }: { websiteId: string; compare?: string },
options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>,
) {
const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters();
const { compare } = useDateRange();
const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters();
return useQuery<WebsiteStatsData>({
queryKey: [
'websites:stats',
{ websiteId, startAt, endAt, unit, timezone, compare, ...filters },
],
queryFn: () =>
get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, compare, ...filters }),
queryKey: ['websites:stats', { websiteId, compare, startAt, endAt, ...filters }],
queryFn: () => get(`/websites/${websiteId}/stats`, { compare, startAt, endAt, ...filters }),
enabled: !!websiteId,
...options,
});

View file

@ -12,13 +12,12 @@ export function useWeeklyTrafficQuery(websiteId: string, params?: Record<string,
return useQuery({
queryKey: [
'sessions',
{ websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters },
{ websiteId, modified, startAt, endAt, timezone, ...params, ...filters },
],
queryFn: () => {
return get(`/websites/${websiteId}/sessions/weekly`, {
startAt,
endAt,
unit,
timezone,
...params,
...filters,

View file

@ -7,13 +7,13 @@ import { getItem } from '@/lib/storage';
export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) {
const {
query: { date = '', offset = 0, compare = 'prev' },
query: { date = '', unit = '', offset = 0, compare = 'prev' },
} = useNavigation();
const { locale } = useLocale();
const dateRange = useMemo(() => {
const dateRangeObject = parseDateRange(
date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE,
unit,
locale,
options.timezone,
);
@ -21,12 +21,13 @@ export function useDateRange(options: { ignoreOffset?: boolean; timezone?: strin
return !options.ignoreOffset && offset
? getOffsetDateRange(dateRangeObject, +offset)
: dateRangeObject;
}, [date, offset, options]);
}, [date, unit, offset, options]);
const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate);
return {
date,
unit,
offset,
compare,
isAllTime: date.endsWith(`:all`),

View file

@ -1,23 +1,142 @@
import { useMessages } from './useMessages';
export type FieldGroup = 'url' | 'sources' | 'location' | 'environment' | 'utm' | 'other';
export interface Field {
name: string;
filterLabel: string;
label: string;
group: FieldGroup;
}
export function useFields() {
const { formatMessage, labels } = useMessages();
const fields = [
{ name: 'path', type: 'string', label: formatMessage(labels.path) },
{ name: 'query', type: 'string', label: formatMessage(labels.query) },
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
{ name: 'browser', type: 'string', label: formatMessage(labels.browser) },
{ name: 'os', type: 'string', label: formatMessage(labels.os) },
{ name: 'device', type: 'string', label: formatMessage(labels.device) },
{ name: 'country', type: 'string', label: formatMessage(labels.country) },
{ name: 'region', type: 'string', label: formatMessage(labels.region) },
{ name: 'city', type: 'string', label: formatMessage(labels.city) },
{ name: 'hostname', type: 'string', label: formatMessage(labels.hostname) },
{ name: 'tag', type: 'string', label: formatMessage(labels.tag) },
{ name: 'event', type: 'string', label: formatMessage(labels.event) },
const fields: Field[] = [
{
name: 'path',
filterLabel: formatMessage(labels.path),
label: formatMessage(labels.path),
group: 'url',
},
{
name: 'query',
filterLabel: formatMessage(labels.query),
label: formatMessage(labels.query),
group: 'url',
},
{
name: 'title',
filterLabel: formatMessage(labels.pageTitle),
label: formatMessage(labels.pageTitle),
group: 'url',
},
{
name: 'referrer',
filterLabel: formatMessage(labels.referrer),
label: formatMessage(labels.referrer),
group: 'sources',
},
{
name: 'country',
filterLabel: formatMessage(labels.country),
label: formatMessage(labels.country),
group: 'location',
},
{
name: 'region',
filterLabel: formatMessage(labels.region),
label: formatMessage(labels.region),
group: 'location',
},
{
name: 'city',
filterLabel: formatMessage(labels.city),
label: formatMessage(labels.city),
group: 'location',
},
{
name: 'browser',
filterLabel: formatMessage(labels.browser),
label: formatMessage(labels.browser),
group: 'environment',
},
{
name: 'os',
filterLabel: formatMessage(labels.os),
label: formatMessage(labels.os),
group: 'environment',
},
{
name: 'device',
filterLabel: formatMessage(labels.device),
label: formatMessage(labels.device),
group: 'environment',
},
{
name: 'utmSource',
filterLabel: formatMessage(labels.source),
label: formatMessage(labels.utmSource),
group: 'utm',
},
{
name: 'utmMedium',
filterLabel: formatMessage(labels.medium),
label: formatMessage(labels.utmMedium),
group: 'utm',
},
{
name: 'utmCampaign',
filterLabel: formatMessage(labels.campaign),
label: formatMessage(labels.utmCampaign),
group: 'utm',
},
{
name: 'utmContent',
filterLabel: formatMessage(labels.content),
label: formatMessage(labels.utmContent),
group: 'utm',
},
{
name: 'utmTerm',
filterLabel: formatMessage(labels.term),
label: formatMessage(labels.utmTerm),
group: 'utm',
},
{
name: 'hostname',
filterLabel: formatMessage(labels.hostname),
label: formatMessage(labels.hostname),
group: 'other',
},
{
name: 'distinctId',
filterLabel: formatMessage(labels.distinctId),
label: formatMessage(labels.distinctId),
group: 'other',
},
{
name: 'tag',
filterLabel: formatMessage(labels.tag),
label: formatMessage(labels.tag),
group: 'other',
},
{
name: 'event',
filterLabel: formatMessage(labels.event),
label: formatMessage(labels.event),
group: 'other',
},
];
return { fields };
const groupLabels: { key: FieldGroup; label: string }[] = [
{ key: 'url', label: formatMessage(labels.url) },
{ key: 'sources', label: formatMessage(labels.sources) },
{ key: 'location', label: formatMessage(labels.location) },
{ key: 'environment', label: formatMessage(labels.environment) },
{ key: 'utm', label: formatMessage(labels.utm) },
{ key: 'other', label: formatMessage(labels.other) },
];
return { fields, groupLabels };
}

View file

@ -18,11 +18,18 @@ export function useFilterParameters() {
event,
tag,
hostname,
distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
page,
pageSize,
search,
segment,
cohort,
excludeBounce,
},
} = useNavigation();
@ -42,9 +49,16 @@ export function useFilterParameters() {
event,
tag,
hostname,
distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
search,
segment,
cohort,
excludeBounce,
};
}, [
path,
@ -61,10 +75,17 @@ export function useFilterParameters() {
event,
tag,
hostname,
distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
page,
pageSize,
search,
segment,
cohort,
excludeBounce,
]);
}

View file

@ -0,0 +1,26 @@
'use client';
import { Checkbox, Row } from '@umami/react-zen';
import { useMessages } from '@/components/hooks/useMessages';
import { useNavigation } from '@/components/hooks/useNavigation';
export function BounceFilter() {
const { router, query, updateParams } = useNavigation();
const { formatMessage, labels } = useMessages();
const isSelected = query.excludeBounce === 'true';
const handleChange = (value: boolean) => {
if (value) {
router.push(updateParams({ excludeBounce: 'true' }));
} else {
router.push(updateParams({ excludeBounce: undefined }));
}
};
return (
<Row alignItems="center" gap>
<Checkbox isSelected={isSelected} onChange={handleChange}>
{formatMessage(labels.excludeBounce)}
</Checkbox>
</Row>
);
}

View file

@ -5,8 +5,10 @@ import {
Icon,
List,
ListItem,
ListSection,
Menu,
MenuItem,
MenuSection,
MenuTrigger,
Popover,
Row,
@ -15,7 +17,7 @@ import { endOfDay, subMonths } from 'date-fns';
import type { Key } from 'react';
import { Empty } from '@/components/common/Empty';
import { FilterRecord } from '@/components/common/FilterRecord';
import { useFields, useMessages, useMobile } from '@/components/hooks';
import { type FieldGroup, useFields, useMessages, useMobile } from '@/components/hooks';
import { Plus } from '@/components/icons';
export interface FieldFiltersProps {
@ -27,11 +29,25 @@ export interface FieldFiltersProps {
export function FieldFilters({ websiteId, value, exclude = [], onChange }: FieldFiltersProps) {
const { formatMessage, messages } = useMessages();
const { fields } = useFields();
const { fields, groupLabels } = useFields();
const startDate = subMonths(endOfDay(new Date()), 6);
const endDate = endOfDay(new Date());
const { isMobile } = useMobile();
const groupedFields = fields
.filter(({ name }) => !exclude.includes(name))
.reduce(
(acc, field) => {
const group = field.group;
if (!acc[group]) {
acc[group] = [];
}
acc[group].push(field);
return acc;
},
{} as Record<FieldGroup, typeof fields>,
);
const updateFilter = (name: string, props: Record<string, any>) => {
onChange(value.map(filter => (filter.name === name ? { ...filter, ...props } : filter)));
};
@ -66,32 +82,44 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field
onAction={handleAdd}
style={{ maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto' }}
>
{fields
.filter(({ name }) => !exclude.includes(name))
.map(field => {
const isDisabled = !!value.find(({ name }) => name === field.name);
return (
<MenuItem key={field.name} id={field.name} isDisabled={isDisabled}>
{field.label}
</MenuItem>
);
})}
{groupLabels.map(({ key: groupKey, label }) => {
const groupFields = groupedFields[groupKey];
return (
<MenuSection key={groupKey} title={label}>
{groupFields.map(field => {
const isDisabled = !!value.find(({ name }) => name === field.name);
return (
<MenuItem key={field.name} id={field.name} isDisabled={isDisabled}>
{field.filterLabel}
</MenuItem>
);
})}
</MenuSection>
);
})}
</Menu>
</Popover>
</MenuTrigger>
</Row>
<Column display={{ xs: 'none', md: 'flex' }} border="right" paddingRight="3" marginRight="6">
<List onAction={handleAdd}>
{fields
.filter(({ name }) => !exclude.includes(name))
.map(field => {
const isDisabled = !!value.find(({ name }) => name === field.name);
return (
<ListItem key={field.name} id={field.name} isDisabled={isDisabled}>
{field.label}
</ListItem>
);
})}
{groupLabels.map(({ key: groupKey, label }) => {
const groupFields = groupedFields[groupKey];
if (!groupFields || groupFields.length === 0) return null;
return (
<ListSection key={groupKey} title={label}>
{groupFields.map(field => {
const isDisabled = !!value.find(({ name }) => name === field.name);
return (
<ListItem key={field.name} id={field.name} isDisabled={isDisabled}>
{field.filterLabel}
</ListItem>
);
})}
</ListSection>
);
})}
</List>
</Column>
<Column overflow="auto" gapY="4" style={{ contain: 'layout' }}>

View file

@ -22,6 +22,7 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
const [currentCohort, setCurrentCohort] = useState(cohort);
const { isMobile } = useMobile();
const excludeFilters = pathname.includes('/pixels') || pathname.includes('/links');
const excludeEvent = !pathname.endsWith('/events');
const handleReset = () => {
setCurrentFilters([]);
@ -61,7 +62,13 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
websiteId={websiteId}
value={currentFilters}
onChange={setCurrentFilters}
exclude={excludeFilters ? ['path', 'title', 'hostname', 'tag', 'event'] : []}
exclude={
excludeFilters
? ['path', 'title', 'hostname', 'distinctId', 'tag', 'event']
: excludeEvent
? ['event']
: []
}
/>
</TabPanel>
<TabPanel id="segments">

View file

@ -10,7 +10,7 @@ export function MobileMenuButton(props: DialogProps) {
</Icon>
</Button>
<Modal placement="left" offset="80px">
<Dialog variant="sheet" {...props} />
<Dialog variant="sheet" {...props} style={{ width: 'auto' }} />
</Modal>
</DialogTrigger>
);

View file

@ -0,0 +1,71 @@
import { ListItem, Row, Select } from '@umami/react-zen';
import { useMessages, useNavigation } from '@/components/hooks';
import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants';
import { getItem } from '@/lib/storage';
export function UnitFilter() {
const { formatMessage, labels } = useMessages();
const { router, query, updateParams } = useNavigation();
const DATE_RANGE_UNIT_CONFIG = {
'0week': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'7day': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'0month': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'30day': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'90day': {
defaultUnit: 'day',
availableUnits: ['day', 'month'],
},
'6month': {
defaultUnit: 'month',
availableUnits: ['month', 'day'],
},
};
const unitConfig =
DATE_RANGE_UNIT_CONFIG[query.date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE];
if (!unitConfig) {
return null;
}
const handleChange = (value: string) => {
router.push(updateParams({ unit: value }));
};
const options = unitConfig.availableUnits.map(unit => ({
id: unit,
label: formatMessage(labels[unit]),
}));
const selectedUnit = query.unit ?? unitConfig.defaultUnit;
return (
<Row>
<Select
value={selectedUnit}
onChange={handleChange}
popoverProps={{ placement: 'bottom right' }}
style={{ width: 100 }}
>
{options.map(({ id, label }) => (
<ListItem key={id} id={id}>
{label}
</ListItem>
))}
</Select>
</Row>
);
}

View file

@ -0,0 +1,71 @@
import { ListItem, Row, Select, type SelectProps, Text } from '@umami/react-zen';
import { useMemo, useState } from 'react';
import { Empty } from '@/components/common/Empty';
import { useMessages, useTeamMembersQuery, useUsersQuery } from '@/components/hooks';
export function UserSelect({
teamId,
onChange,
...props
}: {
teamId?: string;
} & SelectProps) {
const { formatMessage, messages } = useMessages();
const { data: users, isLoading: usersLoading } = useUsersQuery();
const { data: teamMembers, isLoading: teamMembersLoading } = useTeamMembersQuery(teamId);
const [username, setUsername] = useState<string>();
const [search, setSearch] = useState('');
const listItems = useMemo(() => {
if (!users) {
return [];
}
if (!teamId || !teamMembers) {
return users.data;
}
const teamMemberIds = teamMembers.data.map(({ userId }) => userId);
return users.data.filter(({ id }) => !teamMemberIds.includes(id));
}, [users, teamMembers, teamId]);
const handleSearch = (value: string) => {
setSearch(value);
};
const handleOpenChange = () => {
setSearch('');
};
const handleChange = (id: string) => {
setUsername(listItems.find(item => item.id === id)?.username);
onChange(id);
};
const renderValue = () => {
return (
<Row maxWidth="160px">
<Text truncate>{username}</Text>
</Row>
);
};
return (
<Select
{...props}
items={listItems}
value={username}
isLoading={usersLoading || (teamId && teamMembersLoading)}
allowSearch={true}
searchValue={search}
onSearch={handleSearch}
onChange={handleChange}
onOpenChange={handleOpenChange}
renderValue={renderValue}
listProps={{
renderEmptyState: () => <Empty message={formatMessage(messages.noResultsFound)} />,
style: { maxHeight: 'calc(42vh - 65px)' },
}}
>
{({ id, username }: any) => <ListItem key={id}>{username}</ListItem>}
</Select>
);
}

View file

@ -31,9 +31,11 @@ export function WebsiteDateFilter({
const showCompare = allowCompare && !isAllTime;
const websiteDateRange = useDateRangeQuery(websiteId);
const { startDate, endDate } = websiteDateRange;
const hasData = startDate && endDate;
const handleChange = (date: string) => {
if (date === 'all') {
if (date === 'all' && hasData) {
router.push(
updateParams({
date: `${getDateRangeValue(websiteDateRange.startDate, websiteDateRange.endDate)}:all`,
@ -41,7 +43,7 @@ export function WebsiteDateFilter({
}),
);
} else {
router.push(updateParams({ date, offset: undefined }));
router.push(updateParams({ date, offset: undefined, unit: undefined }));
}
};
@ -78,7 +80,7 @@ export function WebsiteDateFilter({
<DateFilter
value={dateValue}
onChange={handleChange}
showAllTime={showAllTime}
showAllTime={hasData && showAllTime}
renderDate={+offset !== 0}
/>
</Row>

View file

@ -1,3 +1,5 @@
import { Checkbox, Row } from '@umami/react-zen';
import { useState } from 'react';
import { useMessages, useNavigation } from '@/components/hooks';
import { ListFilter } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
@ -12,12 +14,20 @@ export function WebsiteFilterButton({
alignment?: 'end' | 'center' | 'start';
}) {
const { formatMessage, labels } = useMessages();
const { updateParams, router } = useNavigation();
const { updateParams, pathname, router, query } = useNavigation();
const [excludeBounce, setExcludeBounce] = useState(!!query.excludeBounce);
const isOverview =
/^\/teams\/[^/]+\/websites\/[^/]+$/.test(pathname) || /^\/share\/[^/]+$/.test(pathname);
const handleChange = ({ filters, segment, cohort }: any) => {
const params = filtersArrayToObject(filters);
const url = updateParams({ ...params, segment, cohort });
const url = updateParams({
...params,
segment,
cohort,
excludeBounce: excludeBounce ? 'true' : undefined,
});
router.push(url);
};
@ -25,7 +35,22 @@ export function WebsiteFilterButton({
return (
<DialogButton icon={<ListFilter />} label={formatMessage(labels.filter)} variant="outline">
{({ close }) => {
return <FilterEditForm websiteId={websiteId} onChange={handleChange} onClose={close} />;
return (
<>
{isOverview && (
<Row position="absolute" top="30px" right="30px">
<Checkbox
value={excludeBounce ? 'true' : ''}
onChange={setExcludeBounce}
style={{ marginTop: '3px' }}
>
{formatMessage(labels.excludeBounce)}
</Checkbox>
</Row>
)}
<FilterEditForm websiteId={websiteId} onChange={handleChange} onClose={close} />
</>
);
}}
</DialogButton>
);

View file

@ -26,7 +26,7 @@ export function WebsiteSelect({
const { user } = useLoginQuery();
const { data, isLoading } = useUserWebsitesQuery(
{ userId: user?.id, teamId },
{ search, pageSize: 10, includeTeams },
{ search, pageSize: 20, includeTeams },
);
const listItems: { id: string; name: string }[] = data?.data || [];

View file

@ -43,6 +43,7 @@ export const labels = defineMessages({
joinTeam: { id: 'label.join-team', defaultMessage: 'Join team' },
settings: { id: 'label.settings', defaultMessage: 'Settings' },
owner: { id: 'label.owner', defaultMessage: 'Owner' },
url: { id: 'label.url', defaultMessage: 'URL' },
teamOwner: { id: 'label.team-owner', defaultMessage: 'Team owner' },
teamManager: { id: 'label.team-manager', defaultMessage: 'Team manager' },
teamMember: { id: 'label.team-member', defaultMessage: 'Team member' },
@ -111,6 +112,7 @@ export const labels = defineMessages({
event: { id: 'label.event', defaultMessage: 'Event' },
events: { id: 'label.events', defaultMessage: 'Events' },
eventName: { id: 'label.event-name', defaultMessage: 'Event name' },
excludeBounce: { id: 'label.exclude-bounce', defaultMessage: 'Exclude bounces' },
query: { id: 'label.query', defaultMessage: 'Query' },
queryParameters: { id: 'label.query-parameters', defaultMessage: 'Query parameters' },
back: { id: 'label.back', defaultMessage: 'Back' },
@ -146,6 +148,7 @@ export const labels = defineMessages({
poweredBy: { id: 'label.powered-by', defaultMessage: 'Powered by {name}' },
pageViews: { id: 'label.page-views', defaultMessage: 'Page views' },
uniqueVisitors: { id: 'label.unique-visitors', defaultMessage: 'Unique visitors' },
uniqueEvents: { id: 'label.unique-events', defaultMessage: 'Unique Events' },
bounceRate: { id: 'label.bounce-rate', defaultMessage: 'Bounce rate' },
viewsPerVisit: { id: 'label.views-per-visit', defaultMessage: 'Views per visit' },
visitDuration: { id: 'label.visit-duration', defaultMessage: 'Visit duration' },
@ -243,9 +246,22 @@ export const labels = defineMessages({
device: { id: 'label.device', defaultMessage: 'Device' },
pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' },
tag: { id: 'label.tag', defaultMessage: 'Tag' },
source: { id: 'label.source', defaultMessage: 'Source' },
medium: { id: 'label.medium', defaultMessage: 'Medium' },
campaign: { id: 'label.campaign', defaultMessage: 'Campaign' },
content: { id: 'label.content', defaultMessage: 'Content' },
term: { id: 'label.term', defaultMessage: 'Term' },
utmSource: { id: 'label.utm-source', defaultMessage: 'UTM source' },
utmMedium: { id: 'label.utm-medium', defaultMessage: 'UTM medium' },
utmCampaign: { id: 'label.utm-campaign', defaultMessage: 'UTM campaign' },
utmContent: { id: 'label.utm-content', defaultMessage: 'UTM content' },
utmTerm: { id: 'label.utm-term', defaultMessage: 'UTM term' },
segment: { id: 'label.segment', defaultMessage: 'Segment' },
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
minute: { id: 'label.minute', defaultMessage: 'Minute' },
hour: { id: 'label.hour', defaultMessage: 'Hour' },
day: { id: 'label.day', defaultMessage: 'Day' },
month: { id: 'label.month', defaultMessage: 'Month' },
date: { id: 'label.date', defaultMessage: 'Date' },
pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' },
create: { id: 'label.create', defaultMessage: 'Create' },
@ -306,9 +322,7 @@ export const labels = defineMessages({
channel: { id: 'label.channel', defaultMessage: 'Channel' },
channels: { id: 'label.channels', defaultMessage: 'Channels' },
sources: { id: 'label.sources', defaultMessage: 'Sources' },
medium: { id: 'label.medium', defaultMessage: 'Medium' },
campaigns: { id: 'label.campaigns', defaultMessage: 'Campaigns' },
content: { id: 'label.content', defaultMessage: 'Content' },
terms: { id: 'label.terms', defaultMessage: 'Terms' },
direct: { id: 'label.direct', defaultMessage: 'Direct' },
referral: { id: 'label.referral', defaultMessage: 'Referral' },

View file

@ -25,7 +25,7 @@ export const MetricCard = ({
showChange = false,
}: MetricCardProps) => {
const diff = value - change;
const pct = ((value - diff) / diff) * 100;
const pct = diff !== 0 ? ((value - diff) / diff) * 100 : value !== 0 ? 100 : 0;
const props = useSpring({ x: Number(value) || 0, from: { x: 0 } });
const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } });