Merge branch 'dev' into search-formatted-metrics

This commit is contained in:
Mike Cao 2024-11-28 16:36:29 -08:00 committed by GitHub
commit 4ab8b1ff91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
807 changed files with 45367 additions and 8474 deletions

View file

@ -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`} />
)}
</>
);

View file

@ -10,6 +10,8 @@ import TeamsButton from 'components/input/TeamsButton';
import Icons from 'components/icons';
import { useMessages, useNavigation, useTeamUrl } from 'components/hooks';
import styles from './NavBar.module.css';
import { useEffect } from 'react';
import { getItem, setItem } from 'next-basics';
export function NavBar() {
const { formatMessage, labels } = useMessages();
@ -74,10 +76,24 @@ export function NavBar() {
const handleTeamChange = (teamId: string) => {
const url = teamId ? `/teams/${teamId}` : '/';
if (!cloudMode) {
setItem('umami.team', { id: teamId });
}
router.push(cloudMode ? `${process.env.cloudUrl}${url}` : url);
};
useEffect(() => {
if (!cloudMode) {
const teamIdLocal = getItem('umami.team')?.id;
if (teamIdLocal && teamIdLocal !== teamId) {
router.push(
pathname !== '/' && pathname !== '/dashboard' ? '/' : `/teams/${teamIdLocal}/dashboard`,
);
}
}
}, [cloudMode]);
return (
<div className={styles.navbar}>
<div className={styles.logo}>

View file

@ -21,14 +21,19 @@ export function TestConsole({ websiteId }: { websiteId: string }) {
router.push(`/console/${value}`);
}
function handleClick() {
window['umami'].track({ url: '/page-view', referrer: 'https://www.google.com' });
function handleRunScript() {
window['umami'].track(props => ({
...props,
url: '/page-view',
referrer: 'https://www.google.com',
}));
window['umami'].track('track-event-no-data');
window['umami'].track('track-event-with-data', {
test: 'test-data',
boolean: true,
booleanError: 'true',
time: new Date(),
user: `user${Math.round(Math.random() * 10)}`,
number: 1,
number2: Math.random() * 100,
time2: new Date().toISOString(),
@ -43,7 +48,47 @@ export function TestConsole({ websiteId }: { websiteId: string }) {
});
}
function handleIdentifyClick() {
function handleRunRevenue() {
window['umami'].track(props => ({
...props,
url: '/checkout-cart',
referrer: 'https://www.google.com',
}));
window['umami'].track('checkout-cart', {
revenue: parseFloat((Math.random() * 1000).toFixed(2)),
currency: 'USD',
});
window['umami'].track('affiliate-link', {
revenue: parseFloat((Math.random() * 1000).toFixed(2)),
currency: 'USD',
});
window['umami'].track('promotion-link', {
revenue: parseFloat((Math.random() * 1000).toFixed(2)),
currency: 'USD',
});
window['umami'].track('checkout-cart', {
revenue: parseFloat((Math.random() * 1000).toFixed(2)),
currency: 'EUR',
});
window['umami'].track('promotion-link', {
revenue: parseFloat((Math.random() * 1000).toFixed(2)),
currency: 'EUR',
});
window['umami'].track('affiliate-link', {
item1: {
productIdentity: 'ABC424',
revenue: parseFloat((Math.random() * 10000).toFixed(2)),
currency: 'JPY',
},
item2: {
productIdentity: 'ZYW684',
revenue: parseFloat((Math.random() * 10000).toFixed(2)),
currency: 'JPY',
},
});
}
function handleRunIdentify() {
window['umami'].identify({
userId: 123,
name: 'brian',
@ -80,7 +125,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}>
@ -122,10 +167,19 @@ export function TestConsole({ websiteId }: { websiteId: string }) {
>
Send event with data
</Button>
<Button
id="generate-revenue-button"
data-umami-event="checkout-cart"
data-umami-event-revenue={(Math.random() * 10000).toFixed(2).toString()}
data-umami-event-currency="USD"
variant="primary"
>
Generate revenue data
</Button>
<Button
id="button-with-div-button"
data-umami-event="button-click"
data-umami-event-name="bob"
data-umami-event-name={'bob'}
data-umami-event-id="123"
variant="primary"
>
@ -144,12 +198,15 @@ export function TestConsole({ websiteId }: { websiteId: string }) {
</div>
<div className={styles.group}>
<div className={styles.header}>Javascript events</div>
<Button id="manual-button" variant="primary" onClick={handleClick}>
<Button id="manual-button" variant="primary" onClick={handleRunScript}>
Run script
</Button>
<Button id="manual-button" variant="primary" onClick={handleIdentifyClick}>
<Button id="manual-button" variant="primary" onClick={handleRunIdentify}>
Run identify
</Button>
<Button id="manual-button" variant="primary" onClick={handleRunRevenue}>
Revenue script
</Button>
</div>
</div>
<WebsiteChart websiteId={website.id} />

View file

@ -5,7 +5,9 @@ async function getEnabled() {
return !!process.env.ENABLE_TEST_CONSOLE;
}
export default async function ({ params: { websiteId } }) {
export default async function ({ params }: { params: { websiteId: string } }) {
const { websiteId } = await params;
const enabled = await getEnabled();
if (!enabled) {

View file

@ -1,34 +1,34 @@
.buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-bottom: 20px;
}
.item {
padding: 5px 0;
}
.item h1 {
font-weight: 600;
font-size: 16px;
}
.item h2 {
font-size: 14px;
color: var(--base700);
}
.text {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 20px;
border-radius: 5px;
border: 1px solid var(--base400);
background: var(--base50);
margin-bottom: 10px;
}
.active .text {
border-color: var(--base600);
box-shadow: 4px 4px 4px var(--base100);
.text {
position: relative;
}
.name {
font-weight: 600;
font-size: 16px;
}
.domain {
font-size: 14px;
color: var(--base700);
}
.dragActive {
@ -38,3 +38,20 @@
.dragActive:active {
cursor: grabbing;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
gap: 20px;
}
.search {
max-width: 360px;
}
.active {
border-color: var(--base600);
box-shadow: 4px 4px 4px var(--base100);
}

View file

@ -1,7 +1,7 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames';
import { Button, Loading } from 'react-basics';
import { Button, Loading, Toggle, SearchField } from 'react-basics';
import { firstBy } from 'thenby';
import useDashboard, { saveDashboard } from 'store/dashboard';
import { useMessages, useWebsites } from 'components/hooks';
@ -11,20 +11,39 @@ const DRAG_ID = 'dashboard-website-ordering';
export function DashboardEdit({ teamId }: { teamId: string }) {
const settings = useDashboard();
const { websiteOrder } = settings;
const { websiteOrder, websiteActive, isEdited } = settings;
const { formatMessage, labels } = useMessages();
const [order, setOrder] = useState(websiteOrder || []);
const [active, setActive] = useState(websiteActive || []);
const [edited, setEdited] = useState(isEdited);
const [websites, setWebsites] = useState([]);
const [search, setSearch] = useState('');
const {
result,
query: { isLoading },
setParams,
} = useWebsites({ teamId });
const websites = result?.data;
useEffect(() => {
if (result?.data) {
setWebsites(prevWebsites => {
const newWebsites = [...prevWebsites, ...result.data];
if (newWebsites.length < result.count) {
setParams(prevParams => ({ ...prevParams, page: prevParams.page + 1 }));
}
return newWebsites;
});
}
}, [result]);
const ordered = useMemo(() => {
if (websites) {
return websites
.map((website: { id: any }) => ({ ...website, order: order.indexOf(website.id) }))
.map((website: { id: any; name: string; domain: string }) => ({
...website,
order: order.indexOf(website.id),
}))
.sort(firstBy('order'));
}
return [];
@ -38,21 +57,33 @@ export function DashboardEdit({ teamId }: { teamId: string }) {
orderedWebsites.splice(destination.index, 0, removed);
setOrder(orderedWebsites.map(website => website?.id || 0));
setEdited(true);
}
function handleActiveWebsites(id: string) {
setActive(prevActive =>
prevActive.includes(id) ? prevActive.filter(a => a !== id) : [...prevActive, id],
);
setEdited(true);
}
function handleSave() {
saveDashboard({
editing: false,
isEdited: edited,
websiteOrder: order,
websiteActive: active,
});
}
function handleCancel() {
saveDashboard({ editing: false, websiteOrder });
saveDashboard({ editing: false, websiteOrder, websiteActive, isEdited });
}
function handleReset() {
setOrder([]);
setActive([]);
setEdited(false);
}
if (isLoading) {
@ -61,16 +92,19 @@ export function DashboardEdit({ teamId }: { teamId: string }) {
return (
<>
<div className={styles.buttons}>
<Button onClick={handleSave} variant="primary" size="sm">
{formatMessage(labels.save)}
</Button>
<Button onClick={handleCancel} size="sm">
{formatMessage(labels.cancel)}
</Button>
<Button onClick={handleReset} size="sm">
{formatMessage(labels.reset)}
</Button>
<div className={styles.header}>
<SearchField className={styles.search} value={search} onSearch={setSearch} />
<div className={styles.buttons}>
<Button onClick={handleSave} variant="primary" size="sm">
{formatMessage(labels.save)}
</Button>
<Button onClick={handleCancel} size="sm">
{formatMessage(labels.cancel)}
</Button>
<Button onClick={handleReset} size="sm">
{formatMessage(labels.reset)}
</Button>
</div>
</div>
<div className={styles.dragActive}>
<DragDropContext onDragEnd={handleWebsiteDrag}>
@ -81,25 +115,38 @@ export function DashboardEdit({ teamId }: { teamId: string }) {
ref={provided.innerRef}
style={{ marginBottom: snapshot.isDraggingOver ? 260 : null }}
>
{ordered.map(({ id, name, domain }, index) => (
<Draggable key={id} draggableId={`${DRAG_ID}-${id}`} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
className={classNames(styles.item, {
[styles.active]: snapshot.isDragging,
})}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div className={styles.text}>
<h1>{name}</h1>
<h2>{domain}</h2>
{ordered.map(({ id, name, domain }, index) => {
if (
search &&
!`${name.toLowerCase()}${domain.toLowerCase()}`.includes(search.toLowerCase())
) {
return null;
}
return (
<Draggable key={id} draggableId={`${DRAG_ID}-${id}`} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
className={classNames(styles.item, {
[styles.active]: snapshot.isDragging,
})}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div className={styles.text}>
<div className={styles.name}>{name}</div>
<div className={styles.domain}>{domain}</div>
</div>
<Toggle
checked={active.includes(id)}
onChange={() => handleActiveWebsites(id)}
/>
</div>
</div>
)}
</Draggable>
))}
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
)}

View file

@ -13,9 +13,9 @@ import LinkButton from 'components/common/LinkButton';
export function DashboardPage() {
const { formatMessage, labels, messages } = useMessages();
const { teamId, renderTeamUrl } = useTeamUrl();
const { showCharts, editing } = useDashboard();
const { showCharts, editing, isEdited } = useDashboard();
const { dir } = useLocale();
const pageSize = 10;
const pageSize = isEdited ? 200 : 10;
const { result, query, params, setParams } = useWebsites({ teamId }, { pageSize });
const { page } = params;

View file

@ -17,17 +17,6 @@
grid-row: 2 / 3;
min-height: 0;
height: calc(100vh - 60px);
height: calc(100dvh - 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);
}

View file

@ -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}>

View file

@ -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}>

View file

@ -1,11 +1,10 @@
import { useState } from 'react';
import { Dropdown, Item, Button, Flexbox } from 'react-basics';
import moment from 'moment-timezone';
import { useTimezone, useMessages } from 'components/hooks';
import { getTimezone } from 'lib/date';
import styles from './TimezoneSetting.module.css';
const timezones = moment.tz.names();
const timezones = Intl.supportedValuesOf('timeZone');
export function TimezoneSetting() {
const [search, setSearch] = useState('');

View file

@ -1,18 +1,21 @@
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 });
return (
<DataTable queryResult={queryResult}>
<DataTable queryResult={queryResult} renderEmpty={() => children}>
{({ data }) => <ReportsTable data={data} showDomain={!websiteId} />}
</DataTable>
);

View file

@ -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 />

View file

@ -1,4 +1,4 @@
import { GridColumn, GridTable, Icon, Icons, Text, useBreakpoint } from 'react-basics';
import { GridColumn, GridTable, Icon, Icons, Text } from 'react-basics';
import LinkButton from 'components/common/LinkButton';
import { useMessages, useLogin, useTeamUrl } from 'components/hooks';
import { REPORT_TYPES } from 'lib/constants';
@ -7,11 +7,10 @@ import ReportDeleteButton from './ReportDeleteButton';
export function ReportsTable({ data = [], showDomain }: { data: any[]; showDomain?: boolean }) {
const { formatMessage, labels } = useMessages();
const { user } = useLogin();
const breakpoint = useBreakpoint();
const { renderTeamUrl } = useTeamUrl();
return (
<GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}>
<GridTable data={data}>
<GridColumn name="name" label={formatMessage(labels.name)} />
<GridColumn name="description" label={formatMessage(labels.description)} />
<GridColumn name="type" label={formatMessage(labels.type)}>

View file

@ -0,0 +1,3 @@
.dropdown div {
max-height: 300px;
}

View file

@ -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}

View file

@ -1,22 +1,22 @@
import { useState, useMemo } from 'react';
import {
Form,
FormRow,
Item,
Flexbox,
Dropdown,
Button,
SearchField,
TextField,
Text,
Icon,
Icons,
Menu,
Loading,
} from 'react-basics';
import { useMessages, useFilters, useFormat, useLocale, useWebsiteValues } from 'components/hooks';
import { useFilters, useFormat, useLocale, useMessages, useWebsiteValues } from 'components/hooks';
import { OPERATORS } from 'lib/constants';
import { isEqualsOperator } from 'lib/params';
import { useMemo, useState } from 'react';
import {
Button,
Dropdown,
Flexbox,
Form,
FormRow,
Icon,
Icons,
Item,
Loading,
Menu,
SearchField,
Text,
TextField,
} from 'react-basics';
import styles from './FieldFilterEditForm.module.css';
export interface FieldFilterFormProps {
@ -69,6 +69,16 @@ export default function FieldFilterEditForm({
search,
});
const filterDropdownItems = (name: string) => {
const limitedFilters = ['country', 'region', 'city'];
if (limitedFilters.includes(name)) {
return filters.filter(f => f.type === type && !f.label.match(/contain/gi));
} else {
return filters.filter(f => f.type === type);
}
};
const formattedValues = useMemo(() => {
if (!values) {
return {};
@ -142,7 +152,7 @@ export default function FieldFilterEditForm({
{allowFilterSelect && (
<Dropdown
className={styles.dropdown}
items={filters.filter(f => f.type === type)}
items={filterDropdownItems(name)}
value={operator}
renderValue={renderFilterValue}
onChange={handleOperatorChange}

View file

@ -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;
}

View file

@ -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>

View file

@ -3,4 +3,5 @@
grid-template-rows: max-content 1fr;
grid-template-columns: max-content 1fr;
margin-bottom: 60px;
height: 90vh;
}

View file

@ -1,5 +1,5 @@
.body {
padding-inline-start: 20px;
grid-row: 2/3;
grid-row: 2 / 3;
grid-column: 2 / 3;
}

View file

@ -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);

View file

@ -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)],

View file

@ -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;
}

View file

@ -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;

View file

@ -1,10 +1,13 @@
'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';
import RevenueReport from '../revenue/RevenueReport';
const reports = {
funnel: FunnelReport,
@ -12,6 +15,9 @@ const reports = {
insights: InsightsReport,
retention: RetentionReport,
utm: UTMReport,
goals: GoalReport,
journey: JourneyReport,
revenue: RevenueReport,
};
export default function ReportPage({ reportId }: { reportId: string }) {

View file

@ -1,7 +1,9 @@
import { Metadata } from 'next';
import ReportPage from './ReportPage';
export default function ({ params: { reportId } }) {
export default async function ({ params }: { params: { reportId: string } }) {
const { reportId } = await params;
return <ReportPage reportId={reportId} />;
}

View file

@ -1,12 +1,15 @@
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 Money from 'assets/money.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 +40,24 @@ 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 />,
},
{
title: formatMessage(labels.revenue),
description: formatMessage(labels.revenueDescription),
url: renderTeamUrl('/reports/revenue'),
icon: <Money />,
},
];
return (

View file

@ -48,7 +48,7 @@ export function EventDataParameters() {
groups,
};
const handleSubmit = values => {
const handleSubmit = (values: any) => {
runReport(values);
};

View file

@ -34,6 +34,10 @@
background-color: var(--base100);
}
.step:last-child::before {
display: none;
}
.card {
display: grid;
gap: 20px;

View file

@ -5,10 +5,6 @@
width: 100%;
}
.type {
color: var(--base700);
}
.value {
display: flex;
align-self: center;

View file

@ -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>

View file

@ -0,0 +1,7 @@
.dropdown {
width: 140px;
}
.input {
width: 200px;
}

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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;
}

View 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>
);
}

View file

@ -0,0 +1,6 @@
'use client';
import GoalReport from './GoalsReport';
export default function GoalReportPage() {
return <GoalReport />;
}

View 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',
};

View file

@ -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>
);

View 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;

View 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>
);
}

View file

@ -0,0 +1,5 @@
import JourneyReport from './JourneyReport';
export default function JourneyReportPage() {
return <JourneyReport />;
}

View 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 {
max-width: 200px;
}
.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;
}

View file

@ -0,0 +1,253 @@
import { useContext, useMemo, useState } from 'react';
import { TextOverflow, 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} title={name}>
<TextOverflow> {name}</TextOverflow>
</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>
);
}

View 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',
};

View file

@ -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 = {

View file

@ -52,19 +52,19 @@ export function RetentionTable({ days = DAYS }) {
{rows.map(({ date, visitors, records }, rowIndex) => {
return (
<div key={rowIndex} className={styles.row}>
<div className={styles.date}>{formatDate(`${date} 00:00:00`, 'PP', locale)}</div>
<div className={styles.date}>{formatDate(date, 'PP', locale)}</div>
<div className={styles.visitors}>{visitors}</div>
{days.map(day => {
if (totalDays - rowIndex < day) {
return null;
}
const percentage = records[day]?.percentage;
const percentage = records.filter(a => a.day === day)[0]?.percentage;
return (
<div
key={day}
className={classNames(styles.cell, { [styles.empty]: !percentage })}
>
{percentage ? `${percentage.toFixed(2)}%` : ''}
{percentage ? `${Number(percentage).toFixed(2)}%` : ''}
</div>
);
})}

View file

@ -0,0 +1,46 @@
import { useMessages } from 'components/hooks';
import useRevenueValues from 'components/hooks/queries/useRevenueValues';
import { useContext } from 'react';
import { Dropdown, Form, FormButtons, FormInput, FormRow, Item, SubmitButton } from 'react-basics';
import BaseParameters from '../[reportId]/BaseParameters';
import { ReportContext } from '../[reportId]/Report';
export function RevenueParameters() {
const { report, runReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {};
const { websiteId, dateRange } = parameters || {};
const queryEnabled = websiteId && dateRange;
const { data: values = [] } = useRevenueValues(
websiteId,
dateRange?.startDate,
dateRange?.endDate,
);
const handleSubmit = (data: any, e: any) => {
e.stopPropagation();
e.preventDefault();
runReport(data);
};
return (
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
<BaseParameters showDateSelect={true} allowWebsiteSelect={!id} />
<FormRow label={formatMessage(labels.currency)}>
<FormInput name="currency" rules={{ required: formatMessage(labels.required) }}>
<Dropdown items={values.map(item => item.currency)}>
{item => <Item key={item}>{item}</Item>}
</Dropdown>
</FormInput>
</FormRow>
<FormButtons>
<SubmitButton variant="primary" disabled={!queryEnabled} isLoading={isRunning}>
{formatMessage(labels.runQuery)}
</SubmitButton>
</FormButtons>
</Form>
);
}
export default RevenueParameters;

View file

@ -0,0 +1,27 @@
import Money from 'assets/money.svg';
import { REPORT_TYPES } from 'lib/constants';
import Report from '../[reportId]/Report';
import ReportBody from '../[reportId]/ReportBody';
import ReportHeader from '../[reportId]/ReportHeader';
import ReportMenu from '../[reportId]/ReportMenu';
import RevenueParameters from './RevenueParameters';
import RevenueView from './RevenueView';
const defaultParameters = {
type: REPORT_TYPES.revenue,
parameters: {},
};
export default function RevenueReport({ reportId }: { reportId?: string }) {
return (
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Money />} />
<ReportMenu>
<RevenueParameters />
</ReportMenu>
<ReportBody>
<RevenueView />
</ReportBody>
</Report>
);
}

View file

@ -0,0 +1,6 @@
'use client';
import RevenueReport from './RevenueReport';
export default function RevenueReportPage() {
return <RevenueReport />;
}

View file

@ -0,0 +1,38 @@
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
import { useMessages } from 'components/hooks';
import { useContext } from 'react';
import { GridColumn, GridTable } from 'react-basics';
import { ReportContext } from '../[reportId]/Report';
import { formatLongCurrency } from 'lib/format';
export function RevenueTable() {
const { report } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { data } = report || {};
if (!data) {
return <EmptyPlaceholder />;
}
return (
<GridTable data={data.table || []}>
<GridColumn name="currency" label={formatMessage(labels.currency)} alignment="end">
{row => row.currency}
</GridColumn>
<GridColumn name="currency" label={formatMessage(labels.total)} width="300px" alignment="end">
{row => formatLongCurrency(row.sum, row.currency)}
</GridColumn>
<GridColumn name="currency" label={formatMessage(labels.average)} alignment="end">
{row => formatLongCurrency(row.count ? row.sum / row.count : 0, row.currency)}
</GridColumn>
<GridColumn name="currency" label={formatMessage(labels.transactions)} alignment="end">
{row => row.count}
</GridColumn>
<GridColumn name="currency" label={formatMessage(labels.uniqueCustomers)} alignment="end">
{row => row.unique_count}
</GridColumn>
</GridTable>
);
}
export default RevenueTable;

View file

@ -0,0 +1,11 @@
.container {
display: grid;
gap: 20px;
margin-bottom: 40px;
}
.row {
display: flex;
align-items: center;
gap: 10px;
}

View file

@ -0,0 +1,156 @@
import classNames from 'classnames';
import { colord } from 'colord';
import BarChart from 'components/charts/BarChart';
import PieChart from 'components/charts/PieChart';
import TypeIcon from 'components/common/TypeIcon';
import { useCountryNames, useLocale, useMessages } from 'components/hooks';
import { GridRow } from 'components/layout/Grid';
import ListTable from 'components/metrics/ListTable';
import MetricCard from 'components/metrics/MetricCard';
import MetricsBar from 'components/metrics/MetricsBar';
import { renderDateLabels } from 'lib/charts';
import { CHART_COLORS } from 'lib/constants';
import { formatLongCurrency, formatLongNumber } from 'lib/format';
import { useCallback, useContext, useMemo } from 'react';
import { ReportContext } from '../[reportId]/Report';
import RevenueTable from './RevenueTable';
import styles from './RevenueView.module.css';
export interface RevenueViewProps {
isLoading?: boolean;
}
export function RevenueView({ isLoading }: RevenueViewProps) {
const { formatMessage, labels } = useMessages();
const { locale } = useLocale();
const { countryNames } = useCountryNames(locale);
const { report } = useContext(ReportContext);
const {
data,
parameters: { dateRange, currency },
} = report || {};
const showTable = data?.table.length > 1;
const renderCountryName = useCallback(
({ x: code }) => (
<span className={classNames(locale, styles.row)}>
<TypeIcon type="country" value={code?.toLowerCase()} />
{countryNames[code]}
</span>
),
[countryNames, locale],
);
const chartData = useMemo(() => {
if (!data) return [];
const map = (data.chart as any[]).reduce((obj, { x, t, y }) => {
if (!obj[x]) {
obj[x] = [];
}
obj[x].push({ x: t, y });
return obj;
}, {});
return {
datasets: Object.keys(map).map((key, index) => {
const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
return {
label: key,
data: map[key],
lineTension: 0,
backgroundColor: color.alpha(0.6).toRgbString(),
borderColor: color.alpha(0.7).toRgbString(),
borderWidth: 1,
};
}),
};
}, [data]);
const countryData = useMemo(() => {
if (!data) return [];
const labels = data.country.map(({ name }) => name);
const datasets = [
{
data: data.country.map(({ value }) => value),
backgroundColor: CHART_COLORS,
borderWidth: 0,
},
];
return { labels, datasets };
}, [data]);
const metricData = useMemo(() => {
if (!data) return [];
const { sum, count, unique_count } = data.total;
return [
{
value: sum,
label: formatMessage(labels.total),
formatValue: n => formatLongCurrency(n, currency),
},
{
value: count ? sum / count : 0,
label: formatMessage(labels.average),
formatValue: n => formatLongCurrency(n, currency),
},
{
value: count,
label: formatMessage(labels.transactions),
formatValue: formatLongNumber,
},
{
value: unique_count,
label: formatMessage(labels.uniqueCustomers),
formatValue: formatLongNumber,
},
] as any;
}, [data, locale]);
return (
<>
<div className={styles.container}>
<MetricsBar isFetched={data}>
{metricData?.map(({ label, value, formatValue }) => {
return <MetricCard key={label} value={value} label={label} formatValue={formatValue} />;
})}
</MetricsBar>
{data && (
<>
<BarChart
minDate={dateRange?.startDate}
maxDate={dateRange?.endDate}
data={chartData}
unit={dateRange?.unit}
stacked={true}
currency={currency}
renderXLabel={renderDateLabels(dateRange?.unit, locale)}
isLoading={isLoading}
/>
<GridRow columns="two">
<ListTable
metric={formatMessage(labels.country)}
data={data?.country.map(({ name, value }) => ({
x: name,
y: Number(value),
z: (value / data?.total.sum) * 100,
}))}
renderLabel={renderCountryName}
/>
<PieChart type="doughnut" data={countryData} />
</GridRow>
</>
)}
{showTable && <RevenueTable />}
</div>
</>
);
}
export default RevenueView;

View file

@ -0,0 +1,10 @@
import RevenueReportPage from './RevenueReportPage';
import { Metadata } from 'next';
export default function () {
return <RevenueReportPage />;
}
export const metadata: Metadata = {
title: 'Revenue Report',
};

View file

@ -34,6 +34,7 @@ export default function UTMView() {
{
data: items.map(({ value }) => value),
backgroundColor: CHART_COLORS,
borderWidth: 0,
},
],
};

View file

@ -6,5 +6,5 @@ export default function () {
}
export const metadata: Metadata = {
title: 'UTM Report',
title: 'Goals Report',
};

View file

@ -1,19 +1,22 @@
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);
return (
<DataTable queryResult={queryResult}>
<DataTable queryResult={queryResult} renderEmpty={() => children}>
{({ data }) => {
return <TeamsTable data={data} allowEdit={allowEdit} showActions={showActions} />;
}}

View file

@ -1,4 +1,4 @@
import { GridColumn, GridTable, Icon, Text, useBreakpoint } from 'react-basics';
import { GridColumn, GridTable, Icon, Text } from 'react-basics';
import { useMessages } from 'components/hooks';
import Icons from 'components/icons';
import { ROLES } from 'lib/constants';
@ -13,10 +13,9 @@ export function TeamsTable({
showActions?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const breakpoint = useBreakpoint();
return (
<GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}>
<GridTable data={data}>
<GridColumn name="name" label={formatMessage(labels.name)} />
<GridColumn name="owner" label={formatMessage(labels.owner)}>
{row => row.teamUser.find(({ role }) => role === ROLES.teamOwner)?.user?.username}

View file

@ -1,10 +0,0 @@
import TeamMembersPage from './TeamMembersPage';
import { Metadata } from 'next';
export default function ({ params: { teamId } }) {
return <TeamMembersPage teamId={teamId} />;
}
export const metadata: Metadata = {
title: 'Team Members',
};

View file

@ -1,10 +0,0 @@
import { Metadata } from 'next';
import TeamPage from './TeamPage';
export default function ({ params: { teamId } }) {
return <TeamPage teamId={teamId} />;
}
export const metadata: Metadata = {
title: 'Teams Details',
};

View file

@ -1,10 +0,0 @@
import TeamWebsitesPage from './TeamWebsitesPage';
import { Metadata } from 'next';
export default function ({ params: { teamId } }) {
return <TeamWebsitesPage teamId={teamId} />;
}
export const metadata: Metadata = {
title: 'Teams Websites',
};

View file

@ -1,12 +1,19 @@
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();
return (
<DataTable queryResult={queryResult}>
<DataTable queryResult={queryResult} renderEmpty={() => children}>
{({ data }) => <UsersTable data={data} showActions={showActions} />}
</DataTable>
);

View file

@ -1,4 +1,4 @@
import { Text, Icon, Icons, GridTable, GridColumn, useBreakpoint } from 'react-basics';
import { Text, Icon, Icons, GridTable, GridColumn } from 'react-basics';
import { formatDistance } from 'date-fns';
import { ROLES } from 'lib/constants';
import { useMessages, useLocale } from 'components/hooks';
@ -14,10 +14,9 @@ export function UsersTable({
}) {
const { formatMessage, labels } = useMessages();
const { dateLocale } = useLocale();
const breakpoint = useBreakpoint();
return (
<GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}>
<GridTable data={data}>
<GridColumn name="username" label={formatMessage(labels.username)} style={{ minWidth: 0 }} />
<GridColumn name="role" label={formatMessage(labels.role)} width={'120px'}>
{row =>

View file

@ -1,7 +1,9 @@
import UserPage from './UserPage';
import { Metadata } from 'next';
export default function ({ params: { userId } }) {
export default async function ({ params }: { params: { userId: string } }) {
const { userId } = await params;
return <UserPage userId={userId} />;
}

View file

@ -19,7 +19,7 @@ export function WebsitesDataTable({
const queryResult = useWebsites({ teamId });
return (
<DataTable queryResult={queryResult}>
<DataTable queryResult={queryResult} renderEmpty={() => children}>
{({ data }) => (
<WebsitesTable
teamId={teamId}
@ -27,9 +27,7 @@ export function WebsitesDataTable({
showActions={showActions}
allowEdit={allowEdit}
allowView={allowView}
>
{children}
</WebsitesTable>
/>
)}
</DataTable>
);

View file

@ -1,5 +1,5 @@
import { ReactNode } from 'react';
import { Text, Icon, Icons, GridTable, GridColumn, useBreakpoint } from 'react-basics';
import { Text, Icon, Icons, GridTable, GridColumn } from 'react-basics';
import { useMessages, useTeamUrl } from 'components/hooks';
import LinkButton from 'components/common/LinkButton';
@ -20,11 +20,14 @@ export function WebsitesTable({
children,
}: WebsitesTableProps) {
const { formatMessage, labels } = useMessages();
const breakpoint = useBreakpoint();
const { renderTeamUrl } = useTeamUrl();
if (!data?.length) {
return children;
}
return (
<GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}>
<GridTable data={data}>
<GridColumn name="name" label={formatMessage(labels.name)} />
<GridColumn name="domain" label={formatMessage(labels.domain)} />
{showActions && (
@ -55,7 +58,6 @@ export function WebsitesTable({
}}
</GridColumn>
)}
{children}
</GridTable>
);
}

View file

@ -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 = () => {

View file

@ -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>`;

View file

@ -13,11 +13,18 @@ export function WebsiteData({ websiteId, onSave }: { websiteId: string; onSave?:
const { teamId, renderTeamUrl } = useTeamUrl();
const router = useRouter();
const { result } = useTeams(user.id);
const hasTeams = result?.data?.length > 0;
const isTeamOwner =
(!teamId && hasTeams) ||
(hasTeams &&
result?.data
const canTransferWebsite =
(
!teamId &&
result.data.filter(({ teamUser }) =>
teamUser.find(
({ role, userId }) =>
[ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id,
),
)
).length > 0 ||
(teamId &&
!!result?.data
?.find(({ id }) => id === teamId)
?.teamUser.find(({ role, userId }) => role === ROLES.teamOwner && userId === user.id));
@ -37,8 +44,8 @@ export function WebsiteData({ websiteId, onSave }: { websiteId: string; onSave?:
label={formatMessage(labels.transferWebsite)}
description={formatMessage(messages.transferWebsite)}
>
<ModalTrigger disabled={!isTeamOwner}>
<Button variant="secondary" disabled={!isTeamOwner}>
<ModalTrigger disabled={!canTransferWebsite}>
<Button variant="secondary" disabled={!canTransferWebsite}>
{formatMessage(labels.transfer)}
</Button>
<Modal title={formatMessage(labels.transferWebsite)}>

View file

@ -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} />}
</>
);

View file

@ -71,7 +71,7 @@ export function WebsiteTransferForm({
{result.data
.filter(({ teamUser }) =>
teamUser.find(
({ role, userId }) => role === ROLES.teamOwner && userId === user.id,
({ role, userId }) => [ ROLES.teamOwner, ROLES.teamManager ].includes(role) && userId === user.id,
),
)
.map(({ id, name }) => {

View file

@ -1,7 +1,9 @@
import WebsiteSettingsPage from './WebsiteSettingsPage';
import { Metadata } from 'next';
export default async function ({ params: { websiteId } }) {
export default async function ({ params }: { params: { websiteId: string } }) {
const { websiteId } = await params;
return <WebsiteSettingsPage websiteId={websiteId} />;
}

View file

@ -1,7 +1,9 @@
import { Metadata } from 'next';
import WebsitesSettingsPage from './WebsitesSettingsPage';
export default function ({ params: { teamId } }: { params: { teamId: string } }) {
export default async function ({ params }: { params: { teamId: string } }) {
const { teamId } = await params;
return <WebsitesSettingsPage teamId={teamId} />;
}

View file

@ -1,3 +0,0 @@
import Page from 'app/(main)/dashboard/page';
export default Page;

View file

@ -1,8 +1,21 @@
import TeamProvider from './TeamProvider';
import { Metadata } from 'next';
import TeamSettingsLayout from './settings/TeamSettingsLayout';
export default function ({ children, params: { teamId } }) {
return <TeamProvider teamId={teamId}>{children}</TeamProvider>;
export default async function ({
children,
params,
}: {
children: any;
params: { teamId: string };
}) {
const { teamId } = await params;
return (
<TeamProvider teamId={teamId}>
<TeamSettingsLayout>{children}</TeamSettingsLayout>
</TeamProvider>
);
}
export const metadata: Metadata = {

View file

@ -1,3 +0,0 @@
import Page from 'app/(main)/reports/[reportId]/page';
export default Page;

View file

@ -1,3 +0,0 @@
import Page from 'app/(main)/reports/create/page';
export default Page;

View file

@ -1,3 +0,0 @@
import Page from 'app/(main)/reports/event-data/page';
export default Page;

View file

@ -1,3 +0,0 @@
import Page from 'app/(main)/reports/funnel/page';
export default Page;

View file

@ -1,3 +0,0 @@
import Page from 'app/(main)/reports/insights/page';
export default Page;

View file

@ -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',
};

View file

@ -1,3 +0,0 @@
import Page from 'app/(main)/reports/retention/page';
export default Page;

View file

@ -1,10 +1,11 @@
'use client';
import { ReactNode } from 'react';
import { useMessages, useTeamUrl } from 'components/hooks';
import MenuLayout from 'components/layout/MenuLayout';
import { useMessages } from 'components/hooks';
export default function ({ children, teamId }: { children: ReactNode; teamId: string }) {
export default function TeamSettingsLayout({ children }: { children: ReactNode }) {
const { formatMessage, labels } = useMessages();
const { teamId } = useTeamUrl();
const items = [
{

View file

@ -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',
};

View file

@ -40,6 +40,9 @@ export function TeamMemberEditForm({
};
const renderValue = (value: string) => {
if (value === ROLES.teamManager) {
return formatMessage(labels.manager);
}
if (value === ROLES.teamMember) {
return formatMessage(labels.member);
}
@ -58,6 +61,7 @@ export function TeamMemberEditForm({
minWidth: '250px',
}}
>
<Item key={ROLES.teamManager}>{formatMessage(labels.manager)}</Item>
<Item key={ROLES.teamMember}>{formatMessage(labels.member)}</Item>
<Item key={ROLES.teamViewOnly}>{formatMessage(labels.viewOnly)}</Item>
</Dropdown>

View file

@ -12,8 +12,10 @@ export function TeamMembersPage({ teamId }: { teamId: string }) {
const { formatMessage, labels } = useMessages();
const canEdit =
team?.teamUser?.find(({ userId, role }) => role === ROLES.teamOwner && userId === user.id) &&
user.role !== ROLES.viewOnly;
team?.teamUser?.find(
({ userId, role }) =>
(role === ROLES.teamOwner || role === ROLES.teamManager) && userId === user.id,
) && user.role !== ROLES.viewOnly;
return (
<>

View file

@ -1,4 +1,4 @@
import { GridColumn, GridTable, useBreakpoint } from 'react-basics';
import { GridColumn, GridTable } from 'react-basics';
import { useMessages, useLogin } from 'components/hooks';
import { ROLES } from 'lib/constants';
import TeamMemberRemoveButton from './TeamMemberRemoveButton';
@ -15,16 +15,16 @@ export function TeamMembersTable({
}) {
const { formatMessage, labels } = useMessages();
const { user } = useLogin();
const breakpoint = useBreakpoint();
const roles = {
[ROLES.teamOwner]: formatMessage(labels.teamOwner),
[ROLES.teamManager]: formatMessage(labels.teamManager),
[ROLES.teamMember]: formatMessage(labels.teamMember),
[ROLES.teamViewOnly]: formatMessage(labels.viewOnly),
};
return (
<GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}>
<GridTable data={data}>
<GridColumn name="username" label={formatMessage(labels.username)}>
{row => row?.user?.username}
</GridColumn>

View file

@ -1,3 +1,12 @@
import Page from 'app/(main)/settings/teams/[teamId]/members/page';
import { Metadata } from 'next';
import TeamMembersPage from './TeamMembersPage';
export default Page;
export default async function ({ params }: { params: { teamId: string } }) {
const { teamId } = await params;
return <TeamMembersPage teamId={teamId} />;
}
export const metadata: Metadata = {
title: 'Team Members',
};

View file

@ -5,7 +5,7 @@ import PageHeader from 'components/layout/PageHeader';
import { ROLES } from 'lib/constants';
import { useContext, useState } from 'react';
import { Flexbox, Item, Tabs } from 'react-basics';
import TeamLeaveButton from '../../TeamLeaveButton';
import TeamLeaveButton from 'app/(main)/settings/teams/TeamLeaveButton';
import TeamManage from './TeamManage';
import TeamEditForm from './TeamEditForm';
@ -15,18 +15,24 @@ export function TeamDetails({ teamId }: { teamId: string }) {
const { user } = useLogin();
const [tab, setTab] = useState('details');
const canEdit =
const isTeamOwner =
!!team?.teamUser?.find(({ userId, role }) => role === ROLES.teamOwner && userId === user.id) &&
user.role !== ROLES.viewOnly;
const canEdit =
!!team?.teamUser?.find(
({ userId, role }) =>
(role === ROLES.teamOwner || role === ROLES.teamManager) && userId === user.id,
) && user.role !== ROLES.viewOnly;
return (
<Flexbox direction="column">
<PageHeader title={team?.name} icon={<Icons.Users />}>
{!canEdit && <TeamLeaveButton teamId={team.id} teamName={team.name} />}
{!isTeamOwner && <TeamLeaveButton teamId={team.id} teamName={team.name} />}
</PageHeader>
<Tabs selectedKey={tab} onSelect={(value: any) => setTab(value)} style={{ marginBottom: 30 }}>
<Item key="details">{formatMessage(labels.details)}</Item>
{canEdit && <Item key="manage">{formatMessage(labels.manage)}</Item>}
{isTeamOwner && <Item key="manage">{formatMessage(labels.manage)}</Item>}
</Tabs>
{tab === 'details' && <TeamEditForm teamId={teamId} allowEdit={canEdit} />}
{tab === 'manage' && <TeamManage teamId={teamId} />}

View file

@ -11,7 +11,7 @@ import {
} from 'react-basics';
import { getRandomChars } from 'next-basics';
import { useContext, useRef, useState } from 'react';
import { useApi, useMessages } from 'components/hooks';
import { useApi, useMessages, useModified } from 'components/hooks';
import { TeamContext } from 'app/(main)/teams/[teamId]/TeamProvider';
const generateId = () => getRandomChars(16);
@ -26,12 +26,14 @@ export function TeamEditForm({ teamId, allowEdit }: { teamId: string; allowEdit?
const ref = useRef(null);
const [accessCode, setAccessCode] = useState(team.accessCode);
const { showToast } = useToasts();
const { touch } = useModified();
const cloudMode = !!process.env.cloudMode;
const handleSubmit = async (data: any) => {
mutate(data, {
onSuccess: async () => {
ref.current.reset(data);
touch('teams');
showToast({ message: formatMessage(messages.saved), variant: 'success' });
},
});

Some files were not shown because too many files have changed in this diff Show more