Merge pull request #2109 from umami-software/dev

v2.3.0
This commit is contained in:
Mike Cao 2023-07-11 19:57:30 -07:00 committed by GitHub
commit 7bfbe26485
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
417 changed files with 14719 additions and 5669 deletions

View File

@ -1,6 +1,5 @@
name: "✨ Feature Request"
name: '✨ Feature Request'
description: Create a feature or enhancement request for Umami.
labels: ['enhancement']
body:
- type: textarea
attributes:

View File

@ -26,9 +26,9 @@ jobs:
db-type: mysql
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'

View File

@ -1 +1 @@
<svg id="Layer_2" height="512" viewBox="0 0 30 30" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 2"><g fill="rgb(0,0,0)"><path d="m15 14a5.5 5.5 0 1 1 5.5-5.5 5.51 5.51 0 0 1 -5.5 5.5zm0-9a3.5 3.5 0 1 0 3.5 3.5 3.5 3.5 0 0 0 -3.5-3.5z"/><path d="m7.5 24.5a1 1 0 0 1 -1-1 8.5 8.5 0 0 1 13.6-6.8 1 1 0 1 1 -1.2 1.6 6.44 6.44 0 0 0 -3.9-1.3 6.51 6.51 0 0 0 -6.5 6.5 1 1 0 0 1 -1 1z"/><path d="m23 27a1 1 0 0 1 -1-1v-6a1 1 0 0 1 2 0v6a1 1 0 0 1 -1 1z"/><path d="m26 24h-6a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2z"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" data-name="Layer 2" viewBox="0 0 30 30"><path d="M15 14a5.5 5.5 0 1 1 5.5-5.5A5.51 5.51 0 0 1 15 14zm0-9a3.5 3.5 0 1 0 3.5 3.5A3.5 3.5 0 0 0 15 5zM7.5 24.5a1 1 0 0 1-1-1 8.5 8.5 0 0 1 13.6-6.8 1 1 0 1 1-1.2 1.6A6.44 6.44 0 0 0 15 17a6.51 6.51 0 0 0-6.5 6.5 1 1 0 0 1-1 1zM23 27a1 1 0 0 1-1-1v-6a1 1 0 0 1 2 0v6a1 1 0 0 1-1 1z"/><path d="M26 24h-6a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2z"/></svg>

Before

Width:  |  Height:  |  Size: 529 B

After

Width:  |  Height:  |  Size: 451 B

View File

@ -1 +1 @@
<svg height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><g clip-rule="evenodd"><path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12z"/><path d="M11.168 11.445a1 1 0 0 1 1.387-.277l3 2a1 1 0 0 1-1.11 1.664l-3-2a1 1 0 0 1-.277-1.387z"/><path d="M12 6a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V7a1 1 0 0 1 1-1z"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 24 24"><g clip-rule="evenodd"><path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12z"/><path d="M11.168 11.445a1 1 0 0 1 1.387-.277l3 2a1 1 0 0 1-1.11 1.664l-3-2a1 1 0 0 1-.277-1.387z"/><path d="M12 6a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V7a1 1 0 0 1 1-1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 400 B

After

Width:  |  Height:  |  Size: 400 B

View File

@ -1 +1 @@
<svg height="512pt" viewBox="0 0 512 512" width="512pt" xmlns="http://www.w3.org/2000/svg"><path d="M197.332 170.668h-160C16.746 170.668 0 153.922 0 133.332v-96C0 16.746 16.746 0 37.332 0h160c20.59 0 37.336 16.746 37.336 37.332v96c0 20.59-16.746 37.336-37.336 37.336zM37.332 32A5.336 5.336 0 0 0 32 37.332v96a5.337 5.337 0 0 0 5.332 5.336h160a5.338 5.338 0 0 0 5.336-5.336v-96A5.337 5.337 0 0 0 197.332 32zM197.332 512h-160C16.746 512 0 495.254 0 474.668v-224c0-20.59 16.746-37.336 37.332-37.336h160c20.59 0 37.336 16.746 37.336 37.336v224c0 20.586-16.746 37.332-37.336 37.332zm-160-266.668A5.337 5.337 0 0 0 32 250.668v224A5.336 5.336 0 0 0 37.332 480h160a5.337 5.337 0 0 0 5.336-5.332v-224a5.338 5.338 0 0 0-5.336-5.336zM474.668 512h-160c-20.59 0-37.336-16.746-37.336-37.332v-96c0-20.59 16.746-37.336 37.336-37.336h160c20.586 0 37.332 16.746 37.332 37.336v96C512 495.254 495.254 512 474.668 512zm-160-138.668a5.338 5.338 0 0 0-5.336 5.336v96a5.337 5.337 0 0 0 5.336 5.332h160a5.336 5.336 0 0 0 5.332-5.332v-96a5.337 5.337 0 0 0-5.332-5.336zM474.668 298.668h-160c-20.59 0-37.336-16.746-37.336-37.336v-224C277.332 16.746 294.078 0 314.668 0h160C495.254 0 512 16.746 512 37.332v224c0 20.59-16.746 37.336-37.332 37.336zM314.668 32a5.337 5.337 0 0 0-5.336 5.332v224a5.338 5.338 0 0 0 5.336 5.336h160a5.337 5.337 0 0 0 5.332-5.336v-224A5.336 5.336 0 0 0 474.668 32zm0 0"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512pt" height="512pt" viewBox="0 0 512 512"><path d="M197.332 170.668h-160C16.746 170.668 0 153.922 0 133.332v-96C0 16.746 16.746 0 37.332 0h160c20.59 0 37.336 16.746 37.336 37.332v96c0 20.59-16.746 37.336-37.336 37.336zM37.332 32A5.336 5.336 0 0 0 32 37.332v96a5.337 5.337 0 0 0 5.332 5.336h160a5.338 5.338 0 0 0 5.336-5.336v-96A5.337 5.337 0 0 0 197.332 32zm160 480h-160C16.746 512 0 495.254 0 474.668v-224c0-20.59 16.746-37.336 37.332-37.336h160c20.59 0 37.336 16.746 37.336 37.336v224c0 20.586-16.746 37.332-37.336 37.332zm-160-266.668A5.337 5.337 0 0 0 32 250.668v224A5.336 5.336 0 0 0 37.332 480h160a5.337 5.337 0 0 0 5.336-5.332v-224a5.338 5.338 0 0 0-5.336-5.336zM474.668 512h-160c-20.59 0-37.336-16.746-37.336-37.332v-96c0-20.59 16.746-37.336 37.336-37.336h160c20.586 0 37.332 16.746 37.332 37.336v96C512 495.254 495.254 512 474.668 512zm-160-138.668a5.338 5.338 0 0 0-5.336 5.336v96a5.337 5.337 0 0 0 5.336 5.332h160a5.336 5.336 0 0 0 5.332-5.332v-96a5.337 5.337 0 0 0-5.332-5.336zm160-74.664h-160c-20.59 0-37.336-16.746-37.336-37.336v-224C277.332 16.746 294.078 0 314.668 0h160C495.254 0 512 16.746 512 37.332v224c0 20.59-16.746 37.336-37.332 37.336zM314.668 32a5.337 5.337 0 0 0-5.336 5.332v224a5.338 5.338 0 0 0 5.336 5.336h160a5.337 5.337 0 0 0 5.332-5.336v-224A5.336 5.336 0 0 0 474.668 32zm0 0"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

1
assets/expand.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 0 48 48"><path d="M7.5 40.018v-10.5c0-1.379-1.12-2.5-2.5-2.5s-2.5 1.121-2.5 2.5v11a4.5 4.5 0 0 0 4.5 4.5h12a2.5 2.5 0 0 0 0-5zm33 0H29a2.5 2.5 0 0 0 0 5h12a4.5 4.5 0 0 0 4.5-4.5v-11c0-1.379-1.12-2.5-2.5-2.5s-2.5 1.121-2.5 2.5zm-33-33H19a2.5 2.5 0 0 0 0-5H7a4.5 4.5 0 0 0-4.5 4.5v11a2.5 2.5 0 0 0 5 0zm33 0v10.5a2.5 2.5 0 0 0 5 0v-11a4.5 4.5 0 0 0-4.5-4.5H29a2.5 2.5 0 0 0 0 5z"/></svg>

After

Width:  |  Height:  |  Size: 547 B

1
assets/funnel.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 32 32"><path d="M29 11H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h26a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1zM4 9h24V5H4z"/><path d="M25 17H7a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1zM8 15h16v-4H8z"/><path d="M22 23H10a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1zm-11-2h10v-4H11z"/><path d="M19 29h-6a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1zm-5-2h4v-4h-4z"/></svg>

After

Width:  |  Height:  |  Size: 487 B

1
assets/lightbulb.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="enable-background:new 0 0 512 512" viewBox="0 0 512 512"><path d="M223.718 124.76c-48.027 11.198-86.688 49.285-98.494 97.031-11.843 47.899 1.711 96.722 36.259 130.601C173.703 364.377 181 383.586 181 403.777V407c0 13.296 5.801 25.26 15 33.505V467c0 24.813 20.187 45 45 45h30c24.813 0 45-20.187 45-45v-26.495c9.199-8.245 15-20.208 15-33.505v-3.282c0-19.884 7.687-39.458 20.563-52.361C376.994 325.87 391 292.005 391 256c0-86.079-79.769-151.638-167.282-131.24zM286 467c0 8.271-6.729 15-15 15h-30c-8.271 0-15-6.729-15-15v-15h60v15zm44.326-136.834C311.689 348.843 301 375.651 301 403.718V407c0 8.271-6.729 15-15 15h-60c-8.271 0-15-6.729-15-15v-3.223c0-28.499-10.393-55.035-28.513-72.804-26.89-26.37-37.409-64.493-28.141-101.981 9.125-36.907 39.029-66.353 76.184-75.015C299.202 137.964 361 189.228 361 256c0 28.004-10.894 54.343-30.674 74.166zM139.327 118.114 96.9 75.688c-5.857-5.858-15.355-5.858-21.213 0-5.858 5.858-5.858 15.355 0 21.213l42.427 42.426c5.857 5.858 15.356 5.858 21.213 0 5.858-5.858 5.858-15.355 0-21.213zM76 241H15c-8.284 0-15 6.716-15 15s6.716 15 15 15h61c8.284 0 15-6.716 15-15s-6.716-15-15-15zm421 0h-61c-8.284 0-15 6.716-15 15s6.716 15 15 15h61c8.284 0 15-6.716 15-15s-6.716-15-15-15zM436.313 75.688c-5.856-5.858-15.354-5.858-21.213 0l-42.427 42.426c-5.858 5.857-5.858 15.355 0 21.213 5.857 5.858 15.355 5.858 21.213 0l42.427-42.426c5.858-5.857 5.858-15.355 0-21.213zM256 0c-8.284 0-15 6.716-15 15v61c0 8.284 6.716 15 15 15s15-6.716 15-15V15c0-8.284-6.716-15-15-15z"/><path d="M256 181c-6.166 0-12.447.739-18.658 2.194-25.865 6.037-47.518 27.328-53.879 52.979-1.994 8.041 2.907 16.175 10.947 18.17 8.042 1.994 16.176-2.909 18.17-10.948 3.661-14.758 16.647-27.5 31.593-30.989 3.982-.933 7.962-1.406 11.827-1.406 8.284 0 15-6.716 15-15s-6.716-15-15-15z"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -1 +1 @@
<svg height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M18.75 9H18V6c0-3.309-2.691-6-6-6S6 2.691 6 6v3h-.75A2.253 2.253 0 0 0 3 11.25v10.5C3 22.991 4.01 24 5.25 24h13.5c1.24 0 2.25-1.009 2.25-2.25v-10.5C21 10.009 19.99 9 18.75 9zM8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v3H8zm5 10.722V19a1 1 0 1 1-2 0v-2.278c-.595-.347-1-.985-1-1.722 0-1.103.897-2 2-2s2 .897 2 2c0 .737-.405 1.375-1 1.722z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 24 24"><path d="M18.75 9H18V6c0-3.309-2.691-6-6-6S6 2.691 6 6v3h-.75A2.253 2.253 0 0 0 3 11.25v10.5C3 22.991 4.01 24 5.25 24h13.5c1.24 0 2.25-1.009 2.25-2.25v-10.5C21 10.009 19.99 9 18.75 9zM8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v3H8zm5 10.722V19a1 1 0 1 1-2 0v-2.278c-.595-.347-1-.985-1-1.722 0-1.103.897-2 2-2s2 .897 2 2c0 .737-.405 1.375-1 1.722z"/></svg>

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 433 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 389.11" width="20" height="20"><circle cx="214.15" cy="181" r="171" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="20"/><path d="M413 134.11H15.29a15 15 0 0 0-15 15v15.3C.12 168 0 171.52 0 175.11c0 118.19 95.81 214 214 214 116.4 0 211.1-92.94 213.93-208.67 0-.44.07-.88.07-1.33v-30a15 15 0 0 0-15-15Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 428 389.11"><circle cx="214.15" cy="181" r="171" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="20"/><path d="M413 134.11H15.29a15 15 0 0 0-15 15v15.3C.12 168 0 171.52 0 175.11c0 118.19 95.81 214 214 214 116.4 0 211.1-92.94 213.93-208.67 0-.44.07-.88.07-1.33v-30a15 15 0 0 0-15-15Z"/></svg>

Before

Width:  |  Height:  |  Size: 390 B

After

Width:  |  Height:  |  Size: 390 B

1
assets/nodes.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M19 9.874A4.002 4.002 0 0 0 18 2a4.002 4.002 0 0 0-3.874 3H9.874A4.002 4.002 0 0 0 2 6a4.002 4.002 0 0 0 3 3.874v4.252A4.002 4.002 0 0 0 6 22a4.002 4.002 0 0 0 3.874-3h4.252A4.002 4.002 0 0 0 22 18a4.002 4.002 0 0 0-3-3.874zM6 4a2 2 0 1 1 0 4 2 2 0 0 1 0-4zm3.874 3A4.007 4.007 0 0 1 7 9.874v4.252A4.007 4.007 0 0 1 9.874 17h4.252A4.007 4.007 0 0 1 17 14.126V9.874A4.007 4.007 0 0 1 14.126 7zM18 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM8 18a2 2 0 1 0-4 0 2 2 0 0 0 4 0z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 636 B

1
assets/overview.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M452 36H60C26.916 36 0 62.916 0 96v240c0 33.084 26.916 60 60 60h176v40H132v40h248v-40H276v-40h176c33.084 0 60-26.916 60-60V96c0-33.084-26.916-60-60-60zm20 300c0 11.028-8.972 20-20 20H60c-11.028 0-20-8.972-20-20V96c0-11.028 8.972-20 20-20h392c11.028 0 20 8.972 20 20v240z"/></svg>

After

Width:  |  Height:  |  Size: 371 B

View File

@ -1 +1 @@
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M437.02 74.98C388.668 26.63 324.379 0 256 0S123.332 26.629 74.98 74.98C26.63 123.332 0 187.621 0 256s26.629 132.668 74.98 181.02C123.332 485.37 187.621 512 256 512s132.668-26.629 181.02-74.98C485.37 388.668 512 324.379 512 256s-26.629-132.668-74.98-181.02zM111.105 429.297c8.454-72.735 70.989-128.89 144.895-128.89 38.96 0 75.598 15.179 103.156 42.734 23.281 23.285 37.965 53.687 41.742 86.152C361.641 462.172 311.094 482 256 482s-105.637-19.824-144.895-52.703zM256 269.507c-42.871 0-77.754-34.882-77.754-77.753C178.246 148.879 213.13 114 256 114s77.754 34.879 77.754 77.754c0 42.871-34.883 77.754-77.754 77.754zm170.719 134.427a175.9 175.9 0 0 0-46.352-82.004c-18.437-18.438-40.25-32.27-64.039-40.938 28.598-19.394 47.426-52.16 47.426-89.238C363.754 132.34 315.414 84 256 84s-107.754 48.34-107.754 107.754c0 37.098 18.844 69.875 47.465 89.266-21.887 7.976-42.14 20.308-59.566 36.542-25.235 23.5-42.758 53.465-50.883 86.348C50.852 364.242 30 312.512 30 256 30 131.383 131.383 30 256 30s226 101.383 226 226c0 56.523-20.86 108.266-55.281 147.934zm0 0"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M437.02 74.98C388.668 26.63 324.379 0 256 0S123.332 26.629 74.98 74.98C26.63 123.332 0 187.621 0 256s26.629 132.668 74.98 181.02C123.332 485.37 187.621 512 256 512s132.668-26.629 181.02-74.98C485.37 388.668 512 324.379 512 256s-26.629-132.668-74.98-181.02zM111.105 429.297c8.454-72.735 70.989-128.89 144.895-128.89 38.96 0 75.598 15.179 103.156 42.734 23.281 23.285 37.965 53.687 41.742 86.152C361.641 462.172 311.094 482 256 482s-105.637-19.824-144.895-52.703zM256 269.507c-42.871 0-77.754-34.882-77.754-77.753C178.246 148.879 213.13 114 256 114s77.754 34.879 77.754 77.754c0 42.871-34.883 77.754-77.754 77.754zm170.719 134.427a175.9 175.9 0 0 0-46.352-82.004c-18.437-18.438-40.25-32.27-64.039-40.938 28.598-19.394 47.426-52.16 47.426-89.238C363.754 132.34 315.414 84 256 84s-107.754 48.34-107.754 107.754c0 37.098 18.844 69.875 47.465 89.266-21.887 7.976-42.14 20.308-59.566 36.542-25.235 23.5-42.758 53.465-50.883 86.348C50.852 364.242 30 312.512 30 256 30 131.383 131.383 30 256 30s226 101.383 226 226c0 56.523-20.86 108.266-55.281 147.934zm0 0"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

1
assets/reports.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M61.17 18.91A32 32 0 1 0 46.4 60.54l.15-.06.16-.1a31.93 31.93 0 0 0 14.47-41.44s-.01-.02-.01-.03zm-4.53-.16L34 28.91V4.1a28 28 0 0 1 22.64 14.65zM4 32A28 28 0 0 1 30 4.1V32a1.74 1.74 0 0 0 0 .39.17.17 0 0 0 0 .07 1.49 1.49 0 0 0 .15.4l12.76 24.9A28 28 0 0 1 4 32zm42.47 23.94L34.74 33l23.54-10.6a28 28 0 0 1-11.81 33.54z"/></svg>

After

Width:  |  Height:  |  Size: 398 B

View File

@ -1 +1 @@
<svg height="512pt" viewBox="-56 0 512 512" width="512pt" xmlns="http://www.w3.org/2000/svg"><path d="M267 236.375c36.254-22.582 60.434-62.797 60.434-108.563C327.434 57.337 270.098 0 199.62 0 129.145 0 71.81 57.336 71.81 127.813c0 45.765 24.18 85.976 60.43 108.558C55.222 264.071 0 337.84 0 424.273v72.243C0 505.066 6.934 512 15.484 512H383.75c8.55 0 15.48-6.934 15.48-15.484v-72.243c0-86.43-55.218-160.195-132.23-187.898zm101.266 244.656H30.969v-56.758c0-92.992 75.652-168.644 168.648-168.644 92.992 0 168.649 75.652 168.649 168.644zm-71.801-353.219c0 53.403-43.442 96.848-96.844 96.848s-96.844-43.445-96.844-96.847c0-53.399 43.442-96.844 96.844-96.844s96.844 43.445 96.844 96.844zm0 0"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512pt" height="512pt" viewBox="-56 0 512 512"><path d="M267 236.375c36.254-22.582 60.434-62.797 60.434-108.563C327.434 57.337 270.098 0 199.62 0 129.145 0 71.81 57.336 71.81 127.813c0 45.765 24.18 85.976 60.43 108.558C55.222 264.071 0 337.84 0 424.273v72.243C0 505.066 6.934 512 15.484 512H383.75c8.55 0 15.48-6.934 15.48-15.484v-72.243c0-86.43-55.218-160.195-132.23-187.898zm101.266 244.656H30.969v-56.758c0-92.992 75.652-168.644 168.648-168.644 92.992 0 168.649 75.652 168.649 168.644zm-71.801-353.219c0 53.403-43.442 96.848-96.844 96.848s-96.844-43.445-96.844-96.847c0-53.399 43.442-96.844 96.844-96.844s96.844 43.445 96.844 96.844zm0 0"/></svg>

Before

Width:  |  Height:  |  Size: 695 B

After

Width:  |  Height:  |  Size: 695 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 477.869 477.869" style="enable-background:new 0 0 477.869 477.869" xml:space="preserve"><path d="M387.415 233.496c48.976-44.029 52.987-119.424 8.958-168.4C355.991 20.177 288.4 12.546 239.02 47.332c-53.83-37.99-128.264-25.149-166.254 28.68-34.859 49.393-27.259 117.054 17.689 157.483C34.606 262.935-.251 320.976.002 384.108v51.2c0 9.426 7.641 17.067 17.067 17.067h443.733c9.426 0 17.067-7.641 17.067-17.067v-51.2c.252-63.132-34.605-121.173-90.454-150.612zM307.201 59.842c47.062-.052 85.256 38.057 85.309 85.119.037 33.564-19.631 64.023-50.237 77.799-1.314.597-2.628 1.143-3.959 1.707a83.66 83.66 0 0 1-12.988 4.045c-.853.188-1.707.29-2.577.461a85.366 85.366 0 0 1-15.019 1.519c-2.27 0-4.557-.171-6.827-.375-.853 0-1.707 0-2.56-.171a86.219 86.219 0 0 1-27.904-8.226c-.324-.154-.7-.137-1.024-.273-1.707-.819-3.413-1.536-4.932-2.458.137-.171.222-.358.358-.529a119.721 119.721 0 0 0 18.278-33.297l.529-1.434a120.381 120.381 0 0 0 4.523-17.562c.154-.87.273-1.707.41-2.645.987-6.067 1.506-12.2 1.553-18.347a120.041 120.041 0 0 0-1.553-18.313c-.137-.887-.256-1.707-.41-2.645a120.414 120.414 0 0 0-4.523-17.562l-.529-1.434a119.747 119.747 0 0 0-18.278-33.297c-.137-.171-.222-.358-.358-.529a84.787 84.787 0 0 1 42.718-11.553zM85.335 145.176c-.121-47.006 37.886-85.21 84.892-85.331a85.112 85.112 0 0 1 59.134 23.686c.99.956 1.963 1.911 2.918 2.901a87.748 87.748 0 0 1 8.09 9.813c.751 1.058 1.434 2.185 2.133 3.277a83.951 83.951 0 0 1 6.263 11.52c.427.973.751 1.963 1.126 2.935a83.422 83.422 0 0 1 4.233 13.653c.12.512.154 1.024.256 1.553a80.338 80.338 0 0 1 0 32.119c-.102.529-.137 1.041-.256 1.553a83.228 83.228 0 0 1-4.233 13.653c-.375.973-.7 1.963-1.126 2.935a84.251 84.251 0 0 1-6.263 11.503c-.7 1.092-1.382 2.219-2.133 3.277a87.549 87.549 0 0 1-8.09 9.813c-.956.99-1.929 1.946-2.918 2.901a85.187 85.187 0 0 1-23.569 15.906 49.35 49.35 0 0 1-4.198 1.707 85.839 85.839 0 0 1-12.663 3.925c-1.075.239-2.185.375-3.277.563a84.67 84.67 0 0 1-14.046 1.417h-1.877a84.563 84.563 0 0 1-14.046-1.417c-1.092-.188-2.202-.324-3.277-.563a85.802 85.802 0 0 1-12.663-3.925c-1.417-.563-2.816-1.143-4.198-1.707-30.534-13.786-50.173-44.166-50.212-77.667zm221.866 273.066H34.135v-34.133c-.25-57.833 36.188-109.468 90.76-128.614a119.092 119.092 0 0 0 91.546 0 137.138 137.138 0 0 1 16.623 7.356c3.55 1.826 6.827 3.908 10.24 6.007 2.219 1.382 4.471 2.731 6.605 4.25 3.294 2.338 6.4 4.881 9.455 7.492 1.963 1.707 3.908 3.413 5.751 5.12 2.816 2.662 5.461 5.478 8.004 8.363a134.465 134.465 0 0 1 5.291 6.383 132.594 132.594 0 0 1 6.349 8.823c1.707 2.56 3.226 5.222 4.727 7.885 1.707 2.935 3.277 5.871 4.71 8.926 1.434 3.055 2.697 6.4 3.925 9.66 1.075 2.833 2.219 5.649 3.106 8.533 1.195 3.959 2.031 8.055 2.867 12.151.512 2.423 1.178 4.796 1.553 7.253a141.153 141.153 0 0 1 1.553 20.412v34.133zm136.534 0h-102.4v-34.133c0-5.342-.307-10.633-.785-15.872-.137-1.536-.375-3.055-.546-4.591-.461-3.772-.99-7.509-1.707-11.213a246.936 246.936 0 0 0-.973-4.762c-.819-3.8-1.769-7.566-2.85-11.298-.358-1.229-.683-2.475-1.058-3.686a169.105 169.105 0 0 0-20.565-43.127l-.666-.973a168.958 168.958 0 0 0-9.404-12.646l-.119-.154a154.895 154.895 0 0 0-11.008-12.237h.7a120.8 120.8 0 0 0 14.524 1.024h.939c4.496-.039 8.985-.33 13.449-.87 1.399-.171 2.782-.427 4.181-.649a117.43 117.43 0 0 0 10.752-2.167c1.007-.256 2.031-.495 3.055-.785a116.211 116.211 0 0 0 13.653-4.642c54.612 19.127 91.083 70.785 90.829 128.649v34.132z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="enable-background:new 0 0 477.869 477.869" viewBox="0 0 477.869 477.869"><path d="M387.415 233.496c48.976-44.029 52.987-119.424 8.958-168.4C355.991 20.177 288.4 12.546 239.02 47.332c-53.83-37.99-128.264-25.149-166.254 28.68-34.859 49.393-27.259 117.054 17.689 157.483C34.606 262.935-.251 320.976.002 384.108v51.2c0 9.426 7.641 17.067 17.067 17.067h443.733c9.426 0 17.067-7.641 17.067-17.067v-51.2c.252-63.132-34.605-121.173-90.454-150.612zM307.201 59.842c47.062-.052 85.256 38.057 85.309 85.119.037 33.564-19.631 64.023-50.237 77.799-1.314.597-2.628 1.143-3.959 1.707a83.66 83.66 0 0 1-12.988 4.045c-.853.188-1.707.29-2.577.461a85.366 85.366 0 0 1-15.019 1.519c-2.27 0-4.557-.171-6.827-.375-.853 0-1.707 0-2.56-.171a86.219 86.219 0 0 1-27.904-8.226c-.324-.154-.7-.137-1.024-.273-1.707-.819-3.413-1.536-4.932-2.458.137-.171.222-.358.358-.529a119.721 119.721 0 0 0 18.278-33.297l.529-1.434a120.381 120.381 0 0 0 4.523-17.562c.154-.87.273-1.707.41-2.645.987-6.067 1.506-12.2 1.553-18.347a120.041 120.041 0 0 0-1.553-18.313c-.137-.887-.256-1.707-.41-2.645a120.414 120.414 0 0 0-4.523-17.562l-.529-1.434a119.747 119.747 0 0 0-18.278-33.297c-.137-.171-.222-.358-.358-.529a84.787 84.787 0 0 1 42.718-11.553zM85.335 145.176c-.121-47.006 37.886-85.21 84.892-85.331a85.112 85.112 0 0 1 59.134 23.686c.99.956 1.963 1.911 2.918 2.901a87.748 87.748 0 0 1 8.09 9.813c.751 1.058 1.434 2.185 2.133 3.277a83.951 83.951 0 0 1 6.263 11.52c.427.973.751 1.963 1.126 2.935a83.422 83.422 0 0 1 4.233 13.653c.12.512.154 1.024.256 1.553a80.338 80.338 0 0 1 0 32.119c-.102.529-.137 1.041-.256 1.553a83.228 83.228 0 0 1-4.233 13.653c-.375.973-.7 1.963-1.126 2.935a84.251 84.251 0 0 1-6.263 11.503c-.7 1.092-1.382 2.219-2.133 3.277a87.549 87.549 0 0 1-8.09 9.813c-.956.99-1.929 1.946-2.918 2.901a85.187 85.187 0 0 1-23.569 15.906 49.35 49.35 0 0 1-4.198 1.707 85.839 85.839 0 0 1-12.663 3.925c-1.075.239-2.185.375-3.277.563a84.67 84.67 0 0 1-14.046 1.417h-1.877a84.563 84.563 0 0 1-14.046-1.417c-1.092-.188-2.202-.324-3.277-.563a85.802 85.802 0 0 1-12.663-3.925c-1.417-.563-2.816-1.143-4.198-1.707-30.534-13.786-50.173-44.166-50.212-77.667zm221.866 273.066H34.135v-34.133c-.25-57.833 36.188-109.468 90.76-128.614a119.092 119.092 0 0 0 91.546 0 137.138 137.138 0 0 1 16.623 7.356c3.55 1.826 6.827 3.908 10.24 6.007 2.219 1.382 4.471 2.731 6.605 4.25 3.294 2.338 6.4 4.881 9.455 7.492 1.963 1.707 3.908 3.413 5.751 5.12 2.816 2.662 5.461 5.478 8.004 8.363a134.465 134.465 0 0 1 5.291 6.383 132.594 132.594 0 0 1 6.349 8.823c1.707 2.56 3.226 5.222 4.727 7.885 1.707 2.935 3.277 5.871 4.71 8.926 1.434 3.055 2.697 6.4 3.925 9.66 1.075 2.833 2.219 5.649 3.106 8.533 1.195 3.959 2.031 8.055 2.867 12.151.512 2.423 1.178 4.796 1.553 7.253a141.153 141.153 0 0 1 1.553 20.412v34.133zm136.534 0h-102.4v-34.133c0-5.342-.307-10.633-.785-15.872-.137-1.536-.375-3.055-.546-4.591-.461-3.772-.99-7.509-1.707-11.213a246.936 246.936 0 0 0-.973-4.762c-.819-3.8-1.769-7.566-2.85-11.298-.358-1.229-.683-2.475-1.058-3.686a169.105 169.105 0 0 0-20.565-43.127l-.666-.973a168.958 168.958 0 0 0-9.404-12.646l-.119-.154a154.895 154.895 0 0 0-11.008-12.237h.7a120.8 120.8 0 0 0 14.524 1.024h.939c4.496-.039 8.985-.33 13.449-.87 1.399-.171 2.782-.427 4.181-.649a117.43 117.43 0 0 0 10.752-2.167c1.007-.256 2.031-.495 3.055-.785a116.211 116.211 0 0 0 13.653-4.642c54.612 19.127 91.083 70.785 90.829 128.649v34.132z"/></svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 511.999 511.999" style="enable-background:new 0 0 511.999 511.999" xml:space="preserve"><path d="M437.019 74.981C388.667 26.628 324.38 0 256 0 187.62 0 123.332 26.628 74.981 74.98 26.628 123.332 0 187.62 0 256s26.628 132.667 74.981 181.019c48.351 48.352 112.639 74.98 181.019 74.98 68.381 0 132.667-26.628 181.02-74.981C485.371 388.667 512 324.379 512 255.999s-26.629-132.667-74.981-181.018zM96.216 96.216c22.511-22.511 48.938-39.681 77.742-50.888-7.672 9.578-14.851 20.587-21.43 32.969-7.641 14.38-14.234 30.173-19.725 47.042-19.022-3.157-36.647-7.039-52.393-11.595a230.423 230.423 0 0 1 15.806-17.528zm-33.987 43.369c18.417 5.897 39.479 10.87 62.461 14.809-6.4 27.166-10.167 56.399-11.066 86.591H30.536c2.36-36.233 13.242-70.813 31.693-101.4zm-1.635 230.053c-17.455-29.899-27.769-63.481-30.059-98.623h83.146c.982 29.329 4.674 57.731 10.858 84.186-23.454 3.802-45.045 8.649-63.945 14.437zm35.622 46.146a229.917 229.917 0 0 1-17.831-20.055c16.323-4.526 34.571-8.359 54.214-11.433 5.53 17.103 12.194 33.105 19.928 47.662 7.17 13.493 15.053 25.349 23.51 35.505-29.61-11.183-56.769-28.629-79.821-51.679zm144.768 62.331c-22.808-6.389-44.384-27.217-61.936-60.249-6.139-11.552-11.531-24.155-16.15-37.587 24.73-2.722 51.045-4.331 78.086-4.709v102.545zm0-132.578c-29.988.409-59.217 2.292-86.59 5.507-6.038-24.961-9.671-51.978-10.668-80.028h97.259v74.521zm0-104.553h-97.315c.911-28.834 4.602-56.605 10.828-82.201 27.198 3.4 56.366 5.468 86.487 6.06v76.141zm0-106.176c-27.146-.547-53.403-2.317-77.958-5.205 4.591-13.292 9.941-25.768 16.022-37.215 17.551-33.032 39.128-53.86 61.936-60.249v102.669zm209.733 6.372c17.874 30.193 28.427 64.199 30.749 99.804h-83.088c-.889-29.844-4.584-58.749-10.85-85.647 23.133-3.736 44.456-8.489 63.189-14.157zm-34.934-44.964a230.122 230.122 0 0 1 16.914 18.91c-16.073 4.389-33.972 8.114-53.204 11.112-5.548-17.208-12.243-33.305-20.02-47.941-6.579-12.382-13.758-23.391-21.43-32.969 28.802 11.207 55.23 28.377 77.74 50.888zm-144.767 174.8h97.259c-1.004 28.268-4.686 55.49-10.81 80.612-27.194-3.381-56.349-5.43-86.449-6.006v-74.606zm0-30.032v-76.041c30.005-.394 59.257-2.261 86.656-5.464 6.125 25.403 9.756 52.932 10.659 81.505h-97.315zm-.002-208.845h.001c22.808 6.389 44.384 27.217 61.936 60.249 6.178 11.627 11.601 24.318 16.24 37.848-24.763 2.712-51.108 4.309-78.177 4.674V32.139zm.002 445.976V375.657c27.12.532 53.357 2.286 77.903 5.156-4.579 13.232-9.911 25.654-15.967 37.053-17.552 33.032-39.128 53.86-61.936 60.249zm144.767-62.331c-23.051 23.051-50.21 40.496-79.821 51.678 8.457-10.156 16.34-22.011 23.51-35.504 7.62-14.341 14.198-30.088 19.68-46.906 19.465 3.213 37.473 7.186 53.515 11.859a230.268 230.268 0 0 1-16.884 18.873zm34.823-44.775c-18.635-5.991-40-11.032-63.326-15.01 6.296-26.68 10.048-55.36 11.041-84.983h83.146c-2.328 35.678-12.918 69.753-30.861 99.993z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="enable-background:new 0 0 511.999 511.999" viewBox="0 0 511.999 511.999"><path d="M437.019 74.981C388.667 26.628 324.38 0 256 0 187.62 0 123.332 26.628 74.981 74.98 26.628 123.332 0 187.62 0 256s26.628 132.667 74.981 181.019c48.351 48.352 112.639 74.98 181.019 74.98 68.381 0 132.667-26.628 181.02-74.981C485.371 388.667 512 324.379 512 255.999s-26.629-132.667-74.981-181.018zM96.216 96.216c22.511-22.511 48.938-39.681 77.742-50.888-7.672 9.578-14.851 20.587-21.43 32.969-7.641 14.38-14.234 30.173-19.725 47.042-19.022-3.157-36.647-7.039-52.393-11.595a230.423 230.423 0 0 1 15.806-17.528zm-33.987 43.369c18.417 5.897 39.479 10.87 62.461 14.809-6.4 27.166-10.167 56.399-11.066 86.591H30.536c2.36-36.233 13.242-70.813 31.693-101.4zm-1.635 230.053c-17.455-29.899-27.769-63.481-30.059-98.623h83.146c.982 29.329 4.674 57.731 10.858 84.186-23.454 3.802-45.045 8.649-63.945 14.437zm35.622 46.146a229.917 229.917 0 0 1-17.831-20.055c16.323-4.526 34.571-8.359 54.214-11.433 5.53 17.103 12.194 33.105 19.928 47.662 7.17 13.493 15.053 25.349 23.51 35.505-29.61-11.183-56.769-28.629-79.821-51.679zm144.768 62.331c-22.808-6.389-44.384-27.217-61.936-60.249-6.139-11.552-11.531-24.155-16.15-37.587 24.73-2.722 51.045-4.331 78.086-4.709v102.545zm0-132.578c-29.988.409-59.217 2.292-86.59 5.507-6.038-24.961-9.671-51.978-10.668-80.028h97.259v74.521zm0-104.553h-97.315c.911-28.834 4.602-56.605 10.828-82.201 27.198 3.4 56.366 5.468 86.487 6.06v76.141zm0-106.176c-27.146-.547-53.403-2.317-77.958-5.205 4.591-13.292 9.941-25.768 16.022-37.215 17.551-33.032 39.128-53.86 61.936-60.249v102.669zm209.733 6.372c17.874 30.193 28.427 64.199 30.749 99.804h-83.088c-.889-29.844-4.584-58.749-10.85-85.647 23.133-3.736 44.456-8.489 63.189-14.157zm-34.934-44.964a230.122 230.122 0 0 1 16.914 18.91c-16.073 4.389-33.972 8.114-53.204 11.112-5.548-17.208-12.243-33.305-20.02-47.941-6.579-12.382-13.758-23.391-21.43-32.969 28.802 11.207 55.23 28.377 77.74 50.888zm-144.767 174.8h97.259c-1.004 28.268-4.686 55.49-10.81 80.612-27.194-3.381-56.349-5.43-86.449-6.006v-74.606zm0-30.032v-76.041c30.005-.394 59.257-2.261 86.656-5.464 6.125 25.403 9.756 52.932 10.659 81.505h-97.315zm-.002-208.845h.001c22.808 6.389 44.384 27.217 61.936 60.249 6.178 11.627 11.601 24.318 16.24 37.848-24.763 2.712-51.108 4.309-78.177 4.674V32.139zm.002 445.976V375.657c27.12.532 53.357 2.286 77.903 5.156-4.579 13.232-9.911 25.654-15.967 37.053-17.552 33.032-39.128 53.86-61.936 60.249zm144.767-62.331c-23.051 23.051-50.21 40.496-79.821 51.678 8.457-10.156 16.34-22.011 23.51-35.504 7.62-14.341 14.198-30.088 19.68-46.906 19.465 3.213 37.473 7.186 53.515 11.859a230.268 230.268 0 0 1-16.884 18.873zm34.823-44.775c-18.635-5.991-40-11.032-63.326-15.01 6.296-26.68 10.048-55.36 11.041-84.983h83.146c-2.328 35.678-12.918 69.753-30.861 99.993z"/></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -1,15 +1,15 @@
import classNames from 'classnames';
import styles from './NoData.module.css';
import styles from './Empty.module.css';
import useMessages from 'hooks/useMessages';
export function NoData({ className }) {
export function Empty({ message, className }) {
const { formatMessage, messages } = useMessages();
return (
<div className={classNames(styles.container, className)}>
{formatMessage(messages.noDataAvailable)}
{message || formatMessage(messages.noDataAvailable)}
</div>
);
}
export default NoData;
export default Empty;

View File

@ -15,7 +15,6 @@ export function HamburgerButton() {
label: formatMessage(labels.dashboard),
url: '/dashboard',
},
{ label: formatMessage(labels.realtime), url: '/realtime' },
!cloudMode && {
label: formatMessage(labels.settings),
url: '/settings',

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Tooltip } from 'react-basics';
import styles from './HoverTooltip.module.css';
export function HoverTooltip({ tooltip }) {
export function HoverTooltip({ children }) {
const [position, setPosition] = useState({ x: -1000, y: -1000 });
useEffect(() => {
@ -18,9 +18,9 @@ export function HoverTooltip({ tooltip }) {
}, []);
return (
<div className={styles.tooltip} style={{ left: position.x, top: position.y }}>
<Tooltip position="top" action="none" label={tooltip} />
</div>
<Tooltip className={styles.tooltip} style={{ left: position.x, top: position.y }}>
{children}
</Tooltip>
);
}

View File

@ -1,43 +1,6 @@
.chart {
position: relative;
}
.tooltip {
position: fixed;
pointer-events: none;
z-index: var(--z-index-popup);
}
.content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
.title {
font-size: var(--font-size-xs);
font-weight: 600;
}
.metric {
display: flex;
justify-content: center;
align-items: center;
font-size: var(--font-size-sm);
font-weight: 600;
}
.dot {
position: relative;
overflow: hidden;
border-radius: 100%;
margin-right: 8px;
background: var(--base50);
}
.color {
width: 10px;
height: 10px;
transform: translate(-50%, calc(-100% - 5px));
}

View File

@ -4,7 +4,7 @@ import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simp
import classNames from 'classnames';
import { colord } from 'colord';
import HoverTooltip from 'components/common/HoverTooltip';
import { ISO_COUNTRIES, THEME_COLORS, MAP_FILE } from 'lib/constants';
import { ISO_COUNTRIES, MAP_FILE } from 'lib/constants';
import useTheme from 'hooks/useTheme';
import useCountryNames from 'hooks/useCountryNames';
import useLocale from 'hooks/useLocale';
@ -14,17 +14,8 @@ import styles from './WorldMap.module.css';
export function WorldMap({ data, className }) {
const { basePath } = useRouter();
const [tooltip, setTooltip] = useState();
const [theme] = useTheme();
const colors = useMemo(
() => ({
baseColor: THEME_COLORS[theme].primary,
fillColor: THEME_COLORS[theme].gray100,
strokeColor: THEME_COLORS[theme].primary,
hoverColor: THEME_COLORS[theme].primary,
}),
[theme],
);
const [tooltip, setTooltipPopup] = useState();
const { theme, colors } = useTheme();
const { locale } = useLocale();
const countryNames = useCountryNames(locale);
const metrics = useMemo(() => (data ? percentFilter(data) : []), [data]);
@ -34,10 +25,10 @@ export function WorldMap({ data, className }) {
const country = metrics?.find(({ x }) => x === code);
if (!country) {
return colors.fillColor;
return colors.map.fillColor;
}
return colord(colors.baseColor)
return colord(colors.map.baseColor)
[theme === 'light' ? 'lighten' : 'darken'](0.4 * (1.0 - country.z / 100))
.toHex();
}
@ -49,7 +40,7 @@ export function WorldMap({ data, className }) {
function handleHover(code) {
if (code === 'AQ') return;
const country = metrics?.find(({ x }) => x === code);
setTooltip(`${countryNames[code]}: ${formatLongNumber(country?.y || 0)} visitors`);
setTooltipPopup(`${countryNames[code]}: ${formatLongNumber(country?.y || 0)} visitors`);
}
return (
@ -70,15 +61,15 @@ export function WorldMap({ data, className }) {
key={geo.rsmKey}
geography={geo}
fill={getFillColor(code)}
stroke={colors.strokeColor}
stroke={colors.map.strokeColor}
opacity={getOpacity(code)}
style={{
default: { outline: 'none' },
hover: { outline: 'none', fill: colors.hoverColor },
hover: { outline: 'none', fill: colors.map.hoverColor },
pressed: { outline: 'none' },
}}
onMouseOver={() => handleHover(code)}
onMouseOut={() => setTooltip(null)}
onMouseOut={() => setTooltipPopup(null)}
/>
);
});
@ -86,7 +77,7 @@ export function WorldMap({ data, className }) {
</Geographies>
</ZoomableGroup>
</ComposableMap>
{tooltip && <HoverTooltip tooltip={tooltip} />}
{tooltip && <HoverTooltip>{tooltip}</HoverTooltip>}
</div>
);
}

View File

@ -10,7 +10,10 @@ import Globe from 'assets/globe.svg';
import Lock from 'assets/lock.svg';
import Logo from 'assets/logo.svg';
import Moon from 'assets/moon.svg';
import Nodes from 'assets/nodes.svg';
import Overview from 'assets/overview.svg';
import Profile from 'assets/profile.svg';
import Reports from 'assets/reports.svg';
import Sun from 'assets/sun.svg';
import User from 'assets/user.svg';
import Users from 'assets/users.svg';
@ -29,7 +32,10 @@ const icons = {
Lock,
Logo,
Moon,
Nodes,
Overview,
Profile,
Reports,
Sun,
User,
Users,

View File

@ -3,31 +3,22 @@ import { Icon, Modal, Dropdown, Item, Text, Flexbox } from 'react-basics';
import { endOfYear, isSameDay } from 'date-fns';
import DatePickerForm from 'components/metrics/DatePickerForm';
import useLocale from 'hooks/useLocale';
import { dateFormat, getDateRangeValues } from 'lib/date';
import { dateFormat } from 'lib/date';
import Icons from 'components/icons';
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import useMessages from 'hooks/useMessages';
export function DateFilter({ websiteId, value, className }) {
export function DateFilter({
value,
startDate,
endDate,
className,
onChange,
showAllTime = false,
alignment = 'end',
}) {
const { formatMessage, labels } = useMessages();
const { get } = useApi();
const [dateRange, setDateRange] = useDateRange(websiteId);
const { startDate, endDate } = dateRange;
const [showPicker, setShowPicker] = useState(false);
async function handleDateChange(value) {
if (value === 'all' && websiteId) {
const data = await get(`/websites/${websiteId}`);
if (data) {
setDateRange({ value, ...getDateRangeValues(new Date(data.createdAt), Date.now()) });
}
} else if (value !== 'all') {
setDateRange(value);
}
}
const options = [
{ label: formatMessage(labels.today), value: '1day' },
{
@ -61,7 +52,7 @@ export function DateFilter({ websiteId, value, className }) {
value: '90day',
},
{ label: formatMessage(labels.thisYear), value: '1year' },
websiteId && {
showAllTime && {
label: formatMessage(labels.allTime),
value: 'all',
divider: true,
@ -74,7 +65,7 @@ export function DateFilter({ websiteId, value, className }) {
].filter(n => n);
const renderValue = value => {
return value === 'custom' ? (
return value.startsWith('range') ? (
<CustomRange startDate={startDate} endDate={endDate} onClick={() => handleChange('custom')} />
) : (
options.find(e => e.value === value).label
@ -86,12 +77,12 @@ export function DateFilter({ websiteId, value, className }) {
setShowPicker(true);
return;
}
handleDateChange(value);
onChange(value);
};
const handlePickerChange = value => {
setShowPicker(false);
handleDateChange(value);
onChange(value);
};
const handleClose = () => setShowPicker(false);
@ -103,7 +94,8 @@ export function DateFilter({ websiteId, value, className }) {
items={options}
renderValue={renderValue}
value={value}
alignment="end"
alignment={alignment}
placeholder={formatMessage(labels.selectDate)}
onChange={handleChange}
>
{({ label, value, divider }) => (

View File

@ -9,8 +9,10 @@ export function LanguageButton() {
const { locale, saveLocale, dir } = useLocale();
const items = Object.keys(languages).map(key => ({ ...languages[key], value: key }));
function handleSelect(value) {
function handleSelect(value, close, e) {
e.stopPropagation();
saveLocale(value);
close();
}
return (
@ -21,13 +23,15 @@ export function LanguageButton() {
</Icon>
</Button>
<Popup position="bottom" alignment={dir === 'rtl' ? 'start' : 'end'}>
{close => {
return (
<div className={styles.menu}>
{items.map(({ value, label }) => {
return (
<div
key={value}
className={classNames(styles.item, { [styles.selected]: value === locale })}
onClick={handleSelect.bind(null, value)}
onClick={handleSelect.bind(null, value, close)}
>
<Text>{label}</Text>
{value === locale && (
@ -39,6 +43,8 @@ export function LanguageButton() {
);
})}
</div>
);
}}
</Popup>
</PopupTrigger>
);

View File

@ -1,8 +1,7 @@
.menu {
display: flex;
flex-flow: row wrap;
min-width: 600px;
max-width: 100vw;
min-width: 640px;
padding: 10px;
background: var(--base50);
z-index: var(--z-index-popup);

View File

@ -1,4 +1,4 @@
import { Button, Icon, Icons, Tooltip } from 'react-basics';
import { Button, Icon, Icons, TooltipPopup } from 'react-basics';
import Link from 'next/link';
import useMessages from 'hooks/useMessages';
@ -6,13 +6,13 @@ export function LogoutButton({ tooltipPosition = 'top' }) {
const { formatMessage, labels } = useMessages();
return (
<Link href="/logout">
<Tooltip label={formatMessage(labels.logout)} position={tooltipPosition}>
<TooltipPopup label={formatMessage(labels.logout)} position={tooltipPosition}>
<Button variant="quiet">
<Icon>
<Icons.Logout />
</Icon>
</Button>
</Tooltip>
</TooltipPopup>
</Link>
);
}

View File

@ -1,4 +1,4 @@
import { LoadingButton, Icon, Tooltip } from 'react-basics';
import { LoadingButton, Icon, TooltipPopup } from 'react-basics';
import { setWebsiteDateRange } from 'store/websites';
import useDateRange from 'hooks/useDateRange';
import Icons from 'components/icons';
@ -19,13 +19,13 @@ export function RefreshButton({ websiteId, isLoading }) {
}
return (
<Tooltip label={formatMessage(labels.refresh)}>
<TooltipPopup label={formatMessage(labels.refresh)}>
<LoadingButton loading={isLoading} onClick={handleClick}>
<Icon>
<Icons.Refresh />
</Icon>
</LoadingButton>
</Tooltip>
</TooltipPopup>
);
}

View File

@ -5,7 +5,7 @@ import Icons from 'components/icons';
import styles from './ThemeButton.module.css';
export function ThemeButton() {
const [theme, setTheme] = useTheme();
const { theme, saveTheme } = useTheme();
const transitions = useTransition(theme, {
initial: { opacity: 1 },
@ -21,7 +21,7 @@ export function ThemeButton() {
});
function handleClick() {
setTheme(theme === 'light' ? 'dark' : 'light');
saveTheme(theme === 'light' ? 'dark' : 'light');
}
return (

View File

@ -0,0 +1,35 @@
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import DateFilter from './DateFilter';
import styles from './WebsiteDateFilter.module.css';
export default function WebsiteDateFilter({ websiteId }) {
const { get } = useApi();
const [dateRange, setDateRange] = useDateRange(websiteId);
const { value, startDate, endDate } = dateRange;
const handleChange = async value => {
if (value === 'all' && websiteId) {
const data = await get(`/websites/${websiteId}`);
if (data) {
const start = new Date(data.createdAt).getTime();
const end = Date.now();
setDateRange(`range:${start}:${end}`);
}
} else if (value !== 'all') {
setDateRange(value);
}
};
return (
<DateFilter
className={styles.dropdown}
value={value}
startDate={startDate}
endDate={endDate}
onChange={handleChange}
showAllTime={true}
/>
);
}

View File

@ -0,0 +1,3 @@
.dropdown {
min-width: 200px;
}

View File

@ -19,7 +19,6 @@ export function WebsiteSelect({ websiteId, onSelect }) {
onChange={onSelect}
alignment="end"
placeholder={formatMessage(labels.selectWebsite)}
style={{ width: 200 }}
>
{({ id, name }) => <Item key={id}>{name}</Item>}
</Dropdown>

View File

@ -2,6 +2,7 @@
display: grid;
grid-template-rows: max-content 1fr;
grid-template-columns: 1fr;
overflow: hidden;
}
.nav {

View File

@ -29,6 +29,7 @@
.row > .col {
border-top: 1px solid var(--base300);
border-inline-start: 0;
border-inline-end: 0;
padding: 20px 0;
}

View File

@ -18,7 +18,6 @@ export function NavBar() {
const links = [
{ label: formatMessage(labels.dashboard), url: '/dashboard' },
{ label: formatMessage(labels.realtime), url: '/realtime' },
!cloudMode && { label: formatMessage(labels.settings), url: '/settings' },
].filter(n => n);

View File

@ -27,7 +27,6 @@
gap: 10px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
min-width: 0;
}

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Icon, Text, Tooltip } from 'react-basics';
import { Icon, Text, TooltipPopup } from 'react-basics';
import classNames from 'classnames';
import { useRouter } from 'next/router';
import Link from 'next/link';
@ -36,7 +36,7 @@ export function NavGroup({
<div className={styles.body}>
{items.map(({ label, url, icon, divider }) => {
return (
<Tooltip key={label} label={label} position="right" disabled={!minimized}>
<TooltipPopup key={label} label={label} position="right" disabled={!minimized}>
<Link
href={url}
className={classNames(styles.item, {
@ -47,7 +47,7 @@ export function NavGroup({
<Icon>{icon}</Icon>
<Text className={styles.text}>{label}</Text>
</Link>
</Tooltip>
</TooltipPopup>
);
})}
</div>

View File

@ -1,10 +1,11 @@
import classNames from 'classnames';
import React from 'react';
import styles from './PageHeader.module.css';
export function PageHeader({ title, children }) {
export function PageHeader({ title, children, className }) {
return (
<div className={styles.header}>
<div className={styles.title}>{title}</div>
<div className={classNames(styles.header, className)}>
{title && <div className={styles.title}>{title}</div>}
<div className={styles.actions}>{children}</div>
</div>
);

View File

@ -4,7 +4,6 @@
align-items: center;
align-content: center;
align-self: stretch;
margin-bottom: 40px;
flex-wrap: wrap;
}
@ -23,6 +22,7 @@
font-weight: 700;
gap: 20px;
height: 60px;
flex: 1;
}
.actions {

View File

@ -0,0 +1,23 @@
import { Column, Row } from 'react-basics';
import styles from './ReportsLayout.module.css';
export function SettingsLayout({ children, filter, header }) {
return (
<>
<Row>{header}</Row>
<Row>
{filter && (
<Column className={styles.filter} defaultSize={12} md={4} lg={3} xl={3}>
<h2>Filters</h2>
{filter}
</Column>
)}
<Column className={styles.content} defaultSize={12} md={8} lg={9} xl={9}>
{children}
</Column>
</Row>
</>
);
}
export default SettingsLayout;

View File

@ -0,0 +1,23 @@
.filter {
margin-top: 30px;
min-width: 200px;
max-width: 100vw;
padding: 10px;
background: var(--base50);
border-radius: 5px;
border: 1px solid var(--border-color);
}
.filter h2 {
padding-bottom: 20px;
}
.content {
min-height: 50vh;
}
@media only screen and (max-width: 768px) {
.menu {
display: none;
}
}

View File

@ -15,9 +15,11 @@ export const labels = defineMessages({
password: { id: 'label.password', defaultMessage: 'Password' },
role: { id: 'label.role', defaultMessage: 'Role' },
user: { id: 'label.user', defaultMessage: 'User' },
viewOnly: { id: 'label.view-only', defaultMessage: 'View only' },
admin: { id: 'label.admin', defaultMessage: 'Administrator' },
confirm: { id: 'label.confirm', defaultMessage: 'Confirm' },
details: { id: 'label.details', defaultMessage: 'Details' },
website: { id: 'label.website', defaultMessage: 'Website' },
websites: { id: 'label.websites', defaultMessage: 'Websites' },
created: { id: 'label.created', defaultMessage: 'Created' },
edit: { id: 'label.edit', defaultMessage: 'Edit' },
@ -47,6 +49,7 @@ export const labels = defineMessages({
deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' },
reset: { id: 'label.reset', defaultMessage: 'Reset' },
addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' },
addDescription: { id: 'label.add-description', defaultMessage: 'Add description' },
changePassword: { id: 'label.change-password', defaultMessage: 'Change password' },
currentPassword: { id: 'label.current-password', defaultMessage: 'Current password' },
newPassword: { id: 'label.new-password', defaultMessage: 'New password' },
@ -79,7 +82,8 @@ export const labels = defineMessages({
countries: { id: 'label.countries', defaultMessage: 'Countries' },
languages: { id: 'label.languages', defaultMessage: 'Languages' },
events: { id: 'label.events', defaultMessage: 'Events' },
query: { id: 'label.query-parameters', defaultMessage: 'Query parameters' },
query: { id: 'label.query', defaultMessage: 'Query' },
queryParameters: { id: 'label.query-parameters', defaultMessage: 'Query parameters' },
back: { id: 'label.back', defaultMessage: 'Back' },
visitors: { id: 'label.visitors', defaultMessage: 'Visitors' },
filterCombined: { id: 'label.filter-combined', defaultMessage: 'Combined' },
@ -97,6 +101,7 @@ export const labels = defineMessages({
allTime: { id: 'label.all-time', defaultMessage: 'All time' },
customRange: { id: 'label.custom-range', defaultMessage: 'Custom range' },
selectWebsite: { id: 'label.select-website', defaultMessage: 'Select website' },
selectDate: { id: 'label.select-date', defaultMessage: 'Select date' },
all: { id: 'label.all', defaultMessage: 'All' },
sessions: { id: 'label.sessions', defaultMessage: 'Sessions' },
pageNotFound: { id: 'message.page-not-found', defaultMessage: 'Page not found' },
@ -117,6 +122,43 @@ export const labels = defineMessages({
view: { id: 'label.view', defaultMessage: 'View' },
cities: { id: 'label.cities', defaultMessage: 'Cities' },
regions: { id: 'label.regions', defaultMessage: 'Regions' },
reports: { id: 'label.reports', defaultMessage: 'Reports' },
eventData: { id: 'label.event-data', defaultMessage: 'Event data' },
funnel: { id: 'label.funnel', defaultMessage: 'Funnel' },
url: { id: 'label.url', defaultMessage: 'URL' },
urls: { id: 'label.urls', defaultMessage: 'URLs' },
add: { id: 'label.add', defaultMessage: 'Add' },
window: { id: 'label.window', defaultMessage: 'Window' },
runQuery: { id: 'label.run-query', defaultMessage: 'Run query' },
field: { id: 'label.field', defaultMessage: 'Field' },
fields: { id: 'label.fields', defaultMessage: 'Fields' },
createReport: { id: 'labels.create-report', defaultMessage: 'Create report' },
description: { id: 'labels.description', defaultMessage: 'Description' },
untitled: { id: 'labels.untitled', defaultMessage: 'Untitled' },
type: { id: 'labels.type', defaultMessage: 'Type' },
filters: { id: 'labels.filters', defaultMessage: 'Filters' },
breakdown: { id: 'labels.breakdown', defaultMessage: 'Breakdown' },
true: { id: 'labels.true', defaultMessage: 'True' },
false: { id: 'labels.false', defaultMessage: 'False' },
equals: { id: 'labels.equals', defaultMessage: 'Equals' },
doesNotEqual: { id: 'labels.does-not-equal', defaultMessage: 'Does not equal' },
greaterThan: { id: 'labels.greater-than', defaultMessage: 'Greater than' },
lessThan: { id: 'labels.less-than', defaultMessage: 'Less than' },
greaterThanEquals: { id: 'labels.greater-than-equals', defaultMessage: 'Greater than or equals' },
lessThanEquals: { id: 'labels.less-than-equals', defaultMessage: 'Less than or equals' },
contains: { id: 'labels.contains', defaultMessage: 'Contains' },
doesNotContain: { id: 'labels.does-not-contain', defaultMessage: 'Does not contain' },
before: { id: 'labels.before', defaultMessage: 'Before' },
after: { id: 'labels.after', defaultMessage: 'After' },
total: { id: 'labels.total', defaultMessage: 'Total' },
sum: { id: 'labels.sum', defaultMessage: 'Sum' },
average: { id: 'labels.average', defaultMessage: 'Average' },
min: { id: 'labels.min', defaultMessage: 'Min' },
max: { id: 'labels.max', defaultMessage: 'Max' },
unique: { id: 'labels.unique', defaultMessage: 'Unique' },
value: { id: 'labels.value', defaultMessage: 'Value' },
overview: { id: 'labels.overview', defaultMessage: 'Overview' },
totalRecords: { id: 'labels.total-records', defaultMessage: 'Total records' },
});
export const messages = defineMessages({
@ -158,6 +200,10 @@ export const messages = defineMessages({
id: 'message.team-already-member',
defaultMessage: 'You are already a member of the team.',
},
deleteAccount: {
id: 'message.delete-account',
defaultMessage: 'To delete this account, type {confirmation} in the box below to confirm.',
},
deleteWebsite: {
id: 'message.delete-website',
defaultMessage: 'To delete this website, type {confirmation} in the box below to confirm.',
@ -179,6 +225,10 @@ export const messages = defineMessages({
id: 'message.delete-website-warning',
defaultMessage: 'All website data will be deleted.',
},
noResultsFound: {
id: 'messages.no-results-found',
defaultMessage: 'No results were found.',
},
noWebsitesConfigured: {
id: 'messages.no-websites-configured',
defaultMessage: 'You do not have any websites configured.',
@ -216,4 +266,8 @@ export const messages = defineMessages({
id: 'message.incorrect-username-password',
defaultMessage: 'Incorrect username and/or password.',
},
noEventData: {
id: 'message.no-event-data',
defaultMessage: 'No event data is available.',
},
});

View File

@ -1,14 +1,13 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { StatusLight, Loading } from 'react-basics';
import { useState, useRef, useEffect, useCallback } from 'react';
import { Loading } from 'react-basics';
import classNames from 'classnames';
import Chart from 'chart.js/auto';
import HoverTooltip from 'components/common/HoverTooltip';
import Legend from 'components/metrics/Legend';
import { formatLongNumber } from 'lib/format';
import { dateFormat } from 'lib/date';
import useLocale from 'hooks/useLocale';
import useTheme from 'hooks/useTheme';
import { DEFAULT_ANIMATION_DURATION, THEME_COLORS } from 'lib/constants';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import { renderNumberLabels } from 'lib/charts';
import styles from './BarChart.module.css';
export function BarChart({
@ -17,84 +16,20 @@ export function BarChart({
animationDuration = DEFAULT_ANIMATION_DURATION,
stacked = false,
loading = false,
onCreate = () => {},
onUpdate = () => {},
renderXLabel,
renderYLabel,
XAxisType = 'time',
YAxisType = 'linear',
renderTooltipPopup,
onCreate,
onUpdate,
className,
}) {
const canvas = useRef();
const chart = useRef(null);
const [tooltip, setTooltip] = useState(null);
const [tooltip, setTooltipPopup] = useState(null);
const { locale } = useLocale();
const [theme] = useTheme();
const colors = useMemo(
() => ({
text: THEME_COLORS[theme].gray700,
line: THEME_COLORS[theme].gray200,
}),
[theme],
);
const renderYLabel = label => {
return +label > 1000 ? formatLongNumber(label) : label;
};
const renderXLabel = useCallback(
(label, index, values) => {
const d = new Date(values[index].value);
switch (unit) {
case 'minute':
return dateFormat(d, 'h:mm', locale);
case 'hour':
return dateFormat(d, 'p', locale);
case 'day':
return dateFormat(d, 'MMM d', locale);
case 'month':
return dateFormat(d, 'MMM', locale);
default:
return label;
}
},
[locale, unit],
);
const renderTooltip = useCallback(
model => {
const { opacity, labelColors, dataPoints } = model.tooltip;
if (!dataPoints?.length || !opacity) {
setTooltip(null);
return;
}
const formats = {
millisecond: 'T',
second: 'pp',
minute: 'p',
hour: 'h:mm aaa - PP',
day: 'PPPP',
week: 'PPPP',
month: 'LLLL yyyy',
quarter: 'qqq',
year: 'yyyy',
};
setTooltip(
<div className={styles.tooltip}>
<div>{dateFormat(new Date(dataPoints[0].raw.x), formats[unit], locale)}</div>
<div>
<StatusLight color={labelColors?.[0]?.backgroundColor}>
<div className={styles.value}>
{formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label}
</div>
</StatusLight>
</div>
</div>,
);
},
[unit],
);
const { theme, colors } = useTheme();
const getOptions = useCallback(() => {
return {
@ -115,12 +50,12 @@ export function BarChart({
},
tooltip: {
enabled: false,
external: renderTooltip,
external: renderTooltipPopup ? renderTooltipPopup.bind(null, setTooltipPopup) : undefined,
},
},
scales: {
x: {
type: 'time',
type: XAxisType,
stacked: true,
time: {
unit,
@ -129,34 +64,44 @@ export function BarChart({
display: false,
},
border: {
color: colors.line,
color: colors.chart.line,
},
ticks: {
color: colors.text,
color: colors.chart.text,
autoSkip: false,
maxRotation: 0,
callback: renderXLabel,
},
},
y: {
type: 'linear',
type: YAxisType,
min: 0,
beginAtZero: true,
stacked,
grid: {
color: colors.line,
color: colors.chart.line,
},
border: {
color: colors.line,
color: colors.chart.line,
},
ticks: {
color: colors.text,
callback: renderYLabel,
callback: renderYLabel || renderNumberLabels,
},
},
},
};
}, [animationDuration, renderTooltip, renderXLabel, stacked, colors, unit, locale]);
}, [
animationDuration,
renderTooltipPopup,
renderXLabel,
XAxisType,
YAxisType,
stacked,
colors,
unit,
locale,
]);
const createChart = () => {
Chart.defaults.font.family = 'Inter';
@ -171,11 +116,11 @@ export function BarChart({
options,
});
onCreate(chart.current);
onCreate?.(chart.current);
};
const updateChart = () => {
setTooltip(null);
setTooltipPopup(null);
datasets.forEach((dataset, index) => {
chart.current.data.datasets[index].data = dataset.data;
@ -184,7 +129,7 @@ export function BarChart({
chart.current.options = getOptions();
onUpdate(chart.current);
onUpdate?.(chart.current);
chart.current.update();
};
@ -206,7 +151,11 @@ export function BarChart({
<canvas ref={canvas} />
</div>
<Legend chart={chart.current} />
{tooltip && <HoverTooltip tooltip={tooltip} />}
{tooltip && (
<HoverTooltip>
<div className={styles.tooltip}>{tooltip}</div>
</HoverTooltip>
)}
</>
);
}

View File

@ -13,9 +13,3 @@
.tooltip .value {
text-transform: lowercase;
}
@media only screen and (max-width: 992px) {
.chart {
/*height: 200px;*/
}
}

View File

@ -2,12 +2,23 @@ import FilterLink from 'components/common/FilterLink';
import MetricsTable from 'components/metrics/MetricsTable';
import { BROWSERS } from 'lib/constants';
import useMessages from 'hooks/useMessages';
import { useRouter } from 'next/router';
export function BrowsersTable({ websiteId, ...props }) {
const { formatMessage, labels } = useMessages();
const { basePath } = useRouter();
function renderLink({ x: browser }) {
return <FilterLink id="browser" value={browser} label={BROWSERS[browser] || browser} />;
return (
<FilterLink id="browser" value={browser} label={BROWSERS[browser] || browser}>
<img
src={`${basePath}/images/browsers/${browser || 'unknown'}.png`}
alt={browser}
width={16}
height={16}
/>
</FilterLink>
);
}
return (

View File

@ -1,13 +1,15 @@
import MetricsTable from './MetricsTable';
import { useRouter } from 'next/router';
import FilterLink from 'components/common/FilterLink';
import useCountryNames from 'hooks/useCountryNames';
import useLocale from 'hooks/useLocale';
import useMessages from 'hooks/useMessages';
import MetricsTable from './MetricsTable';
export function CountriesTable({ websiteId, ...props }) {
const { locale } = useLocale();
const countryNames = useCountryNames(locale);
const { formatMessage, labels } = useMessages();
const { basePath } = useRouter();
function renderLink({ x: code }) {
return (
@ -17,7 +19,7 @@ export function CountriesTable({ websiteId, ...props }) {
value={countryNames[code] && code}
label={countryNames[code]}
>
<img src={`/images/flags/${code?.toLowerCase() || 'xx'}.png`} alt={code} />
<img src={`${basePath}/images/flags/${code?.toLowerCase() || 'xx'}.png`} alt={code} />
</FilterLink>
);
}

View File

@ -3,10 +3,10 @@ import useMeasure from 'react-use-measure';
import { FixedSizeList } from 'react-window';
import { useSpring, animated, config } from 'react-spring';
import classNames from 'classnames';
import NoData from 'components/common/NoData';
import Empty from 'components/common/Empty';
import { formatNumber, formatLongNumber } from 'lib/format';
import useMessages from 'hooks/useMessages';
import styles from './DataTable.module.css';
import useMessages from '../../hooks/useMessages';
export function DataTable({
data = [],
@ -55,7 +55,7 @@ export function DataTable({
</div>
</div>
<div ref={ref} className={styles.body}>
{data?.length === 0 && <NoData />}
{data?.length === 0 && <Empty />}
{virtualize && data.length > 0 ? (
<FixedSizeList height={bounds.height} itemCount={data.length} itemSize={30}>
{Row}

View File

@ -1,9 +1,9 @@
.table {
position: relative;
height: 100%;
display: grid;
grid-template-rows: fit-content(100%) auto;
overflow: hidden;
flex: 1;
}
.body {

View File

@ -2,7 +2,6 @@ import { useState } from 'react';
import { Button, ButtonGroup, Calendar } from 'react-basics';
import { isAfter, isBefore, isSameDay } from 'date-fns';
import useLocale from 'hooks/useLocale';
import { getDateRangeValues } from 'lib/date';
import { getDateLocale } from 'lib/lang';
import { FILTER_DAY, FILTER_RANGE } from 'lib/constants';
import useMessages from 'hooks/useMessages';
@ -19,7 +18,7 @@ export function DatePickerForm({
const [selected, setSelected] = useState(
isSameDay(defaultStartDate, defaultEndDate) ? FILTER_DAY : FILTER_RANGE,
);
const [date, setDate] = useState(defaultStartDate);
const [singleDate, setSingleDate] = useState(defaultStartDate);
const [startDate, setStartDate] = useState(defaultStartDate);
const [endDate, setEndDate] = useState(defaultEndDate);
const { locale } = useLocale();
@ -27,14 +26,14 @@ export function DatePickerForm({
const disabled =
selected === FILTER_DAY
? isAfter(minDate, date) && isBefore(maxDate, date)
? isAfter(minDate, singleDate) && isBefore(maxDate, singleDate)
: isAfter(startDate, endDate);
const handleSave = () => {
if (selected === FILTER_DAY) {
onChange({ ...getDateRangeValues(date, date), value: 'custom' });
onChange(`range:${singleDate.getTime()}:${singleDate.getTime()}`);
} else {
onChange({ ...getDateRangeValues(startDate, endDate), value: 'custom' });
onChange(`range:${startDate.getTime()}:${endDate.getTime()}`);
}
};
@ -48,7 +47,12 @@ export function DatePickerForm({
</div>
<div className={styles.calendars}>
{selected === FILTER_DAY && (
<Calendar date={date} minDate={minDate} maxDate={maxDate} onChange={setDate} />
<Calendar
date={singleDate}
minDate={minDate}
maxDate={maxDate}
onChange={setSingleDate}
/>
)}
{selected === FILTER_RANGE && (
<>

View File

@ -1,9 +1,11 @@
import MetricsTable from './MetricsTable';
import FilterLink from 'components/common/FilterLink';
import useMessages from 'hooks/useMessages';
import { useRouter } from 'next/router';
export function DevicesTable({ websiteId, ...props }) {
const { formatMessage, labels } = useMessages();
const { basePath } = useRouter();
function renderLink({ x: device }) {
return (
@ -11,7 +13,14 @@ export function DevicesTable({ websiteId, ...props }) {
id="device"
value={labels[device] && device}
label={formatMessage(labels[device] || labels.unknown)}
>
<img
src={`${basePath}/images/device/${device?.toLowerCase() || 'unknown'}.png`}
alt={device}
width={16}
height={16}
/>
</FilterLink>
);
}

View File

@ -2,16 +2,15 @@ import { useMemo } from 'react';
import { Loading } from 'react-basics';
import { colord } from 'colord';
import BarChart from './BarChart';
import { getDateArray, getDateLength } from 'lib/date';
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import useTimezone from 'hooks/useTimezone';
import usePageQuery from 'hooks/usePageQuery';
import { getDateArray } from 'lib/date';
import { useApi, useLocale, useDateRange, useTimezone, usePageQuery } from 'hooks';
import { EVENT_COLORS } from 'lib/constants';
import { renderDateLabels, renderStatusTooltipPopup } from 'lib/charts';
export function EventsChart({ websiteId, className, token }) {
const { get, useQuery } = useApi();
const [{ startDate, endDate, unit, modified }] = useDateRange(websiteId);
const { locale } = useLocale();
const [timezone] = useTimezone();
const {
query: { url, eventName },
@ -70,9 +69,10 @@ export function EventsChart({ websiteId, className, token }) {
datasets={datasets}
unit={unit}
height={300}
records={getDateLength(startDate, endDate, unit)}
loading={isLoading}
stacked
renderXLabel={renderDateLabels(unit, locale)}
renderTooltipPopup={renderStatusTooltipPopup(unit, locale)}
/>
);
}

View File

@ -1,113 +1,27 @@
import { useState } from 'react';
import { Loading } from 'react-basics';
import { Loading, cloneChildren } from 'react-basics';
import ErrorMessage from 'components/common/ErrorMessage';
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import usePageQuery from 'hooks/usePageQuery';
import { formatShortTime, formatNumber, formatLongNumber } from 'lib/format';
import MetricCard from './MetricCard';
import useMessages from 'hooks/useMessages';
import styles from './MetricsBar.module.css';
import { formatLongNumber, formatNumber } from 'lib/format';
export function MetricsBar({ websiteId }) {
const { formatMessage, labels } = useMessages();
const { get, useQuery } = useApi();
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, modified } = dateRange;
export function MetricsBar({ children, isLoading, isFetched, error }) {
const [format, setFormat] = useState(true);
const {
query: { url, referrer, os, browser, device, country, region, city },
} = usePageQuery();
const { data, error, isLoading, isFetched } = useQuery(
[
'websites:stats',
{ websiteId, modified, url, referrer, os, browser, device, country, region, city },
],
() =>
get(`/websites/${websiteId}/stats`, {
startAt: +startDate,
endAt: +endDate,
url,
referrer,
os,
browser,
device,
country,
region,
city,
}),
);
const formatFunc = format
? n => (n >= 0 ? formatLongNumber(n) : `-${formatLongNumber(Math.abs(n))}`)
: formatNumber;
function handleSetFormat() {
const handleSetFormat = () => {
setFormat(state => !state);
}
const { pageviews, uniques, bounces, totaltime } = data || {};
const num = Math.min(data && uniques.value, data && bounces.value);
const diffs = data && {
pageviews: pageviews.value - pageviews.change,
uniques: uniques.value - uniques.change,
bounces: bounces.value - bounces.change,
totaltime: totaltime.value - totaltime.change,
};
return (
<div className={styles.bar} onClick={handleSetFormat}>
{isLoading && !isFetched && <Loading icon="dots" />}
{error && <ErrorMessage />}
{data && !error && isFetched && (
<>
<MetricCard
className={styles.card}
label={formatMessage(labels.views)}
value={pageviews.value}
change={pageviews.change}
format={formatFunc}
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.visitors)}
value={uniques.value}
change={uniques.change}
format={formatFunc}
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.bounceRate)}
value={uniques.value ? (num / uniques.value) * 100 : 0}
change={
uniques.value && uniques.change
? (num / uniques.value) * 100 -
(Math.min(diffs.uniques, diffs.bounces) / diffs.uniques) * 100 || 0
: 0
}
format={n => Number(n).toFixed(0) + '%'}
reverseColors
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.averageVisitTime)}
value={
totaltime.value && pageviews.value
? totaltime.value / (pageviews.value - bounces.value)
: 0
}
change={
totaltime.value && pageviews.value
? (diffs.totaltime / (diffs.pageviews - diffs.bounces) -
totaltime.value / (pageviews.value - bounces.value)) *
-1 || 0
: 0
}
format={n => `${n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`}
/>
</>
)}
{cloneChildren(children, child => {
return { format: child.props.format || formatFunc };
})}
</div>
);
}

View File

@ -1,7 +1,8 @@
.bar {
display: flex;
flex-direction: row;
cursor: pointer;
min-height: 80px;
min-height: 110px;
gap: 20px;
flex-wrap: wrap;
}

View File

@ -30,7 +30,7 @@ export function MetricsTable({
const {
resolveUrl,
router,
query: { url, referrer, os, browser, device, country, region, city },
query: { url, referrer, title, os, browser, device, country, region, city },
} = usePageQuery();
const { formatMessage, labels } = useMessages();
const { get, useQuery } = useApi();
@ -38,7 +38,20 @@ export function MetricsTable({
const { data, isLoading, isFetched, error } = useQuery(
[
'websites:metrics',
{ websiteId, type, modified, url, referrer, os, browser, device, country, region, city },
{
websiteId,
type,
modified,
url,
referrer,
os,
title,
browser,
device,
country,
region,
city,
},
],
() =>
get(`/websites/${websiteId}/metrics`, {
@ -46,6 +59,7 @@ export function MetricsTable({
startAt: +startDate,
endAt: +endDate,
url,
title,
referrer,
os,
browser,
@ -59,13 +73,27 @@ export function MetricsTable({
const filteredData = useMemo(() => {
if (data) {
let items = percentFilter(dataFilter ? dataFilter(data, filterOptions) : data);
let items = data;
if (dataFilter) {
if (Array.isArray(dataFilter)) {
items = dataFilter.reduce((arr, filter) => {
return filter(arr);
}, items);
} else {
items = dataFilter(data);
}
}
items = percentFilter(items);
if (limit) {
items = items.filter((e, i) => i < limit);
}
if (filterOptions?.sort === false) {
return items;
}
return items.sort(firstBy('y', -1).thenBy('x'));
}
return [];

View File

@ -1,12 +1,25 @@
import MetricsTable from './MetricsTable';
import FilterLink from 'components/common/FilterLink';
import useMessages from 'hooks/useMessages';
import { useRouter } from 'next/router';
export function OSTable({ websiteId, ...props }) {
const { formatMessage, labels } = useMessages();
const { basePath } = useRouter();
function renderLink({ x: os }) {
return <FilterLink id="os" value={os} />;
return (
<FilterLink id="os" value={os}>
<img
src={`${basePath}/images/os/${
os?.toLowerCase().replaceAll(/[^\w]+/g, '-') || 'unknown'
}.png`}
alt={os}
width={16}
height={16}
/>
</FilterLink>
);
}
return (

View File

@ -1,34 +1,13 @@
import { useMemo } from 'react';
import { colord } from 'colord';
import BarChart from './BarChart';
import { THEME_COLORS } from 'lib/constants';
import useTheme from 'hooks/useTheme';
import useMessages from 'hooks/useMessages';
import useLocale from 'hooks/useLocale';
import { useLocale, useTheme, useMessages } from 'hooks';
import { renderDateLabels, renderStatusTooltipPopup } from 'lib/charts';
export function PageviewsChart({ websiteId, data, unit, records, className, loading, ...props }) {
export function PageviewsChart({ websiteId, data, unit, className, loading, ...props }) {
const { formatMessage, labels } = useMessages();
const [theme] = useTheme();
const { colors } = useTheme();
const { locale } = useLocale();
const colors = useMemo(() => {
const primaryColor = colord(THEME_COLORS[theme].primary);
return {
views: {
hoverBackgroundColor: primaryColor.alpha(0.7).toRgbString(),
backgroundColor: primaryColor.alpha(0.4).toRgbString(),
borderColor: primaryColor.alpha(0.7).toRgbString(),
hoverBorderColor: primaryColor.toRgbString(),
},
visitors: {
hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(),
backgroundColor: primaryColor.alpha(0.6).toRgbString(),
borderColor: primaryColor.alpha(0.9).toRgbString(),
hoverBorderColor: primaryColor.toRgbString(),
},
};
}, [theme]);
const datasets = useMemo(() => {
if (!data) return [];
@ -37,13 +16,13 @@ export function PageviewsChart({ websiteId, data, unit, records, className, load
label: formatMessage(labels.uniqueVisitors),
data: data.sessions,
borderWidth: 1,
...colors.visitors,
...colors.chart.visitors,
},
{
label: formatMessage(labels.pageViews),
data: data.pageviews,
borderWidth: 1,
...colors.views,
...colors.chart.views,
},
];
}, [data, locale, colors]);
@ -55,8 +34,9 @@ export function PageviewsChart({ websiteId, data, unit, records, className, load
className={className}
datasets={datasets}
unit={unit}
records={records}
loading={loading}
renderXLabel={renderDateLabels(unit, locale)}
renderTooltipPopup={renderStatusTooltipPopup(unit, locale)}
/>
);
}

View File

@ -9,7 +9,7 @@ import styles from './QueryParametersTable.module.css';
const filters = {
[FILTER_RAW]: emptyFilter,
[FILTER_COMBINED]: paramFilter,
[FILTER_COMBINED]: [emptyFilter, paramFilter],
};
export function QueryParametersTable({ websiteId, showFilters, ...props }) {

View File

@ -1,15 +1,17 @@
import MetricsTable from './MetricsTable';
import { emptyFilter } from 'lib/filters';
import { useRouter } from 'next/router';
import FilterLink from 'components/common/FilterLink';
import { emptyFilter } from 'lib/filters';
import useLocale from 'hooks/useLocale';
import useMessages from 'hooks/useMessages';
import useCountryNames from 'hooks/useCountryNames';
import MetricsTable from './MetricsTable';
import regions from 'public/iso-3166-2.json';
export function RegionsTable({ websiteId, ...props }) {
const { locale } = useLocale();
const { formatMessage, labels } = useMessages();
const countryNames = useCountryNames(locale);
const { basePath } = useRouter();
const renderLabel = x => {
return regions[x] ? `${regions[x]}, ${countryNames[x.split('-')[0]]}` : x;
@ -18,7 +20,10 @@ export function RegionsTable({ websiteId, ...props }) {
const renderLink = ({ x: code }) => {
return (
<FilterLink id="region" className={locale} value={code} label={renderLabel(code)}>
<img src={`/images/flags/${code?.split('-')?.[0]?.toLowerCase() || 'xx'}.png`} alt={code} />
<img
src={`${basePath}/images/flags/${code?.split('-')?.[0]?.toLowerCase() || 'xx'}.png`}
alt={code}
/>
</FilterLink>
);
};

View File

@ -1,132 +0,0 @@
import { useMemo } from 'react';
import { Button, Icon, Text, Row, Column } from 'react-basics';
import Link from 'next/link';
import classNames from 'classnames';
import PageviewsChart from './PageviewsChart';
import MetricsBar from './MetricsBar';
import WebsiteHeader from './WebsiteHeader';
import DateFilter from 'components/input/DateFilter';
import ErrorMessage from 'components/common/ErrorMessage';
import FilterTags from 'components/metrics/FilterTags';
import RefreshButton from 'components/input/RefreshButton';
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import useTimezone from 'hooks/useTimezone';
import usePageQuery from 'hooks/usePageQuery';
import { getDateArray, getDateLength } from 'lib/date';
import Icons from 'components/icons';
import useSticky from 'hooks/useSticky';
import useMessages from 'hooks/useMessages';
import styles from './WebsiteChart.module.css';
import useLocale from 'hooks/useLocale';
export function WebsiteChart({
websiteId,
name,
domain,
stickyHeader = false,
showChart = true,
showDetailsButton = false,
onDataLoad = () => {},
}) {
const { formatMessage, labels } = useMessages();
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, unit, value, modified } = dateRange;
const [timezone] = useTimezone();
const {
query: { url, referrer, os, browser, device, country, region, city, title },
} = usePageQuery();
const { get, useQuery } = useApi();
const { ref, isSticky } = useSticky({ enabled: stickyHeader });
const { data, isLoading, error } = useQuery(
[
'websites:pageviews',
{ websiteId, modified, url, referrer, os, browser, device, country, region, city, title },
],
() =>
get(`/websites/${websiteId}/pageviews`, {
startAt: +startDate,
endAt: +endDate,
unit,
timezone,
url,
referrer,
os,
browser,
device,
country,
region,
city,
title,
}),
{ onSuccess: onDataLoad },
);
const chartData = useMemo(() => {
if (data) {
return {
pageviews: getDateArray(data.pageviews, startDate, endDate, unit),
sessions: getDateArray(data.sessions, startDate, endDate, unit),
};
}
return { pageviews: [], sessions: [] };
}, [data, modified]);
const { dir } = useLocale();
return (
<>
<WebsiteHeader websiteId={websiteId} name={name} domain={domain}>
{showDetailsButton && (
<Link href={`/websites/${websiteId}`}>
<Button variant="primary">
<Text>{formatMessage(labels.viewDetails)}</Text>
<Icon>
<Icon rotate={dir === 'rtl' ? 180 : 0}>
<Icons.ArrowRight />
</Icon>
</Icon>
</Button>
</Link>
)}
</WebsiteHeader>
<FilterTags
websiteId={websiteId}
params={{ url, referrer, os, browser, device, country, region, city, title }}
/>
<Row
ref={ref}
className={classNames(styles.header, {
[styles.sticky]: stickyHeader,
[styles.isSticky]: isSticky,
})}
>
<Column defaultSize={12} xl={8}>
<MetricsBar websiteId={websiteId} />
</Column>
<Column defaultSize={12} xl={4}>
<div className={styles.actions}>
<RefreshButton websiteId={websiteId} isLoading={isLoading} />
<DateFilter websiteId={websiteId} value={value} className={styles.dropdown} />
</div>
</Column>
</Row>
<Row>
<Column className={styles.chart}>
{error && <ErrorMessage />}
{showChart && (
<PageviewsChart
websiteId={websiteId}
data={chartData}
unit={unit}
records={getDateLength(startDate, endDate, unit)}
loading={isLoading}
/>
)}
</Column>
</Row>
</>
);
}
export default WebsiteChart;

View File

@ -1,21 +0,0 @@
import { Row, Column, Text } from 'react-basics';
import Favicon from 'components/common/Favicon';
import ActiveUsers from './ActiveUsers';
import styles from './WebsiteHeader.module.css';
export function WebsiteHeader({ websiteId, name, domain, children }) {
return (
<Row className={styles.header} justifyContent="center">
<Column className={styles.title} variant="two">
<Favicon domain={domain} />
<Text>{name}</Text>
</Column>
<Column className={styles.info} variant="two">
<ActiveUsers websiteId={websiteId} />
{children}
</Column>
</Row>
);
}
export default WebsiteHeader;

View File

@ -2,7 +2,7 @@ import WebsiteSelect from 'components/input/WebsiteSelect';
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import EventsChart from 'components/metrics/EventsChart';
import WebsiteChart from 'components/metrics/WebsiteChart';
import WebsiteChart from 'components/pages/websites/WebsiteChart';
import useApi from 'hooks/useApi';
import Head from 'next/head';
import Link from 'next/link';
@ -28,10 +28,12 @@ export function TestConsole() {
window.umami.track({ url: '/page-view', referrer: 'https://www.google.com' });
window.umami.track('track-event-no-data');
window.umami.track('track-event-with-data', {
data: {
test: 'test-data',
boolean: true,
booleanError: 'true',
time: new Date(),
number: 1,
number2: Math.random() * 100,
time2: new Date().toISOString(),
nested: {
test: 'test-data',
@ -41,7 +43,27 @@ export function TestConsole() {
},
},
array: [1, 2, 3],
});
}
function handleIdentifyClick() {
window.umami.identify({
userId: 123,
name: 'brian',
number: Math.random() * 100,
test: 'test-data',
boolean: true,
booleanError: 'true',
time: new Date(),
time2: new Date().toISOString(),
nested: {
test: 'test-data',
number: 1,
object: {
test: 'test-data',
},
},
array: [1, 2, 3],
});
}
@ -114,16 +136,15 @@ export function TestConsole() {
<Button id="manual-button" variant="action" onClick={handleClick}>
Run script
</Button>
<p />
<Button id="manual-button" variant="action" onClick={handleIdentifyClick}>
Run identify
</Button>
</Column>
</Row>
<Row>
<Column>
<WebsiteChart
websiteId={website.id}
name={website.name}
domain={website.domain}
showLink
/>
<WebsiteChart websiteId={website.id} />
<EventsChart websiteId={website.id} />
</Column>
</Row>

View File

@ -0,0 +1,54 @@
import { Column, Row } from 'react-basics';
import { useApi, useDateRange } from 'hooks';
import MetricCard from 'components/metrics/MetricCard';
import useMessages from 'hooks/useMessages';
import WebsiteDateFilter from 'components/input/WebsiteDateFilter';
import MetricsBar from 'components/metrics/MetricsBar';
import styles from './EventDataMetricsBar.module.css';
export function EventDataMetricsBar({ websiteId }) {
const { formatMessage, labels } = useMessages();
const { get, useQuery } = useApi();
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, modified } = dateRange;
const { data, error, isLoading, isFetched } = useQuery(
['event-data:stats', { websiteId, startDate, endDate, modified }],
() =>
get(`/event-data/stats`, {
websiteId,
startAt: +startDate,
endAt: +endDate,
}),
);
return (
<Row className={styles.row}>
<Column defaultSize={12} xl={8}>
<MetricsBar isLoading={isLoading} isFetched={isFetched} error={error}>
{!error && isFetched && (
<>
<MetricCard
className={styles.card}
label={formatMessage(labels.fields)}
value={data?.fields}
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.totalRecords)}
value={data?.records}
/>
</>
)}
</MetricsBar>
</Column>
<Column defaultSize={12} xl={4}>
<div className={styles.actions}>
<WebsiteDateFilter websiteId={websiteId} />
</div>
</Column>
</Row>
);
}
export default EventDataMetricsBar;

View File

@ -0,0 +1,46 @@
.container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
min-height: 90px;
margin-bottom: 20px;
background: var(--base50);
z-index: var(--z-index-above);
}
.metrics {
display: flex;
flex-direction: row;
align-items: center;
}
.actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
flex: 1;
}
.bar {
display: flex;
cursor: pointer;
min-height: 110px;
gap: 20px;
flex-wrap: wrap;
}
.card {
justify-self: flex-start;
}
@media only screen and (max-width: 992px) {
.card {
flex-basis: calc(50% - 20px);
}
}
.row {
border-bottom: 1px solid var(--border-color);
}

View File

@ -0,0 +1,32 @@
import Link from 'next/link';
import { GridTable, GridColumn } from 'react-basics';
import { useMessages, usePageQuery } from 'hooks';
import Empty from 'components/common/Empty';
export function EventDataTable({ data = [] }) {
const { formatMessage, labels } = useMessages();
const { resolveUrl } = usePageQuery();
if (data.length === 0) {
return <Empty />;
}
return (
<GridTable data={data}>
<GridColumn name="field" label={formatMessage(labels.field)}>
{row => {
return (
<Link href={resolveUrl({ view: row.field })} shallow={true}>
{row.field}
</Link>
);
}}
</GridColumn>
<GridColumn name="total" label={formatMessage(labels.totalRecords)}>
{({ total }) => total.toLocaleString()}
</GridColumn>
</GridTable>
);
}
export default EventDataTable;

View File

@ -0,0 +1,44 @@
import { GridTable, GridColumn, Button, Icon, Text, Flexbox } from 'react-basics';
import { useMessages, usePageQuery } from 'hooks';
import Link from 'next/link';
import Icons from 'components/icons';
import PageHeader from 'components/layout/PageHeader';
import Empty from 'components/common/Empty';
export function EventDataTable({ data = [], field }) {
const { formatMessage, labels } = useMessages();
const { resolveUrl } = usePageQuery();
const Title = () => {
return (
<>
<Link href={resolveUrl({ view: undefined })}>
<Button>
<Icon rotate={180}>
<Icons.ArrowRight />
</Icon>
<Text>{formatMessage(labels.back)}</Text>
</Button>
</Link>
<Text>{field}</Text>
</>
);
};
return (
<>
<PageHeader title={<Title />} />
{data.length <= 0 && <Empty />}
{data.length > 0 && (
<GridTable data={data}>
<GridColumn name="value" label={formatMessage(labels.value)} />
<GridColumn name="total" label={formatMessage(labels.totalRecords)} width="200px">
{({ total }) => total.toLocaleString()}
</GridColumn>
</GridTable>
)}
</>
);
}
export default EventDataTable;

View File

@ -3,7 +3,7 @@ import { StatusLight, Icon, Text } from 'react-basics';
import { FixedSizeList } from 'react-window';
import firstBy from 'thenby';
import FilterButtons from 'components/common/FilterButtons';
import NoData from 'components/common/NoData';
import Empty from 'components/common/Empty';
import useLocale from 'hooks/useLocale';
import useCountryNames from 'hooks/useCountryNames';
import { BROWSERS } from 'lib/constants';
@ -144,7 +144,7 @@ export function RealtimeLog({ data, websiteDomain }) {
<FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />
<div className={styles.header}>{formatMessage(labels.activityLog)}</div>
<div className={styles.body}>
{logs?.length === 0 && <NoData />}
{logs?.length === 0 && <Empty />}
{logs?.length > 0 && (
<FixedSizeList height={500} itemCount={logs.length} itemSize={50}>
{Row}

View File

@ -1,22 +1,20 @@
import { useState, useEffect, useMemo } from 'react';
import { subMinutes, startOfMinute } from 'date-fns';
import { useRouter } from 'next/router';
import firstBy from 'thenby';
import { GridRow, GridColumn } from 'components/layout/Grid';
import Page from 'components/layout/Page';
import RealtimeChart from 'components/metrics/RealtimeChart';
import PageHeader from 'components/layout/PageHeader';
import WorldMap from 'components/common/WorldMap';
import RealtimeLog from 'components/pages/realtime/RealtimeLog';
import RealtimeHeader from 'components/pages/realtime/RealtimeHeader';
import RealtimeUrls from 'components/pages/realtime/RealtimeUrls';
import RealtimeCountries from 'components/pages/realtime/RealtimeCountries';
import WebsiteSelect from 'components/input/WebsiteSelect';
import WebsiteHeader from 'components/pages/websites/WebsiteHeader';
import useApi from 'hooks/useApi';
import useMessages from 'hooks/useMessages';
import { percentFilter } from 'lib/filters';
import { REALTIME_RANGE, REALTIME_INTERVAL } from 'lib/constants';
import styles from './RealtimeDashboard.module.css';
import styles from './RealtimePage.module.css';
import { useWebsite } from 'hooks';
function mergeData(state = [], data = [], time) {
const ids = state.map(({ __id }) => __id);
@ -25,12 +23,10 @@ function mergeData(state = [], data = [], time) {
.filter(({ timestamp }) => timestamp >= time);
}
export function RealtimeDashboard({ websiteId }) {
const { formatMessage, labels } = useMessages();
const router = useRouter();
export function RealtimePage({ websiteId }) {
const [currentData, setCurrentData] = useState();
const { get, useQuery } = useApi();
const { data: website } = useQuery(['websites', websiteId], () => get(`/websites/${websiteId}`));
const { data: website } = useWebsite(websiteId);
const { data, isLoading, error } = useQuery(
['realtime', websiteId],
() => get(`/realtime/${websiteId}`, { startAt: currentData?.timestamp || 0 }),
@ -93,15 +89,9 @@ export function RealtimeDashboard({ websiteId }) {
return currentData;
}, [currentData]);
const handleSelect = id => {
router.push(`/realtime/${id}`);
};
return (
<Page loading={isLoading} error={error}>
<PageHeader title={formatMessage(labels.realtime)}>
<WebsiteSelect websiteId={websiteId} onSelect={handleSelect} />
</PageHeader>
<WebsiteHeader websiteId={websiteId} />
<RealtimeHeader websiteId={websiteId} data={currentData} />
<div className={styles.chart}>
<RealtimeChart data={realtimeData} unit="minute" records={REALTIME_RANGE} />
@ -126,4 +116,4 @@ export function RealtimeDashboard({ websiteId }) {
);
}
export default RealtimeDashboard;
export default RealtimePage;

View File

@ -10,6 +10,7 @@ export function RealtimeUrls({ websiteDomain, data = {} }) {
const { formatMessage, labels } = useMessages();
const { pageviews } = data;
const [filter, setFilter] = useState(FILTER_REFERRERS);
const limit = 15;
const buttons = [
{
@ -47,7 +48,8 @@ export function RealtimeUrls({ websiteDomain, data = {} }) {
}
return arr;
}, [])
.sort(firstBy('y', -1)),
.sort(firstBy('y', -1))
.slice(0, limit),
);
const pages = percentFilter(
@ -62,7 +64,8 @@ export function RealtimeUrls({ websiteDomain, data = {} }) {
}
return arr;
}, [])
.sort(firstBy('y', -1)),
.sort(firstBy('y', -1))
.slice(0, limit),
);
return [referrers, pages];

View File

@ -0,0 +1,42 @@
import { FormRow } from 'react-basics';
import DateFilter from 'components/input/DateFilter';
import WebsiteSelect from 'components/input/WebsiteSelect';
import { parseDateRange } from 'lib/date';
import { useContext } from 'react';
import { ReportContext } from './Report';
import { useMessages } from 'hooks';
export function BaseParameters() {
const { report, updateReport } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { parameters } = report || {};
const { websiteId, dateRange } = parameters || {};
const { value, startDate, endDate } = dateRange || {};
const handleWebsiteSelect = websiteId => {
updateReport({ websiteId, parameters: { websiteId } });
};
const handleDateChange = value => {
updateReport({ parameters: { dateRange: { ...parseDateRange(value) } } });
};
return (
<>
<FormRow label={formatMessage(labels.website)}>
<WebsiteSelect websiteId={websiteId} onSelect={handleWebsiteSelect} />
</FormRow>
<FormRow label={formatMessage(labels.dateRange)}>
<DateFilter
value={value}
startDate={startDate}
endDate={endDate}
onChange={handleDateChange}
/>
</FormRow>
</>
);
}
export default BaseParameters;

View File

@ -0,0 +1,41 @@
import { Form, FormRow, Menu, Item } from 'react-basics';
import { useMessages } from 'hooks';
export default function FieldAggregateForm({ name, type, onSelect }) {
const { formatMessage, labels } = useMessages();
const options = {
number: [
{ label: formatMessage(labels.sum), value: 'sum' },
{ label: formatMessage(labels.average), value: 'average' },
{ label: formatMessage(labels.min), value: 'min' },
{ label: formatMessage(labels.max), value: 'max' },
],
date: [
{ label: formatMessage(labels.min), value: 'min' },
{ label: formatMessage(labels.max), value: 'max' },
],
string: [
{ label: formatMessage(labels.total), value: 'total' },
{ label: formatMessage(labels.unique), value: 'unique' },
],
};
const items = options[type];
const handleSelect = value => {
onSelect({ name, type, value });
};
return (
<Form>
<FormRow label={name}>
<Menu onSelect={handleSelect}>
{items.map(({ label, value }) => {
return <Item key={value}>{label}</Item>;
})}
</Menu>
</FormRow>
</Form>
);
}

View File

@ -0,0 +1,57 @@
import { useState } from 'react';
import { Form, FormRow, Menu, Item, Flexbox, Dropdown, TextField, Button } from 'react-basics';
import { useFilters } from 'hooks';
import styles from './FieldFilterForm.module.css';
export default function FieldFilterForm({ name, type, onSelect }) {
const [filter, setFilter] = useState('');
const [value, setValue] = useState('');
const { filters, types } = useFilters();
const items = types[type];
const renderValue = value => {
return filters[value];
};
if (type === 'boolean') {
return (
<Form>
<FormRow label={name}>
<Menu onSelect={value => onSelect({ name, type, value: ['eq', value] })}>
{items.map(value => {
return <Item key={value}>{filters[value]}</Item>;
})}
</Menu>
</FormRow>
</Form>
);
}
return (
<Form>
<FormRow label={name} className={styles.filter}>
<Flexbox gap={10}>
<Dropdown
className={styles.dropdown}
items={items}
value={filter}
renderValue={renderValue}
onChange={setFilter}
>
{value => {
return <Item key={value}>{filters[value]}</Item>;
}}
</Dropdown>
<TextField value={value} onChange={e => setValue(e.target.value)} autoFocus={true} />
</Flexbox>
<Button
variant="primary"
onClick={() => onSelect({ name, type, value: [filter, value] })}
disabled={!filter || !value}
>
Add
</Button>
</FormRow>
</Form>
);
}

View File

@ -0,0 +1,17 @@
.selected {
font-weight: bold;
}
.popup {
display: flex;
}
.filter {
display: flex;
flex-direction: column;
gap: 20px;
}
.dropdown {
min-width: 180px;
}

View File

@ -0,0 +1,24 @@
import { Menu, Item, Form, FormRow } from 'react-basics';
import { useMessages } from 'hooks';
import styles from './FieldSelectForm.module.css';
export default function FieldSelectForm({ fields, onSelect }) {
const { formatMessage, labels } = useMessages();
return (
<Form>
<FormRow label={formatMessage(labels.fields)}>
<Menu className={styles.menu} onSelect={key => onSelect(fields[key])}>
{fields.map(({ name, type }, index) => {
return (
<Item key={index} className={styles.item}>
<div>{name}</div>
<div className={styles.type}>{type}</div>
</Item>
);
})}
</Menu>
</FormRow>
</Form>
);
}

View File

@ -0,0 +1,20 @@
.menu {
width: 360px;
max-height: 300px;
overflow: auto;
}
.item {
display: flex;
flex-direction: row;
justify-content: space-between;
border-radius: var(--border-radius);
}
.item:hover {
background: var(--base75);
}
.type {
color: var(--font-color300);
}

View File

@ -0,0 +1,33 @@
import { Icon, TooltipPopup } from 'react-basics';
import Icons from 'components/icons';
import Empty from 'components/common/Empty';
import { useMessages } from 'hooks';
import styles from './ParameterList.module.css';
export function ParameterList({ items = [], children, onRemove }) {
const { formatMessage, labels } = useMessages();
return (
<div className={styles.list}>
{!items.length && <Empty message={formatMessage(labels.none)} />}
{items.map((item, index) => {
return (
<div key={index} className={styles.item}>
{typeof children === 'function' ? children(item) : item}
<TooltipPopup
className={styles.icon}
label={formatMessage(labels.remove)}
position="right"
>
<Icon onClick={onRemove.bind(null, index)}>
<Icons.Close />
</Icon>
</TooltipPopup>
</div>
);
})}
</div>
);
}
export default ParameterList;

View File

@ -0,0 +1,21 @@
.list {
display: flex;
flex-direction: column;
gap: 16px;
}
.item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 12px;
border: 1px solid var(--base400);
border-radius: var(--border-radius);
box-shadow: 1px 1px 1px var(--base400);
gap: 10px;
}
.icon {
align-self: center;
}

View File

@ -0,0 +1,30 @@
import { createPortal } from 'react-dom';
import { useDocumentClick, useKeyDown } from 'react-basics';
import classNames from 'classnames';
import styles from './PopupForm.module.css';
export function PopupForm({ element, className, children, onClose }) {
const { right, top } = element.getBoundingClientRect();
const style = { position: 'absolute', left: right, top };
useKeyDown('Escape', onClose);
useDocumentClick(e => {
if (e.target !== element && !element?.parentElement?.contains(e.target)) {
onClose();
}
});
const handleClick = e => {
e.stopPropagation();
};
return createPortal(
<div className={classNames(styles.form, className)} style={style} onClick={handleClick}>
{children}
</div>,
document.body,
);
}
export default PopupForm;

View File

@ -0,0 +1,10 @@
.form {
position: absolute;
background: var(--base50);
min-width: 300px;
padding: 20px;
margin-left: 30px;
border: 1px solid var(--base400);
border-radius: var(--border-radius);
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1);
}

View File

@ -0,0 +1,22 @@
import { createContext } from 'react';
import Page from 'components/layout/Page';
import styles from './reports.module.css';
import { useReport } from 'hooks';
export const ReportContext = createContext(null);
export function Report({ reportId, defaultParameters, children, ...props }) {
const report = useReport(reportId, defaultParameters);
//console.log({ report });
return (
<ReportContext.Provider value={{ ...report }}>
<Page {...props} className={styles.container}>
{children}
</Page>
</ReportContext.Provider>
);
}
export default Report;

View File

@ -0,0 +1,7 @@
import styles from './reports.module.css';
export function ReportBody({ children }) {
return <div className={styles.body}>{children}</div>;
}
export default ReportBody;

View File

@ -0,0 +1,13 @@
import FunnelReport from './funnel/FunnelReport';
import EventDataReport from './event-data/EventDataReport';
const reports = {
funnel: FunnelReport,
'event-data': EventDataReport,
};
export default function ReportDetails({ reportId, reportType }) {
const Report = reports[reportType];
return <Report reportId={reportId} />;
}

View File

@ -0,0 +1,89 @@
import { useContext } from 'react';
import { useRouter } from 'next/router';
import { Icon, LoadingButton, InlineEditField, useToasts } from 'react-basics';
import PageHeader from 'components/layout/PageHeader';
import { useMessages, useApi } from 'hooks';
import { ReportContext } from './Report';
import styles from './ReportHeader.module.css';
import reportStyles from './reports.module.css';
export function ReportHeader({ icon }) {
const { report, updateReport } = useContext(ReportContext);
const { formatMessage, labels, messages } = useMessages();
const { showToast } = useToasts();
const { post, useMutation } = useApi();
const router = useRouter();
const { mutate: create, isLoading: isCreating } = useMutation(data => post(`/reports`, data));
const { mutate: update, isLoading: isUpdating } = useMutation(data =>
post(`/reports/${data.id}`, data),
);
const { name, description, parameters } = report || {};
const { websiteId, dateRange } = parameters || {};
const handleSave = async () => {
if (!report.id) {
create(report, {
onSuccess: async ({ id }) => {
showToast({ message: formatMessage(messages.saved), variant: 'success' });
router.push(`/reports/${id}`, null, { shallow: true });
},
});
} else {
update(report, {
onSuccess: async () => {
showToast({ message: formatMessage(messages.saved), variant: 'success' });
},
});
}
};
const handleNameChange = name => {
updateReport({ name: name || 'Untitled' });
};
const handleDescriptionChange = description => {
updateReport({ description });
};
const Title = () => {
return (
<>
<Icon size="lg">{icon}</Icon>
<InlineEditField
key={name}
name="name"
value={name}
placeholder={formatMessage(labels.untitled)}
onCommit={handleNameChange}
/>
</>
);
};
return (
<div className={reportStyles.header}>
<PageHeader title={<Title />}>
<LoadingButton
variant="primary"
loading={isCreating || isUpdating}
disabled={!websiteId || !dateRange?.value || !name}
onClick={handleSave}
>
{formatMessage(labels.save)}
</LoadingButton>
</PageHeader>
<div className={styles.description}>
<InlineEditField
key={description}
name="description"
value={description}
placeholder={`+ ${formatMessage(labels.addDescription)}`}
onCommit={handleDescriptionChange}
/>
</div>
</div>
);
}
export default ReportHeader;

View File

@ -0,0 +1,3 @@
.description {
color: var(--font-color300);
}

View File

@ -0,0 +1,7 @@
import styles from './reports.module.css';
export function ReportMenu({ children }) {
return <div className={styles.menu}>{children}</div>;
}
export default ReportMenu;

View File

@ -0,0 +1,59 @@
import Link from 'next/link';
import { Button, Icons, Text, Icon } from 'react-basics';
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import Funnel from 'assets/funnel.svg';
import Nodes from 'assets/nodes.svg';
import Lightbulb from 'assets/lightbulb.svg';
import styles from './ReportTemplates.module.css';
import { useMessages } from 'hooks';
const reports = [
{
title: 'Funnel',
description: 'Understand the conversion and drop-off rate of users.',
url: '/reports/funnel',
icon: <Funnel />,
},
];
function ReportItem({ title, description, url, icon }) {
return (
<div className={styles.report}>
<div className={styles.title}>
<Icon size="lg">{icon}</Icon>
<Text>{title}</Text>
</div>
<div className={styles.description}>{description}</div>
<div className={styles.buttons}>
<Link href={url}>
<Button variant="primary">
<Icon>
<Icons.Plus />
</Icon>
<Text>Create</Text>
</Button>
</Link>
</div>
</div>
);
}
export function ReportTemplates() {
const { formatMessage, labels } = useMessages();
return (
<Page>
<PageHeader title={formatMessage(labels.reports)} />
<div className={styles.reports}>
{reports.map(({ title, description, url, icon }) => {
return (
<ReportItem key={title} icon={icon} title={title} description={description} url={url} />
);
})}
</div>
</Page>
);
}
export default ReportTemplates;

View File

@ -0,0 +1,33 @@
.reports {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 20px;
width: 360px;
}
.report {
display: flex;
flex-direction: column;
gap: 20px;
padding: 20px;
border: 1px solid var(--base500);
border-radius: var(--border-radius);
}
.title {
display: flex;
gap: 10px;
align-items: center;
font-size: var(--font-size-lg);
font-weight: 700;
}
.description {
flex: 1;
}
.buttons {
display: flex;
align-items: center;
justify-content: center;
}

View File

@ -0,0 +1,29 @@
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import Link from 'next/link';
import { Button, Icon, Icons, Text } from 'react-basics';
import { useMessages, useReports } from 'hooks';
import ReportsTable from './ReportsTable';
export function ReportsPage() {
const { formatMessage, labels } = useMessages();
const { reports, error, isLoading } = useReports();
return (
<Page loading={isLoading} error={error}>
<PageHeader title={formatMessage(labels.reports)}>
<Link href="/reports/create">
<Button variant="primary">
<Icon>
<Icons.Plus />
</Icon>
<Text>{formatMessage(labels.createReport)}</Text>
</Button>
</Link>
</PageHeader>
<ReportsTable data={reports} />
</Page>
);
}
export default ReportsPage;

View File

@ -0,0 +1,36 @@
import Link from 'next/link';
import { Button, Text, Icon, Icons } from 'react-basics';
import SettingsTable from 'components/common/SettingsTable';
import useMessages from 'hooks/useMessages';
export function ReportsTable({ data = [] }) {
const { formatMessage, labels } = useMessages();
const columns = [
{ name: 'name', label: formatMessage(labels.name) },
{ name: 'description', label: formatMessage(labels.description) },
{ name: 'type', label: formatMessage(labels.type) },
{ name: 'action', label: ' ' },
];
return (
<SettingsTable columns={columns} data={data}>
{row => {
const { id } = row;
return (
<Link href={`/reports/${id}`}>
<Button>
<Icon>
<Icons.ArrowRight />
</Icon>
<Text>{formatMessage(labels.view)}</Text>
</Button>
</Link>
);
}}
</SettingsTable>
);
}
export default ReportsTable;

View File

@ -0,0 +1,143 @@
import { useContext, useRef } from 'react';
import { useApi, useMessages } from 'hooks';
import { Form, FormRow, FormButtons, SubmitButton, PopupTrigger, Icon, Popup } from 'react-basics';
import { ReportContext } from 'components/pages/reports/Report';
import Empty from 'components/common/Empty';
import { DATA_TYPES, REPORT_PARAMETERS } from 'lib/constants';
import Icons from 'components/icons';
import FieldAddForm from './FieldAddForm';
import BaseParameters from '../BaseParameters';
import ParameterList from '../ParameterList';
import styles from './EventDataParameters.module.css';
function useFields(websiteId, startDate, endDate) {
const { get, useQuery } = useApi();
const { data, error, isLoading } = useQuery(
['fields', websiteId, startDate, endDate],
() =>
get('/reports/event-data', {
websiteId,
startAt: +startDate,
endAt: +endDate,
}),
{ enabled: !!(websiteId && startDate && endDate) },
);
return { data, error, isLoading };
}
export function EventDataParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels, messages } = useMessages();
const ref = useRef(null);
const { parameters } = report || {};
const { websiteId, dateRange, fields, filters, groups } = parameters || {};
const { startDate, endDate } = dateRange || {};
const queryEnabled = websiteId && dateRange && fields?.length;
const { data, error } = useFields(websiteId, startDate, endDate);
const parametersSelected = websiteId && startDate && endDate;
const hasData = data?.length !== 0;
const parameterGroups = [
{ label: formatMessage(labels.fields), group: REPORT_PARAMETERS.fields },
{ label: formatMessage(labels.filters), group: REPORT_PARAMETERS.filters },
];
const parameterData = {
fields,
filters,
groups,
};
const handleSubmit = values => {
runReport(values);
};
const handleAdd = (group, value) => {
const data = parameterData[group].filter(({ name }) => name !== value.name);
updateReport({ parameters: { [group]: data.concat(value) } });
};
const handleRemove = (group, index) => {
const data = [...parameterData[group]];
data.splice(index, 1);
updateReport({ parameters: { [group]: data } });
};
const AddButton = ({ group }) => {
return (
<PopupTrigger>
<Icon>
<Icons.Plus />
</Icon>
<Popup position="bottom" alignment="start">
{(close, element) => {
return (
<FieldAddForm
fields={data.map(({ eventKey, eventDataType }) => ({
name: eventKey,
type: DATA_TYPES[eventDataType],
}))}
group={group}
element={element}
onAdd={handleAdd}
onClose={close}
/>
);
}}
</Popup>
</PopupTrigger>
);
};
return (
<Form ref={ref} values={parameters} error={error} onSubmit={handleSubmit}>
<BaseParameters />
{!hasData && <Empty message={formatMessage(messages.noEventData)} />}
{parametersSelected &&
hasData &&
parameterGroups.map(({ label, group }) => {
return (
<FormRow
key={label}
label={label}
action={<AddButton group={group} onAdd={handleAdd} />}
>
<ParameterList
items={parameterData[group]}
onRemove={index => handleRemove(group, index)}
>
{({ name, value }) => {
return (
<div className={styles.parameter}>
{group === REPORT_PARAMETERS.fields && (
<>
<div>{name}</div>
<div className={styles.op}>{value}</div>
</>
)}
{group === REPORT_PARAMETERS.filters && (
<>
<div>{name}</div>
<div className={styles.op}>{value[0]}</div>
<div>{value[1]}</div>
</>
)}
</div>
);
}}
</ParameterList>
</FormRow>
);
})}
<FormButtons>
<SubmitButton variant="primary" disabled={!queryEnabled} loading={isRunning}>
{formatMessage(labels.runQuery)}
</SubmitButton>
</FormButtons>
</Form>
);
}
export default EventDataParameters;

View File

@ -0,0 +1,12 @@
.parameter {
display: flex;
gap: 10px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}
.op {
font-weight: bold;
}

View File

@ -0,0 +1,26 @@
import Report from '../Report';
import ReportHeader from '../ReportHeader';
import ReportMenu from '../ReportMenu';
import ReportBody from '../ReportBody';
import EventDataParameters from './EventDataParameters';
import EventDataTable from './EventDataTable';
import Nodes from 'assets/nodes.svg';
const defaultParameters = {
type: 'event-data',
parameters: { fields: [], filters: [] },
};
export default function EventDataReport({ reportId }) {
return (
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Nodes />} />
<ReportMenu>
<EventDataParameters />
</ReportMenu>
<ReportBody>
<EventDataTable />
</ReportBody>
</Report>
);
}

View File

@ -0,0 +1,19 @@
import { useContext } from 'react';
import { GridTable, GridColumn } from 'react-basics';
import { useMessages } from 'hooks';
import { ReportContext } from '../Report';
export function EventDataTable() {
const { report } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
return (
<GridTable data={report?.data || []}>
<GridColumn name="field" label={formatMessage(labels.field)} />
<GridColumn name="value" label={formatMessage(labels.value)} />
<GridColumn name="total" label={formatMessage(labels.total)} />
</GridTable>
);
}
export default EventDataTable;

View File

@ -0,0 +1,44 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { REPORT_PARAMETERS } from 'lib/constants';
import PopupForm from '../PopupForm';
import FieldSelectForm from '../FieldSelectForm';
import FieldAggregateForm from '../FieldAggregateForm';
import FieldFilterForm from '../FieldFilterForm';
import styles from './FieldAddForm.module.css';
export function FieldAddForm({ fields = [], group, element, onAdd, onClose }) {
const [selected, setSelected] = useState();
const handleSelect = value => {
const { type } = value;
if (group === REPORT_PARAMETERS.groups || type === 'array' || type === 'boolean') {
value.value = group === REPORT_PARAMETERS.groups ? '' : 'total';
handleSave(value);
return;
}
setSelected(value);
};
const handleSave = value => {
onAdd(group, value);
onClose();
};
return createPortal(
<PopupForm className={styles.popup} element={element} onClose={onClose}>
{!selected && <FieldSelectForm fields={fields} onSelect={handleSelect} />}
{selected && group === REPORT_PARAMETERS.fields && (
<FieldAggregateForm {...selected} onSelect={handleSave} />
)}
{selected && group === REPORT_PARAMETERS.filters && (
<FieldFilterForm {...selected} onSelect={handleSave} />
)}
</PopupForm>,
document.body,
);
}
export default FieldAddForm;

View File

@ -0,0 +1,38 @@
.menu {
width: 360px;
max-height: 300px;
overflow: auto;
}
.item {
display: flex;
flex-direction: row;
justify-content: space-between;
border-radius: var(--border-radius);
}
.item:hover {
background: var(--base75);
}
.type {
color: var(--font-color300);
}
.selected {
font-weight: bold;
}
.popup {
display: flex;
}
.filter {
display: flex;
flex-direction: column;
gap: 20px;
}
.dropdown {
min-width: 60px;
}

View File

@ -0,0 +1,63 @@
import { useCallback, useContext, useMemo } from 'react';
import { Loading } from 'react-basics';
import useMessages from 'hooks/useMessages';
import useTheme from 'hooks/useTheme';
import BarChart from 'components/metrics/BarChart';
import { formatLongNumber } from 'lib/format';
import styles from './FunnelChart.module.css';
import { ReportContext } from '../Report';
export function FunnelChart({ className, loading }) {
const { report } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { colors } = useTheme();
const { parameters, data } = report || {};
const renderXLabel = useCallback(
(label, index) => {
return parameters.urls[index];
},
[parameters],
);
const renderTooltipPopup = useCallback((setTooltipPopup, model) => {
const { opacity, dataPoints } = model.tooltip;
if (!dataPoints?.length || !opacity) {
setTooltipPopup(null);
return;
}
setTooltipPopup(`${formatLongNumber(dataPoints[0].raw.y)} ${formatMessage(labels.visitors)}`);
}, []);
const datasets = useMemo(() => {
return [
{
label: formatMessage(labels.uniqueVisitors),
data: data,
borderWidth: 1,
...colors.chart.visitors,
},
];
}, [data]);
if (loading) {
return <Loading icon="dots" className={styles.loading} />;
}
return (
<BarChart
className={className}
datasets={datasets}
unit="day"
loading={loading}
renderXLabel={renderXLabel}
renderTooltipPopup={renderTooltipPopup}
XAxisType="category"
/>
);
}
export default FunnelChart;

View File

@ -0,0 +1,3 @@
.loading {
height: 300px;
}

View File

@ -0,0 +1,86 @@
import { useContext, useRef } from 'react';
import { useMessages } from 'hooks';
import {
Icon,
Form,
FormButtons,
FormInput,
FormRow,
PopupTrigger,
Popup,
SubmitButton,
TextField,
} from 'react-basics';
import Icons from 'components/icons';
import UrlAddForm from './UrlAddForm';
import { ReportContext } from 'components/pages/reports/Report';
import BaseParameters from '../BaseParameters';
import ParameterList from '../ParameterList';
export function FunnelParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const ref = useRef(null);
const { parameters } = report || {};
const { websiteId, dateRange, urls } = parameters || {};
const queryDisabled = !websiteId || !dateRange || urls?.length < 2;
const handleSubmit = (data, e) => {
e.stopPropagation();
e.preventDefault();
if (!queryDisabled) {
runReport(data);
}
};
const handleAddUrl = url => {
updateReport({ parameters: { urls: parameters.urls.concat(url) } });
};
const handleRemoveUrl = (index, e) => {
e.stopPropagation();
const urls = [...parameters.urls];
urls.splice(index, 1);
updateReport({ parameters: { urls } });
};
const AddUrlButton = () => {
return (
<PopupTrigger>
<Icon>
<Icons.Plus />
</Icon>
<Popup position="bottom" alignment="start">
{(close, element) => {
return <UrlAddForm element={element} onAdd={handleAddUrl} onClose={close} />;
}}
</Popup>
</PopupTrigger>
);
};
return (
<Form ref={ref} values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
<BaseParameters />
<FormRow label={formatMessage(labels.window)}>
<FormInput
name="window"
rules={{ required: formatMessage(labels.required), pattern: /[0-9]+/ }}
>
<TextField autoComplete="off" />
</FormInput>
</FormRow>
<FormRow label={formatMessage(labels.urls)} action={<AddUrlButton />}>
<ParameterList items={urls} onRemove={handleRemoveUrl} />
</FormRow>
<FormButtons>
<SubmitButton variant="primary" disabled={queryDisabled} loading={isRunning}>
{formatMessage(labels.runQuery)}
</SubmitButton>
</FormButtons>
</Form>
);
}
export default FunnelParameters;

View File

@ -0,0 +1,28 @@
import FunnelChart from './FunnelChart';
import FunnelTable from './FunnelTable';
import FunnelParameters from './FunnelParameters';
import Report from '../Report';
import ReportHeader from '../ReportHeader';
import ReportMenu from '../ReportMenu';
import ReportBody from '../ReportBody';
import Funnel from 'assets/funnel.svg';
const defaultParameters = {
type: 'funnel',
parameters: { window: 60, urls: [] },
};
export default function FunnelReport({ reportId }) {
return (
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Funnel />} />
<ReportMenu>
<FunnelParameters />
</ReportMenu>
<ReportBody>
<FunnelChart />
<FunnelTable />
</ReportBody>
</Report>
);
}

Some files were not shown because too many files have changed in this diff Show More