This commit is contained in:
Brian Cao 2024-05-10 23:16:06 -07:00
commit cd797b610c
25 changed files with 186 additions and 124 deletions

View File

@ -71,7 +71,7 @@
"@react-spring/web": "^9.7.3",
"@tanstack/react-query": "^5.28.6",
"@umami/prisma-client": "^0.14.0",
"@umami/redis-client": "^0.20.0",
"@umami/redis-client": "^0.21.0",
"chalk": "^4.1.1",
"chart.js": "^4.4.2",
"chartjs-adapter-date-fns": "^3.0.0",

View File

@ -1,16 +1,23 @@
import { useReports } from 'components/hooks';
import ReportsTable from './ReportsTable';
import DataTable from 'components/common/DataTable';
import { ReactNode } from 'react';
export default function ReportsDataTable({
websiteId,
teamId,
children,
}: {
websiteId?: string;
teamId?: string;
children?: ReactNode;
}) {
const queryResult = useReports({ websiteId, teamId });
if (queryResult?.result?.data?.length === 0) {
return children;
}
return (
<DataTable queryResult={queryResult}>
{({ data }) => <ReportsTable data={data} showDomain={!websiteId} />}

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

@ -5,6 +5,7 @@ import Funnel from 'assets/funnel.svg';
import Lightbulb from 'assets/lightbulb.svg';
import Magnet from 'assets/magnet.svg';
import Tag from 'assets/tag.svg';
import Target from 'assets/target.svg';
import styles from './ReportTemplates.module.css';
import { useMessages, useTeamUrl } from 'components/hooks';
@ -41,7 +42,7 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean })
title: formatMessage(labels.goals),
description: formatMessage(labels.goalsDescription),
url: renderTeamUrl('/reports/goals'),
icon: <Tag />,
icon: <Target />,
},
];

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

@ -76,11 +76,9 @@ export function GoalsAddForm({
className={styles.input}
value={goal?.toString()}
onChange={e => handleChange(e, setGoal)}
autoFocus={true}
autoComplete="off"
onKeyDown={handleKeyDown}
/>
.
</Flexbox>
</FormRow>
<FormRow>

View File

@ -1,27 +1,15 @@
.chart {
display: grid;
gap: 30px;
}
.num {
display: flex;
align-items: center;
justify-content: center;
border-radius: 100%;
width: 50px;
height: 50px;
font-size: 16px;
font-weight: 700;
color: var(--base800);
background: var(--base100);
z-index: 1;
.goal {
padding-bottom: 40px;
border-bottom: 1px solid var(--base400);
}
.step {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 30px;
position: relative;
padding-bottom: 60px;
.goal:last-child {
border: 0;
}
.card {
@ -36,28 +24,12 @@
gap: 20px;
}
.bar {
display: flex;
align-items: center;
justify-content: flex-end;
background: var(--base900);
height: 30px;
border-radius: 5px;
overflow: hidden;
position: relative;
}
.label {
color: var(--base600);
font-weight: 700;
text-transform: uppercase;
}
.track {
background-color: var(--base100);
border-radius: 5px;
}
.item {
font-size: 20px;
color: var(--base900);
@ -73,7 +45,7 @@
text-transform: lowercase;
}
.visitors {
.value {
color: var(--base900);
font-size: 24px;
font-weight: 900;
@ -85,3 +57,39 @@
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

@ -14,9 +14,10 @@ export function GoalsChart({ className }: { className?: string; isLoading?: bool
return (
<div className={classNames(styles.chart, className)}>
{data?.map(({ type, value, goal, result }, index: number) => {
const percent = result > goal ? 100 : (result / goal) * 100;
return (
<div key={index} className={styles.step}>
<div className={styles.num}>{index + 1}</div>
<div key={index} className={styles.goal}>
<div className={styles.card}>
<div className={styles.header}>
<span className={styles.label}>
@ -24,23 +25,27 @@ export function GoalsChart({ className }: { className?: string; isLoading?: bool
</span>
<span className={styles.item}>{value}</span>
</div>
<div className={styles.metric}>
<div>
<span className={styles.visitors}>{formatLongNumber(result)}</span>
{formatMessage(labels.visitors)}
</div>
<div>
<span className={styles.visitors}>{formatLongNumber(goal)}</span>
{formatMessage(labels.goal)}
</div>
<div className={styles.percent}>{((result / goal) * 100).toFixed(2)}%</div>
</div>
<div className={styles.track}>
<div
className={styles.bar}
style={{ width: `${result > goal ? 100 : (result / goal) * 100}%` }}
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>
);

View File

@ -1,18 +1,7 @@
.item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.type {
color: var(--base700);
}
.value {
display: flex;
align-self: center;
gap: 20px;
width: 100%;
margin-bottom: 8px;
font-weight: 600;
}
.goal {
@ -22,5 +11,4 @@
font-weight: 900;
padding: 2px 8px;
border-radius: 5px;
white-space: nowrap;
}

View File

@ -83,15 +83,12 @@ export function GoalsParameters() {
return (
<PopupTrigger key={index}>
<ParameterList.Item
className={styles.item}
icon={goal.type === 'url' ? <Icons.Eye /> : <Icons.Bolt />}
onRemove={() => handleRemoveGoals(index)}
>
<div className={styles.value}>
<div className={styles.type}>
<Icon>{goal.type === 'url' ? <Icons.Eye /> : <Icons.Bolt />}</Icon>
</div>
<div>{goal.value}</div>
<div className={styles.goal}>{formatNumber(goal.goal)}</div>
<div className={styles.value}>{goal.value}</div>
<div className={styles.goal}>
{formatMessage(labels.goal)}: {formatNumber(goal.goal)}
</div>
</ParameterList.Item>
<Popup alignment="start">

View File

@ -4,7 +4,7 @@ import Report from '../[reportId]/Report';
import ReportHeader from '../[reportId]/ReportHeader';
import ReportMenu from '../[reportId]/ReportMenu';
import ReportBody from '../[reportId]/ReportBody';
import Goals from 'assets/funnel.svg';
import Target from 'assets/target.svg';
import { REPORT_TYPES } from 'lib/constants';
const defaultParameters = {
@ -15,7 +15,7 @@ const defaultParameters = {
export default function GoalsReport({ reportId }: { reportId?: string }) {
return (
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Goals />} />
<ReportHeader icon={<Target />} />
<ReportMenu>
<GoalsParameters />
</ReportMenu>

View File

@ -1,17 +1,24 @@
import DataTable from 'components/common/DataTable';
import TeamsTable from 'app/(main)/settings/teams/TeamsTable';
import { useLogin, useTeams } from 'components/hooks';
import { ReactNode } from 'react';
export function TeamsDataTable({
allowEdit,
showActions,
children,
}: {
allowEdit?: boolean;
showActions?: boolean;
children?: ReactNode;
}) {
const { user } = useLogin();
const queryResult = useTeams(user.id);
if (queryResult?.result?.data?.length === 0) {
return children;
}
return (
<DataTable queryResult={queryResult}>
{({ data }) => {

View File

@ -1,10 +1,21 @@
import DataTable from 'components/common/DataTable';
import { useUsers } from 'components/hooks';
import UsersTable from './UsersTable';
import { ReactNode } from 'react';
export function UsersDataTable({ showActions }: { showActions?: boolean }) {
export function UsersDataTable({
showActions,
children,
}: {
showActions?: boolean;
children?: ReactNode;
}) {
const queryResult = useUsers();
if (queryResult?.result?.data?.length === 0) {
return children;
}
return (
<DataTable queryResult={queryResult}>
{({ data }) => <UsersTable data={data} showActions={showActions} />}

View File

@ -18,6 +18,10 @@ export function WebsitesDataTable({
}) {
const queryResult = useWebsites({ teamId });
if (queryResult?.result?.data?.length === 0) {
return children;
}
return (
<DataTable queryResult={queryResult}>
{({ data }) => (
@ -27,9 +31,7 @@ export function WebsitesDataTable({
showActions={showActions}
allowEdit={allowEdit}
allowView={allowView}
>
{children}
</WebsitesTable>
/>
)}
</DataTable>
);

View File

@ -23,6 +23,10 @@ export function WebsitesTable({
const breakpoint = useBreakpoint();
const { renderTeamUrl } = useTeamUrl();
if (!data?.length) {
return children;
}
return (
<GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}>
<GridColumn name="name" label={formatMessage(labels.name)} />
@ -55,7 +59,6 @@ export function WebsitesTable({
}}
</GridColumn>
)}
{children}
</GridTable>
);
}

1
src/assets/target.svg Normal file
View File

@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><g id="Icon"><path d="m19.393 10.825c-.097-.403.151-.808.553-.905.402-.098.808.15.905.553.181.75.277 1.533.277 2.338 0 5.485-4.453 9.939-9.939 9.939-5.485 0-9.939-4.454-9.939-9.939 0-5.486 4.454-9.939 9.939-9.939.805 0 1.588.096 2.338.277.403.097.651.503.553.905-.097.402-.502.65-.905.553-.637-.154-1.302-.235-1.986-.235-4.658 0-8.439 3.781-8.439 8.439s3.781 8.439 8.439 8.439 8.439-3.781 8.439-8.439c0-.684-.081-1.349-.235-1.986z"/><path d="m14.764 12.811c0-.414.336-.75.75-.75.413 0 .75.336.75.75 0 2.8-2.274 5.074-5.075 5.074-2.8 0-5.074-2.274-5.074-5.074 0-2.801 2.274-5.075 5.074-5.075.414 0 .75.337.75.75 0 .414-.336.75-.75.75-1.973 0-3.574 1.602-3.574 3.575s1.601 3.574 3.574 3.574 3.575-1.601 3.575-3.574z"/><path d="m22.53 5.588-3.057 3.058c-.141.141-.332.22-.531.22h-3.058c-.414 0-.75-.336-.75-.75v-3.058c0-.199.079-.39.22-.531l3.058-3.057c.184-.184.45-.26.703-.2s.457.246.539.493l.646 1.937 1.937.646c.247.082.433.286.493.539s-.016.519-.2.703zm-1.918-.202-1.142-.381c-.224-.075-.4-.251-.475-.475l-.381-1.142-1.98 1.98v1.998h1.998z"/><path d="m15.354 7.585c.293-.293.768-.293 1.061 0s.293.768 0 1.061l-4.587 4.586c-.293.293-.768.293-1.06 0-.293-.292-.293-.767 0-1.06z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,22 +1,8 @@
.table {
grid-template-rows: repeat(auto-fit, max-content);
}
.table td {
align-items: center;
max-height: max-content;
}
.search {
max-width: 300px;
margin: 20px 0;
}
.action {
justify-content: flex-end;
gap: 5px;
}
.body {
display: flex;
flex-direction: column;

View File

@ -4,8 +4,8 @@ import { Banner, Loading, SearchField } from 'react-basics';
import { useMessages } from 'components/hooks';
import Empty from 'components/common/Empty';
import Pager from 'components/common/Pager';
import styles from './DataTable.module.css';
import { FilterQueryResult } from 'lib/types';
import styles from './DataTable.module.css';
const DEFAULT_SEARCH_DELAY = 600;

View File

@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { RealtimeData } from 'lib/types';
import { useApi } from 'components/hooks';
import { useApi } from './useApi';
import { REALTIME_INTERVAL, REALTIME_RANGE } from 'lib/constants';
import { startOfMinute, subMinutes } from 'date-fns';
import { percentFilter } from 'lib/filters';

View File

@ -1,4 +1,4 @@
import { useApi } from 'components/hooks';
import { useApi } from './useApi';
export function useWebsiteValues({
websiteId,

View File

@ -5,7 +5,7 @@ import { zonedTimeToUtc } from 'date-fns-tz';
export function useFilterParams(websiteId: string) {
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, unit, offset } = dateRange;
const { startDate, endDate, unit } = dateRange;
const { timezone } = useTimezone();
const {
query: { url, referrer, title, query, os, browser, device, country, region, city, event },
@ -15,7 +15,6 @@ export function useFilterParams(websiteId: string) {
startAt: +zonedTimeToUtc(startDate, timezone),
endAt: +zonedTimeToUtc(endDate, timezone),
unit,
offset,
timezone,
url,
referrer,

View File

@ -1,4 +1,5 @@
import { Prisma, Website } from '@prisma/client';
import redis from '@umami/redis-client';
import prisma from 'lib/prisma';
import { PageResult, PageParams } from 'lib/types';
import WebsiteFindManyArgs = Prisma.WebsiteFindManyArgs;
@ -122,6 +123,7 @@ export async function resetWebsite(
websiteId: string,
): Promise<[Prisma.BatchPayload, Prisma.BatchPayload, Website]> {
const { client, transaction } = prisma;
const cloudMode = !!process.env.cloudMode;
return transaction([
client.eventData.deleteMany({
@ -139,14 +141,20 @@ export async function resetWebsite(
resetAt: new Date(),
},
}),
]);
]).then(async data => {
if (cloudMode) {
await redis.client.set(`website:${websiteId}`, data[3]);
}
return data;
});
}
export async function deleteWebsite(
websiteId: string,
): Promise<[Prisma.BatchPayload, Prisma.BatchPayload, Website]> {
const { client, transaction } = prisma;
const cloudMode = process.env.CLOUD_MODE;
const cloudMode = !!process.env.CLOUD_MODE;
return transaction([
client.eventData.deleteMany({
@ -173,5 +181,11 @@ export async function deleteWebsite(
: client.website.delete({
where: { id: websiteId },
}),
]);
]).then(async data => {
if (cloudMode) {
await redis.client.del(`website:${websiteId}`);
}
return data;
});
}

View File

@ -2899,10 +2899,10 @@
chalk "^4.1.2"
debug "^4.3.4"
"@umami/redis-client@^0.20.0":
version "0.20.0"
resolved "https://registry.yarnpkg.com/@umami/redis-client/-/redis-client-0.20.0.tgz#93b4598d68983b1ad266a0a527c56533c6248bf2"
integrity sha512-ACpoO+M/J2eLWEtusjbslhR4le+rPN4h9x7TXjaVJ905icVE0Qgu5y+A7nxXjcYvlgkHk+8HPeVeeaw5P+rxqw==
"@umami/redis-client@^0.21.0":
version "0.21.0"
resolved "https://registry.yarnpkg.com/@umami/redis-client/-/redis-client-0.21.0.tgz#96426b28860b8b06fae8825fc2f2d9575b64e863"
integrity sha512-PpdJunvT4sAsVWIeEl+cHU6iSV2r/Df9dof2gdUwSigfD88ACsVs1/BvlWERxk/T93rTgVJWSpLvdw/oMYvkcw==
dependencies:
debug "^4.3.4"
redis "^4.5.1"
@ -9532,7 +9532,16 @@ string-length@^4.0.1:
char-regex "^1.0.2"
strip-ansi "^6.0.0"
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -9605,7 +9614,14 @@ string.prototype.trimstart@^1.0.8:
define-properties "^1.2.1"
es-object-atoms "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -10366,7 +10382,7 @@ which@^2.0.1:
dependencies:
isexe "^2.0.0"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -10384,6 +10400,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.0.1, wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"