Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
broker/node_modules
|
||||
broker/scripts/start_dev.js
|
||||
broker/ecosystem.config.cjs
|
||||
broker/ecosystem.config.example.cjs
|
||||
broker/Dockerfile
|
||||
broker/.gitignore
|
||||
broker/dist
|
||||
scripts/node_modules
|
||||
|
||||
lyx-ui/node_modules
|
||||
lyx-ui/.nuxt
|
||||
lyx-ui/.output
|
||||
|
||||
# Producer
|
||||
producer/node_modules
|
||||
producer/scripts/start_dev.js
|
||||
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/.nuxt
|
||||
dashboard/.output
|
||||
dashboard/explains
|
||||
dashboard/tests
|
||||
dashboard/.env.example
|
||||
dashboard/.env
|
||||
dashboard/.gitignore
|
||||
dashboard/winston-*.ndjson
|
||||
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
|
||||
4
.gitignore
vendored
@@ -1,5 +1,7 @@
|
||||
steps
|
||||
PROCESS_EVENT
|
||||
**/node_modules/
|
||||
docker
|
||||
dev
|
||||
docker-compose.admin.yml
|
||||
docker-compose.admin.yml
|
||||
full_reload.sh
|
||||
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"files.exclude": {
|
||||
"**/node_modules": true
|
||||
}
|
||||
}
|
||||
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!
|
||||
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
FROM node:21-alpine as base
|
||||
|
||||
FROM base as build
|
||||
|
||||
RUN npm i -g pnpm
|
||||
RUN npm i -g pm2
|
||||
|
||||
# COPY --link dashboard/package.json dashboard/pnpm-lock.yaml ./
|
||||
# RUN npm install --production=false
|
||||
|
||||
WORKDIR /home/app
|
||||
|
||||
COPY --link dashboard ./dashboard
|
||||
COPY --link lyx-ui ./lyx-ui
|
||||
COPY --link consumer ./consumer
|
||||
COPY --link producer ./producer
|
||||
COPY --link shared ./shared
|
||||
|
||||
WORKDIR /home/app/producer
|
||||
RUN pnpm install
|
||||
|
||||
WORKDIR /home/app/consumer
|
||||
RUN pnpm install
|
||||
|
||||
WORKDIR /home/app/dashboard
|
||||
RUN pnpm install
|
||||
RUN pnpm run dev
|
||||
|
||||
|
||||
# CMD [ "node", "/home/app/.output/server/index.mjs" ]
|
||||
155
README.md
Normal file
@@ -0,0 +1,155 @@
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/claim.png"/>
|
||||
</p>
|
||||
|
||||
<h4 align="center">
|
||||
🌐 <a href="https://litlyx.com">Website</a> 📚 <a href="https://docs.litlyx.com">Docs</a> 👾 <a href="https://discord.gg/9cQykjsmWX">Join Discord</a> 🔥 <a href="https://dashboard.litlyx.com">Try Litlyx Cloud. It's Free.</a>
|
||||
</h4>
|
||||
|
||||
#
|
||||
<p align="center">
|
||||
The freshest, developer-friendly analytics tool.<br>
|
||||
Litlyx is an open-source, self-hostable analytics solution for modern frameworks. Setup takes less than 30 seconds!
|
||||
</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 OR Self-Host Litlyx with Docker.
|
||||
|
||||
## 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 `Page visits`, `Browsers`, `Devices`, `Operating Systems`, `Bouncing Rate`, `Real-Time Online Users`, `Unique Sessions`, `Countries`, and `Average Session Time`.
|
||||
|
||||
# All Javascript Runtimes
|
||||
|
||||
You can install Litlyx using `npm`, `pnpm`, `yarn` 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 plug-in. Litlyx also works in serverless environments with Cloud (or Edge) Functions.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/tech.png" />
|
||||
</p>
|
||||
|
||||
# Import
|
||||
|
||||
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 analytics such as `Page visits`, `Browsers`, `Devices`, `Operating Systems`, `Real-Time Online Users`, `Unique Sessions`, `Countries`, and `Average Session Time`.
|
||||
|
||||
# Track Custom Events
|
||||
|
||||
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 **fork** this repository.
|
||||
|
||||
Then run the following command:
|
||||
```bash
|
||||
docker-compose build
|
||||
```
|
||||
|
||||
after the build finishes, run:
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
at localhost:3000 you will see your own instance of the Litlyx Dashboard.
|
||||
|
||||
## Forward data to your local instance with script tag
|
||||
|
||||
To forward your data on your self-hosted instance, you need to set up the following variables: add your `data-host`, add your `data-port`, and add your `data-secure`, setting it to true if it is HTTPS, and 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>
|
||||
```
|
||||
|
||||
# Official Docs
|
||||
|
||||
For more info read our [documentation](https://docs.litlyx.com). (will be improved in the near future using Mintlify!)
|
||||
|
||||
# Join Discord
|
||||
|
||||
If you need more information, interact with us or the community, help, or want to provide feedbacks, feel free to join us on the Litlyx [Discord](https://discord.gg/9cQykjsmWX)
|
||||
|
||||
# Contributors
|
||||
|
||||
Every kind of contribution is accepted in this stage of the project. In the future we will improve the contributor onboarding process.
|
||||
|
||||
### 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/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: 123 KiB |
|
Before Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 144 KiB |
BIN
assets/tech.png
Normal file
|
After Width: | Height: | Size: 17 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;
|
||||
}
|
||||
3
broker/.gitignore → consumer/.gitignore
vendored
@@ -4,4 +4,5 @@ ecosystem.config.cjs
|
||||
dist
|
||||
scripts/start_dev.js
|
||||
package-lock.json
|
||||
build_all.bat
|
||||
build_all.bat
|
||||
tests
|
||||
28
consumer/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
FROM node:21-alpine as base
|
||||
|
||||
RUN npm i -g pnpm
|
||||
|
||||
WORKDIR /home/app
|
||||
|
||||
COPY --link ./package.json ./tsconfig.json ./pnpm-lock.yaml ./
|
||||
COPY --link ./scripts/package.json ./scripts/pnpm-lock.yaml ./scripts/
|
||||
COPY --link ./consumer/package.json ./consumer/pnpm-lock.yaml ./consumer/
|
||||
|
||||
RUN pnpm install
|
||||
RUN pnpm install --filter consumer
|
||||
|
||||
WORKDIR /home/app/scripts
|
||||
RUN pnpm install
|
||||
|
||||
WORKDIR /home/app
|
||||
|
||||
COPY --link ../scripts ./scripts
|
||||
COPY --link ../shared ./shared
|
||||
COPY --link ../consumer ./consumer
|
||||
|
||||
WORKDIR /home/app/consumer
|
||||
|
||||
RUN pnpm run build
|
||||
|
||||
CMD ["node", "/home/app/consumer/dist/consumer/src/index.js"]
|
||||
21
consumer/ecosystem.config.example.cjs
Normal file
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'consumer',
|
||||
port: '3031',
|
||||
exec_mode: 'cluster',
|
||||
instances: '2',
|
||||
script: './dist/consumer/src/index.js',
|
||||
env: {
|
||||
EMAIL_SERVICE: '',
|
||||
BREVO_API_KEY: '',
|
||||
MONGO_CONNECTION_STRING: '',
|
||||
REDIS_URL: "",
|
||||
REDIS_USERNAME: "",
|
||||
REDIS_PASSWORD: "",
|
||||
STREAM_NAME: "",
|
||||
GROUP_NAME: ''
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
28
consumer/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"ua-parser-js": "^1.0.37"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"compile": "tsc",
|
||||
"build_project": "node ../scripts/build.js",
|
||||
"build": "npm run compile && npm run build_project && 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"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Emily",
|
||||
"license": "MIT",
|
||||
"description": "Database Consumer - Saves events to database."
|
||||
}
|
||||
1498
consumer/pnpm-lock.yaml
generated
Normal file
|
Can't render this file because it is too large.
|
64
consumer/src/EmailController.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
import { LimitNotifyModel } from "@schema/broker/LimitNotifySchema";
|
||||
import EmailService from '@services/EmailService';
|
||||
import { requireEnv } from "@utils/requireEnv";
|
||||
import { TProjectLimit } from "@schema/project/ProjectsLimits";
|
||||
|
||||
if (process.env.EMAIL_SERVICE) {
|
||||
EmailService.init(requireEnv('BREVO_API_KEY'));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmailMax(owner.email, project.name);
|
||||
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;
|
||||
|
||||
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail90(owner.email, project.name);
|
||||
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;
|
||||
|
||||
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail50(owner.email, project.name);
|
||||
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: false, limit3: false });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
15
consumer/src/LimitChecker.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
|
||||
import { ProjectLimitModel } from '@schema/project/ProjectsLimits';
|
||||
import { MAX_LOG_LIMIT_PERCENT } from '@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;
|
||||
}
|
||||
172
consumer/src/index.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
|
||||
import { requireEnv } from '@utils/requireEnv';
|
||||
import { connectDatabase } from '@services/DatabaseService';
|
||||
import { RedisStreamService } from '@services/RedisStreamService';
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { lookup } from './lookup';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { checkLimits } from './LimitChecker';
|
||||
import express from 'express';
|
||||
|
||||
import { ProjectLimitModel } from '@schema/project/ProjectsLimits';
|
||||
import { ProjectCountModel } from '@schema/project/ProjectsCounts';
|
||||
|
||||
|
||||
const app = express();
|
||||
|
||||
let durations: number[] = [];
|
||||
|
||||
app.get('/status', async (req, res) => {
|
||||
try {
|
||||
return res.json({ status: 'ALIVE', durations })
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
return res.setStatus(500).json({ error: ex.message });
|
||||
}
|
||||
})
|
||||
|
||||
app.listen(process.env.PORT);
|
||||
|
||||
connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
|
||||
main();
|
||||
|
||||
|
||||
|
||||
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_${process.env.NODE_APP_INSTANCE || 'DEFAULT'}`
|
||||
}, processStreamEntry);
|
||||
|
||||
}
|
||||
|
||||
async function processStreamEntry(data: Record<string, string>) {
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
|
||||
const eventType = data._type;
|
||||
if (!eventType) return;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// console.log('Entry processed in', duration, 'ms');
|
||||
|
||||
} catch (ex: any) {
|
||||
console.error('ERROR PROCESSING STREAM EVENT', ex.message);
|
||||
}
|
||||
|
||||
const duration = Date.now() - start;
|
||||
|
||||
durations.push(duration);
|
||||
if (durations.length > 1000) {
|
||||
durations = durations.splice(500);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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 } = 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,
|
||||
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, timestamp } = 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,
|
||||
created_at: new Date(parseInt(timestamp))
|
||||
}),
|
||||
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true }),
|
||||
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } })
|
||||
]);
|
||||
|
||||
|
||||
}
|
||||
15
consumer/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"target": "ESNext",
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -10,9 +10,7 @@ AI_PROJECT=
|
||||
AI_KEY=
|
||||
|
||||
EMAIL_SERVICE=
|
||||
EMAIL_HOST=
|
||||
EMAIL_USER=
|
||||
EMAIL_PASS=
|
||||
BREVO_API_KEY=
|
||||
|
||||
AUTH_JWT_SECRET=
|
||||
|
||||
|
||||
7
dashboard/.gitignore
vendored
@@ -12,6 +12,7 @@ node_modules
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
winston-*.ndjson
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
@@ -31,4 +32,8 @@ logs
|
||||
out.pdf
|
||||
|
||||
# TESTS - TO REMOVE
|
||||
tests
|
||||
tests
|
||||
|
||||
# EXPLAINS MONGODB
|
||||
|
||||
explains
|
||||
@@ -1,34 +1,31 @@
|
||||
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
|
||||
|
||||
FROM base as build
|
||||
COPY --link ./package.json ./tsconfig.json ./pnpm-lock.yaml ./
|
||||
COPY --link ./dashboard/package.json ./dashboard/pnpm-lock.yaml ./dashboard/
|
||||
|
||||
COPY --link dashboard/package.json dashboard/pnpm-lock.yaml ./
|
||||
RUN npm install --production=false
|
||||
RUN pnpm install
|
||||
RUN pnpm install --filter dashboard
|
||||
|
||||
COPY --link dashboard/ ./
|
||||
WORKDIR /home/app
|
||||
|
||||
COPY --link shared/ /home/shared
|
||||
COPY --link ./dashboard ./dashboard
|
||||
COPY --link ./shared ./shared
|
||||
|
||||
ARG GOOGLE_AUTH_CLIENT_ID
|
||||
ENV GOOGLE_AUTH_CLIENT_ID=$GOOGLE_AUTH_CLIENT_ID
|
||||
WORKDIR /home/app/dashboard
|
||||
|
||||
RUN npm run build
|
||||
RUN npm prune
|
||||
RUN pnpm run build
|
||||
|
||||
# Final stage
|
||||
FROM node:21-alpine AS production
|
||||
|
||||
FROM base
|
||||
WORKDIR /home/app
|
||||
|
||||
COPY --from=build /home/app /home/app
|
||||
COPY --from=build /home/app/dashboard/.output /home/app/.output
|
||||
|
||||
EXPOSE ${PORT}
|
||||
|
||||
CMD [ "node", "/home/app/.output/server/index.mjs" ]
|
||||
CMD ["node", "/home/app/.output/server/index.mjs"]
|
||||
@@ -6,15 +6,47 @@ Lit.init('6643cd08a1854e3b81722ab5');
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="w-dvw h-dvh bg-[#151517] relative">
|
||||
<div class="w-dvw h-dvh bg-lyx-background-light relative">
|
||||
|
||||
<Transition name="drawer">
|
||||
<LazyDrawerGeneric @onCloseClick="hideDrawer()" :class="drawerClasses"
|
||||
class="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-[#151515] overflow-hidden border-solid border-[2px] 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"
|
||||
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 hidden sm:max-md:flex"> SM - MOBILE </div>
|
||||
<div class="poppins hidden md:max-lg:flex"> MD - TABLET </div>
|
||||
@@ -24,9 +56,9 @@ const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDial
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="bg-menu w-full h-full rounded-xl relative">
|
||||
<div class="flex justify-end absolute z-[100] right-8 top-8">
|
||||
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] bg-black/50">
|
||||
<div :style="dialogStyle" class="bg-lyx-widget rounded-xl relative outline outline-1 outline-lyx-widget-lighter">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex items-center justify-center w-full h-full p-4">
|
||||
@@ -35,9 +67,31 @@ const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDial
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<UModals />
|
||||
|
||||
<LazyOnboarding> </LazyOnboarding>
|
||||
|
||||
<NuxtLayout>
|
||||
<NuxtPage></NuxtPage>
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
|
||||
</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>
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
@use './utilities.scss';
|
||||
@use './colors.scss';
|
||||
|
||||
@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');
|
||||
@import url('https://fonts.cdnfonts.com/css/brockmann');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
|
||||
|
||||
@import '../font-awesome/css/all.css';
|
||||
@import './utilities.scss';
|
||||
@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');
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0');
|
||||
|
||||
|
||||
@font-face {
|
||||
font-family: "Geist";
|
||||
src: url("../fonts/GeistVF.ttf");
|
||||
}
|
||||
|
||||
|
||||
.actionable-visits-color-checkbox {
|
||||
color: #5655d7;
|
||||
}
|
||||
|
||||
.actionable-sessions-color-checkbox {
|
||||
color: #4abde8;
|
||||
}
|
||||
.actionable-events-color-checkbox {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.geist {
|
||||
font-family: "Geist";
|
||||
}
|
||||
@@ -70,10 +85,14 @@
|
||||
|
||||
|
||||
.hide-scrollbars {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none;
|
||||
/* IE and Edge */
|
||||
scrollbar-width: none;
|
||||
|
||||
/* Firefox */
|
||||
&::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
display: none;
|
||||
/* Chrome, Safari and Opera */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,4 +12,17 @@
|
||||
|
||||
.test3 {
|
||||
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 () => {
|
||||
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext("2d");
|
||||
let gradient: any = `${props.color}22`;
|
||||
@@ -95,7 +96,6 @@ onMounted(async () => {
|
||||
chartData.value.datasets[0].backgroundColor = [gradient];
|
||||
|
||||
watch(props, () => {
|
||||
console.log('UPDATE')
|
||||
chartData.value.labels = props.labels;
|
||||
chartData.value.datasets[0].data = props.data;
|
||||
});
|
||||
@@ -106,5 +106,5 @@ onMounted(async () => {
|
||||
|
||||
|
||||
<template>
|
||||
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||
</template>
|
||||
|
||||
@@ -67,7 +67,7 @@ const chartData = ref<ChartData<'bar'>>({
|
||||
label: e.label || '?',
|
||||
backgroundColor: [e.color],
|
||||
borderWidth: 0,
|
||||
borderRadius: 8
|
||||
borderRadius: 0
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
170
dashboard/components/BarCard/Base.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<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-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">
|
||||
|
||||
<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 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) != 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-[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-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">
|
||||
<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>
|
||||
</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">
|
||||
<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'];
|
||||
if (e._id === 'mobile') return ['icon','far fa-mobile'];
|
||||
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']
|
||||
}
|
||||
|
||||
|
||||
function transform(data: { _id: string, count: number }[]) {
|
||||
console.log(data);
|
||||
return data.map(e => ({ ...e, _id: e._id == null ? 'unknown' : 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">
|
||||
<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>
|
||||
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">
|
||||
<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,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import CreateSnapshot from './dialog/CreateSnapshot.vue';
|
||||
|
||||
export type Entry = {
|
||||
label: string,
|
||||
@@ -8,7 +9,9 @@ export type Entry = {
|
||||
icon?: string,
|
||||
action?: () => any,
|
||||
adminOnly?: boolean,
|
||||
external?: boolean
|
||||
premiumOnly?: boolean,
|
||||
external?: boolean,
|
||||
grow?: boolean
|
||||
}
|
||||
|
||||
export type Section = {
|
||||
@@ -23,53 +26,251 @@ type Props = {
|
||||
const route = useRoute();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { isAdmin } = useUserRoles();
|
||||
const { userRoles, setLoggedUser } = useLoggedUser();
|
||||
const { projectList } = useProject();
|
||||
|
||||
const debugMode = process.dev;
|
||||
|
||||
const { isOpen, close } = useMenu();
|
||||
|
||||
const { snapshots, snapshot, updateSnapshots } = useSnapshot();
|
||||
|
||||
const snapshotsItems = computed(() => {
|
||||
if (!snapshots.value) return []
|
||||
return snapshots.value as any[];
|
||||
})
|
||||
|
||||
|
||||
const { openDialogEx } = useCustomDialog();
|
||||
|
||||
function openSnapshotDialog() {
|
||||
openDialogEx(CreateSnapshot, {
|
||||
width: "24rem",
|
||||
height: "16rem",
|
||||
closable: false
|
||||
});
|
||||
}
|
||||
|
||||
const { createAlert } = useAlert()
|
||||
|
||||
async function deleteSnapshot(close: () => any) {
|
||||
await $fetch("/api/snapshot/delete", {
|
||||
method: 'DELETE',
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }).value,
|
||||
body: JSON.stringify({
|
||||
id: snapshot.value._id.toString(),
|
||||
})
|
||||
});
|
||||
await updateSnapshots();
|
||||
snapshot.value = snapshots.value[1];
|
||||
createAlert('Snapshot deleted', 'Snapshot deleted successfully', 'far fa-circle-check', 5000);
|
||||
close();
|
||||
}
|
||||
|
||||
async function generatePDF() {
|
||||
|
||||
try {
|
||||
const res = await $fetch<Blob>('/api/project/generate_pdf', {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false, custom: { 'x-snapshot-name': snapshot.value.name } }).value,
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(res);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `Report.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
const { setToken } = useAccessToken();
|
||||
const router = useRouter();
|
||||
|
||||
function onLogout() {
|
||||
console.log('LOGOUT')
|
||||
setToken('');
|
||||
setLoggedUser(undefined);
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||
headers: computed(() => {
|
||||
return {
|
||||
Authorization: authorizationHeaderComputed.value
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
</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="CVerticalNavigation border-solid border-[#202020] border-r-[1px] h-full w-[20rem] bg-lyx-background 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="py-4 px-2 gap-6 flex flex-col w-full">
|
||||
|
||||
<!-- <div class="flex px-2" v-if="!isPremium">
|
||||
<LyxUiButton type="primary" class="w-full text-center text-[.8rem] font-medium" @click="pricingDrawer.visible.value = true;">
|
||||
Upgrade plan
|
||||
</LyxUiButton>
|
||||
</div> -->
|
||||
|
||||
|
||||
<div class="flex px-2 flex-col">
|
||||
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
|
||||
<ProjectSelector></ProjectSelector>
|
||||
|
||||
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
|
||||
<i @click="close()" class="fas fa-close"></i>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<LyxUiButton to="/project_creation" v-if="projectList && (projectList.length < (maxProjects || 1))"
|
||||
type="outlined" class="w-full py-1 mt-2 text-[.8rem]">
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<div><i class="fas fa-plus text-[.7rem]"></i></div>
|
||||
<div class="poppins"> New Project </div>
|
||||
</div>
|
||||
</LyxUiButton>
|
||||
|
||||
<LyxUiButton v-if="projectList && (projectList.length >= (maxProjects || 1))" type="outlined"
|
||||
class="w-full py-1 mt-2 text-[.7rem]">
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<div><i class="text-lyx-text-darker far fa-lock"></i></div>
|
||||
<div class="text-lyx-text-darker"> Projects limit reached </div>
|
||||
</div>
|
||||
</LyxUiButton>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="w-full flex-col px-2">
|
||||
|
||||
<div class="flex mb-2 items-center justify-between">
|
||||
<div class="poppins text-[.8rem]">
|
||||
Snapshots
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<!-- <UTooltip text="Download report">
|
||||
<LyxUiButton @click="generatePDF()" type="outlined" class="!px-3 !py-1">
|
||||
<div><i class="far fa-download text-[.8rem]"></i></div>
|
||||
</LyxUiButton>
|
||||
</UTooltip> -->
|
||||
<UTooltip text="Create new snapshot">
|
||||
<LyxUiButton @click="openSnapshotDialog()" type="outlined" class="!px-3 !py-1">
|
||||
<div><i class="fas fa-plus text-[.9rem]"></i></div>
|
||||
</LyxUiButton>
|
||||
</UTooltip>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<USelectMenu :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" class="w-full" v-model="snapshot" :options="snapshotsItems">
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div :style="'background-color:' + snapshot?.color" class="w-2 h-2 rounded-full">
|
||||
</div>
|
||||
<div class="poppins"> {{ snapshot?.name }} </div>
|
||||
</div>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div :style="'background-color:' + option.color" class="w-2 h-2 rounded-full">
|
||||
</div>
|
||||
<div class="poppins"> {{ option.name }} </div>
|
||||
</div>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</div>
|
||||
|
||||
<div v-if="snapshot" class="flex flex-col text-[.7rem] mt-2">
|
||||
<div class="flex gap-1 items-center justify-center text-lyx-text-dark">
|
||||
<div class="poppins">
|
||||
{{ new Date(snapshot.from).toLocaleString().split(',')[0].trim() }}
|
||||
</div>
|
||||
<div class="poppins"> to </div>
|
||||
<div class="poppins">
|
||||
{{ new Date(snapshot.to).toLocaleString().split(',')[0].trim() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2" v-if="('default' in snapshot == false)">
|
||||
<UPopover placement="bottom">
|
||||
<LyxUiButton type="danger" class="w-full text-center">
|
||||
Delete current snapshot
|
||||
</LyxUiButton>
|
||||
|
||||
<template #panel="{ close }">
|
||||
<div class="p-4 bg-lyx-widget">
|
||||
<div class="poppins text-center font-medium">
|
||||
Are you sure?
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<LyxUiButton @click="close()" type="secondary"> Cancel </LyxUiButton>
|
||||
<LyxUiButton type="danger" @click="deleteSnapshot(close)"> Delete </LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex mt-4">
|
||||
<LyxUiButton type="outline" class="w-full text-center text-[.7rem]">
|
||||
Export report
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div v-for="section of sections" class="flex flex-col gap-1">
|
||||
<div class="bg-[#202020] h-[1px] w-full"></div>
|
||||
|
||||
<div v-for="entry of section.entries">
|
||||
<div class="flex flex-col h-full">
|
||||
|
||||
<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 || '#')
|
||||
<div v-for="section of sections" class="flex flex-col gap-1 h-full pb-6">
|
||||
|
||||
<div v-for="entry of section.entries" :class="{ 'grow flex items-end': entry.grow }">
|
||||
|
||||
<div v-if="(!entry.adminOnly || (userRoles.isAdmin.value && !isAdminHidden))"
|
||||
class="bg-lyx-background w-full cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
|
||||
:class="{
|
||||
'!text-lyx-text-darker pointer-events-none': entry.disabled,
|
||||
'bg-lyx-background-lighter !text-lyx-text/90': route.path == (entry.to || '#'),
|
||||
'hover:bg-lyx-background-light hover:!text-lyx-text/90': 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">
|
||||
tag="div" class="w-full flex items-center" :to="entry.to || '/'">
|
||||
<div class="flex items-center w-[1.4rem] mr-2 text-[1.1rem] justify-center">
|
||||
<i :class="entry.icon"></i>
|
||||
</div>
|
||||
<div class="manrope">
|
||||
<div class="manrope grow">
|
||||
{{ entry.label }}
|
||||
</div>
|
||||
<div v-if="entry.premiumOnly && !userRoles.isPremium.value" class="flex items-center">
|
||||
<i class="fal fa-lock"></i>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
</div>
|
||||
@@ -78,6 +279,45 @@ const { isOpen, close } = useMenu();
|
||||
|
||||
</div>
|
||||
|
||||
<div class="grow"></div>
|
||||
|
||||
<div class="bg-[#202020] h-[1px] w-full px-4 mb-3"></div>
|
||||
|
||||
<div class="flex justify-end px-2">
|
||||
|
||||
<div class="grow flex gap-3">
|
||||
<!-- <NuxtLink to="https://github.com/litlyx/litlyx" target="_blank"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fab fa-github"></i>
|
||||
</NuxtLink> -->
|
||||
<!-- <NuxtLink to="https://discord.gg/9cQykjsmWX" target="_blank"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fab fa-discord"></i>
|
||||
</NuxtLink> -->
|
||||
<NuxtLink to="https://x.com/litlyx" target="_blank"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fab fa-x-twitter"></i>
|
||||
</NuxtLink>
|
||||
<!-- <NuxtLink to="https://dev.to/litlyx-org" target="_blank"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fab fa-dev"></i>
|
||||
</NuxtLink> -->
|
||||
|
||||
<NuxtLink to="/admin" v-if="userRoles.isAdmin.value"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fas fa-cat"></i>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<UTooltip text="Logout" :popper="{ arrow: true, placement: 'top' }">
|
||||
<div @click="onLogout()" class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="far fa-arrow-right-from-bracket scale-x-[-100%]"></i>
|
||||
</div>
|
||||
</UTooltip>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,10 +325,6 @@ const { isOpen, close } = useMenu();
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.CVerticalNavigation * {
|
||||
font-family: 'Geist';
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -5,23 +5,22 @@ const props = defineProps<{ title: string, sub?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<div class="flex flex-col gap-4">
|
||||
<LyxUiCard>
|
||||
<div class="flex flex-col gap-4 h-full">
|
||||
<div class="flex items-center">
|
||||
<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-text">
|
||||
{{ props.title }}
|
||||
</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-text-sub">
|
||||
{{ props.sub }}
|
||||
</div>
|
||||
</div>
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<div class="h-full">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
28
dashboard/components/CustomTab.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
type CItem = { label: string, slot: string }
|
||||
const props = defineProps<{ items: CItem[] }>();
|
||||
|
||||
const activeTabIndex = ref<number>(0);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex">
|
||||
<div v-for="(tab, index) of items" @click="activeTabIndex = index"
|
||||
class="px-6 pb-3 poppins font-medium text-lyx-text-darker border-b-[1px] border-lyx-text-darker" :class="{
|
||||
'!border-[#88A7FF] !text-[#88A7FF]': activeTabIndex === index,
|
||||
'hover:border-lyx-text-dark hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
|
||||
}">
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
<div class="border-b-[1px] border-lyx-text-darker w-full">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
212
dashboard/components/FirstInteraction.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<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-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">
|
||||
<div class="flex flex-col-reverse 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-[500px] 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-[#1c1b1b] rounded-md w-full">
|
||||
<div class="w-full text-[.9rem] 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="Documentation"
|
||||
sub="Learn how to use Litlyx in every tech stack">
|
||||
<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>
|
||||
20
dashboard/components/LyxUi/Button.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<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-text" :class="{
|
||||
'bg-lyx-primary-dark outline-lyx-primary hover:bg-lyx-primary-hover': type === 'primary',
|
||||
'bg-lyx-widget-lighter outline-lyx-widget-lighter hover:bg-lyx-widget-light': type === 'secondary',
|
||||
'bg-lyx-transparent outline-lyx-widget-lighter hover:bg-lyx-widget-light': (type === 'outline' || type === 'outlined'),
|
||||
'bg-lyx-danger-dark outline-lyx-danger hover:bg-lyx-danger': type === 'danger',
|
||||
'!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-widget p-4 outline outline-[1px] outline-lyx-background-lighter">
|
||||
<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-widget-light text-lyx-text-dark poppins rounded-md outline outline-[1px] outline-lyx-widget-lighter"
|
||||
:type="props.type ?? 'text'" :placeholder="props.placeholder" :value="props.modelValue" @input="handleChange">
|
||||
</template>
|
||||
175
dashboard/components/Onboarding.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<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?.exist === 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-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
|
||||
|
||||
<div class="text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
|
||||
|
||||
<div class="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-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-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
|
||||
|
||||
<div class="text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
|
||||
|
||||
<div class="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-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>
|
||||
55
dashboard/components/ProjectSelector.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { TProject } from '@schema/project/ProjectSchema';
|
||||
|
||||
const { user } = useLoggedUser()
|
||||
|
||||
const { projectList, guestProjectList,allProjectList, actions, project } = useProject();
|
||||
|
||||
|
||||
function isProjectMine(owner?: string) {
|
||||
if (!owner) return false;
|
||||
if (!user.value) return false;
|
||||
if (!user.value.logged) return;
|
||||
return user.value.id == owner;
|
||||
}
|
||||
|
||||
function onChange(e: TProject) {
|
||||
actions.setActiveProject(e._id.toString());
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<USelectMenu :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" class="w-full" v-if="allProjectList" @change="onChange" :value="project" :options="allProjectList">
|
||||
|
||||
<template #option="{ option, active, selected }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
|
||||
</div>
|
||||
<div> {{ option.name }} {{ !isProjectMine(option.owner) ? '(Guest)' : '' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
|
||||
</div>
|
||||
<div>
|
||||
{{ project?.name || '-' }}
|
||||
{{ !isProjectMine(project?.owner?.toString()) ? '(Guest)' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
type Props = {
|
||||
options: { label: string }[],
|
||||
options: { label: string, disabled?: boolean }[],
|
||||
currentIndex: number
|
||||
}
|
||||
|
||||
@@ -16,10 +16,13 @@ const emits = defineEmits<{
|
||||
|
||||
<template>
|
||||
|
||||
<div class="flex gap-2 border-[1px] border-gray-400 p-1 md:p-2 rounded-xl">
|
||||
<div @click="$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="{ 'bg-accent hover:!bg-accent': currentIndex == index }">
|
||||
<div class="flex gap-2 border-[1px] border-lyx-widget-lighter p-1 md:p-2 rounded-xl bg-lyx-widget">
|
||||
<div @click="opt.disabled ? ()=>{}: $emit('changeIndex', index)" v-for="(opt, index) of options"
|
||||
class="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-lyx-widget-lighter hover:!bg-lyx-widget-lighter': currentIndex == index && !opt.disabled,
|
||||
'hover:!bg-lyx-widget !cursor-not-allowed text-lyx-widget-lighter': opt.disabled
|
||||
}">
|
||||
{{ opt.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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>
|
||||
399
dashboard/components/dashboard/ActionableChart.vue
Normal file
@@ -0,0 +1,399 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
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 sessions',
|
||||
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>(() => {
|
||||
const targetValue = selectLabelsAvailable.value[selectedLabelIndex.value];
|
||||
if (!targetValue) return 'day';
|
||||
if (targetValue.disabled) {
|
||||
selectedLabelIndex.value = selectLabelsAvailable.value.findIndex(e => !e.disabled);
|
||||
}
|
||||
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 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 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-400"></i>
|
||||
<div class="poppins 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>
|
||||
<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="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,
|
||||
text: string,
|
||||
avg?: string,
|
||||
trend?: number,
|
||||
color: string,
|
||||
data?: number[],
|
||||
labels?: string[],
|
||||
ready?: boolean
|
||||
ready?: boolean,
|
||||
slow?: boolean,
|
||||
todayIndex: number,
|
||||
tooltipText: string
|
||||
}>();
|
||||
|
||||
const { snapshotDuration } = useSnapshot()
|
||||
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Card class="flex flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
|
||||
<div class="flex p-4 items-start">
|
||||
<LyxUiCard class="flex !p-0 flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
|
||||
<div v-if="ready" class="flex p-4 items-start">
|
||||
<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 class="flex flex-col grow">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="brockmann text-text-dirty text-[1.6rem] 2xl:text-[1.9rem]"> {{ value }} </div>
|
||||
<div class="poppins text-text-sub text-[.7rem] 2xl:text-[.85rem] mb-2"> {{ avg }} </div>
|
||||
</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 class="flex items-center gap-2">
|
||||
<div class="brockmann text-text-dirty text-[1.2rem] 2xl:text-[1.4rem]">
|
||||
{{ value }}
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.65rem] 2xl:text-[.8rem]"> {{ avg }} </div>
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.7rem]"> Daily variation </div>
|
||||
<div class="poppins 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 class="absolute bottom-0 left-0 w-full h-[50%] flex items-end" v-if="(props.data?.length || 0) > 0">
|
||||
<DashboardEmbedChartCard v-if="ready" :data="props.data || []" :labels="props.labels || []"
|
||||
:color="props.color">
|
||||
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end"
|
||||
v-if="((props.data?.length || 0) > 0) && ready">
|
||||
<DashboardEmbedChartCard v-if="ready" :todayIndex="todayIndex" :data="props.data || []"
|
||||
:labels="props.labels || []" :color="props.color">
|
||||
</DashboardEmbedChartCard>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- <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="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 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 v-if="props.slow"> Can be very slow on large snapshots </div>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
|
||||
|
||||
</div> -->
|
||||
|
||||
</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>
|
||||
@@ -7,8 +7,10 @@ const props = defineProps<{
|
||||
data: any[],
|
||||
labels: string[]
|
||||
color: string,
|
||||
todayIndex: number
|
||||
}>();
|
||||
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -48,10 +50,22 @@ const chartData = ref<ChartData<'line'>>({
|
||||
data: props.data,
|
||||
backgroundColor: [props.color + '77'],
|
||||
borderColor: props.color,
|
||||
borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
pointRadius: 0
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.35,
|
||||
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 { 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' });
|
||||
|
||||
@@ -20,15 +20,6 @@ const chartOptions = ref<ChartOptions<'doughnut'>>({
|
||||
ticks: { display: false },
|
||||
grid: { display: false, drawBorder: false },
|
||||
},
|
||||
// r: {
|
||||
// ticks: { display: false },
|
||||
// grid: {
|
||||
// display: true,
|
||||
// drawBorder: false,
|
||||
// color: '#CCCCCC22',
|
||||
// borderDash: [20, 8]
|
||||
// },
|
||||
// }
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
@@ -55,7 +46,28 @@ const chartData = ref<ChartData<'doughnut'>>({
|
||||
{
|
||||
rotation: 1,
|
||||
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'],
|
||||
borderWidth: 2
|
||||
},
|
||||
@@ -65,15 +77,18 @@ const chartData = ref<ChartData<'doughnut'>>({
|
||||
|
||||
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}`;
|
||||
});
|
||||
chartData.value.datasets[0].data = eventsData.map(e => e.count);
|
||||
chartData.value.datasets[0].data = input.map(e => e.count);
|
||||
doughnutChartRef.value?.update();
|
||||
|
||||
if (window.innerWidth < 800) {
|
||||
@@ -81,11 +96,25 @@ onMounted(async () => {
|
||||
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>
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
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 }>();
|
||||
|
||||
async function loadData() {
|
||||
const response = await useTimeline('sessions', props.slice);
|
||||
if (!response) return;
|
||||
data.value = response.map(e => e.count);
|
||||
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
ready.value = true;
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
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(), 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 () => {
|
||||
await loadData();
|
||||
watch(props, async () => { await loadData(); });
|
||||
})
|
||||
sessionsData.execute();
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</template>
|
||||
@@ -1,122 +1,132 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import DateService from '@services/DateService';
|
||||
import type { Slice } from '@services/DateService';
|
||||
|
||||
const { data: metricsInfo } = useMetricsData();
|
||||
const { snapshot, safeSnapshotDates, snapshotDuration } = useSnapshot()
|
||||
|
||||
const avgVisitDay = computed(() => {
|
||||
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);
|
||||
return avg.toFixed(2);
|
||||
|
||||
const chartSlice = computed(() => {
|
||||
if (snapshotDuration.value <= 3) return 'hour' as Slice;
|
||||
if (snapshotDuration.value <= 32) return 'day' as Slice;
|
||||
return 'month' as Slice;
|
||||
});
|
||||
|
||||
const avgEventsDay = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const days = (Date.now() - (metricsInfo.value?.firstEventDate || 0)) / 1000 / 60 / 60 / 24;
|
||||
const avg = metricsInfo.value.eventsCount / Math.max(days, 1);
|
||||
|
||||
function findFirstZeroOrNullIndex(arr: (number | null)[]) {
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
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);
|
||||
});
|
||||
|
||||
const avgSessionsDay = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24;
|
||||
const avg = metricsInfo.value.sessionsVisitsCount / Math.max(days, 1);
|
||||
if (!sessionsData.data.value) return '0.00';
|
||||
const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||
const avg = counts / Math.max(snapshotDuration.value, 1);
|
||||
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) + ' %';
|
||||
})
|
||||
|
||||
const avgSessionDuration = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const avg = metricsInfo.value.avgSessionDuration;
|
||||
if (!sessionsDurationData.data.value) return '0.00 %'
|
||||
|
||||
const counts = sessionsDurationData.data.value.data
|
||||
.filter(e => e > 0)
|
||||
.reduce((a, e) => e + a, 0);
|
||||
|
||||
const avg = counts / Math.max(sessionsDurationData.data.value.data.filter(e => e > 0).length, 1);
|
||||
|
||||
let hours = 0;
|
||||
let minutes = 0;
|
||||
let seconds = 0;
|
||||
seconds += avg * 60;
|
||||
|
||||
while (seconds > 60) {
|
||||
seconds -= 60;
|
||||
minutes += 1;
|
||||
}
|
||||
|
||||
while (minutes > 60) {
|
||||
minutes -= 60;
|
||||
hours += 1;
|
||||
}
|
||||
|
||||
|
||||
while (seconds > 60) { seconds -= 60; minutes += 1; }
|
||||
while (minutes > 60) { minutes -= 60; hours += 1; }
|
||||
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
|
||||
});
|
||||
|
||||
type Data = {
|
||||
data: number[],
|
||||
labels: string[],
|
||||
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);
|
||||
|
||||
});
|
||||
|
||||
const todayIndex = computed(() => {
|
||||
if (!visitsData.data.value) return -1;
|
||||
return visitsData.data.value.input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<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-2 m-cards-wrap:grid-cols-4">
|
||||
|
||||
<DashboardCountCard :ready="visitsData.ready" icon="far fa-earth" text="Total page visits"
|
||||
:value="formatNumberK(metricsInfo.visitsCount)" :avg="formatNumberK(avgVisitDay) + '/day'"
|
||||
:trend="visitsData.trend" :data="visitsData.data" :labels="visitsData.labels" color="#5655d7">
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!visitsData.pending.value" icon="far fa-earth"
|
||||
text="Total visits" :value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
: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 :ready="eventsData.ready" icon="far fa-flag" text="Total custom events"
|
||||
:value="formatNumberK(metricsInfo.eventsCount)" :avg="formatNumberK(avgEventsDay) + '/day'"
|
||||
:trend="eventsData.trend" :data="eventsData.data" :labels="eventsData.labels" color="#1e9b86">
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!bouncingRateData.pending.value" icon="far fa-chart-user"
|
||||
text="Bouncing rate" :value="avgBouncingRate" :slow="true" :data="bouncingRateData.data.value?.data"
|
||||
tooltipText="Percentage of users who leave quickly (lower is better)."
|
||||
:labels="bouncingRateData.data.value?.labels" color="#1e9b86">
|
||||
</DashboardCountCard>
|
||||
|
||||
<DashboardCountCard :ready="sessionsData.ready" icon="far fa-user" text="Unique visits sessions"
|
||||
:value="formatNumberK(metricsInfo.sessionsVisitsCount)" :avg="formatNumberK(avgSessionsDay) + '/day'"
|
||||
:trend="sessionsData.trend" :data="sessionsData.data" :labels="sessionsData.labels" color="#4abde8">
|
||||
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsData.pending.value" icon="far fa-user"
|
||||
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 :ready="sessionsDurationData.ready" icon="far fa-timer" text="Avg session time"
|
||||
:value="avgSessionDuration" :trend="sessionsDurationData.trend" :data="sessionsDurationData.data"
|
||||
:labels="sessionsDurationData.labels" color="#f56523">
|
||||
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsDurationData.pending.value" icon="far fa-timer"
|
||||
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>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,44 +1,76 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { project } = useProject();
|
||||
|
||||
const { onlineUsers, stopWatching, startWatching } = useOnlineUsers();
|
||||
onMounted(() => startWatching());
|
||||
onUnmounted(() => stopWatching());
|
||||
|
||||
const selfhosted = useSelfhosted();
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
|
||||
function copyProjectId() {
|
||||
if (!navigator.clipboard) alert('NON PUOI COPIARE IN HTTP');
|
||||
navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
|
||||
alert('Copiato !');
|
||||
if (!navigator.clipboard) return alert('You can\'t copy in HTTP');
|
||||
if (!project.value) return alert('Project not loaded');
|
||||
navigator.clipboard.writeText((project.value._id).toString());
|
||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
|
||||
function showAnomalyInfoAlert() {
|
||||
createAlert('AI Anomaly Detector info',
|
||||
`Anomaly detector is running. It helps you detect a spike in visits or events, it could mean an
|
||||
attack or simply higher traffic due to good performance. Additionally, it can detect if someone is
|
||||
stealing parts of your website and hosting a duplicate version—an unfortunately common practice.
|
||||
Litlyx will notify you via email with actionable advices`,
|
||||
'far fa-shield',
|
||||
10000
|
||||
)
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full px-6 py-2 lg:py-6 font-bold text-text-sub/40 flex flex-col xl:flex-row text-lg lg:text-2xl gap-2 xl:gap-12">
|
||||
class="w-full px-6 pb-2 lg:pb-6 font-bold text-text-sub/40 flex flex-col xl:flex-row text-lg gap-2 xl:gap-12 lg:text-2xl">
|
||||
|
||||
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
|
||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||
<div> {{ onlineUsers }} Online users</div>
|
||||
<div class="poppins font-medium text-[.9rem]"> {{ onlineUsers.data }} Online users</div>
|
||||
</div>
|
||||
|
||||
<div class="grow"></div>
|
||||
|
||||
<div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row">
|
||||
<div>Project:</div>
|
||||
<div class="text-text/90"> {{ activeProject?.name || 'Loading...' }} </div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row md:gap-2 items-center md:justify-start">
|
||||
<div>Project id:</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-text/90 text-[.9rem] lg:text-2xl">
|
||||
{{ activeProject?._id || 'Loading...' }}
|
||||
</div>
|
||||
<div class="flex items-center ml-3">
|
||||
<i @click="copyProjectId()" class="far fa-copy hover:text-text cursor-pointer text-[1.2rem]"></i>
|
||||
</div>
|
||||
<!-- <div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row">
|
||||
<div class="poppins font-medium text-lyx-text-darker text-[.9rem]">Project:</div>
|
||||
<div class="text-lyx-text poppins font-medium text-[.9rem]"> {{ project?.name || 'Loading...' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row md:gap-2 items-center md:justify-start">
|
||||
<div class="poppins font-medium text-lyx-text-darker text-[.9rem]">Project id:</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-lyx-text poppins font-medium text-[.9rem]">
|
||||
{{ project?._id || 'Loading...' }}
|
||||
</div>
|
||||
<div class="flex items-center ml-3">
|
||||
<i @click="copyProjectId()"
|
||||
class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[.9rem]"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div v-if="!selfhosted" class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
|
||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||
<div class="poppins font-regular text-[.9rem]"> AI Anomaly Detector </div>
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-info-circle text-[.9rem] hover:text-lyx-primary cursor-pointer"
|
||||
@click="showAnomalyInfoAlert"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,29 +2,45 @@
|
||||
import { onMounted } from 'vue';
|
||||
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 }>();
|
||||
|
||||
async function loadData() {
|
||||
const response = await useTimeline('visits', props.slice);
|
||||
if (!response) return;
|
||||
data.value = response.map(e => e.count);
|
||||
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
ready.value = true;
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
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(), props.slice));
|
||||
return { data, labels }
|
||||
}
|
||||
|
||||
const body = computed(() => {
|
||||
return {
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to,
|
||||
slice: props.slice
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const visitsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
watch(props, async () => { await loadData(); });
|
||||
})
|
||||
visitsData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AdvancedLineChart v-if="ready" :data="data" :labels="labels" color="#5655d7"></AdvancedLineChart>
|
||||
<div v-if="visitsData.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="!visitsData.pending.value" :data="visitsData.data.value?.data || []"
|
||||
:labels="visitsData.data.value?.labels || []" color="#5655d7">
|
||||
</AdvancedLineChart>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,61 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { VisitsWebsiteAggregated } from '~/server/api/metrics/[project_id]/data/websites';
|
||||
|
||||
const { data: websites, pending, refresh } = useWebsitesData();
|
||||
|
||||
const currentViewData = ref<(VisitsWebsiteAggregated[] | null)>(websites.value);
|
||||
|
||||
watch(pending, () => {
|
||||
currentViewData.value = websites.value;
|
||||
})
|
||||
|
||||
const isPagesView = ref<boolean>(false);
|
||||
const isLoading = ref<boolean>(false);
|
||||
|
||||
async function showDetails(website: string) {
|
||||
if (isPagesView.value == true) return;
|
||||
isLoading.value = true;
|
||||
isPagesView.value = true;
|
||||
|
||||
const { data: pagesData, pending } = usePagesData(website, 10);
|
||||
|
||||
watch(pending, () => {
|
||||
currentViewData.value = pagesData.value;
|
||||
isLoading.value = false;
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goToView() {
|
||||
router.push('/dashboard/visits');
|
||||
}
|
||||
|
||||
function setDefaultData() {
|
||||
currentViewData.value = websites.value;
|
||||
isPagesView.value = false;
|
||||
}
|
||||
|
||||
async function dataReload() {
|
||||
await refresh();
|
||||
setDefaultData();
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<DashboardBarsCard :hideShowMore="true" @showGeneral="setDefaultData()" @showRawData="goToView()"
|
||||
@dataReload="dataReload()" @showDetails="showDetails" :data="currentViewData || []"
|
||||
:loading="pending || isLoading" :label="isPagesView ? 'Top pages' : 'Top Websites'"
|
||||
:sub-label="isPagesView ? 'Page' : 'Website'"
|
||||
:desc="isPagesView ? 'Most visited pages' : 'Most visited website in this project'"
|
||||
:interactive="!isPagesView" :rawButton="!isLiveDemo()" :isDetailView="isPagesView">
|
||||
</DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
113
dashboard/components/dialog/CreateSnapshot.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { closeDialog } = useCustomDialog();
|
||||
|
||||
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||
|
||||
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() })
|
||||
|
||||
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 currentColor = ref<string>("#5680F8");
|
||||
|
||||
const colorpicker = ref<HTMLInputElement | null>(null);
|
||||
|
||||
function showColorPicker() {
|
||||
colorpicker.value?.click();
|
||||
}
|
||||
|
||||
function onColorChange() {
|
||||
currentColor.value = colorpicker.value?.value || '#000000';
|
||||
}
|
||||
|
||||
const snapshotName = ref<string>("");
|
||||
|
||||
const { updateSnapshots } = useSnapshot();
|
||||
const { createAlert } = useAlert()
|
||||
|
||||
async function confirmSnapshot() {
|
||||
await $fetch("/api/snapshot/create", {
|
||||
method: 'POST',
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }).value,
|
||||
body: JSON.stringify({
|
||||
name: snapshotName.value,
|
||||
color: currentColor.value,
|
||||
from: startOfDay(selected.value.start),
|
||||
to: endOfDay(selected.value.end)
|
||||
})
|
||||
});
|
||||
|
||||
await updateSnapshots();
|
||||
closeDialog();
|
||||
createAlert('Snapshot created', 'Snapshot created successfully', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
|
||||
<div class="poppins text-center">
|
||||
Create a snapshot
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex items-center gap-2">
|
||||
<div :style="`background-color: ${currentColor};`" @click="showColorPicker"
|
||||
class="w-6 h-6 rounded-full aspect-[1/1] relative cursor-pointer">
|
||||
<input @input="onColorChange" ref="colorpicker" class="relative w-0 h-0 z-[-100]" type="color">
|
||||
</div>
|
||||
<div class="grow">
|
||||
<LyxUiInput placeholder="Snapshot name" v-model="snapshotName" class="px-4 py-1 w-full"></LyxUiInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 justify-center flex w-full">
|
||||
<UPopover class="w-full" :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="grow"></div>
|
||||
<div class="flex items-center justify-around gap-4">
|
||||
<LyxUiButton @click="closeDialog()" type="secondary" class="w-full text-center">
|
||||
Cancel
|
||||
</LyxUiButton>
|
||||
<LyxUiButton @click="confirmSnapshot()" type="primary" class="w-full text-center"
|
||||
:disabled="snapshotName.length == 0">
|
||||
Confirm
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
82
dashboard/components/dialog/DeleteDomainData.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const emit = defineEmits(['success', 'cancel'])
|
||||
|
||||
const props = defineProps<{
|
||||
buttonType: string,
|
||||
message: string,
|
||||
deleteData: { isAll: boolean, visits: boolean, sessions: boolean, events: boolean, domain: string }
|
||||
}>();
|
||||
|
||||
const isDone = ref<boolean>(false);
|
||||
const canDelete = ref<boolean>(false);
|
||||
|
||||
async function deleteData() {
|
||||
|
||||
try {
|
||||
if (props.deleteData.isAll) {
|
||||
await $fetch('/api/settings/delete_all', {
|
||||
method: 'DELETE',
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }).value,
|
||||
})
|
||||
} else {
|
||||
await $fetch('/api/settings/delete_domain', {
|
||||
method: 'DELETE',
|
||||
headers: useComputedHeaders({ useSnapshotDates: false, custom: { 'Content-Type': 'application/json' } }).value,
|
||||
body: JSON.stringify({
|
||||
domain: props.deleteData.domain,
|
||||
visits: props.deleteData.visits,
|
||||
sessions: props.deleteData.sessions,
|
||||
events: props.deleteData.events,
|
||||
})
|
||||
})
|
||||
}
|
||||
} catch (ex) {
|
||||
alert('Something went wrong');
|
||||
console.error(ex);
|
||||
}
|
||||
|
||||
isDone.value = true;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal :ui="{
|
||||
strategy: 'override',
|
||||
overlay: {
|
||||
background: 'bg-lyx-background/85'
|
||||
},
|
||||
background: 'bg-lyx-widget',
|
||||
ring: 'border-solid border-[1px] border-[#262626]'
|
||||
}">
|
||||
<div class="h-full flex flex-col gap-2 p-4">
|
||||
|
||||
<div class="font-semibold text-[1.2rem]"> {{ isDone ? "Data Deletion Scheduled" : "Are you sure ?" }}</div>
|
||||
|
||||
<div v-if="!isDone">
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
<div v-if="isDone">
|
||||
Your data deletion request is being processed and will be reflected in your project dashboard within a
|
||||
few minutes.
|
||||
</div>
|
||||
|
||||
<div class="grow"></div>
|
||||
|
||||
<div v-if="!isDone">
|
||||
<UCheckbox v-model="canDelete" label="Confirm data delete"></UCheckbox>
|
||||
</div>
|
||||
|
||||
<div v-if="!isDone" class="flex justify-end gap-2">
|
||||
<LyxUiButton type="secondary" @click="emit('cancel')"> Cancel </LyxUiButton>
|
||||
<LyxUiButton :disabled="!canDelete" @click="canDelete ? deleteData() : () => { }" :type="buttonType"> Confirm </LyxUiButton>
|
||||
</div>
|
||||
|
||||
<div v-if="isDone" class="flex justify-end w-full">
|
||||
<LyxUiButton type="secondary" @click="emit('success')"> Dismiss </LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</UModal>
|
||||
</template>
|
||||
56
dashboard/components/dialog/Feedback.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
const { close } = useModal()
|
||||
|
||||
const text = ref<string>("");
|
||||
|
||||
async function sendFeedback() {
|
||||
if (text.value.length < 5) return;
|
||||
try {
|
||||
|
||||
const res = await $fetch('/api/feedback/add', {
|
||||
headers: useComputedHeaders({
|
||||
useSnapshotDates: false,
|
||||
custom: { 'Content-Type': 'application/json' }
|
||||
}).value,
|
||||
method:'POST',
|
||||
body: JSON.stringify({ text: text.value })
|
||||
});
|
||||
|
||||
createAlert('Success', 'Feedback sent successfully.', 'far fa-circle-check', 5000);
|
||||
|
||||
close();
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
createAlert('Error', 'Error sending feedback. Please try again later', 'far fa-triangle-exclamation', 5000);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal :ui="{
|
||||
strategy: 'override',
|
||||
overlay: {
|
||||
background: 'bg-lyx-background/85'
|
||||
},
|
||||
background: 'bg-lyx-widget',
|
||||
ring: 'border-solid border-[1px] border-[#262626]'
|
||||
}">
|
||||
<div class="h-full flex flex-col gap-2 p-4">
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div> Share everything with us. </div>
|
||||
<textarea v-model="text" placeholder="Leave your feedback"
|
||||
class="p-2 w-full h-[8rem] resize-none rounded-md outline outline-[2px] outline-[#3a3f47]"></textarea>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>Need help ? Check the docs <a href="https://docs.litlyx.com" target="_blank"
|
||||
class="text-blue-500">here</a> </div>
|
||||
<LyxUiButton :disabled="text.length < 5" @click="sendFeedback()" type="primary"> Send </LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UModal>
|
||||
|
||||
</template>
|
||||
13
dashboard/components/drawer/Docs.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const emits = defineEmits<{
|
||||
(evt: 'onCloseClick'): void
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<iframe class="w-full h-full" src="https://docs.litlyx.com/introduction" frameborder="0"></iframe>
|
||||
</div>
|
||||
</template>
|
||||
20
dashboard/components/drawer/Generic.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const emits = defineEmits<{ (evt: 'onCloseClick'): void }>();
|
||||
|
||||
const { drawerComponent } = useDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 overflow-y-auto">
|
||||
|
||||
<div @click="$emit('onCloseClick')"
|
||||
class="cursor-pointer fixed top-4 right-4 rounded-full bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
|
||||
<i class="fas fa-close text-[1.6rem]"></i>
|
||||
</div>
|
||||
|
||||
<Component v-if="drawerComponent" :is="drawerComponent"></Component>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
215
dashboard/components/drawer/Pricing.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PricingCardProp } from '../pricing/PricingCardGeneric.vue';
|
||||
|
||||
|
||||
const { data: planData, refresh: refreshPlanData } = useFetch('/api/project/plan', {
|
||||
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
|
||||
});
|
||||
|
||||
function getPricingsData() {
|
||||
|
||||
const freePricing: PricingCardProp[] = [
|
||||
{
|
||||
title: 'Free',
|
||||
price: '€0 / mo',
|
||||
subs: [
|
||||
'Up to 5000 visits/events per month',
|
||||
|
||||
],
|
||||
features: [
|
||||
'Email support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 10',
|
||||
'Server type: SHARED',
|
||||
'Data retention: 2 Months'
|
||||
],
|
||||
cta: 'Start For Free now!',
|
||||
active: (planData.value?.premium_type || 0) == 0,
|
||||
isDowngrade: (planData.value?.premium_type || 0) > 0,
|
||||
planId: 0
|
||||
},
|
||||
]
|
||||
|
||||
const customPricing: PricingCardProp[] = [
|
||||
{
|
||||
title: 'Enterprise',
|
||||
price: 'Custom',
|
||||
subs: [
|
||||
'Unlimited visits/events per month',
|
||||
'Service Tailor-made on needs'
|
||||
],
|
||||
features: [
|
||||
'Priority support',
|
||||
'Server type: DEDICATED',
|
||||
'DB instance: DEDICATED',
|
||||
'Dedicated operator',
|
||||
'White label',
|
||||
'Custom Data Aggregation'
|
||||
],
|
||||
cta: 'Let\'s Talk!',
|
||||
link: 'mailto:help@litlyx.com',
|
||||
active: false,
|
||||
isDowngrade: false,
|
||||
planId: -1
|
||||
}
|
||||
]
|
||||
|
||||
const slidePricings: PricingCardProp[] = [
|
||||
{
|
||||
title: 'Incubation',
|
||||
price: '€4,99 / mo',
|
||||
subs: [
|
||||
'Up to 50.000 visits/events per month',
|
||||
'0,00010€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 30',
|
||||
'Server type: SHARED',
|
||||
'Data retention: 6 Months'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
active: (planData.value?.premium_type || 0) == 101,
|
||||
isDowngrade: (planData.value?.premium_type || 0) > 101,
|
||||
planId: 101
|
||||
},
|
||||
{
|
||||
title: 'Acceleration',
|
||||
price: '€9,99 / mo',
|
||||
subs: [
|
||||
'Up to 150.000 visits/events per month',
|
||||
'0,00006€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 100',
|
||||
'Server type: SHARED',
|
||||
'Data retention: 9 Months'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
active: (planData.value?.premium_type || 0) == 102,
|
||||
isDowngrade: (planData.value?.premium_type || 0) > 102,
|
||||
planId: 102
|
||||
},
|
||||
{
|
||||
title: 'Growth',
|
||||
price: '€29,99 / mo',
|
||||
subs: [
|
||||
'Up to 500.000 visits/events per month',
|
||||
'0,000059€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 3.000',
|
||||
'Server type: SHARED',
|
||||
'Data retention: 1 Year'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
active: (planData.value?.premium_type || 0) == 103,
|
||||
isDowngrade: (planData.value?.premium_type || 0) > 103,
|
||||
planId: 103
|
||||
},
|
||||
{
|
||||
title: 'Expansion',
|
||||
price: '€59,99 / mo',
|
||||
subs: [
|
||||
'Up to 1.000.000 visits/events per month',
|
||||
'0,000059€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 5.000',
|
||||
'Server type: SHARED',
|
||||
'Data retention: 3 Year'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
active: (planData.value?.premium_type || 0) == 104,
|
||||
isDowngrade: (planData.value?.premium_type || 0) > 104,
|
||||
planId: 104
|
||||
},
|
||||
{
|
||||
title: 'Scaling',
|
||||
price: '€99,99 / mo',
|
||||
subs: [
|
||||
'Up to 2.500.000 visits/events per month',
|
||||
'0,000039€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 10.000',
|
||||
'Server type: DEDICATED',
|
||||
'Data retention: 7 Years'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
active: (planData.value?.premium_type || 0) == 105,
|
||||
isDowngrade: (planData.value?.premium_type || 0) > 105,
|
||||
planId: 105
|
||||
},
|
||||
{
|
||||
title: 'Unicorn',
|
||||
price: '€149,99 / mo',
|
||||
subs: [
|
||||
'Up to 5.000.000 visits/events per month',
|
||||
'0,000029€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 20.000',
|
||||
'Server type: DEDICATED',
|
||||
'Data retention: 8 Years'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
active: (planData.value?.premium_type || 0) == 106,
|
||||
isDowngrade: (planData.value?.premium_type || 0) > 106,
|
||||
planId: 106
|
||||
}
|
||||
]
|
||||
|
||||
return { freePricing, customPricing, slidePricings }
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 overflow-y-auto">
|
||||
|
||||
<div class="flex gap-8 mt-10 h-max xl:flex-row flex-col">
|
||||
<PricingCardGeneric class="flex-1" :datas="getPricingsData().freePricing"></PricingCardGeneric>
|
||||
<PricingCardGeneric class="flex-1" :datas="getPricingsData().slidePricings" :default-index="2">
|
||||
</PricingCardGeneric>
|
||||
<PricingCardGeneric class="flex-1" :datas="getPricingsData().customPricing"></PricingCardGeneric>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-10 flex-col xl:flex-row">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="poppins text-[2rem] font-semibold">
|
||||
Do you need help ?
|
||||
</div>
|
||||
<div class="poppins text-[1.2rem] text-text/90">
|
||||
We respond in max. 1-2 days
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="rounded-lg px-10 py-3 bg-[#151515]">
|
||||
<a href="mailto:help@litlyx.com" class="poppins text-[1.3rem]">
|
||||
help@litlyx.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
161
dashboard/components/events/EventsFunnelChart.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js';
|
||||
import { defineChartComponent } from 'vue-chart-3';
|
||||
|
||||
const FunnelChart = defineChartComponent('funnel', 'funnel');
|
||||
|
||||
const chartOptions = ref<ChartOptions<'funnel'>>({
|
||||
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<'funnel'>>({
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
'#5680F877',
|
||||
"#6bbbe377",
|
||||
"#a6d5cb77",
|
||||
"#fae0b977",
|
||||
"#f28e8e77",
|
||||
"#e3a7e477",
|
||||
"#c4a8e177",
|
||||
"#8cc1d877",
|
||||
"#f9c2cd77",
|
||||
"#b4e3b277",
|
||||
"#ffdfba77",
|
||||
"#e9c3b577",
|
||||
"#d5b8d677",
|
||||
"#add7f677",
|
||||
"#ffd1dc77",
|
||||
"#ffe7a177",
|
||||
"#a8e6cf77",
|
||||
"#d4a5a577",
|
||||
"#f3d6e477",
|
||||
"#c3aed677"
|
||||
],
|
||||
// borderColor: '#0000CC',
|
||||
// borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
hoverBackgroundColor: '#26262677',
|
||||
// hoverBorderColor: 'white',
|
||||
// hoverBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
// const c = document.createElement('canvas');
|
||||
// const ctx = c.getContext("2d");
|
||||
// let gradient: any = `${'#0000CC'}22`;
|
||||
// if (ctx) {
|
||||
// gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
// gradient.addColorStop(0, `${'#0000CC'}99`);
|
||||
// gradient.addColorStop(0.35, `${'#0000CC'}66`);
|
||||
// gradient.addColorStop(1, `${'#0000CC'}22`);
|
||||
// } else {
|
||||
// console.warn('Cannot get context for gradient');
|
||||
// }
|
||||
|
||||
// chartData.value.datasets[0].backgroundColor = [gradient];
|
||||
|
||||
});
|
||||
|
||||
const eventsData = useFetch(`/api/data/events`, {
|
||||
headers: useComputedHeaders(), lazy: true
|
||||
});
|
||||
|
||||
const enabledEvents = ref<string[]>([]);
|
||||
|
||||
async function onEventCheck(eventName: string) {
|
||||
const index = enabledEvents.value.indexOf(eventName);
|
||||
if (index == -1) {
|
||||
enabledEvents.value.push(eventName);
|
||||
} else {
|
||||
enabledEvents.value.splice(index, 1);
|
||||
}
|
||||
|
||||
|
||||
chartData.value.labels = enabledEvents.value;
|
||||
chartData.value.datasets[0].data = [];
|
||||
|
||||
for (const enabledEvent of enabledEvents.value) {
|
||||
const target = (eventsData.data.value ?? []).find(e => e._id == enabledEvent);
|
||||
chartData.value.datasets[0].data.push(target?.count || 0);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<CardTitled title="Funnel"
|
||||
sub="Monitor and analyze the actions your users are performing on your platform to gain insights into their behavior and optimize the user experience">
|
||||
<div class="flex gap-2 justify-between lg:flex-row flex-col">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="min-w-[20rem] text-lyx-text-darker">
|
||||
Select two or more events
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div v-for="event of eventsData.data.value">
|
||||
<UCheckbox color="secondary" @change="onEventCheck(event._id)"
|
||||
:value="enabledEvents.includes(event._id)" :label="event._id">
|
||||
</UCheckbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grow">
|
||||
<FunnelChart :chart-data="chartData" :options="chartOptions"> </FunnelChart>
|
||||
</div>
|
||||
</div>
|
||||
</CardTitled>
|
||||
</template>
|
||||
@@ -1,16 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const eventNames = ref<string[]>([]);
|
||||
const eventNames = await useFetch<string[]>(`/api/data/events_data/names`, {
|
||||
headers: useComputedHeaders()
|
||||
});
|
||||
|
||||
const selectedEventName = ref<string>();
|
||||
const metadataFields = ref<string[]>([]);
|
||||
const selectedMetadataField = ref<string>();
|
||||
const metadataFieldGrouped = ref<any[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
eventNames.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/names`, signHeaders());
|
||||
});
|
||||
|
||||
watch(selectedEventName, () => {
|
||||
getMetadataFields();
|
||||
@@ -21,17 +20,36 @@ watch(selectedMetadataField, () => {
|
||||
});
|
||||
|
||||
async function getMetadataFields() {
|
||||
metadataFields.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/metadata_fields?name=${selectedEventName.value}`, signHeaders());
|
||||
metadataFields.value = await $fetch<string[]>(`/api/data/events_data/metadata_fields?name=${selectedEventName.value}`, {
|
||||
headers: useComputedHeaders().value
|
||||
});
|
||||
selectedMetadataField.value = undefined;
|
||||
currentSearchText.value = "";
|
||||
}
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot();
|
||||
|
||||
async function getMetadataFieldGrouped() {
|
||||
if (!selectedMetadataField.value) return;
|
||||
metadataFieldGrouped.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/metadata_field_group?name=${selectedEventName.value}&field=${selectedMetadataField.value}`, signHeaders());
|
||||
|
||||
|
||||
const queryParams: Record<string, any> = {
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to,
|
||||
name: selectedEventName.value,
|
||||
field: selectedMetadataField.value
|
||||
}
|
||||
|
||||
const queryParamsString = Object.keys(queryParams).map((key) => `${key}=${queryParams[key]}`).join('&');
|
||||
|
||||
metadataFieldGrouped.value = await $fetch<string[]>(`/api/data/events_data/metadata_field_group?${queryParamsString}`, {
|
||||
headers: useComputedHeaders().value
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
const metadataFieldGroupedFiltered = computed(() => {
|
||||
if (currentSearchText.value.length == 0) return metadataFieldGrouped.value;
|
||||
return metadataFieldGrouped.value.filter(e => {
|
||||
@@ -56,11 +74,80 @@ const canSearch = computed(() => {
|
||||
|
||||
<CardTitled title="Event metadata analyzer" sub="Filter events metadata fields to analyze them" class="w-full p-4">
|
||||
|
||||
<div class="p-2 flex flex-col">
|
||||
<div class="">
|
||||
|
||||
<LyxUiCard class="h-full w-full flex gap-2">
|
||||
|
||||
<div class="flex-[2]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<USelectMenu :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" searchable searchable-placeholder="Search an event..." class="w-full"
|
||||
placeholder="Select an event" :options="eventNames.data.value || []"
|
||||
v-model="selectedEventName">
|
||||
</USelectMenu>
|
||||
|
||||
<USelectMenu :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" searchable searchable-placeholder="Search a field..." class="w-full"
|
||||
placeholder="Select a field" :options="metadataFields" v-model="selectedMetadataField">
|
||||
</USelectMenu>
|
||||
</div>
|
||||
|
||||
<div class="text-lyx-text-darker poppins mt-4 flex items-center gap-4 lg:flex-row flex-col">
|
||||
<div class="w-[10rem]">
|
||||
Search results: {{ metadataFieldGroupedFiltered.length }}
|
||||
</div>
|
||||
<div v-if="canSearch" class="h-full flex items-center text-[1.2rem]">
|
||||
|
||||
<div class="bg-lyx-widget-light flex items-center rounded-md pl-4">
|
||||
<div><i class="far fa-search"></i></div>
|
||||
<input class="bg-transparent px-4 py-2 text-[1rem] outline-none" type="text"
|
||||
placeholder="Filter by metadata name" v-model="currentSearchText">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 lg:mt-4 mt-10">
|
||||
|
||||
<div class="bg-lyx-widget-light text-lyx-text-dark px-3 py-2 rounded-md w-fit"
|
||||
v-for="item of metadataFieldGroupedFiltered">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div> {{ item._id || 'OLD_EVENTS' }} </div>
|
||||
<div class="px-1"> {{ item.count }} </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- <div class="border-solid border-[#212121] border-l-[1px]"></div> -->
|
||||
|
||||
<!-- <div class="flex-[1]">
|
||||
<div class="poppins font-semibold"> </div>
|
||||
</div> -->
|
||||
|
||||
</LyxUiCard>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- <div class="p-2 flex flex-col">
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<USelectMenu searchable searchable-placeholder="Search an event..." class="w-full"
|
||||
placeholder="Select an event" :options="eventNames" v-model="selectedEventName">
|
||||
placeholder="Select an event" :options="eventNames.data.value || []" v-model="selectedEventName">
|
||||
</USelectMenu>
|
||||
|
||||
<USelectMenu v-if="metadataFields.length > 0" searchable searchable-placeholder="Search a field..."
|
||||
@@ -85,17 +172,11 @@ const canSearch = computed(() => {
|
||||
Search results: {{ metadataFieldGroupedFiltered.length }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div v-for="item of metadataFieldGroupedFiltered">
|
||||
<div class="flex gap-2">
|
||||
<div> {{ item._id || 'OLD_EVENTS' }} </div>
|
||||
<div> {{ item.count }} </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
</CardTitled>
|
||||
|
||||
|
||||
|
||||
@@ -1,20 +1,47 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
const datasets = ref<any[]>([]);
|
||||
const labels = ref<string[]>([]);
|
||||
const ready = ref<boolean>(false);
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
|
||||
const props = defineProps<{ slice: SliceName }>();
|
||||
const props = defineProps<{ slice: Slice }>();
|
||||
const slice = computed(() => props.slice);
|
||||
|
||||
async function loadData() {
|
||||
const response = await useTimelineDataRaw('events_stacked', props.slice);
|
||||
if (!response) return;
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const fixed = fixMetrics(response, props.slice, { advanced: true, advancedGroupKey: 'name' });
|
||||
function transformResponse(input: { _id: string, name: string, count: number }[]) {
|
||||
|
||||
const fixed = fixMetrics({
|
||||
data: input,
|
||||
from: input[0]._id,
|
||||
to: safeSnapshotDates.value.to
|
||||
},
|
||||
slice.value,
|
||||
{ advanced: true, advancedGroupKey: 'name' });
|
||||
|
||||
const parsedDatasets: any[] = [];
|
||||
const colors = ['#5655d0', '#6bbbe3', '#a6d5cb', '#fae0b9'];
|
||||
|
||||
const colors = [
|
||||
"#5655d0",
|
||||
"#6bbbe3",
|
||||
"#a6d5cb",
|
||||
"#fae0b9",
|
||||
"#f28e8e",
|
||||
"#e3a7e4",
|
||||
"#c4a8e1",
|
||||
"#8cc1d8",
|
||||
"#f9c2cd",
|
||||
"#b4e3b2",
|
||||
"#ffdfba",
|
||||
"#e9c3b5",
|
||||
"#d5b8d6",
|
||||
"#add7f6",
|
||||
"#ffd1dc",
|
||||
"#ffe7a1",
|
||||
"#a8e6cf",
|
||||
"#d4a5a5",
|
||||
"#f3d6e4",
|
||||
"#c3aed6"
|
||||
];
|
||||
|
||||
for (let i = 0; i < fixed.allKeys.length; i++) {
|
||||
const line: any = {
|
||||
@@ -28,25 +55,58 @@ async function loadData() {
|
||||
if (!target) return;
|
||||
line.data.push(target.value);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
datasets.value = parsedDatasets;
|
||||
labels.value = fixed.labels;
|
||||
ready.value = true;
|
||||
return {
|
||||
datasets: parsedDatasets,
|
||||
labels: fixed.labels
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
watch(props, async () => { await loadData(); });
|
||||
const errorData = ref<{ errored: boolean, text: string }>({
|
||||
errored: false,
|
||||
text: ''
|
||||
})
|
||||
|
||||
|
||||
function onResponseError(e: any) {
|
||||
console.log('ON RESPONSE ERROR')
|
||||
errorData.value = { errored: true, text: e.response._data.message ?? 'Generic error' }
|
||||
}
|
||||
|
||||
function onResponse(e: any) {
|
||||
console.log('ON RESPONSE')
|
||||
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
|
||||
}
|
||||
|
||||
const eventsStackedData = useFetch(`/api/timeline/events_stacked`, {
|
||||
lazy: true, immediate: false,
|
||||
transform: transformResponse,
|
||||
headers: useComputedHeaders({ slice }),
|
||||
onResponseError,
|
||||
onResponse
|
||||
});
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
eventsStackedData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AdvancedStackedBarChart v-if="ready" :datasets="datasets" :labels="labels">
|
||||
<div class="h-full">
|
||||
<div v-if="eventsStackedData.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>
|
||||
<AdvancedStackedBarChart v-if="!eventsStackedData.pending.value && !errorData.errored"
|
||||
:datasets="eventsStackedData.data.value?.datasets || []"
|
||||
:labels="eventsStackedData.data.value?.labels || []">
|
||||
</AdvancedStackedBarChart>
|
||||
<div v-if="errorData.errored" class="flex items-center justify-center py-8 h-full">
|
||||
{{ errorData.text }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,57 +1,89 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const eventNames = await useFetch<string[]>(`/api/data/events_data/names`, {
|
||||
headers: useComputedHeaders()
|
||||
});
|
||||
|
||||
const eventNames = ref<string[]>([]);
|
||||
const selectedEventName = ref<string>();
|
||||
|
||||
onMounted(async () => {
|
||||
eventNames.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/names`, signHeaders());
|
||||
});
|
||||
|
||||
const userFlowData = ref<any>();
|
||||
const analyzing = ref<boolean>(false);
|
||||
|
||||
async function analyzeEvent() {
|
||||
const { safeSnapshotDates } = useSnapshot();
|
||||
|
||||
async function getUserFlowData() {
|
||||
userFlowData.value = undefined;
|
||||
analyzing.value = true;
|
||||
userFlowData.value = await $fetch(`/api/metrics/${activeProject.value?._id.toString()}/events/flow_from_name?name=${selectedEventName.value}`, signHeaders());
|
||||
|
||||
const queryParams: Record<string, any> = {
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to,
|
||||
name: selectedEventName.value
|
||||
}
|
||||
|
||||
const queryParamsString = Object.keys(queryParams).map((key) => `${key}=${queryParams[key]}`).join('&');
|
||||
|
||||
userFlowData.value = await $fetch(`/api/data/events_data/flow_from_name?${queryParamsString}`, {
|
||||
headers: useComputedHeaders().value
|
||||
});
|
||||
|
||||
analyzing.value = false;
|
||||
}
|
||||
|
||||
async function analyzeEvent() {
|
||||
getUserFlowData();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardTitled title="Event User Flow"
|
||||
sub="Track your user's journey from external links to custom events within your platform." class="w-full p-4">
|
||||
sub="Track your user's journey from external links to in-app events, maintaining a complete view of their path from entry to engagement."
|
||||
class="w-full p-4">
|
||||
|
||||
<div class="p-2 flex flex-col gap-3">
|
||||
<USelectMenu searchable searchable-placeholder="Search an event..." class="w-full"
|
||||
placeholder="Select an event" :options="eventNames" v-model="selectedEventName">
|
||||
</USelectMenu>
|
||||
<div v-if="selectedEventName && !analyzing" class="flex justify-center">
|
||||
<div @click="analyzeEvent()"
|
||||
class="bg-bg w-fit px-8 py-2 poppins rounded-lg hover:bg-bg/80 cursor-pointer">
|
||||
Analyze
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div class="py-2 flex items-center gap-3">
|
||||
<USelectMenu :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" searchable searchable-placeholder="Search an event..." class="w-full" placeholder="Select an event"
|
||||
:options="eventNames.data.value || []" v-model="selectedEventName">
|
||||
</USelectMenu>
|
||||
<div v-if="selectedEventName && !analyzing" class="flex justify-center">
|
||||
<LyxUiButton @click="analyzeEvent()" type="primary" class="w-fit px-8 py-1">
|
||||
Analyze
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="analyzing">
|
||||
Analyzing...
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2" v-if="userFlowData">
|
||||
<div class="flex gap-4 items-center bg-bg py-1 px-2 rounded-lg" v-for="(count, referrer) in userFlowData">
|
||||
<div class="w-5 h-5 flex items-center justify-center">
|
||||
<img :src="`https://s2.googleusercontent.com/s2/favicons?domain=${referrer}&sz=64`" :alt="'referrer'">
|
||||
</div>
|
||||
<div> {{ referrer }} </div>
|
||||
<div class="grow"></div>
|
||||
<div> {{ count }} </div>
|
||||
<div
|
||||
class="backdrop-blur-[1px] z-[20] w-full h-full flex items-center justify-center font-bold rockmann">
|
||||
<i
|
||||
class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2" v-if="userFlowData">
|
||||
<div class="flex gap-4 items-center bg-bg py-2 px-2 bg-lyx-widget-light rounded-lg"
|
||||
v-for="(count, referrer) in userFlowData">
|
||||
<div class="w-5 h-5 flex items-center justify-center">
|
||||
<img :src="`https://s2.googleusercontent.com/s2/favicons?domain=${referrer}&sz=64`"
|
||||
:alt="'referrer'">
|
||||
</div>
|
||||
<div> {{ referrer }} </div>
|
||||
<div class="grow"></div>
|
||||
<div> {{ count.toFixed(2).replace('.', ',') }} % </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</CardTitled>
|
||||
</template>
|
||||
@@ -1,82 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
export type PricingCardProp = {
|
||||
title: string,
|
||||
cost: string,
|
||||
features: string[],
|
||||
desc: string,
|
||||
active: boolean,
|
||||
planId: number,
|
||||
isDowngrade: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{ data: PricingCardProp }>();
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
async function onUpgradeClick() {
|
||||
const res = await $fetch<string>(`/api/pay/${activeProject.value?._id.toString()}/create`, {
|
||||
...signHeaders({ 'content-type': 'application/json' }),
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ planId: props.data.planId })
|
||||
})
|
||||
if (!res) alert('Something went wrong');
|
||||
window.open(res);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 bg-[#303030] rounded-xl pricing-card flex flex-col">
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="text-[1.1rem] font-semibold mb-4">
|
||||
{{ data.title }}
|
||||
</div>
|
||||
<div class="flex gap-1 items-end mb-2">
|
||||
<div class="text-[1.1rem] font-semibold">
|
||||
€{{ data.cost }}
|
||||
</div>
|
||||
<div class="text-text-sub text-[.9rem] mb-[.15rem]">
|
||||
per month
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="data.active" class="text-[1rem] bg-[#1f1f22] rounded-md py-2 text-center">
|
||||
Current active plan
|
||||
</div>
|
||||
<div @click="onUpgradeClick()" v-if="!data.active && !data.isDowngrade"
|
||||
class="cursor-pointer text-[1rem] font-semibold bg-[#3a3af5] rounded-md py-2 text-center">
|
||||
Upgrade
|
||||
</div>
|
||||
<div @click="onUpgradeClick()" v-if="!data.active && data.isDowngrade"
|
||||
class="cursor-pointer text-[1rem] font-semibold bg-[#1f1f22] text-red-400 rounded-md py-2 text-center">
|
||||
Downgrade
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-400 h-[1px] w-full my-4"></div>
|
||||
|
||||
<div class="flex flex-col gap-1 grow">
|
||||
<div class="flex gap-2 items-center" v-for="feature of data.features">
|
||||
<i class="fas fa-check"></i>
|
||||
<div>
|
||||
{{ feature }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-400 h-[1px] w-full my-4"></div>
|
||||
|
||||
<div class="text-text-sub text-[.9rem] h-[20%]">
|
||||
{{ data.desc }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pricing-card * {
|
||||
font-family: "Poppins";
|
||||
}
|
||||
</style>
|
||||
115
dashboard/components/pricing/PricingCardGeneric.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
export type PricingCardProp = {
|
||||
title: string,
|
||||
price: string,
|
||||
subs: string[],
|
||||
features: string[],
|
||||
cta: string,
|
||||
link?: string,
|
||||
isDowngrade: boolean,
|
||||
active: boolean,
|
||||
planId: number
|
||||
}
|
||||
|
||||
const props = defineProps<{ datas: PricingCardProp[], defaultIndex?: number }>();
|
||||
|
||||
const currentIndex = ref<number>(props.defaultIndex || 0);
|
||||
|
||||
const data = computed(() => {
|
||||
return props.datas[currentIndex.value];
|
||||
})
|
||||
|
||||
async function onUpgradeClick() {
|
||||
const res = await $fetch<string>(`/api/pay/create`, {
|
||||
headers: useComputedHeaders({
|
||||
useSnapshotDates: false,
|
||||
custom: {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
}).value,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ planId: data.value.planId })
|
||||
})
|
||||
if (!res) alert('Something went wrong');
|
||||
window.open(res);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative bg-[#151515] outline outline-[1px] outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]">
|
||||
|
||||
<div class="flex flex-col gap-3 text-center pt-3">
|
||||
<div v-if="data.active"
|
||||
class="absolute right-6 top-3 poppins text-[.75rem] bg-transparent border-[#262626] border-solid border-[1px] px-3 py-[.1rem] rounded-sm">
|
||||
Active
|
||||
</div>
|
||||
<div v-if="!data.active && data.title === 'Growth'"
|
||||
class="absolute right-6 top-3 poppins text-[.75rem] bg-[#fbbe244f] outline outline-[1px] outline-[#fbbf24] px-3 py-[.1rem] rounded-sm">
|
||||
Most popular
|
||||
</div>
|
||||
<div class="poppins text-xl font-light"> {{ data.title }} </div>
|
||||
<div class="poppins text-4xl font-medium"> {{ data.price }} </div>
|
||||
</div>
|
||||
|
||||
<div class="sep bg-[#262626] h-[1px] my-8"></div>
|
||||
|
||||
<div class="flex flex-col text-center h-[6rem] justify-center gap-2">
|
||||
<div v-if="datas.length > 1">
|
||||
<URange :ui="{
|
||||
thumb: {
|
||||
color: 'text-[#5680f8]'
|
||||
},
|
||||
progress: {
|
||||
background: '!bg-[#5680f8]'
|
||||
}
|
||||
}" :min="0" :max="datas.length - 1" v-model="currentIndex">
|
||||
</URange>
|
||||
</div>
|
||||
<div :class="{ '!text-[.8rem] !text-lyx-text-darker': sub.includes('€') }" class="poppins text-[.9rem]"
|
||||
v-for="sub of data.subs">
|
||||
{{ sub }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sep bg-[#262626] h-[1px] my-8"></div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2" v-for="feature of data.features">
|
||||
<div class="h-6 w-6">
|
||||
<img class="w-full h-full" :src="'/check.png'" alt="Check">
|
||||
</div>
|
||||
<div>{{ feature }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex">
|
||||
|
||||
<div class="w-full flex" v-if="data.planId > -1">
|
||||
|
||||
|
||||
<LyxUiButton class="rounded-md py-2 w-full text-center" type="primary" @click="onUpgradeClick()"
|
||||
v-if="!data.active && !data.isDowngrade">
|
||||
Upgrade
|
||||
</LyxUiButton>
|
||||
|
||||
<LyxUiButton class="rounded-md py-2 w-full text-center" type="danger" @click="onUpgradeClick()"
|
||||
v-if="!data.active && data.isDowngrade">
|
||||
Downgrade
|
||||
</LyxUiButton>
|
||||
|
||||
</div>
|
||||
|
||||
<LyxUiButton v-if="data.planId === -1" :to="data.link || 'https://dashboard.litlyx.com'"
|
||||
class="rounded-md py-2 w-full text-center" type="primary">
|
||||
{{ data.cta }}
|
||||
</LyxUiButton>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,109 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PricingCardProp } from './PricingCard.vue';
|
||||
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const props = defineProps<{ currentSub: number }>();
|
||||
|
||||
|
||||
const starterTierCardData = ref<PricingCardProp>({
|
||||
title: 'STARTER',
|
||||
cost: '0',
|
||||
features: [
|
||||
"3K visits/events per month",
|
||||
"10 AI Interaction per month",
|
||||
"1 month data retention",
|
||||
"Limited reports",
|
||||
"1 Team member",
|
||||
"Limited Automatic Email Report",
|
||||
"Shared Server & DB",
|
||||
"Low priority email support",
|
||||
],
|
||||
desc: `Free project are not reliable and sometimes
|
||||
can experience some data loss.To have a
|
||||
dedicated server we suggest to upgrade the
|
||||
plan to an higher one!`,
|
||||
active: activeProject.value?.premium === false,
|
||||
isDowngrade: props.currentSub > 0,
|
||||
planId: 0
|
||||
});
|
||||
|
||||
const accelerationTierCardData = ref<PricingCardProp>({
|
||||
title: 'ACCELERATION',
|
||||
cost: '9,99',
|
||||
features: [
|
||||
"150K visits/events per month",
|
||||
"100 AI Interaction per month",
|
||||
"6 months data retention",
|
||||
"Limited reports",
|
||||
"1 Team member",
|
||||
"Limited Automatic Email Report",
|
||||
"Shared Server & DB",
|
||||
"Low priority email support"
|
||||
],
|
||||
desc: `Your project is entering a growth phase. We simplify data analysis for you. For more support, try our Expansion plan—it's worth it!`,
|
||||
active: activeProject.value?.premium_type === 1,
|
||||
isDowngrade: props.currentSub > 1,
|
||||
planId: 1
|
||||
});
|
||||
|
||||
const expansionTierCardData = ref<PricingCardProp>({
|
||||
title: 'EXPANSION',
|
||||
cost: '39,99',
|
||||
features: [
|
||||
"500K visits/events per month",
|
||||
"5000 AI Interaction per month",
|
||||
"2 years data retention",
|
||||
"Unlimited reports",
|
||||
"10 Team member",
|
||||
"Unlimited Automatic Email Report",
|
||||
"Dedicated Server & DB",
|
||||
"high priority email support"
|
||||
],
|
||||
desc: `We will support you with everything we can offer and give you the full power of our service. If you need more space and are growing, contact us for a custom offer!`,
|
||||
active: activeProject.value?.premium_type === 2,
|
||||
isDowngrade: props.currentSub > 2,
|
||||
planId: 2
|
||||
});
|
||||
|
||||
|
||||
const emits = defineEmits<{
|
||||
(evt: 'onCloseClick'): void
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 overflow-y-auto xl:overflow-y-hidden">
|
||||
|
||||
<div @click="$emit('onCloseClick')"
|
||||
class="cursor-pointer fixed top-4 right-4 rounded-full bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
|
||||
<i class="fas fa-close text-[1.6rem]"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-8 mt-10 h-max xl:flex-row flex-col">
|
||||
<PricingCard class="flex-1" :data="starterTierCardData"></PricingCard>
|
||||
<PricingCard class="flex-1" :data="accelerationTierCardData"></PricingCard>
|
||||
<PricingCard class="flex-1" :data="expansionTierCardData"></PricingCard>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-10 flex-col xl:flex-row">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="poppins text-[2rem] font-semibold">
|
||||
Do you need help ?
|
||||
</div>
|
||||
<div class="poppins text-[1.2rem] text-text/90">
|
||||
We respond in max. 1-2 days
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="rounded-lg px-10 py-3 bg-[#303030]">
|
||||
<a href="mailto:help@litlyx.com" class="poppins text-[1.3rem]">
|
||||
help@litlyx.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||