├── .gitignore
├── jest
├── fetch.js
├── mutation-observer.js
└── test-utils.tsx
├── tsconfig.json
├── .github
└── workflows
│ └── main.yml
├── LICENSE
├── package.json
├── README.md
├── test
└── index.test.tsx
└── src
└── index.tsx
/.gitignore:
--------------------------------------------------------------------------------
1 | *.log
2 | .DS_Store
3 | node_modules
4 | .cache
5 | dist
6 |
--------------------------------------------------------------------------------
/jest/fetch.js:
--------------------------------------------------------------------------------
1 | // Next.js has its own fetch polyfill, but we don't have next when testing.
2 | // So we provide a fetch polyfill.
3 | // require('unfetch/polyfill');
4 | global.fetch = jest.fn();
5 |
--------------------------------------------------------------------------------
/jest/mutation-observer.js:
--------------------------------------------------------------------------------
1 | // This polyfill is necessary until tsdx upgrades to the latest jest and jsdom
2 | // https://github.com/testing-library/dom-testing-library/releases/tag/v7.0.0
3 | window.MutationObserver = require('@sheerun/mutationobserver-shim');
4 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["src", "types"],
3 | "compilerOptions": {
4 | "module": "esnext",
5 | "lib": ["dom", "esnext"],
6 | "importHelpers": true,
7 | "declaration": true,
8 | "sourceMap": true,
9 | "rootDir": "./src",
10 | "strict": true,
11 | "noUnusedLocals": true,
12 | "noUnusedParameters": true,
13 | "noImplicitReturns": true,
14 | "noFallthroughCasesInSwitch": true,
15 | "moduleResolution": "node",
16 | "baseUrl": "./",
17 | "paths": {
18 | "*": ["src/*", "node_modules/*"]
19 | },
20 | "jsx": "react",
21 | "esModuleInterop": true
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on: [push]
3 | jobs:
4 | build:
5 | runs-on: ubuntu-latest
6 |
7 | steps:
8 | - name: Begin CI...
9 | uses: actions/checkout@v2
10 |
11 | - name: Use Node 12
12 | uses: actions/setup-node@v1
13 | with:
14 | node-version: 12.x
15 |
16 | - name: Use cached node_modules
17 | uses: actions/cache@v1
18 | with:
19 | path: node_modules
20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }}
21 | restore-keys: |
22 | nodeModules-
23 |
24 | - name: Install dependencies
25 | run: yarn install --frozen-lockfile
26 | env:
27 | CI: true
28 |
29 | - name: Lint
30 | run: yarn lint
31 | env:
32 | CI: true
33 |
34 | - name: Test
35 | run: yarn test --ci --coverage --maxWorkers=2
36 | env:
37 | CI: true
38 |
39 | - name: Build
40 | run: yarn build
41 | env:
42 | CI: true
43 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Dominik Ferber
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/jest/test-utils.tsx:
--------------------------------------------------------------------------------
1 | // taken from https://github.com/vercel/next.js/issues/7479#issuecomment-659859682
2 | import React from 'react';
3 | import { render as defaultRender } from '@testing-library/react';
4 | import { RouterContext } from 'next/dist/next-server/lib/router-context';
5 | import { NextRouter } from 'next/router';
6 |
7 | export * from '@testing-library/react';
8 |
9 | const mockRouter: NextRouter = {
10 | basePath: '',
11 | pathname: '/',
12 | route: '/',
13 | asPath: '/',
14 | query: {},
15 | push: jest.fn(),
16 | replace: jest.fn(),
17 | reload: jest.fn(),
18 | back: jest.fn(),
19 | prefetch: jest.fn(),
20 | beforePopState: jest.fn(),
21 | events: {
22 | on: jest.fn(),
23 | off: jest.fn(),
24 | emit: jest.fn(),
25 | },
26 | isFallback: false,
27 | };
28 |
29 | // --------------------------------------------------
30 | // Override the default test render with our own
31 | //
32 | // You can override the router mock like this:
33 | //
34 | // const { baseElement } = render(, {
35 | // router: { pathname: '/my-custom-pathname' },
36 | // });
37 | // --------------------------------------------------
38 | type DefaultParams = Parameters;
39 | type RenderUI = DefaultParams[0];
40 | type RenderOptions = DefaultParams[1] & { router?: Partial };
41 |
42 | export function render(
43 | ui: RenderUI,
44 | { wrapper, router, ...options }: RenderOptions = {}
45 | ) {
46 | if (!wrapper) {
47 | wrapper = ({ children }) => (
48 |
49 | {children}
50 |
51 | );
52 | }
53 |
54 | return defaultRender(ui, { wrapper, ...options });
55 | }
56 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "1.0.1",
3 | "license": "MIT",
4 | "main": "dist/index.js",
5 | "typings": "dist/index.d.ts",
6 | "files": [
7 | "dist",
8 | "src"
9 | ],
10 | "engines": {
11 | "node": ">=10"
12 | },
13 | "scripts": {
14 | "start": "tsdx watch",
15 | "build": "tsdx build",
16 | "test": "tsdx test --passWithNoTests",
17 | "test:watch": "tsdx test --watch",
18 | "lint": "tsdx lint",
19 | "prepare": "tsdx build",
20 | "release": "np"
21 | },
22 | "peerDependencies": {
23 | "next": ">=9.1.7",
24 | "react": ">=16"
25 | },
26 | "husky": {
27 | "hooks": {
28 | "pre-commit": "tsdx lint"
29 | }
30 | },
31 | "prettier": {
32 | "printWidth": 80,
33 | "semi": true,
34 | "singleQuote": true,
35 | "trailingComma": "es5"
36 | },
37 | "repository": "happykit/analytics",
38 | "homepage": "https://happykit.dev",
39 | "name": "@happykit/analytics",
40 | "author": "Dominik Ferber",
41 | "module": "dist/analytics.esm.js",
42 | "jest": {
43 | "setupFiles": [
44 | "./jest/fetch.js",
45 | "./jest/mutation-observer.js"
46 | ]
47 | },
48 | "publishConfig": {
49 | "access": "public"
50 | },
51 | "devDependencies": {
52 | "@sheerun/mutationobserver-shim": "0.3.3",
53 | "@testing-library/jest-dom": "5.11.2",
54 | "@testing-library/react": "10.4.7",
55 | "@types/react": "16.9.43",
56 | "@types/react-dom": "16.9.8",
57 | "babel-jest": "26.2.2",
58 | "husky": "4.2.5",
59 | "msw": "0.20.1",
60 | "next": "9.5.1",
61 | "np": "6.3.2",
62 | "react": "16.13.1",
63 | "react-dom": "16.13.1",
64 | "tsdx": "0.13.2",
65 | "tslib": "2.0.0",
66 | "typescript": "3.9.7",
67 | "unfetch": "4.1.0"
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
11 |
12 |
13 |
14 | Add analytics to your Next.js application with a single React Hook. This package integrates your Next.js application with HappyKit Analytics. Create a free [happykit.dev](https://happykit.dev/signup) account to get started.
15 |
16 | ## Key Features
17 |
18 | - Track page views and unique visitors
19 | - Integrate using a single React Hook
20 | - Next.js specific dynamic route tracking (e.g. `/[user]`)
21 | - GDPR compliant by default. No cookie notice necessary.
22 | - Tiny: [1kB minified + gzipped](https://bundlephobia.com/result?p=@happykit/analytics)
23 | - No external runtime, so no costly additional request to load a runtime
24 | - Written in TypeScript
25 |
26 | ## Installation
27 |
28 | Add the package to your project
29 |
30 | ```sh
31 | npm install @happykit/analytics
32 | ```
33 |
34 | ## Integration
35 |
36 | You'll need to add a single `useAnalytics` call to your application. The best place to do this is in `pages/_app.js`.
37 |
38 | Set up a `pages/_app.js` file with this content:
39 |
40 | ```js
41 | import { useAnalytics } from '@happykit/analytics';
42 |
43 | function MyApp({ Component, pageProps }) {
44 | useAnalytics({ publicKey: '' }); // <-- add this
45 |
46 | return ;
47 | }
48 |
49 | export default MyApp;
50 | ```
51 |
52 | > Create a free account on [happykit.dev](https://happykit.dev/signup) to get your _HappyKit Public Key_
53 |
54 |
55 | Using TypeScript?
56 |
57 | ```ts
58 | import { useAnalytics } from '@happykit/analytics';
59 | import type { AppProps } from 'next/app'
60 |
61 | function MyApp({ Component, pageProps }: AppProps) {
62 | useAnalytics({ publicKey: 'HAPPYKIT KEY' }); // <-- add this
63 |
64 | return
65 | }
66 |
67 | export default MyApp
68 | ```
69 |
70 |
71 |
72 | You can read more about using a custom `_app.js` component [here](https://nextjs.org/docs/advanced-features/custom-app).
73 |
74 | ## Options
75 |
76 | `useAnalytics(options)` accepts the following options object:
77 |
78 | - `publicKey` **string** (required): The public key for this project from [happykit.dev](https://happykit.dev/).
79 | - `skip` **function** (optional): This function is called with the created page view record. Return true to avoid tracking it.
80 | - `skipHostnames` **array of strings** (optional): An array of hostnames which will not be tracked. Defaults to `["localhost"]`. HappyKit tracks page views from preview deployments by default. The data is kept separate from your production data.
81 | - `delay` **number** (optional): The number of milliseconds to wait before reporting a page view. Defaults to 5000. This is used for batching purposes. This is used only if the browser supports `navigator.sendBeacon`. Otherwise page views are sent immediately.
82 |
83 | Example:
84 |
85 | ```js
86 | useAnalytics({
87 | publicKey: 'pk_live_5093bcd381',
88 | skip: pageView => pageView.pathname === '/some-ignored-path',
89 | });
90 | ```
91 |
--------------------------------------------------------------------------------
/test/index.test.tsx:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 | import * as React from 'react';
3 | import { render, screen, act } from '../jest/test-utils';
4 | import { useAnalytics, Analytics } from '../src';
5 |
6 | // navigator.sendBeacon is not defined in jsdom, so the
7 | // useAnalytics function will always send page views direclty through fetch.
8 |
9 | type PageViewBody = { publicKey: string; views: Analytics[] };
10 |
11 | beforeEach(() => {
12 | global.fetch = jest.fn();
13 | });
14 |
15 | declare var fetch: jest.Mock;
16 | declare var navigator: Navigator & {
17 | sendBeacon: jest.Mock;
18 | };
19 |
20 | function parseBody(
21 | mockContext: jest.MockContext['calls'][0]
22 | ): PageViewBody {
23 | return JSON.parse(mockContext[1].body);
24 | }
25 |
26 | describe('when called without a publicKey', () => {
27 | // Silence error message logging, while keeping other logs alive
28 | let err = console.error;
29 | beforeEach(() => {
30 | console.error = jest.fn();
31 | });
32 | afterEach(() => {
33 | console.error = err;
34 | });
35 | it('shoud throw an error', () => {
36 | function TestComponent() {
37 | useAnalytics({ publicKey: '' });
38 | return null;
39 | }
40 |
41 | expect.assertions(1);
42 |
43 | expect(() => {
44 | render();
45 | }).toThrowError('@happykit/analytics: missing options.publicKey');
46 | });
47 | });
48 |
49 | describe('when using fetch', () => {
50 | it('calls the api', async () => {
51 | function TestComponent() {
52 | useAnalytics({ publicKey: 'pk_test', skipHostnames: [] });
53 | return content
;
54 | }
55 |
56 | render(, {
57 | router: { pathname: '/[place]', asPath: '/home' },
58 | });
59 |
60 | expect(fetch).toHaveBeenCalledTimes(1);
61 | expect(fetch).toHaveBeenCalledWith(
62 | 'https://happykit.dev/api/pv',
63 | expect.objectContaining({
64 | body: expect.any(String),
65 | })
66 | );
67 |
68 | const body = parseBody(fetch.mock.calls[0]);
69 | expect(body.publicKey).toEqual('pk_test');
70 |
71 | expect(body.views).toHaveLength(1);
72 |
73 | const view = body.views[0];
74 | expect(view.hostname).toBe('localhost');
75 | expect(view.pathname).toBe('/home');
76 | expect(view.route).toBe('/[place]');
77 | expect(view.ua).toMatch(/jsdom/);
78 | expect(typeof view.timeZone).toBe('string');
79 | expect(view.referrer).toBe('');
80 | expect(view.referrerHostname).toBe(undefined);
81 | expect(view.referrerPathname).toBe(undefined);
82 | expect(view.urlReferrer).toBe(undefined);
83 | expect(typeof view.time).toBe('number');
84 |
85 | const content = await screen.findByText('content');
86 | expect(content).toBeInTheDocument();
87 | });
88 |
89 | it('accepts a custom apiRoute', async () => {
90 | function TestComponent() {
91 | useAnalytics({
92 | apiRoute: '/foo',
93 | publicKey: 'pk_test',
94 | skipHostnames: [],
95 | });
96 | return null;
97 | }
98 |
99 | render();
100 |
101 | expect(fetch).toHaveBeenCalledTimes(1);
102 | expect(fetch).toHaveBeenCalledWith(
103 | '/foo',
104 | expect.objectContaining({ body: expect.any(String) })
105 | );
106 |
107 | const body = parseBody(fetch.mock.calls[0]);
108 | expect(body.publicKey).toEqual('pk_test');
109 | });
110 |
111 | it('accepts a custom apiRoute', async () => {
112 | function TestComponent() {
113 | useAnalytics({
114 | apiRoute: '/foo',
115 | publicKey: 'pk_test',
116 | skipHostnames: [],
117 | });
118 | return null;
119 | }
120 |
121 | render(, {
122 | router: { pathname: '/home', route: '/home', asPath: '/' },
123 | });
124 |
125 | expect(fetch).toHaveBeenCalledTimes(1);
126 | expect(fetch).toHaveBeenCalledWith(
127 | '/foo',
128 | expect.objectContaining({ body: expect.any(String) })
129 | );
130 |
131 | const body = parseBody(fetch.mock.calls[0]);
132 | expect(body.publicKey).toEqual('pk_test');
133 | });
134 |
135 | it('respects the given skip function', async () => {
136 | const skip = jest.fn(() => true);
137 | function TestComponent() {
138 | useAnalytics({
139 | apiRoute: '/foo',
140 | publicKey: 'pk_test',
141 | skipHostnames: [],
142 | skip,
143 | });
144 | return null;
145 | }
146 |
147 | render();
148 |
149 | expect(skip).toHaveBeenCalledTimes(1);
150 | expect(skip).toHaveBeenCalledWith(
151 | expect.objectContaining({
152 | hostname: 'localhost',
153 | pathname: '/',
154 | referrer: '',
155 | referrerHostname: undefined,
156 | referrerPathname: undefined,
157 | route: '/',
158 | time: expect.any(Number),
159 | timeZone: expect.any(String),
160 | ua: expect.any(String),
161 | unique: expect.any(Boolean),
162 | urlReferrer: undefined,
163 | width: expect.any(Number),
164 | })
165 | );
166 | expect(fetch).toHaveBeenCalledTimes(0);
167 | });
168 |
169 | it('should not call the api from localhost', async () => {
170 | function TestComponent() {
171 | // note that skipHostnames defaults to ["localhost"],
172 | // and the request should thus be skipped
173 | useAnalytics({ publicKey: 'pk_test' });
174 | return null;
175 | }
176 |
177 | render();
178 | expect(fetch).toHaveBeenCalledTimes(0);
179 | });
180 | });
181 |
182 | describe('when using navigator.sendBeacon', () => {
183 | let n: typeof global.navigator.sendBeacon;
184 | beforeEach(() => {
185 | n = global.navigator.sendBeacon;
186 | global.navigator.sendBeacon = jest.fn();
187 | });
188 | afterEach(() => {
189 | global.navigator.sendBeacon = n;
190 | });
191 |
192 | it('calls the api', async () => {
193 | jest.useFakeTimers();
194 |
195 | function TestComponent() {
196 | useAnalytics({ publicKey: 'pk_test', skipHostnames: [] });
197 | return null;
198 | }
199 |
200 | render();
201 |
202 | // not called yet because it gets queued
203 | expect(navigator.sendBeacon).toHaveBeenCalledTimes(0);
204 |
205 | act(() => {
206 | jest.runAllTimers();
207 | });
208 |
209 | // called now because we forwarded the timers
210 | expect(navigator.sendBeacon).toHaveBeenCalledTimes(1);
211 |
212 | expect(navigator.sendBeacon).toHaveBeenCalledWith(
213 | 'https://happykit.dev/api/pv',
214 | expect.any(String)
215 | );
216 |
217 | const body: PageViewBody = JSON.parse(
218 | navigator.sendBeacon.mock.calls[0][1]
219 | );
220 | expect(body.views).toHaveLength(1);
221 | expect(body.publicKey).toBe('pk_test');
222 | const view = body.views[0];
223 | expect(view).toEqual(
224 | expect.objectContaining({
225 | hostname: 'localhost',
226 | pathname: '/',
227 | referrer: '',
228 | route: '/',
229 | time: expect.any(Number),
230 | timeZone: expect.any(String),
231 | ua: expect.any(String),
232 | unique: expect.any(Boolean),
233 | width: expect.any(Number),
234 | })
235 | );
236 | expect(view).not.toHaveProperty('referrerHostname');
237 | expect(view).not.toHaveProperty('referrerPathname');
238 | expect(view).not.toHaveProperty('urlReferrer');
239 |
240 | // never called because we're using navigator.sendBeacon
241 | expect(fetch).toHaveBeenCalledTimes(0);
242 | });
243 | });
244 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useRouter } from 'next/router';
3 |
4 | export type Analytics = {
5 | /**
6 | * The current hostname of the page
7 | */
8 | hostname: string;
9 | /**
10 | * The current path of the page
11 | */
12 | pathname: string;
13 | /**
14 | * The Next.js route of the page. That is the path of the page in /pages.
15 | */
16 | route: string;
17 | /**
18 | * User Agent
19 | * This is overwritten by the User Agent of the request headers in case they
20 | * are defined.
21 | */
22 | ua?: string;
23 | /**
24 | * The viewport width of the device (not possible via server)
25 | */
26 | width?: number;
27 | /**
28 | * This tells us if this visit is unique.
29 | */
30 | unique?: boolean;
31 | /**
32 | * The time zone of the current user so we can detect the country
33 | */
34 | timeZone?: string;
35 | /**
36 | * The full referrer url of the current page (if any)
37 | */
38 | referrer?: string;
39 | /**
40 | * The referrer hostname of the current page (if any), or the value of ?ref
41 | */
42 | referrerHostname?: string;
43 | /**
44 | * The referrer pathname of the current page (if any).
45 | * Undefined in case ?ref was set.
46 | */
47 | referrerPathname?: string;
48 | /**
49 | * The value of ?ref=... in the URL (more info: https://docs.simpleanalytics.com/how-to-use-url-parameters)
50 | */
51 | urlReferrer?: string;
52 | /**
53 | * The time when the view occurred. Use Date.now() to set it.
54 | */
55 | time: number;
56 | };
57 |
58 | export type AnalyticsConfig = {
59 | /**
60 | * The public key for this project from happykit.dev.
61 | */
62 | publicKey: string;
63 | apiRoute?: string;
64 | /**
65 | * An optional function. Return true to avoid tracking certain page views.
66 | */
67 | skip?: (pageView: Analytics) => boolean;
68 | /**
69 | * An array of hostnames whose page views will not get tracked.
70 | *
71 | * Views from "localhost" are skipped by default. Make sure to readd localhost
72 | * when you provide your own array. You can provide an empty array to track
73 | * views from localhost.
74 | */
75 | skipHostnames?: string[];
76 | /**
77 | * Duration in milliseconds to wait since the last page view before sending
78 | * page views.
79 | */
80 | delay?: number;
81 | };
82 |
83 | type AnalyticsReducerState = {
84 | queue: Analytics[];
85 | hadFirstPageView: boolean;
86 | lastPathname: string;
87 | };
88 |
89 | type AnalyticsReducerAction =
90 | | {
91 | type: 'view';
92 | payload: Analytics;
93 | }
94 | | { type: 'clear'; payload: Analytics[] };
95 |
96 | function analyticsReducer(
97 | state: AnalyticsReducerState,
98 | action: AnalyticsReducerAction
99 | ): AnalyticsReducerState {
100 | if (action.type === 'view') {
101 | const view =
102 | state.hadFirstPageView && action.payload.unique
103 | ? // subsequent page views can never be unique, so we overwrite it
104 | { ...action.payload, unique: false }
105 | : action.payload;
106 |
107 | // Avoid adding duplicate.
108 | // We should only need this in case a React Fast Refresh happened
109 | if (view.pathname === state.lastPathname) return state;
110 |
111 | return {
112 | ...state,
113 | queue: [...state.queue, view],
114 | hadFirstPageView: true,
115 | lastPathname: view.pathname,
116 | };
117 | }
118 |
119 | if (action.type === 'clear') {
120 | return {
121 | ...state,
122 | queue: state.queue.filter(view => !action.payload.includes(view)),
123 | };
124 | }
125 |
126 | return state;
127 | }
128 |
129 | // the url we are passed looks like /projects?x=5
130 | // but we are only interested in /projects
131 | const omitQueryFromPathname = (url: string) => {
132 | const u = new URL(`http://e.de${url}`);
133 | return u.pathname;
134 | };
135 |
136 | function useAnalyticsServer() {}
137 | function useAnalyticsClient({
138 | apiRoute = 'https://happykit.dev/api/pv',
139 | publicKey,
140 | skip,
141 | skipHostnames = ['localhost'],
142 | delay = 5000,
143 | }: AnalyticsConfig) {
144 | if (!publicKey) {
145 | throw new Error('@happykit/analytics: missing options.publicKey');
146 | }
147 | const router = useRouter();
148 | const [state, send] = React.useReducer(analyticsReducer, {
149 | queue: [],
150 | hadFirstPageView: false,
151 | lastPathname: '',
152 | });
153 |
154 | const view = React.useMemo(() => {
155 | const referrer =
156 | document.referrer &&
157 | new URL(document.referrer).hostname === window.location.hostname
158 | ? ''
159 | : document.referrer;
160 |
161 | const searchParams = new URLSearchParams(window.location.search);
162 | const urlReferrer = searchParams.get('ref') || undefined;
163 | return {
164 | hostname: window.location.hostname,
165 | // Actual path (excluding the query) shown in the browser
166 | pathname: omitQueryFromPathname(router.asPath),
167 | // The Next.js route. That is the path of the page in `/pages`.
168 | route: router.pathname,
169 | ua: navigator.userAgent,
170 | width: window.innerWidth,
171 | timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
172 | referrer,
173 | urlReferrer,
174 | // This gets overwritten by the reducer, as subsequent page views can
175 | // never be unique
176 | unique: (() => {
177 | if (document.referrer === '') return true;
178 |
179 | const ref = new URL(document.referrer);
180 | return ref.hostname !== window.location.hostname;
181 | })(),
182 | referrerHostname: urlReferrer
183 | ? urlReferrer
184 | : referrer
185 | ? new URL(referrer).hostname
186 | : undefined,
187 | referrerPathname: urlReferrer
188 | ? undefined
189 | : referrer
190 | ? new URL(referrer).pathname
191 | : undefined,
192 | time: Date.now(),
193 | };
194 | }, [router.pathname, router.asPath]);
195 |
196 | const skipped = // skip ignored hostnames
197 | (Array.isArray(skipHostnames) &&
198 | skipHostnames.some(
199 | hostname => hostname.toLowerCase() === view.hostname.toLowerCase()
200 | )) ||
201 | // skip events as defined by user
202 | (typeof skip === 'function' && skip(view));
203 |
204 | React.useEffect(() => {
205 | if (skipped) return;
206 |
207 | send({ type: 'view', payload: view });
208 | }, [view, skipped]);
209 |
210 | const queue = state.queue;
211 | const sendQueue = React.useCallback(() => {
212 | if (queue.length === 0) return;
213 | const views = [...queue];
214 |
215 | send({ type: 'clear', payload: views });
216 | if (typeof navigator.sendBeacon === 'function') {
217 | navigator.sendBeacon(apiRoute, JSON.stringify({ publicKey, views }));
218 | } else {
219 | const body = JSON.stringify({ publicKey, views });
220 | // Since sendBeacon sends a plain string, we don't set any
221 | // content-type headers on this request either.
222 | //
223 | // That way the server can parse the request body from a string.
224 | fetch(apiRoute, { method: 'POST', keepalive: true, body });
225 | }
226 | }, [queue, apiRoute, publicKey, send]);
227 |
228 | React.useEffect(() => {
229 | if (state.queue.length === 0) return;
230 |
231 | const supportsBeacon = typeof navigator.sendBeacon === 'function';
232 | // sendBeacon works even when a browser window is being closed.
233 | // It's supported in most major browsers. We queue events in case
234 | // we can use sendBeacon, otherwise we send them live.
235 | const adjustedDelay = supportsBeacon ? delay : 0;
236 |
237 | if (!supportsBeacon) {
238 | sendQueue();
239 | return;
240 | }
241 |
242 | const timer = setTimeout(sendQueue, adjustedDelay);
243 |
244 | return () => {
245 | clearTimeout(timer);
246 | };
247 | }, [state, apiRoute, delay, sendQueue]);
248 |
249 | // send anything that hasn't been sent yet
250 | React.useEffect(() => {
251 | window.addEventListener('beforeunload', sendQueue, { once: true });
252 | return () => {
253 | window.removeEventListener('beforeunload', sendQueue);
254 | };
255 | }, [sendQueue]);
256 | }
257 |
258 | // this runs on the client only
259 | export const useAnalytics =
260 | typeof window === 'undefined' ? useAnalyticsServer : useAnalyticsClient;
261 |
--------------------------------------------------------------------------------