Compare commits

..

No commits in common. "c5aa8be15cbee74f3264bcb09437f6cde12b857b" and "67cdfdfb7e7ee3fea9a2699155df8dfbd826f724" have entirely different histories.

30 changed files with 285 additions and 503 deletions

94
.gitignore vendored
View file

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

View file

@ -11,7 +11,7 @@
},
"type": "module",
"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",
"start": "next start",
"build-docker": "npm-run-all build-db build-tracker build-geo build-app",

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
import { SectionHeader } from '@/components/common/SectionHeader';
import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
import { useDateRange, useReportsQuery } from '@/components/hooks';
import { Goal } from './Goal';
import { GoalAddButton } from './GoalAddButton';
@ -13,17 +13,13 @@ export function GoalsPage({ websiteId }: { websiteId: string }) {
const {
dateRange: { startDate, endDate },
} = useDateRange();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
{!isSharePage && (
<SectionHeader>
<GoalAddButton websiteId={websiteId} />
</SectionHeader>
)}
<SectionHeader>
<GoalAddButton websiteId={websiteId} />
</SectionHeader>
<LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && (
<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 { ActiveUsers } from '@/components/metrics/ActiveUsers';
export function WebsiteHeader({
showActions,
allowLink = true,
}: {
showActions?: boolean;
allowLink?: boolean;
}) {
export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
const website = useWebsite();
const { renderUrl, pathname } = useNavigation();
const isSettings = pathname.endsWith('/settings');
@ -27,7 +21,7 @@ export function WebsiteHeader({
<PageHeader
title={website.name}
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">
<ActiveUsers websiteId={website.id} />

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

View file

@ -10,14 +10,14 @@ export function SharesTable(props: DataTableProps) {
const { cloudMode } = useConfig();
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 (
<DataTable {...props}>
<DataColumn id="name" label={formatMessage(labels.name)}>
{({ name }: any) => name}
</DataColumn>
<DataColumn id="slug" label={formatMessage(labels.shareUrl)}>
{({ slug }: any) => {
const url = getUrl(slug);

View file

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

View file

@ -1,47 +1,7 @@
import { ROLES } from '@/lib/constants';
import { secret } from '@/lib/crypto';
import { createToken } from '@/lib/jwt';
import prisma from '@/lib/prisma';
import redis from '@/lib/redis';
import { json, notFound } from '@/lib/response';
import 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;
}
import { getShareByCode } from '@/queries/prisma';
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
@ -52,25 +12,12 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu
return notFound();
}
const website = await getWebsite(share.entityId);
const data: Record<string, any> = {
const data = {
shareId: share.id,
websiteId: share.entityId,
parameters: share.parameters,
};
const token = createToken(data, secret());
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);
return json({ ...data, token });
}

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

View file

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

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,23 +0,0 @@
import { Row, Text } from '@umami/react-zen';
import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants';
import type { WhiteLabel } from '@/lib/types';
export function ShareFooter({ whiteLabel }: { whiteLabel?: WhiteLabel }) {
if (whiteLabel) {
return (
<Row as="footer" paddingY="6" justifyContent="flex-end">
<a href={whiteLabel.url} target="_blank">
<Text weight="bold">{whiteLabel.name}</Text>
</a>
</Row>
);
}
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,33 +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';
import type { WhiteLabel } from '@/lib/types';
export function ShareHeader({ whiteLabel }: { whiteLabel?: WhiteLabel }) {
const logoUrl = whiteLabel?.url || 'https://umami.is';
const logoName = whiteLabel?.name || 'umami';
const logoImage = whiteLabel?.image;
return (
<Row as="header" justifyContent="space-between" alignItems="center" paddingY="3">
<a href={logoUrl} target="_blank" rel="noopener">
<Row alignItems="center" gap>
{logoImage ? (
<img src={logoImage} alt={logoName} style={{ height: 24 }} />
) : (
<Icon>
<Logo />
</Icon>
)}
<Text weight="bold">{logoName}</Text>
</Row>
</a>
<Row alignItems="center" gap>
<ThemeButton />
<LanguageButton />
<PreferencesButton />
</Row>
</Row>
);
}

View file

@ -1,7 +1,6 @@
'use client';
import { Column, Grid, Row, useTheme } from '@umami/react-zen';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo } from 'react';
import { Column, Grid, useTheme } from '@umami/react-zen';
import { useEffect } from 'react';
import { AttributionPage } from '@/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage';
import { BreakdownPage } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage';
import { FunnelsPage } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage';
@ -19,9 +18,8 @@ import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
import { PageBody } from '@/components/common/PageBody';
import { useShareTokenQuery } from '@/components/hooks';
import { MobileMenuButton } from '@/components/input/MobileMenuButton';
import { ShareFooter } from './ShareFooter';
import { ShareHeader } from './ShareHeader';
import { Footer } from './Footer';
import { Header } from './Header';
import { ShareNav } from './ShareNav';
const PAGE_COMPONENTS: Record<string, React.ComponentType<{ websiteId: string }>> = {
@ -41,34 +39,9 @@ const PAGE_COMPONENTS: Record<string, React.ComponentType<{ websiteId: string }>
attribution: AttributionPage,
};
// All section IDs that can be enabled/disabled via parameters
const ALL_SECTION_IDS = [
'overview',
'events',
'sessions',
'realtime',
'compare',
'breakdown',
'goals',
'funnels',
'journeys',
'retention',
'utm',
'revenue',
'attribution',
];
export function SharePage({ shareId, path = '' }: { shareId: string; path?: string }) {
const { shareToken, isLoading } = useShareTokenQuery(shareId);
const { setTheme } = useTheme();
const router = useRouter();
// Calculate allowed sections
const allowedSections = useMemo(() => {
if (!shareToken?.parameters) return [];
const params = shareToken.parameters;
return ALL_SECTION_IDS.filter(id => params[id] !== false);
}, [shareToken?.parameters]);
useEffect(() => {
const url = new URL(window?.location?.href);
@ -79,31 +52,11 @@ export function SharePage({ shareId, path = '' }: { shareId: string; path?: stri
}
}, []);
// Redirect to the only allowed section if there's just one and we're on the base path
useEffect(() => {
if (
allowedSections.length === 1 &&
allowedSections[0] !== 'overview' &&
(path === '' || path === 'overview')
) {
router.replace(`/share/${shareId}/${allowedSections[0]}`);
}
}, [allowedSections, shareId, path, router]);
if (isLoading || !shareToken) {
return null;
}
const { websiteId, parameters = {}, whiteLabel } = shareToken;
// Redirect to only allowed section - return null while redirecting
if (
allowedSections.length === 1 &&
allowedSections[0] !== 'overview' &&
(path === '' || path === 'overview')
) {
return null;
}
const { websiteId, parameters = {} } = shareToken;
// Check if the requested path is allowed
const pageKey = path || '';
@ -117,16 +70,8 @@ export function SharePage({ shareId, path = '' }: { shareId: string; path?: stri
return (
<Column backgroundColor="2">
<Header />
<Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
<Row display={{ xs: 'flex', lg: 'none' }} alignItems="center" gap padding="3">
<Grid columns="auto 1fr" flexGrow={1} backgroundColor="3" borderRadius>
<MobileMenuButton>
{({ close }) => {
return <ShareNav shareId={shareId} parameters={parameters} onItemClick={close} />;
}}
</MobileMenuButton>
</Grid>
</Row>
<Column
display={{ xs: 'none', lg: 'flex' }}
width="240px"
@ -135,21 +80,18 @@ export function SharePage({ shareId, path = '' }: { shareId: string; path?: stri
backgroundColor
marginRight="2"
>
<Column display={{ xs: 'none', lg: 'flex' }}>
<ShareNav shareId={shareId} parameters={parameters} />
</Column>
<ShareNav shareId={shareId} parameters={parameters} />
</Column>
<PageBody gap>
<WebsiteProvider websiteId={websiteId}>
<ShareHeader whiteLabel={whiteLabel} />
<WebsiteHeader showActions={false} />
<Column>
<WebsiteHeader showActions={false} />
<PageComponent websiteId={websiteId} />
</Column>
<ShareFooter whiteLabel={whiteLabel} />
</WebsiteProvider>
</PageBody>
</Grid>
<Footer />
</Column>
);
}

View file

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

View file

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

View file

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

View file

@ -141,9 +141,3 @@ export interface ApiError extends Error {
code?: 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';
export async function canViewEntity({ user }: Auth, entityId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
if (user?.isAdmin) {
return true;
}
@ -29,10 +25,6 @@ export async function canViewEntity({ user }: Auth, entityId: string) {
}
export async function canUpdateEntity({ user }: Auth, entityId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -53,10 +45,6 @@ export async function canUpdateEntity({ user }: Auth, entityId: string) {
}
export async function canDeleteEntity({ user }: Auth, entityId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}

View file

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

View file

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

View file

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

View file

@ -4,10 +4,6 @@ import type { Auth } from '@/lib/types';
import { getTeamUser } from '@/queries/prisma';
export async function canViewTeam({ user }: Auth, teamId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -16,10 +12,6 @@ export async function canViewTeam({ user }: Auth, teamId: string) {
}
export async function canCreateTeam({ user }: Auth) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -28,10 +20,6 @@ export async function canCreateTeam({ user }: Auth) {
}
export async function canUpdateTeam({ user }: Auth, teamId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -42,10 +30,6 @@ export async function canUpdateTeam({ user }: Auth, teamId: string) {
}
export async function canDeleteTeam({ user }: Auth, teamId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -56,10 +40,6 @@ export async function canDeleteTeam({ user }: Auth, teamId: string) {
}
export async function canDeleteTeamUser({ user }: Auth, teamId: string, removeUserId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -74,10 +54,6 @@ export async function canDeleteTeamUser({ user }: Auth, teamId: string, removeUs
}
export async function canCreateTeamWebsite({ user }: Auth, teamId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -88,5 +64,5 @@ export async function canCreateTeamWebsite({ user }: Auth, teamId: string) {
}
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';
export async function canCreateUser({ user }: Auth) {
return user?.isAdmin ?? false;
return user.isAdmin;
}
export async function canViewUser({ user }: Auth, viewedUserId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -17,14 +13,10 @@ export async function canViewUser({ user }: Auth, viewedUserId: string) {
}
export async function canViewUsers({ user }: Auth) {
return user?.isAdmin ?? false;
return user.isAdmin;
}
export async function canUpdateUser({ user }: Auth, viewedUserId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -33,5 +25,5 @@ export async function canUpdateUser({ user }: Auth, viewedUserId: string) {
}
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);
if (!entity || !user) {
if (!entity) {
return false;
}
@ -33,14 +33,10 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri
}
export async function canViewAllWebsites({ user }: Auth) {
return user?.isAdmin ?? false;
return user.isAdmin;
}
export async function canCreateWebsite({ user }: Auth) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -49,10 +45,6 @@ export async function canCreateWebsite({ user }: Auth) {
}
export async function canUpdateWebsite({ user }: Auth, websiteId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -77,10 +69,6 @@ export async function canUpdateWebsite({ user }: Auth, websiteId: string) {
}
export async function canDeleteWebsite({ user }: Auth, websiteId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -105,10 +93,6 @@ export async function canDeleteWebsite({ user }: Auth, websiteId: string) {
}
export async function canTransferWebsiteToUser({ user }: Auth, websiteId: string, userId: string) {
if (!user) {
return false;
}
const website = await getWebsite(websiteId);
if (!website) {
@ -125,10 +109,6 @@ export async function canTransferWebsiteToUser({ user }: Auth, websiteId: string
}
export async function canTransferWebsiteToTeam({ user }: Auth, websiteId: string, teamId: string) {
if (!user) {
return false;
}
const website = await getWebsite(websiteId);
if (!website) {