Merge branch 'dev' into boards
Some checks failed
Node.js CI / build (push) Has been cancelled

# Conflicts:
#	.gitignore
#	package.json
#	pnpm-lock.yaml
#	prisma/migrations/16_boards/migration.sql
#	prisma/schema.prisma
#	src/app/(main)/MobileNav.tsx
#	src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx
#	src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx
#	src/components/common/SideMenu.tsx
#	src/lib/types.ts
This commit is contained in:
Mike Cao 2026-02-05 20:05:25 -08:00
commit c3e0290e65
150 changed files with 3028 additions and 787 deletions

View file

@ -2,6 +2,7 @@
CREATE TABLE "share" ( CREATE TABLE "share" (
"share_id" UUID NOT NULL, "share_id" UUID NOT NULL,
"entity_id" UUID NOT NULL, "entity_id" UUID NOT NULL,
"name" VARCHAR(200) NOT NULL,
"share_type" INTEGER NOT NULL, "share_type" INTEGER NOT NULL,
"slug" VARCHAR(100) NOT NULL, "slug" VARCHAR(100) NOT NULL,
"parameters" JSONB NOT NULL, "parameters" JSONB NOT NULL,
@ -11,9 +12,6 @@ CREATE TABLE "share" (
CONSTRAINT "share_pkey" PRIMARY KEY ("share_id") CONSTRAINT "share_pkey" PRIMARY KEY ("share_id")
); );
-- CreateIndex
CREATE UNIQUE INDEX "share_share_id_key" ON "share"("share_id");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "share_slug_key" ON "share"("slug"); CREATE UNIQUE INDEX "share_slug_key" ON "share"("slug");
@ -21,12 +19,13 @@ CREATE UNIQUE INDEX "share_slug_key" ON "share"("slug");
CREATE INDEX "share_entity_id_idx" ON "share"("entity_id"); CREATE INDEX "share_entity_id_idx" ON "share"("entity_id");
-- MigrateData -- MigrateData
INSERT INTO "share" (share_id, entity_id, share_type, slug, parameters, created_at) INSERT INTO "share" (share_id, entity_id, name, share_type, slug, parameters, created_at)
SELECT gen_random_uuid(), SELECT gen_random_uuid(),
website_id, website_id,
name,
1, 1,
share_id, share_id,
'{}'::jsonb, '{"overview":true}'::jsonb,
now() now()
FROM "website" FROM "website"
WHERE share_id IS NOT NULL; WHERE share_id IS NOT NULL;

View file

@ -0,0 +1,29 @@
-- DropIndex
DROP INDEX "link_link_id_key";
-- DropIndex
DROP INDEX "pixel_pixel_id_key";
-- DropIndex
DROP INDEX "report_report_id_key";
-- DropIndex
DROP INDEX "revenue_revenue_id_key";
-- DropIndex
DROP INDEX "segment_segment_id_key";
-- DropIndex
DROP INDEX "session_session_id_key";
-- DropIndex
DROP INDEX "team_team_id_key";
-- DropIndex
DROP INDEX "team_user_team_user_id_key";
-- DropIndex
DROP INDEX "user_user_id_key";
-- DropIndex
DROP INDEX "website_website_id_key";

View file

@ -3,7 +3,7 @@ import 'dotenv/config';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import https from 'https'; import https from 'https';
import tar from 'tar'; import { list } from 'tar';
import zlib from 'zlib'; import zlib from 'zlib';
if (process.env.VERCEL && !process.env.BUILD_GEO) { if (process.env.VERCEL && !process.env.BUILD_GEO) {
@ -40,7 +40,7 @@ const isDirectMmdb = url.endsWith('.mmdb');
const downloadCompressed = url => const downloadCompressed = url =>
new Promise(resolve => { new Promise(resolve => {
https.get(url, res => { https.get(url, res => {
resolve(res.pipe(zlib.createGunzip({})).pipe(tar.t())); resolve(res.pipe(zlib.createGunzip({})).pipe(list()));
}); });
}); });

View file

@ -3,14 +3,19 @@ import { Column } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { TeamsAddButton } from '../../teams/TeamsAddButton';
import { AdminTeamsDataTable } from './AdminTeamsDataTable'; import { AdminTeamsDataTable } from './AdminTeamsDataTable';
export function AdminTeamsPage() { export function AdminTeamsPage() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const handleSave = () => {};
return ( return (
<Column gap="6" margin="2"> <Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.teams)} /> <PageHeader title={formatMessage(labels.teams)}>
<TeamsAddButton onSave={handleSave} isAdmin={true} />
</PageHeader>
<Panel> <Panel>
<AdminTeamsDataTable /> <AdminTeamsDataTable />
</Panel> </Panel>

View file

@ -10,6 +10,7 @@ import {
TextField, TextField,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks'; import { useMessages, useUpdateQuery } from '@/components/hooks';
import { messages } from '@/components/messages';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
export function UserAddForm({ onSave, onClose }) { export function UserAddForm({ onSave, onClose }) {
@ -37,7 +38,10 @@ export function UserAddForm({ onSave, onClose }) {
<FormField <FormField
label={formatMessage(labels.password)} label={formatMessage(labels.password)}
name="password" name="password"
rules={{ required: formatMessage(labels.required) }} rules={{
required: formatMessage(labels.required),
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) },
}}
> >
<PasswordField autoComplete="new-password" data-test="input-password" /> <PasswordField autoComplete="new-password" data-test="input-password" />
</FormField> </FormField>

View file

@ -2,13 +2,13 @@ import { DataGrid } from '@/components/common/DataGrid';
import { useLinksQuery, useNavigation } from '@/components/hooks'; import { useLinksQuery, useNavigation } from '@/components/hooks';
import { LinksTable } from './LinksTable'; import { LinksTable } from './LinksTable';
export function LinksDataTable() { export function LinksDataTable({ showActions = false }: { showActions?: boolean }) {
const { teamId } = useNavigation(); const { teamId } = useNavigation();
const query = useLinksQuery({ teamId }); const query = useLinksQuery({ teamId });
return ( return (
<DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}> <DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
{({ data }) => <LinksTable data={data} />} {({ data }) => <LinksTable data={data} showActions={showActions} />}
</DataGrid> </DataGrid>
); );
} }

View file

@ -4,21 +4,30 @@ import { LinksDataTable } from '@/app/(main)/links/LinksDataTable';
import { PageBody } from '@/components/common/PageBody'; import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages, useNavigation } from '@/components/hooks'; import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
import { LinkAddButton } from './LinkAddButton'; import { LinkAddButton } from './LinkAddButton';
export function LinksPage() { export function LinksPage() {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { teamId } = useNavigation(); const { teamId } = useNavigation();
const { data } = useTeamMembersQuery(teamId);
const showActions =
(teamId &&
data?.data.filter(team => team.userId === user.id && team.role !== ROLES.teamViewOnly)
.length > 0) ||
(!teamId && user.role !== ROLES.viewOnly);
return ( return (
<PageBody> <PageBody>
<Column gap="6" margin="2"> <Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.links)}> <PageHeader title={formatMessage(labels.links)}>
<LinkAddButton teamId={teamId} /> {showActions && <LinkAddButton teamId={teamId} />}
</PageHeader> </PageHeader>
<Panel> <Panel>
<LinksDataTable /> <LinksDataTable showActions={showActions} />
</Panel> </Panel>
</Column> </Column>
</PageBody> </PageBody>

View file

@ -6,7 +6,11 @@ import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { LinkDeleteButton } from './LinkDeleteButton'; import { LinkDeleteButton } from './LinkDeleteButton';
import { LinkEditButton } from './LinkEditButton'; import { LinkEditButton } from './LinkEditButton';
export function LinksTable(props: DataTableProps) { export interface LinksTableProps extends DataTableProps {
showActions?: boolean;
}
export function LinksTable({ showActions, ...props }: LinksTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { websiteId, renderUrl } = useNavigation(); const { websiteId, renderUrl } = useNavigation();
const { getSlugUrl } = useSlug('link'); const { getSlugUrl } = useSlug('link');
@ -36,16 +40,18 @@ export function LinksTable(props: DataTableProps) {
<DataColumn id="created" label={formatMessage(labels.created)} width="200px"> <DataColumn id="created" label={formatMessage(labels.created)} width="200px">
{(row: any) => <DateDistance date={new Date(row.createdAt)} />} {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn> </DataColumn>
<DataColumn id="action" align="end" width="100px"> {showActions && (
{({ id, name }: any) => { <DataColumn id="action" align="end" width="100px">
return ( {({ id, name }: any) => {
<Row> return (
<LinkEditButton linkId={id} /> <Row>
<LinkDeleteButton linkId={id} websiteId={websiteId} name={name} /> <LinkEditButton linkId={id} />
</Row> <LinkDeleteButton linkId={id} websiteId={websiteId} name={name} />
); </Row>
}} );
</DataColumn> }}
</DataColumn>
)}
</DataTable> </DataTable>
); );
} }

View file

@ -2,13 +2,13 @@ import { DataGrid } from '@/components/common/DataGrid';
import { useNavigation, usePixelsQuery } from '@/components/hooks'; import { useNavigation, usePixelsQuery } from '@/components/hooks';
import { PixelsTable } from './PixelsTable'; import { PixelsTable } from './PixelsTable';
export function PixelsDataTable() { export function PixelsDataTable({ showActions = false }: { showActions?: boolean }) {
const { teamId } = useNavigation(); const { teamId } = useNavigation();
const query = usePixelsQuery({ teamId }); const query = usePixelsQuery({ teamId });
return ( return (
<DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}> <DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
{({ data }) => <PixelsTable data={data} />} {({ data }) => <PixelsTable data={data} showActions={showActions} />}
</DataGrid> </DataGrid>
); );
} }

View file

@ -3,22 +3,31 @@ import { Column } from '@umami/react-zen';
import { PageBody } from '@/components/common/PageBody'; import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages, useNavigation } from '@/components/hooks'; import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
import { PixelAddButton } from './PixelAddButton'; import { PixelAddButton } from './PixelAddButton';
import { PixelsDataTable } from './PixelsDataTable'; import { PixelsDataTable } from './PixelsDataTable';
export function PixelsPage() { export function PixelsPage() {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { teamId } = useNavigation(); const { teamId } = useNavigation();
const { data } = useTeamMembersQuery(teamId);
const showActions =
(teamId &&
data?.data.filter(team => team.userId === user.id && team.role !== ROLES.teamViewOnly)
.length > 0) ||
(!teamId && user.role !== ROLES.viewOnly);
return ( return (
<PageBody> <PageBody>
<Column gap="6" margin="2"> <Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.pixels)}> <PageHeader title={formatMessage(labels.pixels)}>
<PixelAddButton teamId={teamId} /> {showActions && <PixelAddButton teamId={teamId} />}
</PageHeader> </PageHeader>
<Panel> <Panel>
<PixelsDataTable /> <PixelsDataTable showActions={showActions} />
</Panel> </Panel>
</Column> </Column>
</PageBody> </PageBody>

View file

@ -6,7 +6,11 @@ import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { PixelDeleteButton } from './PixelDeleteButton'; import { PixelDeleteButton } from './PixelDeleteButton';
import { PixelEditButton } from './PixelEditButton'; import { PixelEditButton } from './PixelEditButton';
export function PixelsTable(props: DataTableProps) { export interface PixelsTableProps extends DataTableProps {
showActions?: boolean;
}
export function PixelsTable({ showActions, ...props }: PixelsTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { renderUrl } = useNavigation(); const { renderUrl } = useNavigation();
const { getSlugUrl } = useSlug('pixel'); const { getSlugUrl } = useSlug('pixel');
@ -31,18 +35,20 @@ export function PixelsTable(props: DataTableProps) {
<DataColumn id="created" label={formatMessage(labels.created)}> <DataColumn id="created" label={formatMessage(labels.created)}>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />} {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn> </DataColumn>
<DataColumn id="action" align="end" width="100px"> {showActions && (
{(row: any) => { <DataColumn id="action" align="end" width="100px">
const { id, name } = row; {(row: any) => {
const { id, name } = row;
return ( return (
<Row> <Row>
<PixelEditButton pixelId={id} /> <PixelEditButton pixelId={id} />
<PixelDeleteButton pixelId={id} name={name} /> <PixelDeleteButton pixelId={id} name={name} />
</Row> </Row>
); );
}} }}
</DataColumn> </DataColumn>
)}
</DataTable> </DataTable>
); );
} }

View file

@ -7,8 +7,17 @@ import {
TextField, TextField,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks'; import { useMessages, useUpdateQuery } from '@/components/hooks';
import { UserSelect } from '@/components/input/UserSelect';
export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) { export function TeamAddForm({
onSave,
onClose,
isAdmin,
}: {
onSave: () => void;
onClose: () => void;
isAdmin: boolean;
}) {
const { formatMessage, labels, getErrorMessage } = useMessages(); const { formatMessage, labels, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending } = useUpdateQuery('/teams'); const { mutateAsync, error, isPending } = useUpdateQuery('/teams');
@ -26,6 +35,11 @@ export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose:
<FormField name="name" label={formatMessage(labels.name)}> <FormField name="name" label={formatMessage(labels.name)}>
<TextField autoComplete="off" /> <TextField autoComplete="off" />
</FormField> </FormField>
{isAdmin && (
<FormField name="ownerId" label={formatMessage(labels.teamOwner)}>
<UserSelect buttonProps={{ style: { outline: 'none' } }} />
</FormField>
)}
<FormButtons> <FormButtons>
<Button isDisabled={isPending} onPress={onClose}> <Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)} {formatMessage(labels.cancel)}

View file

@ -0,0 +1,76 @@
import {
Button,
Form,
FormButtons,
FormField,
FormSubmitButton,
ListItem,
Select,
} from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks';
import { UserSelect } from '@/components/input/UserSelect';
import { ROLES } from '@/lib/constants';
const roles = [ROLES.teamManager, ROLES.teamMember, ROLES.teamViewOnly];
export function TeamMemberAddForm({
teamId,
onSave,
onClose,
}: {
teamId: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending } = useUpdateQuery(`/teams/${teamId}/users`);
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
onSuccess: async () => {
onSave?.();
onClose?.();
},
});
};
const renderRole = role => {
switch (role) {
case ROLES.teamManager:
return formatMessage(labels.manager);
case ROLES.teamMember:
return formatMessage(labels.member);
case ROLES.teamViewOnly:
return formatMessage(labels.viewOnly);
}
};
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
<FormField
name="userId"
label={formatMessage(labels.username)}
rules={{ required: 'Required' }}
>
<UserSelect teamId={teamId} />
</FormField>
<FormField name="role" label={formatMessage(labels.role)} rules={{ required: 'Required' }}>
<Select items={roles} renderValue={value => renderRole(value as any)}>
{roles.map(value => (
<ListItem key={value} id={value}>
{renderRole(value)}
</ListItem>
))}
</Select>
</FormField>
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
<FormSubmitButton variant="primary" isDisabled={isPending}>
{formatMessage(labels.save)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -4,7 +4,13 @@ import { Plus } from '@/components/icons';
import { messages } from '@/components/messages'; import { messages } from '@/components/messages';
import { TeamAddForm } from './TeamAddForm'; import { TeamAddForm } from './TeamAddForm';
export function TeamsAddButton({ onSave }: { onSave?: () => void }) { export function TeamsAddButton({
onSave,
isAdmin = false,
}: {
onSave?: () => void;
isAdmin?: boolean;
}) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { toast } = useToast(); const { toast } = useToast();
const { touch } = useModified(); const { touch } = useModified();
@ -25,7 +31,7 @@ export function TeamsAddButton({ onSave }: { onSave?: () => void }) {
</Button> </Button>
<Modal> <Modal>
<Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}> <Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}>
{({ close }) => <TeamAddForm onSave={handleSave} onClose={close} />} {({ close }) => <TeamAddForm onSave={handleSave} onClose={close} isAdmin={isAdmin} />}
</Dialog> </Dialog>
</Modal> </Modal>
</DialogTrigger> </DialogTrigger>

View file

@ -0,0 +1,40 @@
import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
import { useMessages, useModified } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { messages } from '@/components/messages';
import { TeamMemberAddForm } from './TeamMemberAddForm';
export function TeamsMemberAddButton({
teamId,
onSave,
}: {
teamId: string;
onSave?: () => void;
isAdmin?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const handleSave = async () => {
toast(formatMessage(messages.saved));
touch('teams:members');
onSave?.();
};
return (
<DialogTrigger>
<Button>
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.addMember)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.addMember)} style={{ width: 400 }}>
{({ close }) => <TeamMemberAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -1,10 +1,12 @@
import { Column } from '@umami/react-zen'; import { Column, Heading, Row } from '@umami/react-zen';
import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton'; import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useLoginQuery, useNavigation, useTeam } from '@/components/hooks'; import { useLoginQuery, useMessages, useNavigation, useTeam } from '@/components/hooks';
import { Users } from '@/components/icons'; import { Users } from '@/components/icons';
import { labels } from '@/components/messages';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
import { TeamsMemberAddButton } from '../TeamsMemberAddButton';
import { TeamEditForm } from './TeamEditForm'; import { TeamEditForm } from './TeamEditForm';
import { TeamManage } from './TeamManage'; import { TeamManage } from './TeamManage';
import { TeamMembersDataTable } from './TeamMembersDataTable'; import { TeamMembersDataTable } from './TeamMembersDataTable';
@ -13,6 +15,7 @@ export function TeamSettings({ teamId }: { teamId: string }) {
const team: any = useTeam(); const team: any = useTeam();
const { user } = useLoginQuery(); const { user } = useLoginQuery();
const { pathname } = useNavigation(); const { pathname } = useNavigation();
const { formatMessage } = useMessages();
const isAdmin = pathname.includes('/admin'); const isAdmin = pathname.includes('/admin');
@ -37,6 +40,10 @@ export function TeamSettings({ teamId }: { teamId: string }) {
<TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} /> <TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} />
</Panel> </Panel>
<Panel> <Panel>
<Row alignItems="center" justifyContent="space-between">
<Heading size="2">{formatMessage(labels.members)}</Heading>
{isAdmin && <TeamsMemberAddButton teamId={teamId} />}
</Row>
<TeamMembersDataTable teamId={teamId} allowEdit={canEdit} /> <TeamMembersDataTable teamId={teamId} allowEdit={canEdit} />
</Panel> </Panel>
{isTeamOwner && ( {isTeamOwner && (

View file

@ -3,22 +3,31 @@ import { Column } from '@umami/react-zen';
import { PageBody } from '@/components/common/PageBody'; import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages, useNavigation } from '@/components/hooks'; import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
import { WebsiteAddButton } from './WebsiteAddButton'; import { WebsiteAddButton } from './WebsiteAddButton';
import { WebsitesDataTable } from './WebsitesDataTable'; import { WebsitesDataTable } from './WebsitesDataTable';
export function WebsitesPage() { export function WebsitesPage() {
const { user } = useLoginQuery();
const { teamId } = useNavigation(); const { teamId } = useNavigation();
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { data } = useTeamMembersQuery(teamId);
const showActions =
(teamId &&
data?.data.filter(team => team.userId === user.id && team.role !== ROLES.teamViewOnly)
.length > 0) ||
(!teamId && user.role !== ROLES.viewOnly);
return ( return (
<PageBody> <PageBody>
<Column gap="6" margin="2"> <Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.websites)}> <PageHeader title={formatMessage(labels.websites)}>
<WebsiteAddButton teamId={teamId} /> {showActions && <WebsiteAddButton teamId={teamId} />}
</PageHeader> </PageHeader>
<Panel> <Panel>
<WebsitesDataTable teamId={teamId} /> <WebsitesDataTable teamId={teamId} showActions={showActions} />
</Panel> </Panel>
</Column> </Column>
</PageBody> </PageBody>

View file

@ -1,6 +1,6 @@
import { Button, Column, Grid, List, ListItem } from '@umami/react-zen'; import { Button, Column, Grid, List, ListItem, ListSection } from '@umami/react-zen';
import { useState } from 'react'; import { useState } from 'react';
import { useFields, useMessages } from '@/components/hooks'; import { type FieldGroup, useFields, useMessages } from '@/components/hooks';
export function FieldSelectForm({ export function FieldSelectForm({
selectedFields = [], selectedFields = [],
@ -13,7 +13,7 @@ export function FieldSelectForm({
}) { }) {
const [selected, setSelected] = useState(selectedFields); const [selected, setSelected] = useState(selectedFields);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { fields } = useFields(); const { fields, groupLabels } = useFields();
const handleChange = (value: string[]) => { const handleChange = (value: string[]) => {
setSelected(value); setSelected(value);
@ -24,17 +24,38 @@ export function FieldSelectForm({
onClose(); onClose();
}; };
const groupedFields = fields
.filter(field => field.name !== 'event')
.reduce(
(acc, field) => {
const group = field.group;
if (!acc[group]) {
acc[group] = [];
}
acc[group].push(field);
return acc;
},
{} as Record<FieldGroup, typeof fields>,
);
return ( return (
<Column gap="6"> <Column gap="6">
<List value={selected} onChange={handleChange} selectionMode="multiple"> <Column gap="3" overflowY="auto" maxHeight="400px">
{fields.map(({ name, label }) => { <List value={selected} onChange={handleChange} selectionMode="multiple">
return ( {groupLabels.map(({ key: groupKey, label }) => {
<ListItem key={name} id={name}> const groupFields = groupedFields[groupKey];
{label} return (
</ListItem> <ListSection key={groupKey} title={label}>
); {groupFields.map(field => (
})} <ListItem key={field.name} id={field.name}>
</List> {field.filterLabel}
</ListItem>
))}
</ListSection>
);
})}
</List>
</Column>
<Grid columns="1fr 1fr" gap> <Grid columns="1fr 1fr" gap>
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
<Button onPress={handleApply} variant="primary"> <Button onPress={handleApply} variant="primary">

View file

@ -1,6 +1,6 @@
import { Box, Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen'; import { Box, Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useMessages, useResultQuery } from '@/components/hooks'; import { useMessages, useNavigation, useResultQuery } from '@/components/hooks';
import { File, User } from '@/components/icons'; import { File, User } from '@/components/icons';
import { ReportEditButton } from '@/components/input/ReportEditButton'; import { ReportEditButton } from '@/components/input/ReportEditButton';
import { ChangeLabel } from '@/components/metrics/ChangeLabel'; import { ChangeLabel } from '@/components/metrics/ChangeLabel';
@ -20,6 +20,8 @@ type FunnelResult = {
export function Funnel({ id, name, type, parameters, websiteId }) { export function Funnel({ id, name, type, parameters, websiteId }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
const { data, error, isLoading } = useResultQuery(type, { const { data, error, isLoading } = useResultQuery(type, {
websiteId, websiteId,
...parameters, ...parameters,
@ -36,21 +38,22 @@ export function Funnel({ id, name, type, parameters, websiteId }) {
</Text> </Text>
</Row> </Row>
</Column> </Column>
<Column> {!isSharePage && (
<ReportEditButton id={id} name={name} type={type}> <Column>
{({ close }) => { <ReportEditButton id={id} name={name} type={type}>
return ( {({ close }) => {
<Dialog return (
title={formatMessage(labels.funnel)} <Dialog
variant="modal" title={formatMessage(labels.funnel)}
style={{ minHeight: 300, minWidth: 400 }} style={{ minHeight: 300, minWidth: 400 }}
> >
<FunnelEditForm id={id} websiteId={websiteId} onClose={close} /> <FunnelEditForm id={id} websiteId={websiteId} onClose={close} />
</Dialog> </Dialog>
); );
}} }}
</ReportEditButton> </ReportEditButton>
</Column> </Column>
)}
</Grid> </Grid>
{data?.map( {data?.map(
( (

View file

@ -4,7 +4,7 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { SectionHeader } from '@/components/common/SectionHeader'; import { SectionHeader } from '@/components/common/SectionHeader';
import { useDateRange, useReportsQuery } from '@/components/hooks'; import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
import { Funnel } from './Funnel'; import { Funnel } from './Funnel';
import { FunnelAddButton } from './FunnelAddButton'; import { FunnelAddButton } from './FunnelAddButton';
@ -13,13 +13,17 @@ export function FunnelsPage({ websiteId }: { websiteId: string }) {
const { const {
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
} = useDateRange(); } = useDateRange();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
<SectionHeader> {!isSharePage && (
<FunnelAddButton websiteId={websiteId} /> <SectionHeader>
</SectionHeader> <FunnelAddButton websiteId={websiteId} />
</SectionHeader>
)}
<LoadingPanel data={data} isLoading={isLoading} error={error}> <LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && ( {data && (
<Grid gap> <Grid gap>

View file

@ -1,6 +1,6 @@
import { Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen'; import { Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useMessages, useResultQuery } from '@/components/hooks'; import { useMessages, useNavigation, useResultQuery } from '@/components/hooks';
import { File, User } from '@/components/icons'; import { File, User } from '@/components/icons';
import { ReportEditButton } from '@/components/input/ReportEditButton'; import { ReportEditButton } from '@/components/input/ReportEditButton';
import { Lightning } from '@/components/svg'; import { Lightning } from '@/components/svg';
@ -25,6 +25,8 @@ export type GoalData = { num: number; total: number };
export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) { export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, { const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, {
websiteId, websiteId,
startDate, startDate,
@ -45,21 +47,23 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
</Text> </Text>
</Row> </Row>
</Column> </Column>
<Column> {!isSharePage && (
<ReportEditButton id={id} name={name} type={type}> <Column>
{({ close }) => { <ReportEditButton id={id} name={name} type={type}>
return ( {({ close }) => {
<Dialog return (
title={formatMessage(labels.goal)} <Dialog
variant="modal" title={formatMessage(labels.goal)}
style={{ minHeight: 300, minWidth: 400 }} variant="modal"
> style={{ minHeight: 300, minWidth: 400 }}
<GoalEditForm id={id} websiteId={websiteId} onClose={close} /> >
</Dialog> <GoalEditForm id={id} websiteId={websiteId} onClose={close} />
); </Dialog>
}} );
</ReportEditButton> }}
</Column> </ReportEditButton>
</Column>
)}
</Grid> </Grid>
<Row alignItems="center" justifyContent="space-between" gap> <Row alignItems="center" justifyContent="space-between" gap>
<Text color="muted"> <Text color="muted">

View file

@ -4,7 +4,7 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { SectionHeader } from '@/components/common/SectionHeader'; import { SectionHeader } from '@/components/common/SectionHeader';
import { useDateRange, useReportsQuery } from '@/components/hooks'; import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
import { Goal } from './Goal'; import { Goal } from './Goal';
import { GoalAddButton } from './GoalAddButton'; import { GoalAddButton } from './GoalAddButton';
@ -13,13 +13,17 @@ export function GoalsPage({ websiteId }: { websiteId: string }) {
const { const {
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
} = useDateRange(); } = useDateRange();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
<SectionHeader> {!isSharePage && (
<GoalAddButton websiteId={websiteId} /> <SectionHeader>
</SectionHeader> <GoalAddButton websiteId={websiteId} />
</SectionHeader>
)}
<LoadingPanel data={data} isLoading={isLoading} error={error}> <LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && ( {data && (
<Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap> <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>

View file

@ -21,9 +21,15 @@ export interface JourneyProps {
steps: number; steps: number;
startStep?: string; startStep?: string;
endStep?: string; endStep?: string;
view: string;
} }
export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) { const EVENT_TYPES = {
views: 1,
events: 2,
};
export function Journey({ websiteId, steps, startStep, endStep, view }: JourneyProps) {
const [selectedNode, setSelectedNode] = useState(null); const [selectedNode, setSelectedNode] = useState(null);
const [activeNode, setActiveNode] = useState(null); const [activeNode, setActiveNode] = useState(null);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@ -32,6 +38,8 @@ export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps)
steps, steps,
startStep, startStep,
endStep, endStep,
view,
eventType: EVENT_TYPES[view],
}); });
useEscapeKey(() => setSelectedNode(null)); useEscapeKey(() => setSelectedNode(null));

View file

@ -1,9 +1,10 @@
'use client'; 'use client';
import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen'; import { Column, Grid, ListItem, Row, SearchField, Select } from '@umami/react-zen';
import { useState } from 'react'; import { useState } from 'react';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useDateRange, useMessages } from '@/components/hooks'; import { useDateRange, useMessages } from '@/components/hooks';
import { FilterButtons } from '@/components/input/FilterButtons';
import { Journey } from './Journey'; import { Journey } from './Journey';
const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7]; const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7];
@ -14,10 +15,26 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
const { const {
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
} = useDateRange(); } = useDateRange();
const [view, setView] = useState('all');
const [steps, setSteps] = useState(DEFAULT_STEP); const [steps, setSteps] = useState(DEFAULT_STEP);
const [startStep, setStartStep] = useState(''); const [startStep, setStartStep] = useState('');
const [endStep, setEndStep] = useState(''); const [endStep, setEndStep] = useState('');
const buttons = [
{
id: 'all',
label: formatMessage(labels.all),
},
{
id: 'views',
label: formatMessage(labels.views),
},
{
id: 'events',
label: formatMessage(labels.events),
},
];
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
@ -52,6 +69,9 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
/> />
</Column> </Column>
</Grid> </Grid>
<Row justifyContent="flex-end">
<FilterButtons items={buttons} value={view} onChange={setView} />
</Row>
<Panel height="900px" allowFullscreen> <Panel height="900px" allowFullscreen>
<Journey <Journey
websiteId={websiteId} websiteId={websiteId}
@ -60,6 +80,7 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
steps={steps} steps={steps}
startStep={startStep} startStep={startStep}
endStep={endStep} endStep={endStep}
view={view}
/> />
</Panel> </Panel>
</Column> </Column>

View file

@ -21,30 +21,28 @@ export function WebsiteChart({
const { pageviews, sessions, compare } = (data || {}) as any; const { pageviews, sessions, compare } = (data || {}) as any;
const chartData = useMemo(() => { const chartData = useMemo(() => {
if (data) { if (!data) {
const result = { return { pageviews: [], sessions: [] };
pageviews, }
sessions,
};
if (compare) { return {
result.compare = { pageviews,
pageviews: result.pageviews.map(({ x }, i) => ({ sessions,
...(compare && {
compare: {
pageviews: pageviews.map(({ x }, i) => ({
x, x,
y: compare.pageviews[i]?.y, y: compare.pageviews[i]?.y,
d: compare.pageviews[i]?.x, d: compare.pageviews[i]?.x,
})), })),
sessions: result.sessions.map(({ x }, i) => ({ sessions: sessions.map(({ x }, i) => ({
x, x,
y: compare.sessions[i]?.y, y: compare.sessions[i]?.y,
d: compare.sessions[i]?.x, d: compare.sessions[i]?.x,
})), })),
}; },
} }),
};
return result;
}
return { pageviews: [], sessions: [] };
}, [data, startDate, endDate, unit]); }, [data, startDate, endDate, unit]);
return ( return (

View file

@ -15,6 +15,7 @@ export function WebsiteControls({
}: { }: {
websiteId: string; websiteId: string;
allowFilter?: boolean; allowFilter?: boolean;
allowBounceFilter?: boolean;
allowDateFilter?: boolean; allowDateFilter?: boolean;
allowMonthFilter?: boolean; allowMonthFilter?: boolean;
allowDownload?: boolean; allowDownload?: boolean;
@ -23,8 +24,8 @@ export function WebsiteControls({
return ( return (
<Column gap> <Column gap>
<Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap> <Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap>
<Row alignItems="center" justifyContent="flex-start"> <Row alignItems="center" justifyContent="flex-start" gap="4">
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />} {allowFilter && <WebsiteFilterButton websiteId={websiteId} />}
</Row> </Row>
<Row alignItems="center" justifyContent={{ xs: 'flex-start', md: 'flex-end' }}> <Row alignItems="center" justifyContent={{ xs: 'flex-start', md: 'flex-end' }}>
{allowDateFilter && ( {allowDateFilter && (

View file

@ -4,10 +4,14 @@ import {
AppWindow, AppWindow,
Cpu, Cpu,
Earth, Earth,
Fingerprint,
Globe, Globe,
KeyRound,
Landmark, Landmark,
Languages, Languages,
Laptop, Laptop,
Layers,
Link2,
LogIn, LogIn,
LogOut, LogOut,
MapPin, MapPin,
@ -15,9 +19,11 @@ import {
Monitor, Monitor,
Network, Network,
Search, Search,
Send,
Share2, Share2,
SquareSlash, SquareSlash,
Tag, Tag,
Target,
Type, Type,
} from '@/components/icons'; } from '@/components/icons';
import { Lightning } from '@/components/svg'; import { Lightning } from '@/components/svg';
@ -154,6 +160,41 @@ export function WebsiteExpandedMenu({
}, },
].filter(filterExcluded), ].filter(filterExcluded),
}, },
{
label: formatMessage(labels.utm),
items: [
{
id: 'utmSource',
label: formatMessage(labels.source),
path: updateParams({ view: 'utmSource' }),
icon: <Link2 />,
},
{
id: 'utmMedium',
label: formatMessage(labels.medium),
path: updateParams({ view: 'utmMedium' }),
icon: <Send />,
},
{
id: 'utmCampaign',
label: formatMessage(labels.campaign),
path: updateParams({ view: 'utmCampaign' }),
icon: <Target />,
},
{
id: 'utmContent',
label: formatMessage(labels.content),
path: updateParams({ view: 'utmContent' }),
icon: <Layers />,
},
{
id: 'utmTerm',
label: formatMessage(labels.term),
path: updateParams({ view: 'utmTerm' }),
icon: <KeyRound />,
},
].filter(filterExcluded),
},
{ {
label: formatMessage(labels.other), label: formatMessage(labels.other),
items: [ items: [
@ -169,6 +210,12 @@ export function WebsiteExpandedMenu({
path: updateParams({ view: 'hostname' }), path: updateParams({ view: 'hostname' }),
icon: <Network />, icon: <Network />,
}, },
{
id: 'distinctId',
label: formatMessage(labels.distinctId),
path: updateParams({ view: 'distinctId' }),
icon: <Fingerprint />,
},
{ {
id: 'tag', id: 'tag',
label: formatMessage(labels.tag), label: formatMessage(labels.tag),

View file

@ -1,15 +1,18 @@
import { Row } from '@umami/react-zen'; import { Icon, Row, Text } from '@umami/react-zen';
import { WebsiteShareForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm';
import { Favicon } from '@/components/common/Favicon'; import { Favicon } from '@/components/common/Favicon';
import { IconLabel } from '@/components/common/IconLabel';
import { LinkButton } from '@/components/common/LinkButton'; import { LinkButton } from '@/components/common/LinkButton';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { useMessages, useNavigation, useWebsite } from '@/components/hooks'; import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
import { Edit, Share } from '@/components/icons'; import { Edit } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { ActiveUsers } from '@/components/metrics/ActiveUsers'; import { ActiveUsers } from '@/components/metrics/ActiveUsers';
export function WebsiteHeader({ showActions }: { showActions?: boolean }) { export function WebsiteHeader({
showActions,
allowLink = true,
}: {
showActions?: boolean;
allowLink?: boolean;
}) {
const website = useWebsite(); const website = useWebsite();
const { renderUrl, pathname } = useNavigation(); const { renderUrl, pathname } = useNavigation();
const isSettings = pathname.endsWith('/settings'); const isSettings = pathname.endsWith('/settings');
@ -24,32 +27,20 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
<PageHeader <PageHeader
title={website.name} title={website.name}
icon={<Favicon domain={website.domain} />} icon={<Favicon domain={website.domain} />}
titleHref={renderUrl(`/websites/${website.id}`, false)} titleHref={allowLink ? renderUrl(`/websites/${website.id}`, false) : undefined}
> >
<Row alignItems="center" gap="6" wrap="wrap"> <Row alignItems="center" gap="6" wrap="wrap">
<ActiveUsers websiteId={website.id} /> <ActiveUsers websiteId={website.id} />
{showActions && ( {showActions && (
<Row alignItems="center" gap> <LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
<ShareButton websiteId={website?.id} shareId={website?.shareId} /> <Icon>
<LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}> <Edit />
<IconLabel icon={<Edit />}>{formatMessage(labels.edit)}</IconLabel> </Icon>
</LinkButton> <Text>{formatMessage(labels.edit)}</Text>
</Row> </LinkButton>
)} )}
</Row> </Row>
</PageHeader> </PageHeader>
); );
} }
const ShareButton = ({ websiteId, shareId }) => {
const { formatMessage, labels } = useMessages();
return (
<DialogButton icon={<Share />} label={formatMessage(labels.share)} width="800px">
{({ close }) => {
return <WebsiteShareForm websiteId={websiteId} shareId={shareId} onClose={close} />;
}}
</DialogButton>
);
};

View file

@ -9,11 +9,10 @@ import { WebsiteNav } from './WebsiteNav';
export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) { export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
return ( return (
<WebsiteProvider websiteId={websiteId}> <WebsiteProvider websiteId={websiteId}>
<Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%"> <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%">
<Column <Column
display={{ xs: 'none', lg: 'flex' }} display={{ xs: 'none', lg: 'flex' }}
width="240px" width="240px"
height="100%"
border="right" border="right"
backgroundColor backgroundColor
marginRight="2" marginRight="2"

View file

@ -10,7 +10,7 @@ import {
} from '@umami/react-zen'; } from '@umami/react-zen';
import { Fragment } from 'react'; import { Fragment } from 'react';
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { Edit, More, Share } from '@/components/icons'; import { Edit, MoreHorizontal, Share } from '@/components/icons';
export function WebsiteMenu({ websiteId }: { websiteId: string }) { export function WebsiteMenu({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@ -33,7 +33,7 @@ export function WebsiteMenu({ websiteId }: { websiteId: string }) {
<MenuTrigger> <MenuTrigger>
<Button variant="quiet"> <Button variant="quiet">
<Icon> <Icon>
<More /> <MoreHorizontal />
</Icon> </Icon>
</Button> </Button>
<Popover placement="bottom"> <Popover placement="bottom">

View file

@ -7,14 +7,18 @@ import { formatLongNumber, formatShortTime } from '@/lib/format';
export function WebsiteMetricsBar({ export function WebsiteMetricsBar({
websiteId, websiteId,
compareMode,
}: { }: {
websiteId: string; websiteId: string;
showChange?: boolean; showChange?: boolean;
compareMode?: boolean; compareMode?: boolean;
}) { }) {
const { isAllTime } = useDateRange(); const { isAllTime, dateCompare } = useDateRange();
const { formatMessage, labels, getErrorMessage } = useMessages(); const { formatMessage, labels, getErrorMessage } = useMessages();
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(websiteId); const { data, isLoading, isFetching, error } = useWebsiteStatsQuery({
websiteId,
compare: compareMode ? dateCompare?.compare : undefined,
});
const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {}; const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {};

View file

@ -29,6 +29,8 @@ export function WebsiteNav({
event: undefined, event: undefined,
compare: undefined, compare: undefined,
view: undefined, view: undefined,
unit: undefined,
excludeBounce: undefined,
}); });
const items = [ const items = [

View file

@ -1,7 +1,8 @@
'use client'; 'use client';
import { Column } from '@umami/react-zen'; import { Column, Row } from '@umami/react-zen';
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal'; import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { UnitFilter } from '@/components/input/UnitFilter';
import { WebsiteChart } from './WebsiteChart'; import { WebsiteChart } from './WebsiteChart';
import { WebsiteControls } from './WebsiteControls'; import { WebsiteControls } from './WebsiteControls';
import { WebsiteMetricsBar } from './WebsiteMetricsBar'; import { WebsiteMetricsBar } from './WebsiteMetricsBar';
@ -10,9 +11,12 @@ import { WebsitePanels } from './WebsitePanels';
export function WebsitePage({ websiteId }: { websiteId: string }) { export function WebsitePage({ websiteId }: { websiteId: string }) {
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} allowBounceFilter={true} />
<WebsiteMetricsBar websiteId={websiteId} showChange={true} /> <WebsiteMetricsBar websiteId={websiteId} showChange={true} />
<Panel minHeight="520px"> <Panel minHeight="520px">
<Row justifyContent="end">
<UnitFilter />
</Row>
<WebsiteChart websiteId={websiteId} /> <WebsiteChart websiteId={websiteId} />
</Panel> </Panel>
<WebsitePanels websiteId={websiteId} /> <WebsitePanels websiteId={websiteId} />

View file

@ -1,15 +1,13 @@
import { Grid, Heading, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; import { Grid, Heading, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { GridRow } from '@/components/common/GridRow'; import { GridRow } from '@/components/common/GridRow';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { EventsChart } from '@/components/metrics/EventsChart';
import { MetricsTable } from '@/components/metrics/MetricsTable'; import { MetricsTable } from '@/components/metrics/MetricsTable';
import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic'; import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic';
import { WorldMap } from '@/components/metrics/WorldMap'; import { WorldMap } from '@/components/metrics/WorldMap';
export function WebsitePanels({ websiteId }: { websiteId: string }) { export function WebsitePanels({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const tableProps = { const tableProps = {
websiteId, websiteId,
limit: 10, limit: 10,
@ -18,7 +16,6 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
metric: formatMessage(labels.visitors), metric: formatMessage(labels.visitors),
}; };
const rowProps = { minHeight: '570px' }; const rowProps = { minHeight: '570px' };
const isSharePage = pathname.includes('/share/');
return ( return (
<Grid gap="3"> <Grid gap="3">
@ -116,25 +113,6 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
<WeeklyTraffic websiteId={websiteId} /> <WeeklyTraffic websiteId={websiteId} />
</Panel> </Panel>
</GridRow> </GridRow>
{isSharePage && (
<GridRow layout="two-one" {...rowProps}>
<Panel>
<Heading size="2">{formatMessage(labels.events)}</Heading>
<Row border="bottom" marginBottom="4" />
<MetricsTable
websiteId={websiteId}
type="event"
title={formatMessage(labels.event)}
metric={formatMessage(labels.count)}
limit={15}
filterLink={false}
/>
</Panel>
<Panel gridColumn={{ xs: 'span 1', md: 'span 2' }}>
<EventsChart websiteId={websiteId} />
</Panel>
</GridRow>
)}
</Grid> </Grid>
); );
} }

View file

@ -10,7 +10,7 @@ export function ComparePage({ websiteId }: { websiteId: string }) {
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} allowCompare={true} /> <WebsiteControls websiteId={websiteId} allowCompare={true} />
<WebsiteMetricsBar websiteId={websiteId} showChange={true} /> <WebsiteMetricsBar websiteId={websiteId} compareMode={true} showChange={true} />
<Panel minHeight="520px"> <Panel minHeight="520px">
<WebsiteChart websiteId={websiteId} compareMode={true} /> <WebsiteChart websiteId={websiteId} compareMode={true} />
</Panel> </Panel>

View file

@ -88,11 +88,41 @@ export function CompareTables({ websiteId }: { websiteId: string }) {
label: formatMessage(labels.events), label: formatMessage(labels.events),
path: renderPath('event'), path: renderPath('event'),
}, },
{
id: 'utmSource',
label: formatMessage(labels.utmSource),
path: renderPath('utmSource'),
},
{
id: 'utmMedium',
label: formatMessage(labels.utmMedium),
path: renderPath('utmMedium'),
},
{
id: 'utmCampaign',
label: formatMessage(labels.utmCampaign),
path: renderPath('utmCampaign'),
},
{
id: 'utmContent',
label: formatMessage(labels.utmContent),
path: renderPath('utmContent'),
},
{
id: 'utmTerm',
label: formatMessage(labels.utmTerm),
path: renderPath('utmTerm'),
},
{ {
id: 'hostname', id: 'hostname',
label: formatMessage(labels.hostname), label: formatMessage(labels.hostname),
path: renderPath('hostname'), path: renderPath('hostname'),
}, },
{
id: 'distinctId',
label: formatMessage(labels.distinctId),
path: renderPath('distinctId'),
},
{ {
id: 'tag', id: 'tag',
label: formatMessage(labels.tags), label: formatMessage(labels.tags),

View file

@ -1,12 +1,18 @@
'use client'; 'use client';
import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { type Key, useState } from 'react'; import locale from 'date-fns/locale/af';
import { type Key, useMemo, useState } from 'react';
import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery';
import { EventsChart } from '@/components/metrics/EventsChart'; import { EventsChart } from '@/components/metrics/EventsChart';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { MetricsTable } from '@/components/metrics/MetricsTable'; import { MetricsTable } from '@/components/metrics/MetricsTable';
import { formatLongNumber } from '@/lib/format';
import { getItem, setItem } from '@/lib/storage'; import { getItem, setItem } from '@/lib/storage';
import { EventProperties } from './EventProperties'; import { EventProperties } from './EventProperties';
import { EventsDataTable } from './EventsDataTable'; import { EventsDataTable } from './EventsDataTable';
@ -15,16 +21,61 @@ const KEY_NAME = 'umami.events.tab';
export function EventsPage({ websiteId }) { export function EventsPage({ websiteId }) {
const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart'); const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart');
const { formatMessage, labels } = useMessages(); const { formatMessage, labels, getErrorMessage } = useMessages();
const { data, isLoading, isFetching, error } = useEventStatsQuery({
websiteId,
});
const handleSelect = (value: Key) => { const handleSelect = (value: Key) => {
setItem(KEY_NAME, value); setItem(KEY_NAME, value);
setTab(value); setTab(value);
}; };
const metrics = useMemo(() => {
if (!data) return [];
const { events, visitors, visits, uniqueEvents } = data || {};
return [
{
value: visitors,
label: formatMessage(labels.visitors),
formatValue: formatLongNumber,
},
{
value: visits,
label: formatMessage(labels.visits),
formatValue: formatLongNumber,
},
{
value: events,
label: formatMessage(labels.events),
formatValue: formatLongNumber,
},
{
value: uniqueEvents,
label: formatMessage(labels.uniqueEvents),
formatValue: formatLongNumber,
},
] as any;
}, [data, locale]);
return ( return (
<Column gap="3"> <Column gap="3">
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
<LoadingPanel
data={metrics}
isLoading={isLoading}
isFetching={isFetching}
error={getErrorMessage(error)}
minHeight="136px"
>
<MetricsBar>
{metrics?.map(({ label, value, formatValue }) => {
return <MetricCard key={label} value={value} label={label} formatValue={formatValue} />;
})}
</MetricsBar>
</LoadingPanel>
<Panel> <Panel>
<Tabs selectedKey={tab} onSelectionChange={key => handleSelect(key)}> <Tabs selectedKey={tab} onSelectionChange={key => handleSelect(key)}>
<TabList> <TabList>

View file

@ -25,6 +25,19 @@ export function EventsTable(props: DataTableProps) {
const { updateParams } = useNavigation(); const { updateParams } = useNavigation();
const { formatValue } = useFormat(); const { formatValue } = useFormat();
const renderLink = (label: string, hostname: string) => {
return (
<a
href={`//${hostname}${label}`}
style={{ fontWeight: 'bold' }}
target="_blank"
rel="noreferrer noopener"
>
{label}
</a>
);
};
return ( return (
<DataTable {...props}> <DataTable {...props}>
<DataColumn id="event" label={formatMessage(labels.event)} width="2fr"> <DataColumn id="event" label={formatMessage(labels.event)} width="2fr">
@ -43,7 +56,7 @@ export function EventsTable(props: DataTableProps) {
title={row.eventName || row.urlPath} title={row.eventName || row.urlPath}
truncate truncate
> >
{row.eventName || row.urlPath} {row.eventName || renderLink(row.urlPath, row.hostname)}
</Text> </Text>
{row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />} {row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />}
</Row> </Row>

View file

@ -75,8 +75,9 @@ export function RealtimeLog({ data }: { data: any }) {
os: string; os: string;
country: string; country: string;
device: string; device: string;
hostname: string;
}) => { }) => {
const { __type, eventName, urlPath, browser, os, country, device } = log; const { __type, eventName, urlPath, browser, os, country, device, hostname } = log;
if (__type === TYPE_EVENT) { if (__type === TYPE_EVENT) {
return ( return (
@ -87,7 +88,8 @@ export function RealtimeLog({ data }: { data: any }) {
url: ( url: (
<a <a
key="a" key="a"
href={`//${website?.domain}${urlPath}`} href={`//${hostname}${urlPath}`}
style={{ fontWeight: 'bold' }}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
@ -101,7 +103,12 @@ export function RealtimeLog({ data }: { data: any }) {
if (__type === TYPE_PAGEVIEW) { if (__type === TYPE_PAGEVIEW) {
return ( return (
<a href={`//${website?.domain}${urlPath}`} target="_blank" rel="noreferrer noopener"> <a
href={`//${hostname}${urlPath}`}
style={{ fontWeight: 'bold' }}
target="_blank"
rel="noreferrer noopener"
>
{urlPath} {urlPath}
</a> </a>
); );

View file

@ -39,10 +39,23 @@ export function SessionActivity({
const { isMobile } = useMobile(); const { isMobile } = useMobile();
let lastDay = null; let lastDay = null;
const renderLink = (label: string, hostname: string) => {
return (
<a
href={`//${hostname}${label}`}
style={{ fontWeight: 'bold' }}
target="_blank"
rel="noreferrer noopener"
>
{label}
</a>
);
};
return ( return (
<LoadingPanel data={data} isLoading={isLoading} error={error}> <LoadingPanel data={data} isLoading={isLoading} error={error}>
<Column gap> <Column gap>
{data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hasData }) => { {data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hostname, hasData }) => {
const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt)); const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt));
lastDay = createdAt; lastDay = createdAt;
@ -61,7 +74,7 @@ export function SessionActivity({
: formatMessage(labels.viewedPage)} : formatMessage(labels.viewedPage)}
</Text> </Text>
<Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate> <Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate>
{eventName || urlPath} {eventName || renderLink(urlPath, hostname)}
</Text> </Text>
{hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />} {hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />}
</Row> </Row>

View file

@ -0,0 +1,57 @@
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
import { Trash } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { messages } from '@/components/messages';
export function ShareDeleteButton({
shareId,
slug,
onSave,
}: {
shareId: string;
slug: string;
onSave?: () => void;
}) {
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
const { mutateAsync, isPending, error } = useDeleteQuery(`/share/id/${shareId}`);
const { touch } = useModified();
const handleConfirm = async (close: () => void) => {
await mutateAsync(null, {
onSuccess: () => {
touch('shares');
onSave?.();
close();
},
});
};
return (
<DialogButton
icon={<Trash />}
title={formatMessage(labels.confirm)}
variant="quiet"
width="400px"
>
{({ close }) => (
<ConfirmationForm
message={
<FormattedMessage
{...messages.confirmRemove}
values={{
target: <b>{slug}</b>,
}}
/>
}
isLoading={isPending}
error={getErrorMessage(error)}
onConfirm={handleConfirm.bind(null, close)}
onClose={close}
buttonLabel={formatMessage(labels.delete)}
buttonVariant="danger"
/>
)}
</DialogButton>
);
}

View file

@ -0,0 +1,16 @@
import { useMessages } from '@/components/hooks';
import { Edit } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { ShareEditForm } from './ShareEditForm';
export function ShareEditButton({ shareId }: { shareId: string }) {
const { formatMessage, labels } = useMessages();
return (
<DialogButton icon={<Edit />} title={formatMessage(labels.share)} variant="quiet" width="600px">
{({ close }) => {
return <ShareEditForm shareId={shareId} onClose={close} />;
}}
</DialogButton>
);
}

View file

@ -0,0 +1,171 @@
import {
Button,
Checkbox,
Column,
Form,
FormField,
FormSubmitButton,
Grid,
Label,
Loading,
Row,
Text,
TextField,
} from '@umami/react-zen';
import { useEffect, useState } from 'react';
import { useApi, useConfig, useMessages, useModified } from '@/components/hooks';
import { SHARE_NAV_ITEMS } from './constants';
export function ShareEditForm({
shareId,
websiteId,
onSave,
onClose,
}: {
shareId?: string;
websiteId?: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, getErrorMessage } = useMessages();
const { cloudMode } = useConfig();
const { get, post } = useApi();
const { touch } = useModified();
const { modified } = useModified('shares');
const [share, setShare] = useState<any>(null);
const [isLoading, setIsLoading] = useState(!!shareId);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<any>(null);
const isEditing = !!shareId;
const getUrl = (slug: string) => {
if (cloudMode) {
return `${process.env.cloudUrl}/share/${slug}`;
}
return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
};
useEffect(() => {
if (!shareId) return;
const loadShare = async () => {
setIsLoading(true);
try {
const data = await get(`/share/id/${shareId}`);
setShare(data);
} finally {
setIsLoading(false);
}
};
loadShare();
}, [shareId, modified]);
const handleSubmit = async (data: any) => {
const parameters: Record<string, boolean> = {};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
parameters[item.id] = data[item.id] ?? false;
});
});
setIsPending(true);
setError(null);
try {
if (isEditing) {
await post(`/share/id/${shareId}`, {
name: data.name,
slug: share.slug,
parameters,
});
} else {
await post(`/websites/${websiteId}/shares`, {
name: data.name,
parameters,
});
}
touch('shares');
onSave?.();
onClose?.();
} catch (e) {
setError(e);
} finally {
setIsPending(false);
}
};
if (isLoading) {
return <Loading placement="absolute" />;
}
const url = isEditing ? getUrl(share?.slug || '') : null;
// Build default values from share parameters
const defaultValues: Record<string, any> = {
name: share?.name || '',
};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
const defaultSelected = item.id === 'overview' || item.id === 'events';
defaultValues[item.id] = share?.parameters?.[item.id] ?? defaultSelected;
});
});
// Get all item ids for validation
const allItemIds = SHARE_NAV_ITEMS.flatMap(section => section.items.map(item => item.id));
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={defaultValues}>
{({ watch }) => {
const values = watch();
const hasSelection = allItemIds.some(id => values[id]);
return (
<Column gap="6">
{url && (
<Column>
<Label>{formatMessage(labels.shareUrl)}</Label>
<TextField value={url} isReadOnly allowCopy />
</Column>
)}
<FormField
label={formatMessage(labels.name)}
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" autoFocus={!isEditing} />
</FormField>
<Grid columns="repeat(auto-fit, minmax(150px, 1fr))" gap="3">
{SHARE_NAV_ITEMS.map(section => (
<Column key={section.section} gap="3">
<Text weight="bold">{formatMessage((labels as any)[section.section])}</Text>
<Column gap="1">
{section.items.map(item => (
<FormField key={item.id} name={item.id}>
<Checkbox>{formatMessage((labels as any)[item.label])}</Checkbox>
</FormField>
))}
</Column>
</Column>
))}
</Grid>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton
variant="primary"
isDisabled={isPending || !hasSelection || !values.name}
>
{formatMessage(labels.save)}
</FormSubmitButton>
</Row>
</Column>
);
}}
</Form>
);
}

View file

@ -0,0 +1,52 @@
import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
import { DateDistance } from '@/components/common/DateDistance';
import { ExternalLink } from '@/components/common/ExternalLink';
import { useConfig, useMessages, useMobile } from '@/components/hooks';
import { ShareDeleteButton } from './ShareDeleteButton';
import { ShareEditButton } from './ShareEditButton';
export function SharesTable(props: DataTableProps) {
const { formatMessage, labels } = useMessages();
const { cloudMode } = useConfig();
const { isMobile } = useMobile();
const getUrl = (slug: string) => {
if (cloudMode) {
return `${process.env.cloudUrl}/share/${slug}`;
}
return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
};
return (
<DataTable {...props}>
<DataColumn id="name" label={formatMessage(labels.name)}>
{({ name }: any) => name}
</DataColumn>
<DataColumn id="slug" label={formatMessage(labels.shareUrl)} width="2fr">
{({ slug }: any) => {
const url = getUrl(slug);
return (
<ExternalLink href={url} prefetch={false}>
{url}
</ExternalLink>
);
}}
</DataColumn>
{!isMobile && (
<DataColumn id="created" label={formatMessage(labels.created)}>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
)}
<DataColumn id="action" align="end" width="100px">
{({ id, slug }: any) => {
return (
<Row>
<ShareEditButton shareId={id} />
<ShareDeleteButton shareId={id} slug={slug} />
</Row>
);
}}
</DataColumn>
</DataTable>
);
}

View file

@ -1,14 +1,11 @@
import { Column } from '@umami/react-zen'; import { Column } from '@umami/react-zen';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useWebsite } from '@/components/hooks';
import { WebsiteData } from './WebsiteData'; import { WebsiteData } from './WebsiteData';
import { WebsiteEditForm } from './WebsiteEditForm'; import { WebsiteEditForm } from './WebsiteEditForm';
import { WebsiteShareForm } from './WebsiteShareForm'; import { WebsiteShareForm } from './WebsiteShareForm';
import { WebsiteTrackingCode } from './WebsiteTrackingCode'; import { WebsiteTrackingCode } from './WebsiteTrackingCode';
export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) { export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) {
const website = useWebsite();
return ( return (
<Column gap="6"> <Column gap="6">
<Panel> <Panel>
@ -18,7 +15,7 @@ export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal
<WebsiteTrackingCode websiteId={websiteId} /> <WebsiteTrackingCode websiteId={websiteId} />
</Panel> </Panel>
<Panel> <Panel>
<WebsiteShareForm websiteId={websiteId} shareId={website.shareId} /> <WebsiteShareForm websiteId={websiteId} />
</Panel> </Panel>
<Panel> <Panel>
<WebsiteData websiteId={websiteId} /> <WebsiteData websiteId={websiteId} />

View file

@ -1,93 +1,47 @@
import { import { Column, Heading, Row, Text } from '@umami/react-zen';
Button, import { Plus } from 'lucide-react';
Column, import { LoadingPanel } from '@/components/common/LoadingPanel';
Form, import { useMessages, useWebsiteSharesQuery } from '@/components/hooks';
FormButtons, import { DialogButton } from '@/components/input/DialogButton';
FormSubmitButton, import { ShareEditForm } from './ShareEditForm';
Label, import { SharesTable } from './SharesTable';
Row,
Switch,
TextField,
} from '@umami/react-zen';
import { RefreshCcw } from 'lucide-react';
import { useState } from 'react';
import { IconLabel } from '@/components/common/IconLabel';
import { useConfig, useMessages, useUpdateQuery } from '@/components/hooks';
import { getRandomChars } from '@/lib/generate';
const generateId = () => getRandomChars(16);
export interface WebsiteShareFormProps { export interface WebsiteShareFormProps {
websiteId: string; websiteId: string;
shareId?: string;
onSave?: () => void;
onClose?: () => void;
} }
export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: WebsiteShareFormProps) { export function WebsiteShareForm({ websiteId }: WebsiteShareFormProps) {
const { formatMessage, labels, messages, getErrorMessage } = useMessages(); const { formatMessage, labels, messages } = useMessages();
const [currentId, setCurrentId] = useState(shareId); const { data, error, isLoading } = useWebsiteSharesQuery({ websiteId });
const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`);
const { cloudMode } = useConfig();
const getUrl = (shareId: string) => { const shares = data?.data || [];
if (cloudMode) { const hasShares = shares.length > 0;
return `${process.env.cloudUrl}/share/${shareId}`;
}
return `${window?.location.origin}${process.env.basePath || ''}/share/${shareId}`;
};
const url = getUrl(currentId);
const handleGenerate = () => {
setCurrentId(generateId());
};
const handleSwitch = () => {
setCurrentId(currentId ? null : generateId());
};
const handleSave = async () => {
const data = {
shareId: currentId,
};
await mutateAsync(data, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch(`website:${websiteId}`);
onSave?.();
onClose?.();
},
});
};
return ( return (
<Form onSubmit={handleSave} error={getErrorMessage(error)} values={{ url }}> <LoadingPanel data={data} isLoading={isLoading} error={error}>
<Column gap> <Column gap="4">
<Switch isSelected={!!currentId} onChange={handleSwitch}> <Row justifyContent="space-between" alignItems="center">
{formatMessage(labels.enableShareUrl)} <Heading>{formatMessage(labels.share)}</Heading>
</Switch> <DialogButton
{currentId && ( icon={<Plus size={16} />}
<Row alignItems="flex-end" gap> label={formatMessage(labels.add)}
<Column flexGrow={1}> title={formatMessage(labels.share)}
<Label>{formatMessage(labels.shareUrl)}</Label> variant="primary"
<TextField value={url} isReadOnly allowCopy /> width="600px"
</Column> >
<Column> {({ close }) => <ShareEditForm websiteId={websiteId} onClose={close} />}
<Button onPress={handleGenerate}> </DialogButton>
<IconLabel icon={<RefreshCcw />} label={formatMessage(labels.regenerate)} /> </Row>
</Button> {hasShares ? (
</Column> <>
</Row> <Text>{formatMessage(messages.shareUrl)}</Text>
<SharesTable data={shares} />
</>
) : (
<Text color="muted">{formatMessage(messages.noDataAvailable)}</Text>
)} )}
<FormButtons justifyContent="flex-end">
<Row alignItems="center" gap>
{onClose && <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>}
<FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton>
</Row>
</FormButtons>
</Column> </Column>
</Form> </LoadingPanel>
); );
} }

View file

@ -0,0 +1,30 @@
export const SHARE_NAV_ITEMS = [
{
section: 'traffic',
items: [
{ id: 'overview', label: 'overview' },
{ id: 'events', label: 'events' },
{ id: 'sessions', label: 'sessions' },
{ id: 'realtime', label: 'realtime' },
{ id: 'compare', label: 'compare' },
{ id: 'breakdown', label: 'breakdown' },
],
},
{
section: 'behavior',
items: [
{ id: 'goals', label: 'goals' },
{ id: 'funnels', label: 'funnels' },
{ id: 'journeys', label: 'journeys' },
{ id: 'retention', label: 'retention' },
],
},
{
section: 'growth',
items: [
{ id: 'utm', label: 'utm' },
{ id: 'revenue', label: 'revenue' },
{ id: 'attribution', label: 'attribution' },
],
},
];

View file

@ -1,7 +1,14 @@
import redis from '@/lib/redis'; import redis from '@/lib/redis';
import { parseRequest } from '@/lib/request';
import { ok } from '@/lib/response'; import { ok } from '@/lib/response';
export async function POST(request: Request) { export async function POST(request: Request) {
const { error } = await parseRequest(request);
if (error) {
return error();
}
if (redis.enabled) { if (redis.enabled) {
const token = request.headers.get('authorization')?.split(' ')?.[1]; const token = request.headers.get('authorization')?.split(' ')?.[1];

View file

@ -1,7 +1,7 @@
import { saveAuth } from '@/lib/auth'; import { saveAuth } from '@/lib/auth';
import redis from '@/lib/redis'; import redis from '@/lib/redis';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { json } from '@/lib/response'; import { json, serverError } from '@/lib/response';
export async function POST(request: Request) { export async function POST(request: Request) {
const { auth, error } = await parseRequest(request); const { auth, error } = await parseRequest(request);
@ -10,9 +10,13 @@ export async function POST(request: Request) {
return error(); return error();
} }
if (redis.enabled) { if (!redis.enabled) {
const token = await saveAuth({ userId: auth.user.id }, 86400); return serverError({
message: 'Redis is disabled',
return json({ user: auth.user, token }); });
} }
const token = await saveAuth({ userId: auth.user.id }, 86400);
return json({ user: auth.user, token });
} }

View file

@ -12,11 +12,16 @@ export async function POST(request: Request) {
} }
const { websiteId, parameters, filters } = body; const { websiteId, parameters, filters } = body;
const { eventType } = parameters;
if (!(await canViewWebsite(auth, websiteId))) { if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized(); return unauthorized();
} }
if (eventType) {
filters.eventType = eventType;
}
const queryFilters = await getQueryFilters(filters, websiteId); const queryFilters = await getQueryFilters(filters, websiteId);
const data = await getJourney(websiteId, parameters, queryFilters); const data = await getJourney(websiteId, parameters, queryFilters);

View file

@ -1,10 +1,10 @@
import { startOfHour, startOfMonth } from 'date-fns'; import { startOfHour } from 'date-fns';
import { isbot } from 'isbot'; import { isbot } from 'isbot';
import { serializeError } from 'serialize-error'; import { serializeError } from 'serialize-error';
import { z } from 'zod'; import { z } from 'zod';
import clickhouse from '@/lib/clickhouse'; import clickhouse from '@/lib/clickhouse';
import { COLLECTION_TYPE, EVENT_TYPE } from '@/lib/constants'; import { COLLECTION_TYPE, EVENT_TYPE } from '@/lib/constants';
import { hash, secret, uuid } from '@/lib/crypto'; import { getSalt, hash, secret, uuid } from '@/lib/crypto';
import { getClientInfo, hasBlockedIp } from '@/lib/detect'; import { getClientInfo, hasBlockedIp } from '@/lib/detect';
import { createToken, parseToken } from '@/lib/jwt'; import { createToken, parseToken } from '@/lib/jwt';
import { fetchWebsite } from '@/lib/load'; import { fetchWebsite } from '@/lib/load';
@ -130,7 +130,8 @@ export async function POST(request: Request) {
const createdAt = timestamp ? new Date(timestamp * 1000) : new Date(); const createdAt = timestamp ? new Date(timestamp * 1000) : new Date();
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const sessionSalt = hash(startOfMonth(createdAt).toUTCString()); const saltRotation = process.env.SALT_ROTATION || 'month';
const sessionSalt = getSalt(saltRotation, createdAt);
const visitSalt = hash(startOfHour(createdAt).toUTCString()); const visitSalt = hash(startOfHour(createdAt).toUTCString());
const sessionId = id ? uuid(sourceId, id) : uuid(sourceId, ip, userAgent, sessionSalt); const sessionId = id ? uuid(sourceId, id) : uuid(sourceId, ip, userAgent, sessionSalt);

View file

@ -1,7 +1,47 @@
import { ROLES } from '@/lib/constants';
import { secret } from '@/lib/crypto'; import { secret } from '@/lib/crypto';
import { createToken } from '@/lib/jwt'; import { createToken } from '@/lib/jwt';
import prisma from '@/lib/prisma';
import redis from '@/lib/redis';
import { json, notFound } from '@/lib/response'; import { json, notFound } from '@/lib/response';
import { getShareByCode } from '@/queries/prisma'; import type { WhiteLabel } from '@/lib/types';
import { getShareByCode, getWebsite } from '@/queries/prisma';
async function getAccountId(website: { userId?: string; teamId?: string }): Promise<string | null> {
if (website.userId) {
return website.userId;
}
if (website.teamId) {
const teamOwner = await prisma.client.teamUser.findFirst({
where: {
teamId: website.teamId,
role: ROLES.teamOwner,
},
select: {
userId: true,
},
});
return teamOwner?.userId || null;
}
return null;
}
async function getWhiteLabel(accountId: string): Promise<WhiteLabel | null> {
if (!redis.enabled) {
return null;
}
const data = await redis.client.get(`white-label:${accountId}`);
if (data) {
return data as WhiteLabel;
}
return null;
}
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) { export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params; const { slug } = await params;
@ -12,8 +52,25 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu
return notFound(); return notFound();
} }
const data = { shareId: share.id }; const website = await getWebsite(share.entityId);
const token = createToken(data, secret());
return json({ ...data, token }); const data: Record<string, any> = {
shareId: share.id,
websiteId: share.entityId,
parameters: share.parameters,
};
data.token = createToken(data, secret());
const accountId = await getAccountId(website);
if (accountId) {
const whiteLabel = await getWhiteLabel(accountId);
if (whiteLabel) {
data.whiteLabel = whiteLabel;
}
}
return json(data);
} }

View file

@ -25,6 +25,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ shar
export async function POST(request: Request, { params }: { params: Promise<{ shareId: string }> }) { export async function POST(request: Request, { params }: { params: Promise<{ shareId: string }> }) {
const schema = z.object({ const schema = z.object({
name: z.string().max(200),
slug: z.string().max(100), slug: z.string().max(100),
parameters: anyObjectParam, parameters: anyObjectParam,
}); });
@ -36,7 +37,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ sha
} }
const { shareId } = await params; const { shareId } = await params;
const { slug, parameters } = body; const { name, slug, parameters } = body;
const share = await getShare(shareId); const share = await getShare(shareId);
@ -49,6 +50,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ sha
} }
const result = await updateShare(shareId, { const result = await updateShare(shareId, {
name,
slug, slug,
parameters, parameters,
} as any); } as any);

View file

@ -1,5 +1,6 @@
import z from 'zod'; import z from 'zod';
import { uuid } from '@/lib/crypto'; import { uuid } from '@/lib/crypto';
import { getRandomChars } from '@/lib/generate';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { anyObjectParam } from '@/lib/schema'; import { anyObjectParam } from '@/lib/schema';
@ -10,7 +11,8 @@ export async function POST(request: Request) {
const schema = z.object({ const schema = z.object({
entityId: z.uuid(), entityId: z.uuid(),
shareType: z.coerce.number().int(), shareType: z.coerce.number().int(),
slug: z.string().max(100), name: z.string().max(200),
slug: z.string().max(100).optional(),
parameters: anyObjectParam, parameters: anyObjectParam,
}); });
@ -20,7 +22,8 @@ export async function POST(request: Request) {
return error(); return error();
} }
const { entityId, shareType, slug, parameters } = body; const { entityId, shareType, name, slug, parameters } = body;
const shareParameters = parameters ?? {};
if (!(await canUpdateEntity(auth, entityId))) { if (!(await canUpdateEntity(auth, entityId))) {
return unauthorized(); return unauthorized();
@ -30,8 +33,9 @@ export async function POST(request: Request) {
id: uuid(), id: uuid(),
entityId, entityId,
shareType, shareType,
slug, name,
parameters, slug: slug || getRandomChars(16),
parameters: shareParameters,
}); });
return json(share); return json(share);

View file

@ -28,6 +28,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
const schema = z.object({ const schema = z.object({
name: z.string().max(50), name: z.string().max(50),
ownerId: z.uuid().optional(),
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);
@ -40,7 +41,7 @@ export async function POST(request: Request) {
return unauthorized(); return unauthorized();
} }
const { name } = body; const { name, ownerId } = body;
const team = await createTeam( const team = await createTeam(
{ {
@ -48,7 +49,7 @@ export async function POST(request: Request) {
name, name,
accessCode: `team_${getRandomChars(16)}`, accessCode: `team_${getRandomChars(16)}`,
}, },
auth.user.id, ownerId ?? auth.user.id,
); );
return json(team); return json(team);

View file

@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { hashPassword } from '@/lib/password'; import { hashPassword } from '@/lib/password';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { badRequest, json, ok, unauthorized } from '@/lib/response'; import { badRequest, json, notFound, ok, unauthorized } from '@/lib/response';
import { userRoleParam } from '@/lib/schema'; import { userRoleParam } from '@/lib/schema';
import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions'; import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions';
import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma'; import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma';
@ -27,7 +27,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user
export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) { export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const schema = z.object({ const schema = z.object({
username: z.string().max(255).optional(), username: z.string().max(255).optional(),
password: z.string().max(255).optional(), password: z.string().min(8).max(255).optional(),
role: userRoleParam.optional(), role: userRoleParam.optional(),
}); });
@ -47,6 +47,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ use
const user = await getUser(userId); const user = await getUser(userId);
if (!user) {
return notFound();
}
const data: any = {}; const data: any = {};
if (password) { if (password) {

View file

@ -4,6 +4,7 @@ import { uuid } from '@/lib/crypto';
import { hashPassword } from '@/lib/password'; import { hashPassword } from '@/lib/password';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { badRequest, json, unauthorized } from '@/lib/response'; import { badRequest, json, unauthorized } from '@/lib/response';
import { userRoleParam } from '@/lib/schema';
import { canCreateUser } from '@/permissions'; import { canCreateUser } from '@/permissions';
import { createUser, getUserByUsername } from '@/queries/prisma'; import { createUser, getUserByUsername } from '@/queries/prisma';
@ -11,8 +12,8 @@ export async function POST(request: Request) {
const schema = z.object({ const schema = z.object({
id: z.uuid().optional(), id: z.uuid().optional(),
username: z.string().max(255), username: z.string().max(255),
password: z.string(), password: z.string().min(8).max(255),
role: z.string().regex(/admin|user|view-only/i), role: userRoleParam,
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);

View file

@ -0,0 +1,34 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { dateRangeParams, filterParams } from '@/lib/schema';
import { canViewWebsite } from '@/permissions';
import { getWebsiteEventStats } from '@/queries/sql/events/getWebsiteEventStats';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
...dateRangeParams,
...filterParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const data = await getWebsiteEventStats(websiteId, filters);
return json({ data });
}

View file

@ -1,9 +1,17 @@
import { z } from 'zod'; import { z } from 'zod';
import { SHARE_ID_REGEX } from '@/lib/constants'; import { ENTITY_TYPE } from '@/lib/constants';
import { uuid } from '@/lib/crypto';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response'; import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions'; import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions';
import { deleteWebsite, getWebsite, updateWebsite } from '@/queries/prisma'; import {
createShare,
deleteSharesByEntityId,
deleteWebsite,
getShareByEntityId,
getWebsite,
updateWebsite,
} from '@/queries/prisma';
export async function GET( export async function GET(
request: Request, request: Request,
@ -33,7 +41,7 @@ export async function POST(
const schema = z.object({ const schema = z.object({
name: z.string().optional(), name: z.string().optional(),
domain: z.string().optional(), domain: z.string().optional(),
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(), shareId: z.string().max(50).nullable().optional(),
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);
@ -50,11 +58,29 @@ export async function POST(
} }
try { try {
const website = await updateWebsite(websiteId, { name, domain, shareId }); const website = await updateWebsite(websiteId, { name, domain });
return Response.json(website); if (shareId === null) {
await deleteSharesByEntityId(website.id);
}
const share = shareId
? await createShare({
id: uuid(),
entityId: websiteId,
shareType: ENTITY_TYPE.website,
name: website.name,
slug: shareId,
parameters: { overview: true, events: true },
})
: await getShareByEntityId(websiteId);
return json({
...website,
shareId: share?.slug ?? null,
});
} catch (e: any) { } catch (e: any) {
if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('share_id')) { if (e.message.toLowerCase().includes('unique constraint')) {
return badRequest({ message: 'That share ID is already taken.' }); return badRequest({ message: 'That share ID is already taken.' });
} }

View file

@ -2,7 +2,7 @@ import { z } from 'zod';
import { uuid } from '@/lib/crypto'; import { uuid } from '@/lib/crypto';
import { getQueryFilters, parseRequest } from '@/lib/request'; import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { anyObjectParam, searchParams, segmentTypeParam } from '@/lib/schema'; import { searchParams, segmentParamSchema, segmentTypeParam } from '@/lib/schema';
import { canUpdateWebsite, canViewWebsite } from '@/permissions'; import { canUpdateWebsite, canViewWebsite } from '@/permissions';
import { createSegment, getWebsiteSegments } from '@/queries/prisma'; import { createSegment, getWebsiteSegments } from '@/queries/prisma';
@ -42,7 +42,7 @@ export async function POST(
const schema = z.object({ const schema = z.object({
type: segmentTypeParam, type: segmentTypeParam,
name: z.string().max(200), name: z.string().max(200),
parameters: anyObjectParam, parameters: segmentParamSchema,
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);

View file

@ -0,0 +1,77 @@
import { z } from 'zod';
import { ENTITY_TYPE } from '@/lib/constants';
import { uuid } from '@/lib/crypto';
import { getRandomChars } from '@/lib/generate';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { anyObjectParam, filterParams, pagingParams } from '@/lib/schema';
import { canUpdateWebsite, canViewWebsite } from '@/permissions';
import { createShare, getSharesByEntityId } from '@/queries/prisma';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
...filterParams,
...pagingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
const { page, pageSize, search } = query;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const data = await getSharesByEntityId(websiteId, {
page,
pageSize,
search,
});
return json(data);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
name: z.string().max(200),
parameters: anyObjectParam.optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
const { name, parameters } = body;
const shareParameters = parameters ?? {};
if (!(await canUpdateWebsite(auth, websiteId))) {
return unauthorized();
}
const slug = getRandomChars(16);
const share = await createShare({
id: uuid(),
entityId: websiteId,
shareType: ENTITY_TYPE.website,
name,
slug,
parameters: shareParameters,
});
return json(share);
}

View file

@ -31,9 +31,11 @@ export async function GET(
const data = await getWebsiteStats(websiteId, filters); const data = await getWebsiteStats(websiteId, filters);
const compare = filters.compare ?? 'prev'; const { startDate, endDate } = getCompareDate(
filters.compare ?? 'prev',
const { startDate, endDate } = getCompareDate(compare, filters.startDate, filters.endDate); filters.startDate,
filters.endDate,
);
const comparison = await getWebsiteStats(websiteId, { const comparison = await getWebsiteStats(websiteId, {
...filters, ...filters,

View file

@ -1,11 +1,12 @@
import { z } from 'zod'; import { z } from 'zod';
import { ENTITY_TYPE } from '@/lib/constants';
import { uuid } from '@/lib/crypto'; import { uuid } from '@/lib/crypto';
import { fetchAccount } from '@/lib/load'; import { fetchAccount } from '@/lib/load';
import { getQueryFilters, parseRequest } from '@/lib/request'; import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema'; import { pagingParams, searchParams } from '@/lib/schema';
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions'; import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
import { createWebsite, getWebsiteCount } from '@/queries/prisma'; import { createShare, createWebsite, getWebsiteCount } from '@/queries/prisma';
import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma/website'; import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma/website';
const CLOUD_WEBSITE_LIMIT = 3; const CLOUD_WEBSITE_LIMIT = 3;
@ -72,7 +73,6 @@ export async function POST(request: Request) {
createdBy: auth.user.id, createdBy: auth.user.id,
name, name,
domain, domain,
shareId,
teamId, teamId,
}; };
@ -82,5 +82,19 @@ export async function POST(request: Request) {
const website = await createWebsite(data); const website = await createWebsite(data);
return json(website); const share = shareId
? await createShare({
id: uuid(),
entityId: website.id,
shareType: ENTITY_TYPE.website,
name: website.name,
slug: shareId,
parameters: { overview: true, events: true },
})
: null;
return json({
...website,
shareId: share?.slug ?? null,
});
} }

View file

@ -0,0 +1,77 @@
'use client';
import { Loading } from '@umami/react-zen';
import { usePathname, useRouter } from 'next/navigation';
import { createContext, type ReactNode, useEffect } from 'react';
import { useShareTokenQuery } from '@/components/hooks';
import type { WhiteLabel } from '@/lib/types';
export interface ShareData {
shareId: string;
slug: string;
websiteId: string;
parameters: any;
token: string;
whiteLabel?: WhiteLabel;
}
export const ShareContext = createContext<ShareData>(null);
const ALL_SECTION_IDS = [
'overview',
'events',
'sessions',
'realtime',
'compare',
'breakdown',
'goals',
'funnels',
'journeys',
'retention',
'utm',
'revenue',
'attribution',
];
function getSharePath(pathname: string) {
const segments = pathname.split('/');
const firstSegment = segments[3];
// If first segment looks like a domain name, skip it
if (firstSegment?.includes('.')) {
return segments[4];
}
return firstSegment;
}
export function ShareProvider({ slug, children }: { slug: string; children: ReactNode }) {
const { share, isLoading, isFetching } = useShareTokenQuery(slug);
const router = useRouter();
const pathname = usePathname();
const path = getSharePath(pathname);
const allowedSections = share?.parameters
? ALL_SECTION_IDS.filter(id => share.parameters[id] !== false)
: [];
const shouldRedirect =
allowedSections.length === 1 &&
allowedSections[0] !== 'overview' &&
(path === undefined || path === '' || path === 'overview');
useEffect(() => {
if (shouldRedirect) {
router.replace(`/share/${slug}/${allowedSections[0]}`);
}
}, [shouldRedirect, slug, allowedSections, router]);
if (isFetching && isLoading) {
return <Loading placement="absolute" />;
}
if (!share || shouldRedirect) {
return null;
}
return <ShareContext.Provider value={{ ...share, slug }}>{children}</ShareContext.Provider>;
}

View file

@ -1,12 +0,0 @@
import { Row, Text } from '@umami/react-zen';
import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants';
export function Footer() {
return (
<Row as="footer" paddingY="6" justifyContent="flex-end">
<a href={HOMEPAGE_URL} target="_blank">
<Text weight="bold">umami</Text> {`v${CURRENT_VERSION}`}
</a>
</Row>
);
}

View file

@ -1,24 +0,0 @@
import { Icon, Row, Text, ThemeButton } from '@umami/react-zen';
import { LanguageButton } from '@/components/input/LanguageButton';
import { PreferencesButton } from '@/components/input/PreferencesButton';
import { Logo } from '@/components/svg';
export function Header() {
return (
<Row as="header" justifyContent="space-between" alignItems="center" paddingY="3">
<a href="https://umami.is" target="_blank" rel="noopener">
<Row alignItems="center" gap>
<Icon>
<Logo />
</Icon>
<Text weight="bold">umami</Text>
</Row>
</a>
<Row alignItems="center" gap>
<ThemeButton />
<LanguageButton />
<PreferencesButton />
</Row>
</Row>
);
}

View file

@ -1,41 +0,0 @@
'use client';
import { Column, useTheme } from '@umami/react-zen';
import { useEffect } from 'react';
import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
import { PageBody } from '@/components/common/PageBody';
import { useShareTokenQuery } from '@/components/hooks';
import { Footer } from './Footer';
import { Header } from './Header';
export function SharePage({ shareId }) {
const { shareToken, isLoading } = useShareTokenQuery(shareId);
const { setTheme } = useTheme();
useEffect(() => {
const url = new URL(window?.location?.href);
const theme = url.searchParams.get('theme');
if (theme === 'light' || theme === 'dark') {
setTheme(theme);
}
}, []);
if (isLoading || !shareToken) {
return null;
}
return (
<Column backgroundColor="2">
<PageBody gap>
<Header />
<WebsiteProvider websiteId={shareToken.websiteId}>
<WebsiteHeader showActions={false} />
<WebsitePage websiteId={shareToken.websiteId} />
</WebsiteProvider>
<Footer />
</PageBody>
</Column>
);
}

View file

@ -1,7 +0,0 @@
import { SharePage } from './SharePage';
export default async function ({ params }: { params: Promise<{ shareId: string[] }> }) {
const { shareId } = await params;
return <SharePage shareId={shareId[0]} />;
}

View file

@ -0,0 +1,206 @@
import { Button, Column, Icon, Row, Text, ThemeButton } from '@umami/react-zen';
import { SideMenu } from '@/components/common/SideMenu';
import { useMessages, useNavigation, useShare } from '@/components/hooks';
import { AlignEndHorizontal, Clock, Eye, PanelLeft, Sheet, Tag, User } from '@/components/icons';
import { LanguageButton } from '@/components/input/LanguageButton';
import { PreferencesButton } from '@/components/input/PreferencesButton';
import { Funnel, Lightning, Logo, Magnet, Money, Network, Path, Target } from '@/components/svg';
export function ShareNav({
collapsed,
onCollapse,
onItemClick,
}: {
collapsed?: boolean;
onCollapse?: (collapsed: boolean) => void;
onItemClick?: () => void;
}) {
const share = useShare();
const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const { slug, parameters, whiteLabel } = share;
const logoUrl = whiteLabel?.url || 'https://umami.is';
const logoName = whiteLabel?.name || 'umami';
const logoImage = whiteLabel?.image;
const renderPath = (path: string) => `/share/${slug}${path}`;
const allItems = [
{
section: 'traffic',
label: formatMessage(labels.traffic),
items: [
{
id: 'overview',
label: formatMessage(labels.overview),
icon: <Eye />,
path: renderPath(''),
},
{
id: 'events',
label: formatMessage(labels.events),
icon: <Lightning />,
path: renderPath('/events'),
},
{
id: 'sessions',
label: formatMessage(labels.sessions),
icon: <User />,
path: renderPath('/sessions'),
},
{
id: 'realtime',
label: formatMessage(labels.realtime),
icon: <Clock />,
path: renderPath('/realtime'),
},
{
id: 'compare',
label: formatMessage(labels.compare),
icon: <AlignEndHorizontal />,
path: renderPath('/compare'),
},
{
id: 'breakdown',
label: formatMessage(labels.breakdown),
icon: <Sheet />,
path: renderPath('/breakdown'),
},
],
},
{
section: 'behavior',
label: formatMessage(labels.behavior),
items: [
{
id: 'goals',
label: formatMessage(labels.goals),
icon: <Target />,
path: renderPath('/goals'),
},
{
id: 'funnels',
label: formatMessage(labels.funnels),
icon: <Funnel />,
path: renderPath('/funnels'),
},
{
id: 'journeys',
label: formatMessage(labels.journeys),
icon: <Path />,
path: renderPath('/journeys'),
},
{
id: 'retention',
label: formatMessage(labels.retention),
icon: <Magnet />,
path: renderPath('/retention'),
},
],
},
{
section: 'growth',
label: formatMessage(labels.growth),
items: [
{
id: 'utm',
label: formatMessage(labels.utm),
icon: <Tag />,
path: renderPath('/utm'),
},
{
id: 'revenue',
label: formatMessage(labels.revenue),
icon: <Money />,
path: renderPath('/revenue'),
},
{
id: 'attribution',
label: formatMessage(labels.attribution),
icon: <Network />,
path: renderPath('/attribution'),
},
],
},
];
// Filter items based on parameters
const items = allItems
.map(section => ({
label: section.label,
items: section.items.filter(item => parameters[item.id] === true),
}))
.filter(section => section.items.length > 0);
const selectedKey = items
.flatMap(e => e.items)
.find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id;
const isMobile = !!onItemClick;
return (
<Column
position={isMobile ? undefined : 'fixed'}
padding="3"
width={isMobile ? '100%' : collapsed ? '60px' : '240px'}
maxHeight="100dvh"
height="100dvh"
border={isMobile ? undefined : 'right'}
borderColor={isMobile ? undefined : '4'}
>
<Row as="header" gap alignItems="center" justifyContent="space-between">
{!collapsed && (
<a href={logoUrl} target="_blank" rel="noopener" style={{ marginLeft: 12 }}>
<Row alignItems="center" gap>
{logoImage ? (
<img src={logoImage} alt={logoName} style={{ height: 24 }} />
) : (
<Icon>
<Logo />
</Icon>
)}
<Text weight="bold">{logoName}</Text>
</Row>
</a>
)}
{!onItemClick && (
<Button variant="quiet" onPress={() => onCollapse?.(!collapsed)}>
<Icon color="muted">
<PanelLeft />
</Icon>
</Button>
)}
</Row>
{!collapsed && (
<Column flexGrow={1} overflowY="auto">
<SideMenu
items={items}
selectedKey={selectedKey}
allowMinimize={false}
onItemClick={onItemClick}
/>
</Column>
)}
<Column
flexGrow={collapsed ? 1 : undefined}
justifyContent="flex-end"
alignItems={collapsed ? 'center' : undefined}
>
{collapsed ? (
<Column gap="2" alignItems="center">
<ThemeButton />
<LanguageButton />
<PreferencesButton />
</Column>
) : (
<Row>
<ThemeButton />
<LanguageButton />
<PreferencesButton />
</Row>
)}
</Column>
</Column>
);
}

View file

@ -0,0 +1,103 @@
'use client';
import { Column, Grid, Row, useTheme } from '@umami/react-zen';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { AttributionPage } from '@/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage';
import { BreakdownPage } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage';
import { FunnelsPage } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage';
import { GoalsPage } from '@/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage';
import { JourneysPage } from '@/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage';
import { RetentionPage } from '@/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage';
import { RevenuePage } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage';
import { UTMPage } from '@/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage';
import { ComparePage } from '@/app/(main)/websites/[websiteId]/compare/ComparePage';
import { EventsPage } from '@/app/(main)/websites/[websiteId]/events/EventsPage';
import { RealtimePage } from '@/app/(main)/websites/[websiteId]/realtime/RealtimePage';
import { SessionsPage } from '@/app/(main)/websites/[websiteId]/sessions/SessionsPage';
import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
import { PageBody } from '@/components/common/PageBody';
import { useShare } from '@/components/hooks';
import { MobileMenuButton } from '@/components/input/MobileMenuButton';
import { ShareNav } from './ShareNav';
const PAGE_COMPONENTS: Record<string, React.ComponentType<{ websiteId: string }>> = {
'': WebsitePage,
overview: WebsitePage,
events: EventsPage,
sessions: SessionsPage,
realtime: RealtimePage,
compare: ComparePage,
breakdown: BreakdownPage,
goals: GoalsPage,
funnels: FunnelsPage,
journeys: JourneysPage,
retention: RetentionPage,
utm: UTMPage,
revenue: RevenuePage,
attribution: AttributionPage,
};
function getSharePath(pathname: string) {
const segments = pathname.split('/');
const firstSegment = segments[3];
// If first segment looks like a domain name, skip it
if (firstSegment?.includes('.')) {
return segments[4];
}
return firstSegment;
}
export function SharePage() {
const [navCollapsed, setNavCollapsed] = useState(false);
const share = useShare();
const { setTheme } = useTheme();
const pathname = usePathname();
const path = getSharePath(pathname);
const { websiteId, parameters = {} } = share;
useEffect(() => {
const url = new URL(window?.location?.href);
const theme = url.searchParams.get('theme');
if (theme === 'light' || theme === 'dark') {
setTheme(theme);
}
}, []);
// Check if the requested path is allowed
const pageKey = path || '';
const isAllowed = pageKey === '' || pageKey === 'overview' || parameters[pageKey] !== false;
if (!isAllowed) {
return null;
}
const PageComponent = PAGE_COMPONENTS[pageKey] || WebsitePage;
return (
<Grid columns={{ xs: '1fr', lg: `${navCollapsed ? '60px' : '240px'} 1fr` }} width="100%">
<Row display={{ xs: 'flex', lg: 'none' }} alignItems="center" gap padding="3">
<MobileMenuButton>
{({ close }) => {
return <ShareNav onItemClick={close} />;
}}
</MobileMenuButton>
</Row>
<Column display={{ xs: 'none', lg: 'flex' }} marginRight="2">
<ShareNav collapsed={navCollapsed} onCollapse={setNavCollapsed} />
</Column>
<PageBody gap>
<WebsiteProvider websiteId={websiteId}>
<Column>
<WebsiteHeader showActions={false} />
<PageComponent websiteId={websiteId} />
</Column>
</WebsiteProvider>
</PageBody>
</Grid>
);
}

View file

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

View file

@ -0,0 +1,13 @@
import { ShareProvider } from '@/app/share/ShareProvider';
export default async function ({
params,
children,
}: {
params: Promise<{ slug: string }>;
children: React.ReactNode;
}) {
const { slug } = await params;
return <ShareProvider slug={slug}>{children}</ShareProvider>;
}

View file

@ -31,6 +31,7 @@ export function PageBody({
<Column <Column
{...props} {...props}
width="100%" width="100%"
minHeight="100vh"
paddingBottom="6" paddingBottom="6"
maxWidth={maxWidth} maxWidth={maxWidth}
paddingX={{ xs: '3', md: '6' }} paddingX={{ xs: '3', md: '6' }}

View file

@ -0,0 +1,6 @@
import { useContext } from 'react';
import { ShareContext } from '@/app/share/ShareProvider';
export function useShare() {
return useContext(ShareContext);
}

View file

@ -4,6 +4,7 @@
export * from './context/useBoard'; export * from './context/useBoard';
export * from './context/useLink'; export * from './context/useLink';
export * from './context/usePixel'; export * from './context/usePixel';
export * from './context/useShare';
export * from './context/useTeam'; export * from './context/useTeam';
export * from './context/useUser'; export * from './context/useUser';
export * from './context/useWebsite'; export * from './context/useWebsite';
@ -54,6 +55,7 @@ export * from './queries/useWebsiteSegmentsQuery';
export * from './queries/useWebsiteSessionQuery'; export * from './queries/useWebsiteSessionQuery';
export * from './queries/useWebsiteSessionStatsQuery'; export * from './queries/useWebsiteSessionStatsQuery';
export * from './queries/useWebsiteSessionsQuery'; export * from './queries/useWebsiteSessionsQuery';
export * from './queries/useWebsiteSharesQuery';
export * from './queries/useWebsiteStatsQuery'; export * from './queries/useWebsiteStatsQuery';
export * from './queries/useWebsitesQuery'; export * from './queries/useWebsitesQuery';
export * from './queries/useWebsiteValuesQuery'; export * from './queries/useWebsiteValuesQuery';

View file

@ -0,0 +1,37 @@
import type { UseQueryOptions } from '@tanstack/react-query';
import { useDateParameters } from '@/components/hooks/useDateParameters';
import { useApi } from '../useApi';
import { useFilterParameters } from '../useFilterParameters';
export interface EventStatsData {
events: number;
visitors: number;
visits: number;
uniqueEvents: number;
}
type EventStatsApiResponse = {
data: EventStatsData;
};
export function useEventStatsQuery(
{ websiteId }: { websiteId: string },
options?: UseQueryOptions<EventStatsApiResponse, Error, EventStatsData>,
) {
const { get, useQuery } = useApi();
const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters();
return useQuery<EventStatsApiResponse, Error, EventStatsData>({
queryKey: ['websites:events:stats', { websiteId, startAt, endAt, ...filters }],
queryFn: () =>
get(`/websites/${websiteId}/events/stats`, {
startAt,
endAt,
...filters,
}),
select: response => response.data,
enabled: !!websiteId,
...options,
});
}

View file

@ -10,7 +10,7 @@ export function useResultQuery<T = any>(
) { ) {
const { websiteId, ...parameters } = params; const { websiteId, ...parameters } = params;
const { post, useQuery } = useApi(); const { post, useQuery } = useApi();
const { startDate, endDate, timezone } = useDateParameters(); const { startDate, endDate, timezone, unit } = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<T>({ return useQuery<T>({
@ -22,6 +22,7 @@ export function useResultQuery<T = any>(
startDate, startDate,
endDate, endDate,
timezone, timezone,
unit,
...params, ...params,
...filters, ...filters,
}, },
@ -35,6 +36,7 @@ export function useResultQuery<T = any>(
startDate, startDate,
endDate, endDate,
timezone, timezone,
unit,
...parameters, ...parameters,
}, },
}), }),

View file

@ -1,25 +1,22 @@
import { setShareToken, useApp } from '@/store/app'; import { setShare, setShareToken, useApp } from '@/store/app';
import { useApi } from '../useApi'; import { useApi } from '../useApi';
const selector = (state: { shareToken: string }) => state.shareToken; const selector = state => state.share;
export function useShareTokenQuery(slug: string): { export function useShareTokenQuery(slug: string) {
shareToken: any; const share = useApp(selector);
isLoading?: boolean;
error?: Error;
} {
const shareToken = useApp(selector);
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { isLoading, error } = useQuery({ const query = useQuery({
queryKey: ['share', slug], queryKey: ['share', slug],
queryFn: async () => { queryFn: async () => {
const data = await get(`/share/${slug}`); const data = await get(`/share/${slug}`);
setShareToken(data); setShare(data);
setShareToken({ token: data?.token });
return data; return data;
}, },
}); });
return { shareToken, isLoading, error }; return { share, ...query };
} }

View file

@ -19,7 +19,7 @@ export function useWebsiteExpandedMetricsQuery(
options?: ReactQueryOptions<WebsiteExpandedMetricsData>, options?: ReactQueryOptions<WebsiteExpandedMetricsData>,
) { ) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters(); const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<WebsiteExpandedMetricsData>({ return useQuery<WebsiteExpandedMetricsData>({
@ -29,8 +29,6 @@ export function useWebsiteExpandedMetricsQuery(
websiteId, websiteId,
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}, },
@ -39,8 +37,6 @@ export function useWebsiteExpandedMetricsQuery(
get(`/websites/${websiteId}/metrics/expanded`, { get(`/websites/${websiteId}/metrics/expanded`, {
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}), }),

View file

@ -15,7 +15,7 @@ export function useWebsiteMetricsQuery(
options?: ReactQueryOptions<WebsiteMetricsData>, options?: ReactQueryOptions<WebsiteMetricsData>,
) { ) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters(); const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<WebsiteMetricsData>({ return useQuery<WebsiteMetricsData>({
@ -25,8 +25,6 @@ export function useWebsiteMetricsQuery(
websiteId, websiteId,
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}, },
@ -35,8 +33,6 @@ export function useWebsiteMetricsQuery(
get(`/websites/${websiteId}/metrics`, { get(`/websites/${websiteId}/metrics`, {
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}), }),

View file

@ -0,0 +1,20 @@
import type { ReactQueryOptions } from '@/lib/types';
import { useApi } from '../useApi';
import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery';
export function useWebsiteSharesQuery(
{ websiteId }: { websiteId: string },
options?: ReactQueryOptions,
) {
const { modified } = useModified('shares');
const { get } = useApi();
return usePagedQuery({
queryKey: ['websiteShares', { websiteId, modified }],
queryFn: pageParams => {
return get(`/websites/${websiteId}/shares`, pageParams);
},
...options,
});
}

View file

@ -1,6 +1,5 @@
import type { UseQueryOptions } from '@tanstack/react-query'; import type { UseQueryOptions } from '@tanstack/react-query';
import { useDateParameters } from '@/components/hooks/useDateParameters'; import { useDateParameters } from '@/components/hooks/useDateParameters';
import { useDateRange } from '@/components/hooks/useDateRange';
import { useApi } from '../useApi'; import { useApi } from '../useApi';
import { useFilterParameters } from '../useFilterParameters'; import { useFilterParameters } from '../useFilterParameters';
@ -20,21 +19,16 @@ export interface WebsiteStatsData {
} }
export function useWebsiteStatsQuery( export function useWebsiteStatsQuery(
websiteId: string, { websiteId, compare }: { websiteId: string; compare?: string },
options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>, options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>,
) { ) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters(); const { startAt, endAt } = useDateParameters();
const { compare } = useDateRange();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<WebsiteStatsData>({ return useQuery<WebsiteStatsData>({
queryKey: [ queryKey: ['websites:stats', { websiteId, compare, startAt, endAt, ...filters }],
'websites:stats', queryFn: () => get(`/websites/${websiteId}/stats`, { compare, startAt, endAt, ...filters }),
{ websiteId, startAt, endAt, unit, timezone, compare, ...filters },
],
queryFn: () =>
get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, compare, ...filters }),
enabled: !!websiteId, enabled: !!websiteId,
...options, ...options,
}); });

View file

@ -12,13 +12,12 @@ export function useWeeklyTrafficQuery(websiteId: string, params?: Record<string,
return useQuery({ return useQuery({
queryKey: [ queryKey: [
'sessions', 'sessions',
{ websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters }, { websiteId, modified, startAt, endAt, timezone, ...params, ...filters },
], ],
queryFn: () => { queryFn: () => {
return get(`/websites/${websiteId}/sessions/weekly`, { return get(`/websites/${websiteId}/sessions/weekly`, {
startAt, startAt,
endAt, endAt,
unit,
timezone, timezone,
...params, ...params,
...filters, ...filters,

View file

@ -7,13 +7,13 @@ import { getItem } from '@/lib/storage';
export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) { export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) {
const { const {
query: { date = '', offset = 0, compare = 'prev' }, query: { date = '', unit = '', offset = 0, compare = 'prev' },
} = useNavigation(); } = useNavigation();
const { locale } = useLocale(); const { locale } = useLocale();
const dateRange = useMemo(() => { const dateRange = useMemo(() => {
const dateRangeObject = parseDateRange( const dateRangeObject = parseDateRange(
date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE, date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE,
unit,
locale, locale,
options.timezone, options.timezone,
); );
@ -21,12 +21,13 @@ export function useDateRange(options: { ignoreOffset?: boolean; timezone?: strin
return !options.ignoreOffset && offset return !options.ignoreOffset && offset
? getOffsetDateRange(dateRangeObject, +offset) ? getOffsetDateRange(dateRangeObject, +offset)
: dateRangeObject; : dateRangeObject;
}, [date, offset, options]); }, [date, unit, offset, options]);
const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate); const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate);
return { return {
date, date,
unit,
offset, offset,
compare, compare,
isAllTime: date.endsWith(`:all`), isAllTime: date.endsWith(`:all`),

View file

@ -1,23 +1,142 @@
import { useMessages } from './useMessages'; import { useMessages } from './useMessages';
export type FieldGroup = 'url' | 'sources' | 'location' | 'environment' | 'utm' | 'other';
export interface Field {
name: string;
filterLabel: string;
label: string;
group: FieldGroup;
}
export function useFields() { export function useFields() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const fields = [ const fields: Field[] = [
{ name: 'path', type: 'string', label: formatMessage(labels.path) }, {
{ name: 'query', type: 'string', label: formatMessage(labels.query) }, name: 'path',
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) }, filterLabel: formatMessage(labels.path),
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) }, label: formatMessage(labels.path),
{ name: 'browser', type: 'string', label: formatMessage(labels.browser) }, group: 'url',
{ name: 'os', type: 'string', label: formatMessage(labels.os) }, },
{ name: 'device', type: 'string', label: formatMessage(labels.device) }, {
{ name: 'country', type: 'string', label: formatMessage(labels.country) }, name: 'query',
{ name: 'region', type: 'string', label: formatMessage(labels.region) }, filterLabel: formatMessage(labels.query),
{ name: 'city', type: 'string', label: formatMessage(labels.city) }, label: formatMessage(labels.query),
{ name: 'hostname', type: 'string', label: formatMessage(labels.hostname) }, group: 'url',
{ name: 'tag', type: 'string', label: formatMessage(labels.tag) }, },
{ name: 'event', type: 'string', label: formatMessage(labels.event) }, {
name: 'title',
filterLabel: formatMessage(labels.pageTitle),
label: formatMessage(labels.pageTitle),
group: 'url',
},
{
name: 'referrer',
filterLabel: formatMessage(labels.referrer),
label: formatMessage(labels.referrer),
group: 'sources',
},
{
name: 'country',
filterLabel: formatMessage(labels.country),
label: formatMessage(labels.country),
group: 'location',
},
{
name: 'region',
filterLabel: formatMessage(labels.region),
label: formatMessage(labels.region),
group: 'location',
},
{
name: 'city',
filterLabel: formatMessage(labels.city),
label: formatMessage(labels.city),
group: 'location',
},
{
name: 'browser',
filterLabel: formatMessage(labels.browser),
label: formatMessage(labels.browser),
group: 'environment',
},
{
name: 'os',
filterLabel: formatMessage(labels.os),
label: formatMessage(labels.os),
group: 'environment',
},
{
name: 'device',
filterLabel: formatMessage(labels.device),
label: formatMessage(labels.device),
group: 'environment',
},
{
name: 'utmSource',
filterLabel: formatMessage(labels.source),
label: formatMessage(labels.utmSource),
group: 'utm',
},
{
name: 'utmMedium',
filterLabel: formatMessage(labels.medium),
label: formatMessage(labels.utmMedium),
group: 'utm',
},
{
name: 'utmCampaign',
filterLabel: formatMessage(labels.campaign),
label: formatMessage(labels.utmCampaign),
group: 'utm',
},
{
name: 'utmContent',
filterLabel: formatMessage(labels.content),
label: formatMessage(labels.utmContent),
group: 'utm',
},
{
name: 'utmTerm',
filterLabel: formatMessage(labels.term),
label: formatMessage(labels.utmTerm),
group: 'utm',
},
{
name: 'hostname',
filterLabel: formatMessage(labels.hostname),
label: formatMessage(labels.hostname),
group: 'other',
},
{
name: 'distinctId',
filterLabel: formatMessage(labels.distinctId),
label: formatMessage(labels.distinctId),
group: 'other',
},
{
name: 'tag',
filterLabel: formatMessage(labels.tag),
label: formatMessage(labels.tag),
group: 'other',
},
{
name: 'event',
filterLabel: formatMessage(labels.event),
label: formatMessage(labels.event),
group: 'other',
},
]; ];
return { fields }; const groupLabels: { key: FieldGroup; label: string }[] = [
{ key: 'url', label: formatMessage(labels.url) },
{ key: 'sources', label: formatMessage(labels.sources) },
{ key: 'location', label: formatMessage(labels.location) },
{ key: 'environment', label: formatMessage(labels.environment) },
{ key: 'utm', label: formatMessage(labels.utm) },
{ key: 'other', label: formatMessage(labels.other) },
];
return { fields, groupLabels };
} }

View file

@ -18,11 +18,18 @@ export function useFilterParameters() {
event, event,
tag, tag,
hostname, hostname,
distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
page, page,
pageSize, pageSize,
search, search,
segment, segment,
cohort, cohort,
excludeBounce,
}, },
} = useNavigation(); } = useNavigation();
@ -42,9 +49,16 @@ export function useFilterParameters() {
event, event,
tag, tag,
hostname, hostname,
distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
search, search,
segment, segment,
cohort, cohort,
excludeBounce,
}; };
}, [ }, [
path, path,
@ -61,10 +75,17 @@ export function useFilterParameters() {
event, event,
tag, tag,
hostname, hostname,
distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
page, page,
pageSize, pageSize,
search, search,
segment, segment,
cohort, cohort,
excludeBounce,
]); ]);
} }

View file

@ -0,0 +1,26 @@
'use client';
import { Checkbox, Row } from '@umami/react-zen';
import { useMessages } from '@/components/hooks/useMessages';
import { useNavigation } from '@/components/hooks/useNavigation';
export function BounceFilter() {
const { router, query, updateParams } = useNavigation();
const { formatMessage, labels } = useMessages();
const isSelected = query.excludeBounce === 'true';
const handleChange = (value: boolean) => {
if (value) {
router.push(updateParams({ excludeBounce: 'true' }));
} else {
router.push(updateParams({ excludeBounce: undefined }));
}
};
return (
<Row alignItems="center" gap>
<Checkbox isSelected={isSelected} onChange={handleChange}>
{formatMessage(labels.excludeBounce)}
</Checkbox>
</Row>
);
}

View file

@ -5,8 +5,10 @@ import {
Icon, Icon,
List, List,
ListItem, ListItem,
ListSection,
Menu, Menu,
MenuItem, MenuItem,
MenuSection,
MenuTrigger, MenuTrigger,
Popover, Popover,
Row, Row,
@ -15,7 +17,7 @@ import { endOfDay, subMonths } from 'date-fns';
import type { Key } from 'react'; import type { Key } from 'react';
import { Empty } from '@/components/common/Empty'; import { Empty } from '@/components/common/Empty';
import { FilterRecord } from '@/components/common/FilterRecord'; import { FilterRecord } from '@/components/common/FilterRecord';
import { useFields, useMessages, useMobile } from '@/components/hooks'; import { type FieldGroup, useFields, useMessages, useMobile } from '@/components/hooks';
import { Plus } from '@/components/icons'; import { Plus } from '@/components/icons';
export interface FieldFiltersProps { export interface FieldFiltersProps {
@ -27,11 +29,25 @@ export interface FieldFiltersProps {
export function FieldFilters({ websiteId, value, exclude = [], onChange }: FieldFiltersProps) { export function FieldFilters({ websiteId, value, exclude = [], onChange }: FieldFiltersProps) {
const { formatMessage, messages } = useMessages(); const { formatMessage, messages } = useMessages();
const { fields } = useFields(); const { fields, groupLabels } = useFields();
const startDate = subMonths(endOfDay(new Date()), 6); const startDate = subMonths(endOfDay(new Date()), 6);
const endDate = endOfDay(new Date()); const endDate = endOfDay(new Date());
const { isMobile } = useMobile(); const { isMobile } = useMobile();
const groupedFields = fields
.filter(({ name }) => !exclude.includes(name))
.reduce(
(acc, field) => {
const group = field.group;
if (!acc[group]) {
acc[group] = [];
}
acc[group].push(field);
return acc;
},
{} as Record<FieldGroup, typeof fields>,
);
const updateFilter = (name: string, props: Record<string, any>) => { const updateFilter = (name: string, props: Record<string, any>) => {
onChange(value.map(filter => (filter.name === name ? { ...filter, ...props } : filter))); onChange(value.map(filter => (filter.name === name ? { ...filter, ...props } : filter)));
}; };
@ -66,32 +82,44 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field
onAction={handleAdd} onAction={handleAdd}
style={{ maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto' }} style={{ maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto' }}
> >
{fields {groupLabels.map(({ key: groupKey, label }) => {
.filter(({ name }) => !exclude.includes(name)) const groupFields = groupedFields[groupKey];
.map(field => { return (
const isDisabled = !!value.find(({ name }) => name === field.name); <MenuSection key={groupKey} title={label}>
return ( {groupFields.map(field => {
<MenuItem key={field.name} id={field.name} isDisabled={isDisabled}> const isDisabled = !!value.find(({ name }) => name === field.name);
{field.label} return (
</MenuItem> <MenuItem key={field.name} id={field.name} isDisabled={isDisabled}>
); {field.filterLabel}
})} </MenuItem>
);
})}
</MenuSection>
);
})}
</Menu> </Menu>
</Popover> </Popover>
</MenuTrigger> </MenuTrigger>
</Row> </Row>
<Column display={{ xs: 'none', md: 'flex' }} border="right" paddingRight="3" marginRight="6"> <Column display={{ xs: 'none', md: 'flex' }} border="right" paddingRight="3" marginRight="6">
<List onAction={handleAdd}> <List onAction={handleAdd}>
{fields {groupLabels.map(({ key: groupKey, label }) => {
.filter(({ name }) => !exclude.includes(name)) const groupFields = groupedFields[groupKey];
.map(field => { if (!groupFields || groupFields.length === 0) return null;
const isDisabled = !!value.find(({ name }) => name === field.name);
return ( return (
<ListItem key={field.name} id={field.name} isDisabled={isDisabled}> <ListSection key={groupKey} title={label}>
{field.label} {groupFields.map(field => {
</ListItem> const isDisabled = !!value.find(({ name }) => name === field.name);
); return (
})} <ListItem key={field.name} id={field.name} isDisabled={isDisabled}>
{field.filterLabel}
</ListItem>
);
})}
</ListSection>
);
})}
</List> </List>
</Column> </Column>
<Column overflow="auto" gapY="4" style={{ contain: 'layout' }}> <Column overflow="auto" gapY="4" style={{ contain: 'layout' }}>

View file

@ -22,6 +22,7 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
const [currentCohort, setCurrentCohort] = useState(cohort); const [currentCohort, setCurrentCohort] = useState(cohort);
const { isMobile } = useMobile(); const { isMobile } = useMobile();
const excludeFilters = pathname.includes('/pixels') || pathname.includes('/links'); const excludeFilters = pathname.includes('/pixels') || pathname.includes('/links');
const excludeEvent = !pathname.endsWith('/events');
const handleReset = () => { const handleReset = () => {
setCurrentFilters([]); setCurrentFilters([]);
@ -61,7 +62,13 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
websiteId={websiteId} websiteId={websiteId}
value={currentFilters} value={currentFilters}
onChange={setCurrentFilters} onChange={setCurrentFilters}
exclude={excludeFilters ? ['path', 'title', 'hostname', 'tag', 'event'] : []} exclude={
excludeFilters
? ['path', 'title', 'hostname', 'distinctId', 'tag', 'event']
: excludeEvent
? ['event']
: []
}
/> />
</TabPanel> </TabPanel>
<TabPanel id="segments"> <TabPanel id="segments">

View file

@ -10,7 +10,7 @@ export function MobileMenuButton(props: DialogProps) {
</Icon> </Icon>
</Button> </Button>
<Modal placement="left" offset="80px"> <Modal placement="left" offset="80px">
<Dialog variant="sheet" {...props} /> <Dialog variant="sheet" {...props} style={{ width: 'auto' }} />
</Modal> </Modal>
</DialogTrigger> </DialogTrigger>
); );

View file

@ -0,0 +1,71 @@
import { ListItem, Row, Select } from '@umami/react-zen';
import { useMessages, useNavigation } from '@/components/hooks';
import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants';
import { getItem } from '@/lib/storage';
export function UnitFilter() {
const { formatMessage, labels } = useMessages();
const { router, query, updateParams } = useNavigation();
const DATE_RANGE_UNIT_CONFIG = {
'0week': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'7day': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'0month': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'30day': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'90day': {
defaultUnit: 'day',
availableUnits: ['day', 'month'],
},
'6month': {
defaultUnit: 'month',
availableUnits: ['month', 'day'],
},
};
const unitConfig =
DATE_RANGE_UNIT_CONFIG[query.date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE];
if (!unitConfig) {
return null;
}
const handleChange = (value: string) => {
router.push(updateParams({ unit: value }));
};
const options = unitConfig.availableUnits.map(unit => ({
id: unit,
label: formatMessage(labels[unit]),
}));
const selectedUnit = query.unit ?? unitConfig.defaultUnit;
return (
<Row>
<Select
value={selectedUnit}
onChange={handleChange}
popoverProps={{ placement: 'bottom right' }}
style={{ width: 100 }}
>
{options.map(({ id, label }) => (
<ListItem key={id} id={id}>
{label}
</ListItem>
))}
</Select>
</Row>
);
}

View file

@ -0,0 +1,71 @@
import { ListItem, Row, Select, type SelectProps, Text } from '@umami/react-zen';
import { useMemo, useState } from 'react';
import { Empty } from '@/components/common/Empty';
import { useMessages, useTeamMembersQuery, useUsersQuery } from '@/components/hooks';
export function UserSelect({
teamId,
onChange,
...props
}: {
teamId?: string;
} & SelectProps) {
const { formatMessage, messages } = useMessages();
const { data: users, isLoading: usersLoading } = useUsersQuery();
const { data: teamMembers, isLoading: teamMembersLoading } = useTeamMembersQuery(teamId);
const [username, setUsername] = useState<string>();
const [search, setSearch] = useState('');
const listItems = useMemo(() => {
if (!users) {
return [];
}
if (!teamId || !teamMembers) {
return users.data;
}
const teamMemberIds = teamMembers.data.map(({ userId }) => userId);
return users.data.filter(({ id }) => !teamMemberIds.includes(id));
}, [users, teamMembers, teamId]);
const handleSearch = (value: string) => {
setSearch(value);
};
const handleOpenChange = () => {
setSearch('');
};
const handleChange = (id: string) => {
setUsername(listItems.find(item => item.id === id)?.username);
onChange(id);
};
const renderValue = () => {
return (
<Row maxWidth="160px">
<Text truncate>{username}</Text>
</Row>
);
};
return (
<Select
{...props}
items={listItems}
value={username}
isLoading={usersLoading || (teamId && teamMembersLoading)}
allowSearch={true}
searchValue={search}
onSearch={handleSearch}
onChange={handleChange}
onOpenChange={handleOpenChange}
renderValue={renderValue}
listProps={{
renderEmptyState: () => <Empty message={formatMessage(messages.noResultsFound)} />,
style: { maxHeight: 'calc(42vh - 65px)' },
}}
>
{({ id, username }: any) => <ListItem key={id}>{username}</ListItem>}
</Select>
);
}

View file

@ -31,9 +31,11 @@ export function WebsiteDateFilter({
const showCompare = allowCompare && !isAllTime; const showCompare = allowCompare && !isAllTime;
const websiteDateRange = useDateRangeQuery(websiteId); const websiteDateRange = useDateRangeQuery(websiteId);
const { startDate, endDate } = websiteDateRange;
const hasData = startDate && endDate;
const handleChange = (date: string) => { const handleChange = (date: string) => {
if (date === 'all') { if (date === 'all' && hasData) {
router.push( router.push(
updateParams({ updateParams({
date: `${getDateRangeValue(websiteDateRange.startDate, websiteDateRange.endDate)}:all`, date: `${getDateRangeValue(websiteDateRange.startDate, websiteDateRange.endDate)}:all`,
@ -41,7 +43,7 @@ export function WebsiteDateFilter({
}), }),
); );
} else { } else {
router.push(updateParams({ date, offset: undefined })); router.push(updateParams({ date, offset: undefined, unit: undefined }));
} }
}; };
@ -78,7 +80,7 @@ export function WebsiteDateFilter({
<DateFilter <DateFilter
value={dateValue} value={dateValue}
onChange={handleChange} onChange={handleChange}
showAllTime={showAllTime} showAllTime={hasData && showAllTime}
renderDate={+offset !== 0} renderDate={+offset !== 0}
/> />
</Row> </Row>

View file

@ -1,3 +1,5 @@
import { Checkbox, Row } from '@umami/react-zen';
import { useState } from 'react';
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { ListFilter } from '@/components/icons'; import { ListFilter } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton'; import { DialogButton } from '@/components/input/DialogButton';
@ -12,12 +14,20 @@ export function WebsiteFilterButton({
alignment?: 'end' | 'center' | 'start'; alignment?: 'end' | 'center' | 'start';
}) { }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { updateParams, router } = useNavigation(); const { updateParams, pathname, router, query } = useNavigation();
const [excludeBounce, setExcludeBounce] = useState(!!query.excludeBounce);
const isOverview =
/^\/teams\/[^/]+\/websites\/[^/]+$/.test(pathname) || /^\/share\/[^/]+$/.test(pathname);
const handleChange = ({ filters, segment, cohort }: any) => { const handleChange = ({ filters, segment, cohort }: any) => {
const params = filtersArrayToObject(filters); const params = filtersArrayToObject(filters);
const url = updateParams({ ...params, segment, cohort }); const url = updateParams({
...params,
segment,
cohort,
excludeBounce: excludeBounce ? 'true' : undefined,
});
router.push(url); router.push(url);
}; };
@ -25,7 +35,22 @@ export function WebsiteFilterButton({
return ( return (
<DialogButton icon={<ListFilter />} label={formatMessage(labels.filter)} variant="outline"> <DialogButton icon={<ListFilter />} label={formatMessage(labels.filter)} variant="outline">
{({ close }) => { {({ close }) => {
return <FilterEditForm websiteId={websiteId} onChange={handleChange} onClose={close} />; return (
<>
{isOverview && (
<Row position="absolute" top="30px" right="30px">
<Checkbox
value={excludeBounce ? 'true' : ''}
onChange={setExcludeBounce}
style={{ marginTop: '3px' }}
>
{formatMessage(labels.excludeBounce)}
</Checkbox>
</Row>
)}
<FilterEditForm websiteId={websiteId} onChange={handleChange} onClose={close} />
</>
);
}} }}
</DialogButton> </DialogButton>
); );

View file

@ -26,7 +26,7 @@ export function WebsiteSelect({
const { user } = useLoginQuery(); const { user } = useLoginQuery();
const { data, isLoading } = useUserWebsitesQuery( const { data, isLoading } = useUserWebsitesQuery(
{ userId: user?.id, teamId }, { userId: user?.id, teamId },
{ search, pageSize: 10, includeTeams }, { search, pageSize: 20, includeTeams },
); );
const listItems: { id: string; name: string }[] = data?.data || []; const listItems: { id: string; name: string }[] = data?.data || [];

View file

@ -43,6 +43,7 @@ export const labels = defineMessages({
joinTeam: { id: 'label.join-team', defaultMessage: 'Join team' }, joinTeam: { id: 'label.join-team', defaultMessage: 'Join team' },
settings: { id: 'label.settings', defaultMessage: 'Settings' }, settings: { id: 'label.settings', defaultMessage: 'Settings' },
owner: { id: 'label.owner', defaultMessage: 'Owner' }, owner: { id: 'label.owner', defaultMessage: 'Owner' },
url: { id: 'label.url', defaultMessage: 'URL' },
teamOwner: { id: 'label.team-owner', defaultMessage: 'Team owner' }, teamOwner: { id: 'label.team-owner', defaultMessage: 'Team owner' },
teamManager: { id: 'label.team-manager', defaultMessage: 'Team manager' }, teamManager: { id: 'label.team-manager', defaultMessage: 'Team manager' },
teamMember: { id: 'label.team-member', defaultMessage: 'Team member' }, teamMember: { id: 'label.team-member', defaultMessage: 'Team member' },
@ -111,6 +112,7 @@ export const labels = defineMessages({
event: { id: 'label.event', defaultMessage: 'Event' }, event: { id: 'label.event', defaultMessage: 'Event' },
events: { id: 'label.events', defaultMessage: 'Events' }, events: { id: 'label.events', defaultMessage: 'Events' },
eventName: { id: 'label.event-name', defaultMessage: 'Event name' }, eventName: { id: 'label.event-name', defaultMessage: 'Event name' },
excludeBounce: { id: 'label.exclude-bounce', defaultMessage: 'Exclude bounces' },
query: { id: 'label.query', defaultMessage: 'Query' }, query: { id: 'label.query', defaultMessage: 'Query' },
queryParameters: { id: 'label.query-parameters', defaultMessage: 'Query parameters' }, queryParameters: { id: 'label.query-parameters', defaultMessage: 'Query parameters' },
back: { id: 'label.back', defaultMessage: 'Back' }, back: { id: 'label.back', defaultMessage: 'Back' },
@ -146,6 +148,7 @@ export const labels = defineMessages({
poweredBy: { id: 'label.powered-by', defaultMessage: 'Powered by {name}' }, poweredBy: { id: 'label.powered-by', defaultMessage: 'Powered by {name}' },
pageViews: { id: 'label.page-views', defaultMessage: 'Page views' }, pageViews: { id: 'label.page-views', defaultMessage: 'Page views' },
uniqueVisitors: { id: 'label.unique-visitors', defaultMessage: 'Unique visitors' }, uniqueVisitors: { id: 'label.unique-visitors', defaultMessage: 'Unique visitors' },
uniqueEvents: { id: 'label.unique-events', defaultMessage: 'Unique Events' },
bounceRate: { id: 'label.bounce-rate', defaultMessage: 'Bounce rate' }, bounceRate: { id: 'label.bounce-rate', defaultMessage: 'Bounce rate' },
viewsPerVisit: { id: 'label.views-per-visit', defaultMessage: 'Views per visit' }, viewsPerVisit: { id: 'label.views-per-visit', defaultMessage: 'Views per visit' },
visitDuration: { id: 'label.visit-duration', defaultMessage: 'Visit duration' }, visitDuration: { id: 'label.visit-duration', defaultMessage: 'Visit duration' },
@ -243,9 +246,22 @@ export const labels = defineMessages({
device: { id: 'label.device', defaultMessage: 'Device' }, device: { id: 'label.device', defaultMessage: 'Device' },
pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' }, pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' },
tag: { id: 'label.tag', defaultMessage: 'Tag' }, tag: { id: 'label.tag', defaultMessage: 'Tag' },
source: { id: 'label.source', defaultMessage: 'Source' },
medium: { id: 'label.medium', defaultMessage: 'Medium' },
campaign: { id: 'label.campaign', defaultMessage: 'Campaign' },
content: { id: 'label.content', defaultMessage: 'Content' },
term: { id: 'label.term', defaultMessage: 'Term' },
utmSource: { id: 'label.utm-source', defaultMessage: 'UTM source' },
utmMedium: { id: 'label.utm-medium', defaultMessage: 'UTM medium' },
utmCampaign: { id: 'label.utm-campaign', defaultMessage: 'UTM campaign' },
utmContent: { id: 'label.utm-content', defaultMessage: 'UTM content' },
utmTerm: { id: 'label.utm-term', defaultMessage: 'UTM term' },
segment: { id: 'label.segment', defaultMessage: 'Segment' }, segment: { id: 'label.segment', defaultMessage: 'Segment' },
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' }, cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
minute: { id: 'label.minute', defaultMessage: 'Minute' },
hour: { id: 'label.hour', defaultMessage: 'Hour' },
day: { id: 'label.day', defaultMessage: 'Day' }, day: { id: 'label.day', defaultMessage: 'Day' },
month: { id: 'label.month', defaultMessage: 'Month' },
date: { id: 'label.date', defaultMessage: 'Date' }, date: { id: 'label.date', defaultMessage: 'Date' },
pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' }, pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' },
create: { id: 'label.create', defaultMessage: 'Create' }, create: { id: 'label.create', defaultMessage: 'Create' },
@ -306,9 +322,7 @@ export const labels = defineMessages({
channel: { id: 'label.channel', defaultMessage: 'Channel' }, channel: { id: 'label.channel', defaultMessage: 'Channel' },
channels: { id: 'label.channels', defaultMessage: 'Channels' }, channels: { id: 'label.channels', defaultMessage: 'Channels' },
sources: { id: 'label.sources', defaultMessage: 'Sources' }, sources: { id: 'label.sources', defaultMessage: 'Sources' },
medium: { id: 'label.medium', defaultMessage: 'Medium' },
campaigns: { id: 'label.campaigns', defaultMessage: 'Campaigns' }, campaigns: { id: 'label.campaigns', defaultMessage: 'Campaigns' },
content: { id: 'label.content', defaultMessage: 'Content' },
terms: { id: 'label.terms', defaultMessage: 'Terms' }, terms: { id: 'label.terms', defaultMessage: 'Terms' },
direct: { id: 'label.direct', defaultMessage: 'Direct' }, direct: { id: 'label.direct', defaultMessage: 'Direct' },
referral: { id: 'label.referral', defaultMessage: 'Referral' }, referral: { id: 'label.referral', defaultMessage: 'Referral' },

View file

@ -25,7 +25,7 @@ export const MetricCard = ({
showChange = false, showChange = false,
}: MetricCardProps) => { }: MetricCardProps) => {
const diff = value - change; const diff = value - change;
const pct = ((value - diff) / diff) * 100; const pct = diff !== 0 ? ((value - diff) / diff) * 100 : value !== 0 ? 100 : 0;
const props = useSpring({ x: Number(value) || 0, from: { x: 0 } }); const props = useSpring({ x: Number(value) || 0, from: { x: 0 } });
const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } }); const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } });

View file

@ -19,11 +19,13 @@ export * from '@/app/(main)/teams/TeamAddForm';
export * from '@/app/(main)/teams/TeamJoinForm'; export * from '@/app/(main)/teams/TeamJoinForm';
export * from '@/app/(main)/teams/TeamLeaveButton'; export * from '@/app/(main)/teams/TeamLeaveButton';
export * from '@/app/(main)/teams/TeamLeaveForm'; export * from '@/app/(main)/teams/TeamLeaveForm';
export * from '@/app/(main)/teams/TeamMemberAddForm';
export * from '@/app/(main)/teams/TeamProvider'; export * from '@/app/(main)/teams/TeamProvider';
export * from '@/app/(main)/teams/TeamsAddButton'; export * from '@/app/(main)/teams/TeamsAddButton';
export * from '@/app/(main)/teams/TeamsDataTable'; export * from '@/app/(main)/teams/TeamsDataTable';
export * from '@/app/(main)/teams/TeamsHeader'; export * from '@/app/(main)/teams/TeamsHeader';
export * from '@/app/(main)/teams/TeamsJoinButton'; export * from '@/app/(main)/teams/TeamsJoinButton';
export * from '@/app/(main)/teams/TeamsMemberAddButton';
export * from '@/app/(main)/teams/TeamsTable'; export * from '@/app/(main)/teams/TeamsTable';
export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteData'; export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteData';
export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm'; export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm';

View file

@ -37,7 +37,7 @@
"label.compare-dates": "Vertaa päivämääriä", "label.compare-dates": "Vertaa päivämääriä",
"label.confirm": "Vahvista", "label.confirm": "Vahvista",
"label.confirm-password": "Vahvista salasana", "label.confirm-password": "Vahvista salasana",
"label.contains": "Contains", "label.contains": "Sisältää",
"label.content": "Sisältö", "label.content": "Sisältö",
"label.continue": "Jatka", "label.continue": "Jatka",
"label.conversion": "Konversio", "label.conversion": "Konversio",
@ -96,7 +96,7 @@
"label.false": "Epätosi", "label.false": "Epätosi",
"label.field": "Kenttä", "label.field": "Kenttä",
"label.fields": "Kentät", "label.fields": "Kentät",
"label.filter": "Filter", "label.filter": "Suodatin",
"label.filter-combined": "Yhdistetty", "label.filter-combined": "Yhdistetty",
"label.filter-raw": "Käsittelemätön", "label.filter-raw": "Käsittelemätön",
"label.filters": "Suodattimet", "label.filters": "Suodattimet",
@ -151,7 +151,7 @@
"label.members": "Jäsenet", "label.members": "Jäsenet",
"label.min": "Minimi", "label.min": "Minimi",
"label.mobile": "Puhelin", "label.mobile": "Puhelin",
"label.model": "Model", "label.model": "Malli",
"label.more": "Lisää", "label.more": "Lisää",
"label.my-account": "Oma tili", "label.my-account": "Oma tili",
"label.my-websites": "Omat verkkosivut", "label.my-websites": "Omat verkkosivut",
@ -184,9 +184,9 @@
"label.paths": "Polut", "label.paths": "Polut",
"label.pixels": "Pikselit", "label.pixels": "Pikselit",
"label.powered-by": "Voimanlähteenä {name}", "label.powered-by": "Voimanlähteenä {name}",
"label.previous": "Previous", "label.previous": "Edellinen",
"label.previous-period": "Previous period", "label.previous-period": "Edellinen ajanjakso",
"label.previous-year": "Previous year", "label.previous-year": "Edellinen vuosi",
"label.profile": "Profiili", "label.profile": "Profiili",
"label.properties": "Ominaisuudet", "label.properties": "Ominaisuudet",
"label.property": "Ominaisuus", "label.property": "Ominaisuus",
@ -195,16 +195,16 @@
"label.query-parameters": "Kyselyn parametrit", "label.query-parameters": "Kyselyn parametrit",
"label.realtime": "Juuri nyt", "label.realtime": "Juuri nyt",
"label.referral": "Viittaus", "label.referral": "Viittaus",
"label.referrer": "Referrer", "label.referrer": "Viittaaja",
"label.referrers": "Viittaajat", "label.referrers": "Viittaajat",
"label.refresh": "Päivitä", "label.refresh": "Päivitä",
"label.regenerate": "Regenerate", "label.regenerate": "Luo uudelleen",
"label.region": "Region", "label.region": "Alue",
"label.regions": "Regions", "label.regions": "Alueet",
"label.remaining": "Jäljellä", "label.remaining": "Jäljellä",
"label.remove": "Remove", "label.remove": "Poista",
"label.remove-member": "Remove member", "label.remove-member": "Poista jäsen",
"label.reports": "Reports", "label.reports": "Raportit",
"label.required": "Vaaditaan", "label.required": "Vaaditaan",
"label.reset": "Nollaa", "label.reset": "Nollaa",
"label.reset-website": "Nollaa tilastot", "label.reset-website": "Nollaa tilastot",
@ -212,19 +212,19 @@
"label.retention-description": "Measure your website stickiness by tracking how often users return.", "label.retention-description": "Measure your website stickiness by tracking how often users return.",
"label.revenue": "Tulot", "label.revenue": "Tulot",
"label.revenue-description": "Katso tulosi ajan mittaan.", "label.revenue-description": "Katso tulosi ajan mittaan.",
"label.role": "Role", "label.role": "Rooli",
"label.run-query": "Run query", "label.run-query": "Suorita kysely",
"label.save": "Tallenna", "label.save": "Tallenna",
"label.screens": "Näytöt", "label.screens": "Näytöt",
"label.search": "Search", "label.search": "Haku",
"label.select": "Select", "label.select": "Valitse",
"label.select-date": "Select date", "label.select-date": "Valitse päivämäärä",
"label.select-filter": "Valitse suodatin", "label.select-filter": "Valitse suodatin",
"label.select-role": "Select role", "label.select-role": "Valitse rooli",
"label.select-website": "Select website", "label.select-website": "Valitse verkkosivu",
"label.session": "Istunto", "label.session": "Istunto",
"label.session-data": "Istuntotiedot", "label.session-data": "Istuntotiedot",
"label.sessions": "Sessions", "label.sessions": "Istunnot",
"label.settings": "Asetukset", "label.settings": "Asetukset",
"label.share": "Jaa", "label.share": "Jaa",
"label.share-url": "Jaa URL", "label.share-url": "Jaa URL",
@ -233,107 +233,107 @@
"label.sources": "Lähteet", "label.sources": "Lähteet",
"label.start-step": "Aloitusvaihe", "label.start-step": "Aloitusvaihe",
"label.steps": "Vaiheet", "label.steps": "Vaiheet",
"label.sum": "Sum", "label.sum": "Summa",
"label.tablet": "Tabletti", "label.tablet": "Tabletti",
"label.tag": "Tunniste", "label.tag": "Tunniste",
"label.tags": "Tunnisteet", "label.tags": "Tunnisteet",
"label.team": "Team", "label.team": "Tiimi",
"label.team-id": "Team ID", "label.team-id": "Tiimin ID",
"label.team-manager": "Team manager", "label.team-manager": "Tiimin johtaja",
"label.team-member": "Team member", "label.team-member": "Tiimin jäsen",
"label.team-name": "Team name", "label.team-name": "Tiimin nimi",
"label.team-owner": "Team owner", "label.team-owner": "Tiimin omistaja",
"label.team-settings": "Tiimin asetukset", "label.team-settings": "Tiimin asetukset",
"label.team-view-only": "Team view only", "label.team-view-only": "Team view only",
"label.team-websites": "Team websites", "label.team-websites": "Tiimin verkkosivut",
"label.teams": "Teams", "label.teams": "Tiimit",
"label.terms": "Ehdot", "label.terms": "Ehdot",
"label.theme": "Teema", "label.theme": "Teema",
"label.this-month": "Tämä kuukausi", "label.this-month": "Tämä kuukausi",
"label.this-week": "Tämä viikko", "label.this-week": "Tämä viikko",
"label.this-year": "Tämä vuosi", "label.this-year": "Tämä vuosi",
"label.timezone": "Aikavyöhyke", "label.timezone": "Aikavyöhyke",
"label.title": "Title", "label.title": "Otsikko",
"label.today": "Tänään", "label.today": "Tänään",
"label.toggle-charts": "Kytke kaaviot päälle/pois", "label.toggle-charts": "Kytke kaaviot päälle/pois",
"label.total": "Total", "label.total": "Yhteensä",
"label.total-records": "Total records", "label.total-records": "Tietueita yhteensä",
"label.tracking-code": "Seurantakoodi", "label.tracking-code": "Seurantakoodi",
"label.transactions": "Transactions", "label.transactions": "Transaktiot",
"label.transfer": "Transfer", "label.transfer": "Siirrä",
"label.transfer-website": "Transfer website", "label.transfer-website": "Siirrä verkkosivu",
"label.true": "True", "label.true": "Tosi",
"label.type": "Type", "label.type": "Tyyppi",
"label.unique": "Unique", "label.unique": "Uniikki",
"label.unique-visitors": "Yksittäiset kävijät", "label.unique-visitors": "Yksittäiset kävijät",
"label.uniqueCustomers": "Unique Customers", "label.uniqueCustomers": "Uniikit asiakkaat",
"label.unknown": "Tuntematon", "label.unknown": "Tuntematon",
"label.untitled": "Untitled", "label.untitled": "Nimetön",
"label.update": "Update", "label.update": "Päivitä",
"label.user": "User", "label.user": "Käyttäjä",
"label.username": "Käyttäjänimi", "label.username": "Käyttäjänimi",
"label.users": "Users", "label.users": "Käyttäjät",
"label.utm": "UTM", "label.utm": "UTM",
"label.utm-description": "Track your campaigns through UTM parameters.", "label.utm-description": "Seuraa kampanjoitasi UTM-parametrien avulla.",
"label.value": "Value", "label.value": "Arvo",
"label.view": "View", "label.view": "Näytä",
"label.view-details": "Katso tiedot", "label.view-details": "Katso tiedot",
"label.view-only": "View only", "label.view-only": "Vain katselu",
"label.views": "Näyttökerrat", "label.views": "Näyttökerrat",
"label.views-per-visit": "Views per visit", "label.views-per-visit": "Katselukerrat vierailua kohti",
"label.visit-duration": "Keskimääräinen vierailuaika", "label.visit-duration": "Keskimääräinen vierailuaika",
"label.visitors": "Vierailijat", "label.visitors": "Vierailijat",
"label.visits": "Visits", "label.visits": "Vierailut",
"label.website": "Website", "label.website": "Verkkosivu",
"label.website-id": "Website ID", "label.website-id": "Verkkosivun ID",
"label.websites": "Verkkosivut", "label.websites": "Verkkosivut",
"label.window": "Window", "label.window": "Ikkuna",
"label.yesterday": "Yesterday", "label.yesterday": "Eilen",
"label.behavior": "Behavior", "label.behavior": "Käyttäytyminen",
"message.action-confirmation": "Type {confirmation} in the box below to confirm.", "message.action-confirmation": "Kirjoita {confirmation} alla olevaan kenttään vahvistaaksesi.",
"message.active-users": "{x} {x, plural, one {vierailija} other {vierailijaa}}", "message.active-users": "{x} {x, plural, one {vierailija} other {vierailijaa}}",
"message.bad-request": "Bad request", "message.bad-request": "Virheellinen pyyntö",
"message.collected-data": "Collected data", "message.collected-data": "Kerätty data",
"message.confirm-delete": "Haluatko varmasti poistaa sivuston {target}?", "message.confirm-delete": "Haluatko varmasti poistaa sivuston {target}?",
"message.confirm-leave": "Are you sure you want to leave {target}?", "message.confirm-leave": "Haluatko varmasti poistua {target}?",
"message.confirm-remove": "Are you sure you want to remove {target}?", "message.confirm-remove": "Haluatko varmasti poistaa {target}?",
"message.confirm-reset": "Haluatko varmasti poistaa sivuston {target} tilastot?", "message.confirm-reset": "Haluatko varmasti poistaa sivuston {target} tilastot?",
"message.delete-team-warning": "Deleting a team will also delete all team websites.", "message.delete-team-warning": "Tiimin poistaminen poistaa myös kaikki tiimin sivustot.",
"message.delete-website-warning": "Kaikki siihen liittyvät tiedot poistetaan.", "message.delete-website-warning": "Kaikki siihen liittyvät tiedot poistetaan.",
"message.error": "Jotain meni pieleen.", "message.error": "Jotain meni pieleen.",
"message.event-log": "{event} on {url}", "message.event-log": "{event} on {url}",
"message.forbidden": "Forbidden", "message.forbidden": "Kielletty",
"message.go-to-settings": "Mene asetuksiin", "message.go-to-settings": "Mene asetuksiin",
"message.incorrect-username-password": "Väärä käyttäjänimi/salasana.", "message.incorrect-username-password": "Väärä käyttäjänimi/salasana.",
"message.invalid-domain": "Virheellinen verkkotunnus", "message.invalid-domain": "Virheellinen verkkotunnus",
"message.min-password-length": "Minimum length of {n} characters", "message.min-password-length": "Vähimmäispituus {n} merkkiä",
"message.new-version-available": "A new version of Umami {version} is available!", "message.new-version-available": "Umamista on saatavilla uusi versio {version}!",
"message.no-data-available": "Tietoja ei ole käytettävissä.", "message.no-data-available": "Tietoja ei ole käytettävissä.",
"message.no-event-data": "No event data is available.", "message.no-event-data": "Tapahtumatietoja ei ole saatavilla.",
"message.no-match-password": "Salasanat eivät täsmää", "message.no-match-password": "Salasanat eivät täsmää",
"message.no-results-found": "No results were found.", "message.no-results-found": "Tuloksia ei löytynyt.",
"message.no-team-websites": "This team does not have any websites.", "message.no-team-websites": "Tällä tiimillä ei ole verkkosivustoja.",
"message.no-teams": "You have not created any teams.", "message.no-teams": "Et ole luonut yhtään tiimiä.",
"message.no-users": "There are no users.", "message.no-users": "Käyttäjiä ei ole.",
"message.no-websites-configured": "Sinulla ei ole määritettyjä verkkosivustoja.", "message.no-websites-configured": "Sinulla ei ole määritettyjä verkkosivustoja.",
"message.not-found": "Not found", "message.not-found": "Ei löytynyt",
"message.nothing-selected": "Nothing selected.", "message.nothing-selected": "Mitään ei ole valittu.",
"message.page-not-found": "Sivua ei löydetty.", "message.page-not-found": "Sivua ei löydetty.",
"message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", "message.reset-website": "Nollaa verkkosivusto kirjoittamalla {confirmation} alla olevaan kenttään vahvistaaksesi.",
"message.reset-website-warning": "Kaikki sivuston tilastot poistetaan, mutta seurantakoodi pysyy muuttumattomana.", "message.reset-website-warning": "Kaikki sivuston tilastot poistetaan, mutta seurantakoodi pysyy muuttumattomana.",
"message.saved": "Tallennettu onnistuneesti.", "message.saved": "Tallennettu onnistuneesti.",
"message.sever-error": "Server error", "message.sever-error": "Palvelinvirhe",
"message.share-url": "Tämä on julkisesti jaettu URL sivustolle {target}.", "message.share-url": "Tämä on julkisesti jaettu URL sivustolle {target}.",
"message.team-already-member": "You are already a member of the team.", "message.team-already-member": "Olet jo tiimissä.",
"message.team-not-found": "Team not found.", "message.team-not-found": "Tiimiä ei löydetty.",
"message.team-websites-info": "Websites can be viewed by anyone on the team.", "message.team-websites-info": "Verkkosivuja voi tarkastella kuka tahansa tiimin jäsen.",
"message.tracking-code": "Seurantakoodi", "message.tracking-code": "Seurantakoodi",
"message.transfer-team-website-to-user": "Transfer this website to your account?", "message.transfer-team-website-to-user": "Siirretäänkö tämä verkkosivu tilillesi?",
"message.transfer-user-website-to-team": "Select the team to transfer this website to.", "message.transfer-user-website-to-team": "Valitse tiimi, johon verkkosivusto siirretään.",
"message.transfer-website": "Transfer website ownership to your account or another team.", "message.transfer-website": "Siirrä verkkosivusto tiliisi tai toiselle tiimille.",
"message.triggered-event": "Triggered event", "message.triggered-event": "Laukaistu tapahtuma",
"message.unauthorized": "Unauthorized", "message.unauthorized": "Ei oikeuksia",
"message.user-deleted": "User deleted.", "message.user-deleted": "Käyttäjä poistettu.",
"message.viewed-page": "Viewed page", "message.viewed-page": "Katsottu sivu",
"message.visitor-log": "Vierailija maasta {country} selaimella {browser} laitteella {os} {device}" "message.visitor-log": "Vierailija maasta {country} selaimella {browser} laitteella {os} {device}"
} }

View file

@ -18,5 +18,5 @@ test('getIpAddress: Standard header', () => {
}); });
test('getIpAddress: No header', () => { test('getIpAddress: No header', () => {
expect(getIpAddress(new Headers())).toEqual(null); expect(getIpAddress(new Headers())).toEqual(undefined);
}); });

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