mirror of
https://github.com/umami-software/umami.git
synced 2026-02-15 18:15:35 +01:00
# 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:
commit
c3e0290e65
150 changed files with 3028 additions and 787 deletions
|
|
@ -3,14 +3,19 @@ import { Column } from '@umami/react-zen';
|
|||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { TeamsAddButton } from '../../teams/TeamsAddButton';
|
||||
import { AdminTeamsDataTable } from './AdminTeamsDataTable';
|
||||
|
||||
export function AdminTeamsPage() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const handleSave = () => {};
|
||||
|
||||
return (
|
||||
<Column gap="6" margin="2">
|
||||
<PageHeader title={formatMessage(labels.teams)} />
|
||||
<PageHeader title={formatMessage(labels.teams)}>
|
||||
<TeamsAddButton onSave={handleSave} isAdmin={true} />
|
||||
</PageHeader>
|
||||
<Panel>
|
||||
<AdminTeamsDataTable />
|
||||
</Panel>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
TextField,
|
||||
} from '@umami/react-zen';
|
||||
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
||||
import { messages } from '@/components/messages';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
|
||||
export function UserAddForm({ onSave, onClose }) {
|
||||
|
|
@ -37,7 +38,10 @@ export function UserAddForm({ onSave, onClose }) {
|
|||
<FormField
|
||||
label={formatMessage(labels.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" />
|
||||
</FormField>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import { DataGrid } from '@/components/common/DataGrid';
|
|||
import { useLinksQuery, useNavigation } from '@/components/hooks';
|
||||
import { LinksTable } from './LinksTable';
|
||||
|
||||
export function LinksDataTable() {
|
||||
export function LinksDataTable({ showActions = false }: { showActions?: boolean }) {
|
||||
const { teamId } = useNavigation();
|
||||
const query = useLinksQuery({ teamId });
|
||||
|
||||
return (
|
||||
<DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
|
||||
{({ data }) => <LinksTable data={data} />}
|
||||
{({ data }) => <LinksTable data={data} showActions={showActions} />}
|
||||
</DataGrid>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,21 +4,30 @@ import { LinksDataTable } from '@/app/(main)/links/LinksDataTable';
|
|||
import { PageBody } from '@/components/common/PageBody';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
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';
|
||||
|
||||
export function LinksPage() {
|
||||
const { user } = useLoginQuery();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
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 (
|
||||
<PageBody>
|
||||
<Column gap="6" margin="2">
|
||||
<PageHeader title={formatMessage(labels.links)}>
|
||||
<LinkAddButton teamId={teamId} />
|
||||
{showActions && <LinkAddButton teamId={teamId} />}
|
||||
</PageHeader>
|
||||
<Panel>
|
||||
<LinksDataTable />
|
||||
<LinksDataTable showActions={showActions} />
|
||||
</Panel>
|
||||
</Column>
|
||||
</PageBody>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import { useMessages, useNavigation, useSlug } from '@/components/hooks';
|
|||
import { LinkDeleteButton } from './LinkDeleteButton';
|
||||
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 { websiteId, renderUrl } = useNavigation();
|
||||
const { getSlugUrl } = useSlug('link');
|
||||
|
|
@ -36,16 +40,18 @@ export function LinksTable(props: DataTableProps) {
|
|||
<DataColumn id="created" label={formatMessage(labels.created)} width="200px">
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
<DataColumn id="action" align="end" width="100px">
|
||||
{({ id, name }: any) => {
|
||||
return (
|
||||
<Row>
|
||||
<LinkEditButton linkId={id} />
|
||||
<LinkDeleteButton linkId={id} websiteId={websiteId} name={name} />
|
||||
</Row>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
{showActions && (
|
||||
<DataColumn id="action" align="end" width="100px">
|
||||
{({ id, name }: any) => {
|
||||
return (
|
||||
<Row>
|
||||
<LinkEditButton linkId={id} />
|
||||
<LinkDeleteButton linkId={id} websiteId={websiteId} name={name} />
|
||||
</Row>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
)}
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import { DataGrid } from '@/components/common/DataGrid';
|
|||
import { useNavigation, usePixelsQuery } from '@/components/hooks';
|
||||
import { PixelsTable } from './PixelsTable';
|
||||
|
||||
export function PixelsDataTable() {
|
||||
export function PixelsDataTable({ showActions = false }: { showActions?: boolean }) {
|
||||
const { teamId } = useNavigation();
|
||||
const query = usePixelsQuery({ teamId });
|
||||
|
||||
return (
|
||||
<DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
|
||||
{({ data }) => <PixelsTable data={data} />}
|
||||
{({ data }) => <PixelsTable data={data} showActions={showActions} />}
|
||||
</DataGrid>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,22 +3,31 @@ import { Column } from '@umami/react-zen';
|
|||
import { PageBody } from '@/components/common/PageBody';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
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 { PixelsDataTable } from './PixelsDataTable';
|
||||
|
||||
export function PixelsPage() {
|
||||
const { user } = useLoginQuery();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
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 (
|
||||
<PageBody>
|
||||
<Column gap="6" margin="2">
|
||||
<PageHeader title={formatMessage(labels.pixels)}>
|
||||
<PixelAddButton teamId={teamId} />
|
||||
{showActions && <PixelAddButton teamId={teamId} />}
|
||||
</PageHeader>
|
||||
<Panel>
|
||||
<PixelsDataTable />
|
||||
<PixelsDataTable showActions={showActions} />
|
||||
</Panel>
|
||||
</Column>
|
||||
</PageBody>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import { useMessages, useNavigation, useSlug } from '@/components/hooks';
|
|||
import { PixelDeleteButton } from './PixelDeleteButton';
|
||||
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 { renderUrl } = useNavigation();
|
||||
const { getSlugUrl } = useSlug('pixel');
|
||||
|
|
@ -31,18 +35,20 @@ export function PixelsTable(props: DataTableProps) {
|
|||
<DataColumn id="created" label={formatMessage(labels.created)}>
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
<DataColumn id="action" align="end" width="100px">
|
||||
{(row: any) => {
|
||||
const { id, name } = row;
|
||||
{showActions && (
|
||||
<DataColumn id="action" align="end" width="100px">
|
||||
{(row: any) => {
|
||||
const { id, name } = row;
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<PixelEditButton pixelId={id} />
|
||||
<PixelDeleteButton pixelId={id} name={name} />
|
||||
</Row>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
return (
|
||||
<Row>
|
||||
<PixelEditButton pixelId={id} />
|
||||
<PixelDeleteButton pixelId={id} name={name} />
|
||||
</Row>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
)}
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,17 @@ import {
|
|||
TextField,
|
||||
} from '@umami/react-zen';
|
||||
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 { mutateAsync, error, isPending } = useUpdateQuery('/teams');
|
||||
|
||||
|
|
@ -26,6 +35,11 @@ export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose:
|
|||
<FormField name="name" label={formatMessage(labels.name)}>
|
||||
<TextField autoComplete="off" />
|
||||
</FormField>
|
||||
{isAdmin && (
|
||||
<FormField name="ownerId" label={formatMessage(labels.teamOwner)}>
|
||||
<UserSelect buttonProps={{ style: { outline: 'none' } }} />
|
||||
</FormField>
|
||||
)}
|
||||
<FormButtons>
|
||||
<Button isDisabled={isPending} onPress={onClose}>
|
||||
{formatMessage(labels.cancel)}
|
||||
|
|
|
|||
76
src/app/(main)/teams/TeamMemberAddForm.tsx
Normal file
76
src/app/(main)/teams/TeamMemberAddForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,7 +4,13 @@ import { Plus } from '@/components/icons';
|
|||
import { messages } from '@/components/messages';
|
||||
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 { toast } = useToast();
|
||||
const { touch } = useModified();
|
||||
|
|
@ -25,7 +31,7 @@ export function TeamsAddButton({ onSave }: { onSave?: () => void }) {
|
|||
</Button>
|
||||
<Modal>
|
||||
<Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}>
|
||||
{({ close }) => <TeamAddForm onSave={handleSave} onClose={close} />}
|
||||
{({ close }) => <TeamAddForm onSave={handleSave} onClose={close} isAdmin={isAdmin} />}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</DialogTrigger>
|
||||
|
|
|
|||
40
src/app/(main)/teams/TeamsMemberAddButton.tsx
Normal file
40
src/app/(main)/teams/TeamsMemberAddButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 { PageHeader } from '@/components/common/PageHeader';
|
||||
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 { labels } from '@/components/messages';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
import { TeamsMemberAddButton } from '../TeamsMemberAddButton';
|
||||
import { TeamEditForm } from './TeamEditForm';
|
||||
import { TeamManage } from './TeamManage';
|
||||
import { TeamMembersDataTable } from './TeamMembersDataTable';
|
||||
|
|
@ -13,6 +15,7 @@ export function TeamSettings({ teamId }: { teamId: string }) {
|
|||
const team: any = useTeam();
|
||||
const { user } = useLoginQuery();
|
||||
const { pathname } = useNavigation();
|
||||
const { formatMessage } = useMessages();
|
||||
|
||||
const isAdmin = pathname.includes('/admin');
|
||||
|
||||
|
|
@ -37,6 +40,10 @@ export function TeamSettings({ teamId }: { teamId: string }) {
|
|||
<TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} />
|
||||
</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} />
|
||||
</Panel>
|
||||
{isTeamOwner && (
|
||||
|
|
|
|||
|
|
@ -3,22 +3,31 @@ import { Column } from '@umami/react-zen';
|
|||
import { PageBody } from '@/components/common/PageBody';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
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 { WebsitesDataTable } from './WebsitesDataTable';
|
||||
|
||||
export function WebsitesPage() {
|
||||
const { user } = useLoginQuery();
|
||||
const { teamId } = useNavigation();
|
||||
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 (
|
||||
<PageBody>
|
||||
<Column gap="6" margin="2">
|
||||
<PageHeader title={formatMessage(labels.websites)}>
|
||||
<WebsiteAddButton teamId={teamId} />
|
||||
{showActions && <WebsiteAddButton teamId={teamId} />}
|
||||
</PageHeader>
|
||||
<Panel>
|
||||
<WebsitesDataTable teamId={teamId} />
|
||||
<WebsitesDataTable teamId={teamId} showActions={showActions} />
|
||||
</Panel>
|
||||
</Column>
|
||||
</PageBody>
|
||||
|
|
|
|||
|
|
@ -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 { useFields, useMessages } from '@/components/hooks';
|
||||
import { type FieldGroup, useFields, useMessages } from '@/components/hooks';
|
||||
|
||||
export function FieldSelectForm({
|
||||
selectedFields = [],
|
||||
|
|
@ -13,7 +13,7 @@ export function FieldSelectForm({
|
|||
}) {
|
||||
const [selected, setSelected] = useState(selectedFields);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { fields } = useFields();
|
||||
const { fields, groupLabels } = useFields();
|
||||
|
||||
const handleChange = (value: string[]) => {
|
||||
setSelected(value);
|
||||
|
|
@ -24,17 +24,38 @@ export function FieldSelectForm({
|
|||
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 (
|
||||
<Column gap="6">
|
||||
<List value={selected} onChange={handleChange} selectionMode="multiple">
|
||||
{fields.map(({ name, label }) => {
|
||||
return (
|
||||
<ListItem key={name} id={name}>
|
||||
{label}
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
<Column gap="3" overflowY="auto" maxHeight="400px">
|
||||
<List value={selected} onChange={handleChange} selectionMode="multiple">
|
||||
{groupLabels.map(({ key: groupKey, label }) => {
|
||||
const groupFields = groupedFields[groupKey];
|
||||
return (
|
||||
<ListSection key={groupKey} title={label}>
|
||||
{groupFields.map(field => (
|
||||
<ListItem key={field.name} id={field.name}>
|
||||
{field.filterLabel}
|
||||
</ListItem>
|
||||
))}
|
||||
</ListSection>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Column>
|
||||
<Grid columns="1fr 1fr" gap>
|
||||
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||
<Button onPress={handleApply} variant="primary">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Box, Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
|
||||
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 { ReportEditButton } from '@/components/input/ReportEditButton';
|
||||
import { ChangeLabel } from '@/components/metrics/ChangeLabel';
|
||||
|
|
@ -20,6 +20,8 @@ type FunnelResult = {
|
|||
|
||||
export function Funnel({ id, name, type, parameters, websiteId }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { pathname } = useNavigation();
|
||||
const isSharePage = pathname.includes('/share/');
|
||||
const { data, error, isLoading } = useResultQuery(type, {
|
||||
websiteId,
|
||||
...parameters,
|
||||
|
|
@ -36,21 +38,22 @@ export function Funnel({ id, name, type, parameters, websiteId }) {
|
|||
</Text>
|
||||
</Row>
|
||||
</Column>
|
||||
<Column>
|
||||
<ReportEditButton id={id} name={name} type={type}>
|
||||
{({ close }) => {
|
||||
return (
|
||||
<Dialog
|
||||
title={formatMessage(labels.funnel)}
|
||||
variant="modal"
|
||||
style={{ minHeight: 300, minWidth: 400 }}
|
||||
>
|
||||
<FunnelEditForm id={id} websiteId={websiteId} onClose={close} />
|
||||
</Dialog>
|
||||
);
|
||||
}}
|
||||
</ReportEditButton>
|
||||
</Column>
|
||||
{!isSharePage && (
|
||||
<Column>
|
||||
<ReportEditButton id={id} name={name} type={type}>
|
||||
{({ close }) => {
|
||||
return (
|
||||
<Dialog
|
||||
title={formatMessage(labels.funnel)}
|
||||
style={{ minHeight: 300, minWidth: 400 }}
|
||||
>
|
||||
<FunnelEditForm id={id} websiteId={websiteId} onClose={close} />
|
||||
</Dialog>
|
||||
);
|
||||
}}
|
||||
</ReportEditButton>
|
||||
</Column>
|
||||
)}
|
||||
</Grid>
|
||||
{data?.map(
|
||||
(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
|
|||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { SectionHeader } from '@/components/common/SectionHeader';
|
||||
import { useDateRange, useReportsQuery } from '@/components/hooks';
|
||||
import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
|
||||
import { Funnel } from './Funnel';
|
||||
import { FunnelAddButton } from './FunnelAddButton';
|
||||
|
||||
|
|
@ -13,13 +13,17 @@ export function FunnelsPage({ websiteId }: { websiteId: string }) {
|
|||
const {
|
||||
dateRange: { startDate, endDate },
|
||||
} = useDateRange();
|
||||
const { pathname } = useNavigation();
|
||||
const isSharePage = pathname.includes('/share/');
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<SectionHeader>
|
||||
<FunnelAddButton websiteId={websiteId} />
|
||||
</SectionHeader>
|
||||
{!isSharePage && (
|
||||
<SectionHeader>
|
||||
<FunnelAddButton websiteId={websiteId} />
|
||||
</SectionHeader>
|
||||
)}
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||
{data && (
|
||||
<Grid gap>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
|
||||
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 { ReportEditButton } from '@/components/input/ReportEditButton';
|
||||
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) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { pathname } = useNavigation();
|
||||
const isSharePage = pathname.includes('/share/');
|
||||
const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, {
|
||||
websiteId,
|
||||
startDate,
|
||||
|
|
@ -45,21 +47,23 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
|
|||
</Text>
|
||||
</Row>
|
||||
</Column>
|
||||
<Column>
|
||||
<ReportEditButton id={id} name={name} type={type}>
|
||||
{({ close }) => {
|
||||
return (
|
||||
<Dialog
|
||||
title={formatMessage(labels.goal)}
|
||||
variant="modal"
|
||||
style={{ minHeight: 300, minWidth: 400 }}
|
||||
>
|
||||
<GoalEditForm id={id} websiteId={websiteId} onClose={close} />
|
||||
</Dialog>
|
||||
);
|
||||
}}
|
||||
</ReportEditButton>
|
||||
</Column>
|
||||
{!isSharePage && (
|
||||
<Column>
|
||||
<ReportEditButton id={id} name={name} type={type}>
|
||||
{({ close }) => {
|
||||
return (
|
||||
<Dialog
|
||||
title={formatMessage(labels.goal)}
|
||||
variant="modal"
|
||||
style={{ minHeight: 300, minWidth: 400 }}
|
||||
>
|
||||
<GoalEditForm id={id} websiteId={websiteId} onClose={close} />
|
||||
</Dialog>
|
||||
);
|
||||
}}
|
||||
</ReportEditButton>
|
||||
</Column>
|
||||
)}
|
||||
</Grid>
|
||||
<Row alignItems="center" justifyContent="space-between" gap>
|
||||
<Text color="muted">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
|
|||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { SectionHeader } from '@/components/common/SectionHeader';
|
||||
import { useDateRange, useReportsQuery } from '@/components/hooks';
|
||||
import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
|
||||
import { Goal } from './Goal';
|
||||
import { GoalAddButton } from './GoalAddButton';
|
||||
|
||||
|
|
@ -13,13 +13,17 @@ export function GoalsPage({ websiteId }: { websiteId: string }) {
|
|||
const {
|
||||
dateRange: { startDate, endDate },
|
||||
} = useDateRange();
|
||||
const { pathname } = useNavigation();
|
||||
const isSharePage = pathname.includes('/share/');
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<SectionHeader>
|
||||
<GoalAddButton websiteId={websiteId} />
|
||||
</SectionHeader>
|
||||
{!isSharePage && (
|
||||
<SectionHeader>
|
||||
<GoalAddButton websiteId={websiteId} />
|
||||
</SectionHeader>
|
||||
)}
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||
{data && (
|
||||
<Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
|
||||
|
|
|
|||
|
|
@ -21,9 +21,15 @@ export interface JourneyProps {
|
|||
steps: number;
|
||||
startStep?: 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 [activeNode, setActiveNode] = useState(null);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
|
@ -32,6 +38,8 @@ export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps)
|
|||
steps,
|
||||
startStep,
|
||||
endStep,
|
||||
view,
|
||||
eventType: EVENT_TYPES[view],
|
||||
});
|
||||
|
||||
useEscapeKey(() => setSelectedNode(null));
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
'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 { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useDateRange, useMessages } from '@/components/hooks';
|
||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
||||
import { Journey } from './Journey';
|
||||
|
||||
const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7];
|
||||
|
|
@ -14,10 +15,26 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
|
|||
const {
|
||||
dateRange: { startDate, endDate },
|
||||
} = useDateRange();
|
||||
const [view, setView] = useState('all');
|
||||
const [steps, setSteps] = useState(DEFAULT_STEP);
|
||||
const [startStep, setStartStep] = 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 (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
|
|
@ -52,6 +69,9 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
|
|||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
<Row justifyContent="flex-end">
|
||||
<FilterButtons items={buttons} value={view} onChange={setView} />
|
||||
</Row>
|
||||
<Panel height="900px" allowFullscreen>
|
||||
<Journey
|
||||
websiteId={websiteId}
|
||||
|
|
@ -60,6 +80,7 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
|
|||
steps={steps}
|
||||
startStep={startStep}
|
||||
endStep={endStep}
|
||||
view={view}
|
||||
/>
|
||||
</Panel>
|
||||
</Column>
|
||||
|
|
|
|||
|
|
@ -21,30 +21,28 @@ export function WebsiteChart({
|
|||
const { pageviews, sessions, compare } = (data || {}) as any;
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (data) {
|
||||
const result = {
|
||||
pageviews,
|
||||
sessions,
|
||||
};
|
||||
if (!data) {
|
||||
return { pageviews: [], sessions: [] };
|
||||
}
|
||||
|
||||
if (compare) {
|
||||
result.compare = {
|
||||
pageviews: result.pageviews.map(({ x }, i) => ({
|
||||
return {
|
||||
pageviews,
|
||||
sessions,
|
||||
...(compare && {
|
||||
compare: {
|
||||
pageviews: pageviews.map(({ x }, i) => ({
|
||||
x,
|
||||
y: compare.pageviews[i]?.y,
|
||||
d: compare.pageviews[i]?.x,
|
||||
})),
|
||||
sessions: result.sessions.map(({ x }, i) => ({
|
||||
sessions: sessions.map(({ x }, i) => ({
|
||||
x,
|
||||
y: compare.sessions[i]?.y,
|
||||
d: compare.sessions[i]?.x,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
return { pageviews: [], sessions: [] };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}, [data, startDate, endDate, unit]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export function WebsiteControls({
|
|||
}: {
|
||||
websiteId: string;
|
||||
allowFilter?: boolean;
|
||||
allowBounceFilter?: boolean;
|
||||
allowDateFilter?: boolean;
|
||||
allowMonthFilter?: boolean;
|
||||
allowDownload?: boolean;
|
||||
|
|
@ -23,8 +24,8 @@ export function WebsiteControls({
|
|||
return (
|
||||
<Column gap>
|
||||
<Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap>
|
||||
<Row alignItems="center" justifyContent="flex-start">
|
||||
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
|
||||
<Row alignItems="center" justifyContent="flex-start" gap="4">
|
||||
{allowFilter && <WebsiteFilterButton websiteId={websiteId} />}
|
||||
</Row>
|
||||
<Row alignItems="center" justifyContent={{ xs: 'flex-start', md: 'flex-end' }}>
|
||||
{allowDateFilter && (
|
||||
|
|
|
|||
|
|
@ -4,10 +4,14 @@ import {
|
|||
AppWindow,
|
||||
Cpu,
|
||||
Earth,
|
||||
Fingerprint,
|
||||
Globe,
|
||||
KeyRound,
|
||||
Landmark,
|
||||
Languages,
|
||||
Laptop,
|
||||
Layers,
|
||||
Link2,
|
||||
LogIn,
|
||||
LogOut,
|
||||
MapPin,
|
||||
|
|
@ -15,9 +19,11 @@ import {
|
|||
Monitor,
|
||||
Network,
|
||||
Search,
|
||||
Send,
|
||||
Share2,
|
||||
SquareSlash,
|
||||
Tag,
|
||||
Target,
|
||||
Type,
|
||||
} from '@/components/icons';
|
||||
import { Lightning } from '@/components/svg';
|
||||
|
|
@ -154,6 +160,41 @@ export function WebsiteExpandedMenu({
|
|||
},
|
||||
].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),
|
||||
items: [
|
||||
|
|
@ -169,6 +210,12 @@ export function WebsiteExpandedMenu({
|
|||
path: updateParams({ view: 'hostname' }),
|
||||
icon: <Network />,
|
||||
},
|
||||
{
|
||||
id: 'distinctId',
|
||||
label: formatMessage(labels.distinctId),
|
||||
path: updateParams({ view: 'distinctId' }),
|
||||
icon: <Fingerprint />,
|
||||
},
|
||||
{
|
||||
id: 'tag',
|
||||
label: formatMessage(labels.tag),
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import { Row } from '@umami/react-zen';
|
||||
import { WebsiteShareForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm';
|
||||
import { Icon, Row, Text } from '@umami/react-zen';
|
||||
import { Favicon } from '@/components/common/Favicon';
|
||||
import { IconLabel } from '@/components/common/IconLabel';
|
||||
import { LinkButton } from '@/components/common/LinkButton';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
|
||||
import { Edit, Share } from '@/components/icons';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
import { Edit } from '@/components/icons';
|
||||
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 { renderUrl, pathname } = useNavigation();
|
||||
const isSettings = pathname.endsWith('/settings');
|
||||
|
|
@ -24,32 +27,20 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
|
|||
<PageHeader
|
||||
title={website.name}
|
||||
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">
|
||||
<ActiveUsers websiteId={website.id} />
|
||||
|
||||
{showActions && (
|
||||
<Row alignItems="center" gap>
|
||||
<ShareButton websiteId={website?.id} shareId={website?.shareId} />
|
||||
<LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
|
||||
<IconLabel icon={<Edit />}>{formatMessage(labels.edit)}</IconLabel>
|
||||
</LinkButton>
|
||||
</Row>
|
||||
<LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
|
||||
<Icon>
|
||||
<Edit />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.edit)}</Text>
|
||||
</LinkButton>
|
||||
)}
|
||||
</Row>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,11 +9,10 @@ import { WebsiteNav } from './WebsiteNav';
|
|||
export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
|
||||
return (
|
||||
<WebsiteProvider websiteId={websiteId}>
|
||||
<Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
|
||||
<Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%">
|
||||
<Column
|
||||
display={{ xs: 'none', lg: 'flex' }}
|
||||
width="240px"
|
||||
height="100%"
|
||||
border="right"
|
||||
backgroundColor
|
||||
marginRight="2"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from '@umami/react-zen';
|
||||
import { Fragment } from 'react';
|
||||
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 }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
|
@ -33,7 +33,7 @@ export function WebsiteMenu({ websiteId }: { websiteId: string }) {
|
|||
<MenuTrigger>
|
||||
<Button variant="quiet">
|
||||
<Icon>
|
||||
<More />
|
||||
<MoreHorizontal />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Popover placement="bottom">
|
||||
|
|
|
|||
|
|
@ -7,14 +7,18 @@ import { formatLongNumber, formatShortTime } from '@/lib/format';
|
|||
|
||||
export function WebsiteMetricsBar({
|
||||
websiteId,
|
||||
compareMode,
|
||||
}: {
|
||||
websiteId: string;
|
||||
showChange?: boolean;
|
||||
compareMode?: boolean;
|
||||
}) {
|
||||
const { isAllTime } = useDateRange();
|
||||
const { isAllTime, dateCompare } = useDateRange();
|
||||
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 || {};
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export function WebsiteNav({
|
|||
event: undefined,
|
||||
compare: undefined,
|
||||
view: undefined,
|
||||
unit: undefined,
|
||||
excludeBounce: undefined,
|
||||
});
|
||||
|
||||
const items = [
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { Column, Row } from '@umami/react-zen';
|
||||
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { UnitFilter } from '@/components/input/UnitFilter';
|
||||
import { WebsiteChart } from './WebsiteChart';
|
||||
import { WebsiteControls } from './WebsiteControls';
|
||||
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
|
||||
|
|
@ -10,9 +11,12 @@ import { WebsitePanels } from './WebsitePanels';
|
|||
export function WebsitePage({ websiteId }: { websiteId: string }) {
|
||||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<WebsiteControls websiteId={websiteId} allowBounceFilter={true} />
|
||||
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
|
||||
<Panel minHeight="520px">
|
||||
<Row justifyContent="end">
|
||||
<UnitFilter />
|
||||
</Row>
|
||||
<WebsiteChart websiteId={websiteId} />
|
||||
</Panel>
|
||||
<WebsitePanels websiteId={websiteId} />
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
import { Grid, Heading, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
|
||||
import { GridRow } from '@/components/common/GridRow';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import { EventsChart } from '@/components/metrics/EventsChart';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||
import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic';
|
||||
import { WorldMap } from '@/components/metrics/WorldMap';
|
||||
|
||||
export function WebsitePanels({ websiteId }: { websiteId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { pathname } = useNavigation();
|
||||
const tableProps = {
|
||||
websiteId,
|
||||
limit: 10,
|
||||
|
|
@ -18,7 +16,6 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
|
|||
metric: formatMessage(labels.visitors),
|
||||
};
|
||||
const rowProps = { minHeight: '570px' };
|
||||
const isSharePage = pathname.includes('/share/');
|
||||
|
||||
return (
|
||||
<Grid gap="3">
|
||||
|
|
@ -116,25 +113,6 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
|
|||
<WeeklyTraffic websiteId={websiteId} />
|
||||
</Panel>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export function ComparePage({ websiteId }: { websiteId: string }) {
|
|||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} allowCompare={true} />
|
||||
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
|
||||
<WebsiteMetricsBar websiteId={websiteId} compareMode={true} showChange={true} />
|
||||
<Panel minHeight="520px">
|
||||
<WebsiteChart websiteId={websiteId} compareMode={true} />
|
||||
</Panel>
|
||||
|
|
|
|||
|
|
@ -88,11 +88,41 @@ export function CompareTables({ websiteId }: { websiteId: string }) {
|
|||
label: formatMessage(labels.events),
|
||||
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',
|
||||
label: formatMessage(labels.hostname),
|
||||
path: renderPath('hostname'),
|
||||
},
|
||||
{
|
||||
id: 'distinctId',
|
||||
label: formatMessage(labels.distinctId),
|
||||
path: renderPath('distinctId'),
|
||||
},
|
||||
{
|
||||
id: 'tag',
|
||||
label: formatMessage(labels.tags),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
'use client';
|
||||
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 { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery';
|
||||
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 { formatLongNumber } from '@/lib/format';
|
||||
import { getItem, setItem } from '@/lib/storage';
|
||||
import { EventProperties } from './EventProperties';
|
||||
import { EventsDataTable } from './EventsDataTable';
|
||||
|
|
@ -15,16 +21,61 @@ const KEY_NAME = 'umami.events.tab';
|
|||
|
||||
export function EventsPage({ websiteId }) {
|
||||
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) => {
|
||||
setItem(KEY_NAME, 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 (
|
||||
<Column gap="3">
|
||||
<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>
|
||||
<Tabs selectedKey={tab} onSelectionChange={key => handleSelect(key)}>
|
||||
<TabList>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,19 @@ export function EventsTable(props: DataTableProps) {
|
|||
const { updateParams } = useNavigation();
|
||||
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 (
|
||||
<DataTable {...props}>
|
||||
<DataColumn id="event" label={formatMessage(labels.event)} width="2fr">
|
||||
|
|
@ -43,7 +56,7 @@ export function EventsTable(props: DataTableProps) {
|
|||
title={row.eventName || row.urlPath}
|
||||
truncate
|
||||
>
|
||||
{row.eventName || row.urlPath}
|
||||
{row.eventName || renderLink(row.urlPath, row.hostname)}
|
||||
</Text>
|
||||
{row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />}
|
||||
</Row>
|
||||
|
|
|
|||
|
|
@ -75,8 +75,9 @@ export function RealtimeLog({ data }: { data: any }) {
|
|||
os: string;
|
||||
country: 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) {
|
||||
return (
|
||||
|
|
@ -87,7 +88,8 @@ export function RealtimeLog({ data }: { data: any }) {
|
|||
url: (
|
||||
<a
|
||||
key="a"
|
||||
href={`//${website?.domain}${urlPath}`}
|
||||
href={`//${hostname}${urlPath}`}
|
||||
style={{ fontWeight: 'bold' }}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
|
|
@ -101,7 +103,12 @@ export function RealtimeLog({ data }: { data: any }) {
|
|||
|
||||
if (__type === TYPE_PAGEVIEW) {
|
||||
return (
|
||||
<a href={`//${website?.domain}${urlPath}`} target="_blank" rel="noreferrer noopener">
|
||||
<a
|
||||
href={`//${hostname}${urlPath}`}
|
||||
style={{ fontWeight: 'bold' }}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{urlPath}
|
||||
</a>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -39,10 +39,23 @@ export function SessionActivity({
|
|||
const { isMobile } = useMobile();
|
||||
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 (
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||
<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));
|
||||
lastDay = createdAt;
|
||||
|
||||
|
|
@ -61,7 +74,7 @@ export function SessionActivity({
|
|||
: formatMessage(labels.viewedPage)}
|
||||
</Text>
|
||||
<Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate>
|
||||
{eventName || urlPath}
|
||||
{eventName || renderLink(urlPath, hostname)}
|
||||
</Text>
|
||||
{hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />}
|
||||
</Row>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
171
src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx
Normal file
171
src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx
Normal file
52
src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
import { Column } from '@umami/react-zen';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useWebsite } from '@/components/hooks';
|
||||
import { WebsiteData } from './WebsiteData';
|
||||
import { WebsiteEditForm } from './WebsiteEditForm';
|
||||
import { WebsiteShareForm } from './WebsiteShareForm';
|
||||
import { WebsiteTrackingCode } from './WebsiteTrackingCode';
|
||||
|
||||
export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) {
|
||||
const website = useWebsite();
|
||||
|
||||
return (
|
||||
<Column gap="6">
|
||||
<Panel>
|
||||
|
|
@ -18,7 +15,7 @@ export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal
|
|||
<WebsiteTrackingCode websiteId={websiteId} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<WebsiteShareForm websiteId={websiteId} shareId={website.shareId} />
|
||||
<WebsiteShareForm websiteId={websiteId} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<WebsiteData websiteId={websiteId} />
|
||||
|
|
|
|||
|
|
@ -1,93 +1,47 @@
|
|||
import {
|
||||
Button,
|
||||
Column,
|
||||
Form,
|
||||
FormButtons,
|
||||
FormSubmitButton,
|
||||
Label,
|
||||
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);
|
||||
import { Column, Heading, Row, Text } from '@umami/react-zen';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useMessages, useWebsiteSharesQuery } from '@/components/hooks';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
import { ShareEditForm } from './ShareEditForm';
|
||||
import { SharesTable } from './SharesTable';
|
||||
|
||||
export interface WebsiteShareFormProps {
|
||||
websiteId: string;
|
||||
shareId?: string;
|
||||
onSave?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: WebsiteShareFormProps) {
|
||||
const { formatMessage, labels, messages, getErrorMessage } = useMessages();
|
||||
const [currentId, setCurrentId] = useState(shareId);
|
||||
const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`);
|
||||
const { cloudMode } = useConfig();
|
||||
export function WebsiteShareForm({ websiteId }: WebsiteShareFormProps) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { data, error, isLoading } = useWebsiteSharesQuery({ websiteId });
|
||||
|
||||
const getUrl = (shareId: string) => {
|
||||
if (cloudMode) {
|
||||
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?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
const shares = data?.data || [];
|
||||
const hasShares = shares.length > 0;
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSave} error={getErrorMessage(error)} values={{ url }}>
|
||||
<Column gap>
|
||||
<Switch isSelected={!!currentId} onChange={handleSwitch}>
|
||||
{formatMessage(labels.enableShareUrl)}
|
||||
</Switch>
|
||||
{currentId && (
|
||||
<Row alignItems="flex-end" gap>
|
||||
<Column flexGrow={1}>
|
||||
<Label>{formatMessage(labels.shareUrl)}</Label>
|
||||
<TextField value={url} isReadOnly allowCopy />
|
||||
</Column>
|
||||
<Column>
|
||||
<Button onPress={handleGenerate}>
|
||||
<IconLabel icon={<RefreshCcw />} label={formatMessage(labels.regenerate)} />
|
||||
</Button>
|
||||
</Column>
|
||||
</Row>
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||
<Column gap="4">
|
||||
<Row justifyContent="space-between" alignItems="center">
|
||||
<Heading>{formatMessage(labels.share)}</Heading>
|
||||
<DialogButton
|
||||
icon={<Plus size={16} />}
|
||||
label={formatMessage(labels.add)}
|
||||
title={formatMessage(labels.share)}
|
||||
variant="primary"
|
||||
width="600px"
|
||||
>
|
||||
{({ close }) => <ShareEditForm websiteId={websiteId} onClose={close} />}
|
||||
</DialogButton>
|
||||
</Row>
|
||||
{hasShares ? (
|
||||
<>
|
||||
<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>
|
||||
</Form>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
30
src/app/(main)/websites/[websiteId]/settings/constants.ts
Normal file
30
src/app/(main)/websites/[websiteId]/settings/constants.ts
Normal 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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
import redis from '@/lib/redis';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { ok } from '@/lib/response';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { error } = await parseRequest(request);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
if (redis.enabled) {
|
||||
const token = request.headers.get('authorization')?.split(' ')?.[1];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { saveAuth } from '@/lib/auth';
|
||||
import redis from '@/lib/redis';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json } from '@/lib/response';
|
||||
import { json, serverError } from '@/lib/response';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { auth, error } = await parseRequest(request);
|
||||
|
|
@ -10,9 +10,13 @@ export async function POST(request: Request) {
|
|||
return error();
|
||||
}
|
||||
|
||||
if (redis.enabled) {
|
||||
const token = await saveAuth({ userId: auth.user.id }, 86400);
|
||||
|
||||
return json({ user: auth.user, token });
|
||||
if (!redis.enabled) {
|
||||
return serverError({
|
||||
message: 'Redis is disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const token = await saveAuth({ userId: auth.user.id }, 86400);
|
||||
|
||||
return json({ user: auth.user, token });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,16 @@ export async function POST(request: Request) {
|
|||
}
|
||||
|
||||
const { websiteId, parameters, filters } = body;
|
||||
const { eventType } = parameters;
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
if (eventType) {
|
||||
filters.eventType = eventType;
|
||||
}
|
||||
|
||||
const queryFilters = await getQueryFilters(filters, websiteId);
|
||||
|
||||
const data = await getJourney(websiteId, parameters, queryFilters);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { startOfHour, startOfMonth } from 'date-fns';
|
||||
import { startOfHour } from 'date-fns';
|
||||
import { isbot } from 'isbot';
|
||||
import { serializeError } from 'serialize-error';
|
||||
import { z } from 'zod';
|
||||
import clickhouse from '@/lib/clickhouse';
|
||||
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 { createToken, parseToken } from '@/lib/jwt';
|
||||
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 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 sessionId = id ? uuid(sourceId, id) : uuid(sourceId, ip, userAgent, sessionSalt);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,47 @@
|
|||
import { ROLES } from '@/lib/constants';
|
||||
import { secret } from '@/lib/crypto';
|
||||
import { createToken } from '@/lib/jwt';
|
||||
import prisma from '@/lib/prisma';
|
||||
import redis from '@/lib/redis';
|
||||
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 }> }) {
|
||||
const { slug } = await params;
|
||||
|
|
@ -12,8 +52,25 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu
|
|||
return notFound();
|
||||
}
|
||||
|
||||
const data = { shareId: share.id };
|
||||
const token = createToken(data, secret());
|
||||
const website = await getWebsite(share.entityId);
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }> }) {
|
||||
const schema = z.object({
|
||||
name: z.string().max(200),
|
||||
slug: z.string().max(100),
|
||||
parameters: anyObjectParam,
|
||||
});
|
||||
|
|
@ -36,7 +37,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ sha
|
|||
}
|
||||
|
||||
const { shareId } = await params;
|
||||
const { slug, parameters } = body;
|
||||
const { name, slug, parameters } = body;
|
||||
|
||||
const share = await getShare(shareId);
|
||||
|
||||
|
|
@ -49,6 +50,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ sha
|
|||
}
|
||||
|
||||
const result = await updateShare(shareId, {
|
||||
name,
|
||||
slug,
|
||||
parameters,
|
||||
} as any);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import z from 'zod';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { getRandomChars } from '@/lib/generate';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { anyObjectParam } from '@/lib/schema';
|
||||
|
|
@ -10,7 +11,8 @@ export async function POST(request: Request) {
|
|||
const schema = z.object({
|
||||
entityId: z.uuid(),
|
||||
shareType: z.coerce.number().int(),
|
||||
slug: z.string().max(100),
|
||||
name: z.string().max(200),
|
||||
slug: z.string().max(100).optional(),
|
||||
parameters: anyObjectParam,
|
||||
});
|
||||
|
||||
|
|
@ -20,7 +22,8 @@ export async function POST(request: Request) {
|
|||
return error();
|
||||
}
|
||||
|
||||
const { entityId, shareType, slug, parameters } = body;
|
||||
const { entityId, shareType, name, slug, parameters } = body;
|
||||
const shareParameters = parameters ?? {};
|
||||
|
||||
if (!(await canUpdateEntity(auth, entityId))) {
|
||||
return unauthorized();
|
||||
|
|
@ -30,8 +33,9 @@ export async function POST(request: Request) {
|
|||
id: uuid(),
|
||||
entityId,
|
||||
shareType,
|
||||
slug,
|
||||
parameters,
|
||||
name,
|
||||
slug: slug || getRandomChars(16),
|
||||
parameters: shareParameters,
|
||||
});
|
||||
|
||||
return json(share);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export async function GET(request: Request) {
|
|||
export async function POST(request: Request) {
|
||||
const schema = z.object({
|
||||
name: z.string().max(50),
|
||||
ownerId: z.uuid().optional(),
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
|
@ -40,7 +41,7 @@ export async function POST(request: Request) {
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const { name } = body;
|
||||
const { name, ownerId } = body;
|
||||
|
||||
const team = await createTeam(
|
||||
{
|
||||
|
|
@ -48,7 +49,7 @@ export async function POST(request: Request) {
|
|||
name,
|
||||
accessCode: `team_${getRandomChars(16)}`,
|
||||
},
|
||||
auth.user.id,
|
||||
ownerId ?? auth.user.id,
|
||||
);
|
||||
|
||||
return json(team);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
import { hashPassword } from '@/lib/password';
|
||||
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 { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions';
|
||||
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 }> }) {
|
||||
const schema = z.object({
|
||||
username: z.string().max(255).optional(),
|
||||
password: z.string().max(255).optional(),
|
||||
password: z.string().min(8).max(255).optional(),
|
||||
role: userRoleParam.optional(),
|
||||
});
|
||||
|
||||
|
|
@ -47,6 +47,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ use
|
|||
|
||||
const user = await getUser(userId);
|
||||
|
||||
if (!user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const data: any = {};
|
||||
|
||||
if (password) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { uuid } from '@/lib/crypto';
|
|||
import { hashPassword } from '@/lib/password';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { badRequest, json, unauthorized } from '@/lib/response';
|
||||
import { userRoleParam } from '@/lib/schema';
|
||||
import { canCreateUser } from '@/permissions';
|
||||
import { createUser, getUserByUsername } from '@/queries/prisma';
|
||||
|
||||
|
|
@ -11,8 +12,8 @@ export async function POST(request: Request) {
|
|||
const schema = z.object({
|
||||
id: z.uuid().optional(),
|
||||
username: z.string().max(255),
|
||||
password: z.string(),
|
||||
role: z.string().regex(/admin|user|view-only/i),
|
||||
password: z.string().min(8).max(255),
|
||||
role: userRoleParam,
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
|
|
|||
34
src/app/api/websites/[websiteId]/events/stats/route.ts
Normal file
34
src/app/api/websites/[websiteId]/events/stats/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -1,9 +1,17 @@
|
|||
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 { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
|
||||
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(
|
||||
request: Request,
|
||||
|
|
@ -33,7 +41,7 @@ export async function POST(
|
|||
const schema = z.object({
|
||||
name: 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);
|
||||
|
|
@ -50,11 +58,29 @@ export async function POST(
|
|||
}
|
||||
|
||||
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) {
|
||||
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.' });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { z } from 'zod';
|
|||
import { uuid } from '@/lib/crypto';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
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 { createSegment, getWebsiteSegments } from '@/queries/prisma';
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ export async function POST(
|
|||
const schema = z.object({
|
||||
type: segmentTypeParam,
|
||||
name: z.string().max(200),
|
||||
parameters: anyObjectParam,
|
||||
parameters: segmentParamSchema,
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
|
|
|||
77
src/app/api/websites/[websiteId]/shares/route.ts
Normal file
77
src/app/api/websites/[websiteId]/shares/route.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -31,9 +31,11 @@ export async function GET(
|
|||
|
||||
const data = await getWebsiteStats(websiteId, filters);
|
||||
|
||||
const compare = filters.compare ?? 'prev';
|
||||
|
||||
const { startDate, endDate } = getCompareDate(compare, filters.startDate, filters.endDate);
|
||||
const { startDate, endDate } = getCompareDate(
|
||||
filters.compare ?? 'prev',
|
||||
filters.startDate,
|
||||
filters.endDate,
|
||||
);
|
||||
|
||||
const comparison = await getWebsiteStats(websiteId, {
|
||||
...filters,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { z } from 'zod';
|
||||
import { ENTITY_TYPE } from '@/lib/constants';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { fetchAccount } from '@/lib/load';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams } from '@/lib/schema';
|
||||
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';
|
||||
|
||||
const CLOUD_WEBSITE_LIMIT = 3;
|
||||
|
|
@ -72,7 +73,6 @@ export async function POST(request: Request) {
|
|||
createdBy: auth.user.id,
|
||||
name,
|
||||
domain,
|
||||
shareId,
|
||||
teamId,
|
||||
};
|
||||
|
||||
|
|
@ -82,5 +82,19 @@ export async function POST(request: Request) {
|
|||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
77
src/app/share/ShareProvider.tsx
Normal file
77
src/app/share/ShareProvider.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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]} />;
|
||||
}
|
||||
206
src/app/share/[slug]/[[...path]]/ShareNav.tsx
Normal file
206
src/app/share/[slug]/[[...path]]/ShareNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
src/app/share/[slug]/[[...path]]/SharePage.tsx
Normal file
103
src/app/share/[slug]/[[...path]]/SharePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/share/[slug]/[[...path]]/page.tsx
Normal file
5
src/app/share/[slug]/[[...path]]/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { SharePage } from './SharePage';
|
||||
|
||||
export default function () {
|
||||
return <SharePage />;
|
||||
}
|
||||
13
src/app/share/[slug]/layout.tsx
Normal file
13
src/app/share/[slug]/layout.tsx
Normal 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>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue