Redesigned share page.

This commit is contained in:
Mike Cao 2026-01-28 23:10:42 -08:00
parent 9d3f5ad0fd
commit 78d467b478
9 changed files with 130 additions and 88 deletions

View file

@ -0,0 +1,29 @@
'use client';
import { Loading } from '@umami/react-zen';
import { createContext, type ReactNode } from 'react';
import { useShareTokenQuery } from '@/components/hooks';
import type { WhiteLabel } from '@/lib/types';
export interface ShareData {
shareId: string;
websiteId: string;
parameters: any;
token: string;
whiteLabel?: WhiteLabel;
}
export const ShareContext = createContext<ShareData>(null);
export function ShareProvider({ shareId, children }: { shareId: string; children: ReactNode }) {
const { share, isLoading, isFetching } = useShareTokenQuery(shareId);
if (isFetching && isLoading) {
return <Loading placement="absolute" />;
}
if (!share) {
return null;
}
return <ShareContext.Provider value={share}>{children}</ShareContext.Provider>;
}

View file

@ -1,21 +1,20 @@
'use client';
import { Column } from '@umami/react-zen';
import { Column, Icon, Row, Text, ThemeButton } from '@umami/react-zen';
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 { 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({
shareId,
parameters,
onItemClick,
}: {
shareId: string;
parameters: Record<string, boolean>;
onItemClick?: () => void;
}) {
export function ShareNav({ onItemClick }: { onItemClick?: () => void }) {
const share = useShare();
const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const { shareId, parameters, whiteLabel } = share;
const logoUrl = whiteLabel?.url || 'https://umami.is';
const logoName = whiteLabel?.name || 'umami';
const logoImage = whiteLabel?.image;
const renderPath = (path: string) => `/share/${shareId}${path}`;
@ -131,7 +130,22 @@ export function ShareNav({
.find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id;
return (
<Column padding="3" position="sticky" top="0" gap>
<Column position="fixed" padding="3" width="240px" maxHeight="100vh" height="100vh">
<Row as="header" gap alignItems="center" paddingY="3" marginLeft="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>
<Column>
<SideMenu
items={items}
selectedKey={selectedKey}
@ -139,5 +153,13 @@ export function ShareNav({
onItemClick={onItemClick}
/>
</Column>
<Column flexGrow={1} justifyContent="flex-end">
<Row>
<ThemeButton />
<LanguageButton />
<PreferencesButton />
</Row>
</Column>
</Column>
);
}

View file

@ -1,6 +1,6 @@
'use client';
import { Column, Grid, Row, useTheme } from '@umami/react-zen';
import { useRouter } from 'next/navigation';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useMemo } from 'react';
import { AttributionPage } from '@/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage';
import { BreakdownPage } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage';
@ -18,10 +18,8 @@ import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
import { PageBody } from '@/components/common/PageBody';
import { useShareTokenQuery } from '@/components/hooks';
import { useShare } from '@/components/hooks';
import { MobileMenuButton } from '@/components/input/MobileMenuButton';
import { ShareFooter } from './ShareFooter';
import { ShareHeader } from './ShareHeader';
import { ShareNav } from './ShareNav';
const PAGE_COMPONENTS: Record<string, React.ComponentType<{ websiteId: string }>> = {
@ -58,17 +56,20 @@ const ALL_SECTION_IDS = [
'attribution',
];
export function SharePage({ shareId, path = '' }: { shareId: string; path?: string }) {
const { shareToken, isLoading } = useShareTokenQuery(shareId);
export function SharePage({ shareId }: { shareId: string }) {
const share = useShare();
const { setTheme } = useTheme();
const router = useRouter();
const pathname = usePathname();
const path = pathname.split('/')[3];
const { websiteId, parameters = {} } = share;
// Calculate allowed sections
const allowedSections = useMemo(() => {
if (!shareToken?.parameters) return [];
const params = shareToken.parameters;
if (!share?.parameters) return [];
const params = share.parameters;
return ALL_SECTION_IDS.filter(id => params[id] !== false);
}, [shareToken?.parameters]);
}, [share?.parameters]);
useEffect(() => {
const url = new URL(window?.location?.href);
@ -90,12 +91,6 @@ export function SharePage({ shareId, path = '' }: { shareId: string; path?: stri
}
}, [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 &&
@ -116,40 +111,25 @@ export function SharePage({ shareId, path = '' }: { shareId: string; path?: stri
const PageComponent = PAGE_COMPONENTS[pageKey] || WebsitePage;
return (
<Column backgroundColor="2">
<Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
<Grid columns={{ xs: '1fr', lg: '240px 1fr' }} width="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} />;
return <ShareNav onItemClick={close} />;
}}
</MobileMenuButton>
</Grid>
</Row>
<Column
display={{ xs: 'none', lg: 'flex' }}
width="240px"
height="100%"
border="right"
backgroundColor
marginRight="2"
>
<Column display={{ xs: 'none', lg: 'flex' }}>
<ShareNav shareId={shareId} parameters={parameters} />
</Column>
<Column display={{ xs: 'none', lg: 'flex' }} marginRight="2">
<ShareNav />
</Column>
<PageBody gap>
<WebsiteProvider websiteId={websiteId}>
<ShareHeader whiteLabel={whiteLabel} />
<Column>
<WebsiteHeader showActions={false} />
<PageComponent websiteId={websiteId} />
</Column>
<ShareFooter whiteLabel={whiteLabel} />
</WebsiteProvider>
</PageBody>
</Grid>
</Column>
);
}

View file

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

View file

@ -7,6 +7,7 @@ import {
NavMenuItem,
type NavMenuProps,
Row,
Text,
} from '@umami/react-zen';
import Link from 'next/link';
@ -42,9 +43,11 @@ export function SideMenu({
return (
<Link key={id} href={path}>
<NavMenuItem isSelected={isSelected}>
<IconLabel icon={icon}>{label}</IconLabel>
</NavMenuItem>
<Row padding hoverBackgroundColor="3">
<IconLabel icon={icon}>
<Text weight={isSelected ? 'bold' : undefined}>{label}</Text>
</IconLabel>
</Row>
</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
export * from './context/useLink';
export * from './context/usePixel';
export * from './context/useShare';
export * from './context/useTeam';
export * from './context/useUser';
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';
const selector = (state: { shareToken: string }) => state.shareToken;
const selector = state => state.share;
export function useShareTokenQuery(slug: string): {
shareToken: any;
isLoading?: boolean;
error?: Error;
} {
const shareToken = useApp(selector);
export function useShareTokenQuery(slug: string) {
const share = useApp(selector);
const { get, useQuery } = useApi();
const { isLoading, error } = useQuery({
const query = useQuery({
queryKey: ['share', slug],
queryFn: async () => {
const data = await get(`/share/${slug}`);
setShareToken(data);
setShare(data);
return data;
},
});
return { shareToken, isLoading, error };
return { share, ...query };
}

View file

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