diff --git a/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx b/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx index 7260a7ea..95628dd3 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx @@ -9,11 +9,10 @@ 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 deleted file mode 100644 index 5348ac63..00000000 --- a/src/app/share/[...shareId]/ShareFooter.tsx +++ /dev/null @@ -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 ( - - - {whiteLabel.name} - - - ); - } - - return ( - - - umami {`v${CURRENT_VERSION}`} - - - ); -} diff --git a/src/app/share/[...shareId]/ShareHeader.tsx b/src/app/share/[...shareId]/ShareHeader.tsx deleted file mode 100644 index abd8511d..00000000 --- a/src/app/share/[...shareId]/ShareHeader.tsx +++ /dev/null @@ -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 ( - - - - {logoImage ? ( - {logoName} - ) : ( - - - - )} - {logoName} - - - - - - - - - ); -} diff --git a/src/app/share/[...shareId]/SharePage.tsx b/src/app/share/[...shareId]/SharePage.tsx deleted file mode 100644 index 91a8b298..00000000 --- a/src/app/share/[...shareId]/SharePage.tsx +++ /dev/null @@ -1,155 +0,0 @@ -'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 deleted file mode 100644 index 3a21f836..00000000 --- a/src/app/share/[...shareId]/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/src/app/share/[...shareId]/ShareNav.tsx b/src/app/share/[slug]/[[...path]]/ShareNav.tsx similarity index 52% rename from src/app/share/[...shareId]/ShareNav.tsx rename to src/app/share/[slug]/[[...path]]/ShareNav.tsx index b494046d..e6ca3865 100644 --- a/src/app/share/[...shareId]/ShareNav.tsx +++ b/src/app/share/[slug]/[[...path]]/ShareNav.tsx @@ -1,23 +1,30 @@ -'use client'; -import { Column } from '@umami/react-zen'; +import { Button, Column, Icon, Row, Text, ThemeButton } from '@umami/react-zen'; import { SideMenu } from '@/components/common/SideMenu'; -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'; +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'; export function ShareNav({ - shareId, - parameters, + collapsed, + onCollapse, onItemClick, }: { - shareId: string; - parameters: Record; + collapsed?: boolean; + onCollapse?: (collapsed: boolean) => void; onItemClick?: () => void; }) { + const share = useShare(); const { formatMessage, labels } = useMessages(); 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 = [ { @@ -130,14 +137,70 @@ 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/[slug]/[[...path]]/SharePage.tsx b/src/app/share/[slug]/[[...path]]/SharePage.tsx new file mode 100644 index 00000000..aab32c1f --- /dev/null +++ b/src/app/share/[slug]/[[...path]]/SharePage.tsx @@ -0,0 +1,103 @@ +'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 new file mode 100644 index 00000000..872cf267 --- /dev/null +++ b/src/app/share/[slug]/[[...path]]/page.tsx @@ -0,0 +1,5 @@ +import { SharePage } from './SharePage'; + +export default function () { + return ; +} diff --git a/src/app/share/[slug]/layout.tsx b/src/app/share/[slug]/layout.tsx new file mode 100644 index 00000000..7a5f4599 --- /dev/null +++ b/src/app/share/[slug]/layout.tsx @@ -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 {children}; +} diff --git a/src/components/common/SideMenu.tsx b/src/components/common/SideMenu.tsx index 92ff798a..dd716d78 100644 --- a/src/components/common/SideMenu.tsx +++ b/src/components/common/SideMenu.tsx @@ -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 ( - - {label} - + + + {label} + + ); }); diff --git a/src/components/hooks/context/useShare.ts b/src/components/hooks/context/useShare.ts new file mode 100644 index 00000000..c7493c66 --- /dev/null +++ b/src/components/hooks/context/useShare.ts @@ -0,0 +1,6 @@ +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 f47f11f0..89cb904b 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -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'; diff --git a/src/components/hooks/queries/useShareTokenQuery.ts b/src/components/hooks/queries/useShareTokenQuery.ts index 446e33da..28820be0 100644 --- a/src/components/hooks/queries/useShareTokenQuery.ts +++ b/src/components/hooks/queries/useShareTokenQuery.ts @@ -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 }; } diff --git a/src/components/input/MobileMenuButton.tsx b/src/components/input/MobileMenuButton.tsx index 5e59cbbb..8498b05a 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 a76058ec..58e596f5 100644 --- a/src/components/input/WebsiteDateFilter.tsx +++ b/src/components/input/WebsiteDateFilter.tsx @@ -31,9 +31,11 @@ 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') { + if (date === 'all' && hasData) { router.push( updateParams({ date: `${getDateRangeValue(websiteDateRange.startDate, websiteDateRange.endDate)}:all`, @@ -78,7 +80,7 @@ export function WebsiteDateFilter({ diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index bfd007d1..c2f98b57 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 != 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)`, ); } } diff --git a/src/queries/sql/getValues.ts b/src/queries/sql/getValues.ts index cc6bb7d2..8573335b 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 != website_event.hostname + excludeDomain = `and website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '') and website_event.referrer_domain != ''`; } diff --git a/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts b/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts index ccb0be53..54164973 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 != website_event.hostname + excludeDomain = `and website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '') 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 9d4f6278..b41ea058 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 != website_event.hostname + excludeDomain = `and website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '') and website_event.referrer_domain != ''`; } diff --git a/src/queries/sql/reports/getAttribution.ts b/src/queries/sql/reports/getAttribution.ts index 29068f7d..088b5b24 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 != hostname + : `and we.referrer_domain != regexp_replace(we.hostname, '^www.', '') and we.referrer_domain != ''` } group by 1 diff --git a/src/store/app.ts b/src/store/app.ts index bb54e565..e2a54a80 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, - 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) {