mirror of
https://github.com/umami-software/umami.git
synced 2026-02-10 15:47:13 +01:00
# 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:
commit
c3e0290e65
150 changed files with 3028 additions and 787 deletions
|
|
@ -31,6 +31,7 @@ export function PageBody({
|
|||
<Column
|
||||
{...props}
|
||||
width="100%"
|
||||
minHeight="100vh"
|
||||
paddingBottom="6"
|
||||
maxWidth={maxWidth}
|
||||
paddingX={{ xs: '3', md: '6' }}
|
||||
|
|
|
|||
6
src/components/hooks/context/useShare.ts
Normal file
6
src/components/hooks/context/useShare.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { useContext } from 'react';
|
||||
import { ShareContext } from '@/app/share/ShareProvider';
|
||||
|
||||
export function useShare() {
|
||||
return useContext(ShareContext);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
37
src/components/hooks/queries/useEventStatsQuery.ts
Normal file
37
src/components/hooks/queries/useEventStatsQuery.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
20
src/components/hooks/queries/useWebsiteSharesQuery.ts
Normal file
20
src/components/hooks/queries/useWebsiteSharesQuery.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
26
src/components/input/BounceFilter.tsx
Normal file
26
src/components/input/BounceFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
71
src/components/input/UnitFilter.tsx
Normal file
71
src/components/input/UnitFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
src/components/input/UserSelect.tsx
Normal file
71
src/components/input/UserSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 || [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue