mirror of
https://github.com/umami-software/umami.git
synced 2026-02-09 23:27:12 +01:00
Merge branch 'dev' into hosts-support
This commit is contained in:
commit
d1559c3a98
281 changed files with 7555 additions and 1973 deletions
|
|
@ -27,7 +27,7 @@ export function App({ children }) {
|
|||
{children}
|
||||
<UpdateNotice user={user} config={config} />
|
||||
{process.env.NODE_ENV === 'production' && !pathname.includes('/share/') && (
|
||||
<Script src={`${process.env.basePath}/telemetry.js`} />
|
||||
<Script src={`${process.env.basePath || ''}/telemetry.js`} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export function TestConsole({ websiteId }: { websiteId: string }) {
|
|||
<Script
|
||||
async
|
||||
data-website-id={websiteId}
|
||||
src={`${process.env.basePath}/script.js`}
|
||||
src={`${process.env.basePath || ''}/script.js`}
|
||||
data-cache="true"
|
||||
/>
|
||||
<div className={styles.actions}>
|
||||
|
|
|
|||
|
|
@ -19,15 +19,3 @@
|
|||
height: calc(100vh - 60px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import Page from 'components/layout/Page';
|
|||
import styles from './layout.module.css';
|
||||
|
||||
export default function ({ children }) {
|
||||
if (process.env.DISABLE_UI) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<App>
|
||||
<main className={styles.layout}>
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ import styles from './DateRangeSetting.module.css';
|
|||
|
||||
export function DateRangeSetting() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [dateRange, setDateRange] = useDateRange();
|
||||
const { dateRange, saveDateRange } = useDateRange();
|
||||
const { value } = dateRange;
|
||||
|
||||
const handleChange = (value: string | DateRange) => setDateRange(value);
|
||||
const handleReset = () => setDateRange(DEFAULT_DATE_RANGE);
|
||||
const handleChange = (value: string | DateRange) => saveDateRange(value);
|
||||
const handleReset = () => saveDateRange(DEFAULT_DATE_RANGE);
|
||||
|
||||
return (
|
||||
<Flexbox gap={10} width={300}>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
import { useReports } from 'components/hooks';
|
||||
import ReportsTable from './ReportsTable';
|
||||
import DataTable from 'components/common/DataTable';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function ReportsDataTable({
|
||||
websiteId,
|
||||
teamId,
|
||||
children,
|
||||
}: {
|
||||
websiteId?: string;
|
||||
teamId?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const queryResult = useReports({ websiteId, teamId });
|
||||
|
||||
if (queryResult?.result?.data?.length === 0) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable queryResult={queryResult}>
|
||||
{({ data }) => <ReportsTable data={data} showDomain={!websiteId} />}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@
|
|||
import { Metadata } from 'next';
|
||||
import ReportsHeader from './ReportsHeader';
|
||||
import ReportsDataTable from './ReportsDataTable';
|
||||
import { useTeamUrl } from 'components/hooks';
|
||||
|
||||
export default function ReportsPage() {
|
||||
const { teamId } = useTeamUrl();
|
||||
|
||||
export default function ReportsPage({ teamId }: { teamId: string }) {
|
||||
return (
|
||||
<>
|
||||
<ReportsHeader />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
.dropdown div {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import DateFilter from 'components/input/DateFilter';
|
|||
import WebsiteSelect from 'components/input/WebsiteSelect';
|
||||
import { useMessages, useTeamUrl, useWebsite } from 'components/hooks';
|
||||
import { ReportContext } from './Report';
|
||||
import styles from './BaseParameters.module.css';
|
||||
|
||||
export interface BaseParametersProps {
|
||||
showWebsiteSelect?: boolean;
|
||||
|
|
@ -48,7 +49,7 @@ export function BaseParameters({
|
|||
</FormRow>
|
||||
)}
|
||||
{showDateSelect && (
|
||||
<FormRow label={formatMessage(labels.dateRange)}>
|
||||
<FormRow label={formatMessage(labels.dateRange)} className={styles.dropdown}>
|
||||
{allowDateSelect && (
|
||||
<DateFilter
|
||||
value={value}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,24 @@
|
|||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
flex-wrap: nowrap;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--base400);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 1px 1px 1px var(--base400);
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.icon,
|
||||
.close {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,18 +24,21 @@ export function ParameterList({ children }: ParameterListProps) {
|
|||
const Item = ({
|
||||
children,
|
||||
className,
|
||||
icon,
|
||||
onClick,
|
||||
onRemove,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
icon?: ReactNode;
|
||||
onClick?: () => void;
|
||||
onRemove?: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className={classNames(styles.item, className)} onClick={onClick}>
|
||||
{children}
|
||||
<Icon onClick={onRemove}>
|
||||
{icon && <Icon className={styles.icon}>{icon}</Icon>}
|
||||
<div className={styles.value}>{children}</div>
|
||||
<Icon className={styles.close} onClick={onRemove}>
|
||||
<Icons.Close />
|
||||
</Icon>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,4 +3,5 @@
|
|||
grid-template-rows: max-content 1fr;
|
||||
grid-template-columns: max-content 1fr;
|
||||
margin-bottom: 60px;
|
||||
height: 90vh;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
.body {
|
||||
padding-inline-start: 20px;
|
||||
grid-row: 2/3;
|
||||
grid-row: 2 / 3;
|
||||
grid-column: 2 / 3;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import styles from './ReportBody.module.css';
|
||||
import { useContext } from 'react';
|
||||
import { ReportContext } from './Report';
|
||||
import styles from './ReportBody.module.css';
|
||||
|
||||
export function ReportBody({ children }) {
|
||||
const { report } = useContext(ReportContext);
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export function ReportHeader({ icon }) {
|
|||
<div className={styles.type}>
|
||||
<Breadcrumb
|
||||
data={[
|
||||
{ label: formatMessage(labels.reports), url: '/reports' },
|
||||
{ label: formatMessage(labels.reports), url: renderTeamUrl('/reports') },
|
||||
{
|
||||
label: formatMessage(
|
||||
labels[Object.keys(REPORT_TYPES).find(key => REPORT_TYPES[key] === report?.type)],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,38 @@
|
|||
.menu {
|
||||
position: relative;
|
||||
width: 300px;
|
||||
padding-top: 20px;
|
||||
padding-inline-end: 20px;
|
||||
border-inline-end: 1px solid var(--base300);
|
||||
grid-row: 2 / 3;
|
||||
grid-column: 1 / 2;
|
||||
}
|
||||
|
||||
.button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
place-content: center;
|
||||
border: 1px solid var(--base400);
|
||||
border-right: 0;
|
||||
width: 30px;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px 0 0 4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--base75);
|
||||
}
|
||||
|
||||
.menu.collapsed {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.menu.collapsed .button {
|
||||
right: 0;
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,27 @@
|
|||
import styles from './ReportMenu.module.css';
|
||||
import { useContext } from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { ReportContext } from './Report';
|
||||
import styles from './ReportMenu.module.css';
|
||||
import { Icon, Icons } from 'react-basics';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export function ReportMenu({ children }) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { report } = useContext(ReportContext);
|
||||
|
||||
if (!report) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={styles.menu}>{children}</div>;
|
||||
return (
|
||||
<div className={classNames(styles.menu, collapsed && styles.collapsed)}>
|
||||
<div className={styles.button} onClick={() => setCollapsed(!collapsed)}>
|
||||
<Icon rotate={collapsed ? -90 : 90}>
|
||||
<Icons.ChevronDown />
|
||||
</Icon>
|
||||
</div>
|
||||
{!collapsed && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportMenu;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
'use client';
|
||||
import FunnelReport from '../funnel/FunnelReport';
|
||||
import { useReport } from 'components/hooks';
|
||||
import EventDataReport from '../event-data/EventDataReport';
|
||||
import FunnelReport from '../funnel/FunnelReport';
|
||||
import GoalReport from '../goals/GoalsReport';
|
||||
import InsightsReport from '../insights/InsightsReport';
|
||||
import JourneyReport from '../journey/JourneyReport';
|
||||
import RetentionReport from '../retention/RetentionReport';
|
||||
import UTMReport from '../utm/UTMReport';
|
||||
import { useReport } from 'components/hooks';
|
||||
|
||||
const reports = {
|
||||
funnel: FunnelReport,
|
||||
|
|
@ -12,6 +14,8 @@ const reports = {
|
|||
insights: InsightsReport,
|
||||
retention: RetentionReport,
|
||||
utm: UTMReport,
|
||||
goals: GoalReport,
|
||||
journey: JourneyReport,
|
||||
};
|
||||
|
||||
export default function ReportPage({ reportId }: { reportId: string }) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import Link from 'next/link';
|
||||
import { Button, Icons, Text, Icon } from 'react-basics';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import Funnel from 'assets/funnel.svg';
|
||||
import Lightbulb from 'assets/lightbulb.svg';
|
||||
import Magnet from 'assets/magnet.svg';
|
||||
import Path from 'assets/path.svg';
|
||||
import Tag from 'assets/tag.svg';
|
||||
import styles from './ReportTemplates.module.css';
|
||||
import Target from 'assets/target.svg';
|
||||
import { useMessages, useTeamUrl } from 'components/hooks';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import Link from 'next/link';
|
||||
import { Button, Icon, Icons, Text } from 'react-basics';
|
||||
import styles from './ReportTemplates.module.css';
|
||||
|
||||
export function ReportTemplates({ showHeader = true }: { showHeader?: boolean }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
|
@ -37,6 +39,18 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean })
|
|||
url: renderTeamUrl('/reports/utm'),
|
||||
icon: <Tag />,
|
||||
},
|
||||
{
|
||||
title: formatMessage(labels.goals),
|
||||
description: formatMessage(labels.goalsDescription),
|
||||
url: renderTeamUrl('/reports/goals'),
|
||||
icon: <Target />,
|
||||
},
|
||||
{
|
||||
title: formatMessage(labels.journey),
|
||||
description: formatMessage(labels.journeyDescription),
|
||||
url: renderTeamUrl('/reports/journey'),
|
||||
icon: <Path />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -5,10 +5,6 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.type {
|
||||
color: var(--base700);
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
|
|
|
|||
|
|
@ -93,12 +93,10 @@ export function FunnelParameters() {
|
|||
<PopupTrigger key={index}>
|
||||
<ParameterList.Item
|
||||
className={styles.item}
|
||||
icon={step.type === 'url' ? <Icons.Eye /> : <Icons.Bolt />}
|
||||
onRemove={() => handleRemoveStep(index)}
|
||||
>
|
||||
<div className={styles.value}>
|
||||
<div className={styles.type}>
|
||||
<Icon>{step.type === 'url' ? <Icons.Eye /> : <Icons.Bolt />}</Icon>
|
||||
</div>
|
||||
<div>{step.value}</div>
|
||||
</div>
|
||||
</ParameterList.Item>
|
||||
|
|
|
|||
7
src/app/(main)/reports/goals/GoalsAddForm.module.css
Normal file
7
src/app/(main)/reports/goals/GoalsAddForm.module.css
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.dropdown {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 200px;
|
||||
}
|
||||
143
src/app/(main)/reports/goals/GoalsAddForm.tsx
Normal file
143
src/app/(main)/reports/goals/GoalsAddForm.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { useMessages } from 'components/hooks';
|
||||
import { useState } from 'react';
|
||||
import { Button, Dropdown, Flexbox, FormRow, Item, TextField } from 'react-basics';
|
||||
import styles from './GoalsAddForm.module.css';
|
||||
|
||||
export function GoalsAddForm({
|
||||
type: defaultType = 'url',
|
||||
value: defaultValue = '',
|
||||
property: defaultProperty = '',
|
||||
operator: defaultAggregae = null,
|
||||
goal: defaultGoal = 10,
|
||||
onChange,
|
||||
}: {
|
||||
type?: string;
|
||||
value?: string;
|
||||
operator?: string;
|
||||
property?: string;
|
||||
goal?: number;
|
||||
onChange?: (step: {
|
||||
type: string;
|
||||
value: string;
|
||||
goal: number;
|
||||
operator?: string;
|
||||
property?: string;
|
||||
}) => void;
|
||||
}) {
|
||||
const [type, setType] = useState(defaultType);
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const [operator, setOperator] = useState(defaultAggregae);
|
||||
const [property, setProperty] = useState(defaultProperty);
|
||||
const [goal, setGoal] = useState(defaultGoal);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const items = [
|
||||
{ label: formatMessage(labels.url), value: 'url' },
|
||||
{ label: formatMessage(labels.event), value: 'event' },
|
||||
{ label: formatMessage(labels.eventData), value: 'event-data' },
|
||||
];
|
||||
const operators = [
|
||||
{ label: formatMessage(labels.count), value: 'count' },
|
||||
{ label: formatMessage(labels.average), value: 'average' },
|
||||
{ label: formatMessage(labels.sum), value: 'sum' },
|
||||
];
|
||||
const isDisabled = !type || !value;
|
||||
|
||||
const handleSave = () => {
|
||||
onChange(
|
||||
type === 'event-data' ? { type, value, goal, operator, property } : { type, value, goal },
|
||||
);
|
||||
setValue('');
|
||||
setProperty('');
|
||||
setGoal(10);
|
||||
};
|
||||
|
||||
const handleChange = (e, set) => {
|
||||
set(e.target.value);
|
||||
};
|
||||
|
||||
const handleKeyDown = e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
const renderTypeValue = (value: any) => {
|
||||
return items.find(item => item.value === value)?.label;
|
||||
};
|
||||
|
||||
const renderoperatorValue = (value: any) => {
|
||||
return operators.find(item => item.value === value)?.label;
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox direction="column" gap={10}>
|
||||
<FormRow label={formatMessage(defaultValue ? labels.update : labels.add)}>
|
||||
<Flexbox gap={10}>
|
||||
<Dropdown
|
||||
className={styles.dropdown}
|
||||
items={items}
|
||||
value={type}
|
||||
renderValue={renderTypeValue}
|
||||
onChange={(value: any) => setType(value)}
|
||||
>
|
||||
{({ value, label }) => {
|
||||
return <Item key={value}>{label}</Item>;
|
||||
}}
|
||||
</Dropdown>
|
||||
<TextField
|
||||
className={styles.input}
|
||||
value={value}
|
||||
onChange={e => handleChange(e, setValue)}
|
||||
autoFocus={true}
|
||||
autoComplete="off"
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</Flexbox>
|
||||
</FormRow>
|
||||
{type === 'event-data' && (
|
||||
<FormRow label={formatMessage(labels.property)}>
|
||||
<Flexbox gap={10}>
|
||||
<Dropdown
|
||||
className={styles.dropdown}
|
||||
items={operators}
|
||||
value={operator}
|
||||
renderValue={renderoperatorValue}
|
||||
onChange={(value: any) => setOperator(value)}
|
||||
>
|
||||
{({ value, label }) => {
|
||||
return <Item key={value}>{label}</Item>;
|
||||
}}
|
||||
</Dropdown>
|
||||
<TextField
|
||||
className={styles.input}
|
||||
value={property}
|
||||
onChange={e => handleChange(e, setProperty)}
|
||||
autoFocus={true}
|
||||
autoComplete="off"
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</Flexbox>
|
||||
</FormRow>
|
||||
)}
|
||||
<FormRow label={formatMessage(labels.goal)}>
|
||||
<Flexbox gap={10}>
|
||||
<TextField
|
||||
className={styles.input}
|
||||
value={goal?.toString()}
|
||||
onChange={e => handleChange(e, setGoal)}
|
||||
autoComplete="off"
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</Flexbox>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<Button variant="primary" onClick={handleSave} disabled={isDisabled}>
|
||||
{formatMessage(defaultValue ? labels.update : labels.add)}
|
||||
</Button>
|
||||
</FormRow>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
export default GoalsAddForm;
|
||||
95
src/app/(main)/reports/goals/GoalsChart.module.css
Normal file
95
src/app/(main)/reports/goals/GoalsChart.module.css
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
.chart {
|
||||
display: grid;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.goal {
|
||||
padding-bottom: 40px;
|
||||
border-bottom: 1px solid var(--base400);
|
||||
}
|
||||
|
||||
.goal:last-child {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--base600);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.item {
|
||||
font-size: 20px;
|
||||
color: var(--base900);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric {
|
||||
color: var(--base700);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--base900);
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.percent {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.total {
|
||||
color: var(--base700);
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
background: var(--base900);
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar.level1 {
|
||||
background: var(--red800);
|
||||
}
|
||||
.bar.level2 {
|
||||
background: var(--orange200);
|
||||
}
|
||||
.bar.level3 {
|
||||
background: var(--orange400);
|
||||
}
|
||||
.bar.level4 {
|
||||
background: var(--orange600);
|
||||
}
|
||||
.bar.level5 {
|
||||
background: var(--green600);
|
||||
}
|
||||
|
||||
.track {
|
||||
background-color: var(--base100);
|
||||
border-radius: 5px;
|
||||
}
|
||||
74
src/app/(main)/reports/goals/GoalsChart.tsx
Normal file
74
src/app/(main)/reports/goals/GoalsChart.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useContext } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useMessages } from 'components/hooks';
|
||||
import { ReportContext } from '../[reportId]/Report';
|
||||
import { formatLongNumber } from 'lib/format';
|
||||
import styles from './GoalsChart.module.css';
|
||||
|
||||
export function GoalsChart({ className }: { className?: string; isLoading?: boolean }) {
|
||||
const { report } = useContext(ReportContext);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const { data } = report || {};
|
||||
|
||||
const getLabel = type => {
|
||||
let label = '';
|
||||
switch (type) {
|
||||
case 'url':
|
||||
label = labels.viewedPage;
|
||||
break;
|
||||
case 'event':
|
||||
label = labels.triggeredEvent;
|
||||
break;
|
||||
default:
|
||||
label = labels.collectedData;
|
||||
break;
|
||||
}
|
||||
|
||||
return label;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.chart, className)}>
|
||||
{data?.map(({ type, value, goal, result, property, operator }, index: number) => {
|
||||
const percent = result > goal ? 100 : (result / goal) * 100;
|
||||
|
||||
return (
|
||||
<div key={index} className={styles.goal}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.label}>{formatMessage(getLabel(type))}</span>
|
||||
<span className={styles.item}>{`${value}${
|
||||
type === 'event-data' ? `:(${operator}):${property}` : ''
|
||||
}`}</span>
|
||||
</div>
|
||||
<div className={styles.track}>
|
||||
<div
|
||||
className={classNames(
|
||||
classNames(styles.bar, {
|
||||
[styles.level1]: percent <= 20,
|
||||
[styles.level2]: percent > 20 && percent <= 40,
|
||||
[styles.level3]: percent > 40 && percent <= 60,
|
||||
[styles.level4]: percent > 60 && percent <= 80,
|
||||
[styles.level5]: percent > 80,
|
||||
}),
|
||||
)}
|
||||
style={{ width: `${percent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className={styles.metric}>
|
||||
<div className={styles.value}>
|
||||
{formatLongNumber(result)}
|
||||
<span className={styles.total}> / {formatLongNumber(goal)}</span>
|
||||
</div>
|
||||
<div className={styles.percent}>{((result / goal) * 100).toFixed(2)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GoalsChart;
|
||||
25
src/app/(main)/reports/goals/GoalsParameters.module.css
Normal file
25
src/app/(main)/reports/goals/GoalsParameters.module.css
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
.value {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.eventData {
|
||||
color: var(--orange900);
|
||||
background-color: var(--orange100);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
padding: 2px 8px;
|
||||
border-radius: 5px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.goal {
|
||||
color: var(--blue900);
|
||||
background-color: var(--blue100);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
padding: 2px 8px;
|
||||
border-radius: 5px;
|
||||
width: fit-content;
|
||||
}
|
||||
141
src/app/(main)/reports/goals/GoalsParameters.tsx
Normal file
141
src/app/(main)/reports/goals/GoalsParameters.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { useMessages } from 'components/hooks';
|
||||
import Icons from 'components/icons';
|
||||
import { formatNumber } from 'lib/format';
|
||||
import { useContext } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Flexbox,
|
||||
Form,
|
||||
FormButtons,
|
||||
FormRow,
|
||||
Icon,
|
||||
Popup,
|
||||
PopupTrigger,
|
||||
SubmitButton,
|
||||
} from 'react-basics';
|
||||
import BaseParameters from '../[reportId]/BaseParameters';
|
||||
import ParameterList from '../[reportId]/ParameterList';
|
||||
import PopupForm from '../[reportId]/PopupForm';
|
||||
import { ReportContext } from '../[reportId]/Report';
|
||||
import GoalsAddForm from './GoalsAddForm';
|
||||
import styles from './GoalsParameters.module.css';
|
||||
|
||||
export function GoalsParameters() {
|
||||
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const { id, parameters } = report || {};
|
||||
const { websiteId, dateRange, goals } = parameters || {};
|
||||
const queryDisabled = !websiteId || !dateRange || goals?.length < 1;
|
||||
|
||||
const handleSubmit = (data: any, e: any) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (!queryDisabled) {
|
||||
runReport(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddGoals = (goal: { type: string; value: string }) => {
|
||||
updateReport({ parameters: { goals: parameters.goals.concat(goal) } });
|
||||
};
|
||||
|
||||
const handleUpdateGoals = (
|
||||
close: () => void,
|
||||
index: number,
|
||||
goal: { type: string; value: string },
|
||||
) => {
|
||||
const goals = [...parameters.goals];
|
||||
goals[index] = goal;
|
||||
updateReport({ parameters: { goals } });
|
||||
close();
|
||||
};
|
||||
|
||||
const handleRemoveGoals = (index: number) => {
|
||||
const goals = [...parameters.goals];
|
||||
delete goals[index];
|
||||
updateReport({ parameters: { goals: goals.filter(n => n) } });
|
||||
};
|
||||
|
||||
const AddGoalsButton = () => {
|
||||
return (
|
||||
<PopupTrigger>
|
||||
<Button>
|
||||
<Icon>
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Popup alignment="start">
|
||||
<PopupForm>
|
||||
<GoalsAddForm onChange={handleAddGoals} />
|
||||
</PopupForm>
|
||||
</Popup>
|
||||
</PopupTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
|
||||
<BaseParameters allowWebsiteSelect={!id} />
|
||||
<FormRow label={formatMessage(labels.goals)} action={<AddGoalsButton />}>
|
||||
<ParameterList>
|
||||
{goals.map(
|
||||
(
|
||||
goal: {
|
||||
type: string;
|
||||
value: string;
|
||||
goal: number;
|
||||
operator?: string;
|
||||
property?: string;
|
||||
},
|
||||
index: number,
|
||||
) => {
|
||||
return (
|
||||
<PopupTrigger key={index}>
|
||||
<ParameterList.Item
|
||||
icon={goal.type === 'url' ? <Icons.Eye /> : <Icons.Bolt />}
|
||||
onRemove={() => handleRemoveGoals(index)}
|
||||
>
|
||||
<Flexbox direction="column" gap={5}>
|
||||
<div className={styles.value}>{goal.value}</div>
|
||||
{goal.type === 'event-data' && (
|
||||
<div className={styles.eventData}>
|
||||
{formatMessage(labels[goal.operator])}: {goal.property}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.goal}>
|
||||
{formatMessage(labels.goal)}: {formatNumber(goal.goal)}
|
||||
</div>
|
||||
</Flexbox>
|
||||
</ParameterList.Item>
|
||||
<Popup alignment="start">
|
||||
{(close: () => void) => (
|
||||
<PopupForm>
|
||||
<GoalsAddForm
|
||||
type={goal.type}
|
||||
value={goal.value}
|
||||
goal={goal.goal}
|
||||
operator={goal.operator}
|
||||
property={goal.property}
|
||||
onChange={handleUpdateGoals.bind(null, close, index)}
|
||||
/>
|
||||
</PopupForm>
|
||||
)}
|
||||
</Popup>
|
||||
</PopupTrigger>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</ParameterList>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<SubmitButton variant="primary" disabled={queryDisabled} isLoading={isRunning}>
|
||||
{formatMessage(labels.runQuery)}
|
||||
</SubmitButton>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default GoalsParameters;
|
||||
10
src/app/(main)/reports/goals/GoalsReport.module.css
Normal file
10
src/app/(main)/reports/goals/GoalsReport.module.css
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--base400);
|
||||
border-radius: var(--border-radius);
|
||||
line-height: 32px;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
27
src/app/(main)/reports/goals/GoalsReport.tsx
Normal file
27
src/app/(main)/reports/goals/GoalsReport.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import GoalsChart from './GoalsChart';
|
||||
import GoalsParameters from './GoalsParameters';
|
||||
import Report from '../[reportId]/Report';
|
||||
import ReportHeader from '../[reportId]/ReportHeader';
|
||||
import ReportMenu from '../[reportId]/ReportMenu';
|
||||
import ReportBody from '../[reportId]/ReportBody';
|
||||
import Target from 'assets/target.svg';
|
||||
import { REPORT_TYPES } from 'lib/constants';
|
||||
|
||||
const defaultParameters = {
|
||||
type: REPORT_TYPES.goals,
|
||||
parameters: { goals: [] },
|
||||
};
|
||||
|
||||
export default function GoalsReport({ reportId }: { reportId?: string }) {
|
||||
return (
|
||||
<Report reportId={reportId} defaultParameters={defaultParameters}>
|
||||
<ReportHeader icon={<Target />} />
|
||||
<ReportMenu>
|
||||
<GoalsParameters />
|
||||
</ReportMenu>
|
||||
<ReportBody>
|
||||
<GoalsChart />
|
||||
</ReportBody>
|
||||
</Report>
|
||||
);
|
||||
}
|
||||
6
src/app/(main)/reports/goals/GoalsReportPage.tsx
Normal file
6
src/app/(main)/reports/goals/GoalsReportPage.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
'use client';
|
||||
import GoalReport from './GoalsReport';
|
||||
|
||||
export default function GoalReportPage() {
|
||||
return <GoalReport />;
|
||||
}
|
||||
10
src/app/(main)/reports/goals/page.tsx
Normal file
10
src/app/(main)/reports/goals/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import GoalsReportPage from './GoalsReportPage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default function () {
|
||||
return <GoalsReportPage />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Goals Report',
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@ import { GridTable, GridColumn } from 'react-basics';
|
|||
import { useFormat, useMessages } from 'components/hooks';
|
||||
import { ReportContext } from '../[reportId]/Report';
|
||||
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
|
||||
import { formatShortTime } from 'lib/format';
|
||||
|
||||
export function InsightsTable() {
|
||||
const [fields, setFields] = useState([]);
|
||||
|
|
@ -31,6 +32,12 @@ export function InsightsTable() {
|
|||
</GridColumn>
|
||||
);
|
||||
})}
|
||||
<GridColumn name="views" label={formatMessage(labels.views)} width="100px" alignment="end">
|
||||
{row => row?.views?.toLocaleString()}
|
||||
</GridColumn>
|
||||
<GridColumn name="visits" label={formatMessage(labels.visits)} width="100px" alignment="end">
|
||||
{row => row?.visits?.toLocaleString()}
|
||||
</GridColumn>
|
||||
<GridColumn
|
||||
name="visitors"
|
||||
label={formatMessage(labels.visitors)}
|
||||
|
|
@ -39,8 +46,27 @@ export function InsightsTable() {
|
|||
>
|
||||
{row => row?.visitors?.toLocaleString()}
|
||||
</GridColumn>
|
||||
<GridColumn name="views" label={formatMessage(labels.views)} width="100px" alignment="end">
|
||||
{row => row?.views?.toLocaleString()}
|
||||
<GridColumn
|
||||
name="bounceRate"
|
||||
label={formatMessage(labels.bounceRate)}
|
||||
width="100px"
|
||||
alignment="end"
|
||||
>
|
||||
{row => {
|
||||
const n = (Math.min(row?.visits, row?.bounces) / row?.visits) * 100;
|
||||
return Math.round(+n) + '%';
|
||||
}}
|
||||
</GridColumn>
|
||||
<GridColumn
|
||||
name="visitDuration"
|
||||
label={formatMessage(labels.visitDuration)}
|
||||
width="100px"
|
||||
alignment="end"
|
||||
>
|
||||
{row => {
|
||||
const n = row?.totaltime / row?.visits;
|
||||
return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
|
||||
}}
|
||||
</GridColumn>
|
||||
</GridTable>
|
||||
);
|
||||
|
|
|
|||
63
src/app/(main)/reports/journey/JourneyParameters.tsx
Normal file
63
src/app/(main)/reports/journey/JourneyParameters.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useContext } from 'react';
|
||||
import { useMessages } from 'components/hooks';
|
||||
import {
|
||||
Dropdown,
|
||||
Form,
|
||||
FormButtons,
|
||||
FormInput,
|
||||
FormRow,
|
||||
Item,
|
||||
SubmitButton,
|
||||
TextField,
|
||||
} from 'react-basics';
|
||||
import { ReportContext } from '../[reportId]/Report';
|
||||
import BaseParameters from '../[reportId]/BaseParameters';
|
||||
|
||||
export function JourneyParameters() {
|
||||
const { report, runReport, isRunning } = useContext(ReportContext);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const { id, parameters } = report || {};
|
||||
const { websiteId, dateRange, steps } = parameters || {};
|
||||
const queryDisabled = !websiteId || !dateRange || !steps;
|
||||
|
||||
const handleSubmit = (data: any, e: any) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (!queryDisabled) {
|
||||
runReport(data);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
|
||||
<BaseParameters showDateSelect={true} allowWebsiteSelect={!id} />
|
||||
<FormRow label={formatMessage(labels.steps)}>
|
||||
<FormInput
|
||||
name="steps"
|
||||
rules={{ required: formatMessage(labels.required), pattern: /[0-9]+/, min: 3, max: 7 }}
|
||||
>
|
||||
<Dropdown items={[3, 4, 5, 6, 7]}>{item => <Item key={item}>{item}</Item>}</Dropdown>
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.startStep)}>
|
||||
<FormInput name="startStep">
|
||||
<TextField autoComplete="off" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.endStep)}>
|
||||
<FormInput name="endStep">
|
||||
<TextField autoComplete="off" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<SubmitButton variant="primary" disabled={queryDisabled} isLoading={isRunning}>
|
||||
{formatMessage(labels.runQuery)}
|
||||
</SubmitButton>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default JourneyParameters;
|
||||
28
src/app/(main)/reports/journey/JourneyReport.tsx
Normal file
28
src/app/(main)/reports/journey/JourneyReport.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
'use client';
|
||||
import Report from '../[reportId]/Report';
|
||||
import ReportHeader from '../[reportId]/ReportHeader';
|
||||
import ReportMenu from '../[reportId]/ReportMenu';
|
||||
import ReportBody from '../[reportId]/ReportBody';
|
||||
import JourneyParameters from './JourneyParameters';
|
||||
import JourneyView from './JourneyView';
|
||||
import Path from 'assets/path.svg';
|
||||
import { REPORT_TYPES } from 'lib/constants';
|
||||
|
||||
const defaultParameters = {
|
||||
type: REPORT_TYPES.journey,
|
||||
parameters: { steps: 5 },
|
||||
};
|
||||
|
||||
export default function JourneyReport({ reportId }: { reportId?: string }) {
|
||||
return (
|
||||
<Report reportId={reportId} defaultParameters={defaultParameters}>
|
||||
<ReportHeader icon={<Path />} />
|
||||
<ReportMenu>
|
||||
<JourneyParameters />
|
||||
</ReportMenu>
|
||||
<ReportBody>
|
||||
<JourneyView />
|
||||
</ReportBody>
|
||||
</Report>
|
||||
);
|
||||
}
|
||||
5
src/app/(main)/reports/journey/JourneyReportPage.tsx
Normal file
5
src/app/(main)/reports/journey/JourneyReportPage.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import JourneyReport from './JourneyReport';
|
||||
|
||||
export default function JourneyReportPage() {
|
||||
return <JourneyReport />;
|
||||
}
|
||||
274
src/app/(main)/reports/journey/JourneyView.module.css
Normal file
274
src/app/(main)/reports/journey/JourneyView.module.css
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
--journey-line-color: var(--base600);
|
||||
--journey-active-color: var(--primary400);
|
||||
--journey-faded-color: var(--base300);
|
||||
}
|
||||
|
||||
.view {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
overflow: auto;
|
||||
gap: 100px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.visitors {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.dropoff {
|
||||
font-weight: 600;
|
||||
color: var(--blue800);
|
||||
background: var(--blue100);
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.num {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 100%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--base100);
|
||||
background: var(--base800);
|
||||
z-index: 1;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nodes {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.node {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 10px 20px;
|
||||
background: var(--base75);
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 300px;
|
||||
max-width: 300px;
|
||||
height: 60px;
|
||||
max-height: 60px;
|
||||
}
|
||||
|
||||
.node:hover:not(.selected) {
|
||||
color: var(--base900);
|
||||
background: var(--base100);
|
||||
}
|
||||
|
||||
.node.selected {
|
||||
color: var(--base75);
|
||||
background: var(--base900);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.node.active {
|
||||
color: var(--light50);
|
||||
background: var(--primary400);
|
||||
}
|
||||
|
||||
.node.selected .count {
|
||||
color: var(--base50);
|
||||
background: var(--base800);
|
||||
}
|
||||
|
||||
.node.selected.active .count {
|
||||
background: var(--primary600);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.count {
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
background: var(--base200);
|
||||
}
|
||||
|
||||
.line {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: -100px;
|
||||
width: 100px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.line.up {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.line.down {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.segment {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.start {
|
||||
left: 0;
|
||||
width: 50px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.mid {
|
||||
top: 60px;
|
||||
width: 50px;
|
||||
border-right: 3px solid var(--journey-line-color);
|
||||
}
|
||||
|
||||
.end {
|
||||
width: 50px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.up .start {
|
||||
top: 30px;
|
||||
border-top-right-radius: 100%;
|
||||
border-top: 3px solid var(--journey-line-color);
|
||||
border-right: 3px solid var(--journey-line-color);
|
||||
}
|
||||
|
||||
.up .end {
|
||||
width: 52px;
|
||||
bottom: 27px;
|
||||
right: 0;
|
||||
border-bottom-left-radius: 100%;
|
||||
border-bottom: 3px solid var(--journey-line-color);
|
||||
border-left: 3px solid var(--journey-line-color);
|
||||
}
|
||||
|
||||
.down .start {
|
||||
bottom: 27px;
|
||||
border-bottom-right-radius: 100%;
|
||||
border-bottom: 3px solid var(--journey-line-color);
|
||||
border-right: 3px solid var(--journey-line-color);
|
||||
}
|
||||
|
||||
.down .end {
|
||||
width: 52px;
|
||||
top: 30px;
|
||||
right: 0;
|
||||
border-top-left-radius: 100%;
|
||||
border-top: 3px solid var(--journey-line-color);
|
||||
border-left: 3px solid var(--journey-line-color);
|
||||
}
|
||||
|
||||
.flat .start {
|
||||
left: 0;
|
||||
top: 30px;
|
||||
border-top: 3px solid var(--journey-line-color);
|
||||
}
|
||||
|
||||
.flat .end {
|
||||
right: 0;
|
||||
top: 30px;
|
||||
border-top: 3px solid var(--journey-line-color);
|
||||
}
|
||||
|
||||
.start:before,
|
||||
.end:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 100%;
|
||||
border: 3px solid var(--journey-line-color);
|
||||
background: var(--light50);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.line:not(.active) .start:before,
|
||||
.line:not(.active) .end:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.up .start:before {
|
||||
left: -8px;
|
||||
top: -8px;
|
||||
}
|
||||
|
||||
.up .end:before {
|
||||
right: -8px;
|
||||
bottom: -8px;
|
||||
}
|
||||
|
||||
.down .start:before {
|
||||
left: -8px;
|
||||
bottom: -8px;
|
||||
}
|
||||
|
||||
.down .end:before {
|
||||
right: -8px;
|
||||
top: -8px;
|
||||
}
|
||||
|
||||
.flat .start:before {
|
||||
left: -8px;
|
||||
top: -8px;
|
||||
}
|
||||
|
||||
.flat .end:before {
|
||||
right: -8px;
|
||||
top: -8px;
|
||||
}
|
||||
|
||||
.line.active .segment,
|
||||
.line.active .segment:before {
|
||||
border-color: var(--journey-active-color);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.column.active .line:not(.active) .segment {
|
||||
border-color: var(--journey-faded-color);
|
||||
}
|
||||
|
||||
.column.active .line:not(.active) .segment:before {
|
||||
display: none;
|
||||
}
|
||||
251
src/app/(main)/reports/journey/JourneyView.tsx
Normal file
251
src/app/(main)/reports/journey/JourneyView.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { useContext, useMemo, useState } from 'react';
|
||||
import { TooltipPopup } from 'react-basics';
|
||||
import { firstBy } from 'thenby';
|
||||
import classNames from 'classnames';
|
||||
import { useEscapeKey, useMessages } from 'components/hooks';
|
||||
import { objectToArray } from 'lib/data';
|
||||
import { ReportContext } from '../[reportId]/Report';
|
||||
import styles from './JourneyView.module.css';
|
||||
import { formatLongNumber } from 'lib/format';
|
||||
|
||||
const NODE_HEIGHT = 60;
|
||||
const NODE_GAP = 10;
|
||||
const LINE_WIDTH = 3;
|
||||
|
||||
export default function JourneyView() {
|
||||
const [selectedNode, setSelectedNode] = useState(null);
|
||||
const [activeNode, setActiveNode] = useState(null);
|
||||
const { report } = useContext(ReportContext);
|
||||
const { data, parameters } = report || {};
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
useEscapeKey(() => setSelectedNode(null));
|
||||
|
||||
const columns = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const selectedPaths = selectedNode?.paths ?? [];
|
||||
const activePaths = activeNode?.paths ?? [];
|
||||
const columns = [];
|
||||
|
||||
for (let columnIndex = 0; columnIndex < +parameters.steps; columnIndex++) {
|
||||
const nodes = {};
|
||||
|
||||
data.forEach(({ items, count }: any, nodeIndex: any) => {
|
||||
const name = items[columnIndex];
|
||||
|
||||
if (name) {
|
||||
const selected = !!selectedPaths.find(({ items }) => items[columnIndex] === name);
|
||||
const active = selected && !!activePaths.find(({ items }) => items[columnIndex] === name);
|
||||
|
||||
if (!nodes[name]) {
|
||||
const paths = data.filter(({ items }) => items[columnIndex] === name);
|
||||
|
||||
nodes[name] = {
|
||||
name,
|
||||
count,
|
||||
totalCount: count,
|
||||
nodeIndex,
|
||||
columnIndex,
|
||||
selected,
|
||||
active,
|
||||
paths,
|
||||
pathMap: paths.map(({ items, count }) => ({
|
||||
[`${columnIndex}:${items.join(':')}`]: count,
|
||||
})),
|
||||
};
|
||||
} else {
|
||||
nodes[name].totalCount += count;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
columns.push({
|
||||
nodes: objectToArray(nodes).sort(firstBy('total', -1)),
|
||||
});
|
||||
}
|
||||
|
||||
columns.forEach((column, columnIndex) => {
|
||||
const nodes = column.nodes.map((currentNode, currentNodeIndex) => {
|
||||
const previousNodes = columns[columnIndex - 1]?.nodes;
|
||||
let selectedCount = previousNodes ? 0 : currentNode.totalCount;
|
||||
let activeCount = selectedCount;
|
||||
|
||||
const lines =
|
||||
previousNodes?.reduce((arr: any[][], previousNode: any, previousNodeIndex: number) => {
|
||||
const fromCount = selectedNode?.paths.reduce((sum, path) => {
|
||||
if (
|
||||
previousNode.name === path.items[columnIndex - 1] &&
|
||||
currentNode.name === path.items[columnIndex]
|
||||
) {
|
||||
sum += path.count;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
if (currentNode.selected && previousNode.selected && fromCount) {
|
||||
arr.push([previousNodeIndex, currentNodeIndex]);
|
||||
selectedCount += fromCount;
|
||||
|
||||
if (previousNode.active) {
|
||||
activeCount += fromCount;
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, []) || [];
|
||||
|
||||
return { ...currentNode, selectedCount, activeCount, lines };
|
||||
});
|
||||
|
||||
const visitorCount = nodes.reduce(
|
||||
(sum: number, { selected, selectedCount, active, activeCount, totalCount }) => {
|
||||
if (!selectedNode) {
|
||||
sum += totalCount;
|
||||
} else if (!activeNode && selectedNode && selected) {
|
||||
sum += selectedCount;
|
||||
} else if (activeNode && active) {
|
||||
sum += activeCount;
|
||||
}
|
||||
return sum;
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
const previousTotal = columns[columnIndex - 1]?.visitorCount ?? 0;
|
||||
const dropOff =
|
||||
previousTotal > 0 ? ((visitorCount - previousTotal) / previousTotal) * 100 : 0;
|
||||
|
||||
Object.assign(column, { nodes, visitorCount, dropOff });
|
||||
});
|
||||
|
||||
return columns;
|
||||
}, [data, selectedNode, activeNode]);
|
||||
|
||||
const handleClick = (name: string, columnIndex: number, paths: any[]) => {
|
||||
if (name !== selectedNode?.name || columnIndex !== selectedNode?.columnIndex) {
|
||||
setSelectedNode({ name, columnIndex, paths });
|
||||
} else {
|
||||
setSelectedNode(null);
|
||||
}
|
||||
setActiveNode(null);
|
||||
};
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.view}>
|
||||
{columns.map((column, columnIndex) => {
|
||||
const dropOffPercent = `${~~column.dropOff}%`;
|
||||
return (
|
||||
<div
|
||||
key={columnIndex}
|
||||
className={classNames(styles.column, {
|
||||
[styles.selected]: selectedNode,
|
||||
[styles.active]: activeNode,
|
||||
})}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.num}>{columnIndex + 1}</div>
|
||||
<div className={styles.stats}>
|
||||
<div className={styles.visitors} title={column.visitorCount}>
|
||||
{formatLongNumber(column.visitorCount)} {formatMessage(labels.visitors)}
|
||||
</div>
|
||||
{columnIndex > 0 && <div className={styles.dropoff}>{dropOffPercent}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.nodes}>
|
||||
{column.nodes.map(
|
||||
({
|
||||
name,
|
||||
totalCount,
|
||||
selected,
|
||||
active,
|
||||
paths,
|
||||
activeCount,
|
||||
selectedCount,
|
||||
lines,
|
||||
}) => {
|
||||
const nodeCount = selected
|
||||
? active
|
||||
? activeCount
|
||||
: selectedCount
|
||||
: totalCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={styles.wrapper}
|
||||
onMouseEnter={() => selected && setActiveNode({ name, columnIndex, paths })}
|
||||
onMouseLeave={() => selected && setActiveNode(null)}
|
||||
>
|
||||
<div
|
||||
className={classNames(styles.node, {
|
||||
[styles.selected]: selected,
|
||||
[styles.active]: active,
|
||||
})}
|
||||
onClick={() => handleClick(name, columnIndex, paths)}
|
||||
>
|
||||
<div className={styles.name}>{name}</div>
|
||||
<TooltipPopup label={dropOffPercent} disabled={!selected}>
|
||||
<div className={styles.count} title={nodeCount}>
|
||||
{formatLongNumber(nodeCount)}
|
||||
</div>
|
||||
</TooltipPopup>
|
||||
{columnIndex < columns.length &&
|
||||
lines.map(([fromIndex, nodeIndex], i) => {
|
||||
const height =
|
||||
(Math.abs(nodeIndex - fromIndex) + 1) * (NODE_HEIGHT + NODE_GAP) -
|
||||
NODE_GAP;
|
||||
const midHeight =
|
||||
(Math.abs(nodeIndex - fromIndex) - 1) * (NODE_HEIGHT + NODE_GAP) +
|
||||
NODE_GAP +
|
||||
LINE_WIDTH;
|
||||
const nodeName = columns[columnIndex - 1]?.nodes[fromIndex].name;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${fromIndex}${nodeIndex}${i}`}
|
||||
className={classNames(styles.line, {
|
||||
[styles.active]:
|
||||
active &&
|
||||
activeNode?.paths.find(
|
||||
path =>
|
||||
path.items[columnIndex] === name &&
|
||||
path.items[columnIndex - 1] === nodeName,
|
||||
),
|
||||
[styles.up]: fromIndex < nodeIndex,
|
||||
[styles.down]: fromIndex > nodeIndex,
|
||||
[styles.flat]: fromIndex === nodeIndex,
|
||||
})}
|
||||
style={{ height }}
|
||||
>
|
||||
<div className={classNames(styles.segment, styles.start)} />
|
||||
<div
|
||||
className={classNames(styles.segment, styles.mid)}
|
||||
style={{
|
||||
height: midHeight,
|
||||
}}
|
||||
/>
|
||||
<div className={classNames(styles.segment, styles.end)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/app/(main)/reports/journey/page.tsx
Normal file
10
src/app/(main)/reports/journey/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Metadata } from 'next';
|
||||
import JourneyReportPage from './JourneyReportPage';
|
||||
|
||||
export default function () {
|
||||
return <JourneyReportPage />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Journey Report',
|
||||
};
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import ReportsPage from './ReportsPage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default function ({ params: { teamId } }: { params: { teamId: string } }) {
|
||||
return <ReportsPage teamId={teamId} />;
|
||||
export default function () {
|
||||
return <ReportsPage />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export default function UTMView() {
|
|||
{
|
||||
data: items.map(({ value }) => value),
|
||||
backgroundColor: CHART_COLORS,
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,5 +6,5 @@ export default function () {
|
|||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'UTM Report',
|
||||
title: 'Goals Report',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,17 +1,24 @@
|
|||
import DataTable from 'components/common/DataTable';
|
||||
import TeamsTable from 'app/(main)/settings/teams/TeamsTable';
|
||||
import { useLogin, useTeams } from 'components/hooks';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export function TeamsDataTable({
|
||||
allowEdit,
|
||||
showActions,
|
||||
children,
|
||||
}: {
|
||||
allowEdit?: boolean;
|
||||
showActions?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const { user } = useLogin();
|
||||
const queryResult = useTeams(user.id);
|
||||
|
||||
if (queryResult?.result?.data?.length === 0) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable queryResult={queryResult}>
|
||||
{({ data }) => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
import DataTable from 'components/common/DataTable';
|
||||
import { useUsers } from 'components/hooks';
|
||||
import UsersTable from './UsersTable';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export function UsersDataTable({ showActions }: { showActions?: boolean }) {
|
||||
export function UsersDataTable({
|
||||
showActions,
|
||||
children,
|
||||
}: {
|
||||
showActions?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const queryResult = useUsers();
|
||||
|
||||
if (queryResult?.result?.data?.length === 0) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable queryResult={queryResult}>
|
||||
{({ data }) => <UsersTable data={data} showActions={showActions} />}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ export function WebsitesDataTable({
|
|||
}) {
|
||||
const queryResult = useWebsites({ teamId });
|
||||
|
||||
if (queryResult?.result?.data?.length === 0) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable queryResult={queryResult}>
|
||||
{({ data }) => (
|
||||
|
|
@ -27,9 +31,7 @@ export function WebsitesDataTable({
|
|||
showActions={showActions}
|
||||
allowEdit={allowEdit}
|
||||
allowView={allowView}
|
||||
>
|
||||
{children}
|
||||
</WebsitesTable>
|
||||
/>
|
||||
)}
|
||||
</DataTable>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ export function WebsitesTable({
|
|||
const breakpoint = useBreakpoint();
|
||||
const { renderTeamUrl } = useTeamUrl();
|
||||
|
||||
if (!data?.length) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}>
|
||||
<GridColumn name="name" label={formatMessage(labels.name)} />
|
||||
|
|
@ -55,7 +59,6 @@ export function WebsitesTable({
|
|||
}}
|
||||
</GridColumn>
|
||||
)}
|
||||
{children}
|
||||
</GridTable>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,7 @@ import { WebsiteContext } from 'app/(main)/websites/[websiteId]/WebsiteProvider'
|
|||
|
||||
const generateId = () => getRandomChars(16);
|
||||
|
||||
export function ShareUrl({
|
||||
hostUrl,
|
||||
onSave,
|
||||
}: {
|
||||
websiteId: string;
|
||||
hostUrl?: string;
|
||||
onSave?: () => void;
|
||||
}) {
|
||||
export function ShareUrl({ hostUrl, onSave }: { hostUrl?: string; onSave?: () => void }) {
|
||||
const website = useContext(WebsiteContext);
|
||||
const { domain, shareId } = website;
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
|
|
@ -33,8 +26,8 @@ export function ShareUrl({
|
|||
});
|
||||
const { touch } = useModified();
|
||||
|
||||
const url = `${hostUrl || process.env.hostUrl || window?.location.origin}${
|
||||
process.env.basePath
|
||||
const url = `${hostUrl || window?.location.origin || ''}${
|
||||
process.env.basePath || ''
|
||||
}/share/${id}/${domain}`;
|
||||
|
||||
const handleGenerate = () => {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ export function TrackingCode({ websiteId, hostUrl }: { websiteId: string; hostUr
|
|||
|
||||
const url = trackerScriptName?.startsWith('http')
|
||||
? trackerScriptName
|
||||
: `${hostUrl || process.env.hostUrl || window?.location.origin}${
|
||||
process.env.basePath
|
||||
: `${hostUrl || window?.location.origin || ''}${
|
||||
process.env.basePath || ''
|
||||
}/${trackerScriptName}`;
|
||||
|
||||
const code = `<script defer src="${url}" data-website-id="${websiteId}"></script>`;
|
||||
|
|
|
|||
|
|
@ -13,11 +13,9 @@ import WebsiteEditForm from './WebsiteEditForm';
|
|||
|
||||
export function WebsiteSettings({
|
||||
websiteId,
|
||||
hostUrl,
|
||||
openExternal = false,
|
||||
}: {
|
||||
websiteId: string;
|
||||
hostUrl?: string;
|
||||
openExternal?: boolean;
|
||||
}) {
|
||||
const website = useContext(WebsiteContext);
|
||||
|
|
@ -62,8 +60,8 @@ export function WebsiteSettings({
|
|||
<Item key="data">{formatMessage(labels.data)}</Item>
|
||||
</Tabs>
|
||||
{tab === 'details' && <WebsiteEditForm websiteId={websiteId} onSave={handleSave} />}
|
||||
{tab === 'tracking' && <TrackingCode websiteId={websiteId} hostUrl={hostUrl} />}
|
||||
{tab === 'share' && <ShareUrl websiteId={websiteId} hostUrl={hostUrl} onSave={handleSave} />}
|
||||
{tab === 'tracking' && <TrackingCode websiteId={websiteId} />}
|
||||
{tab === 'share' && <ShareUrl onSave={handleSave} />}
|
||||
{tab === 'data' && <WebsiteData websiteId={websiteId} onSave={handleSave} />}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/dashboard/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/reports/[reportId]/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/reports/create/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/reports/event-data/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/reports/funnel/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/reports/insights/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import Page from 'app/(main)/reports/page';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default Page;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Team Reports',
|
||||
};
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/reports/retention/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/reports/utm/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
'use client';
|
||||
import { ReactNode } from 'react';
|
||||
import MenuLayout from 'components/layout/MenuLayout';
|
||||
import { useMessages } from 'components/hooks';
|
||||
|
||||
export default function ({ children, teamId }: { children: ReactNode; teamId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: 'team',
|
||||
label: formatMessage(labels.team),
|
||||
url: `/teams/${teamId}/settings/team`,
|
||||
},
|
||||
{
|
||||
key: 'websites',
|
||||
label: formatMessage(labels.websites),
|
||||
url: `/teams/${teamId}/settings/websites`,
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
label: formatMessage(labels.members),
|
||||
url: `/teams/${teamId}/settings/members`,
|
||||
},
|
||||
].filter(n => n);
|
||||
|
||||
return <MenuLayout items={items}>{children}</MenuLayout>;
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import TeamSettingsLayout from './TeamSettingsLayout';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default function ({ children, params: { teamId } }) {
|
||||
return <TeamSettingsLayout teamId={teamId}>{children}</TeamSettingsLayout>;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Team Settings',
|
||||
};
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/settings/teams/[teamId]/members/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/settings/teams/[teamId]/team/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/settings/websites/[websiteId]/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/settings/teams/[teamId]/websites/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/websites/[websiteId]/event-data/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/websites/[websiteId]/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/websites/[websiteId]/realtime/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/websites/[websiteId]/reports/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Page from 'app/(main)/websites/page';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
37
src/app/(main)/websites/[websiteId]/WebsiteDetailsPage.tsx
Normal file
37
src/app/(main)/websites/[websiteId]/WebsiteDetailsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ import styles from './WebsiteExpandedView.module.css';
|
|||
|
||||
const views = {
|
||||
url: PagesTable,
|
||||
entry: PagesTable,
|
||||
exit: PagesTable,
|
||||
title: PagesTable,
|
||||
referrer: ReferrersTable,
|
||||
host: HostsTable,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
.container {
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
width: 200px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--base800);
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
|
@ -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;
|
||||
10
src/app/(main)/websites/[websiteId]/compare/page.tsx
Normal file
10
src/app/(main)/websites/[websiteId]/compare/page.tsx
Normal 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',
|
||||
};
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue