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 = {