205 Commits

Author SHA1 Message Date
Emily
1917b74c32 fix admin panel + payment ok page 2025-04-28 18:52:38 +02:00
Emily
b709ad285a fix deployment of producer 2025-04-25 22:46:33 +02:00
Emily
6a157b81c9 fix 2025-04-25 19:20:13 +02:00
Emily
82b0f6aac4 change reports + chage pricing 2025-04-25 18:36:00 +02:00
Emily
407c84f59c fix premium banner 2025-04-25 15:49:57 +02:00
Emily
e8f8600df4 Mode AI tokens 2025-04-25 15:49:27 +02:00
Emily
eb954cac6c update 2025-04-24 17:36:23 +02:00
Emily
a9bbc58ad1 fix payment service + appsumo + ui 2025-04-22 18:42:18 +02:00
Emily
f631c29fb2 fix bugs 2025-04-16 17:13:19 +02:00
Emily
946f9d4d32 fix dashboard + payments 2025-04-13 18:15:43 +02:00
Emily
1d5dad44fa add titles 2025-04-11 18:16:20 +02:00
Emily
1187cafd07 update email templates 2025-04-11 18:16:14 +02:00
Emily
475512711b change wordpress first interaction 2025-04-09 17:40:01 +02:00
Emily
8f7f89e0bd update UI 2025-04-09 17:35:40 +02:00
Emily
4d51676a2e update free prices 2025-04-07 18:47:50 +02:00
Emily
72ceb7971d update pricing data 2025-04-05 16:42:52 +02:00
Emily
10d4a9f1bc fix dashboard premium tables 2025-04-05 16:32:38 +02:00
Emily
70c15238a0 update payment system 2025-04-01 19:46:07 +02:00
Emily
7e093251fa implementing new payment system + rewrite deploy scripts 2025-03-28 16:57:57 +01:00
Emily
7658dbe85c fix members 2025-03-26 16:15:46 +01:00
Emily
1f9ef5d18c add payment service 2025-03-26 15:30:22 +01:00
Emily
94a28b31d3 update shields 2025-03-24 18:54:15 +01:00
Emily
87c9aca5c4 shields update 2025-03-20 16:04:00 +01:00
Emily
afda29997d Fix 2025-03-14 16:40:50 +01:00
Emily
d1b3e997c1 fix bug on settings page 2025-03-11 15:14:37 +01:00
Emily
be82f7046f fix projection UI on chart 2025-03-11 13:24:53 +01:00
Emily
45e9a9c6a7 update snapthots + admin panel users 2025-03-10 15:54:00 +01:00
Emily
942d074f99 refactoring 2025-03-06 10:55:46 +01:00
Emily
63fa3995c5 refactoring 2025-03-03 19:31:35 +01:00
Emily
76e5e07f79 Merge pull request #29 from wpgaurav/wpgaurav-patch-2
Fallback fonts
2025-02-17 14:20:04 +01:00
Emily
b8f9e598a7 Merge pull request #30 from wpgaurav/wpgaurav-patch-1
Switch from Google Fonts to Bunny Fonts and other privacy first alternatives
2025-02-17 14:19:46 +01:00
Emily
0ee4895e1a Merge pull request #31 from wpgaurav/main
Compress file sizes
2025-02-17 14:19:25 +01:00
Gaurav Tiwari
72d6b97383 Compress file sizes
40% size reduction
2025-02-15 22:40:14 +05:30
Gaurav Tiwari
3f22c655a5 Fallback fonts 2025-02-15 22:34:27 +05:30
Gaurav Tiwari
4fea549a5a Switch from Google Fonts to Bunny Fonts and other privacy first alternatives 2025-02-15 22:29:02 +05:30
Emily
4c1d10f8b7 fix dockerfiles 2025-02-14 17:15:38 +01:00
Emily
50d275e0ff fix dashboard dockerfile 2025-02-14 17:10:47 +01:00
Emily
98238fa180 fix dashboard dockerfile 2025-02-14 17:06:34 +01:00
Emily
c07bccd0dd fix dockerfile dashboard 2025-02-14 17:03:44 +01:00
Emily
7192c31136 fix dockerfiles 2025-02-14 16:55:34 +01:00
Emily
56d7e71d90 add username+pass login on NO_AUTH mode 2025-02-14 16:33:33 +01:00
Emily
30229d4b97 Merge branch 'refactoring' 2025-02-14 16:13:46 +01:00
Emily
b2303468a4 update ui 2025-02-14 16:13:06 +01:00
Emily
af6dff57ed update admin panel 2025-02-12 16:49:19 +01:00
antonio
dd8b089c46 updated readme 2025-02-12 11:00:47 +01:00
antonio
a5750d556a update readme 2025-02-12 10:58:56 +01:00
Emily
f5882bff9f admin panel 2025-02-12 03:20:54 +01:00
Emily
f18cdc8278 Merge branch 'refactoring' 2025-02-11 14:51:09 +01:00
Emily
a7ebbc22c0 update 2025-02-11 14:27:35 +01:00
Emily
346eecc928 update guests logic + fix pdf 2025-02-10 16:28:34 +01:00
Emily
abc485a9ef update dashboard + server 2025-02-10 15:30:19 +01:00
Emily
0292829805 implement domain filter 2025-02-06 15:23:55 +01:00
Emily
4e2c8468f8 change position of docs + text 2025-02-06 15:23:47 +01:00
Emily
38cfd4315d fix ai UI + add domain filter on visits 2025-02-06 15:23:38 +01:00
Emily
b592695a49 add domain filter on events 2025-02-05 16:02:32 +01:00
Emily
0963201a32 rewrite consumer + testmode utils 2025-02-01 15:26:26 +01:00
Emily
4da840f2ec remove shared 2025-01-31 18:47:29 +01:00
Emily
a1718875d9 remove shared 2025-01-31 18:46:13 +01:00
Emily
e931235533 removed shared 2025-01-31 18:10:22 +01:00
Emily
881a7800ce updating consumer 2025-01-31 15:33:26 +01:00
Emily
487c3ac7b4 change consumer 2025-01-31 14:58:46 +01:00
Emily
0dd94be6e6 change text 2025-01-31 14:56:45 +01:00
Emily
29a220b21e fix testmode push 2025-01-30 16:21:55 +01:00
Emily
8cc2f07b95 update gitignore 2025-01-30 14:36:27 +01:00
Emily
88cec21df1 Delete dashboard/ecosystem.config.js 2025-01-30 14:36:11 +01:00
Emily
8183ae1e68 update deploy script 2025-01-30 14:33:54 +01:00
Emily
0f39cab26a update deploy scripts 2025-01-30 14:33:31 +01:00
Emily
a2e4ed9ee0 updates for testmode 2025-01-29 17:14:10 +01:00
antonio
30b5db4200 changed upgrade email text 2025-01-29 16:07:58 +01:00
Emily
bfeee8673c use new mail service in dashboard 2025-01-29 16:03:01 +01:00
Emily
39b8dd84f1 update deploy scripts + dashboard ecosystem 2025-01-28 15:29:20 +01:00
Emily
19b7c7664a update scripts to typescript 2025-01-28 15:08:42 +01:00
Emily
a3e74adf9c . 2025-01-27 16:48:52 +01:00
Emily
ad9aabcbf6 add email service deploy 2025-01-27 16:42:07 +01:00
Emily
510bc2545a add email service 2025-01-27 15:12:22 +01:00
Emily
65c682c75d add appsumo_unicorn 2025-01-27 14:10:28 +01:00
Emily
04acc0b18e add appsumo_unicorn 2025-01-27 14:09:51 +01:00
Emily
852fea45a5 writing shared 2025-01-27 14:08:03 +01:00
Emily
6f3e59e72e fix path 2025-01-25 15:31:50 +01:00
Emily
3960eaa8ad refactoring 2025-01-25 15:31:37 +01:00
Emily
e4bdf7e4c3 refactoring dashboard 2025-01-23 17:34:43 +01:00
Emily
afeaac1b0d update endpoints to support domains 2025-01-22 17:46:59 +01:00
Emily
8922507a64 implementing domain selector 2025-01-21 18:07:01 +01:00
Emily
13e94cb0f0 align icons of devices 2025-01-20 14:55:12 +01:00
Emily
3923a06e9b fix actionable + lightmode 2025-01-20 14:47:57 +01:00
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
642 changed files with 23963 additions and 93039 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

7
.gitignore vendored
View File

@@ -1,6 +1,11 @@
steps
PROCESS_EVENT
**/node_modules/
docker
dev
docker-compose.admin.yml
full_reload.sh
full_reload.sh
build-all.sh
tmp
ecosystem.config.js
todo

8
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"github.copilot.enable": {
"*": false,
"plaintext": false,
"markdown": true,
"scminput": false
}
}

View File

@@ -4,13 +4,14 @@
</p>
<h4 align="center">
🌐 <a href="https://litlyx.com">Website</a> 📚 <a href="https://docs.litlyx.com">Docs</a> 👾 <a href="https://discord.gg/9cQykjsmWX">Join Discord</a> 🔥 <a href="https://dashboard.litlyx.com">Start for free!</a>
📚 <a href="https://docs.litlyx.com">Docs</a> 👾 <a href="https://discord.gg/9cQykjsmWX">Join Discord</a> 🌐 <a href="https://litlyx.com">Website</a> 🔥 <a href="https://dashboard.litlyx.com">Try Litlyx Cloud. It's Free forever.</a>
</h4>
#
<p align="center">
The easiest, developer-centric analytics tool.<br>
Litlyxis an open-source, self-hostable analytics solution for modern framework. Setup takes less than 30 seconds!
Litlys is a modern, developer-friendly, cookie-free analytics tool.<br>
Setup takes less than 30 seconds! Completely self-hostable with docker.<br>
Alternative to Google Analytics, Matomo, Umami, Plausible & Simple Analytics.
</p>
#
@@ -23,35 +24,35 @@
#
## Pre-Requisites on Cloud Version
## Get Started on our Cloud Version
Sign-up on [Litlyx.com](https://dashboard.litlyx.com) and create a project. Then simply use your project_id to connect Litlyx to your website OR Self-Host Litlyx with Docker.
Sign-up on [Litlyx.com](https://dashboard.litlyx.com) and create a project. Then simply use your `project_id` to connect Litlyx to your website.
## Universal Installation
```html
<script defer data-project="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 instantly starts tracking 10 KPIs, including `Page visits`, `Browsers`, `Devices`, `Operating Systems`, `Real-Time Online Users`, `Unique Sessions`, `Countries`, and `Average Session Time`.
Importing Litlyx with a direct script instantly starts tracking `Visits`, `Top Pages`, `Bouncing Rate`, `Real-Time Online Users`, `Unique Visitors`, `Countries`, and `Average Session Duration`.
# All Javascript Runtimes
You can install Litlyx using `npm`, `yarn`, or `pnpm`:
You can install Litlyx using `npm`, `pnpm` or any modern package managers:
```sh
npm i litlyx-js
```
Litlyx natively works with all JavaScript / TypeScript frameworks. You can use Litlyx in all WordPress Websites by injecting JS code using a plug-in. Litlyx also works 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 third party plug-in.
<p align="center">
<img src="assets/tech.png" />
</p>
# Import
# Import using a package manager
Import litlyx-js library into your code:
First, Import litlyx-js library into your code:
```js
import { Lit } from 'litlyx-js';
@@ -63,9 +64,9 @@ 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`, `Operating Systems`, `Real-Time Online Users`, `Unique Sessions`, `Countries`, and `Average Session Time`.
After initialization, Litlyx will automatically track web analytics such as `Page visits`, `Real-Time Online Users`, `Unique Vistors`, and many more.
# Custom Events
# Track Custom Events (Actions)
You aren't just limited to the built-in KPIs. With Litlyx, you can create your own events to track in your project.
@@ -80,6 +81,7 @@ Lit.event('click_on_buy_item', {
metadata: {
'product-name': 'Coca-Cola',
'price': 1.50,
'currency': 'EUR'
}
});
```
@@ -103,33 +105,47 @@ curl -X POST "https://broker.litlyx.com/event" \
}'
```
# Self-Hosting with Docker
# Self-hosting with docker
To self-host the Litlyx dashboard, first **fork** this repository.
To self-host the Litlyx dashboard, first **clone** this repository. (Litlyx's Docker images are hosted on DockerHub).
Then run the following command:
```bash
docker-compose build
```
after the build finishes, run:
```bash
docker-compose up
```
at localhost:3000 you will see your own instance of the Litlyx Dashboard.
# Official Docs
## Forward data to your self-hosted instance with script tag
For more info read our [documentation](https://docs.litlyx.com). (will be improved in the near future using Mintlify!)
To forward your data on your self-hosted instance, you need to set up the following variables: `data-host`, `data-port`, `data-secure`(`true` if it is HTTPS or `false` if it is HTTP).
# Join Discord
```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>
```
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)
# Read our docs
# Contributors
For more info on how to use litlyx read our [documentation](https://docs.litlyx.com).
Every kind of contribution is accepted in this stage of the project. In the future we will improve the contributor onboarding process.
# Stay updated with our roadmap
To keep track on what we are cooking behind the scene we have a public [Roadmap](https://litlyx.com/roadmap) for you to check.
# Join discord
If you need more information, want to interact with us or the community, need help, or have feedback to share, feel free to join us on Litlyx's [Discord](https://discord.gg/9cQykjsmWX) channel.
# Contribution
If you want to contribute to Litlyx's development, reach out to us on [Discord](https://discord.gg/9cQykjsmWX) in our `#contribution` channel.
### Thank you!
<a href="https://github.com/litlyx/litlyx/graphs/contributors">

BIN
assets/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.7 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,20 +0,0 @@
module.exports = {
apps: [
{
name: 'QueueBroker',
port: '3999',
exec_mode: 'fork',
script: './dist/producer/src/index.js',
env: {
EMAIL_SERVICE: "",
BREVO_API_KEY: "",
PORT: "",
MONGO_CONNECTION_STRING: "",
REDIS_URL: "",
REDIS_USERNAME: "",
REDIS_PASSWORD: "",
STREAM_NAME: ""
}
}
]
}

View File

@@ -1,13 +0,0 @@
/** @type {import('ts-jest').JestConfigWithTsJest} **/
module.exports = {
testEnvironment: "node",
transform: {
"^.+.tsx?$": ["ts-jest",{}],
},
moduleNameMapper: {
'@services/(.*)': '<rootDir>/../shared/services/$1',
'@data/(.*)': '<rootDir>/../shared/data/$1',
'@functions/(.*)': '<rootDir>/../shared/functions/$1',
'@schema/(.*)': '<rootDir>/../shared/schema/$1',
}
};

View File

@@ -1,43 +0,0 @@
{
"dependencies": {
"@getbrevo/brevo": "^2.2.0",
"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": {
"@jest/globals": "^29.7.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.12.13",
"@types/nodemailer": "^6.4.15",
"@types/ua-parser-js": "^0.7.39",
"glob": "^10.4.1",
"jest": "^29.7.0",
"node-ssh": "^13.2.0",
"ts-jest": "^29.2.5",
"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",
"test": "jest"
},
"keywords": [],
"author": "Emily",
"license": "MIT",
"description": "Queue broker for Litlyx - Saves events to database."
}

4685
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,67 +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.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)) {
const notify = await LimitNotifyModel.findOne({ project_id });
if (notify && notify.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)) {
const notify = await LimitNotifyModel.findOne({ project_id });
if (notify && notify.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)) {
const notify = await LimitNotifyModel.findOne({ project_id });
if (notify && notify.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

@@ -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,151 +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: 10, empty: 5000 },
readBlock: 2000,
consumer: 'consumer_' + process.env.NODE_APP_INSTANCE
}, processStreamEvent);
}
export 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 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 * EVENT_LOG_LIMIT_PERCENT) return false;
await checkLimitsForEmail(projectLimits);
return true;
}
async function process_visit(data: Record<string, string>, sessionHash: string) {
const { pid, ip, website, page, referrer, userAgent, flowHash } = data;
const canLog = await checkLimits(pid);
if (!canLog) return;
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;
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: device ? device : (userAgentParsed.browser.name ? 'desktop' : undefined),
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 canLog = await checkLimits(pid);
if (!canLog) return;
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 } = data;
const canLog = await checkLimits(pid);
if (!canLog) return;
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;
}

10
consumer/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules
ecosystem.config.cjs
ecosystem.config.js
scripts/start_dev.js
scripts/start_dev_prod.js
dist
src/shared

17
consumer/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:21-alpine as base
RUN npm i -g pnpm
WORKDIR /home/app
COPY --link ./consumer/package.json ./consumer/pnpm-lock.yaml ./consumer/
WORKDIR /home/app/consumer
RUN pnpm install
COPY --link ../consumer ./
RUN pnpm run build
CMD ["node", "/home/app/consumer/dist/index.js"]

34
consumer/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"dependencies": {
"axios": "^1.7.9",
"express": "^4.19.2",
"mongoose": "^8.9.5",
"redis": "^4.7.0",
"ua-parser-js": "^1.0.37"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^20.12.13",
"@types/ua-parser-js": "^0.7.39",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
},
"name": "consumer",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"dev": "node scripts/start_dev.js",
"dev_prod": "node scripts/start_dev_prod.js",
"compile": "tsc",
"build": "npm run compile && npm run create_db",
"create_db": "cd scripts && ts-node create_database.ts",
"docker-build": "docker build -t litlyx-consumer -f Dockerfile ../",
"docker-inspect": "docker run -it litlyx-consumer sh",
"workspace:shared": "ts-node ../scripts/consumer/shared.ts",
"workspace:deploy": "ts-node ../scripts/consumer/deploy.ts"
},
"keywords": [],
"author": "Emily",
"license": "MIT",
"description": "Database Consumer - Saves events to database."
}

1151
consumer/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
import { ProjectModel } from "./shared/schema/project/ProjectSchema";
import { UserModel } from "./shared/schema/UserSchema";
import { LimitNotifyModel } from "./shared/schema/broker/LimitNotifySchema";
import { EmailService } from './shared/services/EmailService';
import { EmailServiceHelper } from "./EmailServiceHelper";
import { TUserLimit } from "./shared/schema/UserLimitSchema";
export async function checkLimitsForEmail(projectCounts: TUserLimit) {
const user_id = projectCounts.user_id;
const hasNotifyEntry = await LimitNotifyModel.findOne({ user_id });
if (!hasNotifyEntry) {
await LimitNotifyModel.create({ user_id, limit1: false, limit2: false, limit3: false })
}
const owner = await UserModel.findById(user_id);
if (!owner) return;
const userName = owner.given_name || owner.name || 'no_name';
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit)) {
if (hasNotifyEntry.limit3 === true) return;
setImmediate(() => {
const emailData = EmailService.getEmailServerInfo('limit_max', { target: owner.email });
EmailServiceHelper.sendEmail(emailData);
});
await LimitNotifyModel.updateOne({ user_id }, { limit1: true, limit2: true, limit3: true });
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.9)) {
if (hasNotifyEntry.limit2 === true) return;
setImmediate(() => {
const emailData = EmailService.getEmailServerInfo('limit_90', { target: owner.email });
EmailServiceHelper.sendEmail(emailData);
});
await LimitNotifyModel.updateOne({ user_id }, { limit1: true, limit2: true, limit3: false });
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.5)) {
if (hasNotifyEntry.limit1 === true) return;
setImmediate(() => {
const emailData = EmailService.getEmailServerInfo('limit_50', { target: owner.email });
EmailServiceHelper.sendEmail(emailData);
});
await LimitNotifyModel.updateOne({ user_id }, { limit1: true, limit2: false, limit3: false });
}
}

View File

@@ -0,0 +1,19 @@
import { EmailServerInfo } from './shared/services/EmailService'
import axios from 'axios';
const EMAIL_SECRET = process.env.EMAIL_SECRET;
export class EmailServiceHelper {
static async sendEmail(data: EmailServerInfo) {
try {
await axios(data.url, {
method: 'POST',
data: data.body,
headers: { ...data.headers, 'x-litlyx-token': EMAIL_SECRET }
})
} catch (ex) {
console.error(ex);
}
}
}

View File

@@ -0,0 +1,15 @@
import { MAX_LOG_LIMIT_PERCENT } from './shared/data/broker/Limits';
import { checkLimitsForEmail } from './EmailController';
import { UserLimitModel } from './shared/schema/UserLimitSchema';
export async function checkLimits(user_id: string) {
const userLimits = await UserLimitModel.findOne({ user_id });
if (!userLimits) return false;
const TOTAL_COUNT = userLimits.events + userLimits.visits;
const COUNT_LIMIT = userLimits.limit;
if ((TOTAL_COUNT) > COUNT_LIMIT * MAX_LOG_LIMIT_PERCENT) return false;
await checkLimitsForEmail(userLimits);
return true;
}

28
consumer/src/Metrics.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Router } from 'express';
import { RedisStreamService } from './shared/services/RedisStreamService';
import { requireEnv } from './shared/utils/requireEnv';
const stream_name = requireEnv('STREAM_NAME');
export const metricsRouter = Router();
metricsRouter.get('/queue', async (req, res) => {
try {
const size = await RedisStreamService.getQueueInfo(stream_name);
res.json({ size });
} catch (ex) {
console.error(ex);
res.status(500).json({ error: ex.message });
}
})
metricsRouter.get('/durations', async (req, res) => {
try {
const durations = await RedisStreamService.METRICS_get()
res.json({ durations });
} catch (ex) {
console.error(ex);
res.status(500).json({ error: ex.message });
}
})

167
consumer/src/index.ts Normal file
View File

@@ -0,0 +1,167 @@
import { requireEnv } from './shared/utils/requireEnv';
import { connectDatabase } from './shared/services/DatabaseService';
import { RedisStreamService } from './shared/services/RedisStreamService';
import { ProjectModel } from "./shared/schema/project/ProjectSchema";
import { VisitModel } from "./shared/schema/metrics/VisitSchema";
import { SessionModel } from "./shared/schema/metrics/SessionSchema";
import { EventModel } from "./shared/schema/metrics/EventSchema";
import { lookup } from './lookup';
import { UAParser } from 'ua-parser-js';
import { checkLimits } from './LimitChecker';
import express from 'express';
import { ProjectCountModel } from './shared/schema/project/ProjectsCounts';
import { metricsRouter } from './Metrics';
import { UserLimitModel } from './shared/schema/UserLimitSchema';
const app = express();
app.use('/metrics', metricsRouter);
app.listen(process.env.PORT, () => console.log(`Listening on port ${process.env.PORT}`));
connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
main();
const CONSUMER_NAME = `CONSUMER_${process.env.NODE_APP_INSTANCE || 'DEFAULT'}`
async function getProjectOwner(pid: string) {
const ownerData = await ProjectModel.findOne({ _id: pid }, { owner: 1 });
return ownerData.owner;
}
async function main() {
await RedisStreamService.connect();
const stream_name = requireEnv('STREAM_NAME');
const group_name = requireEnv('GROUP_NAME') as any; // Checks are inside "startReadingLoop"
await RedisStreamService.startReadingLoop({
stream_name, group_name, consumer_name: CONSUMER_NAME
}, processStreamEntry);
}
async function processStreamEntry(data: Record<string, string>) {
const start = Date.now();
try {
const eventType = data._type;
if (!eventType) return console.log('No type');
const { pid, sessionHash } = data;
const owner = await getProjectOwner(pid);
if (!owner) return;
const canLog = await checkLimits(owner.toString());
if (!canLog) return;
if (eventType === 'event') {
await process_event(data, sessionHash, owner.toString());
} else if (eventType === 'keep_alive') {
await process_keep_alive(data, sessionHash, owner.toString());
} else if (eventType === 'visit') {
await process_visit(data, sessionHash, owner.toString());
}
} catch (ex: any) {
console.error('ERROR PROCESSING STREAM EVENT', ex.message);
}
const duration = Date.now() - start;
RedisStreamService.METRICS_onProcess(CONSUMER_NAME, duration);
}
async function process_visit(data: Record<string, string>, sessionHash: string, user_id: 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 }),
UserLimitModel.updateOne({ user_id }, { $inc: { 'visits': 1 } })
]);
}
async function process_keep_alive(data: Record<string, string>, sessionHash: string, user_id: string) {
const { pid, instant, flowHash, timestamp, website } = data;
const existingSession = await SessionModel.findOne({ project_id: pid, session: sessionHash }, { _id: 1 });
if (!existingSession) {
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'sessions': 1 } }, { upsert: true });
}
if (instant == "true") {
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
$inc: { duration: 0 },
flowHash,
website,
updated_at: new Date(parseInt(timestamp))
}, { upsert: true });
} else {
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
$inc: { duration: 1 },
flowHash,
website,
updated_at: new Date(parseInt(timestamp))
}, { upsert: true });
}
}
async function process_event(data: Record<string, string>, sessionHash: string, user_id: string) {
const { name, metadata, pid, flowHash, timestamp, website } = data;
let metadataObject;
try {
if (metadata) metadataObject = JSON.parse(metadata);
} catch (ex) {
metadataObject = { error: 'Error parsing metadata' }
}
await Promise.all([
EventModel.create({
project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash,
website,
created_at: new Date(parseInt(timestamp))
}),
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true }),
UserLimitModel.updateOne({ user_id }, { $inc: { 'events': 1 } })
]);
}

13
consumer/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"module": "NodeNext",
"target": "ESNext",
"outDir": "dist"
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -24,7 +24,6 @@ winston-*.ndjson
.env.*
!.env.example
# Test reports
*.report.txt
@@ -35,5 +34,10 @@ out.pdf
tests
# EXPLAINS MONGODB
explains
explains
#Ecosystem
ecosystem.config.cjs
ecosystem.config.js
shared

View File

@@ -1,34 +1,25 @@
ARG NODE_VERSION=21
FROM node:${NODE_VERSION}-alpine as base
FROM node:21-alpine AS base
ENV NODE_ENV=production
FROM base AS build
# Build stage
RUN npm i -g pnpm
WORKDIR /home/app
FROM base as build
COPY --link ./dashboard/package.json ./dashboard/pnpm-lock.yaml ./dashboard/
COPY --link dashboard/package.json dashboard/pnpm-lock.yaml ./
RUN npm install --production=false
WORKDIR /home/app/dashboard
RUN pnpm install
COPY --link dashboard/ ./
COPY --link ./dashboard ./
COPY --link shared/ /home/shared
RUN pnpm run build:compose
ARG GOOGLE_AUTH_CLIENT_ID
ENV GOOGLE_AUTH_CLIENT_ID=$GOOGLE_AUTH_CLIENT_ID
FROM node:21-alpine AS production
RUN npm run build
RUN npm prune
WORKDIR /home/app
# Final stage
COPY --from=build /home/app/dashboard/.output /home/app/.output
FROM base
COPY --from=build /home/app /home/app
EXPOSE ${PORT}
CMD [ "node", "/home/app/.output/server/index.mjs" ]
CMD ["node", "/home/app/.output/server/index.mjs"]

9
dashboard/app.config.ts Normal file
View File

@@ -0,0 +1,9 @@
export default defineAppConfig({
ui: {
notifications: {
position: 'top-0 bottom-[unset]'
}
}
})

View File

@@ -10,24 +10,24 @@ const { alerts, closeAlert } = useAlert();
const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dialogClosable } = useCustomDialog();
const { visible } = usePricingDrawer();
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="pdrawer">
<LazyPricingDrawer @onCloseClick="visible = false"
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if="visible">
</LazyPricingDrawer>
<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">
@@ -56,8 +56,8 @@ const { visible } = usePricingDrawer();
</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>
@@ -67,6 +67,12 @@ const { visible } = usePricingDrawer();
</div>
</div>
<UModals />
<UNotifications />
<LazyOnboarding> </LazyOnboarding>
<NuxtLayout>
<NuxtPage></NuxtPage>
</NuxtLayout>
@@ -75,18 +81,18 @@ const { visible } = usePricingDrawer();
</template>
<style scoped lang="scss">
.pdrawer-enter-active,
.pdrawer-leave-active {
.drawer-enter-active,
.drawer-leave-active {
transition: all .5s ease-in-out;
}
.pdrawer-enter-from,
.pdrawer-leave-to {
.drawer-enter-from,
.drawer-leave-to {
transform: translateX(100%)
}
.pdrawer-enter-to,
.pdrawer-leave-from {
.drawer-enter-to,
.drawer-leave-from {
transform: translateX(0)
}
</style>

14
dashboard/assets/main.css Normal file
View File

@@ -0,0 +1,14 @@
@import './font-awesome/css/all.css';
/* Are these many fonts required? For the time being switching to privacy friendly bunny.net for Google fonts. NOTE: No variable font support in bunnet.net yet. */
@import url('https://fonts.bunny.net/css?family=nunito:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
@import url('https://fonts.cdnfonts.com/css/brockmann');
@import url('https://fonts.bunny.net/css?family=inter:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
@import url('https://fonts.cdnfonts.com/css/geometric-sans-serif-v1');
@import url('https://fonts.bunny.net/css?family=manrope:300,400,500,600,700,800');
@import url('https://fonts.bunny.net/css?family=lato:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
@import url('https://fonts.bunny.net/css?family=poppins:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
@import url('https://cdn.jsdelivr.net/npm/material-symbols@0.28.2/index.css');

View File

@@ -1,17 +1,9 @@
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;0,1000;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900;1,1000&display=swap');
@import url('https://fonts.cdnfonts.com/css/brockmann');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
@use './utilities.scss';
@use './colors.scss';
@import '../font-awesome/css/all.css';
@import './utilities.scss';
@import './colors.scss';
@import url('https://fonts.cdnfonts.com/css/geometric-sans-serif-v1');
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0');
:root{
--font-sans: "SF Pro Text","SF Pro Icons", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", "Google Sans", "Helvetica Neue", Helvetica, "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol"
}
@font-face {
@@ -19,8 +11,19 @@
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";
font-family: "Geist", var(--font-sans);
}
@@ -35,47 +38,51 @@
}
.brockmann {
font-family: "Brockmann" !important;
font-family: "Brockmann", var(--font-sans)!important;
}
.nunito {
font-family: "Nunito" !important;
font-family: "Nunito",var(--font-sans)!important;
}
.inter {
font-family: "Inter" !important;
font-family: "Inter", var(--font-sans)!important;
}
.geometric {
font-family: 'Geometric Sans Serif v1' !important;
font-family: "Geometric Sans Serif v1", var(--font-sans)!important;
}
.manrope {
font-family: 'Manrope' !important;
font-family: "Manrope", var(--font-sans)!important;
}
.lato {
font-family: 'Lato' !important;
font-family: "Lato", var(--font-sans)!important;
}
.poppins {
font-family: 'Poppins' !important;
font-family: "Poppins", var(--font-sans)!important;
}
.poppins-childs {
font-family: 'Poppins' !important;
font-family: "Poppins", var(--font-sans)!important;
* {
font-family: 'Poppins' !important;
font-family: "Poppins", var(--font-sans)!important;
}
}
.hide-scrollbars {
-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 */
}
}
@@ -102,5 +109,13 @@ body {
}
* {
font-family: 'Nunito';
font-family: 'Nunito', var(--font-sans);
}
.rotating-thing {
height: 100%;
aspect-ratio: 1 / 1;
opacity: 0.15;
background: radial-gradient(51.24% 31.29% at 50% 50%, rgb(51, 58, 232) 0%, rgba(51, 58, 232, 0) 100%);
animation: 12s linear 0s infinite normal none running spin;
}

View File

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

View File

@@ -6,6 +6,7 @@ registerChartComponents();
const props = defineProps<{
datasets: any[],
labels: string[],
legendPosition?: "left" | "top" | "right" | "bottom" | "center" | "chartArea"
}>();
const chartOptions = ref<ChartOptions<'bar'>>({
@@ -40,7 +41,7 @@ const chartOptions = ref<ChartOptions<'bar'>>({
plugins: {
legend: {
display: true,
position: 'right',
position: props.legendPosition ?? 'right',
},
title: { display: false },
tooltip: {

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-[19] 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 h-full">
<BarCardBase @showMore="showMore()" @dataReload="browsersData.refresh()" :data="browsersData.data.value || []"
desc="The browsers most used to search your website." :dataIcons="true" :iconProvider="iconProvider"
:loading="browsersData.pending.value" label="Browsers" sub-label="Browsers">
</BarCardBase>
</div>
</template>

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 ml-1'];
if (e._id === 'mobile') return ['icon','far fa-mobile ml-1'];
if (e._id === 'smarttv') return ['icon','far fa-tv'];
if (e._id === 'console') return ['icon','far fa-game-console-handheld'];
return ['icon', 'far fa-question ml-1 mr-1']
}
function transform(data: { _id: string, count: number }[]) {
console.log(data);
return data.map(e => ({ ...e, _id: e._id == null ? 'others' : e._id }))
}
const devicesData = useFetch('/api/data/devices', {
headers: useComputedHeaders({ limit: 10, }), lazy: true,
transform
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
async function showMore() {
dialogBarData.value = [];
showDialog.value = true;
isDataLoading.value = true;
const res = await $fetch('/api/data/devices', {
headers: useComputedHeaders({ limit: 1000 }).value,
});
dialogBarData.value = transform(res || []);
isDataLoading.value = false;
}
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<BarCardBase @showMore="showMore()" @dataReload="devicesData.refresh()" :data="devicesData.data.value || []"
:iconProvider="iconProvider" :dataIcons="true" desc="The devices most used to access your website."
:loading="devicesData.pending.value" label="Devices" sub-label="Devices"></BarCardBase>
</div>
</template>

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 h-full">
<BarCardBase @showMore="showMore()" @dataReload="geolocationData.refresh()"
:data="geolocationData.data.value || []" :dataIcons="false" :loading="geolocationData.pending.value"
label="Countries" sub-label="Countries" :iconProvider="iconProvider" :customIconStyle="customIconStyle"
desc=" Lists the countries where users access your website.">
</BarCardBase>
</div>
</template>

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,44 @@
<script lang="ts" setup>
const router = useRouter();
const pagesData = useFetch('/api/data/pages', {
headers: useComputedHeaders({
limit: 10,
}), lazy: true
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
async function showMore() {
dialogBarData.value = [];
showDialog.value = true;
isDataLoading.value = true;
const res = await $fetch('/api/data/pages', {
headers: useComputedHeaders({ limit: 1000 }).value
});
dialogBarData.value = (res || []);
isDataLoading.value = false;
}
function goToView() {
router.push('/dashboard/visits');
}
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<BarCardBase @showRawData="goToView()" @showMore="showMore()" @dataReload="pagesData.refresh()" :showLink=true
:data="pagesData.data.value || []" :interactive="false" desc="Most visited pages."
:rawButton="!isLiveDemo"
:dataIcons="true" :loading="pagesData.pending.value" label="Top Pages" sub-label="Referrers">
</BarCardBase>
</div>
</template>

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 h-full">
<BarCardBase @showMore="showMore()" :elementTextTransformer="elementTextTransformer"
:iconProvider="iconProvider" @dataReload="referrersData.refresh()" :showLink=true
:data="referrersData.data.value || []" :interactive="false" desc="Where users find your website."
:dataIcons="true" :loading="referrersData.pending.value" label="Top Sources" sub-label="Referrers">
</BarCardBase>
</div>
</template>

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,365 +0,0 @@
<script lang="ts" setup>
import type { TProject } from '@schema/ProjectSchema';
import CreateSnapshot from './dialog/CreateSnapshot.vue';
export type Entry = {
label: string,
disabled?: boolean,
to?: string,
icon?: string,
action?: () => any,
adminOnly?: boolean,
premiumOnly?: boolean,
external?: boolean,
grow?: boolean
}
export type Section = {
title: string,
entries: Entry[]
}
type Props = {
sections: Section[]
}
const route = useRoute();
const props = defineProps<Props>();
const { isAdmin } = useUserRoles();
const loggedUser = useLoggedUser()
const debugMode = process.dev;
const { isOpen, close } = useMenu();
const { snapshots, snapshot, updateSnapshots } = useSnapshot();
const snapshotsItems = computed(() => {
if (!snapshots.value) return []
return snapshots.value as any[];
})
const { openDialogEx } = useCustomDialog();
function openSnapshotDialog() {
openDialogEx(CreateSnapshot, {
width: "24rem",
height: "16rem",
closable: false
});
}
const { createAlert } = useAlert()
async function deleteSnapshot(close: () => any) {
await $fetch("/api/snapshot/delete", {
method: 'DELETE',
...signHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
id: snapshot.value._id.toString(),
})
});
await updateSnapshots();
snapshot.value = snapshots.value[1];
createAlert('Snapshot deleted', 'Snapshot deleted successfully', 'far fa-circle-check', 5000);
close();
}
async function generatePDF() {
try {
const res = await $fetch<Blob>('/api/project/generate_pdf', {
...signHeaders({
'x-snapshot-name': snapshot.value.name,
'x-from': snapshot.value.from.toISOString(),
'x-to': snapshot.value.to.toISOString(),
}),
responseType: 'blob'
});
const url = URL.createObjectURL(res);
const a = document.createElement('a');
a.href = url;
a.download = `Report.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (ex: any) {
alert(ex.message);
}
}
const { setToken } = useAccessToken();
const router = useRouter();
function onLogout() {
console.log('LOGOUT')
setToken('');
setLoggedUser(undefined);
router.push('/login');
}
const { projects } = useProjectsList();
const { data: guestProjects } = useGuestProjectsList()
const activeProject = useActiveProject();
const selectorProjects = computed(() => {
const result: TProject[] = [];
if (projects.value) result.push(...projects.value);
if (guestProjects.value) result.push(...guestProjects.value);
return result;
});
const { data: maxProjects } = useFetch("/api/user/max_projects", {
headers: computed(() => {
return {
Authorization: authorizationHeaderComputed.value
}
})
});
const selected = ref<TProject>(activeProject.value as TProject);
watch(selected, () => {
setActiveProject(selected.value._id.toString())
})
const isPremium = computed(() => {
return activeProject.value?.premium;
})
function isProjectMine(owner?: string) {
if (!owner) return false;
if (!loggedUser.value?.logged) return;
return loggedUser.value.id == owner;
}
const pricingDrawer = usePricingDrawer();
</script>
<template>
<div class="CVerticalNavigation h-full w-[20rem] bg-lyx-background flex shadow-[1px_0_10px_#000000] rounded-r-lg"
:class="{
'absolute top-0 w-full md:w-[20rem] z-[45] open': isOpen,
'hidden lg:flex': !isOpen
}">
<div class="py-4 px-2 gap-6 flex flex-col w-full">
<!-- <div class="flex px-2" v-if="!isPremium">
<LyxUiButton type="primary" class="w-full text-center text-[.8rem] font-medium" @click="pricingDrawer.visible.value = true;">
Upgrade plan
</LyxUiButton>
</div> -->
<div class="flex px-2 flex-col">
<div class="flex items-center gap-2 w-full">
<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="selectorProjects" v-model="selected" :options="selectorProjects">
<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>
{{ activeProject?.name || '-' }}
{{ !isProjectMine(activeProject?.owner?.toString()) ? '(Guest)' : '' }}
</div>
</div>
</template>
</USelectMenu>
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
<i @click="close()" class="fas fa-close"></i>
</div>
</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>
</div>
<div class="w-full flex-col px-2">
<div class="flex mb-2 items-center justify-between">
<div class="poppins text-[.8rem]">
Snapshots
</div>
<div @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>
</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>
<div class="poppins"> {{ snapshot?.name }} </div>
</div>
</template>
<template #option="{ option }">
<div class="flex items-center gap-2">
<div :style="'background-color:' + option.color" class="w-2 h-2 rounded-full">
</div>
<div class="poppins"> {{ option.name }} </div>
</div>
</template>
</USelectMenu>
<div 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>
</div>
<div class="flex">
<div class="grow poppins"> To:</div>
<div class="poppins"> {{ new Date(snapshot.to).toLocaleString('it-IT').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">
<UPopover placement="bottom">
<LyxUiButton type="danger" class="w-full text-center">
Delete current snapshot
</LyxUiButton>
<template #panel="{ close }">
<div class="p-4 bg-lyx-widget">
<div class="poppins text-center font-medium">
Are you sure?
</div>
<div class="flex gap-2 mt-4">
<LyxUiButton @click="close()" type="secondary"> Cancel </LyxUiButton>
<LyxUiButton type="danger" @click="deleteSnapshot(close)"> Delete </LyxUiButton>
</div>
</div>
</template>
</UPopover>
</div>
</div>
</div>
<div class="bg-lyx-widget-lighter h-[2px] w-full"></div>
<div class="flex flex-col h-full">
<div v-for="section of sections" class="flex flex-col gap-1 h-full pb-6">
<div v-for="entry of section.entries" :class="{ 'grow flex items-end': entry.grow }">
<div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))"
class="bg-lyx-background cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
:class="{
'!text-lyx-text-darker pointer-events-none': entry.disabled,
'bg-lyx-background-lighter !text-lyx-text/90': route.path == (entry.to || '#'),
'hover:bg-lyx-background-light hover:!text-lyx-text/90': route.path != (entry.to || '#'),
}">
<NuxtLink @click="close() && entry.action?.()" :target="entry.external ? '_blank' : ''"
tag="div" class="w-full flex items-center" :to="entry.to || '/'">
<div class="flex items-center w-[1.4rem] mr-2 text-[1.1rem] justify-center">
<i :class="entry.icon"></i>
</div>
<div class="manrope grow">
{{ entry.label }}
</div>
<div v-if="entry.premiumOnly && !isPremium" class="flex items-center">
<i class="fal fa-lock"></i>
</div>
</NuxtLink>
</div>
</div>
</div>
<div class="grow"></div>
<div class="bg-lyx-widget-lighter h-[2px] px-4 w-full 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>
</NuxtLink>
</div>
<UTooltip text="Logout" :popper="{ arrow: true, placement: 'top' }">
<div @click="onLogout()" class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="far fa-arrow-right-from-bracket scale-x-[-100%]"></i>
</div>
</UTooltip>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
input:focus {
outline: none;
}
</style>

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-[1rem] md:text-[1.3rem] 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-[.7rem] md:text-[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

@@ -1,27 +1,72 @@
<script lang="ts" setup>
type CItem = { label: string, slot: string }
const props = defineProps<{ items: CItem[] }>();
export type CItem = { label: string, slot: string, tab?: string }
const props = defineProps<{
items: CItem[],
manualScroll?: boolean,
route?: boolean
}>();
const router = useRouter();
const route = useRoute();
const activeTabIndex = ref<number>(0);
function updateTab() {
const target = props.items.findIndex(e => e.tab == route.query.tab);
if (target == -1) {
activeTabIndex.value = 0;
} else {
activeTabIndex.value = target;
}
}
function onChangeTab(newIndex: number) {
activeTabIndex.value = newIndex;
const target = props.items[newIndex];
if (!target) return;
router.push({ query: { tab: target.tab } });
}
onMounted(() => {
if (props.route !== true) return;
updateTab();
watch(route, () => {
updateTab();
})
})
</script>
<template>
<div>
<div class="flex">
<div v-for="(tab, index) of items" @click="activeTabIndex = index"
class="px-6 pb-3 poppins font-medium text-lyx-text-darker border-b-[1px] border-lyx-text-darker" :class="{
'!border-[#88A7FF] !text-[#88A7FF]': activeTabIndex === index,
'hover:border-lyx-text-dark hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
}">
{{ tab.label }}
<div class="h-full flex flex-col">
<div class="flex overflow-x-auto hide-scrollbars">
<div class="flex">
<div v-for="(tab, index) of items" @click="onChangeTab(index)"
class="px-6 whitespace-nowrap pb-3 poppins font-medium text-lyx-lightmode-text dark:text-lyx-text-darker border-b-[1px] border-lyx-text-darker"
:class="{
'dark:!border-[#FFFFFF] dark:!text-[#FFFFFF] !border-lyx-primary !text-lyx-primary': activeTabIndex === index,
'hover:border-lyx-lightmode-text-dark hover:text-lyx-lightmode-text-dark/60 dark:hover:border-lyx-text-dark dark:hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
}">
{{ tab.label }}
</div>
</div>
<div class="border-b-[1px] border-lyx-text-darker w-full">
</div>
</div>
<div>
<div :class="{ 'overflow-y-hidden': manualScroll }" class="overflow-y-auto h-full">
<slot :name="props.items[activeTabIndex].slot"></slot>
</div>
</div>

View File

@@ -0,0 +1,247 @@
<script lang="ts" setup>
const { project } = useProject();
const { createAlert } = useAlert();
import 'highlight.js/styles/stackoverflow-dark.css';
import hljs from 'highlight.js';
import CardTitled from './CardTitled.vue';
import { Lit } from 'litlyx-js';
const props = defineProps<{
firstInteraction: boolean,
refreshInteraction: () => any
}>()
onMounted(() => {
hljs.highlightAll();
})
function copyProjectId() {
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
navigator.clipboard.writeText(project.value?._id?.toString() || '');
Lit.event('no_visit_copy_id');
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
}
function copyScript() {
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
const createScriptText = () => {
return [
'<script defer ',
`data-project="${project.value?._id}" `,
'src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></',
'script>'
].join('')
}
Lit.event('no_visit_copy_script');
navigator.clipboard.writeText(createScriptText());
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
}
const scriptText = computed(() => {
return [
`<script defer data-project="${project.value?._id.toString()}"`,
`\nsrc="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js">\n<`,
`/script>`
].join('');
})
function reloadPage() {
location.reload();
}
</script>
<template>
<div v-if="!firstInteraction && project" class="mt-[5vh] flex flex-col">
<div class="flex items-center justify-center">
<div class="mr-4 animate-pulse w-[1rem] h-[1rem] bg-accent rounded-full"> </div>
<div class="text-lyx-lightmode-text dark:text-text/90 poppins text-[1.1rem] font-medium">
Waiting for your first visit
</div>
<LyxUiButton class="ml-6" type="secondary" @click="reloadPage()">
<div class="flex items-center gap-2">
<i class="far fa-refresh"></i>
<div> Refresh </div>
</div>
</LyxUiButton>
</div>
<div class="flex items-center justify-center mt-10 w-full px-10">
<div class="flex flex-col gap-6">
<div class="flex gap-6 xl:flex-row flex-col">
<div class="flex gap-4">
<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="Modules"
sub="Get started with your favorite framework.">
<template #header>
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
to="https://docs.litlyx.com">
Visit documentation
</LyxUiButton>
</template>
<div class="flex flex-col items-end">
<div class="justify-center w-full hidden xl:flex gap-3">
<a href="https://docs.litlyx.com/techs/js" target="_blank">
<img class="cursor-pointer" :src="'tech-icons/js.png'"
alt="Litlyx-Javascript-Analytics">
</a>
<a href="https://docs.litlyx.com/techs/nuxt" target="_blank">
<img class="cursor-pointer" :src="'tech-icons/nuxt.png'"
alt="Litlyx-Nuxt-Analytics">
</a>
<a href="https://docs.litlyx.com/techs/next" target="_blank">
<img class="cursor-pointer" :src="'tech-icons/next.png'"
alt="Litlyx-Next-Analytics">
</a>
<a href="https://docs.litlyx.com/techs/react" target="_blank">
<img class="cursor-pointer" :src="'tech-icons/react.png'"
alt="Litlyx-React-Analytics">
</a>
<a href="https://docs.litlyx.com/techs/vue" target="_blank">
<img class="cursor-pointer" :src="'tech-icons/vue.png'"
alt="Litlyx-Vue-Analytics">
</a>
<a href="https://docs.litlyx.com/techs/angular" target="_blank">
<img class="cursor-pointer" :src="'tech-icons/angular.png'"
alt="Litlyx-Angular-Analytics">
</a>
<a href="https://docs.litlyx.com/techs/python" target="_blank">
<img class="cursor-pointer" :src="'tech-icons/py.png'"
alt="Litlyx-Python-Analytics">
</a>
<a href="https://docs.litlyx.com/techs/serverless" target="_blank">
<img class="cursor-pointer" :src="'tech-icons/serverless.png'"
alt="Litlyx-Serverless-Analytics">
</a>
</div>
</div>
</CardTitled>
</div>
</div>
<div class="flex gap-4 w-full">
<CardTitled class="w-full h-full" title="Start with Wordpress."
sub="Setup Litlyx analytics in 30 seconds on Wordpress.">
<!-- <template #header>
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
to="https://docs.litlyx.com/techs/wordpress">
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/wordpress">
<img class="cursor-pointer" :src="'tech-icons/wpel.png'"
alt="Litlyx-Wordpress-Elementor">
</a>
</div>
</div>
</CardTitled>
<CardTitled class="w-full h-full" title="Start with Shopify."
sub="Setup Litlyx analytics in 30 seconds on Shopify.">
<!-- <template #header>
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
to="https://docs.litlyx.com/techs/shopify">
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/shopify">
<img class="cursor-pointer" :src="'tech-icons/shopify.png'" alt="Litlyx-Shopify">
</a>
</div>
</div>
</CardTitled>
</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,15 @@
<script lang="ts" setup>
const props = defineProps<{ size?: string }>();
const widgetStyle = computed(() => {
return `height: ${props.size ?? '1px'}`;
})
</script>
<template>
<div :style="widgetStyle" class="dark:bg-lyx-widget-light bg-lyx-lightmode-widget"></div>
</template>

View File

@@ -0,0 +1,176 @@
<script lang="ts" setup>
const { data: needsOnboarding } = useFetch("/api/onboarding/exist", {
headers: useComputedHeaders({ useSnapshotDates: false, useTimeOffset: false })
});
const route = useRoute();
const analyticsList = [
"I have no prior analytics tool",
"Google Analytics 4",
"Plausible",
"Umami",
"MixPanel",
"Simple Analytics",
"Matomo",
"Fathom",
"Adobe Analytics",
"Other"
]
const jobsList = [
"Developer",
"Marketing",
"Product",
"Startup founder",
"Indie hacker",
"Other",
]
const selectedIndex = ref<number>(-1);
const otherFieldVisisble = ref<boolean>(false);
const otherText = ref<string>('');
function selectIndex(index: number) {
selectedIndex.value = index;
otherFieldVisisble.value = index == analyticsList.length - 1;
}
const selectedIndex2 = ref<number>(-1);
const otherFieldVisisble2 = ref<boolean>(false);
const otherText2 = ref<string>('');
function selectIndex2(index: number) {
selectedIndex2.value = index;
otherFieldVisisble2.value = index == jobsList.length - 1;
}
const page = ref<number>(0);
function onNextPage() {
if (selectedIndex.value == -1) return;
saveAnalyticsType();
page.value = 1;
}
function onFinish(skipped?: boolean) {
if (skipped) return location.reload();
if (selectedIndex2.value == -1) return;
saveJobTitle();
page.value = 2;
location.reload();
}
async function saveAnalyticsType() {
await $fetch('/api/onboarding/add', {
headers: useComputedHeaders({
useSnapshotDates: false, useTimeOffset: false,
custom: { 'Content-Type': 'application/json' }
}).value,
method: 'POST',
body: JSON.stringify({
analytics:
selectedIndex.value == analyticsList.length - 1 ?
otherText.value :
analyticsList[selectedIndex.value]
})
})
}
async function saveJobTitle() {
await $fetch('/api/onboarding/add', {
headers: useComputedHeaders({
useSnapshotDates: false, useTimeOffset: false,
custom: { 'Content-Type': 'application/json' }
}).value,
method: 'POST',
body: JSON.stringify({
job:
selectedIndex2.value == jobsList.length - 1 ?
otherText2.value :
jobsList[selectedIndex2.value]
})
})
}
const showOnboarding = computed(() => {
if (route.path === '/login') return false;
if (route.path === '/register') return false;
if ((needsOnboarding.value as any)?.exist === false) return true;
if ((needsOnboarding.value as any)?.exists === false) return true;
})
</script>
<template>
<div v-if="showOnboarding" class="absolute top-0 left-0 w-full h-full z-[30] bg-black/80 flex justify-center">
<div v-if="page == 0" class="bg-lyx-lightmode-background-light dark:bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
<div class="text-lyx-lightmode-text dark:text-lyx-text mt-4">
For the current project do you already have other Analytics tools implemented (e.g. GA4) or Litlyx is
going to be your first/main analytics?
</div>
<div class="grid grid-cols-2 gap-3 mt-8">
<div v-for="(e, i) of analyticsList">
<div @click="selectIndex(i)"
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex == i }"
class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
{{ e }}
</div>
</div>
</div>
<div class="mt-8">
<LyxUiInput v-if="otherFieldVisisble" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
v-model="otherText"></LyxUiInput>
</div>
<div class="mt-6 flex justify-center flex-col items-center">
<LyxUiButton @click="onNextPage()" class="px-[8rem] py-2" :disabled="selectedIndex == -1"
type="primary"> Next </LyxUiButton>
<!-- <div class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div> -->
</div>
</div>
<div v-if="page == 1" class="bg-lyx-lightmode-background-light dark:bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
<div class="text-lyx-lightmode-text dark:text-lyx-text mt-4">
What is your job title ?
</div>
<div class="grid grid-cols-2 gap-3 mt-8">
<div v-for="(e, i) of jobsList">
<div @click="selectIndex2(i)"
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex2 == i }"
class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
{{ e }}
</div>
</div>
</div>
<div class="mt-8">
<LyxUiInput v-if="otherFieldVisisble2" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
v-model="otherText2"></LyxUiInput>
</div>
<div class="mt-6 flex justify-center flex-col items-center">
<LyxUiButton @click="onFinish()" class="px-[8rem] py-2" :disabled="selectedIndex2 == -1" type="primary">
Finish </LyxUiButton>
<div @click="onFinish(true)" class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div>
</div>
</div>
</div>
</template>

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-lyx-widget-lighter p-1 md:p-2 rounded-xl bg-lyx-widget">
<div @click="$emit('changeIndex', index)" v-for="(opt, index) of options"
class="hover:bg-lyx-widget-lighter/60 select-btn-animated cursor-pointer rounded-lg poppins font-regular px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
:class="{ 'bg-lyx-widget-lighter hover:!bg-lyx-widget-lighter': currentIndex == index }">
<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,88 @@
<script lang="ts" setup>
const { data: backendData, pending: backendPending, refresh: refreshBackend } = useFetch<any>(() => `/api/admin/backend`, signHeaders());
const avgDuration = computed(() => {
if (!backendData?.value?.durations) return -1;
return (backendData.value.durations.durations.reduce((a: any, e: any) => a + parseInt(e[1]), 0) / backendData.value.durations.durations.length);
})
const labels = computed(() => {
if (!backendData?.value?.durations) return [];
const sizes = new Map<string, number>();
for (const e of backendData.value.durations.durations) {
if (!sizes.has(e[0])) {
sizes.set(e[0], 0);
} else {
const data = sizes.get(e[0]) ?? 0;
sizes.set(e[0], data + 1);
}
}
const max = Array.from(sizes.values()).reduce((a, e) => a > e ? a : e, 0);
return new Array(max).fill('-');
});
const durationsDatasets = computed(() => {
if (!backendData?.value?.durations) return [];
const colors = ['#2200DD', '#CC0022', '#0022CC', '#FF0000', '#00FF00', '#0000FF'];
const datasets = [];
const uniqueConsumers: string[] = Array.from(new Set(backendData.value.durations.durations.map((e: any) => e[0])));
for (let i = 0; i < uniqueConsumers.length; i++) {
const consumerDurations = backendData.value.durations.durations.filter((e: any) => e[0] == uniqueConsumers[i]);
datasets.push({
points: consumerDurations.map((e: any) => {
return 1000 / parseInt(e[1])
}),
color: colors[i],
chartType: 'line',
name: uniqueConsumers[i]
})
}
return datasets;
})
</script>
<template>
<div class="mt-6 h-full">
<div class="cursor-default flex justify-center w-full">
<div v-if="backendData && !backendPending" class="flex flex-col mt-8 gap-6 px-20 items-center w-full">
<div class="flex gap-8">
<div> Queue size: {{ backendData.queue?.size || 'ERROR' }} </div>
<div> Avg consumer time: {{ avgDuration.toFixed(1) }} ms </div>
<div> Avg processed/s: {{ (1000 / avgDuration).toFixed(1) }} </div>
</div>
<div class="w-full">
<AdminBackendLineChart :labels="labels" title="Avg Processed/s" :datasets="durationsDatasets">
</AdminBackendLineChart>
</div>
<div @click="refreshBackend()"> Refresh </div>
</div>
<div v-if="backendPending">
Loading...
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,49 @@
<script lang="ts" setup>
const { data: feedbacks, pending: pendingFeedbacks, refresh } = useFetch<any[]>(() => `/api/admin/feedbacks`, signHeaders());
async function deleteFeedback(id: string) {
await $fetch('/api/admin/delete_feedback', {
method: 'DELETE',
headers: useComputedHeaders({ custom: { 'Content-Type': 'application/json' } }).value,
body: JSON.stringify({ id })
});
refresh();
}
</script>
<template>
<div class="mt-6 h-full">
<div
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
<div v-if="feedbacks" class="flex flex-col-reverse gap-4 px-20">
<div class="flex flex-col text-center outline outline-[1px] outline-lyx-widget-lighter p-4 gap-2"
v-for="feedback of feedbacks">
<div>
<div class="flex flex-col gap-1 items-center">
<div class="text-lyx-text-dark"> {{ feedback.user[0]?.email || 'DELETED USER' }} </div>
<div class="text-lyx-text-dark flex gap-2 items-center">
<div>{{ feedback.project[0]?.name || 'DELETED PROJECT' }}</div>
<div @click="deleteFeedback(feedback._id.toString())" class="hover:text-red-200"><i
class="fas fa-trash"></i></div>
</div>
</div>
{{ feedback.text }}
</div>
</div>
</div>
<div v-if="pendingFeedbacks"> Loading...</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,271 @@
<script lang="ts" setup>
import DateService, { type Slice } from '@services/DateService';
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
import * as fns from 'date-fns';
const props = defineProps<{ pid: string }>();
const errorData = ref<{ errored: boolean, text: string }>({ errored: false, text: '' })
function createGradient(startColor: string) {
const c = document.createElement('canvas');
const ctx = c.getContext("2d");
let gradient: any = `${startColor}22`;
if (ctx) {
gradient = ctx.createLinearGradient(0, 25, 0, 300);
gradient.addColorStop(0, `${startColor}99`);
gradient.addColorStop(0.35, `${startColor}66`);
gradient.addColorStop(1, `${startColor}22`);
} else {
console.warn('Cannot get context for gradient');
}
return gradient;
}
const chartOptions = ref<ChartOptions<'line'>>({
responsive: true,
maintainAspectRatio: false,
interaction: (false as any),
scales: {
y: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
// borderDash: [5, 10]
},
beginAtZero: true,
},
x: {
ticks: { display: true },
stacked: false,
offset: false,
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
}
}
},
plugins: {
legend: { display: false },
title: { display: false },
tooltip: { enabled: false }
},
});
const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
labels: [],
datasets: [
{
label: 'Visits',
data: [],
backgroundColor: ['#5655d7'],
borderColor: '#5655d7',
borderWidth: 4,
fill: true,
tension: 0.35,
pointRadius: 0,
pointHoverRadius: 10,
hoverBackgroundColor: '#5655d7',
hoverBorderColor: 'white',
hoverBorderWidth: 2,
segment: {
borderColor(ctx, options) {
const todayIndex = visitsData.data.value?.todayIndex;
if (!todayIndex || todayIndex == -1) return '#5655d7';
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
return '#5655d7'
},
borderDash(ctx, options) {
const todayIndex = visitsData.data.value?.todayIndex;
if (!todayIndex || todayIndex == -1) return undefined;
if (ctx.p1DataIndex == todayIndex - 1) return [3, 5];
return undefined;
},
backgroundColor(ctx, options) {
const todayIndex = visitsData.data.value?.todayIndex;
if (!todayIndex || todayIndex == -1) return createGradient('#5655d7');
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
return createGradient('#5655d7');
},
},
},
{
label: 'Unique visitors',
data: [],
backgroundColor: ['#4abde8'],
borderColor: '#4abde8',
borderWidth: 2,
hoverBackgroundColor: '#4abde8',
hoverBorderColor: '#4abde8',
hoverBorderWidth: 2,
type: 'bar',
// barThickness: 20,
borderSkipped: ['bottom'],
},
{
label: 'Events',
data: [],
backgroundColor: ['#fbbf24'],
borderWidth: 2,
hoverBackgroundColor: '#fbbf24',
hoverBorderColor: '#fbbf24',
hoverBorderWidth: 2,
type: 'bubble',
stack: 'combined',
borderColor: ["#fbbf24"]
},
],
});
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
const selectedSlice: Slice = 'day'
const allDatesFull = ref<string[]>([]);
function transformResponse(input: { _id: string, count: number }[]) {
const data = input.map(e => e.count);
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice));
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
const todayIndex = input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
return { data, labels, todayIndex }
}
function onResponseError(e: any) {
let message = e.response._data.message ?? 'Generic error';
if (message == 'internal server error') message = 'Please change slice';
errorData.value = { errored: true, text: message }
}
function onResponse(e: any) {
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
}
const headers = computed(() => {
return {
'x-from': fns.startOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
'x-to': fns.endOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
'x-pid': props.pid
}
});
const visitsData = useFetch(`/api/timeline/visits?pid=${props.pid}`, {
headers: useComputedHeaders({
slice: selectedSlice,
custom: { ...headers.value },
useActivePid: false,
useActiveDomain: false
}),
lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const sessionsData = useFetch(`/api/timeline/sessions?pid=${props.pid}`, {
headers: useComputedHeaders({
slice: selectedSlice,
custom: { ...headers.value },
useActivePid: false,
useActiveDomain: false
}), lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const eventsData = useFetch(`/api/timeline/events?pid=${props.pid}`, {
headers: useComputedHeaders({
slice: selectedSlice,
custom: { ...headers.value },
useActivePid: false,
useActiveDomain: false
}), lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const readyToDisplay = computed(() => !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value);
watch(readyToDisplay, () => {
if (readyToDisplay.value === true) onDataReady();
})
function onDataReady() {
if (!visitsData.data.value) return;
if (!eventsData.data.value) return;
if (!sessionsData.data.value) return;
chartData.value.labels = visitsData.data.value.labels;
const maxChartY = Math.max(...visitsData.data.value.data, ...sessionsData.data.value.data);
const maxEventSize = Math.max(...eventsData.data.value.data)
chartData.value.datasets[0].data = visitsData.data.value.data;
chartData.value.datasets[1].data = sessionsData.data.value.data;
chartData.value.datasets[2].data = eventsData.data.value.data.map(e => {
const rValue = 20 / maxEventSize * e;
return { x: 0, y: maxChartY + 20, r: isNaN(rValue) ? 0 : rValue, r2: e }
});
chartData.value.datasets[0].backgroundColor = [createGradient('#5655d7')];
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
(chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => {
const todayIndex = eventsData.data.value?.todayIndex || 0;
if (i == todayIndex - 1) return true;
return 'bottom';
});
chartData.value.datasets[2].borderColor = eventsData.data.value.data.map((e, i) => {
const todayIndex = eventsData.data.value?.todayIndex || 0;
if (i == todayIndex - 1) return '#fbbf2400';
return '#fbbf24';
});
updateChart();
}
</script>
<template>
<div class="h-[10rem] w-full flex">
<div v-if="!readyToDisplay" class="w-full flex justify-center items-center">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
<div class="flex flex-col items-end w-full" v-if="readyToDisplay && !errorData.errored">
<LineChart ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
</div>
<div v-if="errorData.errored" class="flex items-center justify-center py-8">
{{ errorData.text }}
</div>
</div>
</template>
<style lang="scss" scoped>
#external-tooltip {
border-radius: 3px;
color: white;
opacity: 0;
pointer-events: none;
position: absolute;
transform: translate(-50%, 0);
transition: all .1s ease;
}
</style>

View File

@@ -0,0 +1,45 @@
<script lang="ts" setup>
const { data: onboardings, pending: pendingOnboardings } = useFetch<any>(() => `/api/admin/onboardings`, signHeaders());
</script>
<template>
<div class="mt-6 h-full">
<div class="cursor-default flex flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
<div v-if="onboardings" class="flex gap-40 px-20">
<div class="flex flex-col gap-4">
<div class="text-lyx-primary"> Anaytics </div>
<div class="flex items-center gap-2"
v-for="e of onboardings.analytics.sort((a: any, b: any) => b.count - a.count)">
<div>{{ e._id }}</div>
<div>{{ e.count }}</div>
</div>
</div>
<div class="flex flex-col gap-4">
<div class="text-lyx-primary"> Jobs </div>
<div class="flex items-center gap-2"
v-for="e of onboardings.jobs.sort((a: any, b: any) => b.count - a.count)">
<div>{{ e._id }}</div>
<div>{{ e.count }}</div>
</div>
</div>
<div v-if="onboardings" class="flex flex-col gap-8">
<AdminOnboardingPieChart :data="onboardings.analytics" title="Analytics"></AdminOnboardingPieChart>
<AdminOnboardingPieChart :data="onboardings.jobs" title="Jobs"></AdminOnboardingPieChart>
</div>
</div>
<div v-if="pendingOnboardings"> Loading...</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,186 @@
<script lang="ts" setup>
import type { TAdminProject } from '~/server/api/admin/projects';
import { PREMIUM_PLAN, getPlanFromId } from '@data/PLANS'
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
const page = ref<number>(1);
const ordersList = [
{ label: 'Older', id: '{ "created_at": 1 }' },
{ label: 'Newer', id: '{ "created_at": -1 }' },
{ label: 'Less active', id: '{ "last_log_at": 1 }' },
{ label: 'More active', id: '{ "last_log_at": -1 }' },
{ label: 'Less usage', id: '{ "limit_total": 1 }' },
{ label: 'More usage', id: '{ "limit_total": -1 }' },
{ label: 'Smaller plan', id: '{ "premium_type": 1 }' },
{ label: 'Bigger plan', id: '{ "premium_type": -1 }' },
]
const order = ref<string>('{ "created_at": -1 }');
const limitList = [
{ label: '10', id: 10 },
{ label: '20', id: 20 },
{ label: '50', id: 50 },
{ label: '100', id: 100 },
]
const limit = ref<number>(20);
const filterList = [
{ label: 'ALL', id: '{}' },
{ label: 'PREMIUM', id: '{ "premium_type": { "$gt": 0, "$lt": 1000 } }' },
{ label: 'APPSUMO', id: '{ "premium_type": { "$gt": 6000, "$lt": 7000 } }' },
{ label: 'PREMIUM+APPSUMO', id: '{ "premium_type": { "$gt": 0, "$lt": 7000 } }' },
]
function isRangeSelected(duration: Duration) {
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
}
function selectRange(duration: Duration) {
selected.value = { start: sub(new Date(), duration), end: new Date() }
}
const ranges = [
{ label: 'Last 7 days', duration: { days: 7 } },
{ label: 'Last 14 days', duration: { days: 14 } },
{ label: 'Last 30 days', duration: { days: 30 } },
{ label: 'Last 3 months', duration: { months: 3 } },
{ label: 'Last 6 months', duration: { months: 6 } },
{ label: 'Last year', duration: { years: 1 } }
]
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
onMounted(() => {
for (const key in PREMIUM_PLAN) {
filterList.push({ label: key, id: `{"premium_type": ${(PREMIUM_PLAN as any)[key].ID}}` });
}
})
const filter = ref<string>('{}');
const { data: projectsInfo, pending: pendingProjects } = useFetch<{ count: number, projects: TAdminProject[] }>(
() => `/api/admin/projects?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
signHeaders()
);
const { data: metrics, pending: pendingMetrics } = useFetch(
() => `/api/admin/metrics?filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
signHeaders()
);
const { uiMenu } = useSelectMenuStyle();
</script>
<template>
<div class="mt-6 h-full">
<div class="flex flex-col items-center gap-8">
<div class="flex items-center gap-10 px-10">
<div class="flex gap-2 items-center">
<div>Order:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
value-attribute="id" option-attribute="label" v-model="order">
</USelectMenu>
</div>
<div class="flex gap-2 items-center">
<div>Limit:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
value-attribute="id" option-attribute="label" v-model="limit">
</USelectMenu>
</div>
<div class="flex gap-2 items-center">
<div>Filter:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Filter" :options="filterList"
value-attribute="id" option-attribute="label" v-model="filter">
</USelectMenu>
</div>
</div>
<div class="flex items-center gap-10 justify-center px-10 w-full">
<div class="flex gap-2 items-center shrink-0">
<div>Page {{ page }} </div>
<div> {{ Math.min(limit, projectsInfo?.count || 0) }} of {{ projectsInfo?.count || 0
}}</div>
</div>
<div>
<UPagination v-model="page" :page-count="limit" :total="projectsInfo?.count || 0" />
</div>
<UPopover class="w-[20rem]" :popper="{ placement: 'bottom' }">
<UButton class="w-full" color="primary" variant="solid">
<div class="flex items-center justify-center w-full gap-2">
<i class="i-heroicons-calendar-days-20-solid"></i>
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
</div>
</UButton>
<template #panel="{ close }">
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
<div class="hidden sm:flex flex-col py-4">
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
variant="ghost" class="rounded-none px-6"
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
truncate @click="selectRange(range.duration)" />
</div>
<DatePicker v-model="selected" @close="close" />
</div>
</template>
</UPopover>
</div>
<div class="w-[80%]">
<div v-if="pendingMetrics"> Loading... </div>
<div class="flex gap-10 flex-wrap" v-if="!pendingMetrics && metrics">
<div> Projects: {{ metrics.totalProjects }} ({{ metrics.premiumProjects }} premium) </div>
<div>
Total visits: {{ formatNumberK(metrics.totalVisits) }}
</div>
<div>
Active: {{ metrics.totalProjects - metrics.deadProjects }} |
Dead: {{ metrics.deadProjects }}
</div>
<div>
Total events: {{ formatNumberK(metrics.totalEvents) }}
</div>
</div>
</div>
</div>
<div
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[20rem]">
<AdminOverviewProjectCard v-if="!pendingProjects" :key="project._id.toString()" :project="project"
class="w-[26rem]" v-for="project of projectsInfo?.projects" />
<div v-if="pendingProjects"> Loading...</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,151 @@
<script lang="ts" setup>
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
import type { TAdminUser } from '~/server/api/admin/users';
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
const filterText = ref<string>('');
watch(filterText, () => {
page.value = 1;
})
function isRangeSelected(duration: Duration) {
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
}
function selectRange(duration: Duration) {
selected.value = { start: sub(new Date(), duration), end: new Date() }
}
const ranges = [
{ label: 'Last 7 days', duration: { days: 7 } },
{ label: 'Last 14 days', duration: { days: 14 } },
{ label: 'Last 30 days', duration: { days: 30 } },
{ label: 'Last 3 months', duration: { months: 3 } },
{ label: 'Last 6 months', duration: { months: 6 } },
{ label: 'Last year', duration: { years: 1 } }
]
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
const filter = computed(() => {
return JSON.stringify({
$or: [
{ given_name: { $regex: `.*${filterText.value}.*`, $options: "i" } },
{ email: { $regex: `.*${filterText.value}.*`, $options: "i" } }
]
})
})
const page = ref<number>(1);
const ordersList = [
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
]
const order = ref<string>('{ "created_at": -1 }');
const limitList = [
{ label: '10', id: 10 },
{ label: '20', id: 20 },
{ label: '50', id: 50 },
{ label: '100', id: 100 },
]
const limit = ref<number>(20);
const { data: usersInfo, pending: pendingUsers } = await useFetch<{ count: number, users: TAdminUser[] }>(
() => `/api/admin/users?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
signHeaders()
);
const { uiMenu } = useSelectMenuStyle();
</script>
<template>
<div class="mt-6 h-full">
<div class="flex flex-col items-center gap-6">
<div class="flex items-center gap-10 px-10">
<div class="flex gap-2 items-center">
<div>Order:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
value-attribute="id" option-attribute="label" v-model="order">
</USelectMenu>
</div>
<div class="flex gap-2 items-center">
<div>Limit:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
value-attribute="id" option-attribute="label" v-model="limit">
</USelectMenu>
</div>
<div class="flex gap-2 items-center">
<LyxUiInput placeholder="Search user" class="px-2 py-1" v-model="filterText"></LyxUiInput>
</div>
</div>
<div class="flex items-centet gap-10">
<div class="flex gap-2 items-center">
<div>Page {{ page }} </div>
<div>
{{ Math.min(limit, usersInfo?.count || 0) }}
of
{{ usersInfo?.count || 0 }}
</div>
</div>
<div>
<UPagination v-model="page" :page-count="limit" :total="usersInfo?.count || 0" />
</div>
<UPopover class="w-[20rem]" :popper="{ placement: 'bottom' }">
<UButton class="w-full" color="primary" variant="solid">
<div class="flex items-center justify-center w-full gap-2">
<i class="i-heroicons-calendar-days-20-solid"></i>
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
</div>
</UButton>
<template #panel="{ close }">
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
<div class="hidden sm:flex flex-col py-4">
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
variant="ghost" class="rounded-none px-6"
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
truncate @click="selectRange(range.duration)" />
</div>
<DatePicker v-model="selected" @close="close" />
</div>
</template>
</UPopover>
</div>
</div>
<div
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[16rem]">
<AdminUsersUserCard v-if="!pendingUsers" :key="user._id.toString()" :user="user" class="w-[26rem]"
v-for="user of usersInfo?.users" />
<div v-if="pendingUsers"> Loading...</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
import * as datefns from 'date-fns';
const errored = ref<boolean>(false);
const props = defineProps<{
labels: string[],
title: string,
datasets: {
points: number[],
color: string,
name: string
}[]
}>();
const chartOptions = ref<ChartOptions<'line'>>({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'nearest',
axis: 'x',
includeInvisible: true
},
scales: {
y: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
// borderDash: [5, 10]
},
},
x: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
}
}
},
plugins: {
legend: { display: true },
title: {
display: true,
text: props.title
},
tooltip: {
enabled: true,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleFont: { size: 16, weight: 'bold' },
bodyFont: { size: 14 },
padding: 10,
cornerRadius: 4,
boxPadding: 10,
caretPadding: 20,
yAlign: 'bottom',
xAlign: 'center',
}
},
});
const chartData = ref<ChartData<'line'>>({
labels: props.labels.map(e => {
try {
return datefns.format(new Date(e), 'dd/MM');
} catch (ex) {
return e;
}
}),
datasets: props.datasets.map(e => ({
data: e.points,
label: e.name,
backgroundColor: [e.color + '00'],
borderColor: e.color,
borderWidth: 2,
fill: true,
tension: 0.45,
pointRadius: 0,
pointHoverRadius: 10,
type: 'line'
} as any))
});
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
function createGradient(startColor: string) {
const c = document.createElement('canvas');
const ctx = c.getContext("2d");
let gradient: any = `${startColor}22`;
if (ctx) {
gradient = ctx.createLinearGradient(0, 25, 0, 300);
gradient.addColorStop(0, `${startColor}99`);
gradient.addColorStop(0.35, `${startColor}66`);
gradient.addColorStop(1, `${startColor}22`);
} else {
console.warn('Cannot get context for gradient');
}
return gradient;
}
onMounted(async () => {
try {
// chartData.value.datasets.forEach(dataset => {
// if (dataset.borderColor && dataset.borderColor.toString().startsWith('#')) {
// dataset.backgroundColor = [createGradient(dataset.borderColor as string)]
// } else {
// dataset.backgroundColor = [createGradient('#3d59a4')]
// }
// });
} catch (ex) {
errored.value = true;
console.error(ex);
}
});
</script>
<template>
<div>
<div v-if="errored"> ERROR CREATING CHART </div>
<LineChart v-if="!errored" ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
</div>
</template>

View File

@@ -0,0 +1,35 @@
<script lang="ts" setup>
import type { TAdminUserInfo } from '~/server/api/admin/user_info';
const props = defineProps<{ user_id: string }>();
const { data: userInfo, refresh, pending } = useFetch<{ projects: TAdminUserInfo }>(
() => `/api/admin/user_info?user_id=${props.user_id}`,
signHeaders(),
);
</script>
<template>
<div class="mt-6 h-full flex flex-col gap-10 w-full overflow-y-auto pb-[10rem]" v-if="!pending">
<div>
<LyxUiButton type="secondary" @click="refresh"> Refresh </LyxUiButton>
</div>
<div class="flex justify-center gap-10 flex-wrap" v-if="userInfo">
<AdminOverviewProjectCard v-for="project of userInfo.projects" :project="project as any"
class="w-[30rem] shrink-0" />
</div>
</div>
<div v-if="pending">
Loading...
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,62 @@
<script lang="ts" setup>
import { type ChartData, type ChartOptions } from 'chart.js';
import { PieChart, usePieChart } from 'vue-chart-3';
const props = defineProps<{ data: { _id: string, count: number }[], title:string }>();
const eventsTimelineOptions = ref<ChartOptions<'pie'>>({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
title: {
display: true,
text: props.title,
color: '#EEECF6',
},
},
});
const eventsTimelineData = computed<ChartData<'pie'>>(() => ({
labels: props.data.map(e => e._id),
datasets: [
{
data: props.data.map(e => e.count),
backgroundColor: [
"#295270",
"#304F71",
"#374C72",
"#3E4A73",
"#444773",
"#4B4474",
"#524175",
],
borderColor: '#222222'
},
],
}));
const { pieChartProps } = usePieChart({ chartData: eventsTimelineData, options: eventsTimelineOptions });
</script>
<template>
<div>
<div class="graph">
<PieChart v-bind="pieChartProps">
</PieChart>
</div>
</div>
</template>

View File

@@ -0,0 +1,142 @@
<script lang="ts" setup>
import type { TAdminProject } from '~/server/api/admin/projects';
import { getPlanFromId } from '~/shared/data/PLANS';
import { AdminDialogUserDetails } from '#components';
const { openDialogEx } = useCustomDialog();
function showUserDetails(user_id: string) {
openDialogEx(AdminDialogUserDetails, {
params: { user_id }
})
}
const props = defineProps<{ project: TAdminProject & { domains?: string[] } }>();
const logBg = computed(() => {
const day = 1000 * 60 * 60 * 24;
const week = 1000 * 60 * 60 * 24 * 7;
const lastLoggedAtDate = new Date(props.project.last_log_at || 0);
if (lastLoggedAtDate.getTime() > Date.now() - day) {
return 'bg-green-500'
} else if (lastLoggedAtDate.getTime() > Date.now() - week) {
return 'bg-yellow-500'
} else {
return 'bg-red-500'
}
});
const dateDiffDays = computed(() => {
const res = (Date.now() - new Date(props.project.last_log_at || 0).getTime()) / (1000 * 60 * 60 * 24)
if (res > -1 && res < 1) return 0;
return res;
});
const usageLabel = computed(() => {
return formatNumberK(props.project.limit_total) + ' / ' + formatNumberK(props.project.limit_max)
});
const usagePercentLabel = computed(() => {
const percent = 100 / props.project.limit_max * props.project.limit_total;
return `~ ${percent.toFixed(1)}%`;
});
const usageAiLabel = computed(() => {
return formatNumberK(props.project.limit_ai_messages) + ' / ' + formatNumberK(props.project.limit_ai_max);
}
); const usageAiPercentLabel = computed(() => {
const percent = 100 / props.project.limit_ai_max * props.project.limit_ai_messages;
return `~ ${percent.toFixed(1)}%`
});
</script>
<template>
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative h-fit">
<div class="absolute top-1 left-2 text-[.8rem] text-lyx-text-dark flex items-center gap-2">
<div :class="logBg" class="h-3 w-3 rounded-full"> </div>
<div class="mt-1"> {{ dateDiffDays.toFixed(0) }} days </div>
</div>
<div class="flex gap-4 justify-center text-[.9rem]">
<UTooltip :text="`PRICE_ID: ${project.premium[0].premium_type}`">
<div class="font-medium text-lyx-text-dark">
{{ getPlanFromId(project.premium[0].premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
</div>
</UTooltip>
<div class="text-lyx-text-darker">
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
</div>
</div>
<div class="flex flex-col items-center py-1">
<div class="text-center"> {{ project.name }} </div>
<div v-if="project.user" @click="showUserDetails(project.premium[0].user_id.toString())"
class="font-medium hover:text-lyx-primary cursor-pointer text-center">
{{ project.user[0].email }}
</div>
</div>
<div class="flex flex-col items-center mt-2">
<div class="flex gap-4">
<div class="flex gap-2">
<div class="text-right"> Visits:</div>
<div>{{ formatNumberK(project.visits || 0) }}</div>
</div>
<div class="flex gap-2">
<div class="text-right"> Events:</div>
<div>{{ formatNumberK(project.events || 0) }}</div>
</div>
<div class="flex gap-2">
<div class="text-right"> Sessions:</div>
<div>{{ formatNumberK(project.sessions || 0) }}</div>
</div>
</div>
</div>
<LyxUiSeparator class="my-2" />
<div class="mb-2">
<UProgress :value="project.limit_visits + project.limit_events" :max="project.limit_max"></UProgress>
</div>
<div class="flex gap-6 justify-around">
<div class="flex gap-1">
<div>
{{ usageLabel }}
</div>
<div class="text-lyx-text-dark">
{{ usagePercentLabel }}
</div>
</div>
<div class="flex gap-2">
<div>
{{ usageAiLabel }}
</div>
<div class="text-lyx-text-dark">
{{ usageAiPercentLabel }}
</div>
</div>
</div>
<LyxUiSeparator class="my-2" />
<div v-if="project.domains" class="flex flex-wrap gap-4">
<div v-for="domain of project.domains" class="hover:text-gray-200 cursor-pointer">
{{ domain }}
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,112 @@
<script lang="ts" setup>
import type { TAdminProject } from '~/server/api/admin/projects';
import type { TAdminUser } from '~/server/api/admin/users';
import { getPlanFromId } from '~/shared/data/PLANS';
import { AdminDialogUserDetails } from '#components';
const { openDialogEx } = useCustomDialog();
function showUserDetails(user_id: string) {
openDialogEx(AdminDialogUserDetails, {
params: { user_id }
})
}
const props = defineProps<{ user: TAdminUser }>();
</script>
<template>
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative max-h-[15rem]">
<div class="flex gap-4 justify-center text-[.9rem]">
<div class="font-medium text-lyx-text-dark">
{{ user.name ?? user.given_name }}
</div>
<div class="text-lyx-text-darker">
{{ new Date(user.created_at).toLocaleDateString('it-IT') }}
</div>
</div>
<div class="flex gap-5 justify-center">
<div class="font-medium hover:text-blue-400 cursor-pointer" @click="showUserDetails(user._id.toString())">
{{ user.email }}
</div>
</div>
</div>
<!-- <div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative">
<div class="absolute top-1 left-2 text-[.8rem] text-lyx-text-dark flex items-center gap-2">
<div :class="logBg" class="h-3 w-3 rounded-full"> </div>
<div class="mt-1"> {{ dateDiffDays.toFixed(0) }} days </div>
</div>
<div class="flex gap-4 justify-center text-[.9rem]">
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
<div class="font-medium text-lyx-text-dark">
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
</div>
</UTooltip>
<div class="text-lyx-text-darker">
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
</div>
</div>
<div class="flex gap-5 justify-center">
<div class="font-medium">
{{ project.name }}
</div>
</div>
<div class="flex flex-col items-center mt-2">
<div class="flex gap-4">
<div class="flex gap-2">
<div class="text-right"> Visits:</div>
<div>{{ formatNumberK(project.visits || 0) }}</div>
</div>
<div class="flex gap-2">
<div class="text-right"> Events:</div>
<div>{{ formatNumberK(project.events || 0) }}</div>
</div>
<div class="flex gap-2">
<div class="text-right"> Sessions:</div>
<div>{{ formatNumberK(project.sessions || 0) }}</div>
</div>
</div>
</div>
<LyxUiSeparator class="my-2" />
<div class="mb-2">
<UProgress :value="project.limit_visits + project.limit_events" :max="project.limit_max"></UProgress>
</div>
<div class="flex gap-6 justify-around">
<div class="flex gap-1">
<div>
{{ usageLabel }}
</div>
<div class="text-lyx-text-dark">
{{ usagePercentLabel }}
</div>
</div>
<div class="flex gap-2">
<div>
{{ usageAiLabel }}
</div>
<div class="text-lyx-text-dark">
{{ usageAiPercentLabel }}
</div>
</div>
</div>
</div> -->
</template>
<style scoped lang="scss"></style>

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,35 @@
<script lang="ts" setup>
const { showDrawer } = useDrawer();
function goToUpgrade() {
showDrawer('PRICING');
}
const { isPremium } = useLoggedUser()
</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

@@ -1,10 +1,26 @@
<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';
registerChartComponents();
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,
@@ -24,9 +40,12 @@ const chartOptions = ref<ChartOptions<'line'>>({
color: '#CCCCCC22',
// borderDash: [5, 10]
},
beginAtZero: true,
},
x: {
ticks: { display: true },
stacked: false,
offset: false,
grid: {
display: true,
drawBorder: false,
@@ -65,15 +84,35 @@ const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
borderColor: '#5655d7',
borderWidth: 4,
fill: true,
tension: 0.45,
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',
label: 'Unique visitors',
data: [],
backgroundColor: ['#4abde8'],
borderColor: '#4abde8',
@@ -81,24 +120,25 @@ const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
hoverBackgroundColor: '#4abde8',
hoverBorderColor: '#4abde8',
hoverBorderWidth: 2,
type: 'bar'
type: 'bar',
// barThickness: 20,
borderSkipped: ['bottom']
},
{
label: 'Events',
data: [],
backgroundColor: ['#fbbf24'],
borderColor: '#fbbf24',
borderWidth: 2,
hoverBackgroundColor: '#fbbf24',
hoverBorderColor: '#fbbf24',
hoverBorderWidth: 2,
type: 'bubble',
stack: 'combined'
},
stack: 'combined',
borderColor: ["#fbbf24"]
}
],
});
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
const externalTooltipElement = ref<null | HTMLDivElement>(null);
@@ -107,6 +147,17 @@ function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'li
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;
@@ -119,94 +170,104 @@ function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'li
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 + 'px';
tooltipEl.style.left = positionX + (tooltip.caretX + xSwap) + 'px';
tooltipEl.style.top = positionY + tooltip.caretY + 'px';
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px';
}
const { snapshotDuration } = useSnapshot();
const selectLabels: { label: string, value: Slice }[] = [
{ label: 'Hour', value: 'hour' },
{ label: 'Day', value: 'day' },
{ label: 'Month', value: 'month' },
];
const selectLabelsAvailable = computed<{ label: string, value: Slice, disabled: boolean }[]>(() => {
return selectLabels.map(e => {
return { ...e, disabled: !DateService.canUseSliceFromDays(snapshotDuration.value, e.value)[0] }
});
})
const selectedSlice = computed<Slice>(() => {
console.log({ available: selectLabelsAvailable.value })
const targetValue = selectLabelsAvailable.value[selectedLabelIndex.value];
if (!targetValue) return 'day';
if (targetValue.disabled) {
selectedLabelIndex.value = selectLabelsAvailable.value.findIndex(e => !e.disabled);
}
if (selectedLabelIndex.value === -1) {
console.error('ERROR CANNOT FIND CORRECT SLICE')
return 'day';
}
return selectLabelsAvailable.value[selectedLabelIndex.value].value
});
const selectedLabelIndex = ref<number>(1);
const activeProject = useActiveProject();
const { safeSnapshotDates } = useSnapshot()
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, navigator.language, selectLabels[selectedLabelIndex.value].value));
allDatesFull.value = input.map(e => e._id.toString());
return { data, labels }
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice.value));
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
const current = (Date.now());
//console.log(input.map(e => e._id));
//console.log(new Date(current));
const todayIndex = input.findIndex(e => new Date(e._id).getTime() >= current);
//console.log({ todayIndex })
return { data, labels, todayIndex: todayIndex + 1 }
}
const body = computed(() => {
return {
from: safeSnapshotDates.value.from,
to: safeSnapshotDates.value.to,
slice: selectLabels[selectedLabelIndex.value].value
}
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 visitsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
lazy: true, immediate: false
const sessionsData = useFetch('/api/timeline/sessions', {
headers: useComputedHeaders({ slice: selectedSlice }), lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
lazy: true, immediate: false
const eventsData = useFetch('/api/timeline/events', {
headers: useComputedHeaders({ slice: selectedSlice }), lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const sessionsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/sessions`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
lazy: true, immediate: false
});
const readyToDisplay = computed(() => {
return !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value;
});
const readyToDisplay = computed(() => !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value);
watch(readyToDisplay, () => {
if (readyToDisplay.value === true) onDataReady();
})
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;
}
function onDataReady() {
console.log('DATA READY');
if (!visitsData.data.value) return;
if (!eventsData.data.value) return;
if (!sessionsData.data.value) return;
console.log('DATA READY 2');
chartData.value.labels = visitsData.data.value.labels;
const maxChartY = Math.max(...visitsData.data.value.data, ...sessionsData.data.value.data);
@@ -214,18 +275,33 @@ function onDataReady() {
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 = 25 / maxEventSize * e;
return { x: 0, y: maxChartY + 70, r: isNaN(rValue) ? 0 : rValue, r2: 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')];
console.log('UPDATE CHART');
updateChart();
(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 }>({
@@ -238,23 +314,16 @@ const currentTooltipData = ref<{ visits: number, events: number, sessions: numbe
const tooltipNameIndex = ['visits', 'sessions', 'events'];
function onLegendChange(dataset: any, index: number, checked: any) {
dataset.hidden = !checked;
const newValue = !checked;
dataset.hidden = newValue;
}
const legendColors = [
'#5655d7',
'#4abde8',
'#fbbf24'
]
onMounted(async () => {
visitsData.execute();
eventsData.execute();
sessionsData.execute();
});
const legendColors = ref<string[]>(['#5655d7', '#4abde8', '#fbbf24'])
const legendClasses = ref<string[]>([
'actionable-visits-color-checkbox',
'actionable-sessions-color-checkbox',
'actionable-events-color-checkbox'
])
</script>
@@ -262,23 +331,25 @@ onMounted(async () => {
<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="selectLabels">
</SelectButton>
<SelectButton class="w-fit" @changeIndex="selectedLabelIndex = $event" :currentIndex="selectedLabelIndex"
:options="selectLabelsAvailable">
</SelectButton>
</template>
<div class="flex gap-6 w-full justify-between">
<LyxUiButton type="secondary" to="/analyst">
<div class="flex gap-6 w-full justify-between lg:flex-row flex-col">
<LyxUiButton type="secondary" :to="isLiveDemo ? '#' : '/analyst'" :disabled="isLiveDemo">
<div class="flex items-center gap-2 px-10">
<i class="far fa-sparkles text-yellow-400"></i>
<div class="poppins text-lyx-text"> Ask AI </div>
<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: `text-[${legendColors[index]}]`
color: legendClasses[index]
}" :model-value="true" @change="onLegendChange(dataset, index, $event)"></UCheckbox>
<label class="mt-[2px]"> {{ dataset.label }} </label>
</div>
</div>
@@ -287,7 +358,7 @@ onMounted(async () => {
<div id='external-tooltip' ref="externalTooltipElement" class="z-[400]">
<LyxUiCard>
<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>
@@ -300,6 +371,12 @@ onMounted(async () => {
{{ (currentTooltipData as any)[tooltipNameIndex[index]] }}
</div>
</div>
<div class="mt-3 font-normal flex flex-col text-[.9rem] dark:text-lyx-text-dark text-lyx-lightmode-text-dark"
v-if="(currentTooltipData as any).sessions > (currentTooltipData as any).visits">
<div> Unique visitors are higher than total visits </div>
<div> which often means bots (automated scripts or crawlers)</div>
<div> are inflating the numbers.</div>
</div>
<!-- <div class="bg-lyx-background-lighter h-[2px] w-full my-2"> </div> -->
</LyxUiCard>
</div>
@@ -310,9 +387,14 @@ onMounted(async () => {
<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">
<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>

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]"> Trend </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 timeframes </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
@@ -77,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();
@@ -98,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

@@ -1,145 +1,160 @@
<script lang="ts" setup>
import DateService from '@services/DateService';
import type { Slice } from '@services/DateService';
import DateService, { type Slice } from '../../shared/services/DateService';
const { data: metricsInfo } = useMetricsData();
const { snapshot, safeSnapshotDates } = useSnapshot()
const { snapshot, safeSnapshotDates, snapshotDuration } = useSnapshot()
const snapshotFrom = computed(() => new Date(snapshot.value?.from || '0').getTime());
const snapshotTo = computed(() => new Date(snapshot.value?.to || Date.now()).getTime());
const snapshotDays = computed(() => {
return (snapshotTo.value - snapshotFrom.value) / 1000 / 60 / 60 / 24;
const chartSlice = computed(() => {
if (snapshotDuration.value <= 3) return 'hour' as Slice;
if (snapshotDuration.value <= 31 * 2) return 'day' as Slice;
return 'month' as Slice;
});
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) + ' %';
})
function weightedAverage(data: number[]): number {
if (data.length === 0) return 0;
// Compute median
const sortedData = [...data].sort((a, b) => a - b);
const middle = Math.floor(sortedData.length / 2);
const median = sortedData.length % 2 === 0
? (sortedData[middle - 1] + sortedData[middle]) / 2
: sortedData[middle];
// Define a threshold (e.g., 3 times the median) to filter out extreme values
const threshold = median * 3;
const filteredData = data.filter(num => num <= threshold);
if (filteredData.length === 0) return median; // Fallback to median if all are removed
// Compute weights based on inverse absolute deviation from median
const weights = filteredData.map(num => 1 / (1 + Math.abs(num - median)));
// Compute weighted sum and sum of weights
const weightedSum = filteredData.reduce((sum, num, i) => sum + num * weights[i], 0);
const sumOfWeights = weights.reduce((sum, weight) => sum + weight, 0);
return weightedSum / sumOfWeights;
}
const avgSessionDuration = computed(() => {
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 = weightedAverage(sessionsDurationData.data.value.data);
// counts / (Math.max(sessionsDurationData.data.value.data.length, 1));
let hours = 0;
let minutes = 0;
let seconds = 0;
seconds += avg * 60;
while (seconds > 60) { seconds -= 60; minutes += 1; }
while (minutes > 60) { minutes -= 60; hours += 1; }
while (seconds >= 60) { seconds -= 60; minutes += 1; }
while (minutes >= 60) { minutes -= 60; hours += 1; }
if (hours == 0 && minutes == 0 && seconds < 10) return `0m ~10s`
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()));
})
</script>
<template>
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4">
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 m-cards-wrap:grid-cols-4">
<DashboardCountCard :ready="!visitsData.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="Total 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>
</div>
</template>
</template>

View File

@@ -1,72 +1,65 @@
<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-bug',
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="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="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-[.8rem] h-[.8rem] bg-green-400 rounded-full"> </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 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-[1.2rem]">Project id:</div>
<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-[1.2rem]">
{{ activeProject?._id || 'Loading...' }}
<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-[1.2rem]"></i>
class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[.9rem]"></i>
</div>
</div>
</div>
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
</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-[1rem]"> AI Anomaly Detector </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> -->
</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

@@ -0,0 +1,38 @@
<script lang="ts" setup>
const emit = defineEmits(['success', 'cancel'])
</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 class="font-medium">
Are you sure to logout ?
</div>
<div class="flex justify-end gap-2">
<LyxUiButton type="secondary" @click="emit('cancel')">
Cancel
</LyxUiButton>
<LyxUiButton @click="emit('success')" type="danger">
Confirm
</LyxUiButton>
</div>
</div>
</div>
</UModal>
</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('Timeframe created', 'Timeframe created successfully', 'far fa-circle-check', 5000);
const newSnapshot = snapshots.value.at(-1);
if (newSnapshot) snapshot.value = newSnapshot;
}
</script>
@@ -61,8 +64,8 @@ async function confirmSnapshot() {
<template>
<div class="w-full h-full flex flex-col">
<div class="poppins text-center">
Create a snapshot
<div class="poppins text-center text-lyx-lightmode-text dark:text-lyx-text">
Create a timeframe
</div>
<div class="mt-10 flex items-center gap-2">
@@ -71,12 +74,11 @@ async function confirmSnapshot() {
<input @input="onColorChange" ref="colorpicker" class="relative w-0 h-0 z-[-100]" type="color">
</div>
<div class="grow">
<LyxUiInput placeholder="Snapshot name" v-model="snapshotName" class="px-4 py-1 w-full"></LyxUiInput>
<LyxUiInput placeholder="Timeframe name" v-model="snapshotName" class="px-4 py-1 w-full"></LyxUiInput>
</div>
</div>
<div class="mt-4 justify-center flex w-full">
<UPopover class="w-full" :popper="{ placement: 'bottom' }">
<UButton class="w-full" color="primary" variant="solid">
<div class="flex items-center justify-center w-full gap-2">
@@ -97,8 +99,6 @@ async function confirmSnapshot() {
</div>
</template>
</UPopover>
</div>
<div class="grow"></div>
@@ -107,7 +107,7 @@ async function confirmSnapshot() {
Cancel
</LyxUiButton>
<LyxUiButton @click="confirmSnapshot()" type="primary" class="w-full text-center"
:disabled="snapshotName.length == 0">
:disabled="snapshotName.trim().length == 0">
Confirm
</LyxUiButton>
</div>

View File

@@ -0,0 +1,84 @@
<script lang="ts" setup>
import type { ButtonType } from '../LyxUi/Button.vue';
const emit = defineEmits(['success', 'cancel'])
const props = defineProps<{
buttonType: ButtonType,
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-lightmode-widget dark: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,58 @@
<script lang="ts" setup>
const { createAlert } = useAlert();
const { close } = useModal()
function copyEmail() {
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
navigator.clipboard.writeText('help@litlyx.com');
createAlert('Success', 'Email copied successfully.', 'far fa-circle-check', 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 class="font-medium">
Contact Support
</div>
<div class="dark:text-lyx-text-dark">
Contact Support for any questions or issues you have.
</div>
<div class="dark:bg-lyx-widget-lighter bg-lyx-lightmode-widget h-[1px]"></div>
<div class="flex items-center justify-between gap-4">
<div class="p-2 bg-lyx-lightmode-widget dark:bg-[#1c1b1b] rounded-md w-full">
<div class="w-full text-[.9rem] dark:text-[#acacac]"> help@litlyx.com </div>
</div>
<LyxUiButton type="secondary" @click="copyEmail()"> Copy </LyxUiButton>
<LyxUiButton type="secondary" to="mailto:help@litlyx.com"> Send </LyxUiButton>
</div>
<div class="dark:text-lyx-text-dark mt-2">
or text us on Discord, we will reply to you personally.
</div>
<LyxUiButton to="https://discord.gg/9cQykjsmWX" target="_blank" type="secondary">
Discord Support
</LyxUiButton>
</div>
</div>
</UModal>
</template>

View File

@@ -0,0 +1,86 @@
<script lang="ts" setup>
import Accept_invite from '~/pages/accept_invite.vue';
const { createAlert } = useAlert();
const { close } = useModal()
const emit = defineEmits(['success', 'cancel'])
const props = defineProps<{
invites: {
project_name: string, project_id: string
}[]
}>();
async function acceptInvite(project_id: string) {
try {
await $fetch('/api/project/members/accept', {
method: 'POST',
body: JSON.stringify({ project_id }),
headers: useComputedHeaders({
custom: {
'Content-Type': 'application/json'
}
}).value
});
emit('success');
} catch (ex) {
console.error(ex);
alert('Error accepting invite');
emit('cancel');
}
}
async function declineInvite(project_id: string) {
try {
await $fetch('/api/project/members/decline', {
method: 'POST',
body: JSON.stringify({ project_id }),
headers: useComputedHeaders({
custom: {
'Content-Type': 'application/json'
}
}).value
});
emit('success');
} catch (ex) {
console.error(ex);
alert('Error accepting invite');
emit('cancel');
}
}
</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-8 p-6">
<div class="flex flex-col gap-6" v-for="invite of invites">
<div class="dark:text-lyx-text text-lyx-lightmode-text">
You are invited to join
<span class="font-semibold">{{ invite.project_name }}</span>.
Do you accept?
</div>
<div class="flex gap-4 w-full justify-end">
<LyxUiButton @click="declineInvite(invite.project_id)" type="secondary"> Decline </LyxUiButton>
<LyxUiButton @click="acceptInvite(invite.project_id)" type="primary"> Accept </LyxUiButton>
</div>
</div>
</div>
</UModal>
</template>

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