Compare commits
276 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7658dbe85c | ||
|
|
1f9ef5d18c | ||
|
|
94a28b31d3 | ||
|
|
87c9aca5c4 | ||
|
|
afda29997d | ||
|
|
d1b3e997c1 | ||
|
|
be82f7046f | ||
|
|
45e9a9c6a7 | ||
|
|
942d074f99 | ||
|
|
63fa3995c5 | ||
|
|
76e5e07f79 | ||
|
|
b8f9e598a7 | ||
|
|
0ee4895e1a | ||
|
|
72d6b97383 | ||
|
|
3f22c655a5 | ||
|
|
4fea549a5a | ||
|
|
4c1d10f8b7 | ||
|
|
50d275e0ff | ||
|
|
98238fa180 | ||
|
|
c07bccd0dd | ||
|
|
7192c31136 | ||
|
|
56d7e71d90 | ||
|
|
30229d4b97 | ||
|
|
b2303468a4 | ||
|
|
af6dff57ed | ||
|
|
dd8b089c46 | ||
|
|
a5750d556a | ||
|
|
f5882bff9f | ||
|
|
f18cdc8278 | ||
|
|
a7ebbc22c0 | ||
|
|
346eecc928 | ||
|
|
abc485a9ef | ||
|
|
0292829805 | ||
|
|
4e2c8468f8 | ||
|
|
38cfd4315d | ||
|
|
b592695a49 | ||
|
|
0963201a32 | ||
|
|
4da840f2ec | ||
|
|
a1718875d9 | ||
|
|
e931235533 | ||
|
|
881a7800ce | ||
|
|
487c3ac7b4 | ||
|
|
0dd94be6e6 | ||
|
|
29a220b21e | ||
|
|
8cc2f07b95 | ||
|
|
88cec21df1 | ||
|
|
8183ae1e68 | ||
|
|
0f39cab26a | ||
|
|
a2e4ed9ee0 | ||
|
|
30b5db4200 | ||
|
|
bfeee8673c | ||
|
|
39b8dd84f1 | ||
|
|
19b7c7664a | ||
|
|
a3e74adf9c | ||
|
|
ad9aabcbf6 | ||
|
|
510bc2545a | ||
|
|
65c682c75d | ||
|
|
04acc0b18e | ||
|
|
852fea45a5 | ||
|
|
6f3e59e72e | ||
|
|
3960eaa8ad | ||
|
|
e4bdf7e4c3 | ||
|
|
afeaac1b0d | ||
|
|
8922507a64 | ||
|
|
13e94cb0f0 | ||
|
|
3923a06e9b | ||
|
|
6b5d23566c | ||
|
|
dbcda95823 | ||
|
|
fb89c87489 | ||
|
|
b59eea47e9 | ||
|
|
473331047d | ||
|
|
5af77ff63e | ||
|
|
e6e2340432 | ||
|
|
0b90c2fe3c | ||
|
|
a6d1797a4f | ||
|
|
d1abe1a91f | ||
|
|
b733cd2a68 | ||
|
|
88ebfc188c | ||
|
|
ab95772dd4 | ||
|
|
0d5dbc69ad | ||
|
|
8a359936d1 | ||
|
|
ffd2e96138 | ||
|
|
b8e434be9a | ||
|
|
835ab6208e | ||
|
|
617de36fec | ||
|
|
fb31fdcfff | ||
|
|
745a332e56 | ||
|
|
a10755f998 | ||
|
|
46bca2f787 | ||
|
|
cb928977c3 | ||
|
|
3b5a46a64a | ||
|
|
7d05a9d157 | ||
|
|
edc897d62a | ||
|
|
39c42e7bd5 | ||
|
|
7009a0ad02 | ||
|
|
3f26f1ab68 | ||
|
|
7082b88523 | ||
|
|
29bae329b4 | ||
|
|
f908b0b4a9 | ||
|
|
b38363ddf5 | ||
|
|
68d362d1b3 | ||
|
|
0a9474d00c | ||
|
|
6307e09dc3 | ||
|
|
f358bb9bb6 | ||
|
|
23b8f7229a | ||
|
|
78f979d23a | ||
|
|
ad8e9e1ead | ||
|
|
06768b6cdc | ||
|
|
91f69baacd | ||
|
|
0964ec4250 | ||
|
|
9ce2c89575 | ||
|
|
b630bddef0 | ||
|
|
30e428a8dc | ||
|
|
b700b96191 | ||
|
|
606eb0b035 | ||
|
|
ec974c3599 | ||
|
|
4c811c160b | ||
|
|
e140585362 | ||
|
|
7d56b7a6a2 | ||
|
|
070560c1e2 | ||
|
|
41037a01a1 | ||
|
|
caef67a0e1 | ||
|
|
5ac43dec6b | ||
|
|
9de299d841 | ||
|
|
2929b229c4 | ||
|
|
f06d7d78fc | ||
|
|
4d7cfbb7b9 | ||
|
|
b4c0620f17 | ||
|
|
b8c2e40f7a | ||
|
|
e866a1c22b | ||
|
|
f86a399840 | ||
|
|
36c4406af2 | ||
|
|
b2afd585bb | ||
|
|
24ae9d0e0d | ||
|
|
b479ca1bbf | ||
|
|
0a748346c5 | ||
|
|
fa7880552a | ||
|
|
06fb8bfab0 | ||
|
|
a876d77d42 | ||
|
|
e6bb58693f | ||
|
|
00e63cc80b | ||
|
|
e43f138945 | ||
|
|
73309e7021 | ||
|
|
80e3b0caa9 | ||
|
|
0a7f2b58d0 | ||
|
|
e953af2c1b | ||
|
|
126296d28f | ||
|
|
8dd10deecc | ||
|
|
f22e65ccc5 | ||
|
|
dfbc64fe33 | ||
|
|
9568566361 | ||
|
|
634cb641f1 | ||
|
|
204e1348b4 | ||
|
|
b73155a176 | ||
|
|
62c72b3ff9 | ||
|
|
79e956e930 | ||
|
|
b27cacf4e6 | ||
|
|
c2846ca595 | ||
|
|
e1953f2f9f | ||
|
|
314660d8a3 | ||
|
|
f516c53b7b | ||
|
|
dad8c521ee | ||
|
|
089d1a418e | ||
|
|
a08624b69b | ||
|
|
3ba6cd171b | ||
|
|
1828edf98b | ||
|
|
96c39dbba1 | ||
|
|
9403aebbb9 | ||
|
|
69bb6fb03c | ||
|
|
33b730e66b | ||
|
|
0ba44a406d | ||
|
|
3c77a727cd | ||
|
|
8e3ad2920f | ||
|
|
f4401d74a2 | ||
|
|
375330bac4 | ||
|
|
3b1ee0fd13 | ||
|
|
f5edf187fd | ||
|
|
5b7e93bcbb | ||
|
|
3b6a202538 | ||
|
|
cf1aa103e4 | ||
|
|
4eeebaa0c3 | ||
|
|
f285e92132 | ||
|
|
ac7ba7abd3 | ||
|
|
3c59551f88 | ||
|
|
628e471cec | ||
|
|
0be3dbecbf | ||
|
|
fa5a37ece2 | ||
|
|
db32afe741 | ||
|
|
e813b3246d | ||
|
|
86011c38ce | ||
|
|
fd5eca29cc | ||
|
|
a591b43600 | ||
|
|
cebb45484c | ||
|
|
e4e2c2a42a | ||
|
|
dfa1407102 | ||
|
|
e6adbf9c7b | ||
|
|
c3904ebd55 | ||
|
|
4c46a36c75 | ||
|
|
c253846b86 | ||
|
|
e7c2dbf237 | ||
|
|
525a371a6e | ||
|
|
6a9a698b7a | ||
|
|
4134d33dc4 | ||
|
|
5172ad4f4d | ||
|
|
be45448288 | ||
|
|
73739dde9d | ||
|
|
30b3ed80e2 | ||
|
|
8e56069b1a | ||
|
|
3ecdec9ca9 | ||
|
|
7b41a3ed0d | ||
|
|
5804d7a73b | ||
|
|
8b026099de | ||
|
|
d7e18d570f | ||
|
|
023f2b5f4a | ||
|
|
c003b655ec | ||
|
|
d499aa2f39 | ||
|
|
944996eb15 | ||
|
|
87b1f9caf9 | ||
|
|
748894b946 | ||
|
|
01e8a9ab1d | ||
|
|
a2034551ec | ||
|
|
6d26c3c8af | ||
|
|
518b4ce6c1 | ||
|
|
71bd4d0e58 | ||
|
|
0563a833eb | ||
|
|
ab07ffb108 | ||
|
|
79309cc537 | ||
|
|
9b9ed3e9ad | ||
|
|
1cb6b92d5c | ||
|
|
c1bdc30933 | ||
|
|
887ed45b4d | ||
|
|
1b88bad32d | ||
|
|
4c9efda9ca | ||
|
|
0c8ec73722 | ||
|
|
02db836003 | ||
|
|
46774bd114 | ||
|
|
ba1d6c4bd0 | ||
|
|
5a26c8c788 | ||
|
|
cc39043a68 | ||
|
|
93f22dfc54 | ||
|
|
376b39e247 | ||
|
|
6c32b64ac6 | ||
|
|
7cb10f5aa1 | ||
|
|
4bede171fa | ||
|
|
f72bc33871 | ||
|
|
bc27d7cded | ||
|
|
7b54c109f0 | ||
|
|
229c341d7a | ||
|
|
985b3af2e0 | ||
|
|
fc78b3bb43 | ||
|
|
af32669b32 | ||
|
|
e9505e24a0 | ||
|
|
d25bc72623 | ||
|
|
2c9f5c45f8 | ||
|
|
b5b92b947c | ||
|
|
7ae4766771 | ||
|
|
895ebb197d | ||
|
|
39b58c65ca | ||
|
|
b5f1783050 | ||
|
|
e6c9ad9470 | ||
|
|
3eb32145aa | ||
|
|
f3542f711b | ||
|
|
31c10b13b2 | ||
|
|
2e12b3ef73 | ||
|
|
10e0075044 | ||
|
|
96f4c991b1 | ||
|
|
a22aaba3fe | ||
|
|
5300da90f2 | ||
|
|
094e191900 | ||
|
|
807816d0f6 | ||
|
|
3a58b1d91c | ||
|
|
5c8f173600 | ||
|
|
669898986b | ||
|
|
cc1e1741fe | ||
|
|
d7b8e9f575 | ||
|
|
1842842029 |
@@ -1,51 +1,26 @@
|
|||||||
|
shared/node_modules
|
||||||
|
shared/.output
|
||||||
|
|
||||||
# Broker
|
scripts/node_modules
|
||||||
broker/node_modules
|
|
||||||
broker/scripts/start_dev.js
|
lyx-ui/node_modules
|
||||||
broker/ecosystem.config.cjs
|
lyx-ui/.nuxt
|
||||||
broker/ecosystem.config.example.cjs
|
lyx-ui/.output
|
||||||
broker/Dockerfile
|
|
||||||
broker/.gitignore
|
|
||||||
broker/dist
|
|
||||||
|
|
||||||
# Producer
|
|
||||||
producer/node_modules
|
producer/node_modules
|
||||||
producer/scripts/start_dev.js
|
producer/scripts/start_dev.js
|
||||||
producer/ecosystem.config.cjs
|
producer/ecosystem.config.cjs
|
||||||
producer/ecosystem.config.example.cjs
|
|
||||||
producer/Dockerfile
|
|
||||||
producer/.gitignore
|
|
||||||
producer/dist
|
|
||||||
|
|
||||||
# Dashboard
|
consumer/node_modules
|
||||||
|
consumer/scripts/start_dev.js
|
||||||
|
consumer/ecosystem.config.cjs
|
||||||
|
|
||||||
dashboard/node_modules
|
dashboard/node_modules
|
||||||
dashboard/.nuxt
|
dashboard/.nuxt
|
||||||
dashboard/.output
|
dashboard/.output
|
||||||
|
dashboard/explains
|
||||||
dashboard/tests
|
dashboard/tests
|
||||||
dashboard/.env.example
|
|
||||||
dashboard/.env
|
dashboard/.env
|
||||||
dashboard/.gitignore
|
dashboard/winston-*.ndjson
|
||||||
dashboard/ecosystem.config.cjs
|
dashboard/ecosystem.config.cjs
|
||||||
dashboard/out.pdf
|
|
||||||
dashboard/timeline.report.txt
|
|
||||||
dashboard/Dockerfile
|
|
||||||
dashboard/vitest.config.ts
|
|
||||||
dashboard/vitest.setup.ts
|
|
||||||
|
|
||||||
# Shared
|
|
||||||
shared/node_modules
|
|
||||||
shared/.gitignore
|
|
||||||
|
|
||||||
# Others
|
|
||||||
docs/*
|
|
||||||
landing/*
|
|
||||||
docker/*
|
|
||||||
dev/*
|
|
||||||
assets/*
|
|
||||||
CODE_OF_CONDUCT.md
|
|
||||||
LICENSE
|
|
||||||
readme.md
|
|
||||||
SECURITY.md
|
|
||||||
steps
|
|
||||||
docker-compose.yml
|
|
||||||
docker-compose.admin.yml
|
|
||||||
8
.gitignore
vendored
@@ -1,5 +1,11 @@
|
|||||||
steps
|
steps
|
||||||
PROCESS_EVENT
|
PROCESS_EVENT
|
||||||
|
**/node_modules/
|
||||||
docker
|
docker
|
||||||
dev
|
dev
|
||||||
docker-compose.admin.yml
|
docker-compose.admin.yml
|
||||||
|
full_reload.sh
|
||||||
|
build-all.sh
|
||||||
|
tmp
|
||||||
|
ecosystem.config.js
|
||||||
|
todo
|
||||||
47
CONTRIBUTING.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Contributing Guide
|
||||||
|
|
||||||
|
Contributions are welcome from anyone interested in improving this project, and there are multiple ways you can contribute that we will explain in detail. We know that working on Litlyx can be intimidating at first, so you can contact us and talk with the team on [Discord](https://discord.com/invite/9cQykjsmWX).
|
||||||
|
|
||||||
|
## Contributing - Code 🧑💻
|
||||||
|
|
||||||
|
Take a look at the [issues tab](https://github.com/litlyx/litlyx/issues) and look for those labeled with `help wanted`.
|
||||||
|
|
||||||
|
Please ensure that you are familiar with the technology we used in this project where it applies to your code. The technology stack used in this project includes:
|
||||||
|
|
||||||
|
- [TypeScript](https://www.typescriptlang.org)
|
||||||
|
- [Nuxt](https://nuxt.com)
|
||||||
|
- [Vue](https://vuejs.org/)
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com/)
|
||||||
|
- [Mintlify](https://mintlify.com/)
|
||||||
|
|
||||||
|
## Contributing - Ideas 💫
|
||||||
|
|
||||||
|
If you have an **Idea** or just want to share a thought with us, open a [Discussion](https://github.com/orgs/Litlyx/discussions) to discuss the changes that you would like to make or implement yourself contributing to this project.
|
||||||
|
|
||||||
|
## Contributing - Documentation 📚
|
||||||
|
|
||||||
|
We are changing our documentation. We decided to use [Mintlify](https://mintlify.com/).
|
||||||
|
In the future, we will need help in these areas:
|
||||||
|
|
||||||
|
- Use Cases
|
||||||
|
- Community Libraries
|
||||||
|
- General Documentation
|
||||||
|
- Improvements in Design
|
||||||
|
|
||||||
|
## Code of Conduct 👮♀️
|
||||||
|
|
||||||
|
Please read our [Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project, you agree to follow and accept these terms.
|
||||||
|
|
||||||
|
## License 👩⚖️
|
||||||
|
|
||||||
|
By contributing to this project, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE).
|
||||||
|
|
||||||
|
## Discord
|
||||||
|
|
||||||
|
If you want to interact with the team behind Litlyx (Founding Team) join our [Discord](https://discord.com/invite/9cQykjsmWX).
|
||||||
|
|
||||||
|
## Note
|
||||||
|
|
||||||
|
Note that this file can change multiple times based on the needs of the project in future phases. If you decide to contribute, always keep an eye on this file.
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to this project!
|
||||||
157
README.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/claim.png"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 align="center">
|
||||||
|
📚 <a href="https://docs.litlyx.com">Docs</a> 👾 <a href="https://discord.gg/9cQykjsmWX">Join Discord</a> 🌐 <a href="https://litlyx.com">Website</a> 🔥 <a href="https://dashboard.litlyx.com">Try Litlyx Cloud. It's Free forever.</a>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
#
|
||||||
|
<p align="center">
|
||||||
|
Litlys is a modern, developer-friendly, cookie-free analytics tool.<br>
|
||||||
|
Setup takes less than 30 seconds! Completely self-hostable with docker.<br>
|
||||||
|
Alternative to Google Analytics, Matomo, Umami, Plausible & Simple Analytics.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
#
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/dashboard-clip.png"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
#
|
||||||
|
|
||||||
|
## Get Started on our Cloud Version
|
||||||
|
|
||||||
|
Sign-up on [Litlyx.com](https://dashboard.litlyx.com) and create a project. Then simply use your `project_id` to connect Litlyx to your website.
|
||||||
|
|
||||||
|
## Universal Installation
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script defer data-project="your_project_id" src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
Importing Litlyx with a direct script instantly starts tracking `Visits`, `Top Pages`, `Bouncing Rate`, `Real-Time Online Users`, `Unique Visitors`, `Countries`, and `Average Session Duration`.
|
||||||
|
|
||||||
|
# All Javascript Runtimes
|
||||||
|
|
||||||
|
You can install Litlyx using `npm`, `pnpm` or any modern package managers:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm i litlyx-js
|
||||||
|
```
|
||||||
|
|
||||||
|
Litlyx natively works with all JavaScript / TypeScript frameworks. You can use Litlyx in all WordPress Websites by injecting JS code using a third party plug-in.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/tech.png" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
# Import using a package manager
|
||||||
|
|
||||||
|
First, Import litlyx-js library into your code:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { Lit } from 'litlyx-js';
|
||||||
|
```
|
||||||
|
|
||||||
|
Once imported, you need to initialize Litlyx:
|
||||||
|
|
||||||
|
```js
|
||||||
|
Lit.init('your_project_id');
|
||||||
|
```
|
||||||
|
|
||||||
|
After initialization, Litlyx will automatically track web analytics such as `Page visits`, `Real-Time Online Users`, `Unique Vistors`, and many more.
|
||||||
|
|
||||||
|
# Track Custom Events (Actions)
|
||||||
|
|
||||||
|
You aren't just limited to the built-in KPIs. With Litlyx, you can create your own events to track in your project.
|
||||||
|
|
||||||
|
```js
|
||||||
|
Lit.event('click_on_buy_item');
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want more specific tracking, you can use the `metadata` field, like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
Lit.event('click_on_buy_item', {
|
||||||
|
metadata: {
|
||||||
|
'product-name': 'Coca-Cola',
|
||||||
|
'price': 1.50,
|
||||||
|
'currency': 'EUR'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Litlyx makes it easy for you to tailor your analytics to your project's needs.
|
||||||
|
|
||||||
|
|
||||||
|
# Fire Your First Event with cURL
|
||||||
|
|
||||||
|
Want to quickly see how Litlyx works with events? Use the cURL command below to send a test event. Just replace the `project_id` with your actual project ID in your terminal.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://broker.litlyx.com/event" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"pid": "project_id",
|
||||||
|
"name": "testEvent1",
|
||||||
|
"metadata": "{\"test\": \"something\"}",
|
||||||
|
"website": "something",
|
||||||
|
"userAgent": "something"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
# Self-hosting with docker
|
||||||
|
|
||||||
|
To self-host the Litlyx dashboard, first **clone** this repository. (Litlyx's Docker images are hosted on DockerHub).
|
||||||
|
|
||||||
|
Then run the following command:
|
||||||
|
```bash
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
at localhost:3000 you will see your own instance of the Litlyx Dashboard.
|
||||||
|
|
||||||
|
## Forward data to your self-hosted instance with script tag
|
||||||
|
|
||||||
|
To forward your data on your self-hosted instance, you need to set up the following variables: `data-host`, `data-port`, `data-secure`(`true` if it is HTTPS or `false` if it is HTTP).
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script defer data-project="your_project_id"
|
||||||
|
data-host="your-host-name"
|
||||||
|
data-port="your-port"
|
||||||
|
data-secure="true/false"
|
||||||
|
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js">
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
# Read our docs
|
||||||
|
|
||||||
|
For more info on how to use litlyx read our [documentation](https://docs.litlyx.com).
|
||||||
|
|
||||||
|
|
||||||
|
# Stay updated with our roadmap
|
||||||
|
|
||||||
|
To keep track on what we are cooking behind the scene we have a public [Roadmap](https://litlyx.com/roadmap) for you to check.
|
||||||
|
|
||||||
|
|
||||||
|
# Join discord
|
||||||
|
|
||||||
|
If you need more information, want to interact with us or the community, need help, or have feedback to share, feel free to join us on Litlyx's [Discord](https://discord.gg/9cQykjsmWX) channel.
|
||||||
|
|
||||||
|
# Contribution
|
||||||
|
|
||||||
|
If you want to contribute to Litlyx's development, reach out to us on [Discord](https://discord.gg/9cQykjsmWX) in our `#contribution` channel.
|
||||||
|
|
||||||
|
### Thank you!
|
||||||
|
<a href="https://github.com/litlyx/litlyx/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=litlyx/litlyx" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
# License
|
||||||
|
|
||||||
|
Litlyx is licensed under the [Apache 2.0](/LICENSE) license.
|
||||||
BIN
assets/.DS_Store
vendored
Normal file
BIN
assets/agent.png
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 42 KiB |
BIN
assets/bg.png
|
Before Width: | Height: | Size: 180 KiB |
BIN
assets/claim.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
assets/dashboard-clip.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 144 KiB |
BIN
assets/tech.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
assets/techs.png
|
Before Width: | Height: | Size: 29 KiB |
@@ -1,43 +0,0 @@
|
|||||||
ARG NODE_VERSION=21
|
|
||||||
|
|
||||||
FROM node:${NODE_VERSION}-alpine as base
|
|
||||||
|
|
||||||
ENV NODE_ENV=development
|
|
||||||
|
|
||||||
# Build stage
|
|
||||||
|
|
||||||
FROM base as build
|
|
||||||
|
|
||||||
RUN npm install -g pnpm
|
|
||||||
|
|
||||||
COPY --link broker/package.json broker/pnpm-lock.yaml home/app/
|
|
||||||
|
|
||||||
COPY --link shared/package.json shared/pnpm-lock.yaml /home/shared/
|
|
||||||
|
|
||||||
WORKDIR /home/app
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
WORKDIR /home/shared
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY --link ../broker /home/app
|
|
||||||
|
|
||||||
COPY --link ../shared /home/shared
|
|
||||||
|
|
||||||
WORKDIR /home/app
|
|
||||||
|
|
||||||
RUN pnpm run build_all
|
|
||||||
|
|
||||||
RUN pnpm prune
|
|
||||||
|
|
||||||
# Final stage
|
|
||||||
|
|
||||||
FROM base
|
|
||||||
|
|
||||||
COPY --from=build /home/app /home/app
|
|
||||||
|
|
||||||
WORKDIR /home/app
|
|
||||||
|
|
||||||
EXPOSE ${PORT}
|
|
||||||
|
|
||||||
CMD ["node", "dist/app/src/index.js"]
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
name: 'QueueBroker',
|
|
||||||
port: '3999',
|
|
||||||
exec_mode: 'fork',
|
|
||||||
script: './dist/producer/src/index.js',
|
|
||||||
env: {
|
|
||||||
EMAIL_SERVICE: "",
|
|
||||||
EMAIL_HOST: "",
|
|
||||||
EMAIL_USER: "",
|
|
||||||
EMAIL_PASS: "",
|
|
||||||
PORT: "",
|
|
||||||
MONGO_CONNECTION_STRING: "",
|
|
||||||
REDIS_URL: "",
|
|
||||||
REDIS_USERNAME: "",
|
|
||||||
REDIS_PASSWORD: "",
|
|
||||||
STREAM_NAME: ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"express": "^4.19.2",
|
|
||||||
"mongoose": "^8.3.2",
|
|
||||||
"nodemailer": "^6.9.13",
|
|
||||||
"redis": "^4.6.14",
|
|
||||||
"ua-parser-js": "^1.0.37"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/cors": "^2.8.17",
|
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/node": "^20.12.13",
|
|
||||||
"@types/nodemailer": "^6.4.15",
|
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
|
||||||
"glob": "^10.4.1",
|
|
||||||
"node-ssh": "^13.2.0",
|
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"typescript": "^5.4.5"
|
|
||||||
},
|
|
||||||
"name": "litlyx-queue-broker",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "node scripts/start_dev.js",
|
|
||||||
"compile": "tsc",
|
|
||||||
"build": "ts-node scripts/build.ts",
|
|
||||||
"create_db": "cd scripts && ts-node create_database.ts",
|
|
||||||
"build_all": "npm run compile && npm run build && npm run create_db",
|
|
||||||
"docker-build": "docker build -t litlyx-broker -f Dockerfile ../",
|
|
||||||
"docker-inspect": "docker run -it litlyx-broker sh"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "Emily",
|
|
||||||
"license": "MIT",
|
|
||||||
"description": "Queue broker for Litlyx - Saves events to database."
|
|
||||||
}
|
|
||||||
1467
broker/pnpm-lock.yaml
generated
@@ -1,17 +0,0 @@
|
|||||||
|
|
||||||
import fs from 'fs';
|
|
||||||
import { globSync } from 'glob';
|
|
||||||
const tsconfigContent = fs.readFileSync('tsconfig.json', 'utf8');
|
|
||||||
const tsconfigObject = JSON.parse(tsconfigContent);
|
|
||||||
const paths = tsconfigObject.compilerOptions.paths;
|
|
||||||
const filesList = globSync('dist/**/*.js');
|
|
||||||
filesList.forEach(file => {
|
|
||||||
let raw = fs.readFileSync(file, 'utf8');
|
|
||||||
for (const path in paths) {
|
|
||||||
const deep = (file.match(/\\|\//g) || []).length;
|
|
||||||
const pathText = path.replace('*', '');
|
|
||||||
const toReplaceText = new RegExp(`"${pathText}(.*?)"`, 'g');
|
|
||||||
raw = raw.replace(toReplaceText, `"${new Array(deep - 2).fill('../').join('')}${paths[path][0].replace('*', '')}${'$1'}"`);
|
|
||||||
}
|
|
||||||
fs.writeFileSync(file, raw);
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { ProjectModel } from "@schema/ProjectSchema";
|
|
||||||
import { UserModel } from "@schema/UserSchema";
|
|
||||||
import { LimitNotifyModel } from "@schema/broker/LimitNotifySchema";
|
|
||||||
import EmailService from '@services/EmailService';
|
|
||||||
import { requireEnv } from "../../shared/utilts/requireEnv";
|
|
||||||
import { TProjectLimit } from "@schema/ProjectsLimits";
|
|
||||||
|
|
||||||
if (process.env.EMAIL_SERVICE) {
|
|
||||||
EmailService.createTransport(
|
|
||||||
requireEnv('EMAIL_SERVICE'),
|
|
||||||
requireEnv('EMAIL_HOST'),
|
|
||||||
requireEnv('EMAIL_USER'),
|
|
||||||
requireEnv('EMAIL_PASS'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
|
||||||
|
|
||||||
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit / 2)) {
|
|
||||||
const notify = await LimitNotifyModel.findOne({ project_id: projectCounts._id });
|
|
||||||
if (notify && notify.limit1 === true) return;
|
|
||||||
const project = await ProjectModel.findById(projectCounts.project_id);
|
|
||||||
if (!project) return;
|
|
||||||
const owner = await UserModel.findById(project.owner);
|
|
||||||
if (!owner) return;
|
|
||||||
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail50(owner.email);
|
|
||||||
await LimitNotifyModel.updateOne({ project_id: projectCounts._id }, { limit1: true, limit2: false, limit3: false }, { upsert: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
export function getDeviceFromScreenSize(width: number, height: number) {
|
|
||||||
const totalArea = width * height;
|
|
||||||
|
|
||||||
const mobileArea = 375 * 667;
|
|
||||||
const tabletMinArea = 768 * 1366
|
|
||||||
const tabletMaxArea = 1024 * 1366
|
|
||||||
|
|
||||||
const isMobile = totalArea <= mobileArea;
|
|
||||||
const isTablet = totalArea >= tabletMinArea && totalArea <= tabletMaxArea;
|
|
||||||
|
|
||||||
if (isMobile) return 'mobile';
|
|
||||||
if (isTablet) return 'tablet'
|
|
||||||
return 'desktop';
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
|
|
||||||
import { RedisStreamService } from '@services/RedisStreamService';
|
|
||||||
import { requireEnv } from '../../shared/utilts/requireEnv';
|
|
||||||
import { EventModel } from '@schema/metrics/EventSchema';
|
|
||||||
import { SessionModel } from '@schema/metrics/SessionSchema';
|
|
||||||
import { ProjectModel } from '@schema/ProjectSchema';
|
|
||||||
import { ProjectLimitModel } from '@schema/ProjectsLimits';
|
|
||||||
import { ProjectCountModel } from '@schema/ProjectsCounts';
|
|
||||||
import { EVENT_LOG_LIMIT_PERCENT } from '@data/broker/Limits';
|
|
||||||
import { checkLimitsForEmail } from './Controller';
|
|
||||||
import { lookup } from './lookup';
|
|
||||||
import { UAParser } from 'ua-parser-js';
|
|
||||||
import { VisitModel } from '@schema/metrics/VisitSchema';
|
|
||||||
|
|
||||||
|
|
||||||
export async function startStreamLoop() {
|
|
||||||
|
|
||||||
await RedisStreamService.connect();
|
|
||||||
|
|
||||||
await RedisStreamService.startReadingLoop({
|
|
||||||
streamName: requireEnv('STREAM_NAME'),
|
|
||||||
delay: { base: 100, empty: 5000 },
|
|
||||||
readBlock: 2500
|
|
||||||
}, processStreamEvent);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function processStreamEvent(data: Record<string, string>) {
|
|
||||||
try {
|
|
||||||
const eventType = data._type;
|
|
||||||
if (!eventType) return;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { pid, sessionHash } = data;
|
|
||||||
|
|
||||||
const project = await ProjectModel.exists({ _id: pid });
|
|
||||||
if (!project) return;
|
|
||||||
|
|
||||||
if (eventType === 'event') return await process_event(data, sessionHash);
|
|
||||||
if (eventType === 'keep_alive') return await process_keep_alive(data, sessionHash);
|
|
||||||
if (eventType === 'visit') return await process_visit(data, sessionHash);
|
|
||||||
|
|
||||||
} catch (ex: any) {
|
|
||||||
console.error('ERROR PROCESSING STREAM EVENT', ex.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function process_visit(data: Record<string, string>, sessionHash: string) {
|
|
||||||
|
|
||||||
const { pid, ip, website, page, referrer, userAgent, flowHash } = data;
|
|
||||||
|
|
||||||
const projectLimits = await ProjectLimitModel.findOne({ project_id: pid });
|
|
||||||
if (!projectLimits) return;
|
|
||||||
|
|
||||||
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
|
|
||||||
const COUNT_LIMIT = projectLimits.limit;
|
|
||||||
if ((TOTAL_COUNT) > COUNT_LIMIT * EVENT_LOG_LIMIT_PERCENT) return;
|
|
||||||
await checkLimitsForEmail(projectLimits);
|
|
||||||
|
|
||||||
let referrerParsed;
|
|
||||||
try {
|
|
||||||
referrerParsed = new URL(referrer);
|
|
||||||
} catch (ex) {
|
|
||||||
referrerParsed = { hostname: referrer };
|
|
||||||
}
|
|
||||||
|
|
||||||
const geoLocation = lookup(ip);
|
|
||||||
|
|
||||||
const userAgentParsed = UAParser(userAgent);
|
|
||||||
|
|
||||||
const visit = new VisitModel({
|
|
||||||
project_id: pid, website, page, referrer: referrerParsed.hostname,
|
|
||||||
browser: userAgentParsed.browser.name || 'NO_BROWSER',
|
|
||||||
os: userAgentParsed.os.name || 'NO_OS',
|
|
||||||
device: userAgentParsed.device.type,
|
|
||||||
session: sessionHash,
|
|
||||||
flowHash,
|
|
||||||
continent: geoLocation[0],
|
|
||||||
country: geoLocation[1],
|
|
||||||
});
|
|
||||||
|
|
||||||
await visit.save();
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } }, { upsert: true });
|
|
||||||
await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } });
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
async function process_keep_alive(data: Record<string, string>, sessionHash: string) {
|
|
||||||
|
|
||||||
const { pid, instant, flowHash } = data;
|
|
||||||
|
|
||||||
if (instant == "true") {
|
|
||||||
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
|
||||||
$inc: { duration: 0 },
|
|
||||||
flowHash,
|
|
||||||
updated_at: Date.now()
|
|
||||||
}, { upsert: true });
|
|
||||||
} else {
|
|
||||||
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
|
||||||
$inc: { duration: 1 },
|
|
||||||
flowHash,
|
|
||||||
updated_at: Date.now()
|
|
||||||
}, { upsert: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function process_event(data: Record<string, string>, sessionHash: string) {
|
|
||||||
|
|
||||||
const { name, metadata, pid, flowHash } = data;
|
|
||||||
|
|
||||||
let metadataObject;
|
|
||||||
try {
|
|
||||||
if (metadata) metadataObject = JSON.parse(metadata);
|
|
||||||
} catch (ex) {
|
|
||||||
metadataObject = { error: 'Error parsing metadata' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = new EventModel({ project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash });
|
|
||||||
await event.save();
|
|
||||||
|
|
||||||
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true });
|
|
||||||
await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } });
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import cors from 'cors';
|
|
||||||
|
|
||||||
import { requireEnv } from '../../shared/utilts/requireEnv';
|
|
||||||
import { connectDatabase } from '@services/DatabaseService';
|
|
||||||
import { startStreamLoop } from './StreamLoopController';
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(cors());
|
|
||||||
|
|
||||||
connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
|
|
||||||
|
|
||||||
import HealthRouter from './routes/HealthRouter';
|
|
||||||
app.use('/health', HealthRouter);
|
|
||||||
|
|
||||||
app.listen(requireEnv('PORT'), () => console.log(`Listening on port ${requireEnv('PORT')}`));
|
|
||||||
|
|
||||||
startStreamLoop();
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
|
|
||||||
import { Router } from "express";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
|
||||||
try {
|
|
||||||
return res.json({ alive: true });
|
|
||||||
} catch (ex) {
|
|
||||||
console.error(ex);
|
|
||||||
return res.status(500).json({ error: 'ERROR' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Request } from "express";
|
|
||||||
import crypto from 'crypto';
|
|
||||||
|
|
||||||
export function getIPFromRequest(req: Request) {
|
|
||||||
const ip = req.header('X-Real-IP') || req.header('X-Forwarded-For') || '0.0.0.0';
|
|
||||||
return ip;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function createSessionHash(website: string, ip: string, userAgent: string) {
|
|
||||||
const dailySalt = new Date().toLocaleDateString('it-IT');
|
|
||||||
const sessionClean = dailySalt + website + ip + userAgent;
|
|
||||||
const sessionHash = crypto.createHash('md5').update(sessionClean).digest("hex");
|
|
||||||
return sessionHash;
|
|
||||||
}
|
|
||||||
10
consumer/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
ecosystem.config.cjs
|
||||||
|
ecosystem.config.js
|
||||||
|
|
||||||
|
scripts/start_dev.js
|
||||||
|
scripts/start_dev_prod.js
|
||||||
|
dist
|
||||||
|
src/shared
|
||||||
17
consumer/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
FROM node:21-alpine as base
|
||||||
|
|
||||||
|
RUN npm i -g pnpm
|
||||||
|
|
||||||
|
WORKDIR /home/app
|
||||||
|
|
||||||
|
COPY --link ./consumer/package.json ./consumer/pnpm-lock.yaml ./consumer/
|
||||||
|
|
||||||
|
WORKDIR /home/app/consumer
|
||||||
|
RUN pnpm install
|
||||||
|
|
||||||
|
COPY --link ../consumer ./
|
||||||
|
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
CMD ["node", "/home/app/consumer/dist/index.js"]
|
||||||
34
consumer/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"mongoose": "^8.9.5",
|
||||||
|
"redis": "^4.7.0",
|
||||||
|
"ua-parser-js": "^1.0.37"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^20.12.13",
|
||||||
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
},
|
||||||
|
"name": "consumer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node scripts/start_dev.js",
|
||||||
|
"dev_prod": "node scripts/start_dev_prod.js",
|
||||||
|
"compile": "tsc",
|
||||||
|
"build": "npm run compile && npm run create_db",
|
||||||
|
"create_db": "cd scripts && ts-node create_database.ts",
|
||||||
|
"docker-build": "docker build -t litlyx-consumer -f Dockerfile ../",
|
||||||
|
"docker-inspect": "docker run -it litlyx-consumer sh",
|
||||||
|
"workspace:shared": "ts-node ../scripts/consumer/shared.ts",
|
||||||
|
"workspace:deploy": "ts-node ../scripts/consumer/deploy.ts"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "Emily",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "Database Consumer - Saves events to database."
|
||||||
|
}
|
||||||
1151
consumer/pnpm-lock.yaml
generated
Normal file
|
Can't render this file because it is too large.
|
82
consumer/src/EmailController.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { ProjectModel } from "./shared/schema/project/ProjectSchema";
|
||||||
|
import { UserModel } from "./shared/schema/UserSchema";
|
||||||
|
import { LimitNotifyModel } from "./shared/schema/broker/LimitNotifySchema";
|
||||||
|
import { EmailService } from './shared/services/EmailService';
|
||||||
|
import { TProjectLimit } from "./shared/schema/project/ProjectsLimits";
|
||||||
|
import { EmailServiceHelper } from "./EmailServiceHelper";
|
||||||
|
|
||||||
|
|
||||||
|
export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
||||||
|
|
||||||
|
const project_id = projectCounts.project_id;
|
||||||
|
const hasNotifyEntry = await LimitNotifyModel.findOne({ project_id });
|
||||||
|
if (!hasNotifyEntry) {
|
||||||
|
await LimitNotifyModel.create({ project_id, limit1: false, limit2: false, limit3: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit)) {
|
||||||
|
|
||||||
|
if (hasNotifyEntry.limit3 === true) return;
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(project_id);
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
const owner = await UserModel.findById(project.owner);
|
||||||
|
if (!owner) return;
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
const emailData = EmailService.getEmailServerInfo('limit_max', {
|
||||||
|
target: owner.email,
|
||||||
|
projectName: project.name
|
||||||
|
});
|
||||||
|
EmailServiceHelper.sendEmail(emailData);
|
||||||
|
});
|
||||||
|
|
||||||
|
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: true });
|
||||||
|
|
||||||
|
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.9)) {
|
||||||
|
|
||||||
|
if (hasNotifyEntry.limit2 === true) return;
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(project_id);
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
const owner = await UserModel.findById(project.owner);
|
||||||
|
if (!owner) return;
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
const emailData = EmailService.getEmailServerInfo('limit_90', {
|
||||||
|
target: owner.email,
|
||||||
|
projectName: project.name
|
||||||
|
});
|
||||||
|
EmailServiceHelper.sendEmail(emailData);
|
||||||
|
});
|
||||||
|
|
||||||
|
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: false });
|
||||||
|
|
||||||
|
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.5)) {
|
||||||
|
|
||||||
|
if (hasNotifyEntry.limit1 === true) return;
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(project_id);
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
const owner = await UserModel.findById(project.owner);
|
||||||
|
if (!owner) return;
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
const emailData = EmailService.getEmailServerInfo('limit_50', {
|
||||||
|
target: owner.email,
|
||||||
|
projectName: project.name
|
||||||
|
});
|
||||||
|
EmailServiceHelper.sendEmail(emailData);
|
||||||
|
});
|
||||||
|
|
||||||
|
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: false, limit3: false });
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
19
consumer/src/EmailServiceHelper.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
import { EmailServerInfo } from './shared/services/EmailService'
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const EMAIL_SECRET = process.env.EMAIL_SECRET;
|
||||||
|
|
||||||
|
export class EmailServiceHelper {
|
||||||
|
static async sendEmail(data: EmailServerInfo) {
|
||||||
|
try {
|
||||||
|
await axios(data.url, {
|
||||||
|
method: 'POST',
|
||||||
|
data: data.body,
|
||||||
|
headers: { ...data.headers, 'x-litlyx-token': EMAIL_SECRET }
|
||||||
|
})
|
||||||
|
} catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
consumer/src/LimitChecker.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import { ProjectLimitModel } from './shared/schema/project/ProjectsLimits';
|
||||||
|
import { MAX_LOG_LIMIT_PERCENT } from './shared/data/broker/Limits';
|
||||||
|
import { checkLimitsForEmail } from './EmailController';
|
||||||
|
|
||||||
|
export async function checkLimits(project_id: string) {
|
||||||
|
const projectLimits = await ProjectLimitModel.findOne({ project_id });
|
||||||
|
if (!projectLimits) return false;
|
||||||
|
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
|
||||||
|
const COUNT_LIMIT = projectLimits.limit;
|
||||||
|
if ((TOTAL_COUNT) > COUNT_LIMIT * MAX_LOG_LIMIT_PERCENT) return false;
|
||||||
|
await checkLimitsForEmail(projectLimits);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
28
consumer/src/Metrics.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { RedisStreamService } from './shared/services/RedisStreamService';
|
||||||
|
import { requireEnv } from './shared/utils/requireEnv';
|
||||||
|
|
||||||
|
const stream_name = requireEnv('STREAM_NAME');
|
||||||
|
|
||||||
|
export const metricsRouter = Router();
|
||||||
|
|
||||||
|
metricsRouter.get('/queue', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const size = await RedisStreamService.getQueueInfo(stream_name);
|
||||||
|
res.json({ size });
|
||||||
|
} catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
res.status(500).json({ error: ex.message });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
metricsRouter.get('/durations', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const durations = await RedisStreamService.METRICS_get()
|
||||||
|
res.json({ durations });
|
||||||
|
} catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
res.status(500).json({ error: ex.message });
|
||||||
|
}
|
||||||
|
})
|
||||||
162
consumer/src/index.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
|
||||||
|
import { requireEnv } from './shared/utils/requireEnv';
|
||||||
|
import { connectDatabase } from './shared/services/DatabaseService';
|
||||||
|
import { RedisStreamService } from './shared/services/RedisStreamService';
|
||||||
|
import { ProjectModel } from "./shared/schema/project/ProjectSchema";
|
||||||
|
import { VisitModel } from "./shared/schema/metrics/VisitSchema";
|
||||||
|
import { SessionModel } from "./shared/schema/metrics/SessionSchema";
|
||||||
|
import { EventModel } from "./shared/schema/metrics/EventSchema";
|
||||||
|
import { lookup } from './lookup';
|
||||||
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
import { checkLimits } from './LimitChecker';
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
import { ProjectLimitModel } from './shared/schema/project/ProjectsLimits';
|
||||||
|
import { ProjectCountModel } from './shared/schema/project/ProjectsCounts';
|
||||||
|
import { metricsRouter } from './Metrics';
|
||||||
|
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use('/metrics', metricsRouter);
|
||||||
|
|
||||||
|
app.listen(process.env.PORT, () => console.log(`Listening on port ${process.env.PORT}`));
|
||||||
|
|
||||||
|
connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
|
||||||
|
main();
|
||||||
|
|
||||||
|
const CONSUMER_NAME = `CONSUMER_${process.env.NODE_APP_INSTANCE || 'DEFAULT'}`
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
|
||||||
|
await RedisStreamService.connect();
|
||||||
|
|
||||||
|
const stream_name = requireEnv('STREAM_NAME');
|
||||||
|
const group_name = requireEnv('GROUP_NAME') as any; // Checks are inside "startReadingLoop"
|
||||||
|
|
||||||
|
await RedisStreamService.startReadingLoop({
|
||||||
|
stream_name, group_name, consumer_name: CONSUMER_NAME
|
||||||
|
}, processStreamEntry);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processStreamEntry(data: Record<string, string>) {
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const eventType = data._type;
|
||||||
|
if (!eventType) return console.log('No type');
|
||||||
|
|
||||||
|
const { pid, sessionHash } = data;
|
||||||
|
|
||||||
|
const project = await ProjectModel.exists({ _id: pid });
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
const canLog = await checkLimits(pid);
|
||||||
|
if (!canLog) return;
|
||||||
|
|
||||||
|
if (eventType === 'event') {
|
||||||
|
await process_event(data, sessionHash);
|
||||||
|
} else if (eventType === 'keep_alive') {
|
||||||
|
await process_keep_alive(data, sessionHash);
|
||||||
|
} else if (eventType === 'visit') {
|
||||||
|
await process_visit(data, sessionHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (ex: any) {
|
||||||
|
console.error('ERROR PROCESSING STREAM EVENT', ex.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
RedisStreamService.METRICS_onProcess(CONSUMER_NAME, duration);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function process_visit(data: Record<string, string>, sessionHash: string) {
|
||||||
|
|
||||||
|
const { pid, ip, website, page, referrer, userAgent, flowHash, timestamp } = data;
|
||||||
|
|
||||||
|
let referrerParsed;
|
||||||
|
try {
|
||||||
|
referrerParsed = new URL(referrer);
|
||||||
|
} catch (ex) {
|
||||||
|
referrerParsed = { hostname: referrer };
|
||||||
|
}
|
||||||
|
|
||||||
|
const geoLocation = lookup(ip);
|
||||||
|
|
||||||
|
const userAgentParsed = UAParser(userAgent);
|
||||||
|
|
||||||
|
const device = userAgentParsed.device.type;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
VisitModel.create({
|
||||||
|
project_id: pid, website, page, referrer: referrerParsed.hostname,
|
||||||
|
browser: userAgentParsed.browser.name || 'NO_BROWSER',
|
||||||
|
os: userAgentParsed.os.name || 'NO_OS',
|
||||||
|
device: device ? device : (userAgentParsed.browser.name ? 'desktop' : undefined),
|
||||||
|
session: sessionHash,
|
||||||
|
flowHash,
|
||||||
|
continent: geoLocation[0],
|
||||||
|
country: geoLocation[1],
|
||||||
|
created_at: new Date(parseInt(timestamp))
|
||||||
|
}),
|
||||||
|
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } }, { upsert: true }),
|
||||||
|
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function process_keep_alive(data: Record<string, string>, sessionHash: string) {
|
||||||
|
|
||||||
|
const { pid, instant, flowHash, timestamp, website } = data;
|
||||||
|
|
||||||
|
const existingSession = await SessionModel.findOne({ project_id: pid, session: sessionHash }, { _id: 1 });
|
||||||
|
if (!existingSession) {
|
||||||
|
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'sessions': 1 } }, { upsert: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instant == "true") {
|
||||||
|
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
||||||
|
$inc: { duration: 0 },
|
||||||
|
flowHash,
|
||||||
|
website,
|
||||||
|
updated_at: new Date(parseInt(timestamp))
|
||||||
|
}, { upsert: true });
|
||||||
|
} else {
|
||||||
|
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
||||||
|
$inc: { duration: 1 },
|
||||||
|
flowHash,
|
||||||
|
website,
|
||||||
|
updated_at: new Date(parseInt(timestamp))
|
||||||
|
}, { upsert: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function process_event(data: Record<string, string>, sessionHash: string) {
|
||||||
|
|
||||||
|
const { name, metadata, pid, flowHash, timestamp, website } = data;
|
||||||
|
|
||||||
|
let metadataObject;
|
||||||
|
try {
|
||||||
|
if (metadata) metadataObject = JSON.parse(metadata);
|
||||||
|
} catch (ex) {
|
||||||
|
metadataObject = { error: 'Error parsing metadata' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
EventModel.create({
|
||||||
|
project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash,
|
||||||
|
website,
|
||||||
|
created_at: new Date(parseInt(timestamp))
|
||||||
|
}),
|
||||||
|
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true }),
|
||||||
|
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
13
consumer/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"target": "ESNext",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,9 +10,7 @@ AI_PROJECT=
|
|||||||
AI_KEY=
|
AI_KEY=
|
||||||
|
|
||||||
EMAIL_SERVICE=
|
EMAIL_SERVICE=
|
||||||
EMAIL_HOST=
|
BREVO_API_KEY=
|
||||||
EMAIL_USER=
|
|
||||||
EMAIL_PASS=
|
|
||||||
|
|
||||||
AUTH_JWT_SECRET=
|
AUTH_JWT_SECRET=
|
||||||
|
|
||||||
|
|||||||
13
dashboard/.gitignore
vendored
@@ -12,6 +12,7 @@ node_modules
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
winston-*.ndjson
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -23,7 +24,6 @@ logs
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
|
||||||
# Test reports
|
# Test reports
|
||||||
*.report.txt
|
*.report.txt
|
||||||
|
|
||||||
@@ -31,4 +31,13 @@ logs
|
|||||||
out.pdf
|
out.pdf
|
||||||
|
|
||||||
# TESTS - TO REMOVE
|
# TESTS - TO REMOVE
|
||||||
tests
|
tests
|
||||||
|
|
||||||
|
# EXPLAINS MONGODB
|
||||||
|
explains
|
||||||
|
|
||||||
|
#Ecosystem
|
||||||
|
ecosystem.config.cjs
|
||||||
|
ecosystem.config.js
|
||||||
|
|
||||||
|
shared
|
||||||
|
|||||||
@@ -1,34 +1,25 @@
|
|||||||
ARG NODE_VERSION=21
|
|
||||||
|
|
||||||
FROM node:${NODE_VERSION}-alpine as base
|
FROM node:21-alpine AS base
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
FROM base AS build
|
||||||
|
|
||||||
# Build stage
|
RUN npm i -g pnpm
|
||||||
|
|
||||||
WORKDIR /home/app
|
WORKDIR /home/app
|
||||||
|
|
||||||
FROM base as build
|
COPY --link ./dashboard/package.json ./dashboard/pnpm-lock.yaml ./dashboard/
|
||||||
|
|
||||||
COPY --link dashboard/package.json dashboard/pnpm-lock.yaml ./
|
WORKDIR /home/app/dashboard
|
||||||
RUN npm install --production=false
|
RUN pnpm install
|
||||||
|
|
||||||
COPY --link dashboard/ ./
|
COPY --link ./dashboard ./
|
||||||
|
|
||||||
COPY --link shared/ /home/shared
|
RUN pnpm run build:compose
|
||||||
|
|
||||||
ARG GOOGLE_AUTH_CLIENT_ID
|
FROM node:21-alpine AS production
|
||||||
ENV GOOGLE_AUTH_CLIENT_ID=$GOOGLE_AUTH_CLIENT_ID
|
|
||||||
|
|
||||||
RUN npm run build
|
WORKDIR /home/app
|
||||||
RUN npm prune
|
|
||||||
|
|
||||||
# Final stage
|
COPY --from=build /home/app/dashboard/.output /home/app/.output
|
||||||
|
|
||||||
FROM base
|
CMD ["node", "/home/app/.output/server/index.mjs"]
|
||||||
|
|
||||||
COPY --from=build /home/app /home/app
|
|
||||||
|
|
||||||
EXPOSE ${PORT}
|
|
||||||
|
|
||||||
CMD [ "node", "/home/app/.output/server/index.mjs" ]
|
|
||||||
9
dashboard/app.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export default defineAppConfig({
|
||||||
|
ui: {
|
||||||
|
notifications: {
|
||||||
|
position: 'top-0 bottom-[unset]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -6,15 +6,47 @@ Lit.init('6643cd08a1854e3b81722ab5');
|
|||||||
|
|
||||||
const debugMode = process.dev;
|
const debugMode = process.dev;
|
||||||
|
|
||||||
const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDialog();
|
const { alerts, closeAlert } = useAlert();
|
||||||
|
|
||||||
|
const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dialogClosable } = useCustomDialog();
|
||||||
|
|
||||||
|
const { drawerVisible, hideDrawer, drawerClasses } = useDrawer();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div class="w-dvw h-dvh bg-[#151517] relative">
|
<div class="w-dvw h-dvh bg-lyx-lightmode-background-light dark:bg-lyx-background-light relative">
|
||||||
|
|
||||||
|
<Transition name="drawer">
|
||||||
|
<LazyDrawerGeneric @onCloseClick="hideDrawer()" :class="drawerClasses"
|
||||||
|
class="bg-lyx-lightmode-background-light dark:bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if="drawerVisible">
|
||||||
|
</LazyDrawerGeneric>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0">
|
||||||
|
<div v-for="alert of alerts"
|
||||||
|
class="w-[30vw] min-w-[20rem] relative bg-lyx-lightmode-background dark:bg-[#151515] overflow-hidden border-solid border-[2px] border-lyx-lightmode-widget dark:border-[#262626] rounded-lg p-6 drop-shadow-lg">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div> <i :class="alert.icon"></i> </div>
|
||||||
|
<div class="grow">
|
||||||
|
<div class="poppins font-semibold">{{ alert.title }}</div>
|
||||||
|
<div class="poppins">
|
||||||
|
{{ alert.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<i @click="closeAlert(alert.id)" class="fas fa-close hover:text-[#CCCCCC] cursor-pointer"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :style="`width: ${Math.floor(100 / alert.ms * alert.remaining)}%; ${alert.transitionStyle}`"
|
||||||
|
class="absolute bottom-0 left-0 h-1 bg-lyx-primary z-100 alert-bar"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="debugMode"
|
<div v-if="debugMode"
|
||||||
class="absolute bottom-8 left-4 bg-red-400 text-white text-[.9rem] font-bold px-4 py-[.2rem] rounded-lg z-[100]">
|
class="absolute bottom-8 right-4 bg-red-400 text-white text-[.9rem] font-bold px-4 py-[.2rem] rounded-lg z-[100]">
|
||||||
<div class="poppins flex sm:hidden"> XS </div>
|
<div class="poppins flex sm:hidden"> XS </div>
|
||||||
<div class="poppins hidden sm:max-md:flex"> SM - MOBILE </div>
|
<div class="poppins hidden sm:max-md:flex"> SM - MOBILE </div>
|
||||||
<div class="poppins hidden md:max-lg:flex"> MD - TABLET </div>
|
<div class="poppins hidden md:max-lg:flex"> MD - TABLET </div>
|
||||||
@@ -24,9 +56,9 @@ const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDial
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showDialog"
|
<div v-if="showDialog"
|
||||||
class="custom-dialog flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 w-full h-full z-[100] backdrop-blur-[2px] bg-black/50">
|
class="custom-dialog w-full h-full flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 z-[100] backdrop-blur-[2px] dark:bg-black/50">
|
||||||
<div class="bg-menu w-full h-full rounded-xl relative">
|
<div :style="dialogStyle" class="bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget dark:bg-lyx-widget dark:outline-lyx-widget-lighter rounded-xl relative outline outline-1">
|
||||||
<div class="flex justify-end absolute z-[100] right-8 top-8">
|
<div v-if="dialogClosable" class="flex justify-end absolute z-[100] right-8 top-8">
|
||||||
<i @click="closeDialog()" class="fas fa-close text-[1.6rem] hover:text-gray-500 cursor-pointer"></i>
|
<i @click="closeDialog()" class="fas fa-close text-[1.6rem] hover:text-gray-500 cursor-pointer"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-center w-full h-full p-4">
|
<div class="flex items-center justify-center w-full h-full p-4">
|
||||||
@@ -35,9 +67,32 @@ const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDial
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<UModals />
|
||||||
|
<UNotifications />
|
||||||
|
|
||||||
|
<LazyOnboarding> </LazyOnboarding>
|
||||||
|
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage></NuxtPage>
|
<NuxtPage></NuxtPage>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.drawer-enter-active,
|
||||||
|
.drawer-leave-active {
|
||||||
|
transition: all .5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-enter-from,
|
||||||
|
.drawer-leave-to {
|
||||||
|
transform: translateX(100%)
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-enter-to,
|
||||||
|
.drawer-leave-from {
|
||||||
|
transform: translateX(0)
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
14
dashboard/assets/main.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@import './font-awesome/css/all.css';
|
||||||
|
/* Are these many fonts required? For the time being switching to privacy friendly bunny.net for Google fonts. NOTE: No variable font support in bunnet.net yet. */
|
||||||
|
|
||||||
|
@import url('https://fonts.bunny.net/css?family=nunito:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
|
||||||
|
@import url('https://fonts.cdnfonts.com/css/brockmann');
|
||||||
|
@import url('https://fonts.bunny.net/css?family=inter:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
|
||||||
|
|
||||||
|
|
||||||
|
@import url('https://fonts.cdnfonts.com/css/geometric-sans-serif-v1');
|
||||||
|
@import url('https://fonts.bunny.net/css?family=manrope:300,400,500,600,700,800');
|
||||||
|
@import url('https://fonts.bunny.net/css?family=lato:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
|
||||||
|
@import url('https://fonts.bunny.net/css?family=poppins:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
|
||||||
|
|
||||||
|
@import url('https://cdn.jsdelivr.net/npm/material-symbols@0.28.2/index.css');
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;0,1000;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900;1,1000&display=swap');
|
@use './utilities.scss';
|
||||||
@import url('https://fonts.cdnfonts.com/css/brockmann');
|
@use './colors.scss';
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
|
|
||||||
|
|
||||||
@import '../font-awesome/css/all.css';
|
:root{
|
||||||
@import './utilities.scss';
|
--font-sans: "SF Pro Text","SF Pro Icons", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", "Google Sans", "Helvetica Neue", Helvetica, "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol"
|
||||||
@import './colors.scss';
|
}
|
||||||
|
|
||||||
@import url('https://fonts.cdnfonts.com/css/geometric-sans-serif-v1');
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap');
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap');
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
|
|
||||||
|
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
@@ -17,8 +11,19 @@
|
|||||||
src: url("../fonts/GeistVF.ttf");
|
src: url("../fonts/GeistVF.ttf");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actionable-visits-color-checkbox {
|
||||||
|
color: #5655d7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionable-sessions-color-checkbox {
|
||||||
|
color: #4abde8;
|
||||||
|
}
|
||||||
|
.actionable-events-color-checkbox {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
.geist {
|
.geist {
|
||||||
font-family: "Geist";
|
font-family: "Geist", var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -33,47 +38,51 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brockmann {
|
.brockmann {
|
||||||
font-family: "Brockmann" !important;
|
font-family: "Brockmann", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nunito {
|
.nunito {
|
||||||
font-family: "Nunito" !important;
|
font-family: "Nunito",var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inter {
|
.inter {
|
||||||
font-family: "Inter" !important;
|
font-family: "Inter", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.geometric {
|
.geometric {
|
||||||
font-family: 'Geometric Sans Serif v1' !important;
|
font-family: "Geometric Sans Serif v1", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.manrope {
|
.manrope {
|
||||||
font-family: 'Manrope' !important;
|
font-family: "Manrope", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lato {
|
.lato {
|
||||||
font-family: 'Lato' !important;
|
font-family: "Lato", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poppins {
|
.poppins {
|
||||||
font-family: 'Poppins' !important;
|
font-family: "Poppins", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poppins-childs {
|
.poppins-childs {
|
||||||
font-family: 'Poppins' !important;
|
font-family: "Poppins", var(--font-sans)!important;
|
||||||
|
|
||||||
* {
|
* {
|
||||||
font-family: 'Poppins' !important;
|
font-family: "Poppins", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.hide-scrollbars {
|
.hide-scrollbars {
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: none; /* Firefox */
|
/* IE and Edge */
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
display: none; /* Chrome, Safari and Opera */
|
display: none;
|
||||||
|
/* Chrome, Safari and Opera */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,5 +109,5 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
font-family: 'Nunito';
|
font-family: 'Nunito', var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,17 @@
|
|||||||
|
|
||||||
.test3 {
|
.test3 {
|
||||||
border: 3px solid green !important;
|
border: 3px solid green !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.bgtest {
|
||||||
|
background-color: yellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgtest2 {
|
||||||
|
background-color: blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgtest3 {
|
||||||
|
background-color: green;
|
||||||
}
|
}
|
||||||
@@ -80,6 +80,7 @@ const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, op
|
|||||||
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
||||||
const c = document.createElement('canvas');
|
const c = document.createElement('canvas');
|
||||||
const ctx = c.getContext("2d");
|
const ctx = c.getContext("2d");
|
||||||
let gradient: any = `${props.color}22`;
|
let gradient: any = `${props.color}22`;
|
||||||
@@ -95,7 +96,6 @@ onMounted(async () => {
|
|||||||
chartData.value.datasets[0].backgroundColor = [gradient];
|
chartData.value.datasets[0].backgroundColor = [gradient];
|
||||||
|
|
||||||
watch(props, () => {
|
watch(props, () => {
|
||||||
console.log('UPDATE')
|
|
||||||
chartData.value.labels = props.labels;
|
chartData.value.labels = props.labels;
|
||||||
chartData.value.datasets[0].data = props.data;
|
chartData.value.datasets[0].data = props.data;
|
||||||
});
|
});
|
||||||
@@ -106,5 +106,5 @@ onMounted(async () => {
|
|||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const chartData = ref<ChartData<'bar'>>({
|
|||||||
label: e.label || '?',
|
label: e.label || '?',
|
||||||
backgroundColor: [e.color],
|
backgroundColor: [e.color],
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
borderRadius: 8
|
borderRadius: 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
175
dashboard/components/BarCard/Base.vue
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
export type IconProvider = (e: { _id: string, count: string } & any) => ['img' | 'icon', string] | undefined;
|
||||||
|
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: { _id: string, count: number }[],
|
||||||
|
iconProvider?: IconProvider,
|
||||||
|
elementTextTransformer?: (text: string) => string,
|
||||||
|
label: string,
|
||||||
|
subLabel: string,
|
||||||
|
desc: string,
|
||||||
|
loading?: boolean,
|
||||||
|
interactive?: boolean,
|
||||||
|
isDetailView?: boolean,
|
||||||
|
rawButton?: boolean,
|
||||||
|
hideShowMore?: boolean,
|
||||||
|
customIconStyle?: string,
|
||||||
|
showLink?: boolean
|
||||||
|
}
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: 'dataReload'): void,
|
||||||
|
(e: 'showDetails', id: string): void,
|
||||||
|
(e: 'showRawData'): void,
|
||||||
|
(e: 'showGeneral'): void,
|
||||||
|
(e: 'showMore'): void,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const maxData = computed(() => {
|
||||||
|
const counts = props.data.map(e => e.count);
|
||||||
|
return Math.max(...counts);
|
||||||
|
});
|
||||||
|
|
||||||
|
function reloadData() {
|
||||||
|
emits('dataReload');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetails(id: string) {
|
||||||
|
emits('showDetails', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExternalLink(link: string) {
|
||||||
|
if (link === 'self') return;
|
||||||
|
return window.open('https://' + link, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<LyxUiCard class="w-full h-full p-4 flex flex-col gap-8 relative">
|
||||||
|
<div class="flex justify-between mb-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="flex gap-4 items-center">
|
||||||
|
<div class="poppins font-semibold text-[1.4rem] text-lyx-lightmode-text dark:text-lyx-text">
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i @click="reloadData()"
|
||||||
|
class="hover:rotate-[50deg] transition-all duration-100 fas fa-refresh text-[1.2rem] cursor-pointer"></i>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="poppins text-[1rem] text-lyx-ligtmode-text-darker dark:text-text-sub/90">
|
||||||
|
{{ desc }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="rawButton" class="hidden lg:flex">
|
||||||
|
|
||||||
|
<LyxUiButton @click="$emit('showRawData')" type="primary" class="h-fit">
|
||||||
|
<div class="flex gap-1 items-center justify-center ">
|
||||||
|
<div> Show raw data </div>
|
||||||
|
<div class="flex items-center"> <i class="fas fa-arrow-up-right"></i> </div>
|
||||||
|
</div>
|
||||||
|
</LyxUiButton>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<div
|
||||||
|
class="flex justify-between font-bold lyx-text-lightmode-text-dark dark:text-text-sub/80 text-[1.1rem] mb-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div v-if="isDetailView" class="flex items-center justify-center">
|
||||||
|
<i @click="$emit('showGeneral')"
|
||||||
|
class="fas fa-arrow-left text-[.9rem] hover:text-text cursor-pointer"></i>
|
||||||
|
</div>
|
||||||
|
<div> {{ subLabel }} </div>
|
||||||
|
</div>
|
||||||
|
<div> Count </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
|
||||||
|
<div v-if="props.data.length > 0" class="flex justify-between items-center"
|
||||||
|
v-for="element of props.data">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 w-10/12 relative">
|
||||||
|
|
||||||
|
<div v-if="showLink">
|
||||||
|
<i @click="openExternalLink(element._id)"
|
||||||
|
class="fas fa-link text-gray-300 hover:text-gray-400 cursor-pointer"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-1 items-center" @click="showDetails(element._id)"
|
||||||
|
:class="{ 'cursor-pointer line-active': interactive }">
|
||||||
|
|
||||||
|
<div class="absolute rounded-sm w-full h-full bg-[#6f829c38] dark:bg-[#92abcf38]"
|
||||||
|
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
|
||||||
|
|
||||||
|
<div class="flex px-2 py-1 relative items-center gap-4">
|
||||||
|
<div v-if="iconProvider && iconProvider(element) != undefined"
|
||||||
|
class="flex items-center h-[1.3rem]">
|
||||||
|
|
||||||
|
<img v-if="iconProvider(element)?.[0] == 'img'" class="h-full"
|
||||||
|
:style="customIconStyle" :src="iconProvider(element)?.[1]">
|
||||||
|
|
||||||
|
<i v-else :class="iconProvider(element)?.[1]"></i>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-ellipsis line-clamp-1 ui-font z-[19] text-[.95rem] text-lyx-lightmode-text-dark dark:text-text/70">
|
||||||
|
{{ elementTextTransformer?.(element._id) || element._id }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="text-lyx-lightmode-text dark:text-lyx-text font-semibold text-[.9rem] md:text-[1rem] manrope">
|
||||||
|
{{
|
||||||
|
formatNumberK(element.count) }} </div>
|
||||||
|
</div>
|
||||||
|
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-light text-[1.1rem]">
|
||||||
|
No data yet
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 items-end grow">
|
||||||
|
|
||||||
|
<LyxUiButton type="outline" @click="$emit('showMore')">
|
||||||
|
Show more
|
||||||
|
</LyxUiButton>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading"
|
||||||
|
class="backdrop-blur-[1px] z-[20] left-0 top-0 w-full h-full flex items-center justify-center font-bold rockmann absolute">
|
||||||
|
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||||
|
</div>
|
||||||
|
</LyxUiCard>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.line-active:hover {
|
||||||
|
.absolute {
|
||||||
|
@apply bg-accent/20
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-font {
|
||||||
|
font-feature-settings: normal;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
|
||||||
|
font-variation-settings: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
-moz-tab-size: 4;
|
||||||
|
-o-tab-size: 4;
|
||||||
|
tab-size: 4
|
||||||
|
}
|
||||||
|
</style>
|
||||||
66
dashboard/components/BarCard/Browsers.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import type { IconProvider } from './Base.vue';
|
||||||
|
|
||||||
|
function iconProvider(e: { _id: string, flag: string, count: number }): ReturnType<IconProvider> {
|
||||||
|
let name = e._id.toLowerCase().replace(/ /g, '-');
|
||||||
|
|
||||||
|
if (name === 'mobile-safari') name = 'safari';
|
||||||
|
if (name === 'chrome-headless') name = 'chrome'
|
||||||
|
if (name === 'chrome-webview') name = 'chrome'
|
||||||
|
|
||||||
|
if (name === 'duckduckgo') return ['icon', 'far fa-duck']
|
||||||
|
if (name === 'avast-secure-browser') return ['icon', 'far fa-bug']
|
||||||
|
if (name === 'avg-secure-browser') return ['icon', 'far fa-bug']
|
||||||
|
|
||||||
|
if (name === 'no_browser') return ['icon', 'far fa-question']
|
||||||
|
if (name === 'gsa') return ['icon', 'far fa-question']
|
||||||
|
if (name === 'miui-browser') return ['icon', 'far fa-question']
|
||||||
|
|
||||||
|
if (name === 'vivo-browser') return ['icon', 'far fa-question']
|
||||||
|
if (name === 'whale') return ['icon', 'far fa-question']
|
||||||
|
|
||||||
|
if (name === 'twitter') return ['icon', 'fab fa-twitter']
|
||||||
|
if (name === 'linkedin') return ['icon', 'fab fa-linkedin']
|
||||||
|
if (name === 'facebook') return ['icon', 'fab fa-facebook']
|
||||||
|
|
||||||
|
return [
|
||||||
|
'img',
|
||||||
|
`https://github.com/alrra/browser-logos/blob/main/src/${name}/${name}_256x256.png?raw=true`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const browsersData = useFetch('/api/data/browsers', {
|
||||||
|
headers: useComputedHeaders({ limit: 10, }), lazy: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
dialogBarData.value = [];
|
||||||
|
showDialog.value = true;
|
||||||
|
isDataLoading.value = true;
|
||||||
|
|
||||||
|
const res = await $fetch('/api/data/browsers', {
|
||||||
|
headers: useComputedHeaders({ limit: 1000 }).value
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogBarData.value = res?.map(e => {
|
||||||
|
return { ...e, icon: iconProvider(e as any) }
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
isDataLoading.value = false;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase @showMore="showMore()" @dataReload="browsersData.refresh()" :data="browsersData.data.value || []"
|
||||||
|
desc="The browsers most used to search your website." :dataIcons="true" :iconProvider="iconProvider"
|
||||||
|
:loading="browsersData.pending.value" label="Browsers" sub-label="Browsers">
|
||||||
|
</BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
54
dashboard/components/BarCard/Devices.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
import type { IconProvider } from './Base.vue';
|
||||||
|
|
||||||
|
function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> {
|
||||||
|
if (e._id === 'desktop') return ['icon','far fa-desktop'];
|
||||||
|
if (e._id === 'tablet') return ['icon','far fa-tablet ml-1'];
|
||||||
|
if (e._id === 'mobile') return ['icon','far fa-mobile ml-1'];
|
||||||
|
if (e._id === 'smarttv') return ['icon','far fa-tv'];
|
||||||
|
if (e._id === 'console') return ['icon','far fa-game-console-handheld'];
|
||||||
|
return ['icon', 'far fa-question ml-1 mr-1']
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function transform(data: { _id: string, count: number }[]) {
|
||||||
|
console.log(data);
|
||||||
|
return data.map(e => ({ ...e, _id: e._id == null ? 'others' : e._id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const devicesData = useFetch('/api/data/devices', {
|
||||||
|
headers: useComputedHeaders({ limit: 10, }), lazy: true,
|
||||||
|
transform
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
dialogBarData.value = [];
|
||||||
|
showDialog.value = true;
|
||||||
|
isDataLoading.value = true;
|
||||||
|
|
||||||
|
const res = await $fetch('/api/data/devices', {
|
||||||
|
headers: useComputedHeaders({ limit: 1000 }).value,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
dialogBarData.value = transform(res || []);
|
||||||
|
|
||||||
|
isDataLoading.value = false;
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase @showMore="showMore()" @dataReload="devicesData.refresh()" :data="devicesData.data.value || []"
|
||||||
|
:iconProvider="iconProvider" :dataIcons="true" desc="The devices most used to access your website."
|
||||||
|
:loading="devicesData.pending.value" label="Devices" sub-label="Devices"></BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
42
dashboard/components/BarCard/Events.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function goToView() {
|
||||||
|
router.push('/dashboard/events');
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsData = useFetch('/api/data/events', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
limit: 10,
|
||||||
|
}), lazy: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
dialogBarData.value=[];
|
||||||
|
showDialog.value = true;
|
||||||
|
isDataLoading.value = true;
|
||||||
|
|
||||||
|
const res = await $fetch('/api/data/events', {
|
||||||
|
headers: useComputedHeaders({ limit: 1000 }).value
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogBarData.value = res || [];
|
||||||
|
|
||||||
|
isDataLoading.value = false;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase @showMore="showMore()" @showRawData="goToView()"
|
||||||
|
desc="Most frequent user events triggered in this project" @dataReload="eventsData.refresh()"
|
||||||
|
:data="eventsData.data.value || []" :loading="eventsData.pending.value" label="Top Events"
|
||||||
|
sub-label="Events" :rawButton="!isLiveDemo"></BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
59
dashboard/components/BarCard/Geolocations.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import type { IconProvider } from '../BarCard/Base.vue';
|
||||||
|
|
||||||
|
function iconProvider(e: { _id: string, flag: string, count: number }): ReturnType<IconProvider> {
|
||||||
|
if (!e.flag) return ['icon', 'far fa-question']
|
||||||
|
return [
|
||||||
|
'img',
|
||||||
|
`https://raw.githubusercontent.com/hampusborgos/country-flags/refs/heads/main/svg/${e.flag.toLowerCase()}.svg`
|
||||||
|
// `https://raw.githubusercontent.com/hampusborgos/country-flags/main/png250px/${e.flag.toLowerCase()}.png`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const customIconStyle = `width: 2rem; padding: 1px;`
|
||||||
|
|
||||||
|
const geolocationData = useFetch('/api/data/countries', {
|
||||||
|
headers: useComputedHeaders({ limit: 10, }), lazy: true,
|
||||||
|
transform: (e) => {
|
||||||
|
if (!e) return e;
|
||||||
|
return e.map(k => {
|
||||||
|
return { ...k, flag: k._id, _id: getCountryName(k._id) ?? k._id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
dialogBarData.value = [];
|
||||||
|
showDialog.value = true;
|
||||||
|
isDataLoading.value = true;
|
||||||
|
|
||||||
|
const res = await $fetch('/api/data/countries', {
|
||||||
|
headers: useComputedHeaders({ limit: 1000 }).value
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogBarData.value = res?.map(k => {
|
||||||
|
return { ...k, flag: k._id, _id: getCountryName(k._id) ?? k._id }
|
||||||
|
}).map(e => {
|
||||||
|
return { ...e, icon: iconProvider(e) }
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
isDataLoading.value = false;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase @showMore="showMore()" @dataReload="geolocationData.refresh()"
|
||||||
|
:data="geolocationData.data.value || []" :dataIcons="false" :loading="geolocationData.pending.value"
|
||||||
|
label="Countries" sub-label="Countries" :iconProvider="iconProvider" :customIconStyle="customIconStyle"
|
||||||
|
desc=" Lists the countries where users access your website.">
|
||||||
|
</BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
36
dashboard/components/BarCard/OperatingSystems.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
const ossData = useFetch('/api/data/oss', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
limit: 10,
|
||||||
|
}), lazy: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
dialogBarData.value=[];
|
||||||
|
showDialog.value = true;
|
||||||
|
isDataLoading.value = true;
|
||||||
|
|
||||||
|
const res = await $fetch('/api/data/oss', {
|
||||||
|
headers: useComputedHeaders({ limit: 1000 }).value
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogBarData.value = res || [];
|
||||||
|
|
||||||
|
isDataLoading.value = false;
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase @showMore="showMore()" @dataReload="ossData.refresh()" :data="ossData.data.value || []"
|
||||||
|
desc="The operating systems most commonly used by your website's visitors." :dataIcons="false"
|
||||||
|
:loading="ossData.pending.value" label="OS" sub-label="OSs"></BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
44
dashboard/components/BarCard/Pages.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const pagesData = useFetch('/api/data/pages', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
limit: 10,
|
||||||
|
}), lazy: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
|
||||||
|
dialogBarData.value = [];
|
||||||
|
|
||||||
|
showDialog.value = true;
|
||||||
|
isDataLoading.value = true;
|
||||||
|
|
||||||
|
const res = await $fetch('/api/data/pages', {
|
||||||
|
headers: useComputedHeaders({ limit: 1000 }).value
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogBarData.value = (res || []);
|
||||||
|
|
||||||
|
isDataLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToView() {
|
||||||
|
router.push('/dashboard/visits');
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase @showRawData="goToView()" @showMore="showMore()" @dataReload="pagesData.refresh()" :showLink=true
|
||||||
|
:data="pagesData.data.value || []" :interactive="false" desc="Most visited pages."
|
||||||
|
:rawButton="!isLiveDemo"
|
||||||
|
:dataIcons="true" :loading="pagesData.pending.value" label="Top Pages" sub-label="Referrers">
|
||||||
|
</BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
53
dashboard/components/BarCard/Referrers.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import type { IconProvider } from './Base.vue';
|
||||||
|
|
||||||
|
function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> {
|
||||||
|
if (e._id === 'self') return ['icon', 'fas fa-link'];
|
||||||
|
return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${e._id}&sz=64`]
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementTextTransformer(element: string) {
|
||||||
|
if (element === 'self') return 'Direct Link';
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referrersData = useFetch('/api/data/referrers', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
limit: 10,
|
||||||
|
}), lazy: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
|
||||||
|
dialogBarData.value = [];
|
||||||
|
|
||||||
|
showDialog.value = true;
|
||||||
|
isDataLoading.value = true;
|
||||||
|
|
||||||
|
const res = await $fetch('/api/data/referrers', {
|
||||||
|
headers: useComputedHeaders({ limit: 1000 }).value
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
dialogBarData.value = res?.map(e => {
|
||||||
|
return { ...e, icon: iconProvider(e as any) }
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
isDataLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase @showMore="showMore()" :elementTextTransformer="elementTextTransformer"
|
||||||
|
:iconProvider="iconProvider" @dataReload="referrersData.refresh()" :showLink=true
|
||||||
|
:data="referrersData.data.value || []" :interactive="false" desc="Where users find your website."
|
||||||
|
:dataIcons="true" :loading="referrersData.pending.value" label="Top Sources" sub-label="Referrers">
|
||||||
|
</BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
57
dashboard/components/BarCard/Websites.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const currentWebsite = ref<string>("");
|
||||||
|
|
||||||
|
const websitesData = useFetch('/api/data/websites', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
limit: 10,
|
||||||
|
}), lazy: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const pagesData = useFetch('/api/data/websites_pages', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
limit: 10,
|
||||||
|
custom: {
|
||||||
|
'x-website-name': currentWebsite
|
||||||
|
}
|
||||||
|
}), lazy: true
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const isPagesView = ref<boolean>(false);
|
||||||
|
|
||||||
|
const currentData = computed(() => {
|
||||||
|
return isPagesView.value ? pagesData : websitesData
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async function showDetails(website: string) {
|
||||||
|
currentWebsite.value = website;
|
||||||
|
isPagesView.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showGeneral() {
|
||||||
|
websitesData.execute();
|
||||||
|
isPagesView.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function goToView() {
|
||||||
|
router.push('/dashboard/visits');
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase :hideShowMore="true" @showGeneral="showGeneral()" @showRawData="goToView()"
|
||||||
|
@dataReload="currentData.refresh()" @showDetails="showDetails" :data="currentData.data.value || []"
|
||||||
|
:loading="currentData.pending.value" :label="isPagesView ? 'Top pages' : 'Top Domains'"
|
||||||
|
:sub-label="isPagesView ? 'Page' : 'Domains'"
|
||||||
|
:desc="isPagesView ? 'Most visited pages' : 'Most visited domains in this project'"
|
||||||
|
:interactive="!isPagesView" :rawButton="!isLiveDemo" :isDetailView="isPagesView">
|
||||||
|
</BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
|
|
||||||
export type Entry = {
|
|
||||||
label: string,
|
|
||||||
disabled?: boolean,
|
|
||||||
to?: string,
|
|
||||||
icon?: string,
|
|
||||||
action?: () => any,
|
|
||||||
adminOnly?: boolean,
|
|
||||||
external?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Section = {
|
|
||||||
title: string,
|
|
||||||
entries: Entry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
sections: Section[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
|
|
||||||
const { isAdmin } = useUserRoles();
|
|
||||||
|
|
||||||
const debugMode = process.dev;
|
|
||||||
|
|
||||||
const { isOpen, close } = useMenu();
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="CVerticalNavigation h-full w-[20rem] bg-[#111111] flex shadow-[1px_0_10px_#000000] rounded-r-lg" :class="{
|
|
||||||
'absolute top-0 w-full md:w-[20rem] z-[45] open': isOpen,
|
|
||||||
'hidden lg:flex': !isOpen
|
|
||||||
}">
|
|
||||||
<div class="p-4 gap-6 flex flex-col w-full">
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 ml-2">
|
|
||||||
<div class="bg-black h-[2.4rem] aspect-[1/1] flex items-center justify-center rounded-lg">
|
|
||||||
<img class="h-[2rem]" :src="'/logo.png'">
|
|
||||||
</div>
|
|
||||||
<div class="font-bold text-[1.4rem] text-gray-300"> Litlyx </div>
|
|
||||||
|
|
||||||
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
|
|
||||||
<i @click="close()" class="fas fa-close"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
|
|
||||||
<div v-for="section of sections" class="flex flex-col gap-1">
|
|
||||||
|
|
||||||
<div v-for="entry of section.entries">
|
|
||||||
|
|
||||||
<div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))"
|
|
||||||
class="bg-[#111111] text-gray-300 hover:bg-[#1b1b1b] py-2 px-4 rounded-lg" :class="{
|
|
||||||
'text-gray-700 pointer-events-none': entry.disabled,
|
|
||||||
'bg-[#1b1b1b]': route.path == (entry.to || '#')
|
|
||||||
}">
|
|
||||||
|
|
||||||
<NuxtLink @click="close() && entry.action?.()" :target="entry.external ? '_blank' : ''"
|
|
||||||
tag="div" class="flex" :to="entry.to || '/'">
|
|
||||||
<div class="flex items-center w-[1.8rem] justify-start">
|
|
||||||
<i :class="entry.icon"></i>
|
|
||||||
</div>
|
|
||||||
<div class="manrope">
|
|
||||||
{{ entry.label }}
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.CVerticalNavigation * {
|
|
||||||
font-family: 'Geist';
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -5,23 +5,22 @@ const props = defineProps<{ title: string, sub?: string }>();
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card>
|
<LyxUiCard>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4 h-full">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex flex-col grow">
|
<div class="flex flex-col grow">
|
||||||
<div class="poppins font-semibold text-[1.1rem] md:text-[1.4rem] text-text">
|
<div class="poppins font-semibold text-[1rem] md:text-[1.3rem] text-lyx-lightmode-text-dark dark:text-text">
|
||||||
{{ props.title }}
|
{{ props.title }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="props.sub" class="poppins text-[.8rem] md:text-[1.1rem] text-text-sub">
|
<div v-if="props.sub" class="poppins text-[.7rem] md:text-[1rem] text-lyx-lightmode-text-darker dark:text-text-sub">
|
||||||
{{ props.sub }}
|
{{ props.sub }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<slot name="header"></slot>
|
<slot name="header"></slot>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="h-full">
|
||||||
|
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</LyxUiCard>
|
||||||
</template>
|
</template>
|
||||||
73
dashboard/components/CustomTab.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type CItem = { label: string, slot: string, tab?: string }
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: CItem[],
|
||||||
|
manualScroll?: boolean,
|
||||||
|
route?: boolean
|
||||||
|
}>();
|
||||||
|
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const activeTabIndex = ref<number>(0);
|
||||||
|
|
||||||
|
|
||||||
|
function updateTab() {
|
||||||
|
const target = props.items.findIndex(e => e.tab == route.query.tab);
|
||||||
|
if (target == -1) {
|
||||||
|
activeTabIndex.value = 0;
|
||||||
|
} else {
|
||||||
|
activeTabIndex.value = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeTab(newIndex: number) {
|
||||||
|
activeTabIndex.value = newIndex;
|
||||||
|
const target = props.items[newIndex];
|
||||||
|
if (!target) return;
|
||||||
|
router.push({ query: { tab: target.tab } });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
|
||||||
|
if (props.route !== true) return;
|
||||||
|
|
||||||
|
updateTab();
|
||||||
|
|
||||||
|
watch(route, () => {
|
||||||
|
updateTab();
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<div class="flex overflow-x-auto hide-scrollbars">
|
||||||
|
<div class="flex">
|
||||||
|
<div v-for="(tab, index) of items" @click="onChangeTab(index)"
|
||||||
|
class="px-6 whitespace-nowrap pb-3 poppins font-medium text-lyx-lightmode-text dark:text-lyx-text-darker border-b-[1px] border-lyx-text-darker"
|
||||||
|
:class="{
|
||||||
|
'dark:!border-[#FFFFFF] dark:!text-[#FFFFFF] !border-lyx-primary !text-lyx-primary': activeTabIndex === index,
|
||||||
|
'hover:border-lyx-lightmode-text-dark hover:text-lyx-lightmode-text-dark/60 dark:hover:border-lyx-text-dark dark:hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
|
||||||
|
}">
|
||||||
|
{{ tab.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-b-[1px] border-lyx-text-darker w-full">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :class="{ 'overflow-y-hidden': manualScroll }" class="overflow-y-auto h-full">
|
||||||
|
<slot :name="props.items[activeTabIndex].slot"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
63
dashboard/components/DatePicker.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { DatePicker as VCalendarDatePicker } from 'v-calendar'
|
||||||
|
import type { DatePickerDate, DatePickerRangeObject } from 'v-calendar/dist/types/src/use/datePicker'
|
||||||
|
import 'v-calendar/dist/style.css'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: [Date, Object] as PropType<DatePickerDate | DatePickerRangeObject | null>,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:model-value', 'close'])
|
||||||
|
|
||||||
|
const date = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => {
|
||||||
|
emit('update:model-value', value)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const attrs = {
|
||||||
|
transparent: true,
|
||||||
|
borderless: true,
|
||||||
|
color: 'primary',
|
||||||
|
'is-dark': { selector: 'html', darkClass: 'dark' },
|
||||||
|
'first-day-of-week': 2,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VCalendarDatePicker v-if="date && (typeof date === 'object')" v-model.range="date" :columns="2" v-bind="{ ...attrs, ...$attrs }" />
|
||||||
|
<VCalendarDatePicker v-else v-model="date" v-bind="{ ...attrs, ...$attrs }" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--vc-gray-50: rgb(var(--color-gray-50));
|
||||||
|
--vc-gray-100: rgb(var(--color-gray-100));
|
||||||
|
--vc-gray-200: rgb(var(--color-gray-200));
|
||||||
|
--vc-gray-300: rgb(var(--color-gray-300));
|
||||||
|
--vc-gray-400: rgb(var(--color-gray-400));
|
||||||
|
--vc-gray-500: rgb(var(--color-gray-500));
|
||||||
|
--vc-gray-600: rgb(var(--color-gray-600));
|
||||||
|
--vc-gray-700: rgb(var(--color-gray-700));
|
||||||
|
--vc-gray-800: rgb(var(--color-gray-800));
|
||||||
|
--vc-gray-900: rgb(var(--color-gray-900));
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-primary {
|
||||||
|
--vc-accent-50: rgb(var(--color-primary-50));
|
||||||
|
--vc-accent-100: rgb(var(--color-primary-100));
|
||||||
|
--vc-accent-200: rgb(var(--color-primary-200));
|
||||||
|
--vc-accent-300: rgb(var(--color-primary-300));
|
||||||
|
--vc-accent-400: rgb(var(--color-primary-400));
|
||||||
|
--vc-accent-500: rgb(var(--color-primary-500));
|
||||||
|
--vc-accent-600: rgb(var(--color-primary-600));
|
||||||
|
--vc-accent-700: rgb(var(--color-primary-700));
|
||||||
|
--vc-accent-800: rgb(var(--color-primary-800));
|
||||||
|
--vc-accent-900: rgb(var(--color-primary-900));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
234
dashboard/components/FirstInteraction.vue
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { project } = useProject();
|
||||||
|
const { createAlert } = useAlert();
|
||||||
|
|
||||||
|
import 'highlight.js/styles/stackoverflow-dark.css';
|
||||||
|
import hljs from 'highlight.js';
|
||||||
|
import CardTitled from './CardTitled.vue';
|
||||||
|
|
||||||
|
import { Lit } from 'litlyx-js';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
firstInteraction: boolean,
|
||||||
|
refreshInteraction: () => any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
hljs.highlightAll();
|
||||||
|
})
|
||||||
|
|
||||||
|
function copyProjectId() {
|
||||||
|
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||||
|
navigator.clipboard.writeText(project.value?._id?.toString() || '');
|
||||||
|
Lit.event('no_visit_copy_id');
|
||||||
|
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function copyScript() {
|
||||||
|
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||||
|
|
||||||
|
|
||||||
|
const createScriptText = () => {
|
||||||
|
return [
|
||||||
|
'<script defer ',
|
||||||
|
`data-project="${project.value?._id}" `,
|
||||||
|
'src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></',
|
||||||
|
'script>'
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
Lit.event('no_visit_copy_script');
|
||||||
|
navigator.clipboard.writeText(createScriptText());
|
||||||
|
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const scriptText = computed(() => {
|
||||||
|
return [
|
||||||
|
`<script defer data-project="${project.value?._id.toString()}"`,
|
||||||
|
`\nsrc="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js">\n<`,
|
||||||
|
`/script>`
|
||||||
|
].join('');
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
function reloadPage() {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div v-if="!firstInteraction && project" class="mt-[5vh] flex flex-col">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
|
||||||
|
<div class="mr-4 animate-pulse w-[1rem] h-[1rem] bg-accent rounded-full"> </div>
|
||||||
|
<div class="text-lyx-lightmode-text dark:text-text/90 poppins text-[1.1rem] font-medium">
|
||||||
|
Waiting for your first visit
|
||||||
|
</div>
|
||||||
|
<LyxUiButton class="ml-6" type="secondary" @click="reloadPage()">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="far fa-refresh"></i>
|
||||||
|
<div> Refresh </div>
|
||||||
|
</div>
|
||||||
|
</LyxUiButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center mt-10 w-full px-10">
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
|
||||||
|
<div class="flex gap-6 xl:flex-row flex-col">
|
||||||
|
|
||||||
|
<div class="h-full w-full">
|
||||||
|
<CardTitled class="h-full w-full xl:min-w-[400px] xl:h-[35rem]" title="Quick setup tutorial"
|
||||||
|
sub="Quickly Set Up Litlyx in 30 Seconds!">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center h-full w-full">
|
||||||
|
|
||||||
|
<iframe class="w-full h-full min-h-[400px]"
|
||||||
|
src="https://www.youtube.com/embed/LInFoNLJ-CI?si=a97HVXpXFDgFg2Yp" title="Litlyx"
|
||||||
|
frameborder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</CardTitled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<CardTitled title="Quick Integration"
|
||||||
|
sub="Start tracking web analytics in one line. (works everywhere js is supported)">
|
||||||
|
<div class="flex flex-col items-end gap-4">
|
||||||
|
<div class="w-full xl:text-[1rem] text-[.8rem]">
|
||||||
|
<pre>
|
||||||
|
<code class="language-html rounded-md">{{ scriptText }}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<LyxUiButton type="secondary" @click="copyScript()">
|
||||||
|
Copy
|
||||||
|
</LyxUiButton>
|
||||||
|
</div>
|
||||||
|
</CardTitled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-full w-full">
|
||||||
|
<CardTitled class="h-full w-full" title="Project id"
|
||||||
|
sub="This is the identifier for this project, used to forward data">
|
||||||
|
<div class="flex items-center justify-between gap-4 mt-6">
|
||||||
|
<div class="p-2 bg-lyx-lightmode-widget dark:bg-[#1c1b1b] rounded-md w-full">
|
||||||
|
<div class="w-full text-[.9rem] dark:text-[#acacac]"> {{ project?._id }} </div>
|
||||||
|
</div>
|
||||||
|
<LyxUiButton type="secondary" @click="copyProjectId()"> Copy </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</CardTitled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<CardTitled class="w-full h-full" title="Wordpress + Elementor"
|
||||||
|
sub="Our WordPress plugin is coming soon!.">
|
||||||
|
<template #header>
|
||||||
|
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
|
||||||
|
to="https://docs.litlyx.com">
|
||||||
|
Visit documentation
|
||||||
|
</LyxUiButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<div class="justify-center w-full hidden xl:flex gap-3">
|
||||||
|
<a href="#">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/wpel.png'" alt="Litlyx-Wordpress-Elementor">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</CardTitled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<CardTitled class="w-full h-full" title="Modules"
|
||||||
|
sub="Get started with your favorite framework.">
|
||||||
|
<template #header>
|
||||||
|
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
|
||||||
|
to="https://docs.litlyx.com">
|
||||||
|
Visit documentation
|
||||||
|
</LyxUiButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<div class="justify-center w-full hidden xl:flex gap-3">
|
||||||
|
<a href="https://docs.litlyx.com/techs/js" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/js.png'" alt="Litlyx-Javascript-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/nuxt" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/nuxt.png'" alt="Litlyx-Nuxt-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/next" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/next.png'" alt="Litlyx-Next-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/react" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/react.png'" alt="Litlyx-React-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/vue" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/vue.png'" alt="Litlyx-Vue-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/angular" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/angular.png'" alt="Litlyx-Angular-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/python" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/py.png'" alt="Litlyx-Python-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/serverless" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/serverless.png'" alt="Litlyx-Serverless-Analytics">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</CardTitled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- <div class="flex justify-center gap-10 flex-col xl:flex-row items-center xl:items-stretch px-10">
|
||||||
|
|
||||||
|
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full">
|
||||||
|
<div class="poppins font-semibold"> Copy your project_id: </div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div> <i @click="copyProjectId()" class="cursor-pointer hover:text-text far fa-copy"></i> </div>
|
||||||
|
<div class="text-[.9rem] text-[#acacac]"> {{ activeProject?._id }} </div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full xl:max-w-[40vw]">
|
||||||
|
<div class="poppins font-semibold">
|
||||||
|
Start logging visits in 1 click | Plug anywhere !
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div> <i @click="copyScript()" class="cursor-pointer hover:text-text far fa-copy"></i> </div>
|
||||||
|
|
||||||
|
<pre><code class="language-html">{{ scriptText }}</code></pre>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
26
dashboard/components/LyxUi/Button.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
export type ButtonType = 'primary' | 'secondary' | 'outline' | 'outlined' | 'danger';
|
||||||
|
|
||||||
|
const props = defineProps<{ type: ButtonType, link?: string, target?: string, disabled?: boolean }>();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLink tag="div" :to="disabled ? '' : link" :target="target"
|
||||||
|
class="poppins w-fit cursor-pointer px-4 py-1 rounded-md outline outline-[1px] text-lyx-lightmode-text dark:text-lyx-text"
|
||||||
|
:class="{
|
||||||
|
|
||||||
|
'bg-[#85a3ff] hover:bg-[#9db5fc] outline-lyx-lightmode-widget-light dark:bg-lyx-primary-dark dark:outline-lyx-primary dark:hover:bg-lyx-primary-hover': type === 'primary',
|
||||||
|
|
||||||
|
'bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget dark:bg-lyx-widget-lighter hover:bg-lyx-lightmode-widget dark:outline-lyx-widget-lighter dark:hover:bg-lyx-widget-light': type === 'secondary',
|
||||||
|
|
||||||
|
'bg-lyx-transparent outline-lyx-lightmode-widget hover:bg-lyx-lightmode-widget-light dark:outline-lyx-widget-lighter dark:hover:bg-lyx-widget-light': (type === 'outline' || type === 'outlined'),
|
||||||
|
|
||||||
|
'bg-[#fcd1cb] hover:bg-[#f8c5be] dark:bg-lyx-danger-dark outline-lyx-danger dark:hover:bg-lyx-danger': type === 'danger',
|
||||||
|
|
||||||
|
'text-lyx-text !bg-lyx-widget !outline-lyx-widget-lighter !cursor-not-allowed': disabled === true,
|
||||||
|
}">
|
||||||
|
<slot></slot>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
10
dashboard/components/LyxUi/Card.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-fit h-fit rounded-md bg-lyx-lightmode-background outline-lyx-lightmode-widget dark:bg-lyx-widget dark:outline-lyx-background-lighter p-4 outline outline-[1px] ">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
11
dashboard/components/LyxUi/Icon.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const props = defineProps<{ icon: string }>();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="material-symbols-outlined">
|
||||||
|
{{ props.icon }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
24
dashboard/components/LyxUi/Input.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
const props = defineProps<{ placeholder?: string, modelValue: string, type?: string }>();
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: string): void
|
||||||
|
}>();
|
||||||
|
|
||||||
|
|
||||||
|
const handleChange = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
emits('update:modelValue', target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
//TODO: FUNCTIONALITY + PLACEHOLDER DARK
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input
|
||||||
|
class="bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget text-lyx-lightmode-text dark:bg-lyx-widget-light dark:text-lyx-text-dark poppins rounded-md outline outline-[1px] dark:outline-lyx-widget-lighter"
|
||||||
|
:type="props.type ?? 'text'" :placeholder="props.placeholder" :value="props.modelValue" @input="handleChange">
|
||||||
|
</template>
|
||||||
15
dashboard/components/LyxUi/Separator.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const props = defineProps<{ size?: string }>();
|
||||||
|
|
||||||
|
const widgetStyle = computed(() => {
|
||||||
|
return `height: ${props.size ?? '1px'}`;
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :style="widgetStyle" class="dark:bg-lyx-widget-light bg-lyx-lightmode-widget"></div>
|
||||||
|
</template>
|
||||||
176
dashboard/components/Onboarding.vue
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { data: needsOnboarding } = useFetch("/api/onboarding/exist", {
|
||||||
|
headers: useComputedHeaders({ useSnapshotDates: false, useTimeOffset: false })
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const analyticsList = [
|
||||||
|
"I have no prior analytics tool",
|
||||||
|
"Google Analytics 4",
|
||||||
|
"Plausible",
|
||||||
|
"Umami",
|
||||||
|
"MixPanel",
|
||||||
|
"Simple Analytics",
|
||||||
|
"Matomo",
|
||||||
|
"Fathom",
|
||||||
|
"Adobe Analytics",
|
||||||
|
"Other"
|
||||||
|
]
|
||||||
|
|
||||||
|
const jobsList = [
|
||||||
|
"Developer",
|
||||||
|
"Marketing",
|
||||||
|
"Product",
|
||||||
|
"Startup founder",
|
||||||
|
"Indie hacker",
|
||||||
|
"Other",
|
||||||
|
]
|
||||||
|
|
||||||
|
const selectedIndex = ref<number>(-1);
|
||||||
|
const otherFieldVisisble = ref<boolean>(false);
|
||||||
|
const otherText = ref<string>('');
|
||||||
|
function selectIndex(index: number) {
|
||||||
|
selectedIndex.value = index;
|
||||||
|
otherFieldVisisble.value = index == analyticsList.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIndex2 = ref<number>(-1);
|
||||||
|
const otherFieldVisisble2 = ref<boolean>(false);
|
||||||
|
const otherText2 = ref<string>('');
|
||||||
|
function selectIndex2(index: number) {
|
||||||
|
selectedIndex2.value = index;
|
||||||
|
otherFieldVisisble2.value = index == jobsList.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = ref<number>(0);
|
||||||
|
|
||||||
|
function onNextPage() {
|
||||||
|
if (selectedIndex.value == -1) return;
|
||||||
|
saveAnalyticsType();
|
||||||
|
page.value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFinish(skipped?: boolean) {
|
||||||
|
if (skipped) return location.reload();
|
||||||
|
if (selectedIndex2.value == -1) return;
|
||||||
|
saveJobTitle();
|
||||||
|
page.value = 2;
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAnalyticsType() {
|
||||||
|
await $fetch('/api/onboarding/add', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
useSnapshotDates: false, useTimeOffset: false,
|
||||||
|
custom: { 'Content-Type': 'application/json' }
|
||||||
|
}).value,
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
analytics:
|
||||||
|
selectedIndex.value == analyticsList.length - 1 ?
|
||||||
|
otherText.value :
|
||||||
|
analyticsList[selectedIndex.value]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveJobTitle() {
|
||||||
|
|
||||||
|
await $fetch('/api/onboarding/add', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
useSnapshotDates: false, useTimeOffset: false,
|
||||||
|
custom: { 'Content-Type': 'application/json' }
|
||||||
|
}).value,
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
job:
|
||||||
|
selectedIndex2.value == jobsList.length - 1 ?
|
||||||
|
otherText2.value :
|
||||||
|
jobsList[selectedIndex2.value]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const showOnboarding = computed(() => {
|
||||||
|
if (route.path === '/login') return false;
|
||||||
|
if (route.path === '/register') return false;
|
||||||
|
if ((needsOnboarding.value as any)?.exist === false) return true;
|
||||||
|
if ((needsOnboarding.value as any)?.exists === false) return true;
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="showOnboarding" class="absolute top-0 left-0 w-full h-full z-[30] bg-black/80 flex justify-center">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="page == 0" class="bg-lyx-lightmode-background-light dark:bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
|
||||||
|
|
||||||
|
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
|
||||||
|
|
||||||
|
<div class="text-lyx-lightmode-text dark:text-lyx-text mt-4">
|
||||||
|
For the current project do you already have other Analytics tools implemented (e.g. GA4) or Litlyx is
|
||||||
|
going to be your first/main analytics?
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 mt-8">
|
||||||
|
<div v-for="(e, i) of analyticsList">
|
||||||
|
<div @click="selectIndex(i)"
|
||||||
|
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex == i }"
|
||||||
|
class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
|
||||||
|
{{ e }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<LyxUiInput v-if="otherFieldVisisble" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
|
||||||
|
v-model="otherText"></LyxUiInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-center flex-col items-center">
|
||||||
|
<LyxUiButton @click="onNextPage()" class="px-[8rem] py-2" :disabled="selectedIndex == -1"
|
||||||
|
type="primary"> Next </LyxUiButton>
|
||||||
|
<!-- <div class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div v-if="page == 1" class="bg-lyx-lightmode-background-light dark:bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
|
||||||
|
|
||||||
|
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
|
||||||
|
|
||||||
|
<div class="text-lyx-lightmode-text dark:text-lyx-text mt-4">
|
||||||
|
What is your job title ?
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 mt-8">
|
||||||
|
<div v-for="(e, i) of jobsList">
|
||||||
|
<div @click="selectIndex2(i)"
|
||||||
|
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex2 == i }"
|
||||||
|
class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
|
||||||
|
{{ e }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<LyxUiInput v-if="otherFieldVisisble2" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
|
||||||
|
v-model="otherText2"></LyxUiInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-center flex-col items-center">
|
||||||
|
<LyxUiButton @click="onFinish()" class="px-[8rem] py-2" :disabled="selectedIndex2 == -1" type="primary">
|
||||||
|
Finish </LyxUiButton>
|
||||||
|
<div @click="onFinish(true)" class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
options: { label: string }[],
|
options: { label: string, disabled?: boolean }[],
|
||||||
currentIndex: number
|
currentIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,10 +16,13 @@ const emits = defineEmits<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div class="flex gap-2 border-[1px] border-gray-400 p-1 md:p-2 rounded-xl">
|
<div class="flex gap-2 border-[1px] p-1 md:p-2 rounded-xl bg-lyx-lightmode-widget-light border-lyx-lightmode-widget dark:bg-lyx-widget dark:border-lyx-widget-lighter">
|
||||||
<div @click="$emit('changeIndex', index)" v-for="(opt, index) of options"
|
<div @click="opt.disabled ? ()=>{}: $emit('changeIndex', index)" v-for="(opt, index) of options"
|
||||||
class="hover:bg-white/10 select-btn-animated cursor-pointer rounded-lg poppins font-semibold px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
|
class="hover:bg-lyx-lightmode-widget dark:hover:bg-lyx-widget-lighter/60 select-btn-animated cursor-pointer rounded-lg poppins font-regular px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
|
||||||
:class="{ 'bg-accent hover:!bg-accent': currentIndex == index }">
|
:class="{
|
||||||
|
'bg-lyx-lightmode-widget hover:!bg-lyx-lightmode-widget dark:bg-lyx-widget-lighter dark:hover:!bg-lyx-widget-lighter': currentIndex == index && !opt.disabled,
|
||||||
|
'hover:!bg-lyx-lightmode-widget-light text-lyx-lightmode-widget dark:hover:!bg-lyx-widget !cursor-not-allowed dark:!text-lyx-widget-lighter': opt.disabled
|
||||||
|
}">
|
||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
88
dashboard/components/admin/Backend.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const { data: backendData, pending: backendPending, refresh: refreshBackend } = useFetch<any>(() => `/api/admin/backend`, signHeaders());
|
||||||
|
|
||||||
|
const avgDuration = computed(() => {
|
||||||
|
if (!backendData?.value?.durations) return -1;
|
||||||
|
return (backendData.value.durations.durations.reduce((a: any, e: any) => a + parseInt(e[1]), 0) / backendData.value.durations.durations.length);
|
||||||
|
})
|
||||||
|
|
||||||
|
const labels = computed(() => {
|
||||||
|
if (!backendData?.value?.durations) return [];
|
||||||
|
|
||||||
|
const sizes = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const e of backendData.value.durations.durations) {
|
||||||
|
if (!sizes.has(e[0])) {
|
||||||
|
sizes.set(e[0], 0);
|
||||||
|
} else {
|
||||||
|
const data = sizes.get(e[0]) ?? 0;
|
||||||
|
sizes.set(e[0], data + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = Array.from(sizes.values()).reduce((a, e) => a > e ? a : e, 0);
|
||||||
|
return new Array(max).fill('-');
|
||||||
|
});
|
||||||
|
|
||||||
|
const durationsDatasets = computed(() => {
|
||||||
|
if (!backendData?.value?.durations) return [];
|
||||||
|
|
||||||
|
const colors = ['#2200DD', '#CC0022', '#0022CC', '#FF0000', '#00FF00', '#0000FF'];
|
||||||
|
|
||||||
|
const datasets = [];
|
||||||
|
|
||||||
|
const uniqueConsumers: string[] = Array.from(new Set(backendData.value.durations.durations.map((e: any) => e[0])));
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueConsumers.length; i++) {
|
||||||
|
|
||||||
|
const consumerDurations = backendData.value.durations.durations.filter((e: any) => e[0] == uniqueConsumers[i]);
|
||||||
|
|
||||||
|
datasets.push({
|
||||||
|
points: consumerDurations.map((e: any) => {
|
||||||
|
return 1000 / parseInt(e[1])
|
||||||
|
}),
|
||||||
|
color: colors[i],
|
||||||
|
chartType: 'line',
|
||||||
|
name: uniqueConsumers[i]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return datasets;
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
<div class="cursor-default flex justify-center w-full">
|
||||||
|
|
||||||
|
<div v-if="backendData && !backendPending" class="flex flex-col mt-8 gap-6 px-20 items-center w-full">
|
||||||
|
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div> Queue size: {{ backendData.queue?.size || 'ERROR' }} </div>
|
||||||
|
<div> Avg consumer time: {{ avgDuration.toFixed(1) }} ms </div>
|
||||||
|
<div> Avg processed/s: {{ (1000 / avgDuration).toFixed(1) }} </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<AdminBackendLineChart :labels="labels" title="Avg Processed/s" :datasets="durationsDatasets">
|
||||||
|
</AdminBackendLineChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div @click="refreshBackend()"> Refresh </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="backendPending">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
31
dashboard/components/admin/Feedbacks.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { data: feedbacks, pending: pendingFeedbacks } = useFetch<any[]>(() => `/api/admin/feedbacks`, signHeaders());
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||||
|
|
||||||
|
<div v-if="feedbacks" class="flex flex-col-reverse gap-4 px-20">
|
||||||
|
<div class="flex flex-col text-center outline outline-[1px] outline-lyx-widget-lighter p-4 gap-2"
|
||||||
|
v-for="feedback of feedbacks">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-lyx-text-dark"> {{ feedback.user[0]?.email || 'DELETED USER' }} </div>
|
||||||
|
<div class="text-lyx-text-dark"> {{ feedback.project[0]?.name || 'DELETED PROJECT' }} </div>
|
||||||
|
</div>
|
||||||
|
{{ feedback.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="pendingFeedbacks"> Loading...</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
271
dashboard/components/admin/MiniChart.vue
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import DateService, { type Slice } from '@services/DateService';
|
||||||
|
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
|
||||||
|
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||||
|
import * as fns from 'date-fns';
|
||||||
|
|
||||||
|
const props = defineProps<{ pid: string }>();
|
||||||
|
|
||||||
|
const errorData = ref<{ errored: boolean, text: string }>({ errored: false, text: '' })
|
||||||
|
|
||||||
|
function createGradient(startColor: string) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
const ctx = c.getContext("2d");
|
||||||
|
let gradient: any = `${startColor}22`;
|
||||||
|
if (ctx) {
|
||||||
|
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||||
|
gradient.addColorStop(0, `${startColor}99`);
|
||||||
|
gradient.addColorStop(0.35, `${startColor}66`);
|
||||||
|
gradient.addColorStop(1, `${startColor}22`);
|
||||||
|
} else {
|
||||||
|
console.warn('Cannot get context for gradient');
|
||||||
|
}
|
||||||
|
|
||||||
|
return gradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartOptions = ref<ChartOptions<'line'>>({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: (false as any),
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
// borderDash: [5, 10]
|
||||||
|
},
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: { display: true },
|
||||||
|
stacked: false,
|
||||||
|
offset: false,
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
title: { display: false },
|
||||||
|
tooltip: { enabled: false }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Visits',
|
||||||
|
data: [],
|
||||||
|
backgroundColor: ['#5655d7'],
|
||||||
|
borderColor: '#5655d7',
|
||||||
|
borderWidth: 4,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.35,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 10,
|
||||||
|
hoverBackgroundColor: '#5655d7',
|
||||||
|
hoverBorderColor: 'white',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
segment: {
|
||||||
|
borderColor(ctx, options) {
|
||||||
|
const todayIndex = visitsData.data.value?.todayIndex;
|
||||||
|
if (!todayIndex || todayIndex == -1) return '#5655d7';
|
||||||
|
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||||
|
return '#5655d7'
|
||||||
|
},
|
||||||
|
borderDash(ctx, options) {
|
||||||
|
const todayIndex = visitsData.data.value?.todayIndex;
|
||||||
|
if (!todayIndex || todayIndex == -1) return undefined;
|
||||||
|
if (ctx.p1DataIndex == todayIndex - 1) return [3, 5];
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
backgroundColor(ctx, options) {
|
||||||
|
const todayIndex = visitsData.data.value?.todayIndex;
|
||||||
|
if (!todayIndex || todayIndex == -1) return createGradient('#5655d7');
|
||||||
|
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||||
|
return createGradient('#5655d7');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Unique visitors',
|
||||||
|
data: [],
|
||||||
|
backgroundColor: ['#4abde8'],
|
||||||
|
borderColor: '#4abde8',
|
||||||
|
borderWidth: 2,
|
||||||
|
hoverBackgroundColor: '#4abde8',
|
||||||
|
hoverBorderColor: '#4abde8',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
type: 'bar',
|
||||||
|
// barThickness: 20,
|
||||||
|
borderSkipped: ['bottom'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Events',
|
||||||
|
data: [],
|
||||||
|
backgroundColor: ['#fbbf24'],
|
||||||
|
borderWidth: 2,
|
||||||
|
hoverBackgroundColor: '#fbbf24',
|
||||||
|
hoverBorderColor: '#fbbf24',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
type: 'bubble',
|
||||||
|
stack: 'combined',
|
||||||
|
borderColor: ["#fbbf24"]
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
|
||||||
|
|
||||||
|
const selectedSlice: Slice = 'day'
|
||||||
|
|
||||||
|
const allDatesFull = ref<string[]>([]);
|
||||||
|
|
||||||
|
|
||||||
|
function transformResponse(input: { _id: string, count: number }[]) {
|
||||||
|
const data = input.map(e => e.count);
|
||||||
|
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice));
|
||||||
|
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
|
||||||
|
|
||||||
|
const todayIndex = input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
|
||||||
|
|
||||||
|
return { data, labels, todayIndex }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResponseError(e: any) {
|
||||||
|
let message = e.response._data.message ?? 'Generic error';
|
||||||
|
if (message == 'internal server error') message = 'Please change slice';
|
||||||
|
errorData.value = { errored: true, text: message }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResponse(e: any) {
|
||||||
|
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const headers = computed(() => {
|
||||||
|
return {
|
||||||
|
'x-from': fns.startOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
|
||||||
|
'x-to': fns.endOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
|
||||||
|
'x-pid': props.pid
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const visitsData = useFetch(`/api/timeline/visits?pid=${props.pid}`, {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
slice: selectedSlice,
|
||||||
|
custom: { ...headers.value },
|
||||||
|
useActivePid: false,
|
||||||
|
useActiveDomain: false
|
||||||
|
}),
|
||||||
|
lazy: true,
|
||||||
|
transform: transformResponse, onResponseError, onResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionsData = useFetch(`/api/timeline/sessions?pid=${props.pid}`, {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
slice: selectedSlice,
|
||||||
|
custom: { ...headers.value },
|
||||||
|
useActivePid: false,
|
||||||
|
useActiveDomain: false
|
||||||
|
}), lazy: true,
|
||||||
|
transform: transformResponse, onResponseError, onResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventsData = useFetch(`/api/timeline/events?pid=${props.pid}`, {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
slice: selectedSlice,
|
||||||
|
custom: { ...headers.value },
|
||||||
|
useActivePid: false,
|
||||||
|
useActiveDomain: false
|
||||||
|
}), lazy: true,
|
||||||
|
transform: transformResponse, onResponseError, onResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
const readyToDisplay = computed(() => !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value);
|
||||||
|
|
||||||
|
watch(readyToDisplay, () => {
|
||||||
|
if (readyToDisplay.value === true) onDataReady();
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
function onDataReady() {
|
||||||
|
if (!visitsData.data.value) return;
|
||||||
|
if (!eventsData.data.value) return;
|
||||||
|
if (!sessionsData.data.value) return;
|
||||||
|
|
||||||
|
chartData.value.labels = visitsData.data.value.labels;
|
||||||
|
|
||||||
|
const maxChartY = Math.max(...visitsData.data.value.data, ...sessionsData.data.value.data);
|
||||||
|
const maxEventSize = Math.max(...eventsData.data.value.data)
|
||||||
|
|
||||||
|
chartData.value.datasets[0].data = visitsData.data.value.data;
|
||||||
|
chartData.value.datasets[1].data = sessionsData.data.value.data;
|
||||||
|
|
||||||
|
chartData.value.datasets[2].data = eventsData.data.value.data.map(e => {
|
||||||
|
const rValue = 20 / maxEventSize * e;
|
||||||
|
return { x: 0, y: maxChartY + 20, r: isNaN(rValue) ? 0 : rValue, r2: e }
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
chartData.value.datasets[0].backgroundColor = [createGradient('#5655d7')];
|
||||||
|
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
|
||||||
|
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
|
||||||
|
|
||||||
|
|
||||||
|
(chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => {
|
||||||
|
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||||
|
if (i == todayIndex - 1) return true;
|
||||||
|
return 'bottom';
|
||||||
|
});
|
||||||
|
|
||||||
|
chartData.value.datasets[2].borderColor = eventsData.data.value.data.map((e, i) => {
|
||||||
|
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||||
|
if (i == todayIndex - 1) return '#fbbf2400';
|
||||||
|
return '#fbbf24';
|
||||||
|
});
|
||||||
|
|
||||||
|
updateChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div class="h-[10rem] w-full flex">
|
||||||
|
<div v-if="!readyToDisplay" class="w-full flex justify-center items-center">
|
||||||
|
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end w-full" v-if="readyToDisplay && !errorData.errored">
|
||||||
|
<LineChart ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorData.errored" class="flex items-center justify-center py-8">
|
||||||
|
{{ errorData.text }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
#external-tooltip {
|
||||||
|
border-radius: 3px;
|
||||||
|
color: white;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
transition: all .1s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
45
dashboard/components/admin/Onboardings.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { data: onboardings, pending: pendingOnboardings } = useFetch<any>(() => `/api/admin/onboardings`, signHeaders());
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
<div class="cursor-default flex flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||||
|
|
||||||
|
<div v-if="onboardings" class="flex gap-40 px-20">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-lyx-primary"> Anaytics </div>
|
||||||
|
<div class="flex items-center gap-2"
|
||||||
|
v-for="e of onboardings.analytics.sort((a: any, b: any) => b.count - a.count)">
|
||||||
|
<div>{{ e._id }}</div>
|
||||||
|
<div>{{ e.count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-lyx-primary"> Jobs </div>
|
||||||
|
<div class="flex items-center gap-2"
|
||||||
|
v-for="e of onboardings.jobs.sort((a: any, b: any) => b.count - a.count)">
|
||||||
|
<div>{{ e._id }}</div>
|
||||||
|
<div>{{ e.count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="onboardings" class="flex flex-col gap-8">
|
||||||
|
<AdminOnboardingPieChart :data="onboardings.analytics" title="Analytics"></AdminOnboardingPieChart>
|
||||||
|
<AdminOnboardingPieChart :data="onboardings.jobs" title="Jobs"></AdminOnboardingPieChart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="pendingOnboardings"> Loading...</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
204
dashboard/components/admin/Overview.vue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||||
|
|
||||||
|
import { PREMIUM_PLAN, getPlanFromId } from '@data/PREMIUM'
|
||||||
|
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
|
||||||
|
|
||||||
|
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||||
|
|
||||||
|
|
||||||
|
const page = ref<number>(1);
|
||||||
|
|
||||||
|
const ordersList = [
|
||||||
|
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
|
||||||
|
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'active -->', id: '{ "last_log_at": 1 }' },
|
||||||
|
{ label: 'active <--', id: '{ "last_log_at": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'visits -->', id: '{ "visits": 1 }' },
|
||||||
|
{ label: 'visits <--', id: '{ "visits": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'events -->', id: '{ "events": 1 }' },
|
||||||
|
{ label: 'events <--', id: '{ "events": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'sessions -->', id: '{ "sessions": 1 }' },
|
||||||
|
{ label: 'sessions <--', id: '{ "sessions": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'usage total -->', id: '{ "limit_total": 1 }' },
|
||||||
|
{ label: 'usage total <--', id: '{ "limit_total": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'usage visits -->', id: '{ "limit_visits": 1 }' },
|
||||||
|
{ label: 'usage visits <--', id: '{ "limit_visits": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'usage events -->', id: '{ "limit_events": 1 }' },
|
||||||
|
{ label: 'usage events <--', id: '{ "limit_events": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'usage ai -->', id: '{ "limit_ai_messages": 1 }' },
|
||||||
|
{ label: 'usage ai <--', id: '{ "limit_ai_messages": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'plan -->', id: '{ "premium_type": 1 }' },
|
||||||
|
{ label: 'plan <--', id: '{ "premium_type": -1 }' },
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
const order = ref<string>('{ "created_at": -1 }');
|
||||||
|
|
||||||
|
const limitList = [
|
||||||
|
{ label: '10', id: 10 },
|
||||||
|
{ label: '20', id: 20 },
|
||||||
|
{ label: '50', id: 50 },
|
||||||
|
{ label: '100', id: 100 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const limit = ref<number>(20);
|
||||||
|
|
||||||
|
const filterList = [
|
||||||
|
{ label: 'ALL', id: '{}' },
|
||||||
|
{ label: 'PREMIUM', id: '{ "premium_type": { "$gt": 0, "$lt": 1000 } }' },
|
||||||
|
{ label: 'APPSUMO', id: '{ "premium_type": { "$gt": 6000, "$lt": 7000 } }' },
|
||||||
|
{ label: 'PREMIUM+APPSUMO', id: '{ "premium_type": { "$gt": 0, "$lt": 7000 } }' },
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
function isRangeSelected(duration: Duration) {
|
||||||
|
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRange(duration: Duration) {
|
||||||
|
selected.value = { start: sub(new Date(), duration), end: new Date() }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranges = [
|
||||||
|
{ label: 'Last 7 days', duration: { days: 7 } },
|
||||||
|
{ label: 'Last 14 days', duration: { days: 14 } },
|
||||||
|
{ label: 'Last 30 days', duration: { days: 30 } },
|
||||||
|
{ label: 'Last 3 months', duration: { months: 3 } },
|
||||||
|
{ label: 'Last 6 months', duration: { months: 6 } },
|
||||||
|
{ label: 'Last year', duration: { years: 1 } }
|
||||||
|
]
|
||||||
|
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
for (const key in PREMIUM_PLAN) {
|
||||||
|
filterList.push({ label: key, id: `{"premium_type": ${(PREMIUM_PLAN as any)[key].ID}}` });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const filter = ref<string>('{}');
|
||||||
|
|
||||||
|
const { data: projectsInfo, pending: pendingProjects } = useFetch<{ count: number, projects: TAdminProject[] }>(
|
||||||
|
() => `/api/admin/projects?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
|
||||||
|
signHeaders()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: metrics, pending: pendingMetrics } = useFetch(
|
||||||
|
() => `/api/admin/metrics?filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
|
||||||
|
signHeaders()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { uiMenu } = useSelectMenuStyle();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-8">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-10 px-10">
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div>Order:</div>
|
||||||
|
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
|
||||||
|
value-attribute="id" option-attribute="label" v-model="order">
|
||||||
|
</USelectMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div>Limit:</div>
|
||||||
|
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
|
||||||
|
value-attribute="id" option-attribute="label" v-model="limit">
|
||||||
|
</USelectMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div>Filter:</div>
|
||||||
|
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Filter" :options="filterList"
|
||||||
|
value-attribute="id" option-attribute="label" v-model="filter">
|
||||||
|
</USelectMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-10 justify-center px-10 w-full">
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center shrink-0">
|
||||||
|
<div>Page {{ page }} </div>
|
||||||
|
<div> {{ Math.min(limit, projectsInfo?.count || 0) }} of {{ projectsInfo?.count || 0
|
||||||
|
}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<UPagination v-model="page" :page-count="limit" :total="projectsInfo?.count || 0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UPopover class="w-[20rem]" :popper="{ placement: 'bottom' }">
|
||||||
|
<UButton class="w-full" color="primary" variant="solid">
|
||||||
|
<div class="flex items-center justify-center w-full gap-2">
|
||||||
|
<i class="i-heroicons-calendar-days-20-solid"></i>
|
||||||
|
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
|
||||||
|
</div>
|
||||||
|
</UButton>
|
||||||
|
<template #panel="{ close }">
|
||||||
|
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
|
||||||
|
<div class="hidden sm:flex flex-col py-4">
|
||||||
|
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
|
||||||
|
variant="ghost" class="rounded-none px-6"
|
||||||
|
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
|
||||||
|
truncate @click="selectRange(range.duration)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DatePicker v-model="selected" @close="close" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPopover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="w-[80%]">
|
||||||
|
<div v-if="pendingMetrics"> Loading... </div>
|
||||||
|
<div class="flex gap-10 flex-wrap" v-if="!pendingMetrics && metrics">
|
||||||
|
<div> Projects: {{ metrics.totalProjects }} ({{ metrics.premiumProjects }} premium) </div>
|
||||||
|
<div>
|
||||||
|
Total visits: {{ formatNumberK(metrics.totalVisits) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Active: {{ metrics.totalProjects - metrics.deadProjects }} |
|
||||||
|
Dead: {{ metrics.deadProjects }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Total events: {{ formatNumberK(metrics.totalEvents) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||||
|
|
||||||
|
<AdminOverviewProjectCard v-if="!pendingProjects" :key="project._id.toString()" :project="project"
|
||||||
|
class="w-[26rem]" v-for="project of projectsInfo?.projects" />
|
||||||
|
|
||||||
|
<div v-if="pendingProjects"> Loading...</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
151
dashboard/components/admin/Users.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
|
||||||
|
import type { TAdminUser } from '~/server/api/admin/users';
|
||||||
|
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const filterText = ref<string>('');
|
||||||
|
|
||||||
|
watch(filterText, () => {
|
||||||
|
page.value = 1;
|
||||||
|
})
|
||||||
|
|
||||||
|
function isRangeSelected(duration: Duration) {
|
||||||
|
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRange(duration: Duration) {
|
||||||
|
selected.value = { start: sub(new Date(), duration), end: new Date() }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranges = [
|
||||||
|
{ label: 'Last 7 days', duration: { days: 7 } },
|
||||||
|
{ label: 'Last 14 days', duration: { days: 14 } },
|
||||||
|
{ label: 'Last 30 days', duration: { days: 30 } },
|
||||||
|
{ label: 'Last 3 months', duration: { months: 3 } },
|
||||||
|
{ label: 'Last 6 months', duration: { months: 6 } },
|
||||||
|
{ label: 'Last year', duration: { years: 1 } }
|
||||||
|
]
|
||||||
|
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
|
||||||
|
|
||||||
|
const filter = computed(() => {
|
||||||
|
return JSON.stringify({
|
||||||
|
$or: [
|
||||||
|
{ given_name: { $regex: `.*${filterText.value}.*`, $options: "i" } },
|
||||||
|
{ email: { $regex: `.*${filterText.value}.*`, $options: "i" } }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const page = ref<number>(1);
|
||||||
|
|
||||||
|
const ordersList = [
|
||||||
|
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
|
||||||
|
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const order = ref<string>('{ "created_at": -1 }');
|
||||||
|
|
||||||
|
|
||||||
|
const limitList = [
|
||||||
|
{ label: '10', id: 10 },
|
||||||
|
{ label: '20', id: 20 },
|
||||||
|
{ label: '50', id: 50 },
|
||||||
|
{ label: '100', id: 100 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const limit = ref<number>(20);
|
||||||
|
|
||||||
|
const { data: usersInfo, pending: pendingUsers } = await useFetch<{ count: number, users: TAdminUser[] }>(
|
||||||
|
() => `/api/admin/users?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
|
||||||
|
signHeaders()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { uiMenu } = useSelectMenuStyle();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-6">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-10 px-10">
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div>Order:</div>
|
||||||
|
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
|
||||||
|
value-attribute="id" option-attribute="label" v-model="order">
|
||||||
|
</USelectMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div>Limit:</div>
|
||||||
|
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
|
||||||
|
value-attribute="id" option-attribute="label" v-model="limit">
|
||||||
|
</USelectMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<LyxUiInput placeholder="Search user" class="px-2 py-1" v-model="filterText"></LyxUiInput>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-centet gap-10">
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div>Page {{ page }} </div>
|
||||||
|
<div>
|
||||||
|
{{ Math.min(limit, usersInfo?.count || 0) }}
|
||||||
|
of
|
||||||
|
{{ usersInfo?.count || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<UPagination v-model="page" :page-count="limit" :total="usersInfo?.count || 0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UPopover class="w-[20rem]" :popper="{ placement: 'bottom' }">
|
||||||
|
<UButton class="w-full" color="primary" variant="solid">
|
||||||
|
<div class="flex items-center justify-center w-full gap-2">
|
||||||
|
<i class="i-heroicons-calendar-days-20-solid"></i>
|
||||||
|
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
|
||||||
|
</div>
|
||||||
|
</UButton>
|
||||||
|
<template #panel="{ close }">
|
||||||
|
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
|
||||||
|
<div class="hidden sm:flex flex-col py-4">
|
||||||
|
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
|
||||||
|
variant="ghost" class="rounded-none px-6"
|
||||||
|
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
|
||||||
|
truncate @click="selectRange(range.duration)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DatePicker v-model="selected" @close="close" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPopover>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||||
|
|
||||||
|
<AdminUsersUserCard v-if="!pendingUsers" :key="user._id.toString()" :user="user" class="w-[26rem]"
|
||||||
|
v-for="user of usersInfo?.users" />
|
||||||
|
|
||||||
|
<div v-if="pendingUsers"> Loading...</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
132
dashboard/components/admin/backend/LineChart.vue
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js';
|
||||||
|
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||||
|
import * as datefns from 'date-fns';
|
||||||
|
|
||||||
|
const errored = ref<boolean>(false);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
labels: string[],
|
||||||
|
title: string,
|
||||||
|
datasets: {
|
||||||
|
points: number[],
|
||||||
|
color: string,
|
||||||
|
name: string
|
||||||
|
}[]
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartOptions = ref<ChartOptions<'line'>>({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: 'x',
|
||||||
|
includeInvisible: true
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
// borderDash: [5, 10]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true },
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: props.title
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
titleFont: { size: 16, weight: 'bold' },
|
||||||
|
bodyFont: { size: 14 },
|
||||||
|
padding: 10,
|
||||||
|
cornerRadius: 4,
|
||||||
|
boxPadding: 10,
|
||||||
|
caretPadding: 20,
|
||||||
|
yAlign: 'bottom',
|
||||||
|
xAlign: 'center',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = ref<ChartData<'line'>>({
|
||||||
|
labels: props.labels.map(e => {
|
||||||
|
try {
|
||||||
|
return datefns.format(new Date(e), 'dd/MM');
|
||||||
|
} catch (ex) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
datasets: props.datasets.map(e => ({
|
||||||
|
data: e.points,
|
||||||
|
label: e.name,
|
||||||
|
backgroundColor: [e.color + '00'],
|
||||||
|
borderColor: e.color,
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.45,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 10,
|
||||||
|
type: 'line'
|
||||||
|
} as any))
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
|
||||||
|
|
||||||
|
function createGradient(startColor: string) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
const ctx = c.getContext("2d");
|
||||||
|
let gradient: any = `${startColor}22`;
|
||||||
|
if (ctx) {
|
||||||
|
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||||
|
gradient.addColorStop(0, `${startColor}99`);
|
||||||
|
gradient.addColorStop(0.35, `${startColor}66`);
|
||||||
|
gradient.addColorStop(1, `${startColor}22`);
|
||||||
|
} else {
|
||||||
|
console.warn('Cannot get context for gradient');
|
||||||
|
}
|
||||||
|
|
||||||
|
return gradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// chartData.value.datasets.forEach(dataset => {
|
||||||
|
// if (dataset.borderColor && dataset.borderColor.toString().startsWith('#')) {
|
||||||
|
// dataset.backgroundColor = [createGradient(dataset.borderColor as string)]
|
||||||
|
// } else {
|
||||||
|
// dataset.backgroundColor = [createGradient('#3d59a4')]
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
} catch (ex) {
|
||||||
|
errored.value = true;
|
||||||
|
console.error(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="errored"> ERROR CREATING CHART </div>
|
||||||
|
<LineChart v-if="!errored" ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
48
dashboard/components/admin/dialog/ProjectDetails.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||||
|
|
||||||
|
const props = defineProps<{ pid: string }>();
|
||||||
|
|
||||||
|
const { data: projectInfo, refresh, pending } = useFetch<{ domains: { _id: string }[], project: TAdminProject }>(
|
||||||
|
() => `/api/admin/project_info?pid=${props.pid}`,
|
||||||
|
signHeaders(),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div class="mt-6 h-full flex flex-col gap-10 w-full" v-if="!pending">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<LyxUiButton type="secondary" @click="refresh"> Refresh </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center gap-10" v-if="projectInfo">
|
||||||
|
|
||||||
|
<AdminOverviewProjectCard :project="projectInfo.project" class="w-[30rem] shrink-0" />
|
||||||
|
|
||||||
|
<AdminMiniChart class="max-w-[40rem]" :pid="pid"></AdminMiniChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="projectInfo" class="flex flex-col">
|
||||||
|
|
||||||
|
<div>Domains:</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-8 mt-8">
|
||||||
|
|
||||||
|
<div v-for="domain of projectInfo.domains">
|
||||||
|
{{ domain._id }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
62
dashboard/components/admin/onboarding/PieChart.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import { type ChartData, type ChartOptions } from 'chart.js';
|
||||||
|
import { PieChart, usePieChart } from 'vue-chart-3';
|
||||||
|
|
||||||
|
const props = defineProps<{ data: { _id: string, count: number }[], title:string }>();
|
||||||
|
|
||||||
|
const eventsTimelineOptions = ref<ChartOptions<'pie'>>({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: props.title,
|
||||||
|
color: '#EEECF6',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventsTimelineData = computed<ChartData<'pie'>>(() => ({
|
||||||
|
labels: props.data.map(e => e._id),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: props.data.map(e => e.count),
|
||||||
|
backgroundColor: [
|
||||||
|
"#295270",
|
||||||
|
"#304F71",
|
||||||
|
"#374C72",
|
||||||
|
"#3E4A73",
|
||||||
|
"#444773",
|
||||||
|
"#4B4474",
|
||||||
|
"#524175",
|
||||||
|
],
|
||||||
|
borderColor: '#222222'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
const { pieChartProps } = usePieChart({ chartData: eventsTimelineData, options: eventsTimelineOptions });
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="graph">
|
||||||
|
<PieChart v-bind="pieChartProps">
|
||||||
|
</PieChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
134
dashboard/components/admin/overview/ProjectCard.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||||
|
import { getPlanFromId } from '~/shared/data/PREMIUM';
|
||||||
|
|
||||||
|
|
||||||
|
import { AdminDialogProjectDetails } from '#components';
|
||||||
|
|
||||||
|
const { openDialogEx } = useCustomDialog();
|
||||||
|
|
||||||
|
function showProjectDetails(pid: string) {
|
||||||
|
openDialogEx(AdminDialogProjectDetails, {
|
||||||
|
params: { pid }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ project: TAdminProject }>();
|
||||||
|
|
||||||
|
|
||||||
|
const logBg = computed(() => {
|
||||||
|
|
||||||
|
const day = 1000 * 60 * 60 * 24;
|
||||||
|
const week = 1000 * 60 * 60 * 24 * 7;
|
||||||
|
|
||||||
|
const lastLoggedAtDate = new Date(props.project.last_log_at || 0);
|
||||||
|
|
||||||
|
if (lastLoggedAtDate.getTime() > Date.now() - day) {
|
||||||
|
return 'bg-green-500'
|
||||||
|
} else if (lastLoggedAtDate.getTime() > Date.now() - week) {
|
||||||
|
return 'bg-yellow-500'
|
||||||
|
} else {
|
||||||
|
return 'bg-red-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const dateDiffDays = computed(() => {
|
||||||
|
const res = (Date.now() - new Date(props.project.last_log_at || 0).getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
if (res > -1 && res < 1) return 0;
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageLabel = computed(() => {
|
||||||
|
return formatNumberK(props.project.limit_total) + ' / ' + formatNumberK(props.project.limit_max)
|
||||||
|
});
|
||||||
|
|
||||||
|
const usagePercentLabel = computed(() => {
|
||||||
|
const percent = 100 / props.project.limit_max * props.project.limit_total;
|
||||||
|
return `~ ${percent.toFixed(1)}%`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageAiLabel = computed(() => {
|
||||||
|
return formatNumberK(props.project.limit_ai_messages) + ' / ' + formatNumberK(props.project.limit_ai_max);
|
||||||
|
}
|
||||||
|
|
||||||
|
); const usageAiPercentLabel = computed(() => {
|
||||||
|
const percent = 100 / props.project.limit_ai_max * props.project.limit_ai_messages;
|
||||||
|
return `~ ${percent.toFixed(1)}%`
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative h-fit">
|
||||||
|
|
||||||
|
<div class="absolute top-1 left-2 text-[.8rem] text-lyx-text-dark flex items-center gap-2">
|
||||||
|
<div :class="logBg" class="h-3 w-3 rounded-full"> </div>
|
||||||
|
<div class="mt-1"> {{ dateDiffDays.toFixed(0) }} days </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4 justify-center text-[.9rem]">
|
||||||
|
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
|
||||||
|
<div class="font-medium text-lyx-text-dark">
|
||||||
|
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
|
||||||
|
</div>
|
||||||
|
</UTooltip>
|
||||||
|
<div class="text-lyx-text-darker">
|
||||||
|
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-5 justify-center">
|
||||||
|
<div @click="showProjectDetails(project._id.toString())" class="font-medium hover:text-lyx-primary cursor-pointer">
|
||||||
|
{{ project.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center mt-2">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="text-right"> Visits:</div>
|
||||||
|
<div>{{ formatNumberK(project.visits || 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="text-right"> Events:</div>
|
||||||
|
<div>{{ formatNumberK(project.events || 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="text-right"> Sessions:</div>
|
||||||
|
<div>{{ formatNumberK(project.sessions || 0) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LyxUiSeparator class="my-2" />
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<UProgress :value="project.limit_visits + project.limit_events" :max="project.limit_max"></UProgress>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-6 justify-around">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<div>
|
||||||
|
{{ usageLabel }}
|
||||||
|
</div>
|
||||||
|
<div class="text-lyx-text-dark">
|
||||||
|
{{ usagePercentLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div>
|
||||||
|
{{ usageAiLabel }}
|
||||||
|
</div>
|
||||||
|
<div class="text-lyx-text-dark">
|
||||||
|
{{ usageAiPercentLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
135
dashboard/components/admin/users/UserCard.vue
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||||
|
import type { TAdminUser } from '~/server/api/admin/users';
|
||||||
|
import { getPlanFromId } from '~/shared/data/PREMIUM';
|
||||||
|
|
||||||
|
import { AdminDialogProjectDetails } from '#components';
|
||||||
|
|
||||||
|
const { openDialogEx } = useCustomDialog();
|
||||||
|
|
||||||
|
function showProjectDetails(pid: string) {
|
||||||
|
openDialogEx(AdminDialogProjectDetails, {
|
||||||
|
params: { pid }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ user: TAdminUser }>();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative max-h-[15rem]">
|
||||||
|
<div class="flex gap-4 justify-center text-[.9rem]">
|
||||||
|
<div class="font-medium text-lyx-text-dark">
|
||||||
|
{{ user.name ?? user.given_name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-lyx-text-darker">
|
||||||
|
{{ new Date(user.created_at).toLocaleDateString('it-IT') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-5 justify-center">
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ user.email }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LyxUiSeparator class="my-2" />
|
||||||
|
|
||||||
|
<div class="flex flex-col text-[.9rem]">
|
||||||
|
<div class="flex gap-2" v-for="project of user.projects">
|
||||||
|
<div class="text-lyx-text-darker">
|
||||||
|
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||||
|
</div>
|
||||||
|
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
|
||||||
|
<div class="font-medium text-lyx-text-dark">
|
||||||
|
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
|
||||||
|
</div>
|
||||||
|
</UTooltip>
|
||||||
|
|
||||||
|
<div @click="showProjectDetails(project._id.toString())"
|
||||||
|
class="ml-1 hover:text-lyx-primary cursor-pointer">
|
||||||
|
{{ project.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative">
|
||||||
|
|
||||||
|
<div class="absolute top-1 left-2 text-[.8rem] text-lyx-text-dark flex items-center gap-2">
|
||||||
|
<div :class="logBg" class="h-3 w-3 rounded-full"> </div>
|
||||||
|
<div class="mt-1"> {{ dateDiffDays.toFixed(0) }} days </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4 justify-center text-[.9rem]">
|
||||||
|
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
|
||||||
|
<div class="font-medium text-lyx-text-dark">
|
||||||
|
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
|
||||||
|
</div>
|
||||||
|
</UTooltip>
|
||||||
|
<div class="text-lyx-text-darker">
|
||||||
|
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-5 justify-center">
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ project.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center mt-2">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="text-right"> Visits:</div>
|
||||||
|
<div>{{ formatNumberK(project.visits || 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="text-right"> Events:</div>
|
||||||
|
<div>{{ formatNumberK(project.events || 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="text-right"> Sessions:</div>
|
||||||
|
<div>{{ formatNumberK(project.sessions || 0) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LyxUiSeparator class="my-2" />
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<UProgress :value="project.limit_visits + project.limit_events" :max="project.limit_max"></UProgress>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-6 justify-around">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<div>
|
||||||
|
{{ usageLabel }}
|
||||||
|
</div>
|
||||||
|
<div class="text-lyx-text-dark">
|
||||||
|
{{ usagePercentLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div>
|
||||||
|
{{ usageAiLabel }}
|
||||||
|
</div>
|
||||||
|
<div class="text-lyx-text-dark">
|
||||||
|
{{ usageAiPercentLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
138
dashboard/components/analyst/ComposableChart.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js';
|
||||||
|
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||||
|
import * as datefns from 'date-fns';
|
||||||
|
|
||||||
|
registerChartComponents();
|
||||||
|
|
||||||
|
const errored = ref<boolean>(false);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
labels: string[],
|
||||||
|
title: string,
|
||||||
|
datasets: {
|
||||||
|
points: number[],
|
||||||
|
color: string,
|
||||||
|
chartType: string,
|
||||||
|
name: string
|
||||||
|
}[]
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartOptions = ref<ChartOptions<'line'>>({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: 'x',
|
||||||
|
includeInvisible: true
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
// borderDash: [5, 10]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true },
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: props.title
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
titleFont: { size: 16, weight: 'bold' },
|
||||||
|
bodyFont: { size: 14 },
|
||||||
|
padding: 10,
|
||||||
|
cornerRadius: 4,
|
||||||
|
boxPadding: 10,
|
||||||
|
caretPadding: 20,
|
||||||
|
yAlign: 'bottom',
|
||||||
|
xAlign: 'center',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = ref<ChartData<'line'>>({
|
||||||
|
labels: props.labels.map(e => {
|
||||||
|
try {
|
||||||
|
return datefns.format(new Date(e), 'dd/MM');
|
||||||
|
} catch (ex) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
datasets: props.datasets.map(e => ({
|
||||||
|
data: e.points,
|
||||||
|
label: e.name,
|
||||||
|
backgroundColor: [e.color + '77'],
|
||||||
|
borderColor: e.color,
|
||||||
|
borderWidth: 4,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.45,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 10,
|
||||||
|
hoverBackgroundColor: e.color,
|
||||||
|
hoverBorderColor: 'white',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
type: e.chartType
|
||||||
|
} as any))
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
|
||||||
|
|
||||||
|
function createGradient(startColor: string) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
const ctx = c.getContext("2d");
|
||||||
|
let gradient: any = `${startColor}22`;
|
||||||
|
if (ctx) {
|
||||||
|
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||||
|
gradient.addColorStop(0, `${startColor}99`);
|
||||||
|
gradient.addColorStop(0.35, `${startColor}66`);
|
||||||
|
gradient.addColorStop(1, `${startColor}22`);
|
||||||
|
} else {
|
||||||
|
console.warn('Cannot get context for gradient');
|
||||||
|
}
|
||||||
|
|
||||||
|
return gradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
chartData.value.datasets.forEach(dataset => {
|
||||||
|
if (dataset.borderColor && dataset.borderColor.toString().startsWith('#')) {
|
||||||
|
dataset.backgroundColor = [createGradient(dataset.borderColor as string)]
|
||||||
|
} else {
|
||||||
|
dataset.backgroundColor = [createGradient('#3d59a4')]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (ex) {
|
||||||
|
errored.value = true;
|
||||||
|
console.error(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="errored"> ERROR CREATING CHART </div>
|
||||||
|
<LineChart v-if="!errored" ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
110
dashboard/components/analyst/LineChart.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js';
|
||||||
|
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||||
|
registerChartComponents();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: any[],
|
||||||
|
labels: string[]
|
||||||
|
color: string,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartOptions = ref<ChartOptions<'line'>>({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: 'x',
|
||||||
|
includeInvisible: true
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
// borderDash: [5, 10]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
title: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
titleFont: { size: 16, weight: 'bold' },
|
||||||
|
bodyFont: { size: 14 },
|
||||||
|
padding: 10,
|
||||||
|
cornerRadius: 4,
|
||||||
|
boxPadding: 10,
|
||||||
|
caretPadding: 20,
|
||||||
|
yAlign: 'bottom',
|
||||||
|
xAlign: 'center',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const chartData = ref<ChartData<'line'>>({
|
||||||
|
labels: props.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: props.data,
|
||||||
|
backgroundColor: [props.color + '77'],
|
||||||
|
borderColor: props.color,
|
||||||
|
borderWidth: 4,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.45,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 10,
|
||||||
|
hoverBackgroundColor: props.color,
|
||||||
|
hoverBorderColor: 'white',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
const ctx = c.getContext("2d");
|
||||||
|
let gradient: any = `${props.color}22`;
|
||||||
|
if (ctx) {
|
||||||
|
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||||
|
gradient.addColorStop(0, `${props.color}99`);
|
||||||
|
gradient.addColorStop(0.35, `${props.color}66`);
|
||||||
|
gradient.addColorStop(1, `${props.color}22`);
|
||||||
|
} else {
|
||||||
|
console.warn('Cannot get context for gradient');
|
||||||
|
}
|
||||||
|
|
||||||
|
chartData.value.datasets[0].backgroundColor = [gradient];
|
||||||
|
|
||||||
|
watch(props, () => {
|
||||||
|
chartData.value.labels = props.labels;
|
||||||
|
chartData.value.datasets[0].data = props.data;
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||||
|
</template>
|
||||||
33
dashboard/components/banner/LimitsInfo.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
const limitsInfo = await useFetch("/api/project/limits_info", {
|
||||||
|
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showDrawer } = useDrawer();
|
||||||
|
|
||||||
|
function goToUpgrade() {
|
||||||
|
showDrawer('PRICING');
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="limitsInfo.data.value && limitsInfo.data.value.limited"
|
||||||
|
class="w-full bg-[#fbbf2422] p-4 rounded-lg text-[.9rem] flex items-center">
|
||||||
|
<div class="flex flex-col grow">
|
||||||
|
<div class="poppins font-semibold text-[#fbbf24]">
|
||||||
|
Limit reached
|
||||||
|
</div>
|
||||||
|
<div class="poppins text-[#fbbf24]">
|
||||||
|
Litlyx cannot receive new data as you reached your plan's limit. Resume all the great
|
||||||
|
features and collect even more data with a higher plan.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
39
dashboard/components/banner/Offer.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
const { showDrawer } = useDrawer();
|
||||||
|
|
||||||
|
function goToUpgrade() {
|
||||||
|
showDrawer('PRICING');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { project } = useProject()
|
||||||
|
|
||||||
|
const isPremium = computed(() => {
|
||||||
|
return project.value?.premium ?? false;
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="!isPremium" class="w-full bg-[#5680f822] p-4 rounded-lg text-[.9rem] flex items-center">
|
||||||
|
<div class="flex flex-col grow">
|
||||||
|
<div class="poppins font-semibold text-lyx-primary">
|
||||||
|
Launch offer: 25% off forever with code <span class="text-white font-bold text-[1rem]">LIT25</span> at
|
||||||
|
checkout
|
||||||
|
from Acceleration Plan and beyond.
|
||||||
|
</div>
|
||||||
|
<!-- <div class="poppins text-lyx-primary">
|
||||||
|
We're offering an exclusive 25% discount forever on all plans starting from the Acceleration
|
||||||
|
Plan for our first 100 users who believe in our project.
|
||||||
|
<br>
|
||||||
|
Redeem Code: <span class="text-white font-bold text-[1rem]">LIT25</span> at checkout to
|
||||||
|
claim your discount.
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
411
dashboard/components/dashboard/ActionableChart.vue
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import DateService, { type Slice } from '@services/DateService';
|
||||||
|
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
|
||||||
|
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||||
|
|
||||||
|
const errorData = ref<{ errored: boolean, text: string }>({ errored: false, text: '' })
|
||||||
|
|
||||||
|
|
||||||
|
function createGradient(startColor: string) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
const ctx = c.getContext("2d");
|
||||||
|
let gradient: any = `${startColor}22`;
|
||||||
|
if (ctx) {
|
||||||
|
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||||
|
gradient.addColorStop(0, `${startColor}99`);
|
||||||
|
gradient.addColorStop(0.35, `${startColor}66`);
|
||||||
|
gradient.addColorStop(1, `${startColor}22`);
|
||||||
|
} else {
|
||||||
|
console.warn('Cannot get context for gradient');
|
||||||
|
}
|
||||||
|
|
||||||
|
return gradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartOptions = ref<ChartOptions<'line'>>({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: 'x',
|
||||||
|
includeInvisible: true
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
// borderDash: [5, 10]
|
||||||
|
},
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: { display: true },
|
||||||
|
stacked: false,
|
||||||
|
offset: false,
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
title: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
// enabled: true,
|
||||||
|
// backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
// titleFont: { size: 16, weight: 'bold' },
|
||||||
|
// bodyFont: { size: 14 },
|
||||||
|
// padding: 10,
|
||||||
|
// cornerRadius: 4,
|
||||||
|
// boxPadding: 10,
|
||||||
|
// caretPadding: 20,
|
||||||
|
// yAlign: 'bottom',
|
||||||
|
// xAlign: 'center',
|
||||||
|
enabled: false,
|
||||||
|
position: 'nearest',
|
||||||
|
external: externalTooltipHandler
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Visits',
|
||||||
|
data: [],
|
||||||
|
backgroundColor: ['#5655d7'],
|
||||||
|
borderColor: '#5655d7',
|
||||||
|
borderWidth: 4,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.35,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 10,
|
||||||
|
hoverBackgroundColor: '#5655d7',
|
||||||
|
hoverBorderColor: 'white',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
segment: {
|
||||||
|
borderColor(ctx, options) {
|
||||||
|
const todayIndex = visitsData.data.value?.todayIndex;
|
||||||
|
if (!todayIndex || todayIndex == -1) return '#5655d7';
|
||||||
|
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||||
|
return '#5655d7'
|
||||||
|
},
|
||||||
|
borderDash(ctx, options) {
|
||||||
|
const todayIndex = visitsData.data.value?.todayIndex;
|
||||||
|
if (!todayIndex || todayIndex == -1) return undefined;
|
||||||
|
if (ctx.p1DataIndex == todayIndex - 1) return [3, 5];
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
backgroundColor(ctx, options) {
|
||||||
|
const todayIndex = visitsData.data.value?.todayIndex;
|
||||||
|
if (!todayIndex || todayIndex == -1) return createGradient('#5655d7');
|
||||||
|
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||||
|
return createGradient('#5655d7');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Unique visitors',
|
||||||
|
data: [],
|
||||||
|
backgroundColor: ['#4abde8'],
|
||||||
|
borderColor: '#4abde8',
|
||||||
|
borderWidth: 2,
|
||||||
|
hoverBackgroundColor: '#4abde8',
|
||||||
|
hoverBorderColor: '#4abde8',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
type: 'bar',
|
||||||
|
// barThickness: 20,
|
||||||
|
borderSkipped: ['bottom']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Events',
|
||||||
|
data: [],
|
||||||
|
backgroundColor: ['#fbbf24'],
|
||||||
|
borderWidth: 2,
|
||||||
|
hoverBackgroundColor: '#fbbf24',
|
||||||
|
hoverBorderColor: '#fbbf24',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
type: 'bubble',
|
||||||
|
stack: 'combined',
|
||||||
|
borderColor: ["#fbbf24"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
|
||||||
|
|
||||||
|
const externalTooltipElement = ref<null | HTMLDivElement>(null);
|
||||||
|
|
||||||
|
function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'line' | 'bar'> }) {
|
||||||
|
const { chart, tooltip } = context;
|
||||||
|
const tooltipEl = externalTooltipElement.value;
|
||||||
|
|
||||||
|
const currentIndex = tooltip.dataPoints[0].parsed.x;
|
||||||
|
|
||||||
|
const todayIndex = visitsData.data.value?.todayIndex;
|
||||||
|
if (todayIndex && todayIndex >= 0) {
|
||||||
|
if (currentIndex > todayIndex - 1) {
|
||||||
|
if (!tooltipEl) return;
|
||||||
|
return tooltipEl.style.opacity = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
currentTooltipData.value.visits = (tooltip.dataPoints.find(e => e.datasetIndex == 0)?.raw) as number;
|
||||||
|
currentTooltipData.value.sessions = (tooltip.dataPoints.find(e => e.datasetIndex == 1)?.raw) as number;
|
||||||
|
currentTooltipData.value.events = ((tooltip.dataPoints.find(e => e.datasetIndex == 2)?.raw) as any)?.r2 as number;
|
||||||
|
|
||||||
|
currentTooltipData.value.date = new Date(allDatesFull.value[tooltip.dataPoints[0].dataIndex]).toLocaleDateString();
|
||||||
|
|
||||||
|
if (!tooltipEl) return;
|
||||||
|
if (tooltip.opacity === 0) {
|
||||||
|
tooltipEl.style.opacity = '0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { left: positionX, top: positionY } = chart.canvas.getBoundingClientRect();
|
||||||
|
|
||||||
|
|
||||||
|
const xSwap = tooltip.caretX > (window.innerWidth * 0.5) ? -450 : -100;
|
||||||
|
|
||||||
|
tooltipEl.style.opacity = '1';
|
||||||
|
|
||||||
|
tooltipEl.style.left = positionX + (tooltip.caretX + xSwap) + 'px';
|
||||||
|
|
||||||
|
tooltipEl.style.top = positionY + tooltip.caretY + 'px';
|
||||||
|
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px';
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const { snapshotDuration } = useSnapshot();
|
||||||
|
|
||||||
|
const selectLabels: { label: string, value: Slice }[] = [
|
||||||
|
{ label: 'Hour', value: 'hour' },
|
||||||
|
{ label: 'Day', value: 'day' },
|
||||||
|
{ label: 'Month', value: 'month' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectLabelsAvailable = computed<{ label: string, value: Slice, disabled: boolean }[]>(() => {
|
||||||
|
return selectLabels.map(e => {
|
||||||
|
return { ...e, disabled: !DateService.canUseSliceFromDays(snapshotDuration.value, e.value)[0] }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedSlice = computed<Slice>(() => {
|
||||||
|
console.log({ available: selectLabelsAvailable.value })
|
||||||
|
const targetValue = selectLabelsAvailable.value[selectedLabelIndex.value];
|
||||||
|
if (!targetValue) return 'day';
|
||||||
|
if (targetValue.disabled) {
|
||||||
|
selectedLabelIndex.value = selectLabelsAvailable.value.findIndex(e => !e.disabled);
|
||||||
|
}
|
||||||
|
if (selectedLabelIndex.value === -1) {
|
||||||
|
console.error('ERROR CANNOT FIND CORRECT SLICE')
|
||||||
|
return 'day';
|
||||||
|
}
|
||||||
|
return selectLabelsAvailable.value[selectedLabelIndex.value].value
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedLabelIndex = ref<number>(1);
|
||||||
|
const allDatesFull = ref<string[]>([]);
|
||||||
|
|
||||||
|
|
||||||
|
function transformResponse(input: { _id: string, count: number }[]) {
|
||||||
|
const data = input.map(e => e.count);
|
||||||
|
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice.value));
|
||||||
|
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
|
||||||
|
|
||||||
|
const current = (Date.now());
|
||||||
|
//console.log(input.map(e => e._id));
|
||||||
|
//console.log(new Date(current));
|
||||||
|
|
||||||
|
const todayIndex = input.findIndex(e => new Date(e._id).getTime() >= current);
|
||||||
|
|
||||||
|
//console.log({ todayIndex })
|
||||||
|
return { data, labels, todayIndex: todayIndex + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResponseError(e: any) {
|
||||||
|
let message = e.response._data.message ?? 'Generic error';
|
||||||
|
if (message == 'internal server error') message = 'Please change slice';
|
||||||
|
errorData.value = { errored: true, text: message }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResponse(e: any) {
|
||||||
|
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const visitsData = useFetch('/api/timeline/visits', {
|
||||||
|
headers: useComputedHeaders({ slice: selectedSlice }), lazy: true,
|
||||||
|
transform: transformResponse, onResponseError, onResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionsData = useFetch('/api/timeline/sessions', {
|
||||||
|
headers: useComputedHeaders({ slice: selectedSlice }), lazy: true,
|
||||||
|
transform: transformResponse, onResponseError, onResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventsData = useFetch('/api/timeline/events', {
|
||||||
|
headers: useComputedHeaders({ slice: selectedSlice }), lazy: true,
|
||||||
|
transform: transformResponse, onResponseError, onResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
const readyToDisplay = computed(() => !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value);
|
||||||
|
|
||||||
|
watch(readyToDisplay, () => {
|
||||||
|
if (readyToDisplay.value === true) onDataReady();
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
function onDataReady() {
|
||||||
|
if (!visitsData.data.value) return;
|
||||||
|
if (!eventsData.data.value) return;
|
||||||
|
if (!sessionsData.data.value) return;
|
||||||
|
|
||||||
|
chartData.value.labels = visitsData.data.value.labels;
|
||||||
|
|
||||||
|
const maxChartY = Math.max(...visitsData.data.value.data, ...sessionsData.data.value.data);
|
||||||
|
const maxEventSize = Math.max(...eventsData.data.value.data)
|
||||||
|
|
||||||
|
chartData.value.datasets[0].data = visitsData.data.value.data;
|
||||||
|
chartData.value.datasets[1].data = sessionsData.data.value.data;
|
||||||
|
|
||||||
|
chartData.value.datasets[2].data = eventsData.data.value.data.map(e => {
|
||||||
|
const rValue = 20 / maxEventSize * e;
|
||||||
|
return { x: 0, y: maxChartY + 20, r: isNaN(rValue) ? 0 : rValue, r2: e }
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
chartData.value.datasets[0].backgroundColor = [createGradient('#5655d7')];
|
||||||
|
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
|
||||||
|
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
|
||||||
|
|
||||||
|
(chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => {
|
||||||
|
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||||
|
if (i == todayIndex - 1) return true;
|
||||||
|
return 'bottom';
|
||||||
|
});
|
||||||
|
|
||||||
|
chartData.value.datasets[2].borderColor = eventsData.data.value.data.map((e, i) => {
|
||||||
|
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||||
|
if (i == todayIndex - 1) return '#fbbf2400';
|
||||||
|
return '#fbbf24';
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
updateChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTooltipData = ref<{ visits: number, events: number, sessions: number, date: string }>({
|
||||||
|
visits: 0,
|
||||||
|
events: 0,
|
||||||
|
sessions: 0,
|
||||||
|
date: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const tooltipNameIndex = ['visits', 'sessions', 'events'];
|
||||||
|
|
||||||
|
function onLegendChange(dataset: any, index: number, checked: any) {
|
||||||
|
const newValue = !checked;
|
||||||
|
dataset.hidden = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legendColors = ref<string[]>(['#5655d7', '#4abde8', '#fbbf24'])
|
||||||
|
const legendClasses = ref<string[]>([
|
||||||
|
'actionable-visits-color-checkbox',
|
||||||
|
'actionable-sessions-color-checkbox',
|
||||||
|
'actionable-events-color-checkbox'
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CardTitled title="Trend chart" sub="Easily match Visits, Unique sessions and Events trends." class="w-full">
|
||||||
|
<template #header>
|
||||||
|
<SelectButton class="w-fit" @changeIndex="selectedLabelIndex = $event" :currentIndex="selectedLabelIndex"
|
||||||
|
:options="selectLabelsAvailable">
|
||||||
|
</SelectButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex gap-6 w-full justify-between lg:flex-row flex-col">
|
||||||
|
<LyxUiButton type="secondary" :to="isLiveDemo ? '#' : '/analyst'" :disabled="isLiveDemo">
|
||||||
|
<div class="flex items-center gap-2 px-10">
|
||||||
|
<i class="far fa-sparkles text-yellow-600 dark:text-yellow-400"></i>
|
||||||
|
<div class="poppins text-lyx-lightmode-text dark:text-lyx-text"> Ask AI </div>
|
||||||
|
</div>
|
||||||
|
</LyxUiButton>
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<div v-for="(dataset, index) of chartData.datasets" class="flex gap-2 items-center text-[.9rem]">
|
||||||
|
|
||||||
|
<UCheckbox :ui="{
|
||||||
|
color: legendClasses[index]
|
||||||
|
}" :model-value="true" @change="onLegendChange(dataset, index, $event)"></UCheckbox>
|
||||||
|
|
||||||
|
<label class="mt-[2px]"> {{ dataset.label }} </label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div id='external-tooltip' ref="externalTooltipElement" class="z-[400]">
|
||||||
|
<LyxUiCard class="text-lyx-lightmode-text dark:text-lyx-text">
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div> Date: </div>
|
||||||
|
<div v-if="currentTooltipData"> {{ currentTooltipData.date }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="(dataset, index) of chartData.datasets" class="flex gap-2 items-center">
|
||||||
|
<div :style="`background-color: ${legendColors[index]}`" class="h-4 w-4 rounded-full">
|
||||||
|
</div>
|
||||||
|
<div> {{ dataset.label }}</div>
|
||||||
|
<div v-if="currentTooltipData" class="grow text-right px-4">
|
||||||
|
{{ (currentTooltipData as any)[tooltipNameIndex[index]] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 font-normal flex flex-col text-[.9rem] dark:text-lyx-text-dark text-lyx-lightmode-text-dark"
|
||||||
|
v-if="(currentTooltipData as any).sessions > (currentTooltipData as any).visits">
|
||||||
|
<div> Unique visitors are higher than total visits </div>
|
||||||
|
<div> which often means bots (automated scripts or crawlers)</div>
|
||||||
|
<div> are inflating the numbers.</div>
|
||||||
|
</div>
|
||||||
|
<!-- <div class="bg-lyx-background-lighter h-[2px] w-full my-2"> </div> -->
|
||||||
|
</LyxUiCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="!readyToDisplay" class="flex justify-center py-40">
|
||||||
|
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end" v-if="readyToDisplay && !errorData.errored">
|
||||||
|
<LineChart ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorData.errored" class="flex items-center justify-center py-8">
|
||||||
|
{{ errorData.text }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</CardTitled>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
#external-tooltip {
|
||||||
|
border-radius: 3px;
|
||||||
|
color: white;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
transition: all .1s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
|
|
||||||
export type IconProvider = (id: string) => ['img' | 'icon', string] | undefined;
|
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
data: { _id: string, count: number }[],
|
|
||||||
iconProvider?: IconProvider,
|
|
||||||
elementTextTransformer?: (text: string) => string,
|
|
||||||
label: string,
|
|
||||||
subLabel: string,
|
|
||||||
desc: string,
|
|
||||||
loading?: boolean,
|
|
||||||
interactive?: boolean,
|
|
||||||
isDetailView?: boolean,
|
|
||||||
rawButton?: boolean,
|
|
||||||
hideShowMore?: boolean,
|
|
||||||
customIconStyle?: string,
|
|
||||||
showLink?: boolean
|
|
||||||
}
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
const emits = defineEmits<{
|
|
||||||
(e: 'dataReload'): void,
|
|
||||||
(e: 'showDetails', id: string): void,
|
|
||||||
(e: 'showRawData'): void,
|
|
||||||
(e: 'showGeneral'): void,
|
|
||||||
(e: 'showMore'): void,
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const maxData = computed(() => {
|
|
||||||
const counts = props.data.map(e => e.count);
|
|
||||||
return Math.max(...counts);
|
|
||||||
});
|
|
||||||
|
|
||||||
function reloadData() {
|
|
||||||
emits('dataReload');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showDetails(id: string) {
|
|
||||||
emits('showDetails', id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openExternalLink(link: string) {
|
|
||||||
if (link === 'self') return;
|
|
||||||
return window.open('https://' + link, '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
|
|
||||||
<div class="flex h-full">
|
|
||||||
|
|
||||||
<div class="text-text flex flex-col items-start gap-4 w-full relative">
|
|
||||||
|
|
||||||
<div class="w-full h-full p-4 flex flex-col bg-card rounded-xl gap-8 card-shadow">
|
|
||||||
|
|
||||||
<div class="flex justify-between mb-3">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<div class="flex gap-4 items-center">
|
|
||||||
<div class="poppins font-semibold text-[1.4rem] text-text">
|
|
||||||
{{ label }}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<i @click="reloadData()"
|
|
||||||
class="hover:rotate-[50deg] transition-all duration-100 fas fa-refresh text-[1.2rem] cursor-pointer"></i>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="poppins text-[1rem] text-text-sub/90">
|
|
||||||
{{ desc }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="rawButton" class="hidden lg:flex">
|
|
||||||
<div @click="$emit('showRawData')"
|
|
||||||
class="cursor-pointer flex gap-1 items-center justify-center font-semibold poppins rounded-lg text-[#5680f8] hover:text-[#5681f8ce]">
|
|
||||||
<div> Raw data </div>
|
|
||||||
<div class="flex items-center"> <i class="fas fa-arrow-up-right"></i> </div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div v-if="isDetailView" class="flex items-center justify-center">
|
|
||||||
<i @click="$emit('showGeneral')"
|
|
||||||
class="fas fa-arrow-left text-[.9rem] hover:text-text cursor-pointer"></i>
|
|
||||||
</div>
|
|
||||||
<div> {{ subLabel }} </div>
|
|
||||||
</div>
|
|
||||||
<div> Count </div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
|
|
||||||
<div v-if="props.data.length > 0" class="flex justify-between items-center"
|
|
||||||
v-for="element of props.data">
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 w-10/12 relative">
|
|
||||||
|
|
||||||
<div v-if="showLink">
|
|
||||||
<i @click="openExternalLink(element._id)"
|
|
||||||
class="fas fa-link text-gray-300 hover:text-gray-400 cursor-pointer"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-1 items-center" @click="showDetails(element._id)"
|
|
||||||
:class="{ 'cursor-pointer line-active': interactive }">
|
|
||||||
|
|
||||||
<div class="absolute rounded-sm w-full h-full bg-[#92abcf38]"
|
|
||||||
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
|
|
||||||
|
|
||||||
<div class="flex px-2 py-1 relative items-center gap-4">
|
|
||||||
<div v-if="iconProvider && iconProvider(element._id) != undefined"
|
|
||||||
class="flex items-center h-[1.3rem]">
|
|
||||||
|
|
||||||
<img v-if="iconProvider(element._id)?.[0] == 'img'" class="h-full"
|
|
||||||
:style="customIconStyle" :src="iconProvider(element._id)?.[1]">
|
|
||||||
|
|
||||||
<i v-else :class="iconProvider(element._id)?.[1]"></i>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
|
|
||||||
{{ elementTextTransformer?.(element._id) || element._id }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{
|
|
||||||
formatNumberK(element.count) }} </div>
|
|
||||||
</div>
|
|
||||||
<div v-if="props.data.length == 0"
|
|
||||||
class="flex justify-center text-text-sub font-bold text-[1.1rem]">
|
|
||||||
No visits yet
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 ">
|
|
||||||
<div @click="$emit('showMore')"
|
|
||||||
class="poppins hover:bg-black cursor-pointer w-fit px-6 py-1 rounded-lg border-[1px] border-text-sub text-[.9rem]">
|
|
||||||
Show more
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading"
|
|
||||||
class="backdrop-blur-[1px] z-[20] left-0 top-0 w-full h-full flex items-center justify-center font-bold rockmann absolute">
|
|
||||||
<i
|
|
||||||
class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</template>
|
|
||||||
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.line-active:hover {
|
|
||||||
.absolute {
|
|
||||||
@apply bg-accent/20
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui-font {
|
|
||||||
font-feature-settings: normal;
|
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
|
|
||||||
font-variation-settings: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.5;
|
|
||||||
-moz-tab-size: 4;
|
|
||||||
-o-tab-size: 4;
|
|
||||||
tab-size: 4
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
import type { BrowsersAggregated } from '~/server/api/metrics/[project_id]/data/browsers';
|
|
||||||
|
|
||||||
const activeProject = await useActiveProject();
|
|
||||||
const { data: events, pending, refresh } = await useFetch<BrowsersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`, {
|
|
||||||
...signHeaders(),
|
|
||||||
lazy: true
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
|
||||||
|
|
||||||
function showMore() {
|
|
||||||
|
|
||||||
|
|
||||||
showDialog.value = true;
|
|
||||||
dialogBarData.value = [];
|
|
||||||
isDataLoading.value = true;
|
|
||||||
|
|
||||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`, signHeaders({
|
|
||||||
'x-query-limit': '200'
|
|
||||||
})).then(data => {
|
|
||||||
dialogBarData.value = data;
|
|
||||||
isDataLoading.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []"
|
|
||||||
desc="The browsers most used to search your website." :dataIcons="false" :loading="pending"
|
|
||||||
label="Top Browsers" sub-label="Browsers"></DashboardBarsCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -5,67 +5,56 @@ const props = defineProps<{
|
|||||||
value: string,
|
value: string,
|
||||||
text: string,
|
text: string,
|
||||||
avg?: string,
|
avg?: string,
|
||||||
trend?: number,
|
|
||||||
color: string,
|
color: string,
|
||||||
data?: number[],
|
data?: number[],
|
||||||
labels?: string[],
|
labels?: string[],
|
||||||
ready?: boolean
|
ready?: boolean,
|
||||||
|
slow?: boolean,
|
||||||
|
todayIndex: number,
|
||||||
|
tooltipText: string
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { snapshotDuration } = useSnapshot()
|
||||||
|
|
||||||
|
const { showDrawer } = useDrawer();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<Card class="flex flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
|
<LyxUiCard class="flex !p-0 flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
|
||||||
<div class="flex p-4 items-start">
|
<div v-if="ready" class="flex p-4 items-start">
|
||||||
<div class="flex items-center mt-2 mr-4">
|
<div class="flex items-center mt-2 mr-4">
|
||||||
<i :style="`color: ${props.color}`" :class="icon" class="text-[1.6rem] 2xl:text-[2rem]"></i>
|
<i :style="`color: ${props.color}`" :class="icon" class="text-[1.3rem] 2xl:text-[1.5rem]"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col grow">
|
<div class="flex flex-col grow">
|
||||||
<div class="flex items-end gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="brockmann text-text-dirty text-[1.6rem] 2xl:text-[1.9rem]"> {{ value }} </div>
|
<div class="brockmann text-lyx-lightmode-text-dark dark:text-text-dirty text-[1.2rem] 2xl:text-[1.4rem]">
|
||||||
<div class="poppins text-text-sub text-[.7rem] 2xl:text-[.85rem] mb-2"> {{ avg }} </div>
|
{{ value }}
|
||||||
</div>
|
|
||||||
<div class="poppins text-text-sub text-[.9rem] 2xl:text-base"> {{ text }} </div>
|
|
||||||
</div>
|
|
||||||
<div v-if="trend" class="flex flex-col items-center gap-1">
|
|
||||||
<div class="flex items-center gap-3 rounded-xl px-2 py-1" :style="`background-color: ${props.color}33`">
|
|
||||||
<i :class="trend > 0 ? 'fa-arrow-trend-up' : 'fa-arrow-trend-down'"
|
|
||||||
class="far text-[.9rem] 2xl:text-[1rem]" :style="`color: ${props.color}`"></i>
|
|
||||||
<div :style="`color: ${props.color}`" class="font-semibold text-[.75rem] 2xl:text-[.875rem]">
|
|
||||||
{{ trend.toFixed(0) }} %
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="poppins text-lyx-lightmode-darker dark:text-text-sub text-[.65rem] 2xl:text-[.8rem]"> {{ avg }} </div>
|
||||||
</div>
|
</div>
|
||||||
<div class="poppins text-text-sub text-[.7rem]"> Daily variation </div>
|
<div class="poppins text-lyx-lightmode-darker dark:text-text-sub text-[.9rem] 2xl:text-[1rem]"> {{ text }} </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
<UTooltip :text="props.tooltipText">
|
||||||
|
<i class="far fa-info-circle text-lyx-text-darker text-[1rem]"></i>
|
||||||
|
</UTooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end" v-if="(props.data?.length || 0) > 0">
|
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end"
|
||||||
<DashboardEmbedChartCard v-if="ready" :data="props.data || []" :labels="props.labels || []"
|
v-if="((props.data?.length || 0) > 0) && ready">
|
||||||
:color="props.color">
|
<DashboardEmbedChartCard v-if="ready" :todayIndex="todayIndex" :data="props.data || []"
|
||||||
|
:labels="props.labels || []" :color="props.color">
|
||||||
</DashboardEmbedChartCard>
|
</DashboardEmbedChartCard>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<div v-if="!ready" class="flex justify-center items-center w-full h-full flex-col gap-2">
|
||||||
|
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||||
<!-- <div class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full lg:w-[20rem] relative pb-2 lg:pb-4">
|
<!-- <div v-if="props.slow"> Can be very slow on large timeframes </div> -->
|
||||||
|
|
||||||
<div class="gap-4 flex flex-row items-center lg:items-start lg:gap-2 lg:flex-col">
|
|
||||||
<div class="w-[2.5rem] h-[2.5rem] lg:w-[3.5rem] lg:h-[3.5rem] flex items-center justify-center rounded-lg"
|
|
||||||
:style="`background: ${props.color}`">
|
|
||||||
<i :class="icon" class="text-[1rem] lg:text-[1.5rem]"></i>
|
|
||||||
</div>
|
|
||||||
<div class="text-[1rem] lg:text-[1.3rem] text-text-sub/90 poppins">
|
|
||||||
{{ title }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 items-center lg:items-end">
|
|
||||||
<div class="brockmann text-text text-[2rem] lg:text-[2.8rem] grow">
|
|
||||||
{{ text }}
|
|
||||||
</div>
|
|
||||||
<div class="poppins text-text-sub/90 text-[.9rem] lg:text-[1rem]"> {{ sub }} </div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</LyxUiCard>
|
||||||
|
|
||||||
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
icon: string,
|
|
||||||
title: string,
|
|
||||||
text: string,
|
|
||||||
sub: string,
|
|
||||||
color: string
|
|
||||||
}>();
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
|
|
||||||
<div class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full lg:w-[20rem] relative pb-2 lg:pb-4">
|
|
||||||
|
|
||||||
<!-- <div class="absolute flex items-center justify-center right-4 top-4 cursor-pointer hover:text-blue-400">
|
|
||||||
<i class="fal fa-info-circle text-[.9rem] lg:text-[1.4rem]"></i>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<div class="gap-4 flex flex-row items-center lg:items-start lg:gap-2 lg:flex-col">
|
|
||||||
<div class="w-[2.5rem] h-[2.5rem] lg:w-[3.5rem] lg:h-[3.5rem] flex items-center justify-center rounded-lg"
|
|
||||||
:style="`background: ${props.color}`">
|
|
||||||
<i :class="icon" class="text-[1rem] lg:text-[1.5rem]"></i>
|
|
||||||
</div>
|
|
||||||
<div class="text-[1rem] lg:text-[1.3rem] text-text-sub/90 poppins">
|
|
||||||
{{ title }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 items-center lg:items-end">
|
|
||||||
<div class="brockmann text-text text-[2rem] lg:text-[2.8rem] grow">
|
|
||||||
{{ text }}
|
|
||||||
</div>
|
|
||||||
<div class="poppins text-text-sub/90 text-[.9rem] lg:text-[1rem]"> {{ sub }} </div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</template>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
import type { DevicesAggregated } from '~/server/api/metrics/[project_id]/data/devices';
|
|
||||||
|
|
||||||
const activeProject = await useActiveProject();
|
|
||||||
const { data: events, pending, refresh } = await useFetch<DevicesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/devices`, {
|
|
||||||
...signHeaders(),
|
|
||||||
lazy: true
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
|
||||||
|
|
||||||
function showMore() {
|
|
||||||
|
|
||||||
|
|
||||||
showDialog.value = true;
|
|
||||||
dialogBarData.value = [];
|
|
||||||
isDataLoading.value = true;
|
|
||||||
|
|
||||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/devices`, signHeaders({
|
|
||||||
'x-query-limit': '200'
|
|
||||||
})).then(data => {
|
|
||||||
dialogBarData.value = data;
|
|
||||||
isDataLoading.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []" :dataIcons="false"
|
|
||||||
desc="The devices most used to access your website." :loading="pending" label="Top Devices"
|
|
||||||
sub-label="Devices"></DashboardBarsCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -13,8 +13,8 @@ const columns = [
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div class="w-full h-full bg-bg rounded-xl p-8">
|
<div class="w-full h-full bg-lyx-lightmode-background dark:bg-lyx-background-light rounded-xl p-8">
|
||||||
<div class="full h-full overflow-y-auto">
|
<div class="full h-full overflow-y-auto text-lyx-lightmode-text dark:text-lyx-text">
|
||||||
<UTable :columns="columns" :rows="dialogBarData" :loading="isDataLoading" v-if="dialogBarData">
|
<UTable :columns="columns" :rows="dialogBarData" :loading="isDataLoading" v-if="dialogBarData">
|
||||||
<template #count-data="{ row }">
|
<template #count-data="{ row }">
|
||||||
<div class="font-bold"> {{ formatNumberK(row.count) }} </div>
|
<div class="font-bold"> {{ formatNumberK(row.count) }} </div>
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ const props = defineProps<{
|
|||||||
data: any[],
|
data: any[],
|
||||||
labels: string[]
|
labels: string[]
|
||||||
color: string,
|
color: string,
|
||||||
|
todayIndex: number
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|
||||||
const chartOptions = ref<ChartOptions<'line'>>({
|
const chartOptions = ref<ChartOptions<'line'>>({
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@@ -48,10 +50,22 @@ const chartData = ref<ChartData<'line'>>({
|
|||||||
data: props.data,
|
data: props.data,
|
||||||
backgroundColor: [props.color + '77'],
|
backgroundColor: [props.color + '77'],
|
||||||
borderColor: props.color,
|
borderColor: props.color,
|
||||||
borderWidth: 4,
|
borderWidth: 2,
|
||||||
fill: true,
|
fill: false,
|
||||||
tension: 0.45,
|
tension: 0.35,
|
||||||
pointRadius: 0
|
pointRadius: 0,
|
||||||
|
segment: {
|
||||||
|
borderColor(ctx, options) {
|
||||||
|
if (!props.todayIndex || props.todayIndex == -1) return props.color;
|
||||||
|
if (ctx.p1DataIndex >= props.todayIndex) return props.color + '00';
|
||||||
|
return props.color;
|
||||||
|
},
|
||||||
|
borderDash(ctx, options) {
|
||||||
|
if (!props.todayIndex || props.todayIndex == -1) return undefined;
|
||||||
|
if (ctx.p1DataIndex == props.todayIndex -1) return [2, 4];
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
import type { CustomEventsAggregated } from '~/server/api/metrics/[project_id]/visits/events';
|
|
||||||
|
|
||||||
const activeProject = await useActiveProject();
|
|
||||||
const { data: events, pending, refresh } = await useFetch<CustomEventsAggregated[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, {
|
|
||||||
...signHeaders(),
|
|
||||||
lazy: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
function goToView() {
|
|
||||||
router.push('/dashboard/events');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
|
||||||
|
|
||||||
function showMore() {
|
|
||||||
|
|
||||||
|
|
||||||
showDialog.value = true;
|
|
||||||
dialogBarData.value = [];
|
|
||||||
isDataLoading.value = true;
|
|
||||||
|
|
||||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, signHeaders({
|
|
||||||
'x-query-limit': '200'
|
|
||||||
})).then(data => {
|
|
||||||
dialogBarData.value = data;
|
|
||||||
isDataLoading.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2 h-full">
|
|
||||||
<DashboardBarsCard @showMore="showMore()" @showRawData="goToView()"
|
|
||||||
desc="Most frequent user events triggered in this project" @dataReload="refresh" :data="events || []"
|
|
||||||
:loading="pending" label="Top Events" sub-label="Events" :rawButton="!isLiveDemo()"></DashboardBarsCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Chart, registerables, type ChartData, type ChartOptions } from 'chart.js';
|
import { Chart, registerables, type ChartData, type ChartOptions } from 'chart.js';
|
||||||
import { DoughnutChart, useDoughnutChart } from 'vue-chart-3';
|
import { DoughnutChart, useDoughnutChart } from 'vue-chart-3';
|
||||||
import type { EventsPie } from '~/server/api/metrics/[project_id]/events_pie';
|
import type { CustomEventsAggregated } from '~/server/api/metrics/[project_id]/data/events';
|
||||||
|
|
||||||
definePageMeta({ layout: 'dashboard' });
|
definePageMeta({ layout: 'dashboard' });
|
||||||
|
|
||||||
@@ -20,15 +20,6 @@ const chartOptions = ref<ChartOptions<'doughnut'>>({
|
|||||||
ticks: { display: false },
|
ticks: { display: false },
|
||||||
grid: { display: false, drawBorder: false },
|
grid: { display: false, drawBorder: false },
|
||||||
},
|
},
|
||||||
// r: {
|
|
||||||
// ticks: { display: false },
|
|
||||||
// grid: {
|
|
||||||
// display: true,
|
|
||||||
// drawBorder: false,
|
|
||||||
// color: '#CCCCCC22',
|
|
||||||
// borderDash: [20, 8]
|
|
||||||
// },
|
|
||||||
// }
|
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
@@ -36,7 +27,6 @@ const chartOptions = ref<ChartOptions<'doughnut'>>({
|
|||||||
position: 'top',
|
position: 'top',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
labels: {
|
labels: {
|
||||||
color: 'white',
|
|
||||||
font: {
|
font: {
|
||||||
family: 'Poppins',
|
family: 'Poppins',
|
||||||
size: 16
|
size: 16
|
||||||
@@ -55,7 +45,28 @@ const chartData = ref<ChartData<'doughnut'>>({
|
|||||||
{
|
{
|
||||||
rotation: 1,
|
rotation: 1,
|
||||||
data: [],
|
data: [],
|
||||||
backgroundColor: ['#6bbbe3','#5655d0', '#a6d5cb', '#fae0b9'],
|
backgroundColor: [
|
||||||
|
"#5655d0",
|
||||||
|
"#6bbbe3",
|
||||||
|
"#a6d5cb",
|
||||||
|
"#fae0b9",
|
||||||
|
"#f28e8e",
|
||||||
|
"#e3a7e4",
|
||||||
|
"#c4a8e1",
|
||||||
|
"#8cc1d8",
|
||||||
|
"#f9c2cd",
|
||||||
|
"#b4e3b2",
|
||||||
|
"#ffdfba",
|
||||||
|
"#e9c3b5",
|
||||||
|
"#d5b8d6",
|
||||||
|
"#add7f6",
|
||||||
|
"#ffd1dc",
|
||||||
|
"#ffe7a1",
|
||||||
|
"#a8e6cf",
|
||||||
|
"#d4a5a5",
|
||||||
|
"#f3d6e4",
|
||||||
|
"#c3aed6"
|
||||||
|
],
|
||||||
borderColor: ['#1d1d1f'],
|
borderColor: ['#1d1d1f'],
|
||||||
borderWidth: 2
|
borderWidth: 2
|
||||||
},
|
},
|
||||||
@@ -65,15 +76,18 @@ const chartData = ref<ChartData<'doughnut'>>({
|
|||||||
|
|
||||||
const { doughnutChartProps, doughnutChartRef } = useDoughnutChart({ chartData: chartData, options: chartOptions });
|
const { doughnutChartProps, doughnutChartRef } = useDoughnutChart({ chartData: chartData, options: chartOptions });
|
||||||
|
|
||||||
onMounted(async () => {
|
const { projectId } = useProject();
|
||||||
|
|
||||||
const activeProject = useActiveProject()
|
const { safeSnapshotDates } = useSnapshot();
|
||||||
|
|
||||||
const eventsData = await $fetch<EventsPie[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, signHeaders());
|
|
||||||
chartData.value.labels = eventsData.map(e => {
|
|
||||||
|
function transformResponse(input: CustomEventsAggregated[]) {
|
||||||
|
|
||||||
|
chartData.value.labels = input.map(e => {
|
||||||
return `${e._id}`;
|
return `${e._id}`;
|
||||||
});
|
});
|
||||||
chartData.value.datasets[0].data = eventsData.map(e => e.count);
|
chartData.value.datasets[0].data = input.map(e => e.count);
|
||||||
doughnutChartRef.value?.update();
|
doughnutChartRef.value?.update();
|
||||||
|
|
||||||
if (window.innerWidth < 800) {
|
if (window.innerWidth < 800) {
|
||||||
@@ -81,11 +95,25 @@ onMounted(async () => {
|
|||||||
chartOptions.value.plugins.legend.display = false;
|
chartOptions.value.plugins.legend.display = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
const eventsData = useFetch(`/api/data/events`, {
|
||||||
|
headers: useComputedHeaders({ limit: 6 }), lazy: true, immediate: false, transform: transformResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
eventsData.execute();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DoughnutChart v-bind="doughnutChartProps"> </DoughnutChart>
|
<div>
|
||||||
|
<div v-if="eventsData.pending.value" class="flex justify-center py-40">
|
||||||
|
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||||
|
</div>
|
||||||
|
<DoughnutChart v-if="!eventsData.pending.value" v-bind="doughnutChartProps"> </DoughnutChart>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
import type { CountriesAggregated } from '~/server/api/metrics/[project_id]/data/countries';
|
|
||||||
import type { IconProvider } from './BarsCard.vue';
|
|
||||||
|
|
||||||
const activeProject = await useActiveProject();
|
|
||||||
const { data: countries, pending, refresh } = await useFetch<CountriesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/countries`, {
|
|
||||||
...signHeaders(),
|
|
||||||
lazy: true
|
|
||||||
});
|
|
||||||
|
|
||||||
function iconProvider(id: string): ReturnType<IconProvider> {
|
|
||||||
if (id === 'self') return ['icon', 'fas fa-link'];
|
|
||||||
return [
|
|
||||||
'img',
|
|
||||||
`https://raw.githubusercontent.com/hampusborgos/country-flags/main/png250px/${id.toLowerCase()}.png`
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const customIconStyle = `width: 2rem; padding: 1px;`
|
|
||||||
|
|
||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
|
||||||
|
|
||||||
function showMore() {
|
|
||||||
|
|
||||||
|
|
||||||
showDialog.value = true;
|
|
||||||
dialogBarData.value = [];
|
|
||||||
isDataLoading.value = true;
|
|
||||||
|
|
||||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/countries`, signHeaders({
|
|
||||||
'x-query-limit': '200'
|
|
||||||
})).then(data => {
|
|
||||||
dialogBarData.value = data;
|
|
||||||
isDataLoading.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="countries || []" :dataIcons="false"
|
|
||||||
:loading="pending" label="Top Countries" sub-label="Countries" :iconProvider="iconProvider"
|
|
||||||
:customIconStyle="customIconStyle" desc=" Lists the countries where users access your website.">
|
|
||||||
</DashboardBarsCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
import type { OssAggregated } from '~/server/api/metrics/[project_id]/data/oss';
|
|
||||||
|
|
||||||
const activeProject = await useActiveProject();
|
|
||||||
const { data: events, pending, refresh } = await useFetch<OssAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/oss`, {
|
|
||||||
...signHeaders(),
|
|
||||||
lazy: true
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
|
||||||
|
|
||||||
function showMore() {
|
|
||||||
|
|
||||||
|
|
||||||
showDialog.value = true;
|
|
||||||
dialogBarData.value = [];
|
|
||||||
isDataLoading.value = true;
|
|
||||||
|
|
||||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/oss`, signHeaders({
|
|
||||||
'x-query-limit': '200'
|
|
||||||
})).then(data => {
|
|
||||||
dialogBarData.value = data;
|
|
||||||
isDataLoading.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []"
|
|
||||||
desc="The operating systems most commonly used by your website's visitors." :dataIcons="false"
|
|
||||||
:loading="pending" label="Top OS" sub-label="OSs"></DashboardBarsCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
import type { ReferrersAggregated } from '~/server/api/metrics/[project_id]/data/referrers';
|
|
||||||
import type { IconProvider } from './BarsCard.vue';
|
|
||||||
import ReferrerBarChart from '../referrer/ReferrerBarChart.vue';
|
|
||||||
|
|
||||||
const activeProject = await useActiveProject();
|
|
||||||
const { data: events, pending, refresh } = await useFetch<ReferrersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`, {
|
|
||||||
...signHeaders(),
|
|
||||||
lazy: true
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
function iconProvider(id: string): ReturnType<IconProvider> {
|
|
||||||
if (id === 'self') return ['icon', 'fas fa-link'];
|
|
||||||
return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${id}&sz=64`]
|
|
||||||
}
|
|
||||||
|
|
||||||
function elementTextTransformer(element: string) {
|
|
||||||
if (element === 'self') return 'Direct Link';
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
|
||||||
|
|
||||||
const customDialog = useCustomDialog();
|
|
||||||
|
|
||||||
function onShowDetails(referrer: string) {
|
|
||||||
|
|
||||||
customDialog.openDialog(ReferrerBarChart, { slice: 'day', referrer });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function showMore() {
|
|
||||||
|
|
||||||
|
|
||||||
showDialog.value = true;
|
|
||||||
dialogBarData.value = [];
|
|
||||||
isDataLoading.value = true;
|
|
||||||
|
|
||||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`, signHeaders({
|
|
||||||
'x-query-limit': '200'
|
|
||||||
})).then(data => {
|
|
||||||
dialogBarData.value = data.map(e => {
|
|
||||||
return { ...e, icon: iconProvider(e._id) }
|
|
||||||
});
|
|
||||||
isDataLoading.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<DashboardBarsCard @showDetails="onShowDetails" @showMore="showMore()"
|
|
||||||
:elementTextTransformer="elementTextTransformer" :iconProvider="iconProvider" @dataReload="refresh"
|
|
||||||
:showLink=true :data="events || []" :interactive="true" desc="Where users find your website."
|
|
||||||
:dataIcons="true" :loading="pending" label="Top Referrers" sub-label="Referrers"></DashboardBarsCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,31 +1,46 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
|
|
||||||
import DateService, { type Slice } from '@services/DateService';
|
import DateService, { type Slice } from '@services/DateService';
|
||||||
|
|
||||||
const data = ref<number[]>([]);
|
|
||||||
const labels = ref<string[]>([]);
|
|
||||||
const ready = ref<boolean>(false);
|
|
||||||
|
|
||||||
const props = defineProps<{ slice: Slice }>();
|
const props = defineProps<{ slice: Slice }>();
|
||||||
|
|
||||||
async function loadData() {
|
const activeProject = useActiveProject();
|
||||||
const response = await useTimeline('sessions', props.slice);
|
|
||||||
if (!response) return;
|
const { safeSnapshotDates } = useSnapshot()
|
||||||
data.value = response.map(e => e.count);
|
|
||||||
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
function transformResponse(input: { _id: string, count: number }[]) {
|
||||||
ready.value = true;
|
const data = input.map(e => e.count);
|
||||||
|
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), props.slice));
|
||||||
|
return { data, labels }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = computed(() => {
|
||||||
|
return {
|
||||||
|
from: safeSnapshotDates.value.from,
|
||||||
|
to: safeSnapshotDates.value.to,
|
||||||
|
slice: props.slice
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const sessionsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
|
||||||
|
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
|
||||||
|
lazy: true, immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadData();
|
sessionsData.execute();
|
||||||
watch(props, async () => { await loadData(); });
|
});
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<AdvancedLineChart v-if="ready" :data="data" :labels="labels" color="#f56523"></AdvancedLineChart>
|
<div v-if="sessionsData.pending.value" class="flex justify-center py-40">
|
||||||
|
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||||
|
</div>
|
||||||
|
<AdvancedLineChart v-if="!sessionsData.pending.value" :data="sessionsData.data.value?.data || []"
|
||||||
|
:labels="sessionsData.data.value?.labels || []" color="#f56523"></AdvancedLineChart>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,122 +1,157 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
import DateService from '@services/DateService';
|
import DateService, { type Slice } from '../../shared/services/DateService';
|
||||||
|
|
||||||
const { data: metricsInfo } = useMetricsData();
|
|
||||||
|
|
||||||
const avgVisitDay = computed(() => {
|
const { snapshot, safeSnapshotDates, snapshotDuration } = useSnapshot()
|
||||||
if (!metricsInfo.value) return '0.00';
|
|
||||||
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24;
|
|
||||||
const avg = metricsInfo.value.visitsCount / Math.max(days, 1);
|
const chartSlice = computed(() => {
|
||||||
return avg.toFixed(2);
|
if (snapshotDuration.value <= 3) return 'hour' as Slice;
|
||||||
|
if (snapshotDuration.value <= 31 * 3) return 'day' as Slice;
|
||||||
|
return 'month' as Slice;
|
||||||
});
|
});
|
||||||
|
|
||||||
const avgEventsDay = computed(() => {
|
|
||||||
if (!metricsInfo.value) return '0.00';
|
function findFirstZeroOrNullIndex(arr: (number | null)[]) {
|
||||||
const days = (Date.now() - (metricsInfo.value?.firstEventDate || 0)) / 1000 / 60 / 60 / 24;
|
for (let i = 0; i < arr.length; i++) {
|
||||||
const avg = metricsInfo.value.eventsCount / Math.max(days, 1);
|
if (arr.slice(i).every(val => val === 0 || val === null)) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformResponse(input: { _id: string, count: number }[]) {
|
||||||
|
|
||||||
|
const data = input.map(e => e.count || 0);
|
||||||
|
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), chartSlice.value));
|
||||||
|
|
||||||
|
return { data, labels, input }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const visitsData = useFetch('/api/timeline/visits', {
|
||||||
|
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionsData = useFetch('/api/timeline/sessions', {
|
||||||
|
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||||
|
});
|
||||||
|
const sessionsDurationData = useFetch('/api/timeline/sessions_duration', {
|
||||||
|
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||||
|
});
|
||||||
|
const bouncingRateData = useFetch('/api/timeline/bouncing_rate', {
|
||||||
|
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
const avgVisitDay = computed(() => {
|
||||||
|
if (!visitsData.data.value) return '0.00';
|
||||||
|
const counts = visitsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||||
|
const avg = counts / Math.max(snapshotDuration.value, 1);
|
||||||
return avg.toFixed(2);
|
return avg.toFixed(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
const avgSessionsDay = computed(() => {
|
const avgSessionsDay = computed(() => {
|
||||||
if (!metricsInfo.value) return '0.00';
|
if (!sessionsData.data.value) return '0.00';
|
||||||
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24;
|
const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||||
const avg = metricsInfo.value.sessionsVisitsCount / Math.max(days, 1);
|
const avg = counts / Math.max(snapshotDuration.value, 1);
|
||||||
return avg.toFixed(2);
|
return avg.toFixed(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const avgBouncingRate = computed(() => {
|
||||||
|
if (!bouncingRateData.data.value) return '0.00 %'
|
||||||
|
|
||||||
|
const counts = bouncingRateData.data.value.data
|
||||||
|
.filter(e => e > 0)
|
||||||
|
.reduce((a, e) => e + a, 0);
|
||||||
|
|
||||||
|
const avg = counts / Math.max(bouncingRateData.data.value.data.filter(e => e > 0).length, 1);
|
||||||
|
return avg.toFixed(2) + ' %';
|
||||||
|
})
|
||||||
|
|
||||||
|
function weightedAverage(data: number[]): number {
|
||||||
|
if (data.length === 0) return 0;
|
||||||
|
|
||||||
|
// Compute median
|
||||||
|
const sortedData = [...data].sort((a, b) => a - b);
|
||||||
|
const middle = Math.floor(sortedData.length / 2);
|
||||||
|
const median = sortedData.length % 2 === 0
|
||||||
|
? (sortedData[middle - 1] + sortedData[middle]) / 2
|
||||||
|
: sortedData[middle];
|
||||||
|
|
||||||
|
// Define a threshold (e.g., 3 times the median) to filter out extreme values
|
||||||
|
const threshold = median * 3;
|
||||||
|
const filteredData = data.filter(num => num <= threshold);
|
||||||
|
|
||||||
|
if (filteredData.length === 0) return median; // Fallback to median if all are removed
|
||||||
|
|
||||||
|
// Compute weights based on inverse absolute deviation from median
|
||||||
|
const weights = filteredData.map(num => 1 / (1 + Math.abs(num - median)));
|
||||||
|
|
||||||
|
// Compute weighted sum and sum of weights
|
||||||
|
const weightedSum = filteredData.reduce((sum, num, i) => sum + num * weights[i], 0);
|
||||||
|
const sumOfWeights = weights.reduce((sum, weight) => sum + weight, 0);
|
||||||
|
|
||||||
|
return weightedSum / sumOfWeights;
|
||||||
|
}
|
||||||
const avgSessionDuration = computed(() => {
|
const avgSessionDuration = computed(() => {
|
||||||
if (!metricsInfo.value) return '0.00';
|
if (!sessionsDurationData.data.value) return '0.00 %'
|
||||||
const avg = metricsInfo.value.avgSessionDuration;
|
|
||||||
|
const counts = sessionsDurationData.data.value.data
|
||||||
|
// .filter(e => e > 0)
|
||||||
|
.reduce((a, e) => e + a, 0);
|
||||||
|
|
||||||
|
const avg = weightedAverage(sessionsDurationData.data.value.data);
|
||||||
|
// counts / (Math.max(sessionsDurationData.data.value.data.length, 1));
|
||||||
|
|
||||||
let hours = 0;
|
let hours = 0;
|
||||||
let minutes = 0;
|
let minutes = 0;
|
||||||
let seconds = 0;
|
let seconds = 0;
|
||||||
seconds += avg * 60;
|
seconds += avg * 60;
|
||||||
|
while (seconds >= 60) { seconds -= 60; minutes += 1; }
|
||||||
while (seconds > 60) {
|
while (minutes >= 60) { minutes -= 60; hours += 1; }
|
||||||
seconds -= 60;
|
|
||||||
minutes += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (minutes > 60) {
|
|
||||||
minutes -= 60;
|
|
||||||
hours += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
|
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
|
||||||
});
|
});
|
||||||
|
|
||||||
type Data = {
|
const todayIndex = computed(() => {
|
||||||
data: number[],
|
if (!visitsData.data.value) return -1;
|
||||||
labels: string[],
|
return visitsData.data.value.input.findIndex(e => new Date(e._id).getTime() > (Date.now()));
|
||||||
trend: number,
|
})
|
||||||
ready: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const visitsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
|
|
||||||
const eventsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
|
|
||||||
const sessionsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
|
|
||||||
const sessionsDurationData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
|
|
||||||
|
|
||||||
async function loadData(timelineEndpointName: string, target: Data) {
|
|
||||||
|
|
||||||
const response = await useTimeline(timelineEndpointName as any, 'day');
|
|
||||||
if (!response) return;
|
|
||||||
target.data = response.map(e => e.count);
|
|
||||||
target.labels = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, 'day'));
|
|
||||||
|
|
||||||
const pool = [...response.map(e => e.count)];
|
|
||||||
pool.pop();
|
|
||||||
const avg = pool.reduce((a, e) => a + e, 0) / pool.length;
|
|
||||||
|
|
||||||
const diffPercent: number = (100 / avg * (response.at(-1)?.count || 0)) - 100;
|
|
||||||
|
|
||||||
target.trend = Math.max(Math.min(diffPercent, 99), -99);
|
|
||||||
|
|
||||||
target.ready = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
|
|
||||||
await loadData('visits', visitsData);
|
|
||||||
await loadData('events', eventsData);
|
|
||||||
await loadData('sessions', sessionsData);
|
|
||||||
await loadData('sessions_duration', sessionsDurationData);
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4" v-if="metricsInfo">
|
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 m-cards-wrap:grid-cols-4">
|
||||||
|
|
||||||
<DashboardCountCard :ready="visitsData.ready" icon="far fa-earth" text="Total page visits"
|
<DashboardCountCard :todayIndex="todayIndex" :ready="!visitsData.pending.value" icon="far fa-earth"
|
||||||
:value="formatNumberK(metricsInfo.visitsCount)" :avg="formatNumberK(avgVisitDay) + '/day'"
|
text="Total visits" :value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||||
:trend="visitsData.trend" :data="visitsData.data" :labels="visitsData.labels" color="#5655d7">
|
:avg="formatNumberK(avgVisitDay) + '/day'" :data="visitsData.data.value?.data"
|
||||||
|
tooltipText="Sum of all page views on your website." :labels="visitsData.data.value?.labels"
|
||||||
|
color="#5655d7">
|
||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|
||||||
<DashboardCountCard :ready="eventsData.ready" icon="far fa-flag" text="Total custom events"
|
<DashboardCountCard :todayIndex="todayIndex" :ready="!bouncingRateData.pending.value" icon="far fa-chart-user"
|
||||||
:value="formatNumberK(metricsInfo.eventsCount)" :avg="formatNumberK(avgEventsDay) + '/day'"
|
text="Bouncing rate" :value="avgBouncingRate" :slow="true" :data="bouncingRateData.data.value?.data"
|
||||||
:trend="eventsData.trend" :data="eventsData.data" :labels="eventsData.labels" color="#1e9b86">
|
tooltipText="Percentage of users who leave quickly (lower is better)."
|
||||||
|
:labels="bouncingRateData.data.value?.labels" color="#1e9b86">
|
||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|
||||||
<DashboardCountCard :ready="sessionsData.ready" icon="far fa-user" text="Unique visits sessions"
|
|
||||||
:value="formatNumberK(metricsInfo.sessionsVisitsCount)" :avg="formatNumberK(avgSessionsDay) + '/day'"
|
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsData.pending.value" icon="far fa-user"
|
||||||
:trend="sessionsData.trend" :data="sessionsData.data" :labels="sessionsData.labels" color="#4abde8">
|
text="Unique visitors"
|
||||||
|
:value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||||
|
tooltipText="Count of distinct users visiting your website." :avg="formatNumberK(avgSessionsDay) + '/day'"
|
||||||
|
:data="sessionsData.data.value?.data" :labels="sessionsData.data.value?.labels" color="#4abde8">
|
||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|
||||||
<DashboardCountCard :ready="sessionsDurationData.ready" icon="far fa-timer" text="Avg session time"
|
|
||||||
:value="avgSessionDuration" :trend="sessionsDurationData.trend" :data="sessionsDurationData.data"
|
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsDurationData.pending.value" icon="far fa-timer"
|
||||||
:labels="sessionsDurationData.labels" color="#f56523">
|
text="Visit duration" :value="avgSessionDuration" :data="sessionsDurationData.data.value?.data"
|
||||||
|
tooltipText="Average time users spend on your website." :labels="sessionsDurationData.data.value?.labels"
|
||||||
|
color="#f56523">
|
||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||