Compare commits

..

No commits in common. "abfb78bb980ea078d6110738c24bf315221be4fb" and "a37de757a098ed7a64525246216b0f30b2c8139c" have entirely different histories.

52 changed files with 855 additions and 809 deletions

94
.gitignore vendored
View file

@ -1,48 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies
node_modules node_modules
.pnp .pnp
.pnp.js .pnp.js
.pnpm-store .pnpm-store
package-lock.json package-lock.json
# testing # testing
/coverage /coverage
# next.js # next.js
/.next /.next
/out /out
# production # production
/build /build
/public/script.js /public/script.js
/geo /geo
/dist /dist
/generated /generated
/src/generated /src/generated
pm2.yml pm2.yml
# misc # misc
.DS_Store .DS_Store
.idea .idea
.yarn .yarn
*.iml *.iml
*.log *.log
.vscode .vscode
.tool-versions .tool-versions
.claude
nul # debug
npm-debug.log*
# debug yarn-debug.log*
npm-debug.log* yarn-error.log*
yarn-debug.log*
yarn-error.log* # local env files
.env
# local env files .env.*
.env *.env.*
.env.*
*.env.* *.dev.yml
*.dev.yml

View file

@ -11,7 +11,7 @@
}, },
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev -p 3002 --turbo", "dev": "next dev -p 3001 --turbo",
"build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app", "build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app",
"start": "next start", "start": "next start",
"build-docker": "npm-run-all build-db build-tracker build-geo build-app", "build-docker": "npm-run-all build-db build-tracker build-geo build-app",
@ -102,7 +102,7 @@
"kafkajs": "^2.1.0", "kafkajs": "^2.1.0",
"lucide-react": "^0.543.0", "lucide-react": "^0.543.0",
"maxmind": "^5.0.0", "maxmind": "^5.0.0",
"next": "^15.5.10", "next": "^15.5.9",
"node-fetch": "^3.2.8", "node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
@ -164,7 +164,7 @@
"stylelint-config-css-modules": "^4.5.1", "stylelint-config-css-modules": "^4.5.1",
"stylelint-config-prettier": "^9.0.3", "stylelint-config-prettier": "^9.0.3",
"stylelint-config-recommended": "^14.0.0", "stylelint-config-recommended": "^14.0.0",
"tar": "^7.5.7", "tar": "^7.5.4",
"ts-jest": "^29.4.6", "ts-jest": "^29.4.6",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tsup": "^8.5.0", "tsup": "^8.5.0",

617
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -42,7 +42,7 @@ export function MobileNav() {
{({ close }) => { {({ close }) => {
return ( return (
<> <>
<NavMenu padding="3" onItemClick={close} border="bottom" width="100%"> <NavMenu padding="3" onItemClick={close} border="bottom">
<NavButton /> <NavButton />
{links.map(link => { {links.map(link => {
return ( return (

View file

@ -14,6 +14,7 @@ export function PixelsTable({ showActions, ...props }: PixelsTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { renderUrl } = useNavigation(); const { renderUrl } = useNavigation();
const { getSlugUrl } = useSlug('pixel'); const { getSlugUrl } = useSlug('pixel');
console.log(showActions);
return ( return (
<DataTable {...props}> <DataTable {...props}>

View file

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

View file

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

View file

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

View file

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

View file

@ -6,13 +6,7 @@ import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
import { Edit } from '@/components/icons'; import { Edit } from '@/components/icons';
import { ActiveUsers } from '@/components/metrics/ActiveUsers'; import { ActiveUsers } from '@/components/metrics/ActiveUsers';
export function WebsiteHeader({ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
showActions,
allowLink = true,
}: {
showActions?: boolean;
allowLink?: boolean;
}) {
const website = useWebsite(); const website = useWebsite();
const { renderUrl, pathname } = useNavigation(); const { renderUrl, pathname } = useNavigation();
const isSettings = pathname.endsWith('/settings'); const isSettings = pathname.endsWith('/settings');
@ -27,7 +21,7 @@ export function WebsiteHeader({
<PageHeader <PageHeader
title={website.name} title={website.name}
icon={<Favicon domain={website.domain} />} icon={<Favicon domain={website.domain} />}
titleHref={allowLink ? renderUrl(`/websites/${website.id}`, false) : undefined} titleHref={renderUrl(`/websites/${website.id}`, false)}
> >
<Row alignItems="center" gap="6" wrap="wrap"> <Row alignItems="center" gap="6" wrap="wrap">
<ActiveUsers websiteId={website.id} /> <ActiveUsers websiteId={website.id} />

View file

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

View file

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

View file

@ -0,0 +1,81 @@
import {
Button,
Checkbox,
Column,
Form,
FormField,
FormSubmitButton,
Row,
Text,
} from '@umami/react-zen';
import { useState } from 'react';
import { useApi, useMessages, useModified } from '@/components/hooks';
import { SHARE_NAV_ITEMS } from './constants';
export interface ShareCreateFormProps {
websiteId: string;
onSave?: () => void;
onClose?: () => void;
}
export function ShareCreateForm({ websiteId, onSave, onClose }: ShareCreateFormProps) {
const { formatMessage, labels } = useMessages();
const { post } = useApi();
const { touch } = useModified();
const [isPending, setIsPending] = useState(false);
// Build default values - only overview and events enabled by default
const defaultValues: Record<string, boolean> = {};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
defaultValues[item.id] = item.id === 'overview' || item.id === 'events';
});
});
const handleSubmit = async (data: any) => {
setIsPending(true);
try {
const parameters: Record<string, boolean> = {};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
parameters[item.id] = data[item.id] ?? false;
});
});
await post(`/websites/${websiteId}/shares`, { parameters });
touch('shares');
onSave?.();
onClose?.();
} finally {
setIsPending(false);
}
};
return (
<Form onSubmit={handleSubmit} defaultValues={defaultValues}>
<Column gap="3">
{SHARE_NAV_ITEMS.map(section => (
<Column key={section.section} gap="1">
<Text size="2" 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>
))}
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton isDisabled={isPending}>{formatMessage(labels.save)}</FormSubmitButton>
</Row>
</Column>
</Form>
);
}

View file

@ -5,7 +5,6 @@ import {
Form, Form,
FormField, FormField,
FormSubmitButton, FormSubmitButton,
Grid,
Label, Label,
Loading, Loading,
Row, Row,
@ -14,30 +13,25 @@ import {
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useApi, useConfig, useMessages, useModified } from '@/components/hooks'; import { useApi, useConfig, useMessages, useModified } from '@/components/hooks';
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
import { SHARE_NAV_ITEMS } from './constants'; import { SHARE_NAV_ITEMS } from './constants';
export function ShareEditForm({ export function ShareEditForm({
shareId, shareId,
websiteId,
onSave, onSave,
onClose, onClose,
}: { }: {
shareId?: string; shareId: string;
websiteId?: string;
onSave?: () => void; onSave?: () => void;
onClose?: () => void; onClose?: () => void;
}) { }) {
const { formatMessage, labels, getErrorMessage } = useMessages(); const { formatMessage, labels, messages, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(`/share/id/${shareId}`);
const { cloudMode } = useConfig(); const { cloudMode } = useConfig();
const { get, post } = useApi(); const { get } = useApi();
const { touch } = useModified();
const { modified } = useModified('shares'); const { modified } = useModified('shares');
const [share, setShare] = useState<any>(null); const [share, setShare] = useState<any>(null);
const [isLoading, setIsLoading] = useState(!!shareId); const [isLoading, setIsLoading] = useState(true);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<any>(null);
const isEditing = !!shareId;
const getUrl = (slug: string) => { const getUrl = (slug: string) => {
if (cloudMode) { if (cloudMode) {
@ -47,8 +41,6 @@ export function ShareEditForm({
}; };
useEffect(() => { useEffect(() => {
if (!shareId) return;
const loadShare = async () => { const loadShare = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@ -69,35 +61,27 @@ export function ShareEditForm({
}); });
}); });
setIsPending(true); await mutateAsync(
setError(null); { slug: share.slug, parameters },
{
try { onSuccess: async () => {
if (isEditing) { toast(formatMessage(messages.saved));
await post(`/share/id/${shareId}`, { name: data.name, slug: share.slug, parameters }); touch('shares');
} else { onSave?.();
await post(`/websites/${websiteId}/shares`, { name: data.name, parameters }); onClose?.();
} },
touch('shares'); },
onSave?.(); );
onClose?.();
} catch (e) {
setError(e);
} finally {
setIsPending(false);
}
}; };
if (isLoading) { if (isLoading) {
return <Loading placement="absolute" />; return <Loading placement="absolute" />;
} }
const url = isEditing ? getUrl(share?.slug || '') : null; const url = getUrl(share?.slug || '');
// Build default values from share parameters // Build default values from share parameters
const defaultValues: Record<string, any> = { const defaultValues: Record<string, boolean> = {};
name: share?.name || '',
};
SHARE_NAV_ITEMS.forEach(section => { SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => { section.items.forEach(item => {
const defaultSelected = item.id === 'overview' || item.id === 'events'; const defaultSelected = item.id === 'overview' || item.id === 'events';
@ -105,60 +89,34 @@ export function ShareEditForm({
}); });
}); });
// Get all item ids for validation
const allItemIds = SHARE_NAV_ITEMS.flatMap(section => section.items.map(item => item.id));
return ( return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={defaultValues}> <Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={defaultValues}>
{({ watch }) => { <Column gap="3">
const values = watch(); <Column>
const hasSelection = allItemIds.some(id => values[id]); <Label>{formatMessage(labels.shareUrl)}</Label>
<TextField value={url} isReadOnly allowCopy />
return ( </Column>
<Column gap="6"> {SHARE_NAV_ITEMS.map(section => (
{url && ( <Column key={section.section} gap="1">
<Column> <Text weight="bold">{formatMessage((labels as any)[section.section])}</Text>
<Label>{formatMessage(labels.shareUrl)}</Label> <Column gap="1">
<TextField value={url} isReadOnly allowCopy /> {section.items.map(item => (
</Column> <FormField key={item.id} name={item.id}>
)} <Checkbox>{formatMessage((labels as any)[item.label])}</Checkbox>
<FormField </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> </Column>
<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> </Column>
); ))}
}} <Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton variant="primary">{formatMessage(labels.save)}</FormSubmitButton>
</Row>
</Column>
</Form> </Form>
); );
} }

View file

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

View file

@ -2,7 +2,7 @@ import { Column, Heading, Row, Text } from '@umami/react-zen';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useMessages, useWebsiteSharesQuery } from '@/components/hooks'; import { useMessages, useWebsiteSharesQuery } from '@/components/hooks';
import { DialogButton } from '@/components/input/DialogButton'; import { DialogButton } from '@/components/input/DialogButton';
import { ShareEditForm } from './ShareEditForm'; import { ShareCreateForm } from './ShareCreateForm';
import { SharesTable } from './SharesTable'; import { SharesTable } from './SharesTable';
export interface WebsiteShareFormProps { export interface WebsiteShareFormProps {
@ -11,7 +11,7 @@ export interface WebsiteShareFormProps {
export function WebsiteShareForm({ websiteId }: WebsiteShareFormProps) { export function WebsiteShareForm({ websiteId }: WebsiteShareFormProps) {
const { formatMessage, labels, messages } = useMessages(); const { formatMessage, labels, messages } = useMessages();
const { data } = useWebsiteSharesQuery({ websiteId }); const { data, isLoading } = useWebsiteSharesQuery({ websiteId });
const shares = data?.data || []; const shares = data?.data || [];
const hasShares = shares.length > 0; const hasShares = shares.length > 0;
@ -25,9 +25,9 @@ export function WebsiteShareForm({ websiteId }: WebsiteShareFormProps) {
label={formatMessage(labels.add)} label={formatMessage(labels.add)}
title={formatMessage(labels.share)} title={formatMessage(labels.share)}
variant="primary" variant="primary"
width="600px" width="400px"
> >
{({ close }) => <ShareEditForm websiteId={websiteId} onClose={close} />} {({ close }) => <ShareCreateForm websiteId={websiteId} onClose={close} />}
</DialogButton> </DialogButton>
</Row> </Row>
{hasShares ? ( {hasShares ? (

View file

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

View file

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

View file

@ -44,7 +44,6 @@ export async function POST(
{ params }: { params: Promise<{ websiteId: string }> }, { params }: { params: Promise<{ websiteId: string }> },
) { ) {
const schema = z.object({ const schema = z.object({
name: z.string().max(200),
parameters: anyObjectParam.optional(), parameters: anyObjectParam.optional(),
}); });
@ -55,8 +54,7 @@ export async function POST(
} }
const { websiteId } = await params; const { websiteId } = await params;
const { name, parameters } = body; const { parameters = {} } = body;
const shareParameters = parameters ?? {};
if (!(await canUpdateWebsite(auth, websiteId))) { if (!(await canUpdateWebsite(auth, websiteId))) {
return unauthorized(); return unauthorized();
@ -68,9 +66,8 @@ export async function POST(
id: uuid(), id: uuid(),
entityId: websiteId, entityId: websiteId,
shareType: ENTITY_TYPE.website, shareType: ENTITY_TYPE.website,
name,
slug, slug,
parameters: shareParameters, parameters,
}); });
return json(share); return json(share);

View file

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

View file

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

View file

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

View file

@ -1,30 +1,23 @@
import { Button, Column, Icon, Row, Text, ThemeButton } from '@umami/react-zen'; 'use client';
import { Column } from '@umami/react-zen';
import { SideMenu } from '@/components/common/SideMenu'; import { SideMenu } from '@/components/common/SideMenu';
import { useMessages, useNavigation, useShare } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { AlignEndHorizontal, Clock, Eye, PanelLeft, Sheet, Tag, User } from '@/components/icons'; import { AlignEndHorizontal, Clock, Eye, Sheet, Tag, User } from '@/components/icons';
import { LanguageButton } from '@/components/input/LanguageButton'; import { Funnel, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg';
import { PreferencesButton } from '@/components/input/PreferencesButton';
import { Funnel, Lightning, Logo, Magnet, Money, Network, Path, Target } from '@/components/svg';
export function ShareNav({ export function ShareNav({
collapsed, shareId,
onCollapse, parameters,
onItemClick, onItemClick,
}: { }: {
collapsed?: boolean; shareId: string;
onCollapse?: (collapsed: boolean) => void; parameters: Record<string, boolean>;
onItemClick?: () => void; onItemClick?: () => void;
}) { }) {
const share = useShare();
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation(); const { pathname } = useNavigation();
const { slug, parameters, whiteLabel } = share;
const logoUrl = whiteLabel?.url || 'https://umami.is'; const renderPath = (path: string) => `/share/${shareId}${path}`;
const logoName = whiteLabel?.name || 'umami';
const logoImage = whiteLabel?.image;
const renderPath = (path: string) => `/share/${slug}${path}`;
const allItems = [ const allItems = [
{ {
@ -137,70 +130,14 @@ export function ShareNav({
.flatMap(e => e.items) .flatMap(e => e.items)
.find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id; .find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id;
const isMobile = !!onItemClick;
return ( return (
<Column <Column padding="3" position="sticky" top="0" gap>
position={isMobile ? undefined : 'fixed'} <SideMenu
padding="3" items={items}
width={isMobile ? '100%' : collapsed ? '60px' : '240px'} selectedKey={selectedKey}
maxHeight="100dvh" allowMinimize={false}
height="100dvh" onItemClick={onItemClick}
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> </Column>
); );
} }

View file

@ -1,7 +1,6 @@
'use client'; 'use client';
import { Column, Grid, Row, useTheme } from '@umami/react-zen'; import { Column, Grid, useTheme } from '@umami/react-zen';
import { usePathname } from 'next/navigation'; import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { AttributionPage } from '@/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage'; import { AttributionPage } from '@/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage';
import { BreakdownPage } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage'; import { BreakdownPage } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage';
import { FunnelsPage } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage'; import { FunnelsPage } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage';
@ -18,8 +17,9 @@ import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage'; import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider'; import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
import { PageBody } from '@/components/common/PageBody'; import { PageBody } from '@/components/common/PageBody';
import { useShare } from '@/components/hooks'; import { useShareTokenQuery } from '@/components/hooks';
import { MobileMenuButton } from '@/components/input/MobileMenuButton'; import { Footer } from './Footer';
import { Header } from './Header';
import { ShareNav } from './ShareNav'; import { ShareNav } from './ShareNav';
const PAGE_COMPONENTS: Record<string, React.ComponentType<{ websiteId: string }>> = { const PAGE_COMPONENTS: Record<string, React.ComponentType<{ websiteId: string }>> = {
@ -39,25 +39,9 @@ const PAGE_COMPONENTS: Record<string, React.ComponentType<{ websiteId: string }>
attribution: AttributionPage, attribution: AttributionPage,
}; };
function getSharePath(pathname: string) { export function SharePage({ shareId, path = '' }: { shareId: string; path?: string }) {
const segments = pathname.split('/'); const { shareToken, isLoading } = useShareTokenQuery(shareId);
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 { setTheme } = useTheme();
const pathname = usePathname();
const path = getSharePath(pathname);
const { websiteId, parameters = {} } = share;
useEffect(() => { useEffect(() => {
const url = new URL(window?.location?.href); const url = new URL(window?.location?.href);
@ -68,6 +52,12 @@ export function SharePage() {
} }
}, []); }, []);
if (isLoading || !shareToken) {
return null;
}
const { websiteId, parameters = {} } = shareToken;
// Check if the requested path is allowed // Check if the requested path is allowed
const pageKey = path || ''; const pageKey = path || '';
const isAllowed = pageKey === '' || pageKey === 'overview' || parameters[pageKey] !== false; const isAllowed = pageKey === '' || pageKey === 'overview' || parameters[pageKey] !== false;
@ -79,25 +69,29 @@ export function SharePage() {
const PageComponent = PAGE_COMPONENTS[pageKey] || WebsitePage; const PageComponent = PAGE_COMPONENTS[pageKey] || WebsitePage;
return ( return (
<Grid columns={{ xs: '1fr', lg: `${navCollapsed ? '60px' : '240px'} 1fr` }} width="100%"> <Column backgroundColor="2">
<Row display={{ xs: 'flex', lg: 'none' }} alignItems="center" gap padding="3"> <Header />
<MobileMenuButton> <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
{({ close }) => { <Column
return <ShareNav onItemClick={close} />; display={{ xs: 'none', lg: 'flex' }}
}} width="240px"
</MobileMenuButton> height="100%"
</Row> border="right"
<Column display={{ xs: 'none', lg: 'flex' }} marginRight="2"> backgroundColor
<ShareNav collapsed={navCollapsed} onCollapse={setNavCollapsed} /> marginRight="2"
</Column> >
<PageBody gap> <ShareNav shareId={shareId} parameters={parameters} />
<WebsiteProvider websiteId={websiteId}> </Column>
<Column> <PageBody gap>
<WebsiteProvider websiteId={websiteId}>
<WebsiteHeader showActions={false} /> <WebsiteHeader showActions={false} />
<PageComponent websiteId={websiteId} /> <Column>
</Column> <PageComponent websiteId={websiteId} />
</WebsiteProvider> </Column>
</PageBody> </WebsiteProvider>
</Grid> </PageBody>
</Grid>
<Footer />
</Column>
); );
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -7,7 +7,6 @@ import {
NavMenuItem, NavMenuItem,
type NavMenuProps, type NavMenuProps,
Row, Row,
Text,
} from '@umami/react-zen'; } from '@umami/react-zen';
import Link from 'next/link'; import Link from 'next/link';
@ -43,11 +42,9 @@ export function SideMenu({
return ( return (
<Link key={id} href={path}> <Link key={id} href={path}>
<Row padding hoverBackgroundColor="3"> <NavMenuItem isSelected={isSelected}>
<IconLabel icon={icon}> <IconLabel icon={icon}>{label}</IconLabel>
<Text weight={isSelected ? 'bold' : undefined}>{label}</Text> </NavMenuItem>
</IconLabel>
</Row>
</Link> </Link>
); );
}); });

View file

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

View file

@ -3,7 +3,6 @@
// Context hooks // Context hooks
export * from './context/useLink'; export * from './context/useLink';
export * from './context/usePixel'; export * from './context/usePixel';
export * from './context/useShare';
export * from './context/useTeam'; export * from './context/useTeam';
export * from './context/useUser'; export * from './context/useUser';
export * from './context/useWebsite'; export * from './context/useWebsite';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,6 +11,7 @@ export function renderDateLabels(unit: string, locale: string) {
switch (unit) { switch (unit) {
case 'minute': case 'minute':
return formatDate(d, 'h:mm', locale);
case 'hour': case 'hour':
return formatDate(d, 'p', locale); return formatDate(d, 'p', locale);
case 'day': case 'day':

View file

@ -108,7 +108,7 @@ function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}
if (name === 'referrer') { if (name === 'referrer') {
arr.push( arr.push(
`and (website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '') or website_event.referrer_domain is null)`, `and (website_event.referrer_domain != website_event.hostname or website_event.referrer_domain is null)`,
); );
} }
} }

View file

@ -141,9 +141,3 @@ export interface ApiError extends Error {
code?: string; code?: string;
message: string; message: string;
} }
export interface WhiteLabel {
name: string;
url: string;
image: string;
}

View file

@ -5,11 +5,7 @@ import type { Auth } from '@/lib/types';
import { getTeamUser } from '@/queries/prisma'; import { getTeamUser } from '@/queries/prisma';
export async function canViewEntity({ user }: Auth, entityId: string) { export async function canViewEntity({ user }: Auth, entityId: string) {
if (!user) { if (user?.isAdmin) {
return false;
}
if (user.isAdmin) {
return true; return true;
} }
@ -29,10 +25,6 @@ export async function canViewEntity({ user }: Auth, entityId: string) {
} }
export async function canUpdateEntity({ user }: Auth, entityId: string) { export async function canUpdateEntity({ user }: Auth, entityId: string) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }
@ -53,10 +45,6 @@ export async function canUpdateEntity({ user }: Auth, entityId: string) {
} }
export async function canDeleteEntity({ user }: Auth, entityId: string) { export async function canDeleteEntity({ user }: Auth, entityId: string) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }

View file

@ -4,11 +4,7 @@ import type { Auth } from '@/lib/types';
import { getLink, getTeamUser } from '@/queries/prisma'; import { getLink, getTeamUser } from '@/queries/prisma';
export async function canViewLink({ user }: Auth, linkId: string) { export async function canViewLink({ user }: Auth, linkId: string) {
if (!user) { if (user?.isAdmin) {
return false;
}
if (user.isAdmin) {
return true; return true;
} }
@ -28,10 +24,6 @@ export async function canViewLink({ user }: Auth, linkId: string) {
} }
export async function canUpdateLink({ user }: Auth, linkId: string) { export async function canUpdateLink({ user }: Auth, linkId: string) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }
@ -52,10 +44,6 @@ export async function canUpdateLink({ user }: Auth, linkId: string) {
} }
export async function canDeleteLink({ user }: Auth, linkId: string) { export async function canDeleteLink({ user }: Auth, linkId: string) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }

View file

@ -4,11 +4,7 @@ import type { Auth } from '@/lib/types';
import { getPixel, getTeamUser } from '@/queries/prisma'; import { getPixel, getTeamUser } from '@/queries/prisma';
export async function canViewPixel({ user }: Auth, pixelId: string) { export async function canViewPixel({ user }: Auth, pixelId: string) {
if (!user) { if (user?.isAdmin) {
return false;
}
if (user.isAdmin) {
return true; return true;
} }
@ -28,10 +24,6 @@ export async function canViewPixel({ user }: Auth, pixelId: string) {
} }
export async function canUpdatePixel({ user }: Auth, pixelId: string) { export async function canUpdatePixel({ user }: Auth, pixelId: string) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }
@ -52,10 +44,6 @@ export async function canUpdatePixel({ user }: Auth, pixelId: string) {
} }
export async function canDeletePixel({ user }: Auth, pixelId: string) { export async function canDeletePixel({ user }: Auth, pixelId: string) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }

View file

@ -3,11 +3,11 @@ import type { Auth } from '@/lib/types';
import { canViewWebsite } from './website'; import { canViewWebsite } from './website';
export async function canViewReport(auth: Auth, report: Report) { export async function canViewReport(auth: Auth, report: Report) {
if (auth.user?.isAdmin) { if (auth.user.isAdmin) {
return true; return true;
} }
if (auth.user?.id === report.userId) { if (auth.user.id === report.userId) {
return true; return true;
} }
@ -15,10 +15,6 @@ export async function canViewReport(auth: Auth, report: Report) {
} }
export async function canUpdateReport({ user }: Auth, report: Report) { export async function canUpdateReport({ user }: Auth, report: Report) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }

View file

@ -4,10 +4,6 @@ import type { Auth } from '@/lib/types';
import { getTeamUser } from '@/queries/prisma'; import { getTeamUser } from '@/queries/prisma';
export async function canViewTeam({ user }: Auth, teamId: string) { export async function canViewTeam({ user }: Auth, teamId: string) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }
@ -16,10 +12,6 @@ export async function canViewTeam({ user }: Auth, teamId: string) {
} }
export async function canCreateTeam({ user }: Auth) { export async function canCreateTeam({ user }: Auth) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }
@ -28,10 +20,6 @@ export async function canCreateTeam({ user }: Auth) {
} }
export async function canUpdateTeam({ user }: Auth, teamId: string) { export async function canUpdateTeam({ user }: Auth, teamId: string) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }
@ -42,10 +30,6 @@ export async function canUpdateTeam({ user }: Auth, teamId: string) {
} }
export async function canDeleteTeam({ user }: Auth, teamId: string) { export async function canDeleteTeam({ user }: Auth, teamId: string) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }
@ -56,10 +40,6 @@ export async function canDeleteTeam({ user }: Auth, teamId: string) {
} }
export async function canDeleteTeamUser({ user }: Auth, teamId: string, removeUserId: string) { export async function canDeleteTeamUser({ user }: Auth, teamId: string, removeUserId: string) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }
@ -74,10 +54,6 @@ export async function canDeleteTeamUser({ user }: Auth, teamId: string, removeUs
} }
export async function canCreateTeamWebsite({ user }: Auth, teamId: string) { export async function canCreateTeamWebsite({ user }: Auth, teamId: string) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }
@ -88,5 +64,5 @@ export async function canCreateTeamWebsite({ user }: Auth, teamId: string) {
} }
export async function canViewAllTeams({ user }: Auth) { export async function canViewAllTeams({ user }: Auth) {
return user?.isAdmin ?? false; return user.isAdmin;
} }

View file

@ -1,14 +1,10 @@
import type { Auth } from '@/lib/types'; import type { Auth } from '@/lib/types';
export async function canCreateUser({ user }: Auth) { export async function canCreateUser({ user }: Auth) {
return user?.isAdmin ?? false; return user.isAdmin;
} }
export async function canViewUser({ user }: Auth, viewedUserId: string) { export async function canViewUser({ user }: Auth, viewedUserId: string) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }
@ -17,14 +13,10 @@ export async function canViewUser({ user }: Auth, viewedUserId: string) {
} }
export async function canViewUsers({ user }: Auth) { export async function canViewUsers({ user }: Auth) {
return user?.isAdmin ?? false; return user.isAdmin;
} }
export async function canUpdateUser({ user }: Auth, viewedUserId: string) { export async function canUpdateUser({ user }: Auth, viewedUserId: string) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }
@ -33,5 +25,5 @@ export async function canUpdateUser({ user }: Auth, viewedUserId: string) {
} }
export async function canDeleteUser({ user }: Auth) { export async function canDeleteUser({ user }: Auth) {
return user?.isAdmin ?? false; return user.isAdmin;
} }

View file

@ -15,7 +15,7 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri
const entity = await getEntity(websiteId); const entity = await getEntity(websiteId);
if (!entity || !user) { if (!entity) {
return false; return false;
} }
@ -33,14 +33,10 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri
} }
export async function canViewAllWebsites({ user }: Auth) { export async function canViewAllWebsites({ user }: Auth) {
return user?.isAdmin ?? false; return user.isAdmin;
} }
export async function canCreateWebsite({ user }: Auth) { export async function canCreateWebsite({ user }: Auth) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }
@ -49,10 +45,6 @@ export async function canCreateWebsite({ user }: Auth) {
} }
export async function canUpdateWebsite({ user }: Auth, websiteId: string) { export async function canUpdateWebsite({ user }: Auth, websiteId: string) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }
@ -77,10 +69,6 @@ export async function canUpdateWebsite({ user }: Auth, websiteId: string) {
} }
export async function canDeleteWebsite({ user }: Auth, websiteId: string) { export async function canDeleteWebsite({ user }: Auth, websiteId: string) {
if (!user) {
return false;
}
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }
@ -105,10 +93,6 @@ export async function canDeleteWebsite({ user }: Auth, websiteId: string) {
} }
export async function canTransferWebsiteToUser({ user }: Auth, websiteId: string, userId: string) { export async function canTransferWebsiteToUser({ user }: Auth, websiteId: string, userId: string) {
if (!user) {
return false;
}
const website = await getWebsite(websiteId); const website = await getWebsite(websiteId);
if (!website) { if (!website) {
@ -125,10 +109,6 @@ export async function canTransferWebsiteToUser({ user }: Auth, websiteId: string
} }
export async function canTransferWebsiteToTeam({ user }: Auth, websiteId: string, teamId: string) { export async function canTransferWebsiteToTeam({ user }: Auth, websiteId: string, teamId: string) {
if (!user) {
return false;
}
const website = await getWebsite(websiteId); const website = await getWebsite(websiteId);
if (!website) { if (!website) {

View file

@ -23,7 +23,7 @@ async function relationalQuery(websiteId: string, column: string, filters: Query
let excludeDomain = ''; let excludeDomain = '';
if (column === 'referrer_domain') { if (column === 'referrer_domain') {
excludeDomain = `and website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '') excludeDomain = `and website_event.referrer_domain != website_event.hostname
and website_event.referrer_domain != ''`; and website_event.referrer_domain != ''`;
} }

View file

@ -14,7 +14,7 @@ export async function getWeeklyTraffic(...args: [websiteId: string, filters: Que
} }
async function relationalQuery(websiteId: string, filters: QueryFilters) { async function relationalQuery(websiteId: string, filters: QueryFilters) {
const { timezone = 'utc' } = filters; const timezone = 'utc';
const { rawQuery, getDateWeeklySQL, parseFilters } = prisma; const { rawQuery, getDateWeeklySQL, parseFilters } = prisma;
const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
...filters, ...filters,
@ -33,7 +33,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
and website_event.created_at between {{startDate}} and {{endDate}} and website_event.created_at between {{startDate}} and {{endDate}}
${filterQuery} ${filterQuery}
group by time group by time
order by 1 order by 2
`, `,
queryParams, queryParams,
FUNCTION_NAME, FUNCTION_NAME,

View file

@ -50,7 +50,7 @@ async function relationalQuery(
let excludeDomain = ''; let excludeDomain = '';
if (column === 'referrer_domain') { if (column === 'referrer_domain') {
excludeDomain = `and website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '') excludeDomain = `and website_event.referrer_domain != website_event.hostname
and website_event.referrer_domain != ''`; and website_event.referrer_domain != ''`;
if (type === 'domain') { if (type === 'domain') {
column = toPostgresGroupedReferrer(GROUPED_DOMAINS); column = toPostgresGroupedReferrer(GROUPED_DOMAINS);

View file

@ -46,7 +46,7 @@ async function relationalQuery(
let excludeDomain = ''; let excludeDomain = '';
if (column === 'referrer_domain') { if (column === 'referrer_domain') {
excludeDomain = `and website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '') excludeDomain = `and website_event.referrer_domain != website_event.hostname
and website_event.referrer_domain != ''`; and website_event.referrer_domain != ''`;
} }

View file

@ -142,7 +142,7 @@ async function relationalQuery(
${ ${
currency currency
? '' ? ''
: `and we.referrer_domain != regexp_replace(we.hostname, '^www.', '') : `and we.referrer_domain != hostname
and we.referrer_domain != ''` and we.referrer_domain != ''`
} }
group by 1 group by 1

View file

@ -16,7 +16,7 @@ const initialState = {
theme: getItem(THEME_CONFIG) || DEFAULT_THEME, theme: getItem(THEME_CONFIG) || DEFAULT_THEME,
timezone: getItem(TIMEZONE_CONFIG) || getTimezone(), timezone: getItem(TIMEZONE_CONFIG) || getTimezone(),
dateRangeValue: getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE, dateRangeValue: getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE,
share: null, shareToken: null,
user: null, user: null,
config: null, config: null,
}; };
@ -31,8 +31,8 @@ export function setLocale(locale: string) {
store.setState({ locale }); store.setState({ locale });
} }
export function setShare(share: object) { export function setShareToken(shareToken: string) {
store.setState({ share }); store.setState({ shareToken });
} }
export function setUser(user: object) { export function setUser(user: object) {