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, className,
}: { }: {
reportId: string; reportId: string;
defaultParameters: { [key: string]: any }; defaultParameters: { type: string; parameters: { [key: string]: any } };
children: ReactNode; children: ReactNode;
className?: string; className?: string;
}) { }) {

View File

@ -51,7 +51,7 @@
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
background: var(--base900); background: var(--base900);
height: 50px; height: 30px;
border-radius: 5px; border-radius: 5px;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
@ -61,19 +61,12 @@
color: var(--base700); color: var(--base700);
} }
.value {
color: var(--base50);
margin-inline-end: 20px;
}
.track { .track {
background-color: var(--base100); background-color: var(--base100);
border-radius: 5px; border-radius: 5px;
} }
.info { .info {
display: flex;
justify-content: space-between;
text-transform: lowercase; text-transform: lowercase;
} }
@ -82,3 +75,24 @@
border-radius: 4px; border-radius: 4px;
border: 1px solid var(--base300); 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 classNames from 'classnames';
import { useMessages } from 'components/hooks'; import { useMessages } from 'components/hooks';
import { ReportContext } from '../[reportId]/Report'; import { ReportContext } from '../[reportId]/Report';
import styles from './FunnelChart.module.css';
import { formatLongNumber } from 'lib/format'; import { formatLongNumber } from 'lib/format';
import styles from './FunnelChart.module.css';
export interface FunnelChartProps { export interface FunnelChartProps {
className?: string; className?: string;
@ -18,35 +18,33 @@ export function FunnelChart({ className }: FunnelChartProps) {
return ( return (
<div className={classNames(styles.chart, className)}> <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 ( return (
<div key={url} className={styles.step}> <div key={index} className={styles.step}>
<div className={styles.num}>{index + 1}</div> <div className={styles.num}>{index + 1}</div>
<div className={styles.card}> <div className={styles.card}>
<div className={styles.header}> <div className={styles.header}>
<span className={styles.label}>{formatMessage(labels.viewedPage)}:</span> <span className={styles.label}>
<span className={styles.item}>{url}</span> {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>
<div className={styles.track}> <div className={styles.track}>
<div className={styles.bar} style={{ width: `${remaining * 100}%` }}> <div className={styles.bar} style={{ width: `${remaining * 100}%` }}></div>
<span className={styles.value}>
{remaining > 0.1 && `${(remaining * 100).toFixed(2)}%`}
</span>
</div>
</div> </div>
<div className={styles.info}> {dropoff > 0 && (
<div> <div className={styles.info}>
<b>{formatLongNumber(visitors)}</b> <b>{formatLongNumber(dropped)}</b> {formatMessage(labels.visitorsDroppedOff)} (
<span> {formatMessage(labels.visitors)}</span> {(dropoff * 100).toFixed(2)}%)
<span> ({(remaining * 100).toFixed(2)}%)</span>
</div> </div>
{dropoff > 0 && ( )}
<div>
<b>{formatLongNumber(dropped)}</b> {formatMessage(labels.visitorsDroppedOff)} (
{(dropoff * 100).toFixed(2)}%)
</div>
)}
</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, Popup,
SubmitButton, SubmitButton,
TextField, TextField,
Button,
} from 'react-basics'; } from 'react-basics';
import Icons from 'components/icons'; import Icons from 'components/icons';
import UrlAddForm from './UrlAddForm'; import FunnelStepAddForm from './FunnelStepAddForm';
import { ReportContext } from '../[reportId]/Report'; import { ReportContext } from '../[reportId]/Report';
import BaseParameters from '../[reportId]/BaseParameters'; import BaseParameters from '../[reportId]/BaseParameters';
import ParameterList from '../[reportId]/ParameterList'; import ParameterList from '../[reportId]/ParameterList';
import PopupForm from '../[reportId]/PopupForm'; import PopupForm from '../[reportId]/PopupForm';
import styles from './FunnelParameters.module.css';
export function FunnelParameters() { export function FunnelParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext); const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {}; const { id, parameters } = report || {};
const { websiteId, dateRange, urls } = parameters || {}; const { websiteId, dateRange, steps } = parameters || {};
const queryDisabled = !websiteId || !dateRange || urls?.length < 2; const queryDisabled = !websiteId || !dateRange || steps?.length < 2;
const handleSubmit = (data: any, e: any) => { const handleSubmit = (data: any, e: any) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (!queryDisabled) { if (!queryDisabled) {
runReport(data); runReport(data);
} }
}; };
const handleAddUrl = (url: string) => { const handleAddStep = (step: { type: string; value: string }) => {
updateReport({ parameters: { urls: parameters.urls.concat(url) } }); updateReport({ parameters: { steps: parameters.steps.concat(step) } });
}; };
const handleRemoveUrl = (url: string) => { const handleRemoveStep = (index: number) => {
const urls = [...parameters.urls]; const steps = [...parameters.steps];
updateReport({ parameters: { urls: urls.filter(n => n !== url) } }); delete steps[index];
updateReport({ parameters: { steps: steps.filter(n => n) } });
}; };
const AddUrlButton = () => { const AddStepButton = () => {
return ( return (
<PopupTrigger> <PopupTrigger>
<Icon> <Button>
<Icons.Plus /> <Icon>
</Icon> <Icons.Plus />
<Popup position="right" alignment="start"> </Icon>
</Button>
<Popup alignment="start">
<PopupForm> <PopupForm>
<UrlAddForm onAdd={handleAddUrl} /> <FunnelStepAddForm onAdd={handleAddStep} />
</PopupForm> </PopupForm>
</Popup> </Popup>
</PopupTrigger> </PopupTrigger>
@ -69,12 +75,17 @@ export function FunnelParameters() {
<TextField autoComplete="off" /> <TextField autoComplete="off" />
</FormInput> </FormInput>
</FormRow> </FormRow>
<FormRow label={formatMessage(labels.urls)} action={<AddUrlButton />}> <FormRow label={formatMessage(labels.steps)} action={<AddStepButton />}>
<ParameterList> <ParameterList>
{urls.map(url => { {steps.map((step: { type: string; value: string }, index: number) => {
return ( return (
<ParameterList.Item key={url} onRemove={() => handleRemoveUrl(url)}> <ParameterList.Item key={index} onRemove={() => handleRemoveStep(index)}>
{url} <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> </ParameterList.Item>
); );
})} })}

View File

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

View File

@ -232,6 +232,8 @@ export const labels = defineMessages({
id: 'label.utm-description', id: 'label.utm-description',
defaultMessage: 'Track your campaigns through UTM parameters.', 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({ export const messages = defineMessages({

View File

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