diff --git a/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx b/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx index 95628dd3..7260a7ea 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx @@ -9,10 +9,11 @@ import { WebsiteNav } from './WebsiteNav'; export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) { return ( - + (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 ; - } - - if (!share || shouldRedirect) { - return null; - } - - return {children}; -} diff --git a/src/app/share/[...shareId]/ShareFooter.tsx b/src/app/share/[...shareId]/ShareFooter.tsx new file mode 100644 index 00000000..5348ac63 --- /dev/null +++ b/src/app/share/[...shareId]/ShareFooter.tsx @@ -0,0 +1,23 @@ +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 ( + + + {whiteLabel.name} + + + ); + } + + return ( + + + umami {`v${CURRENT_VERSION}`} + + + ); +} diff --git a/src/app/share/[...shareId]/ShareHeader.tsx b/src/app/share/[...shareId]/ShareHeader.tsx new file mode 100644 index 00000000..abd8511d --- /dev/null +++ b/src/app/share/[...shareId]/ShareHeader.tsx @@ -0,0 +1,33 @@ +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 ( + + + + {logoImage ? ( + {logoName} + ) : ( + + + + )} + {logoName} + + + + + + + + + ); +} diff --git a/src/app/share/[slug]/[[...path]]/ShareNav.tsx b/src/app/share/[...shareId]/ShareNav.tsx similarity index 52% rename from src/app/share/[slug]/[[...path]]/ShareNav.tsx rename to src/app/share/[...shareId]/ShareNav.tsx index e6ca3865..b494046d 100644 --- a/src/app/share/[slug]/[[...path]]/ShareNav.tsx +++ b/src/app/share/[...shareId]/ShareNav.tsx @@ -1,30 +1,23 @@ -import { Button, Column, Icon, Row, Text, ThemeButton } from '@umami/react-zen'; +'use client'; +import { Column } from '@umami/react-zen'; import { SideMenu } from '@/components/common/SideMenu'; -import { useMessages, useNavigation, useShare } from '@/components/hooks'; -import { AlignEndHorizontal, Clock, Eye, PanelLeft, Sheet, Tag, User } from '@/components/icons'; -import { LanguageButton } from '@/components/input/LanguageButton'; -import { PreferencesButton } from '@/components/input/PreferencesButton'; -import { Funnel, Lightning, Logo, Magnet, Money, Network, Path, Target } from '@/components/svg'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { AlignEndHorizontal, Clock, Eye, Sheet, Tag, User } from '@/components/icons'; +import { Funnel, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg'; export function ShareNav({ - collapsed, - onCollapse, + shareId, + parameters, onItemClick, }: { - collapsed?: boolean; - onCollapse?: (collapsed: boolean) => void; + shareId: string; + parameters: Record; onItemClick?: () => void; }) { - const share = useShare(); const { formatMessage, labels } = useMessages(); const { pathname } = useNavigation(); - const { slug, parameters, whiteLabel } = share; - const logoUrl = whiteLabel?.url || 'https://umami.is'; - const logoName = whiteLabel?.name || 'umami'; - const logoImage = whiteLabel?.image; - - const renderPath = (path: string) => `/share/${slug}${path}`; + const renderPath = (path: string) => `/share/${shareId}${path}`; const allItems = [ { @@ -137,70 +130,14 @@ export function ShareNav({ .flatMap(e => e.items) .find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id; - const isMobile = !!onItemClick; - return ( - - - {!collapsed && ( - - - {logoImage ? ( - {logoName} - ) : ( - - - - )} - {logoName} - - - )} - {!onItemClick && ( - - )} - - {!collapsed && ( - - - - )} - - {collapsed ? ( - - - - - - ) : ( - - - - - - )} - + + ); } diff --git a/src/app/share/[...shareId]/SharePage.tsx b/src/app/share/[...shareId]/SharePage.tsx new file mode 100644 index 00000000..91a8b298 --- /dev/null +++ b/src/app/share/[...shareId]/SharePage.tsx @@ -0,0 +1,155 @@ +'use client'; +import { Column, Grid, Row, useTheme } from '@umami/react-zen'; +import { 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'; +import { FunnelsPage } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage'; +import { GoalsPage } from '@/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage'; +import { JourneysPage } from '@/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage'; +import { RetentionPage } from '@/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage'; +import { RevenuePage } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage'; +import { UTMPage } from '@/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage'; +import { ComparePage } from '@/app/(main)/websites/[websiteId]/compare/ComparePage'; +import { EventsPage } from '@/app/(main)/websites/[websiteId]/events/EventsPage'; +import { RealtimePage } from '@/app/(main)/websites/[websiteId]/realtime/RealtimePage'; +import { SessionsPage } from '@/app/(main)/websites/[websiteId]/sessions/SessionsPage'; +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 { MobileMenuButton } from '@/components/input/MobileMenuButton'; +import { ShareFooter } from './ShareFooter'; +import { ShareHeader } from './ShareHeader'; +import { ShareNav } from './ShareNav'; + +const PAGE_COMPONENTS: Record> = { + '': WebsitePage, + overview: WebsitePage, + events: EventsPage, + sessions: SessionsPage, + realtime: RealtimePage, + compare: ComparePage, + breakdown: BreakdownPage, + goals: GoalsPage, + funnels: FunnelsPage, + journeys: JourneysPage, + retention: RetentionPage, + utm: UTMPage, + revenue: RevenuePage, + 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); + const theme = url.searchParams.get('theme'); + + if (theme === 'light' || theme === 'dark') { + setTheme(theme); + } + }, []); + + // 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; + } + + // Check if the requested path is allowed + const pageKey = path || ''; + const isAllowed = pageKey === '' || pageKey === 'overview' || parameters[pageKey] !== false; + + if (!isAllowed) { + return null; + } + + const PageComponent = PAGE_COMPONENTS[pageKey] || WebsitePage; + + return ( + + + + + + {({ close }) => { + return ; + }} + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/share/[...shareId]/page.tsx b/src/app/share/[...shareId]/page.tsx new file mode 100644 index 00000000..3a21f836 --- /dev/null +++ b/src/app/share/[...shareId]/page.tsx @@ -0,0 +1,8 @@ +import { SharePage } from './SharePage'; + +export default async function ({ params }: { params: Promise<{ shareId: string[] }> }) { + const { shareId } = await params; + const [slug, ...path] = shareId; + + return ; +} diff --git a/src/app/share/[slug]/[[...path]]/SharePage.tsx b/src/app/share/[slug]/[[...path]]/SharePage.tsx deleted file mode 100644 index aab32c1f..00000000 --- a/src/app/share/[slug]/[[...path]]/SharePage.tsx +++ /dev/null @@ -1,103 +0,0 @@ -'use client'; -import { Column, Grid, Row, useTheme } from '@umami/react-zen'; -import { usePathname } from 'next/navigation'; -import { useEffect, useState } 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'; -import { GoalsPage } from '@/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage'; -import { JourneysPage } from '@/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage'; -import { RetentionPage } from '@/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage'; -import { RevenuePage } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage'; -import { UTMPage } from '@/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage'; -import { ComparePage } from '@/app/(main)/websites/[websiteId]/compare/ComparePage'; -import { EventsPage } from '@/app/(main)/websites/[websiteId]/events/EventsPage'; -import { RealtimePage } from '@/app/(main)/websites/[websiteId]/realtime/RealtimePage'; -import { SessionsPage } from '@/app/(main)/websites/[websiteId]/sessions/SessionsPage'; -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 { useShare } from '@/components/hooks'; -import { MobileMenuButton } from '@/components/input/MobileMenuButton'; -import { ShareNav } from './ShareNav'; - -const PAGE_COMPONENTS: Record> = { - '': WebsitePage, - overview: WebsitePage, - events: EventsPage, - sessions: SessionsPage, - realtime: RealtimePage, - compare: ComparePage, - breakdown: BreakdownPage, - goals: GoalsPage, - funnels: FunnelsPage, - journeys: JourneysPage, - retention: RetentionPage, - utm: UTMPage, - revenue: RevenuePage, - attribution: AttributionPage, -}; - -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 SharePage() { - const [navCollapsed, setNavCollapsed] = useState(false); - const share = useShare(); - const { setTheme } = useTheme(); - const pathname = usePathname(); - const path = getSharePath(pathname); - const { websiteId, parameters = {} } = share; - - useEffect(() => { - const url = new URL(window?.location?.href); - const theme = url.searchParams.get('theme'); - - if (theme === 'light' || theme === 'dark') { - setTheme(theme); - } - }, []); - - // Check if the requested path is allowed - const pageKey = path || ''; - const isAllowed = pageKey === '' || pageKey === 'overview' || parameters[pageKey] !== false; - - if (!isAllowed) { - return null; - } - - const PageComponent = PAGE_COMPONENTS[pageKey] || WebsitePage; - - return ( - - - - {({ close }) => { - return ; - }} - - - - - - - - - - - - - - - ); -} diff --git a/src/app/share/[slug]/[[...path]]/page.tsx b/src/app/share/[slug]/[[...path]]/page.tsx deleted file mode 100644 index 872cf267..00000000 --- a/src/app/share/[slug]/[[...path]]/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SharePage } from './SharePage'; - -export default function () { - return ; -} diff --git a/src/app/share/[slug]/layout.tsx b/src/app/share/[slug]/layout.tsx deleted file mode 100644 index 7a5f4599..00000000 --- a/src/app/share/[slug]/layout.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { ShareProvider } from '@/app/share/ShareProvider'; - -export default async function ({ - params, - children, -}: { - params: Promise<{ slug: string }>; - children: React.ReactNode; -}) { - const { slug } = await params; - - return {children}; -} diff --git a/src/components/common/SideMenu.tsx b/src/components/common/SideMenu.tsx index dd716d78..92ff798a 100644 --- a/src/components/common/SideMenu.tsx +++ b/src/components/common/SideMenu.tsx @@ -7,7 +7,6 @@ import { NavMenuItem, type NavMenuProps, Row, - Text, } from '@umami/react-zen'; import Link from 'next/link'; @@ -43,11 +42,9 @@ export function SideMenu({ return ( - - - {label} - - + + {label} + ); }); diff --git a/src/components/hooks/context/useShare.ts b/src/components/hooks/context/useShare.ts deleted file mode 100644 index c7493c66..00000000 --- a/src/components/hooks/context/useShare.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { useContext } from 'react'; -import { ShareContext } from '@/app/share/ShareProvider'; - -export function useShare() { - return useContext(ShareContext); -} diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index 89cb904b..f47f11f0 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -3,7 +3,6 @@ // 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'; diff --git a/src/components/hooks/queries/useShareTokenQuery.ts b/src/components/hooks/queries/useShareTokenQuery.ts index 28820be0..446e33da 100644 --- a/src/components/hooks/queries/useShareTokenQuery.ts +++ b/src/components/hooks/queries/useShareTokenQuery.ts @@ -1,21 +1,25 @@ -import { setShare, useApp } from '@/store/app'; +import { setShareToken, useApp } from '@/store/app'; import { useApi } from '../useApi'; -const selector = state => state.share; +const selector = (state: { shareToken: string }) => state.shareToken; -export function useShareTokenQuery(slug: string) { - const share = useApp(selector); +export function useShareTokenQuery(slug: string): { + shareToken: any; + isLoading?: boolean; + error?: Error; +} { + const shareToken = useApp(selector); const { get, useQuery } = useApi(); - const query = useQuery({ + const { isLoading, error } = useQuery({ queryKey: ['share', slug], queryFn: async () => { const data = await get(`/share/${slug}`); - setShare(data); + setShareToken(data); return data; }, }); - return { share, ...query }; + return { shareToken, isLoading, error }; } diff --git a/src/components/input/MobileMenuButton.tsx b/src/components/input/MobileMenuButton.tsx index 8498b05a..5e59cbbb 100644 --- a/src/components/input/MobileMenuButton.tsx +++ b/src/components/input/MobileMenuButton.tsx @@ -9,8 +9,8 @@ export function MobileMenuButton(props: DialogProps) { - - + + ); diff --git a/src/components/input/WebsiteDateFilter.tsx b/src/components/input/WebsiteDateFilter.tsx index 58e596f5..a76058ec 100644 --- a/src/components/input/WebsiteDateFilter.tsx +++ b/src/components/input/WebsiteDateFilter.tsx @@ -31,11 +31,9 @@ export function WebsiteDateFilter({ const showCompare = allowCompare && !isAllTime; const websiteDateRange = useDateRangeQuery(websiteId); - const { startDate, endDate } = websiteDateRange; - const hasData = startDate && endDate; const handleChange = (date: string) => { - if (date === 'all' && hasData) { + if (date === 'all') { router.push( updateParams({ date: `${getDateRangeValue(websiteDateRange.startDate, websiteDateRange.endDate)}:all`, @@ -80,7 +78,7 @@ export function WebsiteDateFilter({ diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index c2f98b57..bfd007d1 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -108,7 +108,7 @@ function getFilterQuery(filters: Record, options: QueryOptions = {} if (name === 'referrer') { arr.push( - `and (website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '') or website_event.referrer_domain is null)`, + `and (website_event.referrer_domain != website_event.hostname or website_event.referrer_domain is null)`, ); } } diff --git a/src/queries/sql/getValues.ts b/src/queries/sql/getValues.ts index 8573335b..cc6bb7d2 100644 --- a/src/queries/sql/getValues.ts +++ b/src/queries/sql/getValues.ts @@ -23,7 +23,7 @@ async function relationalQuery(websiteId: string, column: string, filters: Query let excludeDomain = ''; if (column === 'referrer_domain') { - excludeDomain = `and website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '') + excludeDomain = `and website_event.referrer_domain != website_event.hostname and website_event.referrer_domain != ''`; } diff --git a/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts b/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts index 54164973..ccb0be53 100644 --- a/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts +++ b/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts @@ -50,7 +50,7 @@ async function relationalQuery( let excludeDomain = ''; if (column === 'referrer_domain') { - excludeDomain = `and website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '') + excludeDomain = `and website_event.referrer_domain != website_event.hostname and website_event.referrer_domain != ''`; if (type === 'domain') { column = toPostgresGroupedReferrer(GROUPED_DOMAINS); diff --git a/src/queries/sql/pageviews/getPageviewMetrics.ts b/src/queries/sql/pageviews/getPageviewMetrics.ts index b41ea058..9d4f6278 100644 --- a/src/queries/sql/pageviews/getPageviewMetrics.ts +++ b/src/queries/sql/pageviews/getPageviewMetrics.ts @@ -46,7 +46,7 @@ async function relationalQuery( let excludeDomain = ''; if (column === 'referrer_domain') { - excludeDomain = `and website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '') + excludeDomain = `and website_event.referrer_domain != website_event.hostname and website_event.referrer_domain != ''`; } diff --git a/src/queries/sql/reports/getAttribution.ts b/src/queries/sql/reports/getAttribution.ts index 088b5b24..29068f7d 100644 --- a/src/queries/sql/reports/getAttribution.ts +++ b/src/queries/sql/reports/getAttribution.ts @@ -142,7 +142,7 @@ async function relationalQuery( ${ currency ? '' - : `and we.referrer_domain != regexp_replace(we.hostname, '^www.', '') + : `and we.referrer_domain != hostname and we.referrer_domain != ''` } group by 1 diff --git a/src/store/app.ts b/src/store/app.ts index e2a54a80..bb54e565 100644 --- a/src/store/app.ts +++ b/src/store/app.ts @@ -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, - share: null, + shareToken: null, user: null, config: null, }; @@ -31,8 +31,8 @@ export function setLocale(locale: string) { store.setState({ locale }); } -export function setShare(share: object) { - store.setState({ share }); +export function setShareToken(shareToken: string) { + store.setState({ shareToken }); } export function setUser(user: object) {