Merge branch 'analytics' into dev

# Conflicts:
#	.gitignore
#	src/app/api/share/[slug]/route.ts
#	src/app/share/[...shareId]/SharePage.tsx
This commit is contained in:
Mike Cao 2026-01-22 17:44:45 -08:00
commit adea3e9b1c
7 changed files with 148 additions and 62 deletions

1
.gitignore vendored
View file

@ -31,6 +31,7 @@ pm2.yml
*.log *.log
.vscode .vscode
.tool-versions .tool-versions
.claude
# 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 3003 --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",

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

@ -1,7 +1,52 @@
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 { getShareByCode, getWebsite } from '@/queries/prisma';
export interface WhiteLabel {
name: string;
url: string;
image: string;
}
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 +57,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

@ -1,7 +1,18 @@
import { Row, Text } from '@umami/react-zen'; import { Row, Text } from '@umami/react-zen';
import type { WhiteLabel } from '@/app/api/share/[shareId]/route';
import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants'; import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants';
export function Footer() { export function Footer({ 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 ( return (
<Row as="footer" paddingY="6" justifyContent="flex-end"> <Row as="footer" paddingY="6" justifyContent="flex-end">
<a href={HOMEPAGE_URL} target="_blank"> <a href={HOMEPAGE_URL} target="_blank">

View file

@ -1,17 +1,26 @@
import { Icon, Row, Text, ThemeButton } from '@umami/react-zen'; import { Icon, Row, Text, ThemeButton } from '@umami/react-zen';
import type { WhiteLabel } from '@/app/api/share/[shareId]/route';
import { LanguageButton } from '@/components/input/LanguageButton'; import { LanguageButton } from '@/components/input/LanguageButton';
import { PreferencesButton } from '@/components/input/PreferencesButton'; import { PreferencesButton } from '@/components/input/PreferencesButton';
import { Logo } from '@/components/svg'; import { Logo } from '@/components/svg';
export function Header() { export function Header({ whiteLabel }: { whiteLabel?: WhiteLabel }) {
const logoUrl = whiteLabel?.url || 'https://umami.is';
const logoName = whiteLabel?.name || 'umami';
const logoImage = whiteLabel?.image;
return ( return (
<Row as="header" justifyContent="space-between" alignItems="center" paddingY="3"> <Row as="header" justifyContent="space-between" alignItems="center" paddingY="3">
<a href="https://umami.is" target="_blank" rel="noopener"> <a href={logoUrl} target="_blank" rel="noopener">
<Row alignItems="center" gap> <Row alignItems="center" gap>
{logoImage ? (
<img src={logoImage} alt={logoName} style={{ height: 24 }} />
) : (
<Icon> <Icon>
<Logo /> <Logo />
</Icon> </Icon>
<Text weight="bold">umami</Text> )}
<Text weight="bold">{logoName}</Text>
</Row> </Row>
</a> </a>
<Row alignItems="center" gap> <Row alignItems="center" gap>

View file

@ -56,7 +56,7 @@ export function SharePage({ shareId, path = '' }: { shareId: string; path?: stri
return null; return null;
} }
const { websiteId, parameters = {} } = shareToken; const { websiteId, parameters = {}, whiteLabel } = shareToken;
// Check if the requested path is allowed // Check if the requested path is allowed
const pageKey = path || ''; const pageKey = path || '';
@ -84,10 +84,11 @@ export function SharePage({ shareId, path = '' }: { shareId: string; path?: stri
</Column> </Column>
<PageBody gap> <PageBody gap>
<WebsiteProvider websiteId={websiteId}> <WebsiteProvider websiteId={websiteId}>
<WebsiteHeader showActions={false} /> <Header whiteLabel={whiteLabel} />
<Column> <Column>
<PageComponent websiteId={websiteId} /> <PageComponent websiteId={websiteId} />
</Column> </Column>
<Footer whiteLabel={whiteLabel} />
</WebsiteProvider> </WebsiteProvider>
</PageBody> </PageBody>
</Grid> </Grid>