Merge branch 'dev' into master

This commit is contained in:
Mike Cao 2023-06-29 17:10:06 -07:00 committed by GitHub
commit 500606159e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
286 changed files with 11069 additions and 4872 deletions

View File

@ -1,10 +1,9 @@
name: "✨ Feature Request"
name: '✨ Feature Request'
description: Create a feature or enhancement request for Umami.
labels: ['enhancement']
body:
- type: textarea
attributes:
label: Describe the feature or enhancement
description: A clear and concise description of what the feature or enhancement is.
validations:
required: true
required: true

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/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-15zM497 241h-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

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

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

@ -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

@ -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,24 +23,28 @@ export function LanguageButton() {
</Icon>
</Button>
<Popup position="bottom" alignment={dir === 'rtl' ? 'start' : 'end'}>
<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)}
>
<Text>{label}</Text>
{value === locale && (
<Icon className={styles.icon}>
<Icons.Check />
</Icon>
)}
</div>
);
})}
</div>
{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, close)}
>
<Text>{label}</Text>
{value === locale && (
<Icon className={styles.icon}>
<Icons.Check />
</Icon>
)}
</div>
);
})}
</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,25 @@
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import DateFilter from './DateFilter';
export default function WebsiteDateFilter({ websiteId, value }) {
const { get } = useApi();
const [dateRange, setDateRange] = useDateRange(websiteId);
const { startDate, endDate } = dateRange;
const handleChange = async value => {
if (value === 'all' && websiteId) {
const data = await get(`/websites/${websiteId}`);
if (data) {
setDateRange(`range:${new Date(data.createdAt)}:${Date.now()}`);
}
} else if (value !== 'all') {
setDateRange(value);
}
};
return (
<DateFilter value={value} startDate={startDate} endDate={endDate} onChange={handleChange} />
);
}

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

@ -19,6 +19,7 @@ export function NavBar() {
const links = [
{ label: formatMessage(labels.dashboard), url: '/dashboard' },
{ label: formatMessage(labels.realtime), url: '/realtime' },
{ label: formatMessage(labels.reports), url: '/reports' },
!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

@ -18,6 +18,7 @@ export const labels = defineMessages({
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 +48,8 @@ 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' },
addField: { id: 'label.add-field', defaultMessage: 'Add field' },
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,21 @@ 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' },
addUrl: { id: 'label.add-url', defaultMessage: 'Add URL' },
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' },
});
export const messages = defineMessages({
@ -158,6 +178,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 +203,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.',

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

@ -5,8 +5,8 @@ import { useSpring, animated, config } from 'react-spring';
import classNames from 'classnames';
import NoData from 'components/common/NoData';
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 = [],

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

@ -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

@ -16,13 +16,13 @@ export function MetricsBar({ websiteId }) {
const { startDate, endDate, modified } = dateRange;
const [format, setFormat] = useState(true);
const {
query: { url, referrer, os, browser, device, country, region, city },
query: { url, referrer, title, 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 },
{ websiteId, modified, url, referrer, title, os, browser, device, country, region, city },
],
() =>
get(`/websites/${websiteId}/stats`, {
@ -30,6 +30,7 @@ export function MetricsBar({ websiteId }) {
endAt: +endDate,
url,
referrer,
title,
os,
browser,
device,

View File

@ -1,7 +1,7 @@
.bar {
display: flex;
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,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

@ -5,7 +5,7 @@ import classNames from 'classnames';
import PageviewsChart from './PageviewsChart';
import MetricsBar from './MetricsBar';
import WebsiteHeader from './WebsiteHeader';
import DateFilter from 'components/input/DateFilter';
import WebsiteDateFilter from 'components/input/WebsiteDateFilter';
import ErrorMessage from 'components/common/ErrorMessage';
import FilterTags from 'components/metrics/FilterTags';
import RefreshButton from 'components/input/RefreshButton';
@ -107,7 +107,7 @@ export function WebsiteChart({
<Column defaultSize={12} xl={4}>
<div className={styles.actions}>
<RefreshButton websiteId={websiteId} isLoading={isLoading} />
<DateFilter websiteId={websiteId} value={value} className={styles.dropdown} />
<WebsiteDateFilter websiteId={websiteId} value={value} className={styles.dropdown} />
</div>
</Column>
</Row>

View File

@ -1,3 +1,9 @@
.header {
display: flex;
flex-direction: row;
align-items: center;
}
.title {
display: flex;
flex-direction: row;

View File

@ -28,20 +28,41 @@ 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,
time2: new Date().toISOString(),
nested: {
test: 'test-data',
time: new Date(),
number: 1,
time2: new Date().toISOString(),
nested: {
object: {
test: 'test-data',
number: 1,
object: {
test: 'test-data',
},
},
array: [1, 2, 3],
},
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,6 +135,10 @@ 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>

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,43 @@
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}
showAllTime
/>
</FormRow>
</>
);
}
export default BaseParameters;

View File

@ -0,0 +1,20 @@
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);
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,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,71 @@
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: 'Event data',
description: 'Query your custom event data.',
url: '/reports/event-data',
icon: <Nodes />,
},
{
title: 'Funnel',
description: 'Understand the conversion and drop-off rate of users.',
url: '/reports/funnel',
icon: <Funnel />,
},
{
title: 'Insights',
description: 'Explore your data by applying segments and filters.',
url: '/reports/insights',
icon: <Lightbulb />,
},
];
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,32 @@
.reports {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 20px;
}
.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 ReportsList() {
const { formatMessage, labels } = useMessages();
const { reports, error, isLoading } = useReports();
return (
<Page loading={isLoading} error={error}>
<PageHeader title="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 ReportsList;

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,60 @@
import { useContext, useRef } from 'react';
import { useApi, useMessages } from 'hooks';
import { Form, FormRow, FormButtons, SubmitButton, Loading } from 'react-basics';
import { ReportContext } from 'components/pages/reports/Report';
import NoData from 'components/common/NoData';
import styles from './EventDataParameters.module.css';
import { DATA_TYPES } from 'lib/constants';
import BaseParameters from '../BaseParameters';
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, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const ref = useRef(null);
const { parameters } = report || {};
const { websiteId, dateRange } = parameters || {};
const { startDate, endDate } = dateRange || {};
const queryDisabled = !websiteId || !dateRange;
const { data, error, isLoading } = useFields(websiteId, startDate, endDate);
const handleSubmit = values => {
runReport(values);
};
return (
<Form ref={ref} values={parameters} error={error} onSubmit={handleSubmit}>
<BaseParameters />
<FormRow label={formatMessage(labels.fields)}>
<div className={styles.fields}>
{!data?.length && <NoData />}
{data?.map?.(({ eventKey, eventDataType }) => {
return (
<div className={styles.field} key={eventKey}>
<div className={styles.key}>{eventKey}</div>
<div className={styles.type}>{DATA_TYPES[eventDataType]}</div>
</div>
);
})}
</div>
</FormRow>
<FormButtons>
<SubmitButton variant="primary" disabled={queryDisabled} loading={isRunning}>
{formatMessage(labels.runQuery)}
</SubmitButton>
</FormButtons>
</Form>
);
}
export default EventDataParameters;

View File

@ -0,0 +1,27 @@
.fields {
max-height: 300px;
overflow: auto;
}
.field {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
height: 30px;
cursor: pointer;
padding: 4px;
border-radius: var(--border-radius);
}
.field:hover {
background: var(--base75);
}
.key {
font-weight: 400;
}
.type {
color: var(--font-color300);
}

View File

@ -0,0 +1,23 @@
import Report from '../Report';
import ReportHeader from '../ReportHeader';
import ReportMenu from '../ReportMenu';
import ReportBody from '../ReportBody';
import EventDataParameters from './EventDataParameters';
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>hi.</ReportBody>
</Report>
);
}

View File

@ -0,0 +1,28 @@
import { useMessages } from 'hooks';
import { Button, Form, FormButtons, FormRow } from 'react-basics';
export function FieldAddForm({ onClose }) {
const { formatMessage, labels } = useMessages();
const handleSave = () => {
onClose();
};
const handleClose = () => {
onClose();
};
return (
<Form>
<FormRow label={formatMessage(labels.url)}></FormRow>
<FormButtons align="center" flex>
<Button variant="primary" onClick={handleSave}>
{formatMessage(labels.save)}
</Button>
<Button onClick={handleClose}>{formatMessage(labels.cancel)}</Button>
</FormButtons>
</Form>
);
}
export default FieldAddForm;

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,109 @@
import { useContext, useRef } from 'react';
import { useMessages } from 'hooks';
import {
Icon,
Form,
FormButtons,
FormInput,
FormRow,
PopupTrigger,
Popup,
SubmitButton,
Text,
TextField,
TooltipPopup,
} from 'react-basics';
import Icons from 'components/icons';
import UrlAddForm from './UrlAddForm';
import { ReportContext } from 'components/pages/reports/Report';
import styles from './FunnelParameters.module.css';
import BaseParameters from '../BaseParameters';
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 => {
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 } });
};
return (
<Form ref={ref} values={parameters} onSubmit={handleSubmit}>
<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 onAdd={handleAddUrl} />}>
<div className={styles.urls}>
{parameters?.urls?.map((url, index) => {
return (
<div key={index} className={styles.url}>
<Text>{url}</Text>
<TooltipPopup
className={styles.icon}
label={formatMessage(labels.remove)}
position="right"
>
<Icon onClick={handleRemoveUrl.bind(null, index)}>
<Icons.Close />
</Icon>
</TooltipPopup>
</div>
);
})}
</div>
</FormRow>
<FormButtons>
<SubmitButton variant="primary" disabled={queryDisabled} loading={isRunning}>
{formatMessage(labels.runQuery)}
</SubmitButton>
</FormButtons>
</Form>
);
}
function AddUrlButton({ onAdd }) {
const { formatMessage, labels } = useMessages();
return (
<PopupTrigger>
<TooltipPopup label={formatMessage(labels.addUrl)}>
<Icon>
<Icons.Plus />
</Icon>
</TooltipPopup>
<Popup position="bottom" alignment="start">
{(close, element) => {
const { right, bottom } = element.getBoundingClientRect();
return <UrlAddForm onSave={onAdd} onClose={close} style={{ left: right, top: bottom }} />;
}}
</Popup>
</PopupTrigger>
);
}
export default FunnelParameters;

View File

@ -0,0 +1,20 @@
.urls {
display: flex;
flex-direction: column;
gap: 16px;
}
.url {
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);
}
.icon {
align-self: center;
}

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>
);
}

View File

@ -0,0 +1,10 @@
.filters {
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid var(--base400);
border-radius: var(--border-radius);
line-height: 32px;
padding: 10px;
overflow: hidden;
}

View File

@ -0,0 +1,20 @@
import { useContext } from 'react';
import DataTable from 'components/metrics/DataTable';
import { useMessages } from 'hooks';
import { ReportContext } from '../Report';
export function FunnelTable() {
const { report } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
return (
<DataTable
data={report?.data}
title={formatMessage(labels.url)}
metric={formatMessage(labels.visitors)}
showPercentage={false}
/>
);
}
export default FunnelTable;

View File

@ -0,0 +1,48 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { useMessages } from 'hooks';
import { Button, Form, FormRow, TextField, Flexbox } from 'react-basics';
import styles from './UrlAddForm.module.css';
export function UrlAddForm({ defaultValue = '', style, onSave, onClose }) {
const [url, setUrl] = useState(defaultValue);
const { formatMessage, labels } = useMessages();
const handleSave = e => {
e?.stopPropagation?.();
onSave?.(url);
setUrl('');
onClose();
};
const handleChange = e => {
setUrl(e.target.value);
};
const handleClick = e => {
e.stopPropagation();
};
return createPortal(
<Form className={styles.form} onSubmit={handleSave} style={style} onClick={handleClick}>
<FormRow label={formatMessage(labels.url)}>
<Flexbox gap={10}>
<TextField
className={styles.input}
name="url"
value={url}
onChange={handleChange}
autoFocus={true}
autoComplete="off"
/>
<Button variant="primary" onClick={handleSave}>
{formatMessage(labels.add)}
</Button>
</Flexbox>
</FormRow>
</Form>,
document.body,
);
}
export default UrlAddForm;

View File

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

View File

@ -0,0 +1,25 @@
.container {
display: grid;
grid-template-rows: max-content 1fr;
grid-template-columns: max-content 1fr;
}
.header {
grid-row: 1 / 2;
grid-column: 1 / 3;
margin-bottom: 40px;
}
.menu {
width: 300px;
padding-right: 20px;
border-right: 1px solid var(--base300);
grid-row: 2/3;
grid-column: 1 / 2;
}
.body {
padding-left: 20px;
grid-row: 2/3;
grid-column: 2 / 3;
}

View File

@ -7,13 +7,19 @@ import useMessages from 'hooks/useMessages';
export function DateRangeSetting() {
const { formatMessage, labels } = useMessages();
const [dateRange, setDateRange] = useDateRange();
const { startDate, endDate, value } = dateRange;
const { value } = dateRange;
const handleChange = value => setDateRange(value);
const handleReset = () => setDateRange(DEFAULT_DATE_RANGE);
return (
<Flexbox gap={10}>
<DateFilter value={value} startDate={startDate} endDate={endDate} />
<DateFilter
value={value}
startDate={dateRange.startDate}
endDate={dateRange.endDate}
onChange={handleChange}
/>
<Button onClick={handleReset}>{formatMessage(labels.reset)}</Button>
</Flexbox>
);

View File

@ -1,11 +1,11 @@
import { Button, Icon, Text, useToast, ModalTrigger, Modal } from 'react-basics';
import { Button, Icon, Text, useToasts, ModalTrigger, Modal } from 'react-basics';
import PasswordEditForm from 'components/pages/settings/profile/PasswordEditForm';
import Icons from 'components/icons';
import useMessages from 'hooks/useMessages';
export function PasswordChangeButton() {
const { formatMessage, labels, messages } = useMessages();
const { toast, showToast } = useToast();
const { showToast } = useToasts();
const handleSave = () => {
showToast({ message: formatMessage(messages.saved), variant: 'success' });
@ -13,7 +13,6 @@ export function PasswordChangeButton() {
return (
<>
{toast}
<ModalTrigger>
<Button>
<Icon>

View File

@ -6,13 +6,13 @@ import Moon from 'assets/moon.svg';
import styles from './ThemeSetting.module.css';
export function ThemeSetting() {
const [theme, setTheme] = useTheme();
const { theme, saveTheme } = useTheme();
return (
<div className={styles.buttons}>
<Button
className={classNames({ [styles.active]: theme === 'light' })}
onClick={() => setTheme('light')}
onClick={() => saveTheme('light')}
>
<Icon>
<Sun />
@ -20,7 +20,7 @@ export function ThemeSetting() {
</Button>
<Button
className={classNames({ [styles.active]: theme === 'dark' })}
onClick={() => setTheme('dark')}
onClick={() => saveTheme('dark')}
>
<Icon>
<Moon />

View File

@ -5,7 +5,7 @@ import useMessages from 'hooks/useMessages';
export function TeamLeaveForm({ teamId, userId, teamName, onSave, onClose }) {
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
const { del, useMutation } = useApi();
const { mutate, error, isLoading } = useMutation(() => del(`/team/${teamId}/users/${userId}`));
const { mutate, error, isLoading } = useMutation(() => del(`/teams/${teamId}/users/${userId}`));
const handleSubmit = async () => {
mutate(
@ -22,7 +22,7 @@ export function TeamLeaveForm({ teamId, userId, teamName, onSave, onClose }) {
return (
<Form onSubmit={handleSubmit} error={error}>
<p>
<FormattedMessage {...messages.confirmLeave} values={{ name: <b>{teamName}</b> }} />
<FormattedMessage {...messages.confirmDelete} values={{ target: <b>{teamName}</b> }} />
</p>
<FormButtons flex>
<SubmitButton variant="danger" disabled={isLoading}>

View File

@ -5,7 +5,7 @@ import { Icon, Icons, LoadingButton, Text } from 'react-basics';
export function TeamMemberRemoveButton({ teamId, userId, disabled, onSave }) {
const { formatMessage, labels } = useMessages();
const { del, useMutation } = useApi();
const { mutate, isLoading } = useMutation(() => del(`/team/${teamId}/users/${userId}`));
const { mutate, isLoading } = useMutation(() => del(`/teams/${teamId}/users/${userId}`));
const handleRemoveTeamMember = () => {
mutate(

View File

@ -1,10 +1,10 @@
import { Loading, useToast } from 'react-basics';
import { Loading, useToasts } from 'react-basics';
import TeamMembersTable from 'components/pages/settings/teams/TeamMembersTable';
import useApi from 'hooks/useApi';
import useMessages from 'hooks/useMessages';
export function TeamMembers({ teamId, readOnly }) {
const { toast, showToast } = useToast();
const { showToast } = useToasts();
const { get, useQuery } = useApi();
const { formatMessage, messages } = useMessages();
const { data, isLoading, refetch } = useQuery(['teams:users', teamId], () =>
@ -22,7 +22,6 @@ export function TeamMembers({ teamId, readOnly }) {
return (
<>
{toast}
<TeamMembersTable onSave={handleSave} data={data} readOnly={readOnly} />
</>
);

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Breadcrumbs, Item, Tabs, useToast } from 'react-basics';
import { Breadcrumbs, Item, Tabs, useToasts } from 'react-basics';
import Link from 'next/link';
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
@ -17,7 +17,7 @@ export function TeamSettings({ teamId }) {
const [values, setValues] = useState(null);
const [tab, setTab] = useState('details');
const { get, useQuery } = useApi();
const { toast, showToast } = useToast();
const { showToast } = useToasts();
const { data, isLoading } = useQuery(
['team', teamId],
() => {
@ -44,7 +44,6 @@ export function TeamSettings({ teamId }) {
return (
<Page loading={isLoading || !values}>
{toast}
<PageHeader
title={
<Breadcrumbs>

View File

@ -7,7 +7,7 @@ import {
Modal,
ModalTrigger,
Text,
useToast,
useToasts,
} from 'react-basics';
import TeamWebsitesTable from 'components/pages/settings/teams/TeamWebsitesTable';
import TeamAddWebsiteForm from 'components/pages/settings/teams/TeamAddWebsiteForm';
@ -15,7 +15,7 @@ import useApi from 'hooks/useApi';
import useMessages from 'hooks/useMessages';
export function TeamWebsites({ teamId }) {
const { toast, showToast } = useToast();
const { showToast } = useToasts();
const { formatMessage, labels, messages } = useMessages();
const { get, useQuery } = useApi();
const { data, isLoading, refetch } = useQuery(['teams:websites', teamId], () =>
@ -48,7 +48,6 @@ export function TeamWebsites({ teamId }) {
return (
<div>
{toast}
<ActionForm description={formatMessage(messages.teamWebsitesInfo)}>{addButton}</ActionForm>
{hasData && <TeamWebsitesTable teamId={teamId} data={data} onSave={handleSave} />}
</div>

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Button, Icon, Modal, ModalTrigger, useToast, Text, Flexbox } from 'react-basics';
import { Button, Icon, Modal, ModalTrigger, useToasts, Text, Flexbox } from 'react-basics';
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
import TeamAddForm from 'components/pages/settings/teams/TeamAddForm';
import PageHeader from 'components/layout/PageHeader';
@ -16,7 +16,7 @@ export default function TeamsList() {
const { get, useQuery } = useApi();
const { data, isLoading, error } = useQuery(['teams', update], () => get(`/teams`));
const hasData = data && data.length !== 0;
const { toast, showToast } = useToast();
const { showToast } = useToasts();
const handleSave = () => {
setUpdate(state => state + 1);
@ -63,7 +63,6 @@ export default function TeamsList() {
return (
<Page loading={isLoading} error={error}>
{toast}
<PageHeader title={formatMessage(labels.teams)}>
{hasData && (
<Flexbox gap={10}>

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Breadcrumbs, Item, Tabs, useToast } from 'react-basics';
import { Breadcrumbs, Item, Tabs, useToasts } from 'react-basics';
import Link from 'next/link';
import UserEditForm from 'components/pages/settings/users//UserEditForm';
import Page from 'components/layout/Page';
@ -14,7 +14,7 @@ export function UserSettings({ userId }) {
const [values, setValues] = useState(null);
const [tab, setTab] = useState('details');
const { get, useQuery } = useApi();
const { toast, showToast } = useToast();
const { showToast } = useToasts();
const { data, isLoading } = useQuery(
['user', userId],
() => {
@ -44,7 +44,6 @@ export function UserSettings({ userId }) {
return (
<Page loading={isLoading || !values}>
{toast}
<PageHeader
title={
<Breadcrumbs>

View File

@ -1,4 +1,4 @@
import { useToast } from 'react-basics';
import { useToasts } from 'react-basics';
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
@ -15,7 +15,7 @@ export function UsersList() {
const { data, isLoading, error, refetch } = useQuery(['user'], () => get(`/users`), {
enabled: !!user,
});
const { toast, showToast } = useToast();
const { showToast } = useToasts();
const hasData = data && data.length !== 0;
const handleSave = () => {
@ -30,7 +30,6 @@ export function UsersList() {
return (
<Page loading={isLoading} error={error}>
{toast}
<PageHeader title={formatMessage(labels.users)}>
<UserAddButton onSave={handleSave} />
</PageHeader>

View File

@ -4,10 +4,12 @@ import useConfig from 'hooks/useConfig';
export function TrackingCode({ websiteId }) {
const { formatMessage, messages } = useMessages();
const { trackerScriptName } = useConfig();
const { basePath, trackerScriptName } = useConfig();
const url = trackerScriptName?.startsWith('http')
? trackerScriptName
: `${location.origin}/${trackerScriptName?.split(',')?.map(n => n.trim())?.[0] || 'script.js'}`;
: `${location.origin}${basePath}/${
trackerScriptName?.split(',')?.map(n => n.trim())?.[0] || 'script.js'
}`;
const code = `<script async src="${url}" data-website-id="${websiteId}"></script>`;

View File

@ -13,7 +13,7 @@ import useMessages from 'hooks/useMessages';
const CONFIRM_VALUE = 'RESET';
export function WebsiteResetForm({ websiteId, onSave, onClose }) {
const { formatMessage, labels, messages } = useMessages();
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
const { post, useMutation } = useApi();
const { mutate, error } = useMutation(data => post(`/websites/${websiteId}/reset`, data));
@ -28,7 +28,12 @@ export function WebsiteResetForm({ websiteId, onSave, onClose }) {
return (
<Form onSubmit={handleSubmit} error={error}>
<p>{formatMessage(messages.resetWebsite, { confirmation: CONFIRM_VALUE })}</p>
<p>
<FormattedMessage
{...messages.resetWebsite}
values={{ confirmation: <b>{CONFIRM_VALUE}</b> }}
/>
</p>
<FormRow label={formatMessage(labels.confirm)}>
<FormInput name="confirm" rules={{ validate: value => value === CONFIRM_VALUE }}>
<TextField autoComplete="off" />

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Breadcrumbs, Item, Tabs, useToast, Button, Text, Icon, Icons } from 'react-basics';
import { Breadcrumbs, Item, Tabs, useToasts, Button, Text, Icon, Icons } from 'react-basics';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Page from 'components/layout/Page';
@ -17,7 +17,7 @@ export function WebsiteSettings({ websiteId }) {
const { formatMessage, labels, messages } = useMessages();
const { openExternal } = useConfig();
const { get, useQuery } = useApi();
const { toast, showToast } = useToast();
const { showToast } = useToasts();
const { data, isLoading } = useQuery(
['website', websiteId],
() => get(`/websites/${websiteId}`),
@ -51,7 +51,6 @@ export function WebsiteSettings({ websiteId }) {
return (
<Page loading={isLoading || !values}>
{toast}
<PageHeader
title={
<Breadcrumbs>

View File

@ -1,4 +1,4 @@
import { Button, Icon, Text, Modal, ModalTrigger, useToast, Icons } from 'react-basics';
import { Button, Icon, Text, Modal, ModalTrigger, useToasts, Icons } from 'react-basics';
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
@ -17,7 +17,7 @@ export function WebsitesList() {
() => get(`/users/${user?.id}/websites`),
{ enabled: !!user },
);
const { toast, showToast } = useToast();
const { showToast } = useToasts();
const hasData = data && data.length !== 0;
const handleSave = async () => {
@ -41,7 +41,6 @@ export function WebsitesList() {
return (
<Page loading={isLoading} error={error}>
{toast}
<PageHeader title={formatMessage(labels.websites)}>{addButton}</PageHeader>
{hasData && <WebsitesTable data={data} />}
{!hasData && (

View File

@ -0,0 +1,4 @@
ALTER TABLE "event_data" RENAME COLUMN "event_date_value" TO "date_value";
ALTER TABLE "event_data" RENAME COLUMN "event_numeric_value" TO "numeric_value";
ALTER TABLE "event_data" RENAME COLUMN "event_string_value" TO "string_value";
ALTER TABLE "event_data" RENAME COLUMN "event_data_type" TO "data_type";

View File

@ -0,0 +1,2 @@
ALTER TABLE "website_event" ADD COLUMN "job_id" UUID AFTER "created_at";
ALTER TABLE "event_data" ADD COLUMN "job_id" UUID AFTER "created_at";

View File

@ -27,7 +27,8 @@ CREATE TABLE umami.website_event
--event
event_type UInt32,
event_name String,
created_at DateTime('UTC')
created_at DateTime('UTC'),
job_id UUID
)
engine = MergeTree
ORDER BY (website_id, session_id, created_at)
@ -58,7 +59,10 @@ CREATE TABLE umami.website_event_queue (
--event
event_type UInt32,
event_name String,
created_at DateTime('UTC')
created_at DateTime('UTC'),
--virtual columns
_error String,
_raw_message String
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'domain:9092,domain:9093,domain:9094', -- input broker list
@ -66,7 +70,7 @@ SETTINGS kafka_broker_list = 'domain:9092,domain:9093,domain:9094', -- input bro
kafka_group_name = 'event_consumer_group',
kafka_format = 'JSONEachRow',
kafka_max_block_size = 1048576,
kafka_skip_broken_messages = 100;
kafka_handle_error_mode = 'stream'
CREATE MATERIALIZED VIEW umami.website_event_queue_mv TO umami.website_event AS
SELECT website_id,
@ -93,6 +97,19 @@ SELECT website_id,
created_at
FROM umami.website_event_queue;
CREATE MATERIALIZED VIEW umami.website_event_errors_mv
(
error String,
raw String
)
ENGINE = MergeTree
ORDER BY (error, raw)
SETTINGS index_granularity = 8192 AS
SELECT _error AS error,
_raw_message AS raw
FROM umami.website_event_queue
WHERE length(_error) > 0
CREATE TABLE umami.event_data
(
website_id UUID,
@ -101,11 +118,12 @@ CREATE TABLE umami.event_data
url_path String,
event_name String,
event_key String,
event_string_value Nullable(String),
event_numeric_value Nullable(Decimal64(4)), --922337203685477.5625
event_date_value Nullable(DateTime('UTC')),
event_data_type UInt32,
created_at DateTime('UTC')
string_value Nullable(String),
numeric_value Nullable(Decimal64(4)), --922337203685477.5625
date_value Nullable(DateTime('UTC')),
data_type UInt32,
created_at DateTime('UTC'),
job_id UUID
)
engine = MergeTree
ORDER BY (website_id, event_id, event_key, created_at)
@ -118,11 +136,14 @@ CREATE TABLE umami.event_data_queue (
url_path String,
event_name String,
event_key String,
event_string_value Nullable(String),
event_numeric_value Nullable(Decimal64(4)), --922337203685477.5625
event_date_value Nullable(DateTime('UTC')),
event_data_type UInt32,
created_at DateTime('UTC')
string_value Nullable(String),
numeric_value Nullable(Decimal64(4)), --922337203685477.5625
date_value Nullable(DateTime('UTC')),
data_type UInt32,
created_at DateTime('UTC'),
--virtual columns
_error String,
_raw_message String
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'domain:9092,domain:9093,domain:9094', -- input broker list
@ -130,7 +151,7 @@ SETTINGS kafka_broker_list = 'domain:9092,domain:9093,domain:9094', -- input bro
kafka_group_name = 'event_data_consumer_group',
kafka_format = 'JSONEachRow',
kafka_max_block_size = 1048576,
kafka_skip_broken_messages = 100;
kafka_handle_error_mode = 'stream'
CREATE MATERIALIZED VIEW umami.event_data_queue_mv TO umami.event_data AS
SELECT website_id,
@ -139,9 +160,22 @@ SELECT website_id,
url_path,
event_name,
event_key,
event_string_value,
event_numeric_value,
event_date_value,
event_data_type,
string_value,
numeric_value,
date_value,
data_type,
created_at
FROM umami.event_data_queue;
FROM umami.event_data_queue;
CREATE MATERIALIZED VIEW umami.event_data_errors_mv
(
error String,
raw String
)
ENGINE = MergeTree
ORDER BY (error, raw)
SETTINGS index_granularity = 8192 AS
SELECT _error AS error,
_raw_message AS raw
FROM umami.event_data_queue
WHERE length(_error) > 0

View File

@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE `report` (
`report_id` VARCHAR(36) NOT NULL,
`user_id` VARCHAR(36) NOT NULL,
`website_id` VARCHAR(36) NOT NULL,
`type` VARCHAR(200) NOT NULL,
`name` VARCHAR(200) NOT NULL,
`description` VARCHAR(500) NOT NULL,
`parameters` VARCHAR(6000) NOT NULL,
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` TIMESTAMP(0) NULL,
UNIQUE INDEX `report_report_id_key`(`report_id`),
INDEX `report_user_id_idx`(`user_id`),
INDEX `report_website_id_idx`(`website_id`),
INDEX `report_type_idx`(`type`),
INDEX `report_name_idx`(`name`),
PRIMARY KEY (`report_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -0,0 +1,37 @@
/*
Warnings:
- The primary key for the `event_data` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `event_data_type` on the `event_data` table. All the data in the column will be lost.
- You are about to drop the column `event_date_value` on the `event_data` table. All the data in the column will be lost.
- You are about to drop the column `event_id` on the `event_data` table. All the data in the column will be lost.
- You are about to drop the column `event_numeric_value` on the `event_data` table. All the data in the column will be lost.
- You are about to drop the column `event_string_value` on the `event_data` table. All the data in the column will be lost.
- Added the required column `data_type` to the `event_data` table without a default value. This is not possible if the table is not empty.
- Added the required column `event_data_id` to the `event_data` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE `event_data` RENAME COLUMN `event_data_type` TO `data_type`;
ALTER TABLE `event_data` RENAME COLUMN `event_date_value` TO `date_value`;
ALTER TABLE `event_data` RENAME COLUMN `event_id` TO `event_data_id`;
ALTER TABLE `event_data` RENAME COLUMN `event_numeric_value` TO `numeric_value`;
ALTER TABLE `event_data` RENAME COLUMN `event_string_value` TO `string_value`;
-- CreateTable
CREATE TABLE `session_data` (
`session_data_id` VARCHAR(36) NOT NULL,
`website_id` VARCHAR(36) NOT NULL,
`session_id` VARCHAR(36) NOT NULL,
`event_key` VARCHAR(500) NOT NULL,
`event_string_value` VARCHAR(500) NULL,
`event_numeric_value` DECIMAL(19, 4) NULL,
`event_date_value` TIMESTAMP(0) NULL,
`event_data_type` INTEGER UNSIGNED NOT NULL,
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
INDEX `session_data_created_at_idx`(`created_at`),
INDEX `session_data_website_id_idx`(`website_id`),
INDEX `session_data_session_id_idx`(`session_id`),
PRIMARY KEY (`session_data_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -14,11 +14,12 @@ model User {
password String @db.VarChar(60)
role String @map("role") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
deletedAt DateTime? @map("deleted_at") @db.Timestamp(0)
website Website[]
teamUser TeamUser[]
report Report[]
@@map("user")
}
@ -39,6 +40,7 @@ model Session {
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
websiteEvent WebsiteEvent[]
sessionData SessionData[]
@@index([createdAt])
@@index([websiteId])
@ -53,12 +55,14 @@ model Website {
resetAt DateTime? @map("reset_at") @db.Timestamp(0)
userId String? @map("user_id") @db.VarChar(36)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
deletedAt DateTime? @map("deleted_at") @db.Timestamp(0)
user User? @relation(fields: [userId], references: [id])
teamWebsite TeamWebsite[]
eventData EventData[]
report Report[]
sessionData SessionData[]
@@index([userId])
@@index([createdAt])
@ -92,15 +96,15 @@ model WebsiteEvent {
}
model EventData {
id String @id() @map("event_id") @db.VarChar(36)
websiteEventId String @map("website_event_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
eventKey String @map("event_key") @db.VarChar(500)
eventStringValue String? @map("event_string_value") @db.VarChar(500)
eventNumericValue Decimal? @map("event_numeric_value") @db.Decimal(19, 4)
eventDateValue DateTime? @map("event_date_value") @db.Timestamp(0)
eventDataType Int @map("event_data_type") @db.UnsignedInt
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
id String @id() @map("event_data_id") @db.VarChar(36)
websiteEventId String @map("website_event_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
eventKey String @map("event_key") @db.VarChar(500)
stringValue String? @map("string_value") @db.VarChar(500)
numericValue Decimal? @map("numeric_value") @db.Decimal(19, 4)
dateValue DateTime? @map("date_value") @db.Timestamp(0)
dataType Int @map("data_type") @db.UnsignedInt
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
website Website @relation(fields: [websiteId], references: [id])
websiteEvent WebsiteEvent @relation(fields: [websiteEventId], references: [id])
@ -112,12 +116,32 @@ model EventData {
@@map("event_data")
}
model SessionData {
id String @id() @map("session_data_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
sessionId String @map("session_id") @db.VarChar(36)
eventKey String @map("event_key") @db.VarChar(500)
eventStringValue String? @map("event_string_value") @db.VarChar(500)
eventNumericValue Decimal? @map("event_numeric_value") @db.Decimal(19, 4)
eventDateValue DateTime? @map("event_date_value") @db.Timestamp(0)
eventDataType Int @map("event_data_type") @db.UnsignedInt
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
website Website @relation(fields: [websiteId], references: [id])
session Session @relation(fields: [sessionId], references: [id])
@@index([createdAt])
@@index([websiteId])
@@index([sessionId])
@@map("session_data")
}
model Team {
id String @id() @unique() @map("team_id") @db.VarChar(36)
name String @db.VarChar(50)
accessCode String? @unique @map("access_code") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
teamUser TeamUser[]
teamWebsite TeamWebsite[]
@ -132,7 +156,7 @@ model TeamUser {
userId String @map("user_id") @db.VarChar(36)
role String @map("role") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
team Team @relation(fields: [teamId], references: [id])
user User @relation(fields: [userId], references: [id])
@ -155,3 +179,24 @@ model TeamWebsite {
@@index([websiteId])
@@map("team_website")
}
model Report {
id String @id() @unique() @map("report_id") @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
type String @map("type") @db.VarChar(200)
name String @map("name") @db.VarChar(200)
description String @map("description") @db.VarChar(500)
parameters String @map("parameters") @db.VarChar(6000)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
user User @relation(fields: [userId], references: [id])
website Website @relation(fields: [websiteId], references: [id])
@@index([userId])
@@index([websiteId])
@@index([type])
@@index([name])
@@map("report")
}

View File

@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "report" (
"report_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"website_id" UUID NOT NULL,
"type" VARCHAR(200) NOT NULL,
"name" VARCHAR(200) NOT NULL,
"description" VARCHAR(500) NOT NULL,
"parameters" VARCHAR(6000) NOT NULL,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6),
CONSTRAINT "report_pkey" PRIMARY KEY ("report_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "report_report_id_key" ON "report"("report_id");
-- CreateIndex
CREATE INDEX "report_user_id_idx" ON "report"("user_id");
-- CreateIndex
CREATE INDEX "report_website_id_idx" ON "report"("website_id");
-- CreateIndex
CREATE INDEX "report_type_idx" ON "report"("type");
-- CreateIndex
CREATE INDEX "report_name_idx" ON "report"("name");

View File

@ -0,0 +1,31 @@
-- AlterTable
ALTER TABLE "event_data" RENAME COLUMN "event_data_type" TO "data_type";
ALTER TABLE "event_data" RENAME COLUMN "event_date_value" TO "date_value";
ALTER TABLE "event_data" RENAME COLUMN "event_id" TO "event_data_id";
ALTER TABLE "event_data" RENAME COLUMN "event_numeric_value" TO "numeric_value";
ALTER TABLE "event_data" RENAME COLUMN "event_string_value" TO "string_value";
-- CreateTable
CREATE TABLE "session_data" (
"session_data_id" UUID NOT NULL,
"website_id" UUID NOT NULL,
"session_id" UUID NOT NULL,
"session_key" VARCHAR(500) NOT NULL,
"string_value" VARCHAR(500),
"numeric_value" DECIMAL(19,4),
"date_value" TIMESTAMPTZ(6),
"data_type" INTEGER NOT NULL,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "session_data_pkey" PRIMARY KEY ("session_data_id")
);
-- CreateIndex
CREATE INDEX "session_data_created_at_idx" ON "session_data"("created_at");
-- CreateIndex
CREATE INDEX "session_data_website_id_idx" ON "session_data"("website_id");
-- CreateIndex
CREATE INDEX "session_data_session_id_idx" ON "session_data"("session_id");

View File

@ -14,11 +14,12 @@ model User {
password String @db.VarChar(60)
role String @map("role") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
website Website[]
teamUser TeamUser[]
report Report[]
@@map("user")
}
@ -39,6 +40,7 @@ model Session {
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
websiteEvent WebsiteEvent[]
sessionData SessionData[]
@@index([createdAt])
@@index([websiteId])
@ -53,12 +55,14 @@ model Website {
resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
userId String? @map("user_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
user User? @relation(fields: [userId], references: [id])
teamWebsite TeamWebsite[]
eventData EventData[]
report Report[]
sessionData SessionData[]
@@index([userId])
@@index([createdAt])
@ -92,15 +96,15 @@ model WebsiteEvent {
}
model EventData {
id String @id() @map("event_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
websiteEventId String @map("website_event_id") @db.Uuid
eventKey String @map("event_key") @db.VarChar(500)
eventStringValue String? @map("event_string_value") @db.VarChar(500)
eventNumericValue Decimal? @map("event_numeric_value") @db.Decimal(19, 4)
eventDateValue DateTime? @map("event_date_value") @db.Timestamptz(6)
eventDataType Int @map("event_data_type") @db.Integer
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
id String @id() @map("event_data_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
websiteEventId String @map("website_event_id") @db.Uuid
eventKey String @map("event_key") @db.VarChar(500)
stringValue String? @map("string_value") @db.VarChar(500)
numericValue Decimal? @map("numeric_value") @db.Decimal(19, 4)
dateValue DateTime? @map("date_value") @db.Timestamptz(6)
dataType Int @map("data_type") @db.Integer
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
websiteEvent WebsiteEvent @relation(fields: [websiteEventId], references: [id])
@ -111,12 +115,33 @@ model EventData {
@@map("event_data")
}
model SessionData {
id String @id() @map("session_data_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
sessionId String @map("session_id") @db.Uuid
sessionKey String @map("session_key") @db.VarChar(500)
stringValue String? @map("string_value") @db.VarChar(500)
numericValue Decimal? @map("numeric_value") @db.Decimal(19, 4)
dateValue DateTime? @map("date_value") @db.Timestamptz(6)
dataType Int @map("data_type") @db.Integer
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
deletedAt DateTime? @default(now()) @map("deleted_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
session Session @relation(fields: [sessionId], references: [id])
@@index([createdAt])
@@index([websiteId])
@@index([sessionId])
@@map("session_data")
}
model Team {
id String @id() @unique() @map("team_id") @db.Uuid
name String @db.VarChar(50)
accessCode String? @unique @map("access_code") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
teamUser TeamUser[]
teamWebsite TeamWebsite[]
@ -131,7 +156,7 @@ model TeamUser {
userId String @map("user_id") @db.Uuid
role String @map("role") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
team Team @relation(fields: [teamId], references: [id])
user User @relation(fields: [userId], references: [id])
@ -154,3 +179,24 @@ model TeamWebsite {
@@index([websiteId])
@@map("team_website")
}
model Report {
id String @id() @unique() @map("report_id") @db.Uuid
userId String @map("user_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
type String @map("type") @db.VarChar(200)
name String @map("name") @db.VarChar(200)
description String @map("description") @db.VarChar(500)
parameters String @map("parameters") @db.VarChar(6000)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
user User @relation(fields: [userId], references: [id])
website Website @relation(fields: [websiteId], references: [id])
@@index([userId])
@@index([websiteId])
@@index([type])
@@index([name])
@@map("report")
}

View File

@ -10,7 +10,8 @@ services:
DATABASE_TYPE: postgresql
APP_SECRET: replace-me-with-a-random-string
depends_on:
- db
db:
condition: service_healthy
restart: always
db:
image: postgres:15-alpine
@ -19,8 +20,12 @@ services:
POSTGRES_USER: umami
POSTGRES_PASSWORD: umami
volumes:
- ./sql/schema.postgresql.sql:/docker-entrypoint-initdb.d/schema.postgresql.sql:ro
- umami-db-data:/var/lib/postgresql/data
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
volumes:
umami-db-data:

19
hooks/index.js Normal file
View File

@ -0,0 +1,19 @@
export * from './useApi';
export * from './useConfig';
export * from './useCountryNames';
export * from './useDateRange';
export * from './useDocumentClick';
export * from './useEscapeKey';
export * from './useForceUpdate';
export * from './useLanguageNames';
export * from './useLocale';
export * from './useMessages';
export * from './usePageQuery';
export * from './useReport';
export * from './useReports';
export * from './useRequireLogin';
export * from './useShareToken';
export * from './useSticky';
export * from './useTheme';
export * from './useTimezone';
export * from './useUser';

View File

@ -7,7 +7,7 @@ import useStore from 'store/app';
const selector = state => state.shareToken;
export default function useApi() {
export function useApi() {
const { basePath } = useRouter();
const shareToken = useStore(selector);
@ -18,3 +18,5 @@ export default function useApi() {
return { get, post, put, del, ...reactQuery };
}
export default useApi;

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