Compare commits

...

47 commits

Author SHA1 Message Date
Mike Cao
abfb78bb98 Fixed mobile menus.
Some checks failed
Node.js CI / build (push) Has been cancelled
2026-01-29 09:23:53 -08:00
Mike Cao
9b310dacef Remove events section from share page overview
Share page overview should look the same as normal app overview

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 01:00:23 -08:00
Mike Cao
489c2712d1 Make ShareNav full width on mobile
Some checks are pending
Node.js CI / build (push) Waiting to run
- Remove fixed width, position, and border on mobile
- Use 100% width when onItemClick is provided (mobile context)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 00:44:18 -08:00
Mike Cao
b43e7fd3a7 Hide sidebar collapse button on mobile
onItemClick is only passed on mobile, so use it to detect mobile context

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 00:37:29 -08:00
Mike Cao
5880eae4e4 Fix NavMenu scrolling on mobile
- Add overflowY="auto" and flexGrow to menu container
- Menu now scrolls when content exceeds viewport height

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 00:34:47 -08:00
Mike Cao
6169a58e86 Center bottom icons when sidebar collapsed
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 00:13:23 -08:00
Mike Cao
452a385c4e Fix ShareNav collapse button and icon layout
- Use single PanelLeft icon with muted color
- Align collapse button to right of header
- Bottom icons: horizontal (Row) when expanded, vertical (Column) when collapsed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 00:12:23 -08:00
Mike Cao
482d6c1e47 Add collapsible ShareNav sidebar
- Add collapse/expand button in header
- When collapsed: hide menu items and logo, show only toggle button
- Stack bottom icons vertically when collapsed
- Adjust grid layout to match collapsed nav width (60px vs 240px)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 00:08:53 -08:00
Mike Cao
4e8be724ac Handle domain name in share URL path
Skip domain-like segments (containing dots) when parsing the share path.
e.g., /share/slug/aol.com is treated as /share/slug
      /share/slug/aol.com/events is treated as /share/slug/events

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:38:07 -08:00
Mike Cao
c9e14f3bce Restructure share routes to fix client-side navigation
- Change from [...shareId] catch-all to [slug]/[[...path]] structure
- Layout with ShareProvider now persists across sub-route navigation
- Add slug to ShareData context (separate from shareId UUID)
- Links now use slug instead of UUID for proper routing
- Remove unused ShareFooter and ShareHeader files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:32:51 -08:00
Mike Cao
d028bfa1f5 Move share page redirect logic to ShareProvider
Centralizes the single-section redirect logic in ShareProvider instead of
SharePage, reducing useEffect complexity and preventing children from
rendering during redirect.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:18:05 -08:00
Mike Cao
78d467b478 Redesigned share page. 2026-01-28 23:10:42 -08:00
Mike Cao
9d3f5ad0fd Merge remote-tracking branch 'origin/dev' into dev 2026-01-28 20:14:43 -08:00
Mike Cao
018e76b067 Fixed website nav. 2026-01-28 19:24:13 -08:00
Francis Cao
2df24a78ca bug fix. remove All time filter for websites with no data. 2026-01-28 18:05:34 -08:00
Francis Cao
9339383497 remove www. prefix from hostname during comparison. Closes #3256 2026-01-28 17:18:03 -08:00
Francis Cao
b84942b6da make SharesTable mobile friendly
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-28 10:48:13 -08:00
Francis Cao
3d75246802 dependabot - bump tar t. 7.5.7 2026-01-28 09:49:30 -08:00
Francis Cao
376c570d26
Merge pull request #3987 from umami-software/dependabot/npm_and_yarn/next-15.5.10
Bump next from 15.5.9 to 15.5.10
2026-01-28 09:32:24 -08:00
Francis Cao
558cb3b041 fix detest test failure 2026-01-28 09:26:07 -08:00
Francis Cao
9e7285cf2b
Merge branch 'dev' into dependabot/npm_and_yarn/next-15.5.10 2026-01-28 09:18:46 -08:00
Francis Cao
c5aa8be15c remove missing imports
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-28 00:06:47 -08:00
Francis Cao
daccd22ab2 remove duplicate logos and names on sharepage 2026-01-27 23:58:31 -08:00
Francis Cao
b958403224 add mobile navbar to share page 2026-01-27 23:54:43 -08:00
Francis Cao
752f395d83 revert websitelayout changes 2026-01-27 22:47:29 -08:00
Francis Cao
c7a0d65590 add websiteheader to share page. Fix overview WebsiteNav Bug 2026-01-27 22:17:49 -08:00
Mike Cao
c3dad5b7ef Merge remote-tracking branch 'origin/dev' into dev 2026-01-27 18:51:05 -08:00
Mike Cao
9426de90f7 Fixed share path. 2026-01-27 18:50:53 -08:00
Francis Cao
c527819fd4 fix minute label formatting. Closes #3088 2026-01-27 18:22:39 -08:00
Mike Cao
f7bdd5c54e Merge remote-tracking branch 'origin/dev' into dev 2026-01-27 18:05:54 -08:00
Mike Cao
f32ab11785 Fixed bad reference. 2026-01-27 18:05:04 -08:00
Francis Cao
dde1c3a57a Increase website select pageSize to 20. Closes #3913 2026-01-27 17:07:14 -08:00
dependabot[bot]
dd3649bbf7
Bump next from 15.5.9 to 15.5.10
Bumps [next](https://github.com/vercel/next.js) from 15.5.9 to 15.5.10.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.9...v15.5.10)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.10
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-27 21:38:25 +00:00
Francis Cao
67cdfdfb7e remove console.log from pixelstable
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-27 10:51:49 -08:00
Francis Cao
a0886c0594 Correctly pass in timezone into relational query. Closes #3975 2026-01-27 10:03:44 -08:00
Mike Cao
4680c89e28 Merge branch 'dev' of https://github.com/umami-software/umami into dev 2026-01-26 11:18:34 -08:00
Mike Cao
fdafe13c35 Block share token from all editing permissions.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 19:13:49 -08:00
Mike Cao
e782c2e627 Block share token users from modifying reports via API.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:06:39 -08:00
Mike Cao
801a3ec6bb Hide add/edit buttons on share pages.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 14:51:46 -08:00
Mike Cao
4a09f2bff6 Share page changes. 2026-01-24 02:47:09 -08:00
Mike Cao
c9f6653b62 Redirect to single allowed section on share page. 2026-01-24 02:21:51 -08:00
Mike Cao
af7f7adf5b Add name field to share feature and require at least one item selection.
- Add name field to ShareCreateForm and ShareEditForm
- Add name column to SharesTable
- Update API routes to handle name field
- Require at least one item to be selected before saving
- Display parameters in responsive 3-column grid in edit form

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 20:17:45 -08:00
Mike Cao
4c9b2f10da Merge branch 'dev' of https://github.com/umami-software/umami into dev 2026-01-22 19:17:22 -08:00
Mike Cao
adea3e9b1c Merge branch 'analytics' into dev
# Conflicts:
#	.gitignore
#	src/app/api/share/[slug]/route.ts
#	src/app/share/[...shareId]/SharePage.tsx
2026-01-22 17:44:45 -08:00
Mike Cao
518f0b66c6 Don't render link on share page.
Some checks failed
Create docker images (cloud) / Build, push, and deploy (push) Has been cancelled
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
2026-01-22 03:02:15 -08:00
Mike Cao
f84e67b0e6 White-label support. 2026-01-22 01:50:24 -08:00
Francis Cao
52d9dd2871 pass unit into revenue report
Some checks failed
Create docker images (cloud) / Build, push, and deploy (push) Has been cancelled
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
2026-01-20 19:11:00 -08:00
52 changed files with 809 additions and 855 deletions

2
.gitignore vendored
View file

@ -31,6 +31,8 @@ pm2.yml
*.log *.log
.vscode .vscode
.tool-versions .tool-versions
.claude
nul
# debug # debug
npm-debug.log* npm-debug.log*

View file

@ -11,7 +11,7 @@
}, },
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev -p 3001 --turbo", "dev": "next dev -p 3002 --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.9", "next": "^15.5.10",
"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.4", "tar": "^7.5.7",
"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"> <NavMenu padding="3" onItemClick={close} border="bottom" width="100%">
<NavButton /> <NavButton />
{links.map(link => { {links.map(link => {
return ( return (

View file

@ -14,7 +14,6 @@ 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, useResultQuery } from '@/components/hooks'; import { useMessages, useNavigation, useResultQuery } from '@/components/hooks';
import { File, User } from '@/components/icons'; import { File, User } from '@/components/icons';
import { ReportEditButton } from '@/components/input/ReportEditButton'; import { ReportEditButton } from '@/components/input/ReportEditButton';
import { ChangeLabel } from '@/components/metrics/ChangeLabel'; import { ChangeLabel } from '@/components/metrics/ChangeLabel';
@ -20,6 +20,8 @@ type FunnelResult = {
export function Funnel({ id, name, type, parameters, websiteId }) { export function Funnel({ id, name, type, parameters, websiteId }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
const { data, error, isLoading } = useResultQuery(type, { const { data, error, isLoading } = useResultQuery(type, {
websiteId, websiteId,
...parameters, ...parameters,
@ -36,13 +38,13 @@ 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} />
@ -51,6 +53,7 @@ export function Funnel({ id, name, type, parameters, websiteId }) {
}} }}
</ReportEditButton> </ReportEditButton>
</Column> </Column>
)}
</Grid> </Grid>
{data?.map( {data?.map(
( (

View file

@ -4,7 +4,7 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { SectionHeader } from '@/components/common/SectionHeader'; import { SectionHeader } from '@/components/common/SectionHeader';
import { useDateRange, useReportsQuery } from '@/components/hooks'; import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
import { Funnel } from './Funnel'; import { Funnel } from './Funnel';
import { FunnelAddButton } from './FunnelAddButton'; import { FunnelAddButton } from './FunnelAddButton';
@ -13,13 +13,17 @@ export function FunnelsPage({ websiteId }: { websiteId: string }) {
const { const {
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
} = useDateRange(); } = useDateRange();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
{!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, useResultQuery } from '@/components/hooks'; import { useMessages, useNavigation, useResultQuery } from '@/components/hooks';
import { File, User } from '@/components/icons'; import { File, User } from '@/components/icons';
import { ReportEditButton } from '@/components/input/ReportEditButton'; import { ReportEditButton } from '@/components/input/ReportEditButton';
import { Lightning } from '@/components/svg'; import { Lightning } from '@/components/svg';
@ -25,6 +25,8 @@ export type GoalData = { num: number; total: number };
export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) { export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, { const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, {
websiteId, websiteId,
startDate, startDate,
@ -45,6 +47,7 @@ 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 }) => {
@ -60,6 +63,7 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
}} }}
</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, useReportsQuery } from '@/components/hooks'; import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
import { Goal } from './Goal'; import { Goal } from './Goal';
import { GoalAddButton } from './GoalAddButton'; import { GoalAddButton } from './GoalAddButton';
@ -13,13 +13,17 @@ export function GoalsPage({ websiteId }: { websiteId: string }) {
const { const {
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
} = useDateRange(); } = useDateRange();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
{!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,7 +6,13 @@ 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({ showActions }: { showActions?: boolean }) { export function WebsiteHeader({
showActions,
allowLink = true,
}: {
showActions?: boolean;
allowLink?: boolean;
}) {
const website = useWebsite(); const website = useWebsite();
const { renderUrl, pathname } = useNavigation(); const { renderUrl, pathname } = useNavigation();
const isSettings = pathname.endsWith('/settings'); const isSettings = pathname.endsWith('/settings');
@ -21,7 +27,7 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
<PageHeader <PageHeader
title={website.name} title={website.name}
icon={<Favicon domain={website.domain} />} icon={<Favicon domain={website.domain} />}
titleHref={renderUrl(`/websites/${website.id}`, false)} titleHref={allowLink ? renderUrl(`/websites/${website.id}`, false) : undefined}
> >
<Row alignItems="center" gap="6" wrap="wrap"> <Row alignItems="center" gap="6" wrap="wrap">
<ActiveUsers websiteId={website.id} /> <ActiveUsers websiteId={website.id} />

View file

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

View file

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

View file

@ -1,81 +0,0 @@
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,6 +5,7 @@ import {
Form, Form,
FormField, FormField,
FormSubmitButton, FormSubmitButton,
Grid,
Label, Label,
Loading, Loading,
Row, Row,
@ -13,25 +14,30 @@ 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, messages, getErrorMessage } = useMessages(); const { formatMessage, labels, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(`/share/id/${shareId}`);
const { cloudMode } = useConfig(); const { cloudMode } = useConfig();
const { get } = useApi(); const { get, post } = 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(true); const [isLoading, setIsLoading] = useState(!!shareId);
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) {
@ -41,6 +47,8 @@ export function ShareEditForm({
}; };
useEffect(() => { useEffect(() => {
if (!shareId) return;
const loadShare = async () => { const loadShare = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@ -61,27 +69,35 @@ export function ShareEditForm({
}); });
}); });
await mutateAsync( setIsPending(true);
{ slug: share.slug, parameters }, setError(null);
{
onSuccess: async () => { try {
toast(formatMessage(messages.saved)); 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'); touch('shares');
onSave?.(); onSave?.();
onClose?.(); onClose?.();
}, } catch (e) {
}, setError(e);
); } finally {
setIsPending(false);
}
}; };
if (isLoading) { if (isLoading) {
return <Loading placement="absolute" />; return <Loading placement="absolute" />;
} }
const url = getUrl(share?.slug || ''); const url = isEditing ? getUrl(share?.slug || '') : null;
// Build default values from share parameters // Build default values from share parameters
const defaultValues: Record<string, boolean> = {}; const defaultValues: Record<string, any> = {
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';
@ -89,15 +105,33 @@ 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}>
<Column gap="3"> {({ watch }) => {
const values = watch();
const hasSelection = allItemIds.some(id => values[id]);
return (
<Column gap="6">
{url && (
<Column> <Column>
<Label>{formatMessage(labels.shareUrl)}</Label> <Label>{formatMessage(labels.shareUrl)}</Label>
<TextField value={url} isReadOnly allowCopy /> <TextField value={url} isReadOnly allowCopy />
</Column> </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 => ( {SHARE_NAV_ITEMS.map(section => (
<Column key={section.section} gap="1"> <Column key={section.section} gap="3">
<Text weight="bold">{formatMessage((labels as any)[section.section])}</Text> <Text weight="bold">{formatMessage((labels as any)[section.section])}</Text>
<Column gap="1"> <Column gap="1">
{section.items.map(item => ( {section.items.map(item => (
@ -108,15 +142,23 @@ export function ShareEditForm({
</Column> </Column>
</Column> </Column>
))} ))}
</Grid>
<Row justifyContent="flex-end" paddingTop="3" gap="3"> <Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && ( {onClose && (
<Button isDisabled={isPending} onPress={onClose}> <Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)} {formatMessage(labels.cancel)}
</Button> </Button>
)} )}
<FormSubmitButton variant="primary">{formatMessage(labels.save)}</FormSubmitButton> <FormSubmitButton
variant="primary"
isDisabled={isPending || !hasSelection || !values.name}
>
{formatMessage(labels.save)}
</FormSubmitButton>
</Row> </Row>
</Column> </Column>
);
}}
</Form> </Form>
); );
} }

View file

@ -1,24 +1,25 @@
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 } from '@/components/hooks'; import { useConfig, useMessages, useMobile } 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) => {
if (cloudMode) { return `${cloudMode ? process.env.cloudUrl : window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
return `${process.env.cloudUrl}/share/${slug}`;
}
return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
}; };
return ( return (
<DataTable {...props}> <DataTable {...props}>
<DataColumn id="slug" label={formatMessage(labels.shareUrl)}> <DataColumn id="name" label={formatMessage(labels.name)}>
{({ 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 (
@ -28,9 +29,11 @@ export function SharesTable(props: DataTableProps) {
); );
}} }}
</DataColumn> </DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)} width="200px"> {!isMobile && (
<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 { ShareCreateForm } from './ShareCreateForm'; import { ShareEditForm } from './ShareEditForm';
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, isLoading } = useWebsiteSharesQuery({ websiteId }); const { data } = 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="400px" width="600px"
> >
{({ close }) => <ShareCreateForm websiteId={websiteId} onClose={close} />} {({ close }) => <ShareEditForm websiteId={websiteId} onClose={close} />}
</DialogButton> </DialogButton>
</Row> </Row>
{hasShares ? ( {hasShares ? (

View file

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

View file

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

View file

@ -44,6 +44,7 @@ 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(),
}); });
@ -54,7 +55,8 @@ export async function POST(
} }
const { websiteId } = await params; const { websiteId } = await params;
const { parameters = {} } = body; const { name, parameters } = body;
const shareParameters = parameters ?? {};
if (!(await canUpdateWebsite(auth, websiteId))) { if (!(await canUpdateWebsite(auth, websiteId))) {
return unauthorized(); return unauthorized();
@ -66,8 +68,9 @@ export async function POST(
id: uuid(), id: uuid(),
entityId: websiteId, entityId: websiteId,
shareType: ENTITY_TYPE.website, shareType: ENTITY_TYPE.website,
name,
slug, slug,
parameters, parameters: shareParameters,
}); });
return json(share); return json(share);

View file

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

View file

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

View file

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

View file

@ -1,8 +0,0 @@
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,23 +1,30 @@
'use client'; import { Button, Column, Icon, Row, Text, ThemeButton } from '@umami/react-zen';
import { Column } from '@umami/react-zen';
import { SideMenu } from '@/components/common/SideMenu'; import { SideMenu } from '@/components/common/SideMenu';
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages, useNavigation, useShare } from '@/components/hooks';
import { AlignEndHorizontal, Clock, Eye, Sheet, Tag, User } from '@/components/icons'; import { AlignEndHorizontal, Clock, Eye, PanelLeft, Sheet, Tag, User } from '@/components/icons';
import { Funnel, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg'; 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({ export function ShareNav({
shareId, collapsed,
parameters, onCollapse,
onItemClick, onItemClick,
}: { }: {
shareId: string; collapsed?: boolean;
parameters: Record<string, boolean>; onCollapse?: (collapsed: boolean) => void;
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 renderPath = (path: string) => `/share/${shareId}${path}`; 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 = [ const allItems = [
{ {
@ -130,8 +137,43 @@ 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 padding="3" position="sticky" top="0" gap> <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 <SideMenu
items={items} items={items}
selectedKey={selectedKey} selectedKey={selectedKey}
@ -139,5 +181,26 @@ export function ShareNav({
onItemClick={onItemClick} onItemClick={onItemClick}
/> />
</Column> </Column>
)}
<Column
flexGrow={collapsed ? 1 : undefined}
justifyContent="flex-end"
alignItems={collapsed ? 'center' : undefined}
>
{collapsed ? (
<Column gap="2" alignItems="center">
<ThemeButton />
<LanguageButton />
<PreferencesButton />
</Column>
) : (
<Row>
<ThemeButton />
<LanguageButton />
<PreferencesButton />
</Row>
)}
</Column>
</Column>
); );
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -7,6 +7,7 @@ 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';
@ -42,9 +43,11 @@ export function SideMenu({
return ( return (
<Link key={id} href={path}> <Link key={id} href={path}>
<NavMenuItem isSelected={isSelected}> <Row padding hoverBackgroundColor="3">
<IconLabel icon={icon}>{label}</IconLabel> <IconLabel icon={icon}>
</NavMenuItem> <Text weight={isSelected ? 'bold' : undefined}>{label}</Text>
</IconLabel>
</Row>
</Link> </Link>
); );
}); });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,7 +11,6 @@ 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 != website_event.hostname or website_event.referrer_domain is null)`, `and (website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '') or website_event.referrer_domain is null)`,
); );
} }
} }

View file

@ -141,3 +141,9 @@ 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,7 +5,11 @@ 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?.isAdmin) { if (!user) {
return false;
}
if (user.isAdmin) {
return true; return true;
} }
@ -25,6 +29,10 @@ 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;
} }
@ -45,6 +53,10 @@ 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,7 +4,11 @@ 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?.isAdmin) { if (!user) {
return false;
}
if (user.isAdmin) {
return true; return true;
} }
@ -24,6 +28,10 @@ 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;
} }
@ -44,6 +52,10 @@ 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,7 +4,11 @@ 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?.isAdmin) { if (!user) {
return false;
}
if (user.isAdmin) {
return true; return true;
} }
@ -24,6 +28,10 @@ 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;
} }
@ -44,6 +52,10 @@ 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,6 +15,10 @@ 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,6 +4,10 @@ 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;
} }
@ -12,6 +16,10 @@ 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;
} }
@ -20,6 +28,10 @@ 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;
} }
@ -30,6 +42,10 @@ 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;
} }
@ -40,6 +56,10 @@ 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;
} }
@ -54,6 +74,10 @@ 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;
} }
@ -64,5 +88,5 @@ export async function canCreateTeamWebsite({ user }: Auth, teamId: string) {
} }
export async function canViewAllTeams({ user }: Auth) { export async function canViewAllTeams({ user }: Auth) {
return user.isAdmin; return user?.isAdmin ?? false;
} }

View file

@ -1,10 +1,14 @@
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; return user?.isAdmin ?? false;
} }
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;
} }
@ -13,10 +17,14 @@ export async function canViewUser({ user }: Auth, viewedUserId: string) {
} }
export async function canViewUsers({ user }: Auth) { export async function canViewUsers({ user }: Auth) {
return user.isAdmin; return user?.isAdmin ?? false;
} }
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;
} }
@ -25,5 +33,5 @@ export async function canUpdateUser({ user }: Auth, viewedUserId: string) {
} }
export async function canDeleteUser({ user }: Auth) { export async function canDeleteUser({ user }: Auth) {
return user.isAdmin; return user?.isAdmin ?? false;
} }

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) { if (!entity || !user) {
return false; return false;
} }
@ -33,10 +33,14 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri
} }
export async function canViewAllWebsites({ user }: Auth) { export async function canViewAllWebsites({ user }: Auth) {
return user.isAdmin; return user?.isAdmin ?? false;
} }
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;
} }
@ -45,6 +49,10 @@ 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;
} }
@ -69,6 +77,10 @@ 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;
} }
@ -93,6 +105,10 @@ 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) {
@ -109,6 +125,10 @@ 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 != website_event.hostname excludeDomain = `and website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '')
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'; const { timezone = 'utc' } = filters;
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 2 order by 1
`, `,
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 != website_event.hostname excludeDomain = `and website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '')
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 != website_event.hostname excludeDomain = `and website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '')
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 != hostname : `and we.referrer_domain != regexp_replace(we.hostname, '^www.', '')
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,
shareToken: null, share: 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 setShareToken(shareToken: string) { export function setShare(share: object) {
store.setState({ shareToken }); store.setState({ share });
} }
export function setUser(user: object) { export function setUser(user: object) {