UI for new funnel report.

This commit is contained in:
Mike Cao 2024-04-02 23:30:12 -07:00
parent 30a1cdd53c
commit cfe7089916
13 changed files with 179 additions and 125 deletions

View File

@ -13,7 +13,7 @@ export function Report({
className,
}: {
reportId: string;
defaultParameters: { [key: string]: any };
defaultParameters: { type: string; parameters: { [key: string]: any } };
children: ReactNode;
className?: string;
}) {

View File

@ -51,7 +51,7 @@
align-items: center;
justify-content: flex-end;
background: var(--base900);
height: 50px;
height: 30px;
border-radius: 5px;
overflow: hidden;
position: relative;
@ -61,19 +61,12 @@
color: var(--base700);
}
.value {
color: var(--base50);
margin-inline-end: 20px;
}
.track {
background-color: var(--base100);
border-radius: 5px;
}
.info {
display: flex;
justify-content: space-between;
text-transform: lowercase;
}
@ -82,3 +75,24 @@
border-radius: 4px;
border: 1px solid var(--base300);
}
.metric {
color: var(--base700);
display: flex;
justify-content: space-between;
gap: 10px;
font-size: 24px;
margin: 10px 0;
text-transform: lowercase;
}
.visitors {
color: var(--base900);
font-weight: 900;
margin-right: 10px;
}
.percent {
font-weight: 700;
align-self: flex-end;
}

View File

@ -2,8 +2,8 @@ import { useContext } from 'react';
import classNames from 'classnames';
import { useMessages } from 'components/hooks';
import { ReportContext } from '../[reportId]/Report';
import styles from './FunnelChart.module.css';
import { formatLongNumber } from 'lib/format';
import styles from './FunnelChart.module.css';
export interface FunnelChartProps {
className?: string;
@ -18,37 +18,35 @@ export function FunnelChart({ className }: FunnelChartProps) {
return (
<div className={classNames(styles.chart, className)}>
{data?.map(({ url, visitors, dropped, dropoff, remaining }, index: number) => {
{data?.map(({ type, value, visitors, dropped, dropoff, remaining }, index: number) => {
return (
<div key={url} className={styles.step}>
<div key={index} className={styles.step}>
<div className={styles.num}>{index + 1}</div>
<div className={styles.card}>
<div className={styles.header}>
<span className={styles.label}>{formatMessage(labels.viewedPage)}:</span>
<span className={styles.item}>{url}</span>
<span className={styles.label}>
{formatMessage(type === 'url' ? labels.viewedPage : labels.triggeredEvent)}:
</span>
<span className={styles.item}>{value}</span>
</div>
<div className={styles.metric}>
<div>
<span className={styles.visitors}>{formatLongNumber(visitors)}</span>
{formatMessage(labels.visitors)}
</div>
<div className={styles.percent}>{(remaining * 100).toFixed(2)}%</div>
</div>
<div className={styles.track}>
<div className={styles.bar} style={{ width: `${remaining * 100}%` }}>
<span className={styles.value}>
{remaining > 0.1 && `${(remaining * 100).toFixed(2)}%`}
</span>
</div>
</div>
<div className={styles.info}>
<div>
<b>{formatLongNumber(visitors)}</b>
<span> {formatMessage(labels.visitors)}</span>
<span> ({(remaining * 100).toFixed(2)}%)</span>
<div className={styles.bar} style={{ width: `${remaining * 100}%` }}></div>
</div>
{dropoff > 0 && (
<div>
<div className={styles.info}>
<b>{formatLongNumber(dropped)}</b> {formatMessage(labels.visitorsDroppedOff)} (
{(dropoff * 100).toFixed(2)}%)
</div>
)}
</div>
</div>
</div>
);
})}
</div>

View File

@ -0,0 +1,9 @@
.item {
display: flex;
align-items: center;
gap: 10px;
}
.type {
color: var(--base700);
}

View File

@ -10,48 +10,54 @@ import {
Popup,
SubmitButton,
TextField,
Button,
} from 'react-basics';
import Icons from 'components/icons';
import UrlAddForm from './UrlAddForm';
import FunnelStepAddForm from './FunnelStepAddForm';
import { ReportContext } from '../[reportId]/Report';
import BaseParameters from '../[reportId]/BaseParameters';
import ParameterList from '../[reportId]/ParameterList';
import PopupForm from '../[reportId]/PopupForm';
import styles from './FunnelParameters.module.css';
export function FunnelParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {};
const { websiteId, dateRange, urls } = parameters || {};
const queryDisabled = !websiteId || !dateRange || urls?.length < 2;
const { websiteId, dateRange, steps } = parameters || {};
const queryDisabled = !websiteId || !dateRange || steps?.length < 2;
const handleSubmit = (data: any, e: any) => {
e.stopPropagation();
e.preventDefault();
if (!queryDisabled) {
runReport(data);
}
};
const handleAddUrl = (url: string) => {
updateReport({ parameters: { urls: parameters.urls.concat(url) } });
const handleAddStep = (step: { type: string; value: string }) => {
updateReport({ parameters: { steps: parameters.steps.concat(step) } });
};
const handleRemoveUrl = (url: string) => {
const urls = [...parameters.urls];
updateReport({ parameters: { urls: urls.filter(n => n !== url) } });
const handleRemoveStep = (index: number) => {
const steps = [...parameters.steps];
delete steps[index];
updateReport({ parameters: { steps: steps.filter(n => n) } });
};
const AddUrlButton = () => {
const AddStepButton = () => {
return (
<PopupTrigger>
<Button>
<Icon>
<Icons.Plus />
</Icon>
<Popup position="right" alignment="start">
</Button>
<Popup alignment="start">
<PopupForm>
<UrlAddForm onAdd={handleAddUrl} />
<FunnelStepAddForm onAdd={handleAddStep} />
</PopupForm>
</Popup>
</PopupTrigger>
@ -69,12 +75,17 @@ export function FunnelParameters() {
<TextField autoComplete="off" />
</FormInput>
</FormRow>
<FormRow label={formatMessage(labels.urls)} action={<AddUrlButton />}>
<FormRow label={formatMessage(labels.steps)} action={<AddStepButton />}>
<ParameterList>
{urls.map(url => {
{steps.map((step: { type: string; value: string }, index: number) => {
return (
<ParameterList.Item key={url} onRemove={() => handleRemoveUrl(url)}>
{url}
<ParameterList.Item key={index} onRemove={() => handleRemoveStep(index)}>
<div className={styles.item}>
<div className={styles.type}>
<Icon>{step.type === 'url' ? <Icons.Eye /> : <Icons.Bolt />}</Icon>
</div>
<div>{step.value}</div>
</div>
</ParameterList.Item>
);
})}

View File

@ -9,7 +9,7 @@ import { REPORT_TYPES } from 'lib/constants';
const defaultParameters = {
type: REPORT_TYPES.funnel,
parameters: { window: 60, urls: [] },
parameters: { window: 60, steps: [] },
};
export default function FunnelReport({ reportId }: { reportId?: string }) {

View File

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

View File

@ -0,0 +1,71 @@
import { useState } from 'react';
import { useMessages } from 'components/hooks';
import { Button, FormRow, TextField, Flexbox, Dropdown, Item } from 'react-basics';
import styles from './FunnelStepAddForm.module.css';
export interface UrlAddFormProps {
defaultValue?: string;
onAdd?: (step: { type: string; value: string }) => void;
}
export function FunnelStepAddForm({ defaultValue = '', onAdd }: UrlAddFormProps) {
const [type, setType] = useState('url');
const [value, setValue] = useState(defaultValue);
const { formatMessage, labels } = useMessages();
const items = [
{ label: formatMessage(labels.url), value: 'url' },
{ label: formatMessage(labels.event), value: 'event' },
];
const isDisabled = !type || !value;
const handleSave = () => {
onAdd({ type, value });
setValue('');
};
const handleChange = e => {
setValue(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;
};
return (
<FormRow label={formatMessage(labels.addStep)}>
<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={handleChange}
autoFocus={true}
autoComplete="off"
onKeyDown={handleKeyDown}
/>
<Button variant="primary" onClick={handleSave} disabled={isDisabled}>
{formatMessage(labels.add)}
</Button>
</Flexbox>
</FormRow>
);
}
export default FunnelStepAddForm;

View File

@ -1,14 +0,0 @@
.form {
position: absolute;
background: var(--base50);
width: 300px;
padding: 30px;
margin-top: 10px;
border: 1px solid var(--base400);
border-radius: var(--border-radius);
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1);
}
.input {
width: 100%;
}

View File

@ -1,52 +0,0 @@
import { useState } from 'react';
import { useMessages } from 'components/hooks';
import { Button, Form, FormRow, TextField, Flexbox } from 'react-basics';
import styles from './UrlAddForm.module.css';
export interface UrlAddFormProps {
defaultValue?: string;
onAdd?: (url: string) => void;
}
export function UrlAddForm({ defaultValue = '', onAdd }: UrlAddFormProps) {
const [url, setUrl] = useState(defaultValue);
const { formatMessage, labels } = useMessages();
const handleSave = () => {
onAdd(url);
setUrl('');
};
const handleChange = e => {
setUrl(e.target.value);
};
const handleKeyDown = e => {
if (e.key === 'Enter') {
e.stopPropagation();
handleSave();
}
};
return (
<Form>
<FormRow label={formatMessage(labels.url)}>
<Flexbox gap={10}>
<TextField
className={styles.input}
value={url}
onChange={handleChange}
autoFocus={true}
autoComplete="off"
onKeyDown={handleKeyDown}
/>
<Button variant="primary" onClick={handleSave}>
{formatMessage(labels.add)}
</Button>
</Flexbox>
</FormRow>
</Form>
);
}
export default UrlAddForm;

View File

@ -4,7 +4,10 @@ import { useApi } from './useApi';
import { useTimezone } from '../useTimezone';
import { useMessages } from '../useMessages';
export function useReport(reportId: string, defaultParameters: { [key: string]: any } = {}) {
export function useReport(
reportId: string,
defaultParameters: { type: string; parameters: { [key: string]: any } },
) {
const [report, setReport] = useState(null);
const [isRunning, setIsRunning] = useState(false);
const { get, post } = useApi();
@ -28,6 +31,8 @@ export function useReport(reportId: string, defaultParameters: { [key: string]:
dateRange.endDate = new Date(endDate);
}
data.parameters = { ...defaultParameters?.parameters, ...data.parameters };
setReport(data);
};
@ -41,7 +46,7 @@ export function useReport(reportId: string, defaultParameters: { [key: string]:
setReport(
produce((state: any) => {
state.parameters = parameters;
state.parameters = { ...defaultParameters?.parameters, ...parameters };
state.data = data;
return state;
@ -60,7 +65,11 @@ export function useReport(reportId: string, defaultParameters: { [key: string]:
const { parameters, ...rest } = data;
if (parameters) {
state.parameters = { ...state.parameters, ...parameters };
state.parameters = {
...defaultParameters?.parameters,
...state.parameters,
...parameters,
};
}
for (const key in rest) {
@ -80,7 +89,7 @@ export function useReport(reportId: string, defaultParameters: { [key: string]:
} else {
loadReport(reportId);
}
}, []);
}, [reportId]);
return { report, runReport, updateReport, isRunning };
}

View File

@ -232,6 +232,8 @@ export const labels = defineMessages({
id: 'label.utm-description',
defaultMessage: 'Track your campaigns through UTM parameters.',
},
steps: { id: 'label.steps', defaultMessage: 'Steps' },
addStep: { id: 'label.add-step', defaultMessage: 'Add step' },
});
export const messages = defineMessages({

View File

@ -3,8 +3,7 @@ import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
import prisma from 'lib/prisma';
const formatResults = (steps: { type: string; value: string }[]) => (results: unknown) => {
return steps.map((steps: { type: string; value: string }, i: number) => {
const url = steps.value;
return steps.map((step: { type: string; value: string }, i: number) => {
const visitors = Number(results[i]?.count) || 0;
const previous = Number(results[i - 1]?.count) || 0;
const dropped = previous > 0 ? previous - visitors : 0;
@ -12,7 +11,7 @@ const formatResults = (steps: { type: string; value: string }[]) => (results: un
const remaining = visitors / Number(results[0].count);
return {
url,
...step,
visitors,
previous,
dropped,
@ -49,7 +48,7 @@ async function relationalQuery(
},
): Promise<
{
url: string;
value: string;
visitors: number;
dropoff: number;
}[]
@ -141,7 +140,7 @@ async function clickhouseQuery(
},
): Promise<
{
url: string;
value: string;
visitors: number;
dropoff: number;
}[]