diff --git a/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx b/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx index e4a08b05..4858ec73 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx @@ -25,6 +25,7 @@ const views = { exit: PagesTable, title: PagesTable, referrer: ReferrersTable, + grouped: ReferrersTable, host: HostsTable, browser: BrowsersTable, os: OSTable, diff --git a/src/app/api/websites/[websiteId]/metrics/route.ts b/src/app/api/websites/[websiteId]/metrics/route.ts index 1842fc80..70ed9f90 100644 --- a/src/app/api/websites/[websiteId]/metrics/route.ts +++ b/src/app/api/websites/[websiteId]/metrics/route.ts @@ -1,7 +1,18 @@ import { z } from 'zod'; import thenby from 'thenby'; import { canViewWebsite } from '@/lib/auth'; -import { SESSION_COLUMNS, EVENT_COLUMNS, FILTER_COLUMNS, OPERATORS } from '@/lib/constants'; +import { + SESSION_COLUMNS, + EVENT_COLUMNS, + FILTER_COLUMNS, + OPERATORS, + SEARCH_DOMAINS, + SOCIAL_DOMAINS, + EMAIL_DOMAINS, + SHOPPING_DOMAINS, + VIDEO_DOMAINS, + PAID_AD_PARAMS, +} from '@/lib/constants'; import { getRequestFilters, getRequestDateRange, parseRequest } from '@/lib/request'; import { json, unauthorized, badRequest } from '@/lib/response'; import { getPageviewMetrics, getSessionMetrics, getChannelMetrics } from '@/queries'; @@ -94,64 +105,6 @@ export async function GET( return badRequest(); } -const SOCIAL_DOMAINS = [ - 'facebook.com', - 'fb.com', - 'instagram.com', - 'ig.com', - 'twitter.com', - 't.co', - 'x.com', - 'linkedin.', - 'tiktok.', - 'reddit.', - 'threads.net', - 'bsky.app', - 'news.ycombinator.com', -]; - -const SEARCH_DOMAINS = [ - 'google.', - 'bing.com', - 'msn.com', - 'duckduckgo.com', - 'search.brave.com', - 'yandex.', - 'baidu.com', - 'ecosia.org', - 'chatgpt.com', - 'perplexity.ai', -]; - -const SHOPPING_DOMAINS = [ - 'amazon.', - 'ebay.com', - 'walmart.com', - 'alibab.com', - 'aliexpress.com', - 'etsy.com', - 'bestbuy.com', - 'target.com', - 'newegg.com', -]; - -const EMAIL_DOMAINS = ['gmail.', 'mail.yahoo.', 'outlook.', 'hotmail.', 'protonmail.', 'proton.me']; - -const VIDEO_DOMAINS = ['youtube.', 'twitch.']; - -const PAID_AD_PARAMS = [ - 'utm_source=google', - 'gclid=', - 'fbclid=', - 'msclkid=', - 'dclid=', - 'twclid=', - 'li_fat_id=', - 'epik=', - 'ttclid=', - 'scid=', -]; - function getChannels(data: { domain: string; query: string; visitors: number }[]) { const channels = { direct: 0, diff --git a/src/components/common/Favicon.tsx b/src/components/common/Favicon.tsx index 47c65aab..ea3f31aa 100644 --- a/src/components/common/Favicon.tsx +++ b/src/components/common/Favicon.tsx @@ -1,3 +1,5 @@ +import { GROUPED_DOMAINS } from '@/lib/constants'; + function getHostName(url: string) { const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?([^:/\n?=]+)/im); return match && match.length > 1 ? match[1] : null; @@ -9,16 +11,11 @@ export function Favicon({ domain, ...props }) { } const hostName = domain ? getHostName(domain) : null; + const src = hostName + ? `https://icons.duckduckgo.com/ip3/${GROUPED_DOMAINS[hostName]?.domain || hostName}.ico` + : null; - return hostName ? ( - - ) : null; + return hostName ? : null; } export default Favicon; diff --git a/src/components/messages.ts b/src/components/messages.ts index cefc00e1..5279e1b4 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -295,6 +295,8 @@ export const labels = defineMessages({ paidSocial: { id: 'label.paid-social', defaultMessage: 'Paid social' }, paidShopping: { id: 'label.paid-shopping', defaultMessage: 'Paid shopping' }, paidVideo: { id: 'label.paid-video', defaultMessage: 'Paid video' }, + grouped: { id: 'label.grouped', defaultMessage: 'Grouped' }, + other: { id: 'label.other', defaultMessage: 'Other' }, }); export const messages = defineMessages({ diff --git a/src/components/metrics/ReferrersTable.tsx b/src/components/metrics/ReferrersTable.tsx index 9e326994..142f361b 100644 --- a/src/components/metrics/ReferrersTable.tsx +++ b/src/components/metrics/ReferrersTable.tsx @@ -1,12 +1,53 @@ import FilterLink from '@/components/common/FilterLink'; import Favicon from '@/components/common/Favicon'; -import { useMessages } from '@/components/hooks'; +import { useMessages, useNavigation } from '@/components/hooks'; import MetricsTable, { MetricsTableProps } from './MetricsTable'; +import FilterButtons from '@/components/common/FilterButtons'; +import thenby from 'thenby'; +import { GROUPED_DOMAINS } from '@/lib/constants'; +import { Flexbox } from 'react-basics'; -export function ReferrersTable(props: MetricsTableProps) { +export interface ReferrersTableProps extends MetricsTableProps { + allowFilter?: boolean; +} + +export function ReferrersTable({ allowFilter, ...props }: ReferrersTableProps) { + const { + router, + renderUrl, + query: { view = 'referrer' }, + } = useNavigation(); const { formatMessage, labels } = useMessages(); + const handleSelect = (key: any) => { + router.push(renderUrl({ view: key }), { scroll: false }); + }; + + const buttons = [ + { + label: formatMessage(labels.domain), + key: 'referrer', + }, + { + label: formatMessage(labels.grouped), + key: 'grouped', + }, + ]; + const renderLink = ({ x: referrer }) => { + if (view === 'grouped') { + if (referrer === '_other') { + return `(${formatMessage(labels.other)})`; + } else { + return ( + + + {GROUPED_DOMAINS.find(({ domain }) => domain === referrer)?.name} + + ); + } + } + return ( { + const groups = { _other: 0 }; + + for (const { x, y } of data) { + for (const { domain, match } of GROUPED_DOMAINS) { + if (Array.isArray(match) ? match.some(str => x.includes(str)) : x.includes(match)) { + if (!groups[domain]) { + groups[domain] = 0; + } + groups[domain] += y; + } else { + groups._other += y; + } + } + } + + return Object.keys(groups) + .map((key: any) => ({ x: key, y: groups[key] })) + .sort(thenby.firstBy('y', -1)); + }; + return ( <> + > + {allowFilter && ( + + )} + ); } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index cbc954e3..545f86c8 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -338,6 +338,90 @@ export const IP_ADDRESS_HEADERS = [ 'x-appengine-user-ip', ]; +export const SOCIAL_DOMAINS = [ + 'facebook.com', + 'fb.com', + 'instagram.com', + 'ig.com', + 'twitter.com', + 't.co', + 'x.com', + 'linkedin.', + 'tiktok.', + 'reddit.', + 'threads.net', + 'bsky.app', + 'news.ycombinator.com', + 'snapchat.', + 'pinterest.', +]; + +export const SEARCH_DOMAINS = [ + 'google.', + 'bing.com', + 'msn.com', + 'duckduckgo.com', + 'search.brave.com', + 'yandex.', + 'baidu.com', + 'ecosia.org', + 'chatgpt.com', + 'perplexity.ai', +]; + +export const SHOPPING_DOMAINS = [ + 'amazon.', + 'ebay.com', + 'walmart.com', + 'alibab.com', + 'aliexpress.com', + 'etsy.com', + 'bestbuy.com', + 'target.com', + 'newegg.com', +]; + +export const EMAIL_DOMAINS = [ + 'gmail.', + 'mail.yahoo.', + 'outlook.', + 'hotmail.', + 'protonmail.', + 'proton.me', +]; + +export const VIDEO_DOMAINS = ['youtube.', 'twitch.']; + +export const PAID_AD_PARAMS = [ + 'utm_source=google', + 'gclid=', + 'fbclid=', + 'msclkid=', + 'dclid=', + 'twclid=', + 'li_fat_id=', + 'epik=', + 'ttclid=', + 'scid=', +]; + +export const GROUPED_DOMAINS = [ + { name: 'Google', domain: 'google.com', match: 'google.' }, + { name: 'Facebook', domain: 'facebook.com', match: 'facebook.' }, + { name: 'Reddit', domain: 'reddit.com', match: 'reddit.' }, + { name: 'LinkedIn', domain: 'linkedin.com', match: 'linkedin.' }, + { name: 'GitHub', domain: 'github.com', match: 'github.' }, + { name: 'Hacker News', domain: 'news.ycombinator.com', match: 'news.ycombinator.com' }, + { name: 'Bing', domain: 'bing.com', match: 'bing.' }, + { name: 'Brave', domain: 'brave.com', match: 'brave.' }, + { name: 'DuckDuckGo', domain: 'duckduckgo.com', match: 'duckduckgo.' }, + { name: 'Twitter', domain: 'twitter.com', match: ['twitter.', 't.co', 'x.com'] }, + { name: 'Instagram', domain: 'instagram.com', match: ['instagram.', 'ig.com'] }, + { name: 'Snapchat', domain: 'snapchat.com', match: 'snapchat.' }, + { name: 'Pinterest', domain: 'pinterest.com', match: 'pinterest.' }, + { name: 'ChatGPT', domain: 'chatgpt.com', match: 'chatgpt.' }, +]; + export const MAP_FILE = '/datamaps.world.json'; export const ISO_COUNTRIES = {