Merge branch 'dev' into hosts-support

This commit is contained in:
Mike Cao 2024-06-18 23:02:14 -07:00 committed by GitHub
commit d1559c3a98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
281 changed files with 7555 additions and 1973 deletions

View file

@ -1,8 +1,11 @@
'use client';
import WebsitesHeader from 'app/(main)/settings/websites/WebsitesHeader';
import WebsitesDataTable from 'app/(main)/settings/websites/WebsitesDataTable';
import { useTeamUrl } from 'components/hooks';
export default function WebsitesPage() {
const { teamId } = useTeamUrl();
export default function WebsitesPage({ teamId }: { teamId: string }) {
return (
<>
<WebsitesHeader teamId={teamId} allowCreate={false} />

View file

@ -4,17 +4,41 @@ import { getDateArray } from 'lib/date';
import useWebsitePageviews from 'components/hooks/queries/useWebsitePageviews';
import { useDateRange } from 'components/hooks';
export function WebsiteChart({ websiteId }: { websiteId: string }) {
const [dateRange] = useDateRange(websiteId);
export function WebsiteChart({
websiteId,
compareMode = false,
}: {
websiteId: string;
compareMode?: boolean;
}) {
const { dateRange, dateCompare } = useDateRange(websiteId);
const { startDate, endDate, unit } = dateRange;
const { data, isLoading } = useWebsitePageviews(websiteId);
const { data, isLoading } = useWebsitePageviews(websiteId, compareMode ? dateCompare : undefined);
const { pageviews, sessions, compare } = (data || {}) as any;
const chartData = useMemo(() => {
if (data) {
return {
pageviews: getDateArray(data.pageviews, startDate, endDate, unit),
sessions: getDateArray(data.sessions, startDate, endDate, unit),
const result = {
pageviews: getDateArray(pageviews, startDate, endDate, unit),
sessions: getDateArray(sessions, startDate, endDate, unit),
};
if (compare) {
result['compare'] = {
pageviews: result.pageviews.map(({ x }, i) => ({
x,
y: compare.pageviews[i]?.y,
d: compare.pageviews[i]?.x,
})),
sessions: result.sessions.map(({ x }, i) => ({
x,
y: compare.sessions[i]?.y,
d: compare.sessions[i]?.x,
})),
};
}
return result;
}
return { pageviews: [], sessions: [] };
}, [data, startDate, endDate, unit]);

View file

@ -47,7 +47,7 @@ export default function WebsiteChartList({
</Button>
</Link>
</WebsiteHeader>
<WebsiteMetricsBar websiteId={id} showFilter={false} />
<WebsiteMetricsBar websiteId={id} />
{showCharts && <WebsiteChart websiteId={id} />}
</div>
) : null;

View file

@ -1,40 +0,0 @@
'use client';
import { Loading } from 'react-basics';
import { usePathname } from 'next/navigation';
import Page from 'components/layout/Page';
import FilterTags from 'components/metrics/FilterTags';
import { useNavigation, useWebsite } from 'components/hooks';
import WebsiteChart from './WebsiteChart';
import WebsiteExpandedView from './WebsiteExpandedView';
import WebsiteHeader from './WebsiteHeader';
import WebsiteMetricsBar from './WebsiteMetricsBar';
import WebsiteTableView from './WebsiteTableView';
export default function WebsiteDetails({ websiteId }: { websiteId: string }) {
const { data: website, isLoading, error } = useWebsite(websiteId);
const pathname = usePathname();
const { query } = useNavigation();
if (isLoading || error) {
return <Page isLoading={isLoading} error={error} />;
}
const showLinks = !pathname.includes('/share/');
const { view, ...params } = query;
return (
<>
<WebsiteHeader websiteId={websiteId} showLinks={showLinks} />
<FilterTags websiteId={websiteId} params={params} />
<WebsiteMetricsBar websiteId={websiteId} sticky={true} />
<WebsiteChart websiteId={websiteId} />
{!website && <Loading icon="dots" style={{ minHeight: 300 }} />}
{website && (
<>
{!view && <WebsiteTableView websiteId={websiteId} domainName={website.domain} />}
{view && <WebsiteExpandedView websiteId={websiteId} domainName={website.domain} />}
</>
)}
</>
);
}

View file

@ -0,0 +1,37 @@
'use client';
import { usePathname } from 'next/navigation';
import FilterTags from 'components/metrics/FilterTags';
import { useNavigation } from 'components/hooks';
import WebsiteChart from './WebsiteChart';
import WebsiteExpandedView from './WebsiteExpandedView';
import WebsiteHeader from './WebsiteHeader';
import WebsiteMetricsBar from './WebsiteMetricsBar';
import WebsiteTableView from './WebsiteTableView';
import WebsiteProvider from './WebsiteProvider';
import { FILTER_COLUMNS } from 'lib/constants';
export default function WebsiteDetailsPage({ websiteId }: { websiteId: string }) {
const pathname = usePathname();
const { query } = useNavigation();
const showLinks = !pathname.includes('/share/');
const { view } = query;
const params = Object.keys(query).reduce((obj, key) => {
if (FILTER_COLUMNS[key]) {
obj[key] = query[key];
}
return obj;
}, {});
return (
<WebsiteProvider websiteId={websiteId}>
<WebsiteHeader websiteId={websiteId} showLinks={showLinks} />
<FilterTags websiteId={websiteId} params={params} />
<WebsiteMetricsBar websiteId={websiteId} showFilter={true} showChange={true} sticky={true} />
<WebsiteChart websiteId={websiteId} />
{!view && <WebsiteTableView websiteId={websiteId} />}
{view && <WebsiteExpandedView websiteId={websiteId} />}
</WebsiteProvider>
);
}

View file

@ -19,6 +19,8 @@ import styles from './WebsiteExpandedView.module.css';
const views = {
url: PagesTable,
entry: PagesTable,
exit: PagesTable,
title: PagesTable,
referrer: ReferrersTable,
host: HostsTable,

View file

@ -1,4 +1,3 @@
import classNames from 'classnames';
import { Button, Icon, Icons, Popup, PopupTrigger, Text } from 'react-basics';
import PopupForm from 'app/(main)/reports/[reportId]/PopupForm';
import FilterSelectForm from 'app/(main)/reports/[reportId]/FilterSelectForm';
@ -9,14 +8,22 @@ import styles from './WebsiteFilterButton.module.css';
export function WebsiteFilterButton({
websiteId,
className,
position = 'bottom',
alignment = 'end',
showText = true,
}: {
websiteId: string;
className?: string;
position?: 'bottom' | 'top' | 'left' | 'right';
alignment?: 'end' | 'center' | 'start';
showText?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const { renderUrl, router } = useNavigation();
const { fields } = useFields();
const [{ startDate, endDate }] = useDateRange(websiteId);
const {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
const handleAddFilter = ({ name, operator, value }) => {
const prefix = OPERATOR_PREFIXES[operator];
@ -25,14 +32,14 @@ export function WebsiteFilterButton({
};
return (
<PopupTrigger>
<Button className={classNames(className, styles.button)} variant="quiet">
<PopupTrigger className={className}>
<Button className={styles.button} variant="quiet">
<Icon>
<Icons.Plus />
</Icon>
<Text>{formatMessage(labels.filter)}</Text>
{showText && <Text>{formatMessage(labels.filter)}</Text>}
</Button>
<Popup position="bottom" alignment="end">
<Popup position={position} alignment={alignment}>
{(close: () => void) => {
return (
<PopupForm>

View file

@ -30,6 +30,11 @@ export function WebsiteHeader({
icon: <Icons.Overview />,
path: '',
},
{
label: formatMessage(labels.compare),
icon: <Icons.Compare />,
path: '/compare',
},
{
label: formatMessage(labels.realtime),
icon: <Icons.Clock />,

View file

@ -1,6 +1,6 @@
.container {
display: grid;
grid-template-columns: 1fr max-content;
grid-template-columns: 2fr 1fr;
justify-content: space-between;
align-items: center;
background: var(--base50);
@ -11,10 +11,22 @@
.actions {
display: flex;
align-items: center;
flex-direction: row;
justify-content: flex-end;
flex-direction: column;
align-items: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.vs {
display: flex;
align-items: center;
justify-content: flex-end;
flex-basis: 100%;
gap: 10px;
}
.dropdown {
min-width: 200px;
}
@media screen and (max-width: 1200px) {
@ -38,9 +50,3 @@
border-bottom: 1px solid var(--base300);
}
}
@media screen and (max-width: 768px) {
.button {
display: none;
}
}

View file

@ -1,96 +1,133 @@
import classNames from 'classnames';
import { useMessages, useSticky } from 'components/hooks';
import { useDateRange, useMessages, useSticky } from 'components/hooks';
import WebsiteDateFilter from 'components/input/WebsiteDateFilter';
import MetricCard from 'components/metrics/MetricCard';
import MetricsBar from 'components/metrics/MetricsBar';
import { formatShortTime } from 'lib/format';
import { formatShortTime, formatLongNumber } from 'lib/format';
import WebsiteFilterButton from './WebsiteFilterButton';
import styles from './WebsiteMetricsBar.module.css';
import useWebsiteStats from 'components/hooks/queries/useWebsiteStats';
import styles from './WebsiteMetricsBar.module.css';
import { Dropdown, Item } from 'react-basics';
import useStore, { setWebsiteDateCompare } from 'store/websites';
export function WebsiteMetricsBar({
websiteId,
showFilter = true,
sticky,
showChange = false,
compareMode = false,
showFilter = false,
}: {
websiteId: string;
showFilter?: boolean;
sticky?: boolean;
showChange?: boolean;
compareMode?: boolean;
showFilter?: boolean;
}) {
const { dateRange } = useDateRange(websiteId);
const { formatMessage, labels } = useMessages();
const dateCompare = useStore(state => state[websiteId]?.dateCompare);
const { ref, isSticky } = useSticky({ enabled: sticky });
const { data, isLoading, isFetched, error } = useWebsiteStats(websiteId);
const { data, isLoading, isFetched, error } = useWebsiteStats(
websiteId,
compareMode && dateCompare,
);
const isAllTime = dateRange.value === 'all';
const { pageviews, visitors, visits, bounces, totaltime } = data || {};
const num = Math.min(data && visitors.value, data && bounces.value);
const diffs = data && {
pageviews: pageviews.value - pageviews.change,
visitors: visitors.value - visitors.change,
visits: visits.value - visits.change,
bounces: bounces.value - bounces.change,
totaltime: totaltime.value - totaltime.change,
};
const metrics = data
? [
{
...pageviews,
label: formatMessage(labels.views),
change: pageviews.value - pageviews.prev,
formatValue: formatLongNumber,
},
{
...visits,
label: formatMessage(labels.visits),
change: visits.value - visits.prev,
formatValue: formatLongNumber,
},
{
...visitors,
label: formatMessage(labels.visitors),
change: visitors.value - visitors.prev,
formatValue: formatLongNumber,
},
{
label: formatMessage(labels.bounceRate),
value: (Math.min(visits.value, bounces.value) / visits.value) * 100,
prev: (Math.min(visits.prev, bounces.prev) / visits.prev) * 100,
change:
(Math.min(visits.value, bounces.value) / visits.value) * 100 -
(Math.min(visits.prev, bounces.prev) / visits.prev) * 100,
formatValue: n => Math.round(+n) + '%',
reverseColors: true,
},
{
label: formatMessage(labels.visitDuration),
value: totaltime.value / visits.value,
prev: totaltime.prev / visits.prev,
change: totaltime.value / visits.value - totaltime.prev / visits.prev,
formatValue: n =>
`${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`,
},
]
: [];
const items = [
{ label: formatMessage(labels.previousPeriod), value: 'prev' },
{ label: formatMessage(labels.previousYear), value: 'yoy' },
];
return (
<div
ref={ref}
className={classNames(styles.container, {
[styles.sticky]: sticky,
[styles.isSticky]: isSticky,
[styles.isSticky]: sticky && isSticky,
})}
>
<MetricsBar isLoading={isLoading} isFetched={isFetched} error={error}>
{pageviews && visitors && (
<>
<MetricCard
label={formatMessage(labels.views)}
value={pageviews.value}
change={pageviews.change}
/>
<MetricCard
label={formatMessage(labels.visits)}
value={visits.value}
change={visits.change}
/>
<MetricCard
label={formatMessage(labels.visitors)}
value={visitors.value}
change={visitors.change}
/>
<MetricCard
label={formatMessage(labels.bounceRate)}
value={visitors.value ? (num / visitors.value) * 100 : 0}
change={
visitors.value && visitors.change
? (num / visitors.value) * 100 -
(Math.min(diffs.visitors, diffs.bounces) / diffs.visitors) * 100 || 0
: 0
}
format={n => Number(n).toFixed(0) + '%'}
reverseColors
/>
<MetricCard
label={formatMessage(labels.averageVisitTime)}
value={
totaltime.value && pageviews.value
? totaltime.value / (pageviews.value - bounces.value)
: 0
}
change={
totaltime.value && pageviews.value
? (diffs.totaltime / (diffs.pageviews - diffs.bounces) -
totaltime.value / (pageviews.value - bounces.value)) *
-1 || 0
: 0
}
format={n => `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`}
/>
</>
)}
</MetricsBar>
<div>
<MetricsBar isLoading={isLoading} isFetched={isFetched} error={error}>
{metrics.map(({ label, value, prev, change, formatValue, reverseColors }) => {
return (
<MetricCard
key={label}
value={value}
previousValue={prev}
label={label}
change={change}
formatValue={formatValue}
reverseColors={reverseColors}
showChange={!isAllTime && (compareMode || showChange)}
showPrevious={!isAllTime && compareMode}
/>
);
})}
</MetricsBar>
</div>
<div className={styles.actions}>
{showFilter && <WebsiteFilterButton websiteId={websiteId} className={styles.button} />}
<WebsiteDateFilter websiteId={websiteId} />
{showFilter && <WebsiteFilterButton websiteId={websiteId} />}
<WebsiteDateFilter websiteId={websiteId} showAllTime={!compareMode} />
{compareMode && (
<div className={styles.vs}>
<b>VS</b>
<Dropdown
className={styles.dropdown}
items={items}
value={dateCompare || 'prev'}
renderValue={value => items.find(i => i.value === value)?.label}
alignment="end"
onChange={(value: any) => setWebsiteDateCompare(websiteId, value)}
>
{items.map(({ label, value }) => (
<Item key={value}>{label}</Item>
))}
</Dropdown>
</div>
)}
</div>
</div>
);

View file

@ -11,17 +11,10 @@ import CountriesTable from 'components/metrics/CountriesTable';
import EventsTable from 'components/metrics/EventsTable';
import EventsChart from 'components/metrics/EventsChart';
export default function WebsiteTableView({
websiteId,
domainName,
}: {
websiteId: string;
domainName: string;
}) {
export default function WebsiteTableView({ websiteId }: { websiteId: string }) {
const [countryData, setCountryData] = useState();
const tableProps = {
websiteId,
domainName,
limit: 10,
};

View file

@ -0,0 +1,32 @@
'use client';
import WebsiteHeader from '../WebsiteHeader';
import WebsiteMetricsBar from '../WebsiteMetricsBar';
import FilterTags from 'components/metrics/FilterTags';
import { useNavigation } from 'components/hooks';
import { FILTER_COLUMNS } from 'lib/constants';
import WebsiteChart from '../WebsiteChart';
import WebsiteCompareTables from './WebsiteCompareTables';
import WebsiteProvider from '../WebsiteProvider';
export function WebsiteComparePage({ websiteId }) {
const { query } = useNavigation();
const params = Object.keys(query).reduce((obj, key) => {
if (FILTER_COLUMNS[key]) {
obj[key] = query[key];
}
return obj;
}, {});
return (
<WebsiteProvider websiteId={websiteId}>
<WebsiteHeader websiteId={websiteId} />
<FilterTags websiteId={websiteId} params={params} />
<WebsiteMetricsBar websiteId={websiteId} compareMode={true} showFilter={true} />
<WebsiteChart websiteId={websiteId} compareMode={true} />
<WebsiteCompareTables websiteId={websiteId} />
</WebsiteProvider>
);
}
export default WebsiteComparePage;

View file

@ -0,0 +1,14 @@
.container {
margin-bottom: 60px;
}
.nav {
width: 200px;
margin-top: 40px;
}
.title {
color: var(--base800);
text-align: center;
font-weight: 700;
}

View file

@ -0,0 +1,161 @@
import { useState } from 'react';
import SideNav from 'components/layout/SideNav';
import { useDateRange, useMessages, useNavigation } from 'components/hooks';
import PagesTable from 'components/metrics/PagesTable';
import ReferrersTable from 'components/metrics/ReferrersTable';
import BrowsersTable from 'components/metrics/BrowsersTable';
import OSTable from 'components/metrics/OSTable';
import DevicesTable from 'components/metrics/DevicesTable';
import ScreenTable from 'components/metrics/ScreenTable';
import CountriesTable from 'components/metrics/CountriesTable';
import RegionsTable from 'components/metrics/RegionsTable';
import CitiesTable from 'components/metrics/CitiesTable';
import LanguagesTable from 'components/metrics/LanguagesTable';
import EventsTable from 'components/metrics/EventsTable';
import QueryParametersTable from 'components/metrics/QueryParametersTable';
import { Grid, GridRow } from 'components/layout/Grid';
import MetricsTable from 'components/metrics/MetricsTable';
import useStore from 'store/websites';
import { getCompareDate } from 'lib/date';
import { formatNumber } from 'lib/format';
import ChangeLabel from 'components/metrics/ChangeLabel';
import styles from './WebsiteCompareTables.module.css';
const views = {
url: PagesTable,
title: PagesTable,
referrer: ReferrersTable,
browser: BrowsersTable,
os: OSTable,
device: DevicesTable,
screen: ScreenTable,
country: CountriesTable,
region: RegionsTable,
city: CitiesTable,
language: LanguagesTable,
event: EventsTable,
query: QueryParametersTable,
};
export function WebsiteCompareTables({ websiteId }: { websiteId: string }) {
const [data, setData] = useState([]);
const { dateRange } = useDateRange(websiteId);
const dateCompare = useStore(state => state[websiteId]?.dateCompare);
const { formatMessage, labels } = useMessages();
const {
renderUrl,
query: { view },
} = useNavigation();
const Component: typeof MetricsTable = views[view || 'url'] || (() => null);
const items = [
{
key: 'url',
label: formatMessage(labels.pages),
url: renderUrl({ view: 'url' }),
},
{
key: 'referrer',
label: formatMessage(labels.referrers),
url: renderUrl({ view: 'referrer' }),
},
{
key: 'browser',
label: formatMessage(labels.browsers),
url: renderUrl({ view: 'browser' }),
},
{
key: 'os',
label: formatMessage(labels.os),
url: renderUrl({ view: 'os' }),
},
{
key: 'device',
label: formatMessage(labels.devices),
url: renderUrl({ view: 'device' }),
},
{
key: 'country',
label: formatMessage(labels.countries),
url: renderUrl({ view: 'country' }),
},
{
key: 'region',
label: formatMessage(labels.regions),
url: renderUrl({ view: 'region' }),
},
{
key: 'city',
label: formatMessage(labels.cities),
url: renderUrl({ view: 'city' }),
},
{
key: 'language',
label: formatMessage(labels.languages),
url: renderUrl({ view: 'language' }),
},
{
key: 'screen',
label: formatMessage(labels.screens),
url: renderUrl({ view: 'screen' }),
},
{
key: 'event',
label: formatMessage(labels.events),
url: renderUrl({ view: 'event' }),
},
{
key: 'query',
label: formatMessage(labels.queryParameters),
url: renderUrl({ view: 'query' }),
},
];
const renderChange = ({ x, y }) => {
const prev = data.find(d => d.x === x)?.y;
const value = y - prev;
const change = Math.abs(((y - prev) / prev) * 100);
return !isNaN(change) && <ChangeLabel value={value}>{formatNumber(change)}%</ChangeLabel>;
};
const { startDate, endDate } = getCompareDate(
dateCompare,
dateRange.startDate,
dateRange.endDate,
);
const params = {
startAt: startDate.getTime(),
endAt: endDate.getTime(),
};
return (
<Grid className={styles.container}>
<GridRow columns="compare">
<SideNav className={styles.nav} items={items} selectedKey={view} shallow={true} />
<div>
<div className={styles.title}>{formatMessage(labels.previous)}</div>
<Component
websiteId={websiteId}
limit={20}
showMore={false}
onDataLoad={setData}
params={params}
/>
</div>
<div>
<div className={styles.title}> {formatMessage(labels.current)}</div>
<Component
websiteId={websiteId}
limit={20}
showMore={false}
renderChange={renderChange}
/>
</div>
</GridRow>
</Grid>
);
}
export default WebsiteCompareTables;

View file

@ -0,0 +1,10 @@
import WebsiteComparePage from './WebsiteComparePage';
import { Metadata } from 'next';
export default function ({ params: { websiteId } }) {
return <WebsiteComparePage websiteId={websiteId} />;
}
export const metadata: Metadata = {
title: 'Website Comparison',
};

View file

@ -7,7 +7,7 @@ import styles from './EventDataMetricsBar.module.css';
export function EventDataMetricsBar({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages();
const { get, useQuery } = useApi();
const [dateRange] = useDateRange(websiteId);
const { dateRange } = useDateRange(websiteId);
const { startDate, endDate } = dateRange;
const { data, error, isLoading, isFetched } = useQuery({

View file

@ -6,7 +6,7 @@ import { useDateRange, useApi, useNavigation } from 'components/hooks';
import styles from './WebsiteEventData.module.css';
function useData(websiteId: string, event: string) {
const [dateRange] = useDateRange(websiteId);
const { dateRange } = useDateRange(websiteId);
const { startDate, endDate } = dateRange;
const { get, useQuery } = useApi();
const { data, error, isLoading } = useQuery({

View file

@ -1,8 +1,8 @@
import WebsiteDetails from './WebsiteDetails';
import WebsiteDetailsPage from './WebsiteDetailsPage';
import { Metadata } from 'next';
export default function WebsitePage({ params: { websiteId } }) {
return <WebsiteDetails websiteId={websiteId} />;
return <WebsiteDetailsPage websiteId={websiteId} />;
}
export const metadata: Metadata = {

View file

@ -13,7 +13,7 @@ export function RealtimeCountries({ data }) {
({ x: code }) => (
<span className={classNames(locale, styles.row)}>
<img
src={`${process.env.basePath}/images/flags/${code?.toLowerCase() || 'xx'}.png`}
src={`${process.env.basePath || ''}/images/flags/${code?.toLowerCase() || 'xx'}.png`}
alt={code}
/>
{countryNames[code]}

View file

@ -14,25 +14,21 @@ export function RealtimeHeader({ data }: { data: RealtimeData }) {
className={styles.card}
label={formatMessage(labels.views)}
value={pageviews?.length}
hideComparison
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.visitors)}
value={visitors?.length}
hideComparison
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.events)}
value={events?.length}
hideComparison
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.countries)}
value={countries?.length}
hideComparison
/>
</div>
</div>

View file

@ -1,8 +1,8 @@
import WebsitesPage from './WebsitesPage';
import { Metadata } from 'next';
export default function ({ params: { teamId, userId } }) {
return <WebsitesPage teamId={teamId} userId={userId} />;
export default function () {
return <WebsitesPage />;
}
export const metadata: Metadata = {