├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── npm.yml ├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── demo ├── .env.example ├── .gitignore ├── nodemon.json ├── package.json ├── src │ ├── collections │ │ ├── Categories.ts │ │ ├── Media.ts │ │ ├── Posts.ts │ │ ├── Tags.ts │ │ └── Users.ts │ ├── globals │ │ └── Homepage.ts │ ├── payload.config.ts │ └── server.ts ├── tsconfig.json └── yarn.lock ├── package.json ├── src ├── components │ ├── .gitkeep │ ├── Aggregates │ │ └── AggregateDataWidget.tsx │ ├── Charts │ │ ├── GlobalViewsChart.tsx │ │ └── PageViewsChart.tsx │ ├── Live │ │ └── LiveDataWidget.tsx │ └── Reports │ │ └── TopPages.tsx ├── extendWebpackConfig.ts ├── index.ts ├── mocks │ └── serverModule.js ├── providers │ ├── google │ │ ├── client.ts │ │ ├── getGlobalAggregateData.ts │ │ ├── getGlobalChartData.ts │ │ ├── getLiveData.ts │ │ ├── getPageAggregateData.ts │ │ ├── getPageChartData.ts │ │ ├── getReportData.ts │ │ ├── index.ts │ │ └── utilities.ts │ ├── index.ts │ └── plausible │ │ ├── client.ts │ │ ├── getGlobalAggregateData.ts │ │ ├── getGlobalChartData.ts │ │ ├── getLiveData.ts │ │ ├── getPageAggregateData.ts │ │ ├── getPageChartData.ts │ │ ├── getReportData.ts │ │ ├── index.ts │ │ └── utilities.ts ├── routes │ ├── getGlobalAggregate │ │ ├── handler.ts │ │ └── index.ts │ ├── getGlobalChart │ │ ├── handler.ts │ │ └── index.ts │ ├── getLive │ │ ├── handler.ts │ │ └── index.ts │ ├── getPageAggregate │ │ ├── handler.ts │ │ └── index.ts │ ├── getPageChart │ │ ├── handler.ts │ │ └── index.ts │ └── getReport │ │ ├── handler.ts │ │ └── index.ts ├── types │ ├── data.ts │ ├── index.ts │ ├── providers.ts │ └── widgets.ts └── utilities │ ├── timings.ts │ └── widgetMaps.ts ├── tsconfig.json ├── types.d.ts ├── types.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | max_line_length = null 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/npm.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: "14.x" 15 | registry-url: "https://registry.npmjs.org" 16 | - run: yarn install 17 | - run: yarn build 18 | - run: npm publish --access public 19 | env: 20 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node-terminal", 6 | "name": "Run Script: dev", 7 | "request": "launch", 8 | "command": "cd demo && yarn run dev", 9 | "cwd": "${workspaceFolder}" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 NouanceLabs 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Payload Dashboard Analytics Plugin 2 | 3 | A plugin for [Payload CMS](https://github.com/payloadcms/payload) to connect analytics data to your Payload dashboard. 4 | 5 | Features: 6 | 7 | - Chart widgets on collections, globals and dashboard 8 | - Live users indicator in sidebar 9 | - Aggregate data widgets on collections and globals 10 | - Basic report widgets on dashboard 11 | - Support for Plausible and Google Analytics 12 | 13 | ## Installation 14 | 15 | ```bash 16 | yarn add @nouance/payload-dashboard-analytics 17 | # OR 18 | npm i @nouance/payload-dashboard-analytics 19 | ``` 20 | 21 | ## Basic Usage 22 | 23 | In the `plugins` array of your [Payload config](https://payloadcms.com/docs/configuration/overview), call the plugin with [options](#options): 24 | 25 | ```ts 26 | // ... 27 | payloadDashboardAnalytics({ 28 | provider: { 29 | source: "plausible", 30 | apiSecret: PLAUSIBLE_API_KEY, 31 | siteId: PLAUSIBLE_SITE_ID, 32 | host: PLAUSIBLE_HOST, // optional, for self-hosted instances 33 | }, 34 | cache: true, 35 | access: (user: any) => { 36 | return Boolean(user); 37 | }, 38 | navigation: { 39 | afterNavLinks: [ 40 | { 41 | type: "live", 42 | }, 43 | ], 44 | }, 45 | dashboard: { 46 | beforeDashboard: ["viewsChart"], 47 | afterDashboard: ["topPages"], 48 | }, 49 | globals: [ 50 | { 51 | slug: "homepage", 52 | widgets: [ 53 | { 54 | type: "info", 55 | label: "Page data", 56 | metrics: ["views", "sessions", "sessionDuration"], 57 | timeframe: "currentMonth", 58 | idMatcher: () => `/`, 59 | }, 60 | ], 61 | }, 62 | ], 63 | collections: [ 64 | { 65 | slug: Posts.slug, 66 | widgets: [ 67 | { 68 | type: "chart", 69 | label: "Views and visitors", 70 | metrics: ["views", "visitors", "sessions"], 71 | timeframe: "30d", 72 | idMatcher: (document: any) => `/articles/${document.slug}`, 73 | }, 74 | ], 75 | }, 76 | ], 77 | }), 78 | 79 | // ... 80 | ``` 81 | 82 | ## Configuration 83 | 84 | - `provider` | required 85 | 86 | Configuration for a [supported provider](#providers). 87 | 88 | - `access` | optional 89 | 90 | Accepts a function that takes in the req.user object to determine access to the API routes. **By default the routes are unprotected.** 91 | 92 | Example to allow only authenticated users: 93 | 94 | ```ts 95 | access: (user: any) => Boolean(user); 96 | ``` 97 | 98 | - `cache` | optional 99 | 100 | Accepts a boolean or a configuration object for cache management. **Defaults to false**. 101 | This creates a new collection type that will store cached data so that you don't get limited by the API. 102 | 103 | ```ts 104 | cache: true; 105 | ``` 106 | 107 | - `slug` | optional 108 | 109 | You can customise the slug of this new collection to avoid conflicts. Defaults to `analyticsData`. 110 | 111 | - `routes` | optional 112 | 113 | By default all routes are cached to one day. This is because for most analytics platforms the data report is about one day out anyway. Live data is cached to 5 minutes. 114 | 115 | Object with any of these keys `globalAggregate` `globalChart` `pageAggregate` `pageChart` `report` `live` set to a number in minutes. 116 | 117 | ```ts 118 | routes?: { 119 | globalAggregate: 1440; 120 | globalChart: 1440; 121 | pageAggregate: 1440; 122 | pageChart: 1440; 123 | report: 1440; 124 | live: 5; 125 | }; 126 | ``` 127 | 128 | - `navigation` | optional 129 | 130 | Object of either `beforeNavLinks` `afterNavLinks` which are arrays of [navigation widgets](#navigation). 131 | 132 | - `dashboard` | optional 133 | 134 | Object of either `beforeDashboard` `afterDashboard` which are arrays of [dashboard widgets](#dashboard). 135 | 136 | - `globals` | optional 137 | 138 | Array of global configurations requiring a slug and an array of [page widgets](#page). 139 | 140 | - `collections` | optional 141 | 142 | Array of collection configurations requiring a slug and an array of [page widgets](#page). 143 | 144 | ## Widgets 145 | 146 | A full list of supported widgets. Due to some time limitations or API constraints the selection may be limited. 147 | 148 | ### Navigation 149 | 150 | Navigation widgets have no configuration. 151 | 152 | #### Live visitors 153 | 154 | ![Screenshot from 2023-04-12 18-12-58](https://user-images.githubusercontent.com/35137243/231445032-37f06d87-11cc-47aa-894f-e893e185c3ea.png) 155 | 156 | **type** `live` 157 | 158 | ```ts 159 | { 160 | type: "live"; 161 | } 162 | ``` 163 | 164 | ### Dashboard 165 | 166 | Dashboard widgets have no configuration. 167 | 168 | #### Views chart 169 | 170 | ![Screenshot from 2023-04-12 18-13-28](https://user-images.githubusercontent.com/35137243/231444975-55154b82-5a91-46fd-b31b-64724e7e48c2.png) 171 | 172 | **type** `viewsChart` 173 | 174 | ```ts 175 | ["viewsChart"]; 176 | ``` 177 | 178 | #### Top pages 179 | 180 | ![Screenshot from 2023-04-12 18-13-36](https://user-images.githubusercontent.com/35137243/231444945-b92e3fd0-b60f-4777-9342-97edf9a7ccd9.png) 181 | 182 | **type** `topPages` 183 | 184 | ```ts 185 | ["topPages"]; 186 | ``` 187 | 188 | ### Page 189 | 190 | Page widgets have more configuration available with custom timeframes and metrics. These are usable on both globals and collections. 191 | 192 | #### Page chart 193 | 194 | ![Screenshot from 2023-04-12 18-13-08](https://user-images.githubusercontent.com/35137243/231444858-89c987a5-5e42-419e-935c-70a7feb9a1b4.png) 195 | 196 | - **type** | `chart` | required 197 | 198 | - **label** | string or `hidden` | optional 199 | Sets a custom label for the component in the frontend, defaults to a list of metrics and it's accompanied by the timeframe. 200 | If `hidden` then the label is not displayed. 201 | 202 | - **metrics** | metric[] | required 203 | Array of metrics to fetch data for. See list of [available metrics](#metrics). 204 | 205 | - **timeframe** | timeframe | optional 206 | Defaults to `30d`. See list of [available timeframes](#timeframes). 207 | 208 | - **idMatches** | function | required 209 | A function that takes in the published document from the React hook `useDocument` and returns a string that matches the current page to a page in the analytics data. 210 | 211 | ```ts 212 | { 213 | type: "chart", 214 | label: "Views and visitors", 215 | metrics: ["views", "visitors", "sessions"], 216 | timeframe: "30d", 217 | idMatcher: (document: any) => `/blog/${document.slug}`, 218 | } 219 | ``` 220 | 221 | #### Page info 222 | 223 | ![Screenshot from 2023-04-12 18-13-17](https://user-images.githubusercontent.com/35137243/231444901-ddfbef1d-d433-4f52-a5c9-d0757b07e04d.png) 224 | 225 | - **type** | `info` | required 226 | 227 | - **label** | string or `hidden` | optional 228 | Sets a custom label for the component in the frontend, defaults to a list of metrics and it's accompanied by the timeframe. 229 | If `hidden` then the label is not displayed. 230 | 231 | - **metrics** | metric[] | required 232 | Array of metrics to fetch data for. See list of [available metrics](#metrics). 233 | 234 | - **timeframe** | timeframe | optional 235 | Defaults to `30d`. See list of [available timeframes](#timeframes). 236 | 237 | - **idMatches** | function | required 238 | A function that takes in the published document from the React hook `useDocument` and returns a string that matches the current page to a page in the analytics data. 239 | 240 | ```ts 241 | { 242 | type: "info", 243 | label: "Views and visitors", 244 | metrics: ["views", "visitors", "sessions"], 245 | timeframe: "7d", 246 | idMatcher: (document: any) => `/blog/${document.slug}`, 247 | } 248 | ``` 249 | 250 | ### Timeframes 251 | 252 | Currently supported timeframes. 253 | 254 | - `12mo` 255 | - `6mo` 256 | - `30d` 257 | - `7d` 258 | - `currentMonth` limits the data period to the current month 259 | 260 | ### Metrics 261 | 262 | A full list of currently supported metrics, each provider will map these on their own internally to the API they communicate with. 263 | 264 | - `views` 265 | - `visitors` 266 | - `sessions` 267 | - `sessionDuration` 268 | - `bounceRate` 269 | 270 | ### Properties 271 | 272 | Properties are used to generate reports, currently the widgets are limited in configuration but these should be fully supported via the API routes. 273 | 274 | - `page` 275 | - `source` 276 | - `entryPoint` 277 | - `exitPoint` 278 | - `country` 279 | 280 | ## Providers 281 | 282 | ### Plausible 283 | 284 | We support Plausible's Stats API, more [information on their website](https://plausible.io/docs/stats-api). 285 | 286 | - **source** | "plausible" | required 287 | 288 | - **apiSecret** | string | required 289 | You can generate an API secret in the admin panel of Plausible. 290 | 291 | - **siteId** | string | required 292 | 293 | - **host** | string | optional 294 | 295 | Set this value to the full domain host including protocol if you're self hosting Plausible, eg. `https://plausible.example.com` 296 | 297 | Example 298 | 299 | ```ts 300 | const plausibleProvider: PlausibleProvider = { 301 | source: "plausible", 302 | apiSecret: PLAUSIBLE_API_KEY, 303 | siteId: PLAUSIBLE_SITE_ID, 304 | host: PLAUSIBLE_HOST, 305 | }; 306 | ``` 307 | 308 | ### Google Analytics 309 | 310 | We support the GA4 Analytics API only. You will need to get a credentials file from [Google here](https://developers.google.com/analytics/devguides/reporting/data/v1/quickstart-client-libraries), and follow the initial setup instructions so that these credentials have the correct read access to your analytics data. 311 | 312 | - **source** | "google" | required 313 | 314 | - **propertyId** | string | required 315 | The id of your target property, you can find this information in the GA property settings panel. 316 | 317 | - **credentials** | string | optional 318 | Path to your credentials.json file, make sure the filesystem has access to this, alternatively, set an environment variable named `GOOGLE_APPLICATION_CREDENTIALS` with the path to the credentials file. 319 | 320 | Example 321 | 322 | ```ts 323 | const googleProvider: GoogleProvider = { 324 | source: "google", 325 | credentials: GOOGLE_CREDENTIALS_FILE, 326 | propertyId: GOOGLE_PROPERTY_ID, 327 | }; 328 | ``` 329 | 330 | ## API 331 | 332 | To do, add API documentation. 333 | 334 | ## Known issues 335 | 336 | Multi-metric charts have a floating tooltip in the wrong position, this is a problem upstream. 337 | 338 | ## Development 339 | 340 | For development purposes, there is a full working example of how this plugin might be used in the [demo](./demo) of this repo. Then: 341 | 342 | ```bash 343 | git clone git@github.com:NouanceLabs/payload-dashboard-analytics.git \ 344 | cd payload-dashboard-analytics && yarn \ 345 | cd demo && yarn \ 346 | cp .env.example .env \ 347 | vim .env \ # add your API creds to this file 348 | yarn dev 349 | ``` 350 | 351 | ### Add custom provider 352 | 353 | Tbd 354 | -------------------------------------------------------------------------------- /demo/.env.example: -------------------------------------------------------------------------------- 1 | MONGODB_URI= 2 | PAYLOAD_SECRET= 3 | 4 | PLAUSIBLE_API_KEY= 5 | PLAUSIBLE_SITE_ID= 6 | PLAUSIBLE_HOST= 7 | 8 | GOOGLE_PROPERTY_ID= 9 | GOOGLE_CREDENTIALS_FILE="./ga_credentials.json" 10 | GOOGLE_APPLICATION_CREDENTIALS= 11 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | media 3 | 4 | .env 5 | 6 | ga_credentials.json 7 | -------------------------------------------------------------------------------- /demo/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ext": "ts", 3 | "exec": "ts-node src/server.ts" 4 | } 5 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payload-dashboard-analytics", 3 | "description": "Payload project created from blog template", 4 | "version": "1.0.0", 5 | "main": "dist/server.js", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon", 9 | "build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build", 10 | "build:server": "tsc", 11 | "build": "yarn copyfiles && yarn build:payload && yarn build:server", 12 | "serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js", 13 | "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/", 14 | "generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types", 15 | "generate:graphQLSchema": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema" 16 | }, 17 | "dependencies": { 18 | "dotenv": "^8.2.0", 19 | "express": "^4.17.1", 20 | "payload": "^1.6.15" 21 | }, 22 | "devDependencies": { 23 | "@types/express": "^4.17.9", 24 | "copyfiles": "^2.4.1", 25 | "cross-env": "^7.0.3", 26 | "nodemon": "^2.0.6", 27 | "ts-node": "^9.1.1", 28 | "typescript": "^4.8.4" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /demo/src/collections/Categories.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from 'payload/types'; 2 | 3 | const Categories: CollectionConfig = { 4 | slug: 'categories', 5 | admin: { 6 | useAsTitle: 'name', 7 | }, 8 | access: { 9 | read: () => true, 10 | }, 11 | fields: [ 12 | { 13 | name: 'name', 14 | type: 'text', 15 | }, 16 | ], 17 | timestamps: false, 18 | } 19 | 20 | export default Categories; -------------------------------------------------------------------------------- /demo/src/collections/Media.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import type { CollectionConfig } from 'payload/types'; 3 | 4 | const Media: CollectionConfig = { 5 | slug: 'media', 6 | upload: { 7 | staticDir: path.resolve(__dirname, '../../media'), 8 | // Specify the size name that you'd like to use as admin thumbnail 9 | adminThumbnail: 'thumbnail', 10 | imageSizes: [ 11 | { 12 | height: 400, 13 | width: 400, 14 | crop: 'center', 15 | name: 'thumbnail', 16 | }, 17 | { 18 | width: 900, 19 | height: 450, 20 | crop: 'center', 21 | name: 'sixteenByNineMedium', 22 | }, 23 | ], 24 | }, 25 | fields: [], 26 | }; 27 | 28 | export default Media; 29 | -------------------------------------------------------------------------------- /demo/src/collections/Posts.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from "payload/types"; 2 | 3 | const Posts: CollectionConfig = { 4 | slug: "posts", 5 | admin: { 6 | defaultColumns: ["title", "author", "category", "tags", "status"], 7 | useAsTitle: "title", 8 | }, 9 | access: { 10 | read: () => true, 11 | }, 12 | fields: [ 13 | { 14 | name: "title", 15 | type: "text", 16 | }, 17 | { 18 | name: "slug", 19 | type: "text", 20 | }, 21 | { 22 | name: "author", 23 | type: "relationship", 24 | relationTo: "users", 25 | }, 26 | { 27 | name: "publishedDate", 28 | type: "date", 29 | }, 30 | { 31 | name: "category", 32 | type: "relationship", 33 | relationTo: "categories", 34 | }, 35 | { 36 | name: "featuredImage", 37 | type: "upload", 38 | relationTo: "media", 39 | }, 40 | { 41 | name: "tags", 42 | type: "relationship", 43 | relationTo: "tags", 44 | hasMany: true, 45 | }, 46 | { 47 | name: "views", 48 | type: "number", 49 | defaultValue: 0, 50 | }, 51 | { 52 | name: "content", 53 | type: "richText", 54 | }, 55 | { 56 | name: "status", 57 | type: "select", 58 | options: [ 59 | { 60 | value: "draft", 61 | label: "Draft", 62 | }, 63 | { 64 | value: "published", 65 | label: "Published", 66 | }, 67 | ], 68 | defaultValue: "draft", 69 | admin: { 70 | position: "sidebar", 71 | }, 72 | }, 73 | ], 74 | }; 75 | 76 | export default Posts; 77 | -------------------------------------------------------------------------------- /demo/src/collections/Tags.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from "payload/types"; 2 | 3 | const Tags: CollectionConfig = { 4 | slug: "tags", 5 | admin: { 6 | useAsTitle: "name", 7 | }, 8 | access: { 9 | read: () => true, 10 | }, 11 | fields: [ 12 | { 13 | name: "name", 14 | type: "text", 15 | }, 16 | { 17 | name: "metadata", 18 | type: "json", 19 | }, 20 | ], 21 | timestamps: false, 22 | }; 23 | 24 | export default Tags; 25 | -------------------------------------------------------------------------------- /demo/src/collections/Users.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from "payload/types"; 2 | 3 | const Users: CollectionConfig = { 4 | slug: "users", 5 | auth: { 6 | tokenExpiration: 40000000, 7 | }, 8 | admin: { 9 | useAsTitle: "email", 10 | }, 11 | access: { 12 | read: () => true, 13 | }, 14 | fields: [ 15 | // Email added by default 16 | { 17 | name: "name", 18 | type: "text", 19 | }, 20 | ], 21 | }; 22 | 23 | export default Users; 24 | -------------------------------------------------------------------------------- /demo/src/globals/Homepage.ts: -------------------------------------------------------------------------------- 1 | import { GlobalConfig } from "payload/types"; 2 | 3 | const Homepage: GlobalConfig = { 4 | slug: "homepage", 5 | fields: [ 6 | { 7 | name: "featuredPost", 8 | type: "relationship", 9 | relationTo: "posts", 10 | }, 11 | ], 12 | }; 13 | 14 | export default Homepage; 15 | -------------------------------------------------------------------------------- /demo/src/payload.config.ts: -------------------------------------------------------------------------------- 1 | import { buildConfig } from "payload/config"; 2 | import path from "path"; 3 | import Categories from "./collections/Categories"; 4 | import Posts from "./collections/Posts"; 5 | import Tags from "./collections/Tags"; 6 | import Users from "./collections/Users"; 7 | import Media from "./collections/Media"; 8 | import Homepage from "./globals/Homepage"; 9 | import dashboardAnalytics from "../../src/index"; 10 | 11 | import { PlausibleProvider, GoogleProvider } from "../../src/types/providers"; 12 | 13 | const PLAUSIBLE_API_KEY = process.env.PLAUSIBLE_API_KEY; 14 | const PLAUSIBLE_HOST = process.env.PLAUSIBLE_HOST; 15 | const PLAUSIBLE_SITE_ID = process.env.PLAUSIBLE_SITE_ID; 16 | 17 | const GOOGLE_PROPERTY_ID = process.env.GOOGLE_PROPERTY_ID; 18 | const GOOGLE_CREDENTIALS_FILE = process.env.GOOGLE_CREDENTIALS_FILE; 19 | 20 | const plausibleProvider: PlausibleProvider = { 21 | source: "plausible", 22 | apiSecret: PLAUSIBLE_API_KEY, 23 | siteId: PLAUSIBLE_SITE_ID, 24 | host: PLAUSIBLE_HOST, 25 | }; 26 | 27 | const googleProvider: GoogleProvider = { 28 | source: "google", 29 | //credentials: GOOGLE_CREDENTIALS_FILE, 30 | propertyId: GOOGLE_PROPERTY_ID, 31 | }; 32 | 33 | export default buildConfig({ 34 | serverURL: "http://localhost:3000", 35 | admin: { 36 | user: Users.slug, 37 | // Used for development 38 | webpack: (config) => { 39 | const newConfig = { 40 | ...config, 41 | resolve: { 42 | ...config.resolve, 43 | alias: { 44 | ...config.resolve.alias, 45 | react: path.join(__dirname, "../node_modules/react"), 46 | "react-dom": path.join(__dirname, "../node_modules/react-dom"), 47 | payload: path.join(__dirname, "../node_modules/payload"), 48 | }, 49 | }, 50 | }; 51 | 52 | return newConfig; 53 | }, 54 | }, 55 | collections: [Categories, Posts, Tags, Users, Media], 56 | globals: [Homepage], 57 | typescript: { 58 | outputFile: path.resolve(__dirname, "payload-types.ts"), 59 | }, 60 | graphQL: { 61 | schemaOutputFile: path.resolve(__dirname, "generated-schema.graphql"), 62 | }, 63 | plugins: [ 64 | dashboardAnalytics({ 65 | provider: plausibleProvider, 66 | access: (user: any) => { 67 | return Boolean(user); 68 | }, 69 | cache: { 70 | slug: "dashboardAnalytics", 71 | }, 72 | navigation: { 73 | afterNavLinks: [ 74 | { 75 | type: "live", 76 | }, 77 | ], 78 | }, 79 | dashboard: { 80 | beforeDashboard: ["viewsChart"], 81 | afterDashboard: ["topPages"], 82 | }, 83 | globals: [ 84 | { 85 | slug: "homepage", 86 | widgets: [ 87 | { 88 | type: "chart", 89 | label: "Views and visitors", 90 | metrics: ["views", "visitors", "sessions"], 91 | timeframe: "30d", 92 | idMatcher: () => `/`, 93 | }, 94 | { 95 | type: "info", 96 | label: "Page data", 97 | metrics: ["views", "sessions", "sessionDuration"], 98 | timeframe: "currentMonth", 99 | idMatcher: () => `/`, 100 | }, 101 | ], 102 | }, 103 | ], 104 | collections: [ 105 | { 106 | slug: Posts.slug, 107 | widgets: [ 108 | { 109 | type: "chart", 110 | label: "Views and visitors", 111 | metrics: ["views", "visitors", "sessions"], 112 | timeframe: "30d", 113 | idMatcher: (document: any) => `/articles/${document.slug}`, 114 | }, 115 | { 116 | type: "info", 117 | label: "Page data", 118 | metrics: ["views", "sessions", "sessionDuration"], 119 | timeframe: "currentMonth", 120 | idMatcher: (document: any) => `/articles/${document.slug}`, 121 | }, 122 | ], 123 | }, 124 | ], 125 | }), 126 | ], 127 | }); 128 | -------------------------------------------------------------------------------- /demo/src/server.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import payload from "payload"; 3 | 4 | require("dotenv").config(); 5 | const app = express(); 6 | 7 | // Redirect root to Admin panel 8 | app.get("/", (_, res) => { 9 | res.redirect("/admin"); 10 | }); 11 | 12 | const start = async () => { 13 | // Initialize Payload 14 | await payload.init({ 15 | secret: process.env.PAYLOAD_SECRET, 16 | mongoURL: process.env.MONGODB_URI, 17 | express: app, 18 | onInit: async () => { 19 | payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`); 20 | }, 21 | }); 22 | 23 | // Add your own express routes here 24 | 25 | app.listen(3000); 26 | }; 27 | 28 | start(); 29 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "strict": false, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "outDir": "./dist", 10 | "rootDir": "../", 11 | "jsx": "react", 12 | "paths": { 13 | "payload/generated-types": ["./src/payload-types.ts"] 14 | } 15 | }, 16 | "include": ["src"], 17 | "exclude": ["node_modules", "dist", "build"], 18 | "ts-node": { 19 | "transpileOnly": true, 20 | "swc": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nouance/payload-dashboard-analytics", 3 | "version": "0.3.0", 4 | "homepage:": "https://nouance.io", 5 | "repository": "git@github.com:NouanceLabs/payload-dashboard-analytics.git", 6 | "description": "Dashboard analytics integration plugin for Payload CMS", 7 | "main": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "scripts": { 10 | "build": "tsc", 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "keywords": [ 14 | "payload", 15 | "analytics", 16 | "cms", 17 | "plugin", 18 | "typescript", 19 | "plausible", 20 | "dashboard", 21 | "google analytics" 22 | ], 23 | "author": "dev@nouance.io", 24 | "license": "MIT", 25 | "peerDependencies": { 26 | "payload": "^1.6.16", 27 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 28 | }, 29 | "devDependencies": { 30 | "@types/react-router-dom": "^5.3.3", 31 | "payload": "^1.6.26", 32 | "react": "^18", 33 | "typescript": "^5" 34 | }, 35 | "resolutions": { 36 | "d3-color": "^3.1.0" 37 | }, 38 | "files": [ 39 | "dist", 40 | "types.js", 41 | "types.d.ts" 42 | ], 43 | "dependencies": { 44 | "@google-analytics/data": "^3.2.2", 45 | "react-charts": "^3.0.0-beta.54" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NouanceLabs/payload-dashboard-analytics/c0bd6aa5014dc3c0379ba9f8b4098036be79c0d3/src/components/.gitkeep -------------------------------------------------------------------------------- /src/components/Aggregates/AggregateDataWidget.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useEffect, 3 | useState, 4 | lazy, 5 | useReducer, 6 | useRef, 7 | useMemo, 8 | } from "react"; 9 | import type { AggregateData, MetricsMap } from "../../types/data"; 10 | import type { PageInfoWidget } from "../../types/widgets"; 11 | import { useDocumentInfo } from "payload/components/utilities"; 12 | import { useTheme } from "payload/dist/admin/components/utilities/Theme"; 13 | 14 | type Props = { 15 | options: PageInfoWidget; 16 | metricsMap: MetricsMap; 17 | }; 18 | 19 | const AggregateDataWidget: React.FC = ({ options, metricsMap }) => { 20 | const [data, setData] = useState([]); 21 | const [isLoading, setIsLoading] = useState(true); 22 | const theme = useTheme(); 23 | const { publishedDoc } = useDocumentInfo(); 24 | 25 | const { timeframe, metrics, idMatcher, label } = options; 26 | 27 | const pageId = useMemo(() => { 28 | if (publishedDoc) return idMatcher(publishedDoc); 29 | else return ""; 30 | }, [publishedDoc]); 31 | 32 | const timeframeIndicator = 33 | timeframe === "currentMonth" 34 | ? new Date().toLocaleString("default", { month: "long" }) 35 | : timeframe ?? "30d"; 36 | 37 | useEffect(() => { 38 | if (pageId) { 39 | const getAggregateData = fetch(`/api/analytics/pageAggregate`, { 40 | method: "post", 41 | credentials: "include", 42 | headers: { 43 | Accept: "application/json", 44 | "Content-Type": "application/json", 45 | }, 46 | body: JSON.stringify({ 47 | timeframe: timeframe, 48 | metrics: metrics, 49 | pageId: pageId, 50 | }), 51 | }).then((response) => response.json()); 52 | 53 | getAggregateData.then((data: AggregateData) => { 54 | setData(data); 55 | setIsLoading(false); 56 | }); 57 | } else { 58 | setIsLoading(false); 59 | } 60 | }, [publishedDoc, pageId]); 61 | 62 | const heading = useMemo(() => { 63 | if (label) return label; 64 | 65 | const metricValues: string[] = []; 66 | 67 | Object.entries(metricsMap).forEach(([key, value]) => { 68 | /* @ts-ignore */ 69 | if (metrics.includes(key)) metricValues.push(value.label); 70 | }); 71 | 72 | return metricValues.join(", "); 73 | }, [options, metricsMap]); 74 | 75 | return ( 76 |
84 | {label !== "hidden" && ( 85 |

86 | {heading} ({timeframeIndicator}) 87 |

88 | )} 89 |
90 | {isLoading ? ( 91 | <>Loading... 92 | ) : data.length > 0 ? ( 93 |
    94 | {data.map((item, index) => { 95 | const value = 96 | typeof item.value === "string" 97 | ? Math.floor(parseInt(item.value)) 98 | : Math.floor(item.value); 99 | 100 | const itemLabel = item.label; 101 | 102 | const label = metricsMap?.[itemLabel]?.label ?? itemLabel; 103 | 104 | return ( 105 |
  • 109 |
    {label}
    110 |
    {value}
    111 |
  • 112 | ); 113 | })} 114 |
115 | ) : ( 116 |
No data found.
117 | )} 118 |
119 |
120 | ); 121 | }; 122 | 123 | export const getAggregateDataWidget = ( 124 | metricsMap: MetricsMap, 125 | props?: any, 126 | options?: PageInfoWidget 127 | ) => { 128 | const combinedProps: Props = { 129 | ...props, 130 | options, 131 | metricsMap, 132 | }; 133 | return ; 134 | }; 135 | 136 | export default { AggregateDataWidget, getAggregateDataWidget }; 137 | -------------------------------------------------------------------------------- /src/components/Charts/GlobalViewsChart.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, lazy, useMemo } from "react"; 2 | import type { ChartDataPoint, ChartData } from "../../types/data"; 3 | import type { AxisOptions } from "react-charts"; 4 | import { useTheme } from "payload/dist/admin/components/utilities/Theme"; 5 | 6 | type Props = {}; 7 | 8 | const ChartComponent = lazy(() => 9 | import("react-charts").then((module) => { 10 | return { default: module.Chart }; 11 | }) 12 | ); 13 | 14 | const GlobalViewsChart: React.FC = ({}) => { 15 | const [chartData, setChartData] = useState([]); 16 | const [isLoading, setIsLoading] = useState(true); 17 | const theme = useTheme(); 18 | 19 | useEffect(() => { 20 | const getChartData = fetch(`/api/analytics/globalChart`, { 21 | method: "post", 22 | credentials: "include", 23 | headers: { 24 | Accept: "application/json", 25 | "Content-Type": "application/json", 26 | }, 27 | body: JSON.stringify({ 28 | timeframe: "currentMonth", 29 | metrics: ["views"], 30 | }), 31 | }).then((response) => response.json()); 32 | 33 | getChartData.then((data: ChartData) => { 34 | setChartData(data); 35 | setIsLoading(false); 36 | }); 37 | }, []); 38 | 39 | const timeframeIndicator = useMemo(() => { 40 | return new Date().toLocaleString("default", { month: "long" }); 41 | }, []); 42 | 43 | const chartLabel = useMemo(() => { 44 | return "Views"; 45 | }, []); 46 | 47 | const primaryAxis = React.useMemo>(() => { 48 | return { 49 | getValue: (datum) => datum.timestamp, 50 | show: false, 51 | elementType: "line", 52 | showDatumElements: false, 53 | }; 54 | }, []); 55 | 56 | const secondaryAxes = React.useMemo[]>( 57 | () => [ 58 | { 59 | getValue: (datum) => { 60 | return datum.value; 61 | }, 62 | elementType: "line", 63 | }, 64 | ], 65 | [] 66 | ); 67 | 68 | return ( 69 |
75 | {chartData?.length && chartData.length > 0 ? ( 76 | <> 77 |

78 | {chartLabel} ({timeframeIndicator}) 79 |

80 |
81 | 93 |
94 | 95 | ) : isLoading ? ( 96 | <> Loading... 97 | ) : ( 98 |
No data found for {chartLabel}.
99 | )} 100 |
101 | ); 102 | }; 103 | 104 | export default GlobalViewsChart; 105 | -------------------------------------------------------------------------------- /src/components/Charts/PageViewsChart.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useEffect, 3 | useState, 4 | lazy, 5 | useReducer, 6 | useRef, 7 | useMemo, 8 | } from "react"; 9 | import type { ChartDataPoint, ChartData, MetricsMap } from "../../types/data"; 10 | import type { PageChartWidget } from "../../types/widgets"; 11 | import type { AxisOptions } from "react-charts"; 12 | import { useDocumentInfo } from "payload/components/utilities"; 13 | import { useTheme } from "payload/dist/admin/components/utilities/Theme"; 14 | 15 | type Props = { 16 | options: PageChartWidget; 17 | metricsMap: MetricsMap; 18 | }; 19 | 20 | const ChartComponent = lazy(() => 21 | import("react-charts").then((module) => { 22 | return { default: module.Chart }; 23 | }) 24 | ); 25 | 26 | const PageViewsChart: React.FC = ({ options, metricsMap }) => { 27 | const [chartData, setChartData] = useState([]); 28 | const [isLoading, setIsLoading] = useState(true); 29 | const theme = useTheme(); 30 | const { publishedDoc } = useDocumentInfo(); 31 | 32 | const { timeframe, metrics, idMatcher, label } = options; 33 | 34 | const pageId = useMemo(() => { 35 | if (publishedDoc) return idMatcher(publishedDoc); 36 | else return ""; 37 | }, [publishedDoc]); 38 | 39 | const timeframeIndicator = useMemo(() => { 40 | return timeframe === "currentMonth" 41 | ? new Date().toLocaleString("default", { month: "long" }) 42 | : timeframe ?? "30d"; 43 | }, [timeframe]); 44 | 45 | useEffect(() => { 46 | if (pageId) { 47 | const getChartData = fetch(`/api/analytics/pageChart`, { 48 | method: "post", 49 | credentials: "include", 50 | headers: { 51 | Accept: "application/json", 52 | "Content-Type": "application/json", 53 | }, 54 | body: JSON.stringify({ 55 | timeframe: timeframe, 56 | metrics: metrics, 57 | pageId: pageId, 58 | }), 59 | }).then((response) => response.json()); 60 | 61 | getChartData.then((data: ChartData) => { 62 | setChartData(data); 63 | setIsLoading(false); 64 | }); 65 | } else { 66 | setIsLoading(false); 67 | } 68 | }, [publishedDoc, pageId]); 69 | 70 | const chartLabel = useMemo(() => { 71 | if (!!label) return label; 72 | 73 | if (metrics) { 74 | const metricValues: string[] = []; 75 | 76 | Object.entries(metricsMap).forEach(([key, value]) => { 77 | // @ts-ignore 78 | if (metrics.includes(key)) metricValues.push(value.label); 79 | }); 80 | 81 | return metricValues.join(", "); 82 | } else { 83 | return "No metrics defined for this widget"; 84 | } 85 | }, [label, metrics, metricsMap]); 86 | 87 | const primaryAxis = React.useMemo>(() => { 88 | return { 89 | getValue: (datum) => datum.timestamp, 90 | show: false, 91 | elementType: "line", 92 | showDatumElements: false, 93 | }; 94 | }, []); 95 | 96 | const secondaryAxes = React.useMemo[]>( 97 | () => [ 98 | { 99 | getValue: (datum) => { 100 | return Math.floor(datum.value); 101 | }, 102 | elementType: "line", 103 | shouldNice: true, 104 | }, 105 | ], 106 | [] 107 | ); 108 | 109 | return ( 110 |
115 | {pageId !== "" && chartData?.length && chartData.length > 0 ? ( 116 | <> 117 |

118 | {chartLabel} ({timeframeIndicator}) 119 |

120 |
121 | 1, 127 | /* @ts-ignore */ 128 | primaryAxis, 129 | /* @ts-ignore */ 130 | secondaryAxes, 131 | }} 132 | /> 133 |
134 | 135 | ) : isLoading ? ( 136 | <> Loading... 137 | ) : ( 138 |
No data found for {chartLabel}.
139 | )} 140 |
141 | ); 142 | }; 143 | 144 | export const getPageViewsChart = ( 145 | metricsMap: MetricsMap, 146 | props?: any, 147 | options?: PageChartWidget 148 | ) => { 149 | const combinedProps: Props = { 150 | ...props, 151 | options, 152 | metricsMap, 153 | }; 154 | return ; 155 | }; 156 | 157 | export default { PageViewsChart, getPageViewsChart }; 158 | -------------------------------------------------------------------------------- /src/components/Live/LiveDataWidget.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useEffect, 3 | useState, 4 | lazy, 5 | useReducer, 6 | useRef, 7 | useMemo, 8 | } from "react"; 9 | import type { LiveData } from "../../types/data"; 10 | import type { LiveWidget } from "../../types/widgets"; 11 | import { useTheme } from "payload/dist/admin/components/utilities/Theme"; 12 | 13 | type Props = {}; 14 | 15 | const LiveDataWidget: React.FC = ({}) => { 16 | const [data, setData] = useState(); 17 | const [isLoading, setIsLoading] = useState(true); 18 | const theme = useTheme(); 19 | 20 | /* const { label } = options; */ 21 | 22 | useEffect(() => { 23 | const getLiveData = fetch(`/api/analytics/live`, { 24 | method: "post", 25 | credentials: "include", 26 | headers: { 27 | Accept: "application/json", 28 | "Content-Type": "application/json", 29 | }, 30 | body: JSON.stringify({}), 31 | }).then((response) => response.json()); 32 | 33 | getLiveData.then((data: LiveData) => { 34 | setData(data); 35 | setIsLoading(false); 36 | }); 37 | }, []); 38 | 39 | const heading = useMemo(() => { 40 | /* if (label) return label; */ 41 | 42 | return "hidden"; 43 | }, []); 44 | 45 | return ( 46 |
55 | {heading !== "hidden" && ( 56 |

57 | {heading} 58 |

59 | )} 60 |
61 | {isLoading ? ( 62 | <>Loading... 63 | ) : ( 64 |
    65 |
  • 66 |
    Live visitors
    67 |
    {data?.visitors}
    68 |
  • 69 |
70 | )} 71 |
72 |
73 | ); 74 | }; 75 | 76 | export const getLiveDataWidget = (props?: any, options?: LiveWidget) => { 77 | const combinedProps: Props = { 78 | ...props, 79 | options, 80 | }; 81 | return ; 82 | }; 83 | 84 | export default { LiveDataWidget, getLiveDataWidget }; 85 | -------------------------------------------------------------------------------- /src/components/Reports/TopPages.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useEffect, 3 | useState, 4 | lazy, 5 | useReducer, 6 | useRef, 7 | useMemo, 8 | } from "react"; 9 | 10 | import type { ReportData } from "../../types/data"; 11 | interface Props {} 12 | 13 | const TopPages: React.FC = () => { 14 | const [data, setData] = useState(); 15 | const [isLoading, setIsLoading] = useState(true); 16 | 17 | /* const { label } = options; */ 18 | 19 | useEffect(() => { 20 | const getLiveData = fetch(`/api/analytics/report`, { 21 | method: "post", 22 | credentials: "include", 23 | headers: { 24 | Accept: "application/json", 25 | "Content-Type": "application/json", 26 | }, 27 | body: JSON.stringify({ 28 | metrics: ["views"], 29 | property: "page", 30 | }), 31 | }).then((response) => response.json()); 32 | 33 | getLiveData.then((data: ReportData) => { 34 | setData(data); 35 | setIsLoading(false); 36 | }); 37 | }, []); 38 | 39 | return ( 40 |
50 |

51 | {"Top pages"} 52 |

53 |
54 | {isLoading ? ( 55 | <>Loading... 56 | ) : ( 57 |
    58 | {data?.map((item, itemIndex) => { 59 | const property = Object.keys(item)[0]; 60 | 61 | return ( 62 |
  • 71 |
    {item[property]}
    72 | {item.values.map((value, valueIndex) => { 73 | const valueKey = Object.keys(value)[0]; 74 | 75 | return
    {value[valueKey]}
    ; 76 | })} 77 |
  • 78 | ); 79 | })} 80 |
81 | )} 82 |
83 |
84 | ); 85 | }; 86 | 87 | export default TopPages; 88 | -------------------------------------------------------------------------------- /src/extendWebpackConfig.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "payload/config"; 2 | import path from "path"; 3 | import type { Configuration as WebpackConfig } from "webpack"; 4 | import type { Provider } from "./types"; 5 | 6 | const mockModulePath = path.resolve(__dirname, "mocks/serverModule.js"); 7 | 8 | /* Some providers may need their own list of mocked modules to avoid react errors and bundling of node modules */ 9 | const aliasMap /* : Record */ = { 10 | [path.resolve(__dirname, "./providers/google/client")]: mockModulePath, 11 | [path.resolve(__dirname, "./providers/google/getLiveData")]: mockModulePath, 12 | "@google-analytics/data": mockModulePath, 13 | url: mockModulePath, 14 | querystring: mockModulePath, 15 | fs: mockModulePath, 16 | os: mockModulePath, 17 | stream: mockModulePath, 18 | child_process: mockModulePath, 19 | util: mockModulePath, 20 | net: mockModulePath, 21 | tls: mockModulePath, 22 | assert: mockModulePath, 23 | request: mockModulePath, 24 | [path.resolve(__dirname, "./providers/plausible/client")]: mockModulePath, 25 | }; 26 | 27 | export const extendWebpackConfig = 28 | ( 29 | config: Config, 30 | provider: Provider["source"] 31 | ): ((webpackConfig: WebpackConfig) => WebpackConfig) => 32 | (webpackConfig) => { 33 | const existingWebpackConfig = 34 | typeof config.admin?.webpack === "function" 35 | ? config.admin.webpack(webpackConfig) 36 | : webpackConfig; 37 | 38 | return { 39 | ...existingWebpackConfig, 40 | resolve: { 41 | ...(existingWebpackConfig.resolve || {}), 42 | alias: { 43 | ...(existingWebpackConfig.resolve?.alias 44 | ? existingWebpackConfig.resolve.alias 45 | : {}), 46 | express: mockModulePath, 47 | ...aliasMap, 48 | }, 49 | }, 50 | }; 51 | }; 52 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { Config as PayloadConfig } from "payload/config"; 2 | import type { DashboardAnalyticsConfig } from "./types"; 3 | 4 | import { extendWebpackConfig } from "./extendWebpackConfig"; 5 | import getProvider from "./providers"; 6 | 7 | import getGlobalAggregate from "./routes/getGlobalAggregate"; 8 | import getGlobalChart from "./routes/getGlobalChart"; 9 | import getPageChart from "./routes/getPageChart"; 10 | import getPageAggregate from "./routes/getPageAggregate"; 11 | import getLive from "./routes/getLive"; 12 | import getReport from "./routes/getReport"; 13 | 14 | import type { CollectionConfig } from "payload/dist/collections/config/types"; 15 | import type { GlobalConfig } from "payload/dist/globals/config/types"; 16 | 17 | import { 18 | PageWidgetMap, 19 | NavigationWidgetMap, 20 | DashboardWidgetMap, 21 | } from "./utilities/widgetMaps"; 22 | 23 | const dashboardAnalytics = 24 | (incomingConfig: DashboardAnalyticsConfig) => 25 | (config: PayloadConfig): PayloadConfig => { 26 | const { admin, collections, globals } = config; 27 | const { provider, navigation, dashboard, access, cache } = incomingConfig; 28 | const endpoints = config.endpoints ?? []; 29 | const apiProvider = getProvider(provider); 30 | 31 | const cacheSlug = 32 | typeof cache === "object" 33 | ? cache?.slug ?? "analyticsData" 34 | : "analyticsData"; 35 | 36 | const cacheCollection: CollectionConfig = { 37 | slug: cacheSlug, 38 | admin: { 39 | defaultColumns: ["id", "cacheTimestamp", "cacheKey"], 40 | }, 41 | access: { 42 | read: () => false, 43 | update: () => false, 44 | create: () => false, 45 | delete: () => false, 46 | }, 47 | fields: [ 48 | { 49 | type: "text", 50 | name: "cacheKey", 51 | }, 52 | { 53 | type: "text", 54 | name: "cacheTimestamp", 55 | }, 56 | { 57 | type: "json", 58 | name: "data", 59 | }, 60 | ], 61 | }; 62 | 63 | const routeOptions = { 64 | access: access, 65 | cache: { 66 | ...(typeof cache === "object" ? cache : {}), 67 | slug: cacheSlug, 68 | }, 69 | }; 70 | 71 | const processedConfig: PayloadConfig = { 72 | ...config, 73 | admin: { 74 | ...admin, 75 | components: { 76 | ...admin?.components, 77 | ...(navigation?.beforeNavLinks && { 78 | beforeNavLinks: [ 79 | ...(admin?.components?.beforeNavLinks ?? []), 80 | ...navigation.beforeNavLinks.map( 81 | (widget) => NavigationWidgetMap[widget.type] 82 | ), 83 | ], 84 | }), 85 | ...(navigation?.afterNavLinks && { 86 | afterNavLinks: [ 87 | ...(admin?.components?.afterNavLinks ?? []), 88 | ...navigation.afterNavLinks.map( 89 | (widget) => NavigationWidgetMap[widget.type] 90 | ), 91 | ], 92 | }), 93 | ...(dashboard?.beforeDashboard && { 94 | beforeDashboard: [ 95 | ...(admin?.components?.beforeDashboard ?? []), 96 | ...dashboard.beforeDashboard.map( 97 | (widget) => DashboardWidgetMap[widget] 98 | ), 99 | ], 100 | }), 101 | ...(dashboard?.afterDashboard && { 102 | afterDashboard: [ 103 | ...(admin?.components?.afterDashboard ?? []), 104 | ...dashboard.afterDashboard.map( 105 | (widget) => DashboardWidgetMap[widget] 106 | ), 107 | ], 108 | }), 109 | }, 110 | webpack: extendWebpackConfig(config, provider.source), 111 | }, 112 | endpoints: [ 113 | ...endpoints, 114 | getGlobalAggregate(apiProvider, routeOptions), 115 | getGlobalChart(apiProvider, routeOptions), 116 | getPageChart(apiProvider, routeOptions), 117 | getPageAggregate(apiProvider, routeOptions), 118 | getLive(apiProvider, routeOptions), 119 | getReport(apiProvider, routeOptions), 120 | ], 121 | 122 | collections: [ 123 | ...(collections 124 | ? collections.map((collection) => { 125 | const targetCollection = incomingConfig.collections?.find( 126 | (pluginCollection) => { 127 | if (pluginCollection.slug === collection.slug) return true; 128 | return false; 129 | } 130 | ); 131 | 132 | if (targetCollection) { 133 | const collectionConfigWithHooks: CollectionConfig = { 134 | ...collection, 135 | fields: [ 136 | ...collection.fields, 137 | ...targetCollection.widgets.map((widget, index) => { 138 | const field = PageWidgetMap[widget.type]; 139 | 140 | return field(widget, index, apiProvider.metricsMap); 141 | }), 142 | ], 143 | }; 144 | 145 | return collectionConfigWithHooks; 146 | } 147 | 148 | return collection; 149 | }) 150 | : []), 151 | ...(cache ? [cacheCollection] : []), 152 | ], 153 | ...(globals && { 154 | globals: globals.map((global) => { 155 | const targetGlobal = incomingConfig.globals?.find((pluginGlobal) => { 156 | if (pluginGlobal.slug === global.slug) return true; 157 | return false; 158 | }); 159 | 160 | if (targetGlobal) { 161 | const globalConfigWithHooks: GlobalConfig = { 162 | ...global, 163 | fields: [ 164 | ...global.fields, 165 | ...targetGlobal.widgets.map((widget, index) => { 166 | const field = PageWidgetMap[widget.type]; 167 | 168 | return field(widget, index, apiProvider.metricsMap); 169 | }), 170 | ], 171 | }; 172 | 173 | return globalConfigWithHooks; 174 | } 175 | 176 | return global; 177 | }), 178 | }), 179 | }; 180 | 181 | return processedConfig; 182 | }; 183 | 184 | export default dashboardAnalytics; 185 | -------------------------------------------------------------------------------- /src/mocks/serverModule.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /src/providers/google/client.ts: -------------------------------------------------------------------------------- 1 | import type { GoogleProvider } from "../../types/providers"; 2 | import type { Metrics, Properties } from "../../types/widgets"; 3 | import { BetaAnalyticsDataClient } from "@google-analytics/data"; 4 | import { MetricMap, PropertyMap } from "./utilities"; 5 | 6 | type ClientOptions = {}; 7 | 8 | function client(provider: GoogleProvider, options?: ClientOptions) { 9 | const analyticsDataClient = new BetaAnalyticsDataClient({ 10 | ...(provider.credentials ? { keyFilename: provider.credentials } : {}), 11 | }); 12 | 13 | return { 14 | run: analyticsDataClient, 15 | }; 16 | } 17 | 18 | export default client; 19 | -------------------------------------------------------------------------------- /src/providers/google/getGlobalAggregateData.ts: -------------------------------------------------------------------------------- 1 | import type { GoogleProvider } from "../../types/providers"; 2 | import type { GlobalAggregateOptions } from ".."; 3 | import type { AggregateData } from "../../types/data"; 4 | import { getMetrics, getDateRange } from "./utilities"; 5 | import client from "./client"; 6 | import type { protos } from "@google-analytics/data"; 7 | import type { Timeframes } from "../../types/widgets"; 8 | 9 | async function getGlobalAggregateData( 10 | provider: GoogleProvider, 11 | options: GlobalAggregateOptions 12 | ) { 13 | const googleClient = client(provider); 14 | 15 | const { metrics } = options; 16 | const timeframe: Timeframes = (options.timeframe as Timeframes) ?? "30d"; 17 | 18 | const usedMetrics = getMetrics(metrics); 19 | 20 | const dateRange = getDateRange(timeframe); 21 | 22 | const request: protos.google.analytics.data.v1beta.IRunReportRequest = { 23 | property: `properties/${provider.propertyId}`, 24 | dateRanges: [dateRange.formatted], 25 | dimensions: [], 26 | metrics: usedMetrics.map((metric) => { 27 | return { 28 | name: metric, 29 | }; 30 | }), 31 | keepEmptyRows: false, 32 | metricAggregations: [1], 33 | }; 34 | 35 | const data = await googleClient.run.runReport(request).then((data) => data); 36 | 37 | const processedData: AggregateData = usedMetrics.map((metric, index) => { 38 | return { 39 | label: metrics[index], 40 | value: data[0].totals?.[0].metricValues?.[index].value ?? 0, 41 | }; 42 | }); 43 | 44 | return processedData; 45 | } 46 | 47 | export default getGlobalAggregateData; 48 | -------------------------------------------------------------------------------- /src/providers/google/getGlobalChartData.ts: -------------------------------------------------------------------------------- 1 | import type { GoogleProvider } from "../../types/providers"; 2 | import type { GlobalChartOptions } from ".."; 3 | import type { ChartData } from "../../types/data"; 4 | import { 5 | getMetrics, 6 | getDateRange, 7 | DateFormat, 8 | GoogleDateFormat, 9 | } from "./utilities"; 10 | import client from "./client"; 11 | import type { protos } from "@google-analytics/data"; 12 | import type { Timeframes } from "../../types/widgets"; 13 | import { eachDayOfInterval, parse, isEqual, format } from "date-fns"; 14 | 15 | async function getGlobalChartData( 16 | provider: GoogleProvider, 17 | options: GlobalChartOptions 18 | ) { 19 | const googleClient = client(provider); 20 | 21 | const { metrics } = options; 22 | const timeframe: Timeframes = (options.timeframe as Timeframes) ?? "30d"; 23 | 24 | const usedMetrics = getMetrics(metrics); 25 | 26 | const dateRange = getDateRange(timeframe); 27 | 28 | const dates = eachDayOfInterval({ 29 | start: dateRange.dates.startDate, 30 | end: dateRange.dates.endDate, 31 | }); 32 | 33 | const request: protos.google.analytics.data.v1beta.IRunReportRequest = { 34 | property: `properties/${provider.propertyId}`, 35 | dateRanges: [dateRange.formatted], 36 | dimensions: [{ name: "date" }], 37 | metrics: usedMetrics.map((metric) => { 38 | return { 39 | name: metric, 40 | }; 41 | }), 42 | keepEmptyRows: false, 43 | metricAggregations: [], 44 | }; 45 | 46 | const data = await googleClient.run.runReport(request).then((data) => data); 47 | 48 | const processedData: ChartData = usedMetrics.map((metric, index) => { 49 | return { 50 | label: metrics[index], 51 | data: dates.map((date, dateIndex) => { 52 | const matchingRow = data[0].rows?.find((row) => { 53 | if (row.dimensionValues?.[0].value) { 54 | const parsedDate = parse( 55 | row.dimensionValues[0].value, 56 | GoogleDateFormat, 57 | new Date() 58 | ); 59 | 60 | return isEqual(date, parsedDate); 61 | } 62 | 63 | return false; 64 | }); 65 | 66 | if (matchingRow) { 67 | const value = matchingRow.metricValues?.[index]?.value; 68 | 69 | return { 70 | timestamp: format(date, DateFormat), 71 | value: value ? parseInt(value) : 0, 72 | }; 73 | } else { 74 | return { 75 | timestamp: format(date, DateFormat), 76 | value: 0, 77 | }; 78 | } 79 | }), 80 | }; 81 | }); 82 | 83 | return processedData; 84 | } 85 | 86 | export default getGlobalChartData; 87 | -------------------------------------------------------------------------------- /src/providers/google/getLiveData.ts: -------------------------------------------------------------------------------- 1 | import type { GoogleProvider } from "../../types/providers"; 2 | import type { LiveDataOptions } from ".."; 3 | import type { LiveData } from "../../types/data"; 4 | import client from "./client"; 5 | 6 | async function getLiveData(provider: GoogleProvider, options: LiveDataOptions) { 7 | const googleClient = client(provider); 8 | 9 | const request = { 10 | property: `properties/${provider.propertyId}`, 11 | dateRanges: [ 12 | { 13 | startDate: "2023-03-05", 14 | endDate: "today", 15 | }, 16 | ], 17 | /* dimensions: [{ name: "country" }], */ 18 | metrics: [{ name: "activeUsers" }], 19 | }; 20 | 21 | const data = await googleClient.run 22 | .runRealtimeReport(request) 23 | .then((data) => data[0].rows?.[0]?.metricValues?.[0]?.value ?? null); 24 | 25 | const processedData: LiveData = { 26 | visitors: data ? parseInt(data) : 0, 27 | }; 28 | 29 | return processedData; 30 | } 31 | 32 | export default getLiveData; 33 | -------------------------------------------------------------------------------- /src/providers/google/getPageAggregateData.ts: -------------------------------------------------------------------------------- 1 | import type { GoogleProvider } from "../../types/providers"; 2 | import type { PageAggregateOptions } from ".."; 3 | import type { AggregateData } from "../../types/data"; 4 | import { getMetrics, getDateRange } from "./utilities"; 5 | import client from "./client"; 6 | import type { protos } from "@google-analytics/data"; 7 | import type { Timeframes } from "../../types/widgets"; 8 | 9 | async function getPageAggregateData( 10 | provider: GoogleProvider, 11 | options: PageAggregateOptions 12 | ) { 13 | const googleClient = client(provider); 14 | 15 | const { metrics, pageId } = options; 16 | const timeframe: Timeframes = (options.timeframe as Timeframes) ?? "30d"; 17 | 18 | const usedMetrics = getMetrics(metrics); 19 | 20 | const dateRange = getDateRange(timeframe); 21 | 22 | const request: protos.google.analytics.data.v1beta.IRunReportRequest = { 23 | property: `properties/${provider.propertyId}`, 24 | dateRanges: [dateRange.formatted], 25 | dimensions: [{ name: "pagePath" }], 26 | metrics: usedMetrics.map((metric) => { 27 | return { 28 | name: metric, 29 | }; 30 | }), 31 | keepEmptyRows: false, 32 | metricAggregations: [1], 33 | dimensionFilter: { 34 | andGroup: { 35 | expressions: [ 36 | { 37 | filter: { 38 | fieldName: "pagePath", 39 | stringFilter: { 40 | matchType: "EXACT", 41 | value: pageId, 42 | caseSensitive: true, 43 | }, 44 | }, 45 | }, 46 | ], 47 | }, 48 | }, 49 | }; 50 | 51 | const data = await googleClient.run.runReport(request).then((data) => data); 52 | 53 | const processedData: AggregateData = usedMetrics.map((metric, index) => { 54 | return { 55 | label: metrics[index], 56 | value: data[0].totals?.[0].metricValues?.[index].value ?? 0, 57 | }; 58 | }); 59 | 60 | return processedData; 61 | } 62 | 63 | export default getPageAggregateData; 64 | -------------------------------------------------------------------------------- /src/providers/google/getPageChartData.ts: -------------------------------------------------------------------------------- 1 | import type { GoogleProvider } from "../../types/providers"; 2 | import type { PageChartOptions } from ".."; 3 | import type { ChartData } from "../../types/data"; 4 | import { 5 | getMetrics, 6 | getDateRange, 7 | DateFormat, 8 | GoogleDateFormat, 9 | } from "./utilities"; 10 | import client from "./client"; 11 | import type { protos } from "@google-analytics/data"; 12 | import type { Timeframes } from "../../types/widgets"; 13 | import { eachDayOfInterval, parse, isEqual, format } from "date-fns"; 14 | 15 | async function getPageChartData( 16 | provider: GoogleProvider, 17 | options: PageChartOptions 18 | ) { 19 | const googleClient = client(provider); 20 | 21 | const { metrics, pageId } = options; 22 | const timeframe: Timeframes = (options.timeframe as Timeframes) ?? "30d"; 23 | 24 | const usedMetrics = getMetrics(metrics); 25 | 26 | const dateRange = getDateRange(timeframe); 27 | 28 | const dates = eachDayOfInterval({ 29 | start: dateRange.dates.startDate, 30 | end: dateRange.dates.endDate, 31 | }); 32 | 33 | const request: protos.google.analytics.data.v1beta.IRunReportRequest = { 34 | property: `properties/${provider.propertyId}`, 35 | dateRanges: [dateRange.formatted], 36 | dimensions: [{ name: "date" }, { name: "pagePath" }], 37 | metrics: usedMetrics.map((metric) => { 38 | return { 39 | name: metric, 40 | }; 41 | }), 42 | keepEmptyRows: false, 43 | metricAggregations: [], 44 | dimensionFilter: { 45 | andGroup: { 46 | expressions: [ 47 | { 48 | filter: { 49 | fieldName: "pagePath", 50 | stringFilter: { 51 | matchType: "EXACT", 52 | value: pageId, 53 | caseSensitive: true, 54 | }, 55 | }, 56 | }, 57 | ], 58 | }, 59 | }, 60 | }; 61 | 62 | const data = await googleClient.run.runReport(request).then((data) => data); 63 | 64 | const processedData: ChartData = usedMetrics.map((metric, index) => { 65 | return { 66 | label: metrics[index], 67 | data: dates.map((date, dateIndex) => { 68 | const matchingRow = data[0].rows?.find((row) => { 69 | if (row.dimensionValues?.[0].value) { 70 | const parsedDate = parse( 71 | row.dimensionValues[0].value, 72 | GoogleDateFormat, 73 | new Date() 74 | ); 75 | 76 | return isEqual(date, parsedDate); 77 | } 78 | 79 | return false; 80 | }); 81 | 82 | if (matchingRow) { 83 | const value = matchingRow.metricValues?.[index]?.value; 84 | 85 | return { 86 | timestamp: format(date, DateFormat), 87 | value: value ? parseInt(value) : 0, 88 | }; 89 | } else { 90 | return { 91 | timestamp: format(date, DateFormat), 92 | value: 0, 93 | }; 94 | } 95 | }), 96 | }; 97 | }); 98 | 99 | return processedData; 100 | } 101 | 102 | export default getPageChartData; 103 | -------------------------------------------------------------------------------- /src/providers/google/getReportData.ts: -------------------------------------------------------------------------------- 1 | import type { GoogleProvider } from "../../types/providers"; 2 | import type { ReportDataOptions } from ".."; 3 | import type { ReportData } from "../../types/data"; 4 | import { getMetrics, getDateRange, PropertyMap } from "./utilities"; 5 | import client from "./client"; 6 | import type { protos } from "@google-analytics/data"; 7 | import type { Timeframes } from "../../types/widgets"; 8 | 9 | async function getReportData( 10 | provider: GoogleProvider, 11 | options: ReportDataOptions 12 | ) { 13 | const googleClient = client(provider); 14 | 15 | const { metrics, property } = options; 16 | const timeframe: Timeframes = (options.timeframe as Timeframes) ?? "30d"; 17 | 18 | const usedMetrics = getMetrics(metrics); 19 | const usedProperty = PropertyMap[property]; 20 | 21 | const dateRange = getDateRange(timeframe); 22 | 23 | const request: protos.google.analytics.data.v1beta.IRunReportRequest = { 24 | property: `properties/${provider.propertyId}`, 25 | dateRanges: [dateRange.formatted], 26 | dimensions: [{ name: usedProperty.value ?? "pagePath" }], 27 | metrics: usedMetrics.map((metric) => { 28 | return { 29 | name: metric, 30 | }; 31 | }), 32 | keepEmptyRows: false, 33 | metricAggregations: [1], 34 | limit: 10, 35 | orderBys: [ 36 | { 37 | desc: true, 38 | metric: { 39 | metricName: usedMetrics[0], 40 | }, 41 | }, 42 | ...(usedMetrics[1] 43 | ? [ 44 | { 45 | desc: true, 46 | metric: { 47 | metricName: usedMetrics[1], 48 | }, 49 | }, 50 | ] 51 | : []), 52 | ], 53 | }; 54 | 55 | const data = await googleClient.run.runReport(request).then((data) => data); 56 | 57 | // @todo: fix types 58 | // @ts-ignore 59 | const processedData: ReportData = data[0].rows?.map((row, index) => { 60 | const dimension = row.dimensionValues?.[0].value; 61 | return { 62 | [property]: dimension ?? "", 63 | values: metrics.map((metric, index) => { 64 | const value = row.metricValues?.[index].value; 65 | 66 | return { 67 | [metric]: value ?? "0", 68 | }; 69 | }), 70 | }; 71 | }); 72 | 73 | return processedData; 74 | } 75 | 76 | export default getReportData; 77 | -------------------------------------------------------------------------------- /src/providers/google/index.ts: -------------------------------------------------------------------------------- 1 | import type { GoogleProvider } from "../../types/providers"; 2 | 3 | import getGlobalAggregateData from "./getGlobalAggregateData"; 4 | import getGlobalChartData from "./getGlobalChartData"; 5 | import getPageChartData from "./getPageChartData"; 6 | import getPageAggregateData from "./getPageAggregateData"; 7 | import getLiveData from "./getLiveData"; 8 | import getReportData from "./getReportData"; 9 | 10 | import type { 11 | ApiProvider, 12 | GlobalAggregateOptions, 13 | GlobalChartOptions, 14 | PageChartOptions, 15 | PageAggregateOptions, 16 | LiveDataOptions, 17 | ReportDataOptions, 18 | } from ".."; 19 | 20 | import { MetricMap } from "./utilities"; 21 | 22 | const google = (provider: GoogleProvider): ApiProvider => { 23 | return { 24 | getGlobalAggregateData: async (options: GlobalAggregateOptions) => 25 | await getGlobalAggregateData(provider, options), 26 | getGlobalChartData: async (options: GlobalChartOptions) => 27 | await getGlobalChartData(provider, options), 28 | getPageChartData: async (options: PageChartOptions) => 29 | await getPageChartData(provider, options), 30 | getPageAggregateData: async (options: PageAggregateOptions) => 31 | await getPageAggregateData(provider, options), 32 | getLiveData: async (options: LiveDataOptions) => 33 | await getLiveData(provider, options), 34 | getReportData: async (options: ReportDataOptions) => 35 | await getReportData(provider, options), 36 | metricsMap: MetricMap, 37 | }; 38 | }; 39 | 40 | export default google; 41 | -------------------------------------------------------------------------------- /src/providers/google/utilities.ts: -------------------------------------------------------------------------------- 1 | import type { MetricsMap, PropertiesMap } from "../../types/data"; 2 | import { Timeframes } from "../../types/widgets"; 3 | import type { protos } from "@google-analytics/data"; 4 | import { 5 | format, 6 | subDays, 7 | subMonths, 8 | startOfMonth, 9 | lastDayOfMonth, 10 | } from "date-fns"; 11 | 12 | export const MetricMap: MetricsMap = { 13 | views: { 14 | label: "Views", 15 | value: "screenPageViews", 16 | }, 17 | visitors: { label: "Visitors", value: "activeUsers" }, 18 | bounceRate: { label: "Bounce rate", value: "bounceRate" }, 19 | sessionDuration: { label: "Avg. duration", value: "averageSessionDuration" }, 20 | sessions: { label: "Sessions", value: "sessions" }, 21 | }; 22 | 23 | export const PropertyMap: PropertiesMap = { 24 | page: { 25 | label: "Page", 26 | value: "pagePath", 27 | }, 28 | country: { 29 | label: "Country", 30 | value: "country", 31 | }, 32 | /* entryPoint: { 33 | label: "Pages", 34 | value: "landingPagePlusQueryString", 35 | }, 36 | exitPoint: { 37 | label: "Pages", 38 | value: "event:page", 39 | }, */ 40 | source: { 41 | label: "Source", 42 | value: "source", 43 | }, 44 | }; 45 | 46 | /* const TimeframeMap: Record, number> = { 47 | "12mo": "", 48 | "6mo": "", 49 | "30d": "", 50 | "7d": "", 51 | }; */ 52 | 53 | export const getMetrics = (metrics: Array) => { 54 | const myMetrics: string[] = []; 55 | const availableMetrics = Object.entries(MetricMap); 56 | 57 | metrics?.forEach((metric) => { 58 | const foundMetric = availableMetrics.find((mappedMetric) => { 59 | return mappedMetric[0] === metric; 60 | }); 61 | 62 | if (foundMetric) myMetrics.push(foundMetric[1].value); 63 | }); 64 | 65 | return myMetrics; 66 | }; 67 | 68 | interface DateRangeReturn { 69 | dates: { 70 | startDate: Date; 71 | endDate: Date; 72 | }; 73 | formatted: protos.google.analytics.data.v1beta.IDateRange; 74 | } 75 | 76 | export const DateFormat = "yyyy-MM-dd"; 77 | export const GoogleDateFormat = "yyyyMMdd"; 78 | 79 | export const getDateRange = (timeframe: Timeframes): DateRangeReturn => { 80 | const currentDate = new Date(); 81 | 82 | const startDate = (): Date => { 83 | const date = new Date(currentDate); 84 | 85 | switch (timeframe) { 86 | case "12mo": 87 | return subMonths(date, 12); 88 | case "6mo": 89 | return subMonths(date, 6); 90 | case "30d": 91 | return subDays(date, 30); 92 | case "7d": 93 | return subDays(date, 7); 94 | case "currentMonth": 95 | return startOfMonth(date); 96 | } 97 | }; 98 | 99 | const endDate = 100 | timeframe === "currentMonth" ? lastDayOfMonth(currentDate) : currentDate; 101 | 102 | return { 103 | dates: { 104 | startDate: startDate(), 105 | endDate: endDate, 106 | }, 107 | formatted: { 108 | startDate: format(startDate(), DateFormat), 109 | endDate: format(endDate, DateFormat), 110 | }, 111 | }; 112 | }; 113 | -------------------------------------------------------------------------------- /src/providers/index.ts: -------------------------------------------------------------------------------- 1 | import plausible from "./plausible"; 2 | import google from "./google"; 3 | import type { Provider } from "../types"; 4 | import type { ChartWidget, InfoWidget, ReportWidget } from "../types/widgets"; 5 | import type { 6 | ChartData, 7 | AggregateData, 8 | LiveData, 9 | ReportData, 10 | MetricsMap, 11 | } from "../types/data"; 12 | 13 | type BaseOptions = { 14 | timeframe?: string; 15 | }; 16 | 17 | export interface LiveDataOptions {} 18 | 19 | export interface ReportDataOptions extends BaseOptions { 20 | metrics: ReportWidget["metrics"]; 21 | property: ReportWidget["property"]; 22 | } 23 | 24 | export interface GlobalAggregateOptions extends BaseOptions { 25 | metrics: InfoWidget["metrics"]; 26 | } 27 | export interface GlobalChartOptions extends BaseOptions { 28 | metrics: ChartWidget["metrics"]; 29 | } 30 | 31 | export interface PageAggregateOptions extends BaseOptions { 32 | metrics: InfoWidget["metrics"]; 33 | pageId: string; 34 | } 35 | export interface PageChartOptions extends BaseOptions { 36 | metrics: ChartWidget["metrics"]; 37 | pageId: string; 38 | } 39 | 40 | export type ApiProvider = { 41 | getGlobalAggregateData: ( 42 | options: GlobalAggregateOptions 43 | ) => Promise; 44 | getGlobalChartData: (options: GlobalChartOptions) => Promise; 45 | getPageAggregateData: ( 46 | options: PageAggregateOptions 47 | ) => Promise; 48 | getPageChartData: (options: PageChartOptions) => Promise; 49 | getLiveData: (options: LiveDataOptions) => Promise; 50 | getReportData: (options: ReportDataOptions) => Promise; 51 | metricsMap: MetricsMap; 52 | }; 53 | 54 | const getProvider = (provider: Provider) => { 55 | switch (provider.source) { 56 | case "plausible": 57 | return plausible(provider); 58 | case "google": 59 | return google(provider); 60 | } 61 | }; 62 | 63 | export default getProvider; 64 | -------------------------------------------------------------------------------- /src/providers/plausible/client.ts: -------------------------------------------------------------------------------- 1 | import type { PlausibleProvider } from "../../types/providers"; 2 | import type { Metrics, Properties } from "../../types/widgets"; 3 | import { MetricMap, PropertyMap } from "./utilities"; 4 | 5 | type ClientOptions = { 6 | endpoint: string; 7 | timeframe?: string; 8 | metrics?: Metrics[]; 9 | property?: Properties; 10 | }; 11 | 12 | function client(provider: PlausibleProvider, options: ClientOptions) { 13 | const { endpoint, timeframe, metrics, property } = options; 14 | const host = provider.host ?? `https://plausible.io`; 15 | const apiVersion = `v1`; // for future use 16 | 17 | const period = () => { 18 | switch (timeframe) { 19 | case "currentMonth": 20 | return "month"; 21 | case null: 22 | case undefined: 23 | return "30d"; 24 | default: 25 | return timeframe; 26 | } 27 | }; 28 | 29 | const url = new URL(`${host}/api/${apiVersion}${endpoint}`); 30 | url.searchParams.append("site_id", provider.siteId); 31 | 32 | const getMetrics = () => { 33 | const myMetrics: string[] = []; 34 | const availableMetrics = Object.entries(MetricMap); 35 | 36 | metrics?.forEach((metric) => { 37 | const foundMetric = availableMetrics.find((mappedMetric) => { 38 | return mappedMetric[0] === metric; 39 | }); 40 | 41 | if (foundMetric) myMetrics.push(foundMetric[1].value); 42 | }); 43 | 44 | return myMetrics; 45 | }; 46 | 47 | const plausibleMetrics = metrics?.length ? getMetrics() : "pageviews"; 48 | 49 | const baseUrl = String(url.href); 50 | url.searchParams.append("period", period()); 51 | url.searchParams.append("metrics", String(plausibleMetrics)); 52 | 53 | if (property) { 54 | const availableProperties = Object.entries(PropertyMap); 55 | 56 | const foundMetric = availableProperties.find((mappedProperty) => { 57 | return mappedProperty[0] === property; 58 | }); 59 | 60 | if (foundMetric) { 61 | url.searchParams.append("property", String(foundMetric[1].value)); 62 | } 63 | } 64 | 65 | return { 66 | host: host, 67 | baseUrl: baseUrl, 68 | metric: plausibleMetrics, 69 | url: url, 70 | metricsMap: MetricMap, 71 | propertiesMap: PropertyMap, 72 | fetch: async (customUrl?: string) => { 73 | const fetchUrl = customUrl ?? url.toString(); 74 | 75 | return await fetch(fetchUrl, { 76 | method: "get", 77 | headers: new Headers({ 78 | Authorization: `Bearer ${provider.apiSecret}`, 79 | "Content-Type": "application/x-www-form-urlencoded", 80 | }), 81 | }); 82 | }, 83 | }; 84 | } 85 | 86 | export default client; 87 | -------------------------------------------------------------------------------- /src/providers/plausible/getGlobalAggregateData.ts: -------------------------------------------------------------------------------- 1 | import type { PlausibleProvider } from "../../types/providers"; 2 | import type { GlobalAggregateOptions } from ".."; 3 | import client from "./client"; 4 | 5 | async function getGlobalAggregateData( 6 | provider: PlausibleProvider, 7 | options: GlobalAggregateOptions 8 | ) { 9 | const plausibleClient = client(provider, { 10 | endpoint: "/stats/aggregate", 11 | timeframe: options.timeframe, 12 | metrics: options.metrics, 13 | }); 14 | 15 | const data = await plausibleClient.fetch().then((response) => { 16 | return response.json(); 17 | }); 18 | 19 | return data; 20 | } 21 | 22 | export default getGlobalAggregateData; 23 | -------------------------------------------------------------------------------- /src/providers/plausible/getGlobalChartData.ts: -------------------------------------------------------------------------------- 1 | import type { PlausibleProvider } from "../../types/providers"; 2 | import type { ChartData } from "../../types/data"; 3 | import type { GlobalChartOptions } from ".."; 4 | import { MetricMap } from "./utilities"; 5 | import payload from "payload"; 6 | import client from "./client"; 7 | 8 | async function getGlobalChartData( 9 | provider: PlausibleProvider, 10 | options: GlobalChartOptions 11 | ) { 12 | const plausibleClient = client(provider, { 13 | endpoint: "/stats/timeseries", 14 | timeframe: options?.timeframe, 15 | metrics: options.metrics, 16 | }); 17 | 18 | const { results } = await plausibleClient 19 | .fetch() 20 | .then((response) => { 21 | return response.json(); 22 | }) 23 | .catch((error) => { 24 | payload.logger.error(error); 25 | }); 26 | 27 | /* @todo: fix types later */ 28 | /* @ts-ignore */ 29 | const dataSeries: ChartData = options.metrics.map((metric) => { 30 | const mappedMetric = Object.entries(MetricMap).find(([key, value]) => { 31 | return metric === key; 32 | }); 33 | 34 | if (mappedMetric) { 35 | const data = results.map((item: any) => { 36 | return { 37 | timestamp: item.date, 38 | value: item[mappedMetric[1].value], 39 | }; 40 | }); 41 | 42 | return { 43 | label: mappedMetric[1].label, 44 | data: data, 45 | }; 46 | } 47 | }); 48 | 49 | return dataSeries; 50 | } 51 | 52 | export default getGlobalChartData; 53 | -------------------------------------------------------------------------------- /src/providers/plausible/getLiveData.ts: -------------------------------------------------------------------------------- 1 | import type { PlausibleProvider } from "../../types/providers"; 2 | import type { LiveDataOptions } from ".."; 3 | import type { LiveData } from "../../types/data"; 4 | import client from "./client"; 5 | 6 | async function getLiveData( 7 | provider: PlausibleProvider, 8 | options: LiveDataOptions 9 | ) { 10 | const plausibleClient = client(provider, { 11 | endpoint: "/stats/realtime/visitors", 12 | }); 13 | 14 | const data = await plausibleClient.fetch().then((response) => { 15 | return response.json(); 16 | }); 17 | 18 | const processedData: LiveData = { 19 | visitors: data, 20 | }; 21 | 22 | return processedData; 23 | } 24 | 25 | export default getLiveData; 26 | -------------------------------------------------------------------------------- /src/providers/plausible/getPageAggregateData.ts: -------------------------------------------------------------------------------- 1 | import type { PlausibleProvider } from "../../types/providers"; 2 | import type { PageAggregateOptions } from ".."; 3 | import type { AggregateData } from "../../types/data"; 4 | import { MetricMap } from "./utilities"; 5 | import client from "./client"; 6 | 7 | async function getPageAggregateData( 8 | provider: PlausibleProvider, 9 | options: PageAggregateOptions 10 | ) { 11 | const plausibleClient = client(provider, { 12 | endpoint: "/stats/aggregate", 13 | timeframe: options?.timeframe, 14 | metrics: options.metrics, 15 | }); 16 | 17 | const url = plausibleClient.url; 18 | 19 | const pageFilter = `event:page==${options.pageId}`; 20 | 21 | url.searchParams.append("filters", pageFilter); 22 | 23 | const data = await plausibleClient 24 | .fetch(url.toString()) 25 | .then((response) => response.json()); 26 | 27 | const processedData: AggregateData = Object.entries(data.results).map( 28 | ([label, value]: any) => { 29 | const labelAsMetric = Object.values(MetricMap).find((item) => { 30 | return label === item.value; 31 | }); 32 | 33 | return { 34 | label: labelAsMetric?.label ?? label, 35 | value: value.value, 36 | }; 37 | } 38 | ); 39 | return processedData; 40 | } 41 | 42 | export default getPageAggregateData; 43 | -------------------------------------------------------------------------------- /src/providers/plausible/getPageChartData.ts: -------------------------------------------------------------------------------- 1 | import type { PlausibleProvider } from "../../types/providers"; 2 | import type { PageChartOptions } from ".."; 3 | import type { ChartData } from "../../types/data"; 4 | import { MetricMap } from "./utilities"; 5 | import payload from "payload"; 6 | import client from "./client"; 7 | 8 | async function getPageChartData( 9 | provider: PlausibleProvider, 10 | options: PageChartOptions 11 | ) { 12 | const plausibleClient = client(provider, { 13 | endpoint: "/stats/timeseries", 14 | timeframe: options?.timeframe, 15 | metrics: options.metrics, 16 | }); 17 | 18 | const url = plausibleClient.url; 19 | 20 | const pageFilter = `event:page==${options.pageId}`; 21 | 22 | url.searchParams.append("filters", pageFilter); 23 | 24 | const { results } = await plausibleClient 25 | .fetch(url.toString()) 26 | .then((response) => { 27 | return response.json(); 28 | }) 29 | .catch((error) => { 30 | payload.logger.error(error); 31 | }); 32 | 33 | // @todo: fix types later 34 | // @ts-ignore 35 | const dataSeries: ChartData = options.metrics.map((metric) => { 36 | const mappedMetric = Object.entries(MetricMap).find(([key, value]) => { 37 | return metric === key; 38 | }); 39 | 40 | if (mappedMetric) { 41 | const data = results.map((item: any) => { 42 | return { 43 | timestamp: item.date, 44 | value: item[mappedMetric[1].value], 45 | }; 46 | }); 47 | 48 | return { 49 | label: mappedMetric[1].label, 50 | data: data, 51 | }; 52 | } 53 | }); 54 | 55 | return dataSeries; 56 | } 57 | 58 | export default getPageChartData; 59 | -------------------------------------------------------------------------------- /src/providers/plausible/getReportData.ts: -------------------------------------------------------------------------------- 1 | import type { PlausibleProvider } from "../../types/providers"; 2 | import type { ReportDataOptions } from ".."; 3 | import type { ReportData } from "../../types/data"; 4 | import client from "./client"; 5 | 6 | async function getReportData( 7 | provider: PlausibleProvider, 8 | options: ReportDataOptions 9 | ) { 10 | const plausibleClient = client(provider, { 11 | endpoint: "/stats/breakdown", 12 | metrics: options.metrics, 13 | property: options.property, 14 | }); 15 | 16 | const url = plausibleClient.url; 17 | 18 | const metricsMap = plausibleClient.metricsMap; 19 | 20 | url.searchParams.append("limit", "10"); 21 | 22 | const data = await plausibleClient.fetch(url.toString()).then((response) => { 23 | return response.json(); 24 | }); 25 | 26 | const processedData: ReportData = data.results.map((item: any) => { 27 | const matchingProperyKey = Object.keys(item)[0]; 28 | 29 | return { 30 | [options.property]: item[matchingProperyKey], 31 | values: Object.keys(item) 32 | .map((value) => { 33 | const matchingMetric = Object.entries(metricsMap).find( 34 | ([key, metricValue]) => { 35 | return value === metricValue.value; 36 | } 37 | ); 38 | 39 | if (matchingMetric) { 40 | return { 41 | [matchingMetric[0]]: item[value], 42 | }; 43 | } 44 | }) 45 | .filter((filterItem) => Boolean(filterItem)), 46 | }; 47 | }); 48 | 49 | return processedData; 50 | } 51 | 52 | export default getReportData; 53 | -------------------------------------------------------------------------------- /src/providers/plausible/index.ts: -------------------------------------------------------------------------------- 1 | import type { PlausibleProvider } from "../../types/providers"; 2 | import getGlobalAggregateData from "./getGlobalAggregateData"; 3 | import getGlobalChartData from "./getGlobalChartData"; 4 | import getPageAggregateData from "./getPageAggregateData"; 5 | import getPageChartData from "./getPageChartData"; 6 | import getLiveData from "./getLiveData"; 7 | import getReportData from "./getReportData"; 8 | import type { 9 | ApiProvider, 10 | GlobalAggregateOptions, 11 | GlobalChartOptions, 12 | PageChartOptions, 13 | PageAggregateOptions, 14 | LiveDataOptions, 15 | ReportDataOptions, 16 | } from ".."; 17 | 18 | import { MetricMap } from "./utilities"; 19 | 20 | const plausible = (provider: PlausibleProvider): ApiProvider => { 21 | return { 22 | getGlobalAggregateData: async (options: GlobalAggregateOptions) => 23 | await getGlobalAggregateData(provider, options), 24 | getGlobalChartData: async (options: GlobalChartOptions) => 25 | await getGlobalChartData(provider, options), 26 | getPageChartData: async (options: PageChartOptions) => 27 | await getPageChartData(provider, options), 28 | getPageAggregateData: async (options: PageAggregateOptions) => 29 | await getPageAggregateData(provider, options), 30 | getLiveData: async (options: LiveDataOptions) => 31 | await getLiveData(provider, options), 32 | getReportData: async (options: ReportDataOptions) => 33 | await getReportData(provider, options), 34 | metricsMap: MetricMap, 35 | }; 36 | }; 37 | 38 | export default plausible; 39 | -------------------------------------------------------------------------------- /src/providers/plausible/utilities.ts: -------------------------------------------------------------------------------- 1 | import { MetricsMap, PropertiesMap } from "../../types/data"; 2 | 3 | export const MetricMap: MetricsMap = { 4 | views: { 5 | label: "Views", 6 | value: "pageviews", 7 | }, 8 | visitors: { label: "Visitors", value: "visitors" }, 9 | bounceRate: { label: "Bounce rate", value: "bounce_rate" }, 10 | sessionDuration: { label: "Avg. duration", value: "visit_duration" }, 11 | sessions: { label: "Sessions", value: "visits" }, 12 | }; 13 | 14 | export const PropertyMap: PropertiesMap = { 15 | page: { 16 | label: "Pages", 17 | value: "event:page", 18 | }, 19 | country: { 20 | label: "Pages", 21 | value: "event:page", 22 | }, 23 | /* entryPoint: { 24 | label: "Pages", 25 | value: "event:page", 26 | }, 27 | exitPoint: { 28 | label: "Pages", 29 | value: "event:page", 30 | }, */ 31 | source: { 32 | label: "Pages", 33 | value: "event:page", 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /src/routes/getGlobalAggregate/handler.ts: -------------------------------------------------------------------------------- 1 | import type { Endpoint } from "payload/config"; 2 | import type { ApiProvider } from "../../providers"; 3 | import type { RouteOptions } from "../../types"; 4 | import type { Payload } from "payload"; 5 | import { dayInMinutes } from "../../utilities/timings"; 6 | import { differenceInMinutes } from "date-fns"; 7 | 8 | const handler = (provider: ApiProvider, options: RouteOptions) => { 9 | const handler: Endpoint["handler"] = async (req, res, next) => { 10 | const { user } = req; 11 | const payload: Payload = req.payload; 12 | const { timeframe, metrics } = req.body; 13 | const { access, cache } = options; 14 | 15 | if (access) { 16 | const accessControl = access(user); 17 | 18 | if (!accessControl) { 19 | payload.logger.error("📊 Analytics API: Request fails access control."); 20 | res 21 | .status(500) 22 | .send("Request fails access control. Are you authenticated?"); 23 | return next(); 24 | } 25 | } 26 | 27 | if (!metrics) { 28 | payload.logger.error("📊 Analytics API: Missing metrics argument."); 29 | res.status(500).send("Missing metrics argument."); 30 | return next(); 31 | } 32 | 33 | try { 34 | if (cache) { 35 | const timeNow = new Date(); 36 | const cacheKey = `globalAggregate|${metrics.join("-")}|${ 37 | timeframe ?? "30d" 38 | }`; 39 | const cacheLifetime = 40 | options.cache?.routes?.pageAggregate ?? dayInMinutes; 41 | 42 | const { 43 | docs: [cachedData], 44 | } = await payload.find({ 45 | collection: cache.slug, 46 | where: { 47 | and: [ 48 | { 49 | cacheKey: { 50 | equals: cacheKey, 51 | }, 52 | }, 53 | ], 54 | }, 55 | }); 56 | 57 | if (!cachedData) { 58 | const data = await provider 59 | .getGlobalAggregateData({ 60 | timeframe, 61 | metrics, 62 | }) 63 | .catch((error) => payload.logger.error(error)); 64 | 65 | await payload.create({ 66 | collection: cache.slug, 67 | data: { 68 | cacheKey: cacheKey, 69 | cacheTimestamp: timeNow.toISOString(), 70 | data: data, 71 | }, 72 | }); 73 | 74 | res.status(200).send(data); 75 | return next(); 76 | } 77 | 78 | if (cachedData) { 79 | if ( 80 | differenceInMinutes( 81 | timeNow, 82 | Date.parse(cachedData.cacheTimestamp) 83 | ) > cacheLifetime 84 | ) { 85 | const data = await provider 86 | .getGlobalAggregateData({ 87 | timeframe, 88 | metrics, 89 | }) 90 | .catch((error) => payload.logger.error(error)); 91 | 92 | await payload.update({ 93 | id: cachedData.id, 94 | collection: cache.slug, 95 | data: { 96 | cacheKey: cacheKey, 97 | cacheTimestamp: timeNow.toISOString(), 98 | data: data, 99 | }, 100 | }); 101 | 102 | res.status(200).send(data); 103 | return next(); 104 | } else { 105 | res.status(200).send(cachedData.data); 106 | return next(); 107 | } 108 | } 109 | } 110 | 111 | const data = await provider 112 | .getGlobalAggregateData({ 113 | timeframe, 114 | metrics, 115 | }) 116 | .catch((error) => payload.logger.error(error)); 117 | 118 | res.status(200).send(data); 119 | } catch (error) { 120 | payload.logger.error(error); 121 | res.status(500).send(`📊 Analytics API: ${error}`); 122 | return next(); 123 | } 124 | }; 125 | 126 | return handler; 127 | }; 128 | 129 | export default handler; 130 | -------------------------------------------------------------------------------- /src/routes/getGlobalAggregate/index.ts: -------------------------------------------------------------------------------- 1 | import type { Endpoint } from "payload/config"; 2 | import type { ApiProvider } from "../../providers"; 3 | import type { RouteOptions } from "../../types"; 4 | import handler from "./handler"; 5 | 6 | const getGlobalAggregate = ( 7 | provider: ApiProvider, 8 | options: RouteOptions 9 | ): Endpoint => { 10 | return { 11 | path: "/analytics/globalAggregate", 12 | method: "post", 13 | handler: handler(provider, options), 14 | }; 15 | }; 16 | 17 | export default getGlobalAggregate; 18 | -------------------------------------------------------------------------------- /src/routes/getGlobalChart/handler.ts: -------------------------------------------------------------------------------- 1 | import type { Endpoint } from "payload/config"; 2 | import type { ApiProvider } from "../../providers"; 3 | import type { RouteOptions } from "../../types"; 4 | import type { Payload } from "payload"; 5 | import { dayInMinutes } from "../../utilities/timings"; 6 | import { differenceInMinutes } from "date-fns"; 7 | 8 | const handler = (provider: ApiProvider, options: RouteOptions) => { 9 | const handler: Endpoint["handler"] = async (req, res, next) => { 10 | const { user } = req; 11 | const payload: Payload = req.payload; 12 | const { timeframe, metrics } = req.body; 13 | const { access, cache } = options; 14 | 15 | if (access) { 16 | const accessControl = access(user); 17 | 18 | if (!accessControl) { 19 | payload.logger.error("📊 Analytics API: Request fails access control."); 20 | res 21 | .status(500) 22 | .send("Request fails access control. Are you authenticated?"); 23 | return next(); 24 | } 25 | } 26 | 27 | if (!metrics) { 28 | payload.logger.error("📊 Analytics API: Missing metrics argument."); 29 | res.status(500).send("Missing metrics argument."); 30 | return next(); 31 | } 32 | 33 | try { 34 | if (cache) { 35 | const timeNow = new Date(); 36 | const cacheKey = `globalChart|${metrics.join("-")}|${ 37 | timeframe ?? "30d" 38 | }`; 39 | const cacheLifetime = 40 | options.cache?.routes?.pageAggregate ?? dayInMinutes; 41 | 42 | const { 43 | docs: [cachedData], 44 | } = await payload.find({ 45 | collection: cache.slug, 46 | where: { 47 | and: [ 48 | { 49 | cacheKey: { 50 | equals: cacheKey, 51 | }, 52 | }, 53 | ], 54 | }, 55 | }); 56 | 57 | if (!cachedData) { 58 | const data = await provider 59 | .getGlobalChartData({ 60 | timeframe: timeframe, 61 | metrics: metrics, 62 | }) 63 | .catch((error) => payload.logger.error(error)); 64 | 65 | await payload.create({ 66 | collection: cache.slug, 67 | data: { 68 | cacheKey: cacheKey, 69 | cacheTimestamp: timeNow.toISOString(), 70 | data: data, 71 | }, 72 | }); 73 | 74 | res.status(200).send(data); 75 | return next(); 76 | } 77 | 78 | if (cachedData) { 79 | if ( 80 | differenceInMinutes( 81 | timeNow, 82 | Date.parse(cachedData.cacheTimestamp) 83 | ) > cacheLifetime 84 | ) { 85 | const data = await provider 86 | .getGlobalChartData({ 87 | timeframe: timeframe, 88 | metrics: metrics, 89 | }) 90 | .catch((error) => payload.logger.error(error)); 91 | 92 | await payload.update({ 93 | id: cachedData.id, 94 | collection: cache.slug, 95 | data: { 96 | cacheKey: cacheKey, 97 | cacheTimestamp: timeNow.toISOString(), 98 | data: data, 99 | }, 100 | }); 101 | 102 | res.status(200).send(data); 103 | return next(); 104 | } else { 105 | res.status(200).send(cachedData.data); 106 | return next(); 107 | } 108 | } 109 | } 110 | 111 | const data = await provider 112 | .getGlobalChartData({ 113 | timeframe: timeframe, 114 | metrics: metrics, 115 | }) 116 | .catch((error) => payload.logger.error(error)); 117 | 118 | res.status(200).send(data); 119 | } catch (error) { 120 | payload.logger.error(error); 121 | res.status(500).send(`📊 Analytics API: ${error}`); 122 | return next(); 123 | } 124 | }; 125 | 126 | return handler; 127 | }; 128 | 129 | export default handler; 130 | -------------------------------------------------------------------------------- /src/routes/getGlobalChart/index.ts: -------------------------------------------------------------------------------- 1 | import type { Endpoint } from "payload/config"; 2 | import type { ApiProvider } from "../../providers"; 3 | import type { RouteOptions } from "../../types"; 4 | import handler from "./handler"; 5 | 6 | const getGlobalChart = ( 7 | provider: ApiProvider, 8 | options: RouteOptions 9 | ): Endpoint => { 10 | return { 11 | path: "/analytics/globalChart", 12 | method: "post", 13 | handler: handler(provider, options), 14 | }; 15 | }; 16 | 17 | export default getGlobalChart; 18 | -------------------------------------------------------------------------------- /src/routes/getLive/handler.ts: -------------------------------------------------------------------------------- 1 | import type { Endpoint } from "payload/config"; 2 | import type { ApiProvider } from "../../providers"; 3 | import type { RouteOptions } from "../../types"; 4 | import type { Payload } from "payload"; 5 | import { differenceInMinutes } from "date-fns"; 6 | 7 | const handler = (provider: ApiProvider, options: RouteOptions) => { 8 | const handler: Endpoint["handler"] = async (req, res, next) => { 9 | const { user } = req; 10 | const payload: Payload = req.payload; 11 | const { access, cache } = options; 12 | 13 | if (access) { 14 | const accessControl = access(user); 15 | 16 | if (!accessControl) { 17 | payload.logger.error("📊 Analytics API: Request fails access control."); 18 | res 19 | .status(500) 20 | .send("Request fails access control. Are you authenticated?"); 21 | return next(); 22 | } 23 | } 24 | 25 | try { 26 | if (cache) { 27 | const timeNow = new Date(); 28 | const cacheKey = "liveData"; 29 | const cacheLifetime = options.cache?.routes?.live ?? 5; 30 | 31 | const { 32 | docs: [cachedData], 33 | } = await payload.find({ 34 | collection: cache.slug, 35 | where: { 36 | and: [ 37 | { 38 | cacheKey: { 39 | equals: cacheKey, 40 | }, 41 | }, 42 | ], 43 | }, 44 | }); 45 | 46 | if (!cachedData) { 47 | const data = await provider.getLiveData({}); 48 | 49 | await payload.create({ 50 | collection: cache.slug, 51 | data: { 52 | cacheKey: cacheKey, 53 | cacheTimestamp: timeNow.toISOString(), 54 | data: data, 55 | }, 56 | }); 57 | 58 | res.status(200).send(data); 59 | return next(); 60 | } 61 | 62 | if (cachedData) { 63 | if ( 64 | differenceInMinutes( 65 | timeNow, 66 | Date.parse(cachedData.cacheTimestamp) 67 | ) > cacheLifetime 68 | ) { 69 | const data = await provider.getLiveData({}); 70 | 71 | await payload.update({ 72 | id: cachedData.id, 73 | collection: cache.slug, 74 | data: { 75 | cacheKey: cacheKey, 76 | cacheTimestamp: timeNow.toISOString(), 77 | data: data, 78 | }, 79 | }); 80 | 81 | res.status(200).send(data); 82 | return next(); 83 | } else { 84 | res.status(200).send(cachedData.data); 85 | return next(); 86 | } 87 | } 88 | } 89 | const data = await provider.getLiveData({}); 90 | 91 | res.status(200).send(data); 92 | } catch (error) { 93 | payload.logger.error(error); 94 | res.status(500).send(`📊 Analytics API: ${error}`); 95 | return next(); 96 | } 97 | }; 98 | 99 | return handler; 100 | }; 101 | 102 | export default handler; 103 | -------------------------------------------------------------------------------- /src/routes/getLive/index.ts: -------------------------------------------------------------------------------- 1 | import type { Endpoint } from "payload/config"; 2 | import type { ApiProvider } from "../../providers"; 3 | import type { RouteOptions } from "../../types"; 4 | import handler from "./handler"; 5 | 6 | const getLive = (provider: ApiProvider, options: RouteOptions): Endpoint => { 7 | return { 8 | path: "/analytics/live", 9 | method: "post", 10 | handler: handler(provider, options), 11 | }; 12 | }; 13 | 14 | export default getLive; 15 | -------------------------------------------------------------------------------- /src/routes/getPageAggregate/handler.ts: -------------------------------------------------------------------------------- 1 | import type { Endpoint } from "payload/config"; 2 | import type { ApiProvider } from "../../providers"; 3 | import type { AggregateData } from "../../types/data"; 4 | import type { RouteOptions } from "../../types"; 5 | import type { Payload } from "payload"; 6 | import { dayInMinutes } from "../../utilities/timings"; 7 | import { differenceInMinutes } from "date-fns"; 8 | 9 | const handler = (provider: ApiProvider, options: RouteOptions) => { 10 | const handler: Endpoint["handler"] = async (req, res, next) => { 11 | const { user } = req; 12 | const payload: Payload = req.payload; 13 | const { timeframe, metrics, pageId } = req.body; 14 | const { access, cache } = options; 15 | 16 | if (access) { 17 | const accessControl = access(user); 18 | 19 | if (!accessControl) { 20 | payload.logger.error("📊 Analytics API: Request fails access control."); 21 | res 22 | .status(500) 23 | .send("Request fails access control. Are you authenticated?"); 24 | return next(); 25 | } 26 | } 27 | 28 | if (!metrics) { 29 | payload.logger.error("📊 Analytics API: Missing metrics argument."); 30 | res.status(500).send("Missing metrics argument."); 31 | return next(); 32 | } 33 | 34 | try { 35 | if (cache) { 36 | const timeNow = new Date(); 37 | const cacheKey = `pageAggregate|${metrics.join("-")}|${ 38 | timeframe ?? "30d" 39 | }|${pageId}`; 40 | const cacheLifetime = 41 | options.cache?.routes?.pageAggregate ?? dayInMinutes; 42 | 43 | const { 44 | docs: [cachedData], 45 | } = await payload.find({ 46 | collection: cache.slug, 47 | where: { 48 | and: [ 49 | { 50 | cacheKey: { 51 | equals: cacheKey, 52 | }, 53 | }, 54 | ], 55 | }, 56 | }); 57 | 58 | if (!cachedData) { 59 | const data: AggregateData = await provider 60 | .getPageAggregateData({ 61 | timeframe: timeframe, 62 | metrics: metrics, 63 | pageId, 64 | }) 65 | .catch((error) => payload.logger.error(error)); 66 | 67 | await payload.create({ 68 | collection: cache.slug, 69 | data: { 70 | cacheKey: cacheKey, 71 | cacheTimestamp: timeNow.toISOString(), 72 | data: data, 73 | }, 74 | }); 75 | 76 | res.status(200).send(data); 77 | return next(); 78 | } 79 | 80 | if (cachedData) { 81 | if ( 82 | differenceInMinutes( 83 | timeNow, 84 | Date.parse(cachedData.cacheTimestamp) 85 | ) > cacheLifetime 86 | ) { 87 | const data: AggregateData = await provider 88 | .getPageAggregateData({ 89 | timeframe: timeframe, 90 | metrics: metrics, 91 | pageId, 92 | }) 93 | .catch((error) => payload.logger.error(error)); 94 | 95 | await payload.update({ 96 | id: cachedData.id, 97 | collection: cache.slug, 98 | data: { 99 | cacheKey: cacheKey, 100 | cacheTimestamp: timeNow.toISOString(), 101 | data: data, 102 | }, 103 | }); 104 | 105 | res.status(200).send(data); 106 | return next(); 107 | } else { 108 | res.status(200).send(cachedData.data); 109 | return next(); 110 | } 111 | } 112 | } 113 | 114 | const data: AggregateData = await provider 115 | .getPageAggregateData({ 116 | timeframe: timeframe, 117 | metrics: metrics, 118 | pageId, 119 | }) 120 | .catch((error) => payload.logger.error(error)); 121 | 122 | res.status(200).send(data); 123 | } catch (error) { 124 | payload.logger.error(error); 125 | res.status(500).send(`📊 Analytics API: ${error}`); 126 | return next(); 127 | } 128 | }; 129 | 130 | return handler; 131 | }; 132 | 133 | export default handler; 134 | -------------------------------------------------------------------------------- /src/routes/getPageAggregate/index.ts: -------------------------------------------------------------------------------- 1 | import type { Endpoint } from "payload/config"; 2 | import type { ApiProvider } from "../../providers"; 3 | import type { RouteOptions } from "../../types"; 4 | import handler from "./handler"; 5 | 6 | const getPageAggregate = ( 7 | provider: ApiProvider, 8 | options: RouteOptions 9 | ): Endpoint => { 10 | return { 11 | path: "/analytics/pageAggregate", 12 | method: "post", 13 | handler: handler(provider, options), 14 | }; 15 | }; 16 | 17 | export default getPageAggregate; 18 | -------------------------------------------------------------------------------- /src/routes/getPageChart/handler.ts: -------------------------------------------------------------------------------- 1 | import type { Endpoint } from "payload/config"; 2 | import type { ApiProvider } from "../../providers"; 3 | import type { RouteOptions } from "../../types"; 4 | import type { ChartData } from "../../types/data"; 5 | import type { Payload } from "payload"; 6 | import { dayInMinutes } from "../../utilities/timings"; 7 | import { differenceInMinutes } from "date-fns"; 8 | 9 | const handler = (provider: ApiProvider, options: RouteOptions) => { 10 | const handler: Endpoint["handler"] = async (req, res, next) => { 11 | const { user } = req; 12 | const payload: Payload = req.payload; 13 | const { timeframe, metrics, pageId } = req.body; 14 | const { access, cache } = options; 15 | 16 | if (access) { 17 | const accessControl = access(user); 18 | 19 | if (!accessControl) { 20 | payload.logger.error("📊 Analytics API: Request fails access control."); 21 | res 22 | .status(500) 23 | .send("Request fails access control. Are you authenticated?"); 24 | return next(); 25 | } 26 | } 27 | 28 | if (!metrics) { 29 | payload.logger.error("📊 Analytics API: Missing metrics argument."); 30 | res.status(500).send("Missing metrics argument."); 31 | return next(); 32 | } 33 | 34 | if (!pageId) { 35 | payload.logger.error("📊 Analytics API: Missing pageId argument."); 36 | res.status(500).send("Missing pageId argument."); 37 | return next(); 38 | } 39 | 40 | try { 41 | if (cache) { 42 | const timeNow = new Date(); 43 | const cacheKey = `pageChart|${metrics.join("-")}|${ 44 | timeframe ?? "30d" 45 | }|${pageId}`; 46 | const cacheLifetime = 47 | options.cache?.routes?.pageAggregate ?? dayInMinutes; 48 | 49 | const { 50 | docs: [cachedData], 51 | } = await payload.find({ 52 | collection: cache.slug, 53 | where: { 54 | and: [ 55 | { 56 | cacheKey: { 57 | equals: cacheKey, 58 | }, 59 | }, 60 | ], 61 | }, 62 | }); 63 | 64 | if (!cachedData) { 65 | const data: ChartData = await provider 66 | .getPageChartData({ 67 | timeframe: timeframe, 68 | metrics: metrics, 69 | pageId, 70 | }) 71 | .catch((error) => payload.logger.error(error)); 72 | 73 | await payload.create({ 74 | collection: cache.slug, 75 | data: { 76 | cacheKey: cacheKey, 77 | cacheTimestamp: timeNow.toISOString(), 78 | data: data, 79 | }, 80 | }); 81 | 82 | res.status(200).send(data); 83 | return next(); 84 | } 85 | 86 | if (cachedData) { 87 | if ( 88 | differenceInMinutes( 89 | timeNow, 90 | Date.parse(cachedData.cacheTimestamp) 91 | ) > cacheLifetime 92 | ) { 93 | const data: ChartData = await provider 94 | .getPageChartData({ 95 | timeframe: timeframe, 96 | metrics: metrics, 97 | pageId, 98 | }) 99 | .catch((error) => payload.logger.error(error)); 100 | 101 | await payload.update({ 102 | id: cachedData.id, 103 | collection: cache.slug, 104 | data: { 105 | cacheKey: cacheKey, 106 | cacheTimestamp: timeNow.toISOString(), 107 | data: data, 108 | }, 109 | }); 110 | 111 | res.status(200).send(data); 112 | return next(); 113 | } else { 114 | res.status(200).send(cachedData.data); 115 | return next(); 116 | } 117 | } 118 | } 119 | 120 | const data: ChartData = await provider 121 | .getPageChartData({ 122 | timeframe: timeframe, 123 | metrics: metrics, 124 | pageId, 125 | }) 126 | .catch((error) => payload.logger.error(error)); 127 | 128 | res.status(200).send(data); 129 | } catch (error) { 130 | payload.logger.error(error); 131 | res.status(500).send(`📊 Analytics API: ${error}`); 132 | return next(); 133 | } 134 | }; 135 | 136 | return handler; 137 | }; 138 | 139 | export default handler; 140 | -------------------------------------------------------------------------------- /src/routes/getPageChart/index.ts: -------------------------------------------------------------------------------- 1 | import type { Endpoint } from "payload/config"; 2 | import type { ApiProvider } from "../../providers"; 3 | import type { RouteOptions } from "../../types"; 4 | import handler from "./handler"; 5 | 6 | const getPageChart = ( 7 | provider: ApiProvider, 8 | options: RouteOptions 9 | ): Endpoint => { 10 | return { 11 | path: "/analytics/pageChart", 12 | method: "post", 13 | handler: handler(provider, options), 14 | }; 15 | }; 16 | 17 | export default getPageChart; 18 | -------------------------------------------------------------------------------- /src/routes/getReport/handler.ts: -------------------------------------------------------------------------------- 1 | import type { Endpoint } from "payload/config"; 2 | import type { ApiProvider } from "../../providers"; 3 | import type { RouteOptions } from "../../types"; 4 | import type { Payload } from "payload"; 5 | import { dayInMinutes } from "../../utilities/timings"; 6 | import { differenceInMinutes } from "date-fns"; 7 | 8 | const handler = (provider: ApiProvider, options: RouteOptions) => { 9 | const handler: Endpoint["handler"] = async (req, res, next) => { 10 | const { user } = req; 11 | const payload: Payload = req.payload; 12 | const { property, metrics, timeframe } = req.body; 13 | const { access, cache } = options; 14 | 15 | if (access) { 16 | const accessControl = access(user); 17 | 18 | if (!accessControl) { 19 | payload.logger.error("📊 Analytics API: Request fails access control."); 20 | res 21 | .status(500) 22 | .send("Request fails access control. Are you authenticated?"); 23 | return next(); 24 | } 25 | } 26 | 27 | if (!metrics) { 28 | payload.logger.error("📊 Analytics API: Missing metrics argument."); 29 | res.status(500).send("Missing metrics argument."); 30 | return next(); 31 | } 32 | 33 | if (!property) { 34 | payload.logger.error("📊 Analytics API: Missing property argument."); 35 | res.status(500).send("Missing property argument."); 36 | return next(); 37 | } 38 | 39 | try { 40 | if (cache) { 41 | const timeNow = new Date(); 42 | const cacheKey = `report|${metrics.join("-")}|${ 43 | timeframe ?? "30d" 44 | }|${property}`; 45 | const cacheLifetime = options.cache?.routes?.report ?? dayInMinutes; 46 | 47 | const { 48 | docs: [cachedData], 49 | } = await payload.find({ 50 | collection: cache.slug, 51 | where: { 52 | and: [ 53 | { 54 | cacheKey: { 55 | equals: cacheKey, 56 | }, 57 | }, 58 | ], 59 | }, 60 | }); 61 | 62 | if (!cachedData) { 63 | const data = await provider 64 | .getReportData({ 65 | property, 66 | metrics, 67 | timeframe, 68 | }) 69 | .catch((error) => payload.logger.error(error)); 70 | 71 | await payload.create({ 72 | collection: cache.slug, 73 | data: { 74 | cacheKey: cacheKey, 75 | cacheTimestamp: timeNow.toISOString(), 76 | data: data, 77 | }, 78 | }); 79 | 80 | res.status(200).send(data); 81 | return next(); 82 | } 83 | 84 | if (cachedData) { 85 | if ( 86 | differenceInMinutes( 87 | timeNow, 88 | Date.parse(cachedData.cacheTimestamp) 89 | ) > cacheLifetime 90 | ) { 91 | const data = await provider 92 | .getReportData({ 93 | property, 94 | metrics, 95 | timeframe, 96 | }) 97 | .catch((error) => payload.logger.error(error)); 98 | 99 | await payload.update({ 100 | id: cachedData.id, 101 | collection: cache.slug, 102 | data: { 103 | cacheKey: cacheKey, 104 | cacheTimestamp: timeNow.toISOString(), 105 | data: data, 106 | }, 107 | }); 108 | 109 | res.status(200).send(data); 110 | return next(); 111 | } else { 112 | res.status(200).send(cachedData.data); 113 | return next(); 114 | } 115 | } 116 | } 117 | 118 | const data = await provider 119 | .getReportData({ 120 | property, 121 | metrics, 122 | timeframe, 123 | }) 124 | .catch((error) => payload.logger.error(error)); 125 | 126 | res.status(200).send(data); 127 | } catch (error) { 128 | payload.logger.error(error); 129 | res.status(500).send(`📊 Analytics API: ${error}`); 130 | return next(); 131 | } 132 | }; 133 | 134 | return handler; 135 | }; 136 | 137 | export default handler; 138 | -------------------------------------------------------------------------------- /src/routes/getReport/index.ts: -------------------------------------------------------------------------------- 1 | import type { Endpoint } from "payload/config"; 2 | import handler from "./handler"; 3 | import type { ApiProvider } from "../../providers"; 4 | import type { RouteOptions } from "../../types"; 5 | 6 | const getReport = (provider: ApiProvider, options: RouteOptions): Endpoint => { 7 | return { 8 | path: "/analytics/report", 9 | method: "post", 10 | handler: handler(provider, options), 11 | }; 12 | }; 13 | 14 | export default getReport; 15 | -------------------------------------------------------------------------------- /src/types/data.ts: -------------------------------------------------------------------------------- 1 | import type { Metrics, Properties } from "./widgets"; 2 | 3 | export interface ChartDataPoint { 4 | timestamp: string; 5 | value: number; 6 | } 7 | 8 | export interface ChartDataSeries { 9 | label: string; 10 | data: ChartDataPoint[]; 11 | } 12 | 13 | export type ChartData = ChartDataSeries[]; 14 | 15 | export type AggregateData = Array<{ 16 | label: Metrics; 17 | value: string | number; 18 | }>; 19 | 20 | export type LiveData = { 21 | visitors: number; 22 | }; 23 | 24 | type ReportDataIndex = { 25 | [label: string]: string; 26 | }; 27 | 28 | type ReportDataValues = { 29 | values: { [value: string]: string | number }[]; 30 | }; 31 | 32 | export type ReportData = (ReportDataIndex & ReportDataValues)[]; 33 | 34 | export type MetricsMap = Record; 35 | 36 | export type PropertiesMap = Record< 37 | Properties, 38 | { label: string; value: string } 39 | >; 40 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { PlausibleProvider, GoogleProvider } from "./providers"; 2 | import type { 3 | DashboardWidgets, 4 | PageWidgets, 5 | NavigationWidgets, 6 | } from "./widgets"; 7 | 8 | export interface ItemConfig { 9 | widgets: DashboardWidgets[]; 10 | } 11 | 12 | export interface PageItemConfig { 13 | widgets: PageWidgets[]; 14 | } 15 | 16 | export interface Collection extends PageItemConfig { 17 | slug: string; 18 | } 19 | 20 | export interface Global extends PageItemConfig { 21 | slug: string; 22 | } 23 | 24 | export type Provider = PlausibleProvider | GoogleProvider; 25 | 26 | export type AccessControl = (user: any) => boolean; 27 | 28 | export type CacheConfig = { 29 | slug: string; 30 | routes?: { 31 | globalAggregate?: number; 32 | globalChart?: number; 33 | pageAggregate?: number; 34 | pageChart?: number; 35 | report?: number; 36 | live?: number; 37 | }; 38 | }; 39 | 40 | export type RouteOptions = { 41 | access?: AccessControl; 42 | cache?: CacheConfig; 43 | }; 44 | 45 | export type DashboardAnalyticsConfig = { 46 | provider: Provider; 47 | access?: AccessControl; 48 | cache?: boolean | CacheConfig; 49 | collections?: Collection[]; 50 | globals?: Global[]; 51 | navigation?: { 52 | beforeNavLinks?: NavigationWidgets[]; 53 | afterNavLinks?: NavigationWidgets[]; 54 | }; 55 | dashboard?: { 56 | beforeDashboard?: DashboardWidgets[]; 57 | afterDashboard?: DashboardWidgets[]; 58 | }; 59 | }; 60 | -------------------------------------------------------------------------------- /src/types/providers.ts: -------------------------------------------------------------------------------- 1 | export interface PlausibleProvider { 2 | source: "plausible"; 3 | apiSecret: string; 4 | siteId: string; 5 | host?: string; 6 | } 7 | 8 | export interface GoogleProvider { 9 | source: "google"; 10 | propertyId: string; 11 | credentials?: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/types/widgets.ts: -------------------------------------------------------------------------------- 1 | export type Timeframes = "12mo" | "6mo" | "30d" | "7d" | "currentMonth"; 2 | 3 | export type IdMatcherFunction = (document: any) => string; 4 | 5 | export type Metrics = 6 | | "views" 7 | | "visitors" 8 | | "sessions" 9 | | "bounceRate" 10 | | "sessionDuration"; 11 | 12 | export type Properties = 13 | | "page" 14 | /* | "entryPoint" 15 | | "exitPoint" */ 16 | | "source" 17 | | "country"; 18 | 19 | /* Keeping this for later */ 20 | export type Reports = "topSources" | "topPages" | "topCountries"; 21 | 22 | export interface ChartWidget { 23 | type: "chart"; 24 | metrics: Metrics[]; 25 | timeframe?: Timeframes; 26 | label?: string | "hidden"; 27 | } 28 | 29 | export interface PageChartWidget extends ChartWidget { 30 | idMatcher: IdMatcherFunction; 31 | } 32 | 33 | export interface InfoWidget { 34 | type: "info"; 35 | label?: string | "hidden"; 36 | metrics: Metrics[]; 37 | timeframe?: Timeframes; 38 | } 39 | 40 | export interface LiveWidget { 41 | type: "live"; 42 | } 43 | 44 | export interface ReportWidget { 45 | type: "report"; 46 | report: Reports; 47 | property: Properties; 48 | metrics: Metrics[]; 49 | timeframe?: Timeframes; 50 | } 51 | 52 | export interface PageInfoWidget extends InfoWidget { 53 | idMatcher: IdMatcherFunction; 54 | } 55 | 56 | /* export type DashboardWidgets = ChartWidget | InfoWidget | ReportWidget; */ 57 | 58 | export type DashboardWidgets = "topPages" | "viewsChart"; 59 | 60 | export type NavigationWidgets = LiveWidget; 61 | 62 | export type PageWidgets = PageChartWidget | PageInfoWidget; 63 | -------------------------------------------------------------------------------- /src/utilities/timings.ts: -------------------------------------------------------------------------------- 1 | const hourInMinutes = 60; 2 | export const dayInMinutes = hourInMinutes * 24; 3 | -------------------------------------------------------------------------------- /src/utilities/widgetMaps.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | PageInfoWidget, 3 | PageChartWidget, 4 | PageWidgets, 5 | NavigationWidgets, 6 | DashboardWidgets, 7 | } from "../types/widgets"; 8 | import type { MetricsMap } from "../types/data"; 9 | import type { Field } from "payload/dist/fields/config/types"; 10 | import { getPageViewsChart } from "../components/Charts/PageViewsChart"; 11 | import { getAggregateDataWidget } from "../components/Aggregates/AggregateDataWidget"; 12 | import LiveDataComponent from "../components/Live/LiveDataWidget"; 13 | import GlobalViewsChart from "../components/Charts/GlobalViewsChart"; 14 | import TopPages from "../components/Reports/TopPages"; 15 | 16 | export const PageWidgetMap: Record< 17 | PageWidgets["type"], 18 | (config: any, index: number, metricsMap: MetricsMap) => Field 19 | > = { 20 | chart: (config: PageChartWidget, index: number, metricsMap: MetricsMap) => ({ 21 | type: "ui", 22 | name: `chart_${index}_${config.timeframe ?? "30d"}`, 23 | admin: { 24 | position: "sidebar", 25 | components: { 26 | Field: (props: any) => getPageViewsChart(metricsMap, props, config), 27 | }, 28 | }, 29 | }), 30 | info: (config: PageInfoWidget, index: number, metricsMap: MetricsMap) => ({ 31 | type: "ui", 32 | name: `info_${index}_${config.timeframe ?? "30d"}`, 33 | admin: { 34 | position: "sidebar", 35 | components: { 36 | Field: (props: any) => 37 | getAggregateDataWidget(metricsMap, props, config), 38 | }, 39 | }, 40 | }), 41 | }; 42 | 43 | export const NavigationWidgetMap: Record = 44 | { 45 | live: LiveDataComponent.LiveDataWidget, 46 | }; 47 | 48 | export const DashboardWidgetMap: Record = { 49 | topPages: TopPages, 50 | viewsChart: GlobalViewsChart, 51 | }; 52 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "outDir": "./dist", 5 | "allowJs": true, 6 | "module": "commonjs", 7 | "sourceMap": true, 8 | "jsx": "react", 9 | "esModuleInterop": true, 10 | "declaration": true, 11 | "declarationDir": "./dist", 12 | "skipLibCheck": true, 13 | "strict": true, 14 | }, 15 | "include": [ 16 | "src/**/*" 17 | ], 18 | } 19 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/types'; 2 | -------------------------------------------------------------------------------- /types.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/types'); 2 | --------------------------------------------------------------------------------