165 Commits

Author SHA1 Message Date
Emily
6b5d23566c fix SELFHOST env on docker-compose 2025-01-17 18:07:53 +01:00
Emily
dbcda95823 fix selfhost 2025-01-17 17:40:20 +01:00
Emily
fb89c87489 fix selfhost 2025-01-17 16:44:22 +01:00
Emily
b59eea47e9 active snapshot on creation 2025-01-17 16:44:16 +01:00
Emily
473331047d fix dockercompose 2025-01-16 18:21:59 +01:00
Emily
5af77ff63e update docker-compose 2025-01-16 18:16:16 +01:00
Emily
e6e2340432 fix lightmode 2025-01-16 16:47:35 +01:00
Emily
0b90c2fe3c add lightmode to alerts 2025-01-15 16:34:10 +01:00
Emily
a6d1797a4f add lightmode 2025-01-15 16:31:10 +01:00
Emily
d1abe1a91f change packages 2025-01-15 14:45:02 +01:00
Emily
b733cd2a68 navbar lightmode 2025-01-14 17:38:33 +01:00
Emily
88ebfc188c add password reset + password change 2025-01-13 17:01:34 +01:00
antonio
ab95772dd4 read-me 2025-01-13 15:28:20 +01:00
Emily
0d5dbc69ad update docker compose 2025-01-13 15:24:14 +01:00
Emily
8a359936d1 . 2025-01-04 18:16:37 +01:00
Emily
ffd2e96138 fix emails templates 2025-01-04 18:14:12 +01:00
Emily
b8e434be9a fix chat limit reached message 2025-01-04 15:52:32 +01:00
Emily
835ab6208e fix flags 2025-01-03 15:48:23 +01:00
Emily
617de36fec change admin panel 2025-01-03 15:39:40 +01:00
Emily
fb31fdcfff change export report button 2025-01-03 15:39:33 +01:00
Emily
745a332e56 add feedback 2024-12-27 16:49:21 +01:00
Emily
a10755f998 add tech stacks icons 2024-12-27 16:26:43 +01:00
Emily
46bca2f787 fix account delete 2024-12-27 15:39:28 +01:00
Emily
cb928977c3 Merge branch 'snapshot-rework' 2024-12-27 15:27:42 +01:00
Emily
3b5a46a64a add onboarding 2024-12-23 15:52:55 +01:00
Emily
7d05a9d157 fix chat limits update 2024-12-23 14:37:19 +01:00
Emily
edc897d62a add onboarding and feedback models 2024-12-20 16:31:34 +01:00
Emily
39c42e7bd5 fix cards index 2024-12-20 15:56:06 +01:00
Emily
7009a0ad02 better drawer 2024-12-19 16:46:03 +01:00
Emily
3f26f1ab68 add ai plugins 2024-12-19 16:45:53 +01:00
Emily
7082b88523 add canSend flag 2024-12-18 17:11:42 +01:00
Emily
29bae329b4 stop typer on chat change 2024-12-18 17:07:19 +01:00
Emily
f908b0b4a9 remove status on chat finish 2024-12-18 16:50:15 +01:00
Emily
b38363ddf5 fix ai message + add typer 2024-12-18 16:49:18 +01:00
Emily
68d362d1b3 fix function calling + add clear all on chats 2024-12-17 16:25:27 +01:00
Emily
0a9474d00c adjust ai 2024-12-16 16:57:52 +01:00
Emily
6307e09dc3 fix chat streaming + add "deleted" field to chats 2024-12-13 15:07:34 +01:00
Emily
f358bb9bb6 Add streaming to AI 2024-12-11 18:32:22 +01:00
Emily
23b8f7229a [NOT READY] fix dates + charts + ui 2024-12-09 17:57:50 +01:00
Emily
78f979d23a [NOT READY] fix granularity 2024-12-07 18:24:48 +01:00
Emily
ad8e9e1ead [NOT READY] start change aggregation timeline 2024-12-06 17:04:29 +01:00
Emily
06768b6cdc add selfhosted env + start fix dates 2024-12-05 17:30:28 +01:00
Emily
91f69baacd add selfhosted env + start fix dates 2024-12-05 17:30:02 +01:00
Emily
0964ec4250 update dockerfile 2024-12-05 14:51:43 +01:00
Emily
9ce2c89575 add dates to producer/consumer 2024-12-03 17:40:47 +01:00
Emily
b630bddef0 fix dates 2024-12-03 17:40:37 +01:00
Emily
30e428a8dc fix date sort 2024-12-02 15:53:15 +01:00
Emily
b700b96191 actionable chart + date service 2024-11-21 15:39:51 +01:00
Emily
606eb0b035 add snapshots and fix top cards following it 2024-11-20 16:43:52 +01:00
Emily
ec974c3599 merged lyxui into dashboard 2024-11-18 14:33:09 +01:00
Emily
4c811c160b restructure 2024-11-16 01:35:15 +01:00
Emily
e140585362 updated dependencies 2024-11-16 01:21:53 +01:00
Emily
7d56b7a6a2 restructure 2024-11-16 01:17:02 +01:00
Emily
070560c1e2 restructure 2024-11-16 01:14:05 +01:00
Emily
41037a01a1 Restrucure consumer + monorepo 2024-11-15 23:36:40 +01:00
Emily
caef67a0e1 . 2024-11-14 18:36:05 +01:00
Emily
5ac43dec6b . 2024-11-14 18:23:56 +01:00
Emily
9de299d841 adjust dashboard 2024-11-13 15:44:20 +01:00
Emily
2929b229c4 add domain wipe 2024-11-11 16:54:02 +01:00
Emily
f06d7d78fc add code redeem 2024-11-08 15:14:09 +01:00
Emily
4d7cfbb7b9 adding support for appsumo codes 2024-11-07 17:06:53 +01:00
Emily
b4c0620f17 adjust admin dashboard 2024-11-06 17:25:48 +01:00
Emily
b8c2e40f7a set 20 max projects 2024-11-06 16:29:25 +01:00
Emily
e866a1c22b add updated_at 2024-11-06 16:29:19 +01:00
Emily
f86a399840 fix snapshots 2024-11-01 15:47:43 +01:00
antonio
36c4406af2 changed asset 2024-10-29 18:03:34 +01:00
Antonio Verdiglione
b2afd585bb Merge pull request #19 from fr0st-iwnl/main
fix: correct language display in admin dashboard
2024-10-29 17:58:35 +01:00
Antonio Verdiglione
24ae9d0e0d Merge branch 'main' into main 2024-10-29 17:58:24 +01:00
Emily
b479ca1bbf committo tutto 2024-10-29 17:51:32 +01:00
Emily
0a748346c5 Fix responsiveness + other things 2024-10-29 15:51:30 +01:00
fr0st-iwnl
fa7880552a fix: language correction 2024-10-29 04:22:38 +02:00
Emily
06fb8bfab0 Merge branch 'main' of https://github.com/Litlyx/litlyx 2024-10-15 13:30:38 +02:00
Emily
a876d77d42 fix trends + stacked chart 2024-10-15 13:30:36 +02:00
Antonio Verdiglione
e6bb58693f Merge pull request #18 from eltociear/patch-2
docs: update README.md
2024-10-14 14:22:03 +02:00
Emily
00e63cc80b fix funnel chart + live demo 2024-10-14 14:14:23 +02:00
Ikko Eltociear Ashimine
e43f138945 docs: update README.md
enviroments -> environments
2024-10-11 08:18:09 +09:00
Emily
73309e7021 . 2024-10-10 15:54:14 +02:00
Emily
80e3b0caa9 add email login 2024-10-10 15:49:55 +02:00
Emily
0a7f2b58d0 improve 2024-10-09 19:35:07 +02:00
Emily
e953af2c1b fix 2024-10-09 15:30:59 +02:00
Emily
126296d28f . 2024-10-08 19:30:49 +02:00
Emily
8dd10deecc . 2024-10-08 19:30:11 +02:00
Emily
f22e65ccc5 fix pricing 2024-10-08 19:28:11 +02:00
Emily
dfbc64fe33 rewrite analyst UI 2024-10-08 19:23:30 +02:00
Emily
9568566361 rewrite litlyx 2024-10-08 18:47:30 +02:00
Emily
634cb641f1 fix project creation 2024-10-08 18:24:00 +02:00
Emily
204e1348b4 fix docs wfull 2024-10-08 18:22:14 +02:00
Emily
b73155a176 fix devices 2024-10-08 15:31:45 +02:00
Emily
62c72b3ff9 fix csv endpoint 2024-10-08 15:24:23 +02:00
Emily
79e956e930 rewrite litlyx 2024-10-08 15:12:04 +02:00
Emily
b27cacf4e6 rewrite 2024-10-07 15:26:57 +02:00
Emily
c2846ca595 rewrite settings + banners 2024-10-04 14:39:08 +02:00
Emily
e1953f2f9f Rewrite endpoints + bar cards 2024-10-03 15:07:16 +02:00
Emily
314660d8a3 change in progress 2024-10-02 17:05:34 +02:00
Emily
f516c53b7b Merge branch 'main' of https://github.com/Litlyx/litlyx 2024-10-01 14:25:58 +02:00
Emily
dad8c521ee fix ai + chart + events 2024-10-01 14:25:56 +02:00
antonio
089d1a418e updated read me 2024-09-30 19:13:15 +02:00
Emily
a08624b69b fix csv export 2024-09-30 17:08:23 +02:00
Emily
3ba6cd171b add bouncing rate + adjustments 2024-09-30 17:01:16 +02:00
Emily
1828edf98b remove lifetime deal 2024-09-29 15:09:35 +02:00
Emily
96c39dbba1 fix docker-compose 2024-09-28 14:24:47 +02:00
Emily
9403aebbb9 update dockercompose 2024-09-28 14:21:53 +02:00
Emily
69bb6fb03c fix cors on api 2024-09-28 13:42:40 +02:00
Emily
33b730e66b add cors + adjusting dockerfile 2024-09-28 13:37:10 +02:00
Emily
0ba44a406d removed old dirs + start dockerfile unification 2024-09-27 21:14:23 +02:00
Emily
3c77a727cd update 2024-09-27 20:33:49 +02:00
Emily
8e3ad2920f fix script url 2024-09-26 15:54:51 +02:00
Emily
f4401d74a2 fix ecosystem 2024-09-26 15:53:00 +02:00
Emily
375330bac4 add ecosystem 2024-09-26 15:51:59 +02:00
Emily
3b1ee0fd13 add security loop 2024-09-26 15:48:59 +02:00
Emily
f5edf187fd update security + chart events è anomaly service 2024-09-24 16:48:23 +02:00
Emily
5b7e93bcbb add cap to dates on slices 2024-09-23 15:01:14 +02:00
Emily
3b6a202538 better logging 2024-09-23 14:03:37 +02:00
Emily
cf1aa103e4 fix security page 2024-09-21 15:19:41 +02:00
Emily
4eeebaa0c3 better first interactions + bug fix 2024-09-21 15:14:46 +02:00
Emily
f285e92132 add secutiry 2024-09-19 15:44:27 +02:00
Emily
ac7ba7abd3 fix 2024-09-19 14:00:12 +02:00
Emily
3c59551f88 new consumer 2024-09-18 23:05:07 +02:00
Emily
628e471cec update consumers 2024-09-18 17:57:25 +02:00
Emily
0be3dbecbf Services rewrite 2024-09-18 17:43:04 +02:00
Emily
fa5a37ece2 . 2024-09-17 13:41:52 +02:00
Emily
db32afe741 better processed logs 2024-09-17 13:39:56 +02:00
Emily
e813b3246d remove console.log 2024-09-17 13:38:47 +02:00
Emily
86011c38ce add logger 2024-09-17 13:38:02 +02:00
Emily
fd5eca29cc remove test error 2024-09-16 20:39:07 +02:00
Emily
a591b43600 fixes 2024-09-16 20:09:32 +02:00
Emily
cebb45484c add logger 2024-09-16 20:09:15 +02:00
Emily
e4e2c2a42a fix anomaly 2024-09-16 20:09:07 +02:00
Emily
dfa1407102 fix anomaly service 2024-09-16 20:08:58 +02:00
Emily
e6adbf9c7b enchance ai 2024-09-16 15:37:18 +02:00
Emily
c3904ebd55 Add advanced ai 2024-09-16 01:03:49 +02:00
Emily
4c46a36c75 add anomaly + fix billing + add emails templates 2024-09-14 17:07:46 +02:00
Emily
c253846b86 fix email 2024-09-13 19:02:15 +02:00
Emily
e7c2dbf237 fix dates 2024-09-13 15:25:26 +02:00
Emily
525a371a6e . 2024-09-12 16:16:19 +02:00
Emily
6a9a698b7a add colors + fix billing page 2024-09-11 15:13:03 +02:00
Emily
4134d33dc4 fix pdf + admin panel 2024-09-10 16:59:34 +02:00
Emily
5172ad4f4d add api support 2024-09-09 14:43:27 +02:00
Emily
be45448288 add api keys 2024-09-08 15:51:03 +02:00
Emily
73739dde9d fix pricing + limits email + redis 2024-09-07 15:47:13 +02:00
Emily
30b3ed80e2 fix pricing + stripe payments 2024-09-05 16:56:21 +02:00
antonio
8e56069b1a fix on readme curl example 2024-09-05 13:14:23 +02:00
Antonio Verdiglione
3ecdec9ca9 Merge pull request #16 from art-santos/issue-15
fix: css bug in header
2024-09-05 11:54:14 +02:00
Arthur Santos
7b41a3ed0d fix: css bug in header 2024-09-04 09:28:56 -03:00
Emily
5804d7a73b fix support type from Discord to Slack 2024-09-04 14:01:06 +02:00
Emily
8b026099de fix dashboard + live demo 2024-09-04 14:00:03 +02:00
Emily
d7e18d570f fix userAgent device type 2024-09-04 13:59:53 +02:00
Emily
023f2b5f4a aggregation optimization 2024-09-02 18:37:02 +02:00
Emily
c003b655ec aggregation optimization 2024-09-02 18:36:52 +02:00
Emily
d499aa2f39 remove project_max text 2024-09-02 18:14:25 +02:00
Emily
944996eb15 add proper limit + csv lock 2024-09-02 15:24:29 +02:00
antonio
87b1f9caf9 improvements on readme 2024-09-01 14:49:44 +02:00
Emily
748894b946 . 2024-08-30 17:32:36 +02:00
Emily
01e8a9ab1d . 2024-08-30 17:30:18 +02:00
Emily
a2034551ec add log to stream loop 2024-08-30 17:27:34 +02:00
Emily
6d26c3c8af add brevo email 2024-08-30 16:26:53 +02:00
Emily
518b4ce6c1 add blog-post + links 2024-08-30 14:59:17 +02:00
Emily
71bd4d0e58 remove beta text 2024-08-30 14:16:07 +02:00
Emily
0563a833eb . 2024-08-29 16:34:26 +02:00
Emily
ab07ffb108 fix limits 2024-08-29 16:31:50 +02:00
Emily
79309cc537 add tests infrastructure 2024-08-29 16:31:44 +02:00
Emily
9b9ed3e9ad update mailsave to https 2024-08-29 15:09:43 +02:00
Emily
1cb6b92d5c add mail + github stars 2024-08-29 14:55:42 +02:00
Antonio Verdiglione
c1bdc30933 Merge pull request #12 from bradenhirschi/readme
Updated readme for better English
2024-08-19 15:59:36 +02:00
Braden Hirschi
887ed45b4d Updated readme for better English 2024-08-09 12:15:08 -07:00
502 changed files with 26804 additions and 85974 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

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

2
.gitignore vendored
View File

@@ -1,5 +1,7 @@
steps
PROCESS_EVENT
**/node_modules/
docker
dev
docker-compose.admin.yml
full_reload.sh

View File

@@ -1,18 +1,16 @@
<p align="center">
<img src="assets/claim-t.png"/>
<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://dashboard.litlyx.com">Start for Free!</a>
🌐 <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>
<br />
#
<p align="center">
The easiest Dev-Centric Analytics tool.<br>Litlyx is , Open-Source, Plug-In everywhere Javascript is Supported. Setup in less then 30 seconds, with just One-Line of code.
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>
#
@@ -20,39 +18,35 @@
<br />
<p align="center">
<img src="assets/screen.png"/>
<img src="assets/dashboard-clip.png"/>
</p>
#
![GitHub Repo stars](https://img.shields.io/github/stars/Litlyx/litlyx)
![NPM Version](https://img.shields.io/npm/v/litlyx?logo=npm&color=orange)
![npm bundle size](https://img.shields.io/bundlephobia/min/litlyx)
## Get Started on our Cloud Version
## Pre-Requisites
Sign-up on [Litlyx cloud](https://dashboard.litlyx.com) using OAuth & name your project to get your project_id to connect Litlyx to your website OR Self-Host Litlyx with Docker.
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="project_id_here" src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>
<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 already tracks 10 KPIs such as `Page visits`, `Browsers`, `Devices`, `OS`, `Real-Time Online Users`, `Unique Session`, `Countries`, `Average Session Time`.
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`, `yarn`, or `pnpm`:
You can install Litlyx using `npm`, `pnpm`, `yarn` or any modern package managers:
```sh
npm i litlyx-js
```
Litlyx natively supports all JS/TS frameworks. You can use Litlyx in all WordPress Websites by injecting JS code using a plug-in. Litlyx work in serverless enviroments with Cloud (or Edge) Functions.
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/techs.png" />
<img src="assets/tech.png" />
</p>
# Import
@@ -69,54 +63,72 @@ Once imported, you need to initialize Litlyx:
Lit.init('your_project_id');
```
After initialization, Litlyx will automatically track Analytics such as `Page visits`, `Browsers`, `Devices`, `OS`, `Real-Time Online Users`, `Unique Session`, `Countries`, `Average Session Time`.
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`.
# Custom Events
# Track Custom Events
With Litlyx, you can create your own events to track in your project.
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 dept tracking, you can use the `metadata` field, like this:
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'
}
});
```
You can create your Tailor-Made Experience at ease.
Litlyx makes it easy for you to tailor your analytics to your project's needs.
# AI Data-Analyst
<p align="center">
<img src="assets/agent.png" width="180px"/>
</p>
# Fire Your First Event with cURL
Lit can compare data, query specific metadata, visualize charts, and much more just by having a simple `conversation` with him.
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
First thing first **Fork** this repository.
To self-host the Litlyx dashboard, first **fork** this repository.
You can find our Docker images on DockerHub for more.
Then run the following command:
```bash
docker-compose build
```
then, after the build finish, run:
```bash
docker-compose up
```
on your localhost you will see your own instance of the Litlyx Dashboard.
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
@@ -124,11 +136,11 @@ For more info read our [documentation](https://docs.litlyx.com). (will be improv
# Join Discord
If you need more information, help, or want to provide general feedback, feel free to join us on[Discord](https://discord.gg/9cQykjsmWX)
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 onboard you better.
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">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/claim.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

BIN
assets/dashboard-clip.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

BIN
assets/tech.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View File

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

View File

@@ -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: ""
}
}
]
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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';
}

View File

@@ -1,139 +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;
const existingSession = await SessionModel.findOne({ project_id: pid }, { _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 } = 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 } });
}

View File

@@ -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();

View File

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

View File

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

View File

@@ -5,3 +5,4 @@ dist
scripts/start_dev.js
package-lock.json
build_all.bat
tests

28
consumer/Dockerfile Normal file
View 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"]

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

File diff suppressed because it is too large Load Diff

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

View 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
View 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
View File

@@ -0,0 +1,15 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"target": "ESNext",
"esModuleInterop": true,
"outDir": "dist"
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -10,9 +10,7 @@ AI_PROJECT=
AI_KEY=
EMAIL_SERVICE=
EMAIL_HOST=
EMAIL_USER=
EMAIL_PASS=
BREVO_API_KEY=
AUTH_JWT_SECRET=

View File

@@ -12,6 +12,7 @@ node_modules
# Logs
logs
*.log
winston-*.ndjson
# Misc
.DS_Store
@@ -32,3 +33,7 @@ out.pdf
# TESTS - TO REMOVE
tests
# EXPLAINS MONGODB
explains

View File

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

View File

@@ -9,15 +9,25 @@ const debugMode = process.dev;
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-lyx-background-light relative">
<div class="w-dvw h-dvh bg-lyx-lightmode-background-light dark:bg-lyx-background-light relative">
<Transition name="drawer">
<LazyDrawerGeneric @onCloseClick="hideDrawer()" :class="drawerClasses"
class="bg-lyx-lightmode-background-light dark:bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if="drawerVisible">
</LazyDrawerGeneric>
</Transition>
<div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0">
<div v-for="alert of alerts"
class="w-[30vw] min-w-[20rem] relative bg-[#151515] overflow-hidden border-solid border-[2px] border-[#262626] rounded-lg p-6 drop-shadow-lg">
class="w-[30vw] min-w-[20rem] relative bg-lyx-lightmode-background dark:bg-[#151515] overflow-hidden border-solid border-[2px] border-lyx-lightmode-widget dark:border-[#262626] rounded-lg p-6 drop-shadow-lg">
<div class="flex items-start gap-4">
<div> <i :class="alert.icon"></i> </div>
<div class="grow">
@@ -46,8 +56,8 @@ const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dia
</div>
<div v-if="showDialog"
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">
class="custom-dialog w-full h-full flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 z-[100] backdrop-blur-[2px] dark:bg-black/50">
<div :style="dialogStyle" class="bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget dark:bg-lyx-widget dark:outline-lyx-widget-lighter rounded-xl relative outline outline-1">
<div 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>
@@ -57,6 +67,11 @@ const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dia
</div>
</div>
<UModals />
<LazyOnboarding> </LazyOnboarding>
<NuxtLayout>
<NuxtPage></NuxtPage>
</NuxtLayout>
@@ -64,3 +79,19 @@ const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dia
</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>

View File

@@ -1,10 +1,11 @@
@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');
@@ -19,6 +20,18 @@
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";
}
@@ -72,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 */
}
}

View File

@@ -13,3 +13,16 @@
.test3 {
border: 3px solid green !important;
}
.bgtest {
background-color: yellow;
}
.bgtest2 {
background-color: blue;
}
.bgtest3 {
background-color: green;
}

View File

@@ -67,7 +67,7 @@ const chartData = ref<ChartData<'bar'>>({
label: e.label || '?',
backgroundColor: [e.color],
borderWidth: 0,
borderRadius: 8
borderRadius: 0
}
})
});

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
export type IconProvider = (id: string) => ['img' | 'icon', string] | undefined;
export type IconProvider = (e: { _id: string, count: string } & any) => ['img' | 'icon', string] | undefined;
type Props = {
@@ -54,7 +54,7 @@ function openExternalLink(link: string) {
<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">
<div class="poppins font-semibold text-[1.4rem] text-lyx-lightmode-text dark:text-lyx-text">
{{ label }}
</div>
<div class="flex items-center">
@@ -63,22 +63,26 @@ function openExternalLink(link: string) {
</div>
</div>
<div class="poppins text-[1rem] text-text-sub/90">
<div class="poppins text-[1rem] text-lyx-ligtmode-text-darker dark:text-text-sub/90">
{{ desc }}
</div>
</div>
<div v-if="rawButton" class="hidden lg:flex">
<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>
<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>
<div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4">
<div class="h-full flex flex-col">
<div
class="flex justify-between font-bold lyx-text-lightmode-text-dark dark:text-text-sub/80 text-[1.1rem] mb-4">
<div class="flex items-center gap-2">
<div v-if="isDetailView" class="flex items-center justify-center">
<i @click="$emit('showGeneral')"
@@ -104,37 +108,40 @@ function openExternalLink(link: string) {
<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]"
<div class="absolute rounded-sm w-full h-full bg-[#6f829c38] dark:bg-[#92abcf38]"
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
<div class="flex px-2 py-1 relative items-center gap-4">
<div v-if="iconProvider && iconProvider(element._id) != undefined"
<div v-if="iconProvider && iconProvider(element) != 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]">
<img v-if="iconProvider(element)?.[0] == 'img'" class="h-full"
:style="customIconStyle" :src="iconProvider(element)?.[1]">
<i v-else :class="iconProvider(element._id)?.[1]"></i>
<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">
<span
class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-lyx-lightmode-text-dark dark: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
class="text-lyx-lightmode-text dark:text-lyx-text font-semibold text-[.9rem] md:text-[1rem] manrope">
{{
formatNumberK(element.count) }} </div>
</div>
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-bold text-[1.1rem]">
No visits yet
<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 ">
<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]">
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 items-end grow">
<LyxUiButton type="outline" @click="$emit('showMore')">
Show more
</div>
</LyxUiButton>
</div>
</div>

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

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

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

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

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

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

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

View File

@@ -1,6 +1,5 @@
<script lang="ts" setup>
import type { TProject } from '@schema/ProjectSchema';
import CreateSnapshot from './dialog/CreateSnapshot.vue';
export type Entry = {
@@ -10,6 +9,7 @@ export type Entry = {
icon?: string,
action?: () => any,
adminOnly?: boolean,
premiumOnly?: boolean,
external?: boolean,
grow?: boolean
}
@@ -23,10 +23,24 @@ type Props = {
sections: Section[]
}
const colorMode = useColorMode()
const isDark = computed({
get() {
return colorMode.value === 'dark'
},
set() {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
}
})
const route = useRoute();
const props = defineProps<Props>();
const { isAdmin } = useUserRoles();
const { userRoles, setLoggedUser } = useLoggedUser();
const { projectList } = useProject();
const debugMode = process.dev;
@@ -55,7 +69,7 @@ const { createAlert } = useAlert()
async function deleteSnapshot(close: () => any) {
await $fetch("/api/snapshot/delete", {
method: 'DELETE',
...signHeaders({ 'Content-Type': 'application/json' }),
headers: useComputedHeaders({ useSnapshotDates: false }).value,
body: JSON.stringify({
id: snapshot.value._id.toString(),
})
@@ -70,7 +84,7 @@ async function generatePDF() {
try {
const res = await $fetch<Blob>('/api/project/generate_pdf', {
...signHeaders(),
headers: useComputedHeaders({ useSnapshotDates: false, custom: { 'x-snapshot-name': snapshot.value.name } }).value,
responseType: 'blob'
});
@@ -95,10 +109,6 @@ function onLogout() {
router.push('/login');
}
const { projects } = useProjectsList();
const activeProject = useActiveProject();
const { data: maxProjects } = useFetch("/api/user/max_projects", {
headers: computed(() => {
return {
@@ -107,52 +117,29 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
})
});
const selected = ref<TProject>(activeProject.value as TProject);
watch(selected, () => {
setActiveProject(selected.value._id.toString())
})
</script>
<template>
<div class="CVerticalNavigation h-full w-[20rem] bg-lyx-background flex shadow-[1px_0_10px_#000000] rounded-r-lg"
<div class="CVerticalNavigation border-solid border-[#D9D9E0] dark:border-[#202020] border-r-[1px] h-full w-[20rem] bg-lyx-lightmode-background dark:bg-lyx-background flex shadow-[1px_0_10px_#000000]"
: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">
<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="projects" v-model="selected" :options="projects">
<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 }} </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> {{ activeProject?.name || '???' }} </div>
</div>
</template>
</USelectMenu>
<ProjectSelector></ProjectSelector>
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
<i @click="close()" class="fas fa-close"></i>
@@ -160,69 +147,88 @@ watch(selected, () => {
</div>
<NuxtLink to="/project_creation" v-if="projects && (projects.length < (maxProjects || 1))"
class="flex items-center text-[.8rem] gap-1 justify-end pt-2 pr-2 text-lyx-text-dark hover:text-lyx-text cursor-pointer">
<div><i class="fas fa-plus"></i></div>
<div> Create new project </div>
</NuxtLink>
<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="flex mb-2 items-center justify-between text-lyx-lightmode-text dark:text-lyx-text">
<div class="poppins text-[.8rem]">
Snapshots
</div>
<div @click="openSnapshotDialog()"
class="poppins text-[.8rem] px-2 rounded-lg outline outline-[2px] outline-lyx-widget-lighter cursor-pointer hover:bg-lyx-widget-lighter">
<i class="far fa-plus"></i>
Add
<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-[.8rem]"></i></div>
</LyxUiButton>
</UTooltip>
</div>
</div>
<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 class="flex items-center gap-2">
<USelectMenu :uiMenu="{
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
option: {
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-lightmode-widget-light dark:!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>
<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">
</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>
<div class="poppins"> {{ option.name }} </div>
</div>
</template>
</USelectMenu>
</template>
</USelectMenu>
</div>
<div v-if="snapshot" class="flex flex-col text-[.8rem] mt-2">
<div class="flex">
<div class="grow poppins"> From:</div>
<div class="poppins"> {{ new Date(snapshot.from).toLocaleString('it-IT').split(',')[0].trim() }}
<div v-if="snapshot" class="flex flex-col text-[.7rem] mt-2">
<div
class="flex gap-1 items-center justify-center text-lyx-lightmode-text-dark dark:text-lyx-text-dark">
<div class="poppins">
{{ new Date(snapshot.from).toLocaleString().split(',')[0].trim() }}
</div>
</div>
<div class="flex">
<div class="grow poppins"> To:</div>
<div class="poppins"> {{ new Date(snapshot.to).toLocaleString('it-IT').split(',')[0].trim() }}
<div class="poppins"> to </div>
<div class="poppins">
{{ new Date(snapshot.to).toLocaleString().split(',')[0].trim() }}
</div>
</div>
<LyxUiButton @click="generatePDF()" type="secondary" class="w-full text-center mt-4">
Download report
</LyxUiButton>
<div class="mt-2" v-if="snapshot._id.toString().startsWith('default') === false">
<div class="mt-2" v-if="('default' in snapshot == false)">
<UPopover placement="bottom">
<LyxUiButton type="danger" class="w-full text-center">
Delete current snapshot
@@ -243,22 +249,29 @@ watch(selected, () => {
</div>
</div>
<div class="w-full flex mt-4">
<LyxUiButton type="outline" class="w-full text-center text-[.8rem]">
Export report
</LyxUiButton>
</div>
</div>
<div class="bg-lyx-widget-lighter h-[2px] w-full"></div>
<div class="bg-lyx-lightmode-widget dark:bg-[#202020] h-[1px] w-full"></div>
<div class="flex flex-col h-full">
<div v-for="section of sections" class="flex flex-col gap-1">
<div v-for="section of sections" class="flex flex-col gap-1 h-full pb-6">
<div v-for="entry of section.entries">
<div v-for="entry of section.entries" :class="{ 'grow flex items-end': entry.grow }">
<div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))"
class="bg-lyx-background cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
<div v-if="(!entry.adminOnly || (userRoles.isAdmin.value && !isAdminHidden))"
class="bg-lyx-lightmode-background text-lyx-lightmode-text-dark dark:bg-lyx-background dark:text-lyx-text-dark w-full cursor-pointer 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 || '#'),
'bg-lyx-lightmode-background-light !text-lyx-lightmode-text dark:bg-lyx-background-lighter dark:!text-lyx-text': route.path == (entry.to || '#'),
'hover:bg-lyx-lightmode-background-light hover:!text-lyx-lightmode-text dark:hover:bg-lyx-background-light dark:hover:!text-lyx-text': route.path != (entry.to || '#'),
}">
<NuxtLink @click="close() && entry.action?.()" :target="entry.external ? '_blank' : ''"
@@ -266,9 +279,12 @@ watch(selected, () => {
<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>
@@ -278,37 +294,26 @@ watch(selected, () => {
</div>
<div class="grow"></div>
<div class="text-lyx-text-dark poppins text-[.8rem] px-4 pb-3">
Litlyx is in Beta version.
</div>
<div class="bg-lyx-widget-lighter h-[2px] px-4 w-full mb-3"></div>
<div class="bg-lyx-lightmode-widget dark: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="isAdmin"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fas fa-cat"></i>
<div>
<i @click="isDark = !isDark" class="cursor-pointer hover:text-lyx-lightmode-text text-lyx-lightmode-text-dark dark:hover:text-lyx-text dark:text-lyx-text-dark"
:class="isDark ? 'far fa-moon' : 'far fa-sun'"></i>
</div>
<NuxtLink to="/admin" v-if="userRoles.isAdmin.value"
class="cursor-pointer hover:text-lyx-lightmode-text text-lyx-lightmode-text-dark dark:hover:text-lyx-text dark:text-lyx-text-dark">
<i class="far 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">
<div @click="onLogout()" class="cursor-pointer hover:text-lyx-lightmode-text text-lyx-lightmode-text-dark dark:hover:text-lyx-text dark:text-lyx-text-dark">
<i class="far fa-arrow-right-from-bracket scale-x-[-100%]"></i>
</div>
</UTooltip>

View File

@@ -6,20 +6,19 @@ const props = defineProps<{ title: string, sub?: string }>();
<template>
<LyxUiCard>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 h-full">
<div class="flex items-center">
<div class="flex flex-col grow">
<div class="poppins font-semibold text-[1.1rem] md:text-[1.4rem] text-text">
<div class="poppins font-semibold text-[1rem] md:text-[1.3rem] text-lyx-lightmode-text-dark dark:text-text">
{{ props.title }}
</div>
<div v-if="props.sub" class="poppins text-[.8rem] md:text-[1.1rem] text-text-sub">
<div v-if="props.sub" class="poppins text-[.7rem] md:text-[1rem] text-lyx-lightmode-text-darker dark:text-text-sub">
{{ props.sub }}
</div>
</div>
<slot name="header"></slot>
</div>
<div>
<div class="h-full">
<slot></slot>
</div>
</div>

View File

@@ -11,9 +11,9 @@ const activeTabIndex = ref<number>(0);
<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="{
class="px-6 pb-3 poppins font-medium text-lyx-lightmode-text dark: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
'hover:border-lyx-lightmode-text-dark hover:text-lyx-lightmode-text-dark/60 dark:hover:border-lyx-text-dark dark:hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
}">
{{ tab.label }}
</div>

View 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-lyx-lightmode-text dark:text-text/90 poppins text-[1.1rem] font-medium">
Waiting for your first visit
</div>
<LyxUiButton class="ml-6" type="secondary" @click="reloadPage()">
<div class="flex items-center gap-2">
<i class="far fa-refresh"></i>
<div> Refresh </div>
</div>
</LyxUiButton>
</div>
<div class="flex items-center justify-center mt-10">
<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-lyx-lightmode-widget dark:bg-[#1c1b1b] rounded-md w-full">
<div class="w-full text-[.9rem] dark:text-[#acacac]"> {{ project?._id }} </div>
</div>
<LyxUiButton type="secondary" @click="copyProjectId()"> Copy </LyxUiButton>
</div>
</CardTitled>
</div>
</div>
</div>
<div>
<div>
<CardTitled class="w-full h-full" title="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>

View File

@@ -0,0 +1,26 @@
<script lang="ts" setup>
export type ButtonType = 'primary' | 'secondary' | 'outline' | 'outlined' | 'danger';
const props = defineProps<{ type: ButtonType, link?: string, target?: string, disabled?: boolean }>();
</script>
<template>
<NuxtLink tag="div" :to="disabled ? '' : link" :target="target"
class="poppins w-fit cursor-pointer px-4 py-1 rounded-md outline outline-[1px] text-lyx-lightmode-text dark:text-lyx-text"
:class="{
'bg-[#85a3ff] hover:bg-[#9db5fc] outline-lyx-lightmode-widget-light dark:bg-lyx-primary-dark dark:outline-lyx-primary dark:hover:bg-lyx-primary-hover': type === 'primary',
'bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget dark:bg-lyx-widget-lighter hover:bg-lyx-lightmode-widget dark:outline-lyx-widget-lighter dark:hover:bg-lyx-widget-light': type === 'secondary',
'bg-lyx-transparent outline-lyx-lightmode-widget hover:bg-lyx-lightmode-widget-light dark:outline-lyx-widget-lighter dark:hover:bg-lyx-widget-light': (type === 'outline' || type === 'outlined'),
'bg-[#fcd1cb] hover:bg-[#f8c5be] dark:bg-lyx-danger-dark outline-lyx-danger dark:hover:bg-lyx-danger': type === 'danger',
'text-lyx-text !bg-lyx-widget !outline-lyx-widget-lighter !cursor-not-allowed': disabled === true,
}">
<slot></slot>
</NuxtLink>
</template>

View File

@@ -0,0 +1,10 @@
<script lang="ts" setup>
</script>
<template>
<div class="w-fit h-fit rounded-md bg-lyx-lightmode-background outline-lyx-lightmode-widget dark:bg-lyx-widget dark:outline-lyx-background-lighter p-4 outline outline-[1px] ">
<slot></slot>
</div>
</template>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
const props = defineProps<{ placeholder?: string, modelValue: string }>();
const props = defineProps<{ placeholder?: string, modelValue: string, type?: string }>();
const emits = defineEmits<{
(e: "update:modelValue", value: string): void
@@ -18,8 +18,7 @@ const handleChange = (event: Event) => {
</script>
<template>
<input class="bg-lyx-widget-light text-lyx-text-dark poppins rounded-md outline outline-[1px] outline-lyx-widget-lighter" type="text"
:placeholder="props.placeholder"
:value="props.modelValue"
@input="handleChange">
<input
class="bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget text-lyx-lightmode-text dark:bg-lyx-widget-light dark:text-lyx-text-dark poppins rounded-md outline outline-[1px] dark:outline-lyx-widget-lighter"
:type="props.type ?? 'text'" :placeholder="props.placeholder" :value="props.modelValue" @input="handleChange">
</template>

View 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-lightmode-background-light dark:bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
<div class="text-lyx-lightmode-text dark:text-lyx-text mt-4">
For the current project do you already have other Analytics tools implemented (e.g. GA4) or Litlyx is
going to be your first/main analytics?
</div>
<div class="grid grid-cols-2 gap-3 mt-8">
<div v-for="(e, i) of analyticsList">
<div @click="selectIndex(i)"
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex == i }"
class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
{{ e }}
</div>
</div>
</div>
<div class="mt-8">
<LyxUiInput v-if="otherFieldVisisble" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
v-model="otherText"></LyxUiInput>
</div>
<div class="mt-6 flex justify-center flex-col items-center">
<LyxUiButton @click="onNextPage()" class="px-[8rem] py-2" :disabled="selectedIndex == -1"
type="primary"> Next </LyxUiButton>
<!-- <div class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div> -->
</div>
</div>
<div v-if="page == 1" class="bg-lyx-lightmode-background-light dark:bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
<div class="text-lyx-lightmode-text dark:text-lyx-text mt-4">
What is your job title ?
</div>
<div class="grid grid-cols-2 gap-3 mt-8">
<div v-for="(e, i) of jobsList">
<div @click="selectIndex2(i)"
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex2 == i }"
class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
{{ e }}
</div>
</div>
</div>
<div class="mt-8">
<LyxUiInput v-if="otherFieldVisisble2" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
v-model="otherText2"></LyxUiInput>
</div>
<div class="mt-6 flex justify-center flex-col items-center">
<LyxUiButton @click="onFinish()" class="px-[8rem] py-2" :disabled="selectedIndex2 == -1" type="primary">
Finish </LyxUiButton>
<div @click="onFinish(true)" class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div>
</div>
</div>
</div>
</template>

View 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-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
option: {
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-lightmode-widget-light dark:!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>

View File

@@ -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] p-1 md:p-2 rounded-xl bg-lyx-lightmode-widget-light border-lyx-lightmode-widget dark:bg-lyx-widget dark:border-lyx-widget-lighter">
<div @click="opt.disabled ? ()=>{}: $emit('changeIndex', index)" v-for="(opt, index) of options"
class="hover:bg-lyx-lightmode-widget dark:hover:bg-lyx-widget-lighter/60 select-btn-animated cursor-pointer rounded-lg poppins font-regular px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
:class="{
'bg-lyx-lightmode-widget hover:!bg-lyx-lightmode-widget dark:bg-lyx-widget-lighter dark:hover:!bg-lyx-widget-lighter': currentIndex == index && !opt.disabled,
'hover:!bg-lyx-lightmode-widget-light text-lyx-lightmode-widget dark:hover:!bg-lyx-widget !cursor-not-allowed dark:!text-lyx-widget-lighter': opt.disabled
}">
{{ opt.label }}
</div>
</div>

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

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

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

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

View 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-600 dark:text-yellow-400"></i>
<div class="poppins text-lyx-lightmode-text dark:text-lyx-text"> Ask AI </div>
</div>
</LyxUiButton>
<div class="flex gap-6">
<div v-for="(dataset, index) of chartData.datasets" class="flex gap-2 items-center text-[.9rem]">
<UCheckbox :ui="{
color: legendClasses[index]
}" :model-value="true" @change="onLegendChange(dataset, index, $event)"></UCheckbox>
<label class="mt-[2px]"> {{ dataset.label }} </label>
</div>
</div>
</div>
<div id='external-tooltip' ref="externalTooltipElement" class="z-[400]">
<LyxUiCard class="text-lyx-lightmode-text dark:text-lyx-text">
<div class="flex gap-2 items-center">
<div> Date: </div>
<div v-if="currentTooltipData"> {{ currentTooltipData.date }}</div>
</div>
<div v-for="(dataset, index) of chartData.datasets" class="flex gap-2 items-center">
<div :style="`background-color: ${legendColors[index]}`" class="h-4 w-4 rounded-full">
</div>
<div> {{ dataset.label }}</div>
<div v-if="currentTooltipData" class="grow text-right px-4">
{{ (currentTooltipData as any)[tooltipNameIndex[index]] }}
</div>
</div>
<!-- <div class="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>

View File

@@ -1,46 +0,0 @@
<script lang="ts" setup>
const activeProject = useActiveProject();
const { safeSnapshotDates } = useSnapshot()
const isShowMore = ref<boolean>(false);
const headers = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: isShowMore.value === true ? '200' : '10'
}
});
const browsersData = useFetch(`/api/metrics/${activeProject.value?._id}/data/browsers`, {
method: 'POST', headers, lazy: true, immediate: false
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() {
isShowMore.value = true;
showDialog.value = true;
dialogBarData.value = browsersData.data.value || [];
}
onMounted(() => {
browsersData.execute();
});
</script>
<template>
<div class="flex flex-col gap-2">
<DashboardBarsCard @showMore="showMore()" @dataReload="browsersData.refresh()"
:data="browsersData.data.value || []" desc="The browsers most used to search your website."
:dataIcons="false" :loading="browsersData.pending.value" label="Top Browsers" sub-label="Browsers">
</DashboardBarsCard>
</div>
</template>

View File

@@ -5,13 +5,19 @@ 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>
@@ -19,35 +25,34 @@ const props = defineProps<{
<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-lyx-lightmode-text-dark dark:text-text-dirty text-[1.2rem] 2xl:text-[1.4rem]">
{{ value }}
</div>
<div class="poppins text-lyx-lightmode-darker dark: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-lyx-lightmode-darker dark:text-text-sub text-[.9rem] 2xl:text-[1rem]"> {{ text }} </div>
</div>
<div class="flex flex-col items-center gap-1">
<UTooltip :text="props.tooltipText">
<i class="far fa-info-circle text-lyx-text-darker text-[1rem]"></i>
</UTooltip>
</div>
</div>
<div 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" :data="props.data || []" :labels="props.labels || []"
:color="props.color">
<DashboardEmbedChartCard v-if="ready" :todayIndex="todayIndex" :data="props.data || []"
:labels="props.labels || []" :color="props.color">
</DashboardEmbedChartCard>
</div>
<div v-if="!ready" class="flex justify-center items-center w-full h-full">
<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>

View File

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

View File

@@ -1,46 +0,0 @@
<script lang="ts" setup>
const activeProject = useActiveProject();
const { safeSnapshotDates } = useSnapshot()
const isShowMore = ref<boolean>(false);
const headers = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: isShowMore.value === true ? '200' : '10'
}
});
const devicesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/devices`, {
method: 'POST', headers, lazy: true, immediate: false
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() {
isShowMore.value = true;
showDialog.value = true;
dialogBarData.value = devicesData.data.value || [];
}
onMounted(() => {
devicesData.execute();
});
</script>
<template>
<div class="flex flex-col gap-2">
<DashboardBarsCard @showMore="showMore()" @dataReload="devicesData.refresh()" :data="devicesData.data.value || []" :dataIcons="false"
desc="The devices most used to access your website." :loading="devicesData.pending.value" label="Top Devices"
sub-label="Devices"></DashboardBarsCard>
</div>
</template>

View File

@@ -13,8 +13,8 @@ const columns = [
<template>
<div class="w-full h-full bg-bg rounded-xl p-8">
<div class="full h-full overflow-y-auto">
<div class="w-full h-full bg-lyx-lightmode-background dark:bg-lyx-background-light rounded-xl p-8">
<div class="full h-full overflow-y-auto text-lyx-lightmode-text dark:text-lyx-text">
<UTable :columns="columns" :rows="dialogBarData" :loading="isDataLoading" v-if="dialogBarData">
<template #count-data="{ row }">
<div class="font-bold"> {{ formatNumberK(row.count) }} </div>

View File

@@ -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;
},
},
},
],
});

View File

@@ -1,51 +0,0 @@
<script lang="ts" setup>
const router = useRouter();
function goToView() {
router.push('/dashboard/events');
}
const activeProject = useActiveProject();
const { safeSnapshotDates } = useSnapshot()
const isShowMore = ref<boolean>(false);
const headers = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: isShowMore.value === true ? '200' : '10'
}
});
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/data/events`, {
method: 'POST', headers, lazy: true, immediate: false
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() {
isShowMore.value = true;
showDialog.value = true;
dialogBarData.value = eventsData.data.value || [];
}
onMounted(async () => {
eventsData.execute();
});
</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="eventsData.refresh()"
:data="eventsData.data.value || []" :loading="eventsData.pending.value" label="Top Events"
sub-label="Events" :rawButton="!isLiveDemo()"></DashboardBarsCard>
</div>
</template>

View File

@@ -27,7 +27,6 @@ const chartOptions = ref<ChartOptions<'doughnut'>>({
position: 'top',
align: 'center',
labels: {
color: 'white',
font: {
family: 'Poppins',
size: 16
@@ -46,7 +45,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
},
@@ -56,7 +76,7 @@ const chartData = ref<ChartData<'doughnut'>>({
const { doughnutChartProps, doughnutChartRef } = useDoughnutChart({ chartData: chartData, options: chartOptions });
const activeProject = useActiveProject();
const { projectId } = useProject();
const { safeSnapshotDates } = useSnapshot();
@@ -77,17 +97,8 @@ function transformResponse(input: CustomEventsAggregated[]) {
}
}
const headers = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: "10"
}
});
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/data/events`, {
method: 'POST', headers, lazy: true, immediate: false,transform:transformResponse
const eventsData = useFetch(`/api/data/events`, {
headers: useComputedHeaders({ limit: 6 }), lazy: true, immediate: false, transform: transformResponse
});
onMounted(() => {

View File

@@ -1,63 +0,0 @@
<script lang="ts" setup>
import type { IconProvider } from './BarsCard.vue';
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 activeProject = useActiveProject();
const { safeSnapshotDates } = useSnapshot()
const isShowMore = ref<boolean>(false);
const headers = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: isShowMore.value === true ? '200' : '10'
}
});
const geolocationData = useFetch(`/api/metrics/${activeProject.value?._id}/data/countries`, {
method: 'POST', headers, lazy: true, immediate: false
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() {
isShowMore.value = true;
showDialog.value = true;
dialogBarData.value = geolocationData.data.value?.map(e => {
return { ...e, icon: iconProvider(e._id) }
}) || [];
isDataLoading.value = false;
}
onMounted(async () => {
geolocationData.execute();
});
</script>
<template>
<div class="flex flex-col gap-2">
<DashboardBarsCard @showMore="showMore()" @dataReload="geolocationData.refresh()" :data="geolocationData.data.value || []" :dataIcons="false"
:loading="geolocationData.pending.value" label="Top Countries" sub-label="Countries" :iconProvider="iconProvider"
:customIconStyle="customIconStyle" desc=" Lists the countries where users access your website.">
</DashboardBarsCard>
</div>
</template>

View File

@@ -1,44 +0,0 @@
<script lang="ts" setup>
const activeProject = useActiveProject();
const { safeSnapshotDates } = useSnapshot()
const isShowMore = ref<boolean>(false);
const headers = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: isShowMore.value === true ? '200' : '10'
}
});
const ossData = useFetch(`/api/metrics/${activeProject.value?._id}/data/oss`, {
method: 'POST', headers, lazy: true, immediate: false
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() {
isShowMore.value = true;
showDialog.value = true;
dialogBarData.value = ossData.data.value || [];
}
onMounted(() => {
ossData.execute();
});
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<DashboardBarsCard @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="Top OS" sub-label="OSs"></DashboardBarsCard>
</div>
</template>

View File

@@ -1,68 +0,0 @@
<script lang="ts" setup>
import type { IconProvider } from './BarsCard.vue';
import ReferrerBarChart from '../referrer/ReferrerBarChart.vue';
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 activeProject = useActiveProject();
const { safeSnapshotDates } = useSnapshot()
const isShowMore = ref<boolean>(false);
const headers = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: isShowMore.value === true ? '200' : '10'
}
});
const referrersData = useFetch(`/api/metrics/${activeProject.value?._id}/data/referrers`, {
method: 'POST', headers, lazy: true, immediate: false
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
// const customDialog = useCustomDialog();
// function onShowDetails(referrer: string) {
// customDialog.openDialog(ReferrerBarChart, { slice: 'day', referrer });
// }
function showMore() {
isShowMore.value = true;
showDialog.value = true;
dialogBarData.value = referrersData.data.value?.map(e => {
return { ...e, icon: iconProvider(e._id) }
}) || [];
}
onMounted(async () => {
referrersData.execute();
});
</script>
<template>
<div class="flex flex-col gap-2">
<DashboardBarsCard @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 Referrers" sub-label="Referrers"></DashboardBarsCard>
</div>
</template>

View File

@@ -10,7 +10,7 @@ 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, navigator.language, props.slice));
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), props.slice));
return { data, labels }
}

View File

@@ -3,42 +3,80 @@
import DateService from '@services/DateService';
import type { Slice } from '@services/DateService';
const { data: metricsInfo } = useMetricsData();
const { snapshot, safeSnapshotDates, snapshotDuration } = useSnapshot()
const { snapshot, safeSnapshotDates } = useSnapshot()
const snapshotFrom = computed(() => new Date(snapshot.value?.from || '0').getTime());
const snapshotTo = computed(() => new Date(snapshot.value?.to || Date.now()).getTime());
const chartSlice = computed(() => {
if (snapshotDuration.value <= 3) return 'hour' as Slice;
if (snapshotDuration.value <= 32) return 'day' as Slice;
return 'month' as Slice;
});
const snapshotDays = computed(() => {
return (snapshotTo.value - snapshotFrom.value) / 1000 / 60 / 60 / 24;
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(snapshotDays.value, 1);
return avg.toFixed(2);
});
const avgEventsDay = computed(() => {
if (!eventsData.data.value) return '0.00';
const counts = eventsData.data.value.data.reduce((a, e) => e + a, 0);
const avg = counts / Math.max(snapshotDays.value, 1);
const avg = counts / Math.max(snapshotDuration.value, 1);
return avg.toFixed(2);
});
const avgSessionsDay = computed(() => {
if (!sessionsData.data.value) return '0.00';
const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
const avg = counts / Math.max(snapshotDays.value, 1);
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;
@@ -48,64 +86,10 @@ const avgSessionDuration = computed(() => {
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
});
const chartSlice = computed(() => {
const snapshotSizeMs = new Date(snapshot.value.to).getTime() - new Date(snapshot.value.from).getTime();
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 6) return 'hour' as Slice;
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 30) return 'day' as Slice;
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 90) return 'day' as Slice;
return 'month' as Slice;
});
function transformResponse(input: { _id: string, count: number }[]) {
const data = input.map(e => e.count);
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, chartSlice.value));
const pool = [...input.map(e => e.count)];
pool.pop();
const avg = pool.reduce((a, e) => a + e, 0) / pool.length;
const diffPercent: number = (100 / avg * (input.at(-1)?.count || 0)) - 100;
const trend = Math.max(Math.min(diffPercent, 99), -99);
return { data, labels, trend }
}
const activeProject = useActiveProject();
function getBody() {
return JSON.stringify({
from: safeSnapshotDates.value.from,
to: safeSnapshotDates.value.to,
slice: chartSlice.value
});
}
const visitsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
lazy: true, immediate: false
});
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
lazy: true, immediate: false
});
const sessionsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/sessions`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
lazy: true, immediate: false
});
const sessionsDurationData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/sessions_duration`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
lazy: true, immediate: false
});
onMounted(async () => {
visitsData.execute();
eventsData.execute();
sessionsData.execute();
sessionsDurationData.execute();
});
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>
@@ -114,29 +98,31 @@ onMounted(async () => {
<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">
<DashboardCountCard :ready="!visitsData.pending.value" icon="far fa-earth" text="Total page visits"
:value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
:avg="formatNumberK(avgVisitDay) + '/day'" :trend="visitsData.data.value?.trend"
:data="visitsData.data.value?.data" :labels="visitsData.data.value?.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.pending.value" icon="far fa-flag" text="Total custom events"
:value="formatNumberK(eventsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
:avg="formatNumberK(avgEventsDay) + '/day'" :trend="eventsData.data.value?.trend"
:data="eventsData.data.value?.data" :labels="eventsData.data.value?.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.pending.value" icon="far fa-user" text="Unique visits sessions"
<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) || '...')"
:avg="formatNumberK(avgSessionsDay) + '/day'" :trend="sessionsData.data.value?.trend"
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.pending.value" icon="far fa-timer" text="Avg session time"
:value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
<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>

View File

@@ -1,46 +1,77 @@
<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('You can\'t copy in HTTP');
navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
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">
<div class="w-full px-6 pb-2 lg:pb-6 font-bold 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="flex gap-2 items-center text-lyx-lightmode-text/90 dark:text-lyx-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-medium text-[1.2rem]"> {{ 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 class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project:</div>
<div class="text-lyx-text poppins font-medium text-[1.2rem]"> {{ activeProject?.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-[1.2rem]">Project id:</div>
<div class="flex gap-2">
<div class="text-lyx-text poppins font-medium text-[1.2rem]">
{{ activeProject?._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-[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-lyx-lightmode-text/90 dark:text-lyx-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>

View File

@@ -10,7 +10,7 @@ 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, navigator.language, props.slice));
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), props.slice));
return { data, labels }
}

View File

@@ -1,81 +0,0 @@
<script lang="ts" setup>
import type { VisitsWebsiteAggregated } from '~/server/api/metrics/[project_id]/data/websites';
const activeProject = useActiveProject();
const { safeSnapshotDates } = useSnapshot()
const isShowMore = ref<boolean>(false);
const currentWebsite = ref<string>("");
const websitesHeaders = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: isShowMore.value === true ? '200' : '10'
}
});
const pagesHeaders = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: isShowMore.value === true ? '200' : '10',
'x-website-name': currentWebsite.value
}
});
const websitesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/websites`, {
method: 'POST', headers: websitesHeaders, lazy: true, immediate: false
});
const pagesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/pages`, {
method: 'POST', headers: pagesHeaders, lazy: true, immediate: false
});
const isPagesView = ref<boolean>(false);
const currentData = computed(() => {
return isPagesView.value ? pagesData : websitesData
})
async function showDetails(website: string) {
currentWebsite.value = website;
pagesData.execute();
isPagesView.value = true;
}
async function showGeneral() {
websitesData.execute();
isPagesView.value = false;
}
const router = useRouter();
function goToView() {
router.push('/dashboard/visits');
}
onMounted(()=>{
websitesData.execute();
})
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<DashboardBarsCard :hideShowMore="true" @showGeneral="showGeneral()" @showRawData="goToView()"
@dataReload="currentData.refresh()" @showDetails="showDetails" :data="currentData.data.value || []"
:loading="currentData.pending.value" :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>

View File

@@ -2,7 +2,7 @@
const { closeDialog } = useCustomDialog();
import { sub, format, isSameDay, type Duration } from 'date-fns'
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
const ranges = [
{ label: 'Last 7 days', duration: { days: 7 } },
@@ -36,24 +36,27 @@ function onColorChange() {
const snapshotName = ref<string>("");
const { updateSnapshots } = useSnapshot();
const { updateSnapshots, snapshot, snapshots } = useSnapshot();
const { createAlert } = useAlert()
async function confirmSnapshot() {
await $fetch("/api/snapshot/create", {
method: 'POST',
...signHeaders({ 'Content-Type': 'application/json' }),
headers: useComputedHeaders({ useSnapshotDates: false }).value,
body: JSON.stringify({
name: snapshotName.value,
color: currentColor.value,
from: selected.value.start.toISOString(),
to: selected.value.end.toISOString()
from: startOfDay(selected.value.start),
to: endOfDay(selected.value.end)
})
});
await updateSnapshots();
closeDialog();
createAlert('Snapshot created','Snapshot created successfully', 'far fa-circle-check', 5000);
createAlert('Snapshot created', 'Snapshot created successfully', 'far fa-circle-check', 5000);
const newSnapshot = snapshots.value.at(-1);
if (newSnapshot) snapshot.value = newSnapshot;
}
</script>
@@ -61,7 +64,7 @@ async function confirmSnapshot() {
<template>
<div class="w-full h-full flex flex-col">
<div class="poppins text-center">
<div class="poppins text-center text-lyx-lightmode-text dark:text-lyx-text">
Create a snapshot
</div>
@@ -76,7 +79,6 @@ async function confirmSnapshot() {
</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">
@@ -97,8 +99,6 @@ async function confirmSnapshot() {
</div>
</template>
</UPopover>
</div>
<div class="grow"></div>

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

View 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: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
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] dark:bg-lyx-widget bg-lyx-lightmode-widget-light 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>

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

View 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 dark: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>

View File

@@ -0,0 +1,223 @@
<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-[1.1rem] text-lyx-lightmode-text dark:text-yellow-400 mb-2">
*Plan upgrades are applicable exclusively to this project(workspace).
</div>
<div class="poppins text-[2rem] font-semibold">
Do you need help ?
</div>
<div class="poppins text-[1.2rem]">
We respond in max. 1-2 days
</div>
</div>
<div class="flex flex-col gap-2">
<LyxUiButton type="secondary">
<a href="mailto:help@litlyx.com" class="poppins text-[1.1rem]">
help@litlyx.com
</a>
</LyxUiButton>
<LyxUiButton type="secondary">
<a href="https://discord.com/invite/9cQykjsmWX" class="poppins text-[1.1rem]">
Discord support
</a>
</LyxUiButton>
</div>
</div>
</div>
</template>

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

View File

@@ -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,7 +20,9 @@ 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 = "";
}
@@ -41,7 +42,9 @@ async function getMetadataFieldGrouped() {
const queryParamsString = Object.keys(queryParams).map((key) => `${key}=${queryParams[key]}`).join('&');
metadataFieldGrouped.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/metadata_field_group?${queryParamsString}`, signHeaders());
metadataFieldGrouped.value = await $fetch<string[]>(`/api/data/events_data/metadata_field_group?${queryParamsString}`, {
headers: useComputedHeaders().value
});
}
@@ -71,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-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
option: {
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-lightmode-widget-light dark:!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-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
option: {
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-lightmode-widget-light dark:!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-lightmode-widget dark: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-lightmode-widget dark:bg-lyx-widget-light text-lyx-lightmode-text dark: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..."
@@ -100,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>

View File

@@ -1,36 +1,47 @@
<script lang="ts" setup>
import type { Slice } from '@services/DateService';
import { onMounted } from 'vue';
import DateService, { type Slice } from '@services/DateService';
const props = defineProps<{ slice: Slice }>();
const slice = computed(() => props.slice);
const activeProject = useActiveProject();
const { safeSnapshotDates } = useSnapshot()
const body = computed(() => {
return {
from: safeSnapshotDates.value.from,
to: safeSnapshotDates.value.to,
slice: slice.value,
}
});
function transformResponse(input: { _id: string, name: string, count: number }[]) {
const fixed = fixMetrics({
data: input,
from: safeSnapshotDates.value.from,
from: input[0]._id,
to: safeSnapshotDates.value.to
}, slice.value, {
advanced: true,
advancedGroupKey: 'name'
});
},
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 = {
@@ -50,10 +61,31 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
datasets: parsedDatasets,
labels: fixed.labels
}
}
const eventsStackedData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events_stacked`, {
method: 'POST', body, lazy: true, immediate: false, transform: transformResponse, ...signHeaders()
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
});
@@ -64,12 +96,17 @@ onMounted(async () => {
</script>
<template>
<div>
<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" :datasets="eventsStackedData.data.value?.datasets || []"
<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>

View File

@@ -1,13 +1,11 @@
<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);
@@ -26,7 +24,10 @@ async function getUserFlowData() {
const queryParamsString = Object.keys(queryParams).map((key) => `${key}=${queryParams[key]}`).join('&');
userFlowData.value = await $fetch(`/api/metrics/${activeProject.value?._id.toString()}/events/flow_from_name?${queryParamsString}`, signHeaders());
userFlowData.value = await $fetch(`/api/data/events_data/flow_from_name?${queryParamsString}`, {
headers: useComputedHeaders().value
});
analyzing.value = false;
}
@@ -38,25 +39,39 @@ async function analyzeEvent() {
<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-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
option: {
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-lightmode-widget-light dark:!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
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-1 px-2 rounded-lg"
<div class="flex gap-4 items-center bg-bg py-2 px-2 bg-lyx-lightmode-widget dark: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`"
@@ -67,8 +82,8 @@ async function analyzeEvent() {
<div> {{ count.toFixed(2).replace('.', ',') }} % </div>
</div>
</div>
</div>
</CardTitled>
</template>

View File

@@ -22,86 +22,27 @@ const widthHeight = computed(() => {
return 9 + props.size * props.spacing;
});
const colorMode = useColorMode();
</script>
<template>
<div class="w-fit h-fit">
<svg xmlns="http://www.w3.org/2000/svg" :width="widthHeight" :height="widthHeight" :style="`opacity: ${props.opacity};`"
fill="none">
<template v-for="(p, x) of sizeArr">
<template v-for="(p, y) of sizeArr">
<circle :cx="9 + (spacing * x)" :cy="9 + (spacing * y)" r="1" fill="#fff"
<circle :cx="9 + (spacing * x)" :cy="9 + (spacing * y)" r="1" :fill="colorMode.value === 'light' ? '#000' : '#FFF'"
:fill-opacity="calculateOpacity(x, y)" />
</template>
</template>
<!-- <circle cx="27" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="135" r="1" fill="#fff" fill-opacity=".9" />
-->
</svg>
</div>

View File

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

View File

@@ -13,19 +13,22 @@ export type PricingCardProp = {
planId: number
}
const props = defineProps<{ datas: PricingCardProp[] }>();
const props = defineProps<{ datas: PricingCardProp[], defaultIndex?: number }>();
const activeProject = useActiveProject();
const currentIndex = ref<number>(0);
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/${activeProject.value?._id.toString()}/create`, {
...signHeaders({ 'content-type': 'application/json' }),
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 })
})
@@ -37,17 +40,23 @@ async function onUpgradeClick() {
<template>
<div class="relative bg-[#151515] outline outline-[1px] outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]">
<div
class="relative bg-lyx-lightmode-widget-light dark:bg-[#151515] outline outline-[1px] outline-lyx-lightmode-widget dark:outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]">
<div class="flex flex-col gap-3 text-center">
<div class="poppins text-xl font-light"> {{ data.title }} </div>
<div v-if="data.active" class="absolute right-6 top-3 poppins text-[.75rem] bg-[#222A42] outline outline-[1px] outline-[#5680F8] px-3 py-[.1rem] rounded-xl">
<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="sep bg-lyx-lightmode-widget dark: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">
@@ -61,35 +70,45 @@ async function onUpgradeClick() {
}" :min="0" :max="datas.length - 1" v-model="currentIndex">
</URange>
</div>
<div class="poppins" v-for="sub of data.subs"> {{ sub }} </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="sep bg-lyx-lightmode-widget dark: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">
<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" v-if="data.planId > -1">
<div @click="onUpgradeClick()" v-if="!data.active && !data.isDowngrade"
class="cursor-pointer text-[1rem] font-semibold bg-[#3a3af5] rounded-md py-2 text-center">
<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
</div>
<div @click="onUpgradeClick()" v-if="!data.active && data.isDowngrade"
class="w-full cursor-pointer text-[1rem] font-semibold bg-[#1f1f22] text-red-400 rounded-md py-2 text-center">
</LyxUiButton>
<LyxUiButton class="rounded-md py-2 w-full text-center" type="danger" @click="onUpgradeClick()"
v-if="!data.active && data.isDowngrade">
Downgrade
</div>
</LyxUiButton>
</div>
<NuxtLink v-if="data.planId === -1" :to="data.link || 'https://dashboard.litlyx.com'"
class="bg-[#222A42] cursor-pointer outline outline-[1px] outline-[#5680F8] w-full !rounded-md text-center text-[.9rem] !py-2 ">
<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 }}
</NuxtLink>
</LyxUiButton>
</div>
</div>

View File

@@ -1,226 +0,0 @@
<script lang="ts" setup>
import type { PricingCardProp } from './PricingCardGeneric.vue';
const activeProject = useActiveProject();
const props = defineProps<{ currentSub: number }>();
const freePricing: PricingCardProp[] = [
{
title: 'Free',
price: '€0 / mo',
subs: [
'Up to 5000 visits/events per month',
'CPM 0€ per visit/event'
],
features: [
'Email support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 10',
'Server type: SHARED',
'Projects: max 2',
'Data retention: 2 Months'
],
cta: 'Start For Free now!',
active: props.currentSub == 0,
isDowngrade: props.currentSub > 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 Charts',
'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',
'CPM 0,10€ per visit/event'
],
features: [
'Discord support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 30',
'Server type: SHARED',
'Projects: max 3',
'Data retention: 6 Months'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 101,
isDowngrade: props.currentSub > 101,
planId: 101
},
{
title: 'Acceleration',
price: '€9,99 / mo',
subs: [
'Up to 150.000 visits/events per month',
'CPM 0,06€ per visit/event'
],
features: [
'Discord support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 100',
'Server type: SHARED',
'Projects: max 3',
'Data retention: 9 Months'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 102,
isDowngrade: props.currentSub > 102,
planId: 102
},
{
title: 'Growth',
price: '€29,99 / mo',
subs: [
'Up to 500.000 visits/events per month',
'CPM 0,059€ per visit/event'
],
features: [
'Discord support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 3.000',
'Server type: SHARED',
'Projects: max 3',
'Data retention: 1 Year'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 103,
isDowngrade: props.currentSub > 103,
planId: 103
},
{
title: 'Expansion',
price: '€59,99 / mo',
subs: [
'Up to 1.000.000 visits/events per month',
'CPM 0,059€ per visit/event'
],
features: [
'Discord support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 5.000',
'Server type: SHARED',
'Projects: max 3',
'Data retention: 1 Year'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 104,
isDowngrade: props.currentSub > 104,
planId: 104
},
{
title: 'Scaling',
price: '€99,99 / mo',
subs: [
'Up to 2.500.000 visits/events per month',
'CPM 0,039€ per visit/event'
],
features: [
'Discord support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 10.000',
'Server type: DEDICATED',
'Projects: max 3',
'Data retention: 2 Years'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 105,
isDowngrade: props.currentSub > 105,
planId: 105
},
{
title: 'Unicorn',
price: '€149,99 / mo',
subs: [
'Up to 5.000.000 visits/events per month',
'CPM 0,029€ per visit/event'
],
features: [
'Discord support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 20.000',
'Server type: DEDICATED',
'Projects: max 3',
'Data retention: 3 Years'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 106,
isDowngrade: props.currentSub > 106,
planId: 106
}
]
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">
<PricingCardGeneric class="flex-1" :datas="freePricing"></PricingCardGeneric>
<PricingCardGeneric class="flex-1" :datas="slidePricings"></PricingCardGeneric>
<PricingCardGeneric class="flex-1" :datas="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-[#303030]">
<a href="mailto:help@litlyx.com" class="poppins text-[1.3rem]">
help@litlyx.com
</a>
</div>
</div>
</div>
</div>
</template>

View File

@@ -2,12 +2,18 @@
import type { SettingsTemplateEntry } from './Template.vue';
const entries: SettingsTemplateEntry[] = [
{ id: 'change_pass', title: 'Change password', text: 'Change your password' },
{ id: 'delete', title: 'Delete account', text: 'Delete your account' },
]
const { user } = useLoggedUser();
const { setToken } = useAccessToken();
const canChangePassword = useFetch('/api/user/password/can_change', {
headers: useComputedHeaders({ useSnapshotDates: false })
});
async function deleteAccount() {
const sure = confirm("Are you sure you want to delete this account ?");
if (!sure) return;
@@ -20,17 +26,63 @@ async function deleteAccount() {
location.href = "/login"
}
const old_password = ref<string>("");
const new_password = ref<string>("");
const { createAlert } = useAlert()
async function changePassword() {
try {
const res = await $fetch("/api/user/password/change", {
...signHeaders({ 'Content-Type': 'application/json' }),
method: "POST",
body: JSON.stringify({ old_password: old_password.value, new_password: new_password.value })
})
if (!res) throw Error('No response');
if (res.error) return createAlert('Error', res.message, 'far fa-triangle-exclamation', 5000);
old_password.value = '';
new_password.value = '';
return createAlert('Success', 'Password changed successfully', 'far fa-circle-check', 5000);
} catch (ex) {
console.error(ex);
createAlert('Error', 'Internal error', 'far fa-triangle-exclamation', 5000);
}
}
</script>
<template>
<SettingsTemplate :entries="entries">
<template #change_pass>
<div v-if="canChangePassword.data.value?.can_change">
<div class="flex flex-col gap-4">
<LyxUiInput type="password" class="py-1 px-2" v-model="old_password" placeholder="Current password"></LyxUiInput>
<LyxUiInput type="password" class="py-1 px-2" v-model="new_password" placeholder="New password"></LyxUiInput>
<LyxUiButton type="primary" @click="changePassword()"> Change password </LyxUiButton>
</div>
</div>
<div v-if="!canChangePassword.data.value?.can_change">
You cannot change the password for accounts created using social login options.
</div>
</template>
<template #delete>
<div
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-[#1e1412]">
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-lyx-lightmode-widget-light dark:bg-[#1e1412]">
<div class="poppins font-semibold"> Deleting this account will also remove its projects </div>
<div @click="deleteAccount()"
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-[#291415]">
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-lyx-lightmode-widget-light dark:bg-[#291415]">
Delete account
</div>
</div>

View File

@@ -0,0 +1,61 @@
<script lang="ts" setup>
import type { TApiSettings } from '@schema/ApiSettingsSchema';
import type { SettingsTemplateEntry } from './Template.vue';
const { project } = useProject();
const entries: SettingsTemplateEntry[] = [
{ id: 'acodes', title: 'Appsumo codes', text: 'Redeem appsumo codes' },
]
const { createAlert } = useAlert()
const currentCode = ref<string>("");
const redeeming = ref<boolean>(false);
const valid_codes = useFetch('/api/pay/valid_codes', signHeaders({ 'x-pid': project.value?._id.toString() ?? '' }));
async function redeemCode() {
redeeming.value = true;
try {
const res = await $fetch<TApiSettings>('/api/pay/redeem_appsumo_code', {
method: 'POST', ...signHeaders({
'Content-Type': 'application/json',
'x-pid': project.value?._id.toString() ?? ''
}),
body: JSON.stringify({ code: currentCode.value })
});
createAlert('Success', 'Code redeem success.', 'far fa-check', 5000);
valid_codes.refresh();
} catch (ex: any) {
createAlert('Error', ex?.response?.statusText || 'Unexpected error. Contact support.', 'far fa-error', 5000);
} finally {
currentCode.value = '';
}
redeeming.value = false;
}
</script>
<template>
<SettingsTemplate :entries="entries" :key="project?.name || 'NONE'">
<template #acodes>
<div class="flex items-center gap-4">
<LyxUiInput class="w-full px-4 py-2" placeholder="Appsumo code" v-model="currentCode"></LyxUiInput>
<LyxUiButton v-if="!redeeming" :disabled="currentCode.length == 0" @click="redeemCode()" type="primary">
Redeem
</LyxUiButton>
<div v-if="redeeming">
Redeeming...
</div>
</div>
<div class="text-lyx-text-darker mt-1 text-[.9rem] poppins">
Redeemed codes: {{ valid_codes.data.value?.count || '0' }}
</div>
<div class="poppins text-[1.1rem] text-lyx-lightmode-text dark:text-yellow-400 mb-2">
*Plan upgrades are applicable exclusively to this project(workspace).
</div>
</template>
</SettingsTemplate>
</template>

View File

@@ -0,0 +1,156 @@
<script lang="ts" setup>
import DeleteDomainData from '../dialog/DeleteDomainData.vue';
import type { SettingsTemplateEntry } from './Template.vue';
const entries: SettingsTemplateEntry[] = [
{ id: 'delete_dns', title: 'Delete domain data', text: 'Delete data of a specific domain from this project' },
{ id: 'delete_data', title: 'Delete project data', text: 'Delete all data from this project' },
]
const domains = useFetch('/api/settings/domains', {
headers: useComputedHeaders({ useSnapshotDates: false }),
transform: (e) => {
if (!e) return [];
return e.sort((a, b) => {
return a.count - b.count;
}).map(e => {
return { id: e._id, label: `${e._id} - ${e.count} visits` }
})
}
})
const selectedDomain = ref<{ id: string, label: string }>();
const selectedVisits = ref<boolean>(true);
const selectedSessions = ref<boolean>(true);
const selectedEvents = ref<boolean>(true);
const domainCounts = useFetch(() => `/api/settings/domain_counts?domain=${selectedDomain.value?.id}`, {
headers: useComputedHeaders({ useSnapshotDates: false }),
})
const { setToken } = useAccessToken();
const modal = useModal();
function openDeleteDomainDataDialog() {
modal.open(DeleteDomainData, {
preventClose: true,
deleteData: {
isAll: false,
domain: selectedDomain.value?.id as string,
visits: selectedVisits.value,
sessions: selectedSessions.value,
events: selectedEvents.value,
},
buttonType: 'primary',
message: 'This action is irreversable and will wipe all the data from the selected domain.',
onSuccess: () => {
modal.close()
},
onCancel: () => {
modal.close()
},
});
}
function openDeleteAllDomainDataDialog() {
modal.open(DeleteDomainData, {
preventClose: true,
deleteData: {
isAll: true,
domain: '',
visits: false,
sessions: false,
events: false,
},
buttonType: 'danger',
message: 'This action is irreversable and will wipe all the data from the entire project.',
onSuccess: () => {
modal.close()
},
onCancel: () => {
modal.close()
},
});
}
const visitsLabel = computed(() => {
if (domainCounts.pending.value === true) return 'Visits loading...';
if (domainCounts.data.value?.error === true) return 'Visits (too many to compute)';
return 'Visits ' + (domainCounts.data.value?.visits ?? '');
})
const eventsLabel = computed(() => {
if (domainCounts.pending.value === true) return 'Events loading...';
if (domainCounts.data.value?.error === true) return 'Events (too many to compute)';
return 'Events ' + (domainCounts.data.value?.events ?? '');
})
const sessionsLabel = computed(() => {
if (domainCounts.pending.value === true) return 'Sessions loading...';
if (domainCounts.data.value?.error === true) return 'Sessions (too many to compute)';
return 'Sessions ' + (domainCounts.data.value?.sessions ?? '');
})
</script>
<template>
<SettingsTemplate :entries="entries">
<template #delete_dns>
<div class="flex flex-col">
<!-- <div class="text-[.9rem] text-lyx-text-darker"> Select a domain </div> -->
<USelectMenu placeholder="Select a domain" :uiMenu="{
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
option: {
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
}
}" :options="domains.data.value ?? []" v-model="selectedDomain"></USelectMenu>
<div v-if="selectedDomain" class="flex flex-col gap-2 mt-4">
<div class="text-[.9rem] text-lyx-text-dark"> Select data to delete </div>
<div class="flex flex-col gap-1">
<UCheckbox :ui="{ color: 'actionable-visits-color-checkbox' }" v-model="selectedVisits"
:label="visitsLabel" />
<UCheckbox :ui="{ color: 'actionable-sessions-color-checkbox' }" v-model="selectedSessions"
:label="sessionsLabel" />
<UCheckbox :ui="{ color: 'actionable-events-color-checkbox' }" v-model="selectedEvents"
:label="eventsLabel" />
</div>
<LyxUiButton class="mt-2" v-if="selectedVisits || selectedSessions || selectedEvents"
@click="openDeleteDomainDataDialog()" type="outline">
Delete data
</LyxUiButton>
<div class="text-lyx-text-dark">
This action will delete all data from the project creation date.
</div>
</div>
</div>
</template>
<template #delete_data>
<div
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-lyx-lightmode-widget-light dark:bg-[#1e1412]">
<div class="poppins font-semibold"> This operation will reset this project to it's initial state (0
visits 0 events 0 sessions) </div>
<div @click="openDeleteAllDomainDataDialog()"
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-lyx-lightmode-widget-light dark:bg-[#291415]">
Delete all data
</div>
</div>
</template>
</SettingsTemplate>
</template>

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