120 Commits

Author SHA1 Message Date
Emily
73309e7021 . 2024-10-10 15:54:14 +02:00
Emily
80e3b0caa9 add email login 2024-10-10 15:49:55 +02:00
Emily
0a7f2b58d0 improve 2024-10-09 19:35:07 +02:00
Emily
e953af2c1b fix 2024-10-09 15:30:59 +02:00
Emily
126296d28f . 2024-10-08 19:30:49 +02:00
Emily
8dd10deecc . 2024-10-08 19:30:11 +02:00
Emily
f22e65ccc5 fix pricing 2024-10-08 19:28:11 +02:00
Emily
dfbc64fe33 rewrite analyst UI 2024-10-08 19:23:30 +02:00
Emily
9568566361 rewrite litlyx 2024-10-08 18:47:30 +02:00
Emily
634cb641f1 fix project creation 2024-10-08 18:24:00 +02:00
Emily
204e1348b4 fix docs wfull 2024-10-08 18:22:14 +02:00
Emily
b73155a176 fix devices 2024-10-08 15:31:45 +02:00
Emily
62c72b3ff9 fix csv endpoint 2024-10-08 15:24:23 +02:00
Emily
79e956e930 rewrite litlyx 2024-10-08 15:12:04 +02:00
Emily
b27cacf4e6 rewrite 2024-10-07 15:26:57 +02:00
Emily
c2846ca595 rewrite settings + banners 2024-10-04 14:39:08 +02:00
Emily
e1953f2f9f Rewrite endpoints + bar cards 2024-10-03 15:07:16 +02:00
Emily
314660d8a3 change in progress 2024-10-02 17:05:34 +02:00
Emily
f516c53b7b Merge branch 'main' of https://github.com/Litlyx/litlyx 2024-10-01 14:25:58 +02:00
Emily
dad8c521ee fix ai + chart + events 2024-10-01 14:25:56 +02:00
antonio
089d1a418e updated read me 2024-09-30 19:13:15 +02:00
Emily
a08624b69b fix csv export 2024-09-30 17:08:23 +02:00
Emily
3ba6cd171b add bouncing rate + adjustments 2024-09-30 17:01:16 +02:00
Emily
1828edf98b remove lifetime deal 2024-09-29 15:09:35 +02:00
Emily
96c39dbba1 fix docker-compose 2024-09-28 14:24:47 +02:00
Emily
9403aebbb9 update dockercompose 2024-09-28 14:21:53 +02:00
Emily
69bb6fb03c fix cors on api 2024-09-28 13:42:40 +02:00
Emily
33b730e66b add cors + adjusting dockerfile 2024-09-28 13:37:10 +02:00
Emily
0ba44a406d removed old dirs + start dockerfile unification 2024-09-27 21:14:23 +02:00
Emily
3c77a727cd update 2024-09-27 20:33:49 +02:00
Emily
8e3ad2920f fix script url 2024-09-26 15:54:51 +02:00
Emily
f4401d74a2 fix ecosystem 2024-09-26 15:53:00 +02:00
Emily
375330bac4 add ecosystem 2024-09-26 15:51:59 +02:00
Emily
3b1ee0fd13 add security loop 2024-09-26 15:48:59 +02:00
Emily
f5edf187fd update security + chart events è anomaly service 2024-09-24 16:48:23 +02:00
Emily
5b7e93bcbb add cap to dates on slices 2024-09-23 15:01:14 +02:00
Emily
3b6a202538 better logging 2024-09-23 14:03:37 +02:00
Emily
cf1aa103e4 fix security page 2024-09-21 15:19:41 +02:00
Emily
4eeebaa0c3 better first interactions + bug fix 2024-09-21 15:14:46 +02:00
Emily
f285e92132 add secutiry 2024-09-19 15:44:27 +02:00
Emily
ac7ba7abd3 fix 2024-09-19 14:00:12 +02:00
Emily
3c59551f88 new consumer 2024-09-18 23:05:07 +02:00
Emily
628e471cec update consumers 2024-09-18 17:57:25 +02:00
Emily
0be3dbecbf Services rewrite 2024-09-18 17:43:04 +02:00
Emily
fa5a37ece2 . 2024-09-17 13:41:52 +02:00
Emily
db32afe741 better processed logs 2024-09-17 13:39:56 +02:00
Emily
e813b3246d remove console.log 2024-09-17 13:38:47 +02:00
Emily
86011c38ce add logger 2024-09-17 13:38:02 +02:00
Emily
fd5eca29cc remove test error 2024-09-16 20:39:07 +02:00
Emily
a591b43600 fixes 2024-09-16 20:09:32 +02:00
Emily
cebb45484c add logger 2024-09-16 20:09:15 +02:00
Emily
e4e2c2a42a fix anomaly 2024-09-16 20:09:07 +02:00
Emily
dfa1407102 fix anomaly service 2024-09-16 20:08:58 +02:00
Emily
e6adbf9c7b enchance ai 2024-09-16 15:37:18 +02:00
Emily
c3904ebd55 Add advanced ai 2024-09-16 01:03:49 +02:00
Emily
4c46a36c75 add anomaly + fix billing + add emails templates 2024-09-14 17:07:46 +02:00
Emily
c253846b86 fix email 2024-09-13 19:02:15 +02:00
Emily
e7c2dbf237 fix dates 2024-09-13 15:25:26 +02:00
Emily
525a371a6e . 2024-09-12 16:16:19 +02:00
Emily
6a9a698b7a add colors + fix billing page 2024-09-11 15:13:03 +02:00
Emily
4134d33dc4 fix pdf + admin panel 2024-09-10 16:59:34 +02:00
Emily
5172ad4f4d add api support 2024-09-09 14:43:27 +02:00
Emily
be45448288 add api keys 2024-09-08 15:51:03 +02:00
Emily
73739dde9d fix pricing + limits email + redis 2024-09-07 15:47:13 +02:00
Emily
30b3ed80e2 fix pricing + stripe payments 2024-09-05 16:56:21 +02:00
antonio
8e56069b1a fix on readme curl example 2024-09-05 13:14:23 +02:00
Antonio Verdiglione
3ecdec9ca9 Merge pull request #16 from art-santos/issue-15
fix: css bug in header
2024-09-05 11:54:14 +02:00
Arthur Santos
7b41a3ed0d fix: css bug in header 2024-09-04 09:28:56 -03:00
Emily
5804d7a73b fix support type from Discord to Slack 2024-09-04 14:01:06 +02:00
Emily
8b026099de fix dashboard + live demo 2024-09-04 14:00:03 +02:00
Emily
d7e18d570f fix userAgent device type 2024-09-04 13:59:53 +02:00
Emily
023f2b5f4a aggregation optimization 2024-09-02 18:37:02 +02:00
Emily
c003b655ec aggregation optimization 2024-09-02 18:36:52 +02:00
Emily
d499aa2f39 remove project_max text 2024-09-02 18:14:25 +02:00
Emily
944996eb15 add proper limit + csv lock 2024-09-02 15:24:29 +02:00
antonio
87b1f9caf9 improvements on readme 2024-09-01 14:49:44 +02:00
Emily
748894b946 . 2024-08-30 17:32:36 +02:00
Emily
01e8a9ab1d . 2024-08-30 17:30:18 +02:00
Emily
a2034551ec add log to stream loop 2024-08-30 17:27:34 +02:00
Emily
6d26c3c8af add brevo email 2024-08-30 16:26:53 +02:00
Emily
518b4ce6c1 add blog-post + links 2024-08-30 14:59:17 +02:00
Emily
71bd4d0e58 remove beta text 2024-08-30 14:16:07 +02:00
Emily
0563a833eb . 2024-08-29 16:34:26 +02:00
Emily
ab07ffb108 fix limits 2024-08-29 16:31:50 +02:00
Emily
79309cc537 add tests infrastructure 2024-08-29 16:31:44 +02:00
Emily
9b9ed3e9ad update mailsave to https 2024-08-29 15:09:43 +02:00
Emily
1cb6b92d5c add mail + github stars 2024-08-29 14:55:42 +02:00
Antonio Verdiglione
c1bdc30933 Merge pull request #12 from bradenhirschi/readme
Updated readme for better English
2024-08-19 15:59:36 +02:00
Braden Hirschi
887ed45b4d Updated readme for better English 2024-08-09 12:15:08 -07:00
Emily
1b88bad32d Merge branch 'snapshots' 2024-08-07 15:06:44 +02:00
Emily
4c9efda9ca fix reactivity 2024-08-07 15:06:06 +02:00
Emily
0c8ec73722 fix reactivity 2024-08-07 02:11:35 +02:00
Emily
02db836003 fix ui + sessions + reactivity 2024-08-06 15:32:46 +02:00
Emily
46774bd114 fix reactivity 2024-08-06 03:00:24 +02:00
Emily
ba1d6c4bd0 fix visits + sessions reactivity 2024-08-04 00:33:28 +02:00
Emily
5a26c8c788 fix topcards reactivity 2024-08-03 19:39:12 +02:00
Emily
cc39043a68 updating ui 2024-08-03 16:14:02 +02:00
Emily
93f22dfc54 implementing snapshots 2024-08-02 16:09:11 +02:00
Emily
376b39e247 implementing snapshots 2024-08-01 23:35:32 +02:00
Emily
6c32b64ac6 implementing snapshots + change ui 2024-07-31 16:02:00 +02:00
Emily
7cb10f5aa1 implementing snapshots 2024-07-31 15:34:35 +02:00
Emily
4bede171fa implementing snapshots 2024-07-30 15:18:25 +02:00
Emily
f72bc33871 implementing snapshots 2024-07-30 15:04:56 +02:00
Emily
bc27d7cded implementing snapshots 2024-07-29 16:07:15 +02:00
Emily
7b54c109f0 implementing snapshots 2024-07-29 15:21:39 +02:00
Emily
229c341d7a implementing snapshots 2024-07-26 16:18:20 +02:00
Emily
985b3af2e0 fix landing page 2024-07-26 14:29:17 +02:00
Emily
fc78b3bb43 implementig snapshots 2024-07-26 14:28:29 +02:00
Emily
af32669b32 add custom fetch 2024-07-26 02:23:42 +02:00
Emily
e9505e24a0 implementing snapshots 2024-07-26 01:29:58 +02:00
Emily
d25bc72623 Merge branch 'dev' 2024-07-25 14:25:37 +02:00
Emily
2c9f5c45f8 fix typo + fix privacy policy 2024-07-25 14:25:05 +02:00
Emily
b5b92b947c Merge pull request #11 from eltociear/patch-1
docs: update README.md
2024-07-24 17:32:33 +02:00
Emily
7ae4766771 new ui 2024-07-24 17:28:29 +02:00
Emily
895ebb197d update landing page 2024-07-24 17:16:40 +02:00
Ikko Eltociear Ashimine
39b58c65ca docs: update README.md
Avarage -> Average
2024-07-25 00:13:12 +09:00
Emily
b5f1783050 update landing 2024-07-24 15:35:58 +02:00
Emily
e6c9ad9470 new landing page ui 2024-07-23 18:13:45 +02:00
Emily
3eb32145aa . 2024-07-22 16:31:54 +02:00
Emily
f3542f711b . 2024-07-22 15:07:51 +02:00
441 changed files with 17540 additions and 70478 deletions

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

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ PROCESS_EVENT
docker
dev
docker-compose.admin.yml
full_reload.sh

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

@@ -0,0 +1,5 @@
{
"files.exclude": {
"**/node_modules": true
}
}

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM node:21-alpine as base
FROM base as build
RUN npm i -g pnpm
RUN npm i -g pm2
# COPY --link dashboard/package.json dashboard/pnpm-lock.yaml ./
# RUN npm install --production=false
WORKDIR /home/app
COPY --link dashboard ./dashboard
COPY --link lyx-ui ./lyx-ui
COPY --link consumer ./consumer
COPY --link producer ./producer
COPY --link shared ./shared
WORKDIR /home/app/producer
RUN pnpm install
WORKDIR /home/app/consumer
RUN pnpm install
WORKDIR /home/app/dashboard
RUN pnpm install
RUN pnpm run dev
# CMD [ "node", "/home/app/.output/server/index.mjs" ]

View File

@@ -1,18 +1,16 @@
<p align="center">
<img src="assets/claim-t.png"/>
<img src="assets/claim.png"/>
</p>
<h4 align="center">
🌐 <a href="https://litlyx.com">Website</a> 📚 <a href="https://docs.litlyx.com">Docs</a> 🔥 <a href="https://dashboard.litlyx.com">Start for Free!</a>
🌐 <a href="https://litlyx.com">Website</a> 📚 <a href="https://docs.litlyx.com">Docs</a> 👾 <a href="https://discord.gg/9cQykjsmWX">Join Discord</a> 🔥 <a href="https://dashboard.litlyx.com">Try Litlyx Cloud. It's Free.</a>
</h4>
<br />
#
<p align="center">
The easiest Dev-Centric Analytics tool.<br>Litlyx is , Open-Source, Plug-In everywhere Javascript is Supported. Setup in less then 30 seconds, with just One-Line of code.
The freshest, developer-friendly analytics tool.<br>
Litlyx is an open-source, self-hostable analytics solution for modern frameworks. Setup takes less than 30 seconds!
</p>
#
@@ -20,39 +18,35 @@
<br />
<p align="center">
<img src="assets/screen.png"/>
<img src="assets/dashboard-clip.png"/>
</p>
#
![GitHub Repo stars](https://img.shields.io/github/stars/Litlyx/litlyx)
![NPM Version](https://img.shields.io/npm/v/litlyx?logo=npm&color=orange)
![npm bundle size](https://img.shields.io/bundlephobia/min/litlyx)
## Get Started on our Cloud Version
## Pre-Requisites
Sign-up on [Litlyx cloud](https://dashboard.litlyx.com) using OAuth & name your project to get your project_id to connect Litlyx to your website OR Self-Host Litlyx with Docker.
Sign-up on [Litlyx.com](https://dashboard.litlyx.com) and create a project. Then simply use your project_id to connect Litlyx to your website OR Self-Host Litlyx with Docker.
## Universal Installation
```html
<script defer data-project="project_id_here" src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>
<script defer data-project="your_project_id" src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>
```
Importing Litlyx with a direct script already tracks 10 KPIs such as `Page visits`, `Browsers`, `Devices`, `OS`, `Real-Time Online Users`, `Unique Session`, `Countries`, `Avarage Session Time`.
Importing Litlyx with a direct script instantly starts tracking `Page visits`, `Browsers`, `Devices`, `Operating Systems`, `Bouncing Rate`, `Real-Time Online Users`, `Unique Sessions`, `Countries`, and `Average Session Time`.
# All Javascript Runtimes
You can install Litlyx using `npm`, `yarn`, or `pnpm`:
You can install Litlyx using `npm`, `pnpm`, `yarn` or any modern package managers:
```sh
npm i litlyx-js
```
Litlyx natively supports all JS/TS frameworks. You can use Litlyx in all WordPress Websites by injecting JS code using a plug-in. Litlyx work in serverless enviroments with Cloud (or Edge) Functions.
Litlyx natively works with all JavaScript / TypeScript frameworks. You can use Litlyx in all WordPress Websites by injecting JS code using a plug-in. Litlyx also works in serverless enviroments with Cloud (or Edge) Functions.
<p align="center">
<img src="assets/techs.png" />
<img src="assets/tech.png" />
</p>
# Import
@@ -69,54 +63,75 @@ Once imported, you need to initialize Litlyx:
Lit.init('your_project_id');
```
After initialization, Litlyx will automatically track Analytics such as `Page visits`, `Browsers`, `Devices`, `OS`, `Real-Time Online Users`, `Unique Session`, `Countries`, `Avarage Session Time`.
After initialization, Litlyx will automatically track analytics such as `Page visits`, `Browsers`, `Devices`, `Operating Systems`, `Real-Time Online Users`, `Unique Sessions`, `Countries`, and `Average Session Time`.
# Custom Events
# Track Custom Events
With Litlyx, you can create your own events to track in your project.
You aren't just limited to the built-in KPIs. With Litlyx, you can create your own events to track in your project.
```js
Lit.event('click_on_buy_item');
```
If you want more dept tracking, you can use the `metadata` field, like this:
If you want more specific tracking, you can use the `metadata` field, like this:
```js
Lit.event('click_on_buy_item', {
metadata: {
'product-name': 'Coca-Cola',
'price': 1.50,
'currency': 'EUR'
}
});
```
You can create your Tailor-Made Experience at ease.
Litlyx makes it easy for you to tailor your analytics to your project's needs.
# AI Data-Analyst
<p align="center">
<img src="assets/agent.png" width="180px"/>
</p>
# Fire Your First Event with cURL
Lit can compare data, query specific metadata, visualize charts, and much more just by having a simple `conversation` with him.
Want to quickly see how Litlyx works with events? Use the cURL command below to send a test event. Just replace the `project_id` with your actual project ID in your terminal.
```bash
curl -X POST "https://broker.litlyx.com/event" \
-H "Content-Type: application/json" \
-d '{
"pid": "project_id",
"name": "testEvent1",
"metadata": "{\"test\": \"something\"}",
"website": "something",
"userAgent": "something"
}'
```
# Self-Hosting with Docker
First thing first **Fork** this repository.
To self-host the Litlyx dashboard, first **fork** this repository.
Then run the following command:
```bash
docker-compose build
```
then, after the build finish, run:
after the build finishes, run:
```bash
docker-compose up
```
on your localhost you will see your own instance of the Litlyx Dashboard.
at localhost:3000 you will see your own instance of the Litlyx Dashboard.
## Forward data to your local instance with script tag
To forward your data on your self-hosted instance, you need to set up the following variables: add your `data-host`, add your `data-port`, and add your `data-secure`, setting it to true if it is HTTPS, and false if it is HTTP.
```html
<script defer data-project="your_project_id"
data-host="your-host-name"
data-port="your-port"
data-secure="true/false"
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js">
</script>
```
# Official Docs
@@ -124,11 +139,11 @@ For more info read our [documentation](https://docs.litlyx.com). (will be improv
# Join Discord
If you need more information, help, or want to provide general feedback, feel free to join us on[Discord](https://discord.gg/9cQykjsmWX)
If you need more information, interact with us or the community, help, or want to provide feedbacks, feel free to join us on the Litlyx [Discord](https://discord.gg/9cQykjsmWX)
# Contributors
Every kind of contribution is accepted in this stage of the project. In the future we will onboard you better.
Every kind of contribution is accepted in this stage of the project. In the future we will improve the contributor onboarding process.
### Thank you!
<a href="https://github.com/litlyx/litlyx/graphs/contributors">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/claim.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

BIN
assets/dashboard-clip.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

BIN
assets/tech.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1,43 +0,0 @@
ARG NODE_VERSION=21
FROM node:${NODE_VERSION}-alpine as base
ENV NODE_ENV=development
# Build stage
FROM base as build
RUN npm install -g pnpm
COPY --link broker/package.json broker/pnpm-lock.yaml home/app/
COPY --link shared/package.json shared/pnpm-lock.yaml /home/shared/
WORKDIR /home/app
RUN pnpm install --frozen-lockfile
WORKDIR /home/shared
RUN pnpm install --frozen-lockfile
COPY --link ../broker /home/app
COPY --link ../shared /home/shared
WORKDIR /home/app
RUN pnpm run build_all
RUN pnpm prune
# Final stage
FROM base
COPY --from=build /home/app /home/app
WORKDIR /home/app
EXPOSE ${PORT}
CMD ["node", "dist/app/src/index.js"]

View File

@@ -1,22 +0,0 @@
module.exports = {
apps: [
{
name: 'QueueBroker',
port: '3999',
exec_mode: 'fork',
script: './dist/producer/src/index.js',
env: {
EMAIL_SERVICE: "",
EMAIL_HOST: "",
EMAIL_USER: "",
EMAIL_PASS: "",
PORT: "",
MONGO_CONNECTION_STRING: "",
REDIS_URL: "",
REDIS_USERNAME: "",
REDIS_PASSWORD: "",
STREAM_NAME: ""
}
}
]
}

1467
broker/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +0,0 @@
import fs from 'fs';
import { globSync } from 'glob';
const tsconfigContent = fs.readFileSync('tsconfig.json', 'utf8');
const tsconfigObject = JSON.parse(tsconfigContent);
const paths = tsconfigObject.compilerOptions.paths;
const filesList = globSync('dist/**/*.js');
filesList.forEach(file => {
let raw = fs.readFileSync(file, 'utf8');
for (const path in paths) {
const deep = (file.match(/\\|\//g) || []).length;
const pathText = path.replace('*', '');
const toReplaceText = new RegExp(`"${pathText}(.*?)"`, 'g');
raw = raw.replace(toReplaceText, `"${new Array(deep - 2).fill('../').join('')}${paths[path][0].replace('*', '')}${'$1'}"`);
}
fs.writeFileSync(file, raw);
});

View File

@@ -1,30 +0,0 @@
import { ProjectModel } from "@schema/ProjectSchema";
import { UserModel } from "@schema/UserSchema";
import { LimitNotifyModel } from "@schema/broker/LimitNotifySchema";
import EmailService from '@services/EmailService';
import { requireEnv } from "../../shared/utilts/requireEnv";
import { TProjectLimit } from "@schema/ProjectsLimits";
if (process.env.EMAIL_SERVICE) {
EmailService.createTransport(
requireEnv('EMAIL_SERVICE'),
requireEnv('EMAIL_HOST'),
requireEnv('EMAIL_USER'),
requireEnv('EMAIL_PASS'),
);
}
export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit / 2)) {
const notify = await LimitNotifyModel.findOne({ project_id: projectCounts._id });
if (notify && notify.limit1 === true) return;
const project = await ProjectModel.findById(projectCounts.project_id);
if (!project) return;
const owner = await UserModel.findById(project.owner);
if (!owner) return;
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail50(owner.email);
await LimitNotifyModel.updateOne({ project_id: projectCounts._id }, { limit1: true, limit2: false, limit3: false }, { upsert: true });
}
}

View File

@@ -1,16 +0,0 @@
export function getDeviceFromScreenSize(width: number, height: number) {
const totalArea = width * height;
const mobileArea = 375 * 667;
const tabletMinArea = 768 * 1366
const tabletMaxArea = 1024 * 1366
const isMobile = totalArea <= mobileArea;
const isTablet = totalArea >= tabletMinArea && totalArea <= tabletMaxArea;
if (isMobile) return 'mobile';
if (isTablet) return 'tablet'
return 'desktop';
}

View File

@@ -1,134 +0,0 @@
import { RedisStreamService } from '@services/RedisStreamService';
import { requireEnv } from '../../shared/utilts/requireEnv';
import { EventModel } from '@schema/metrics/EventSchema';
import { SessionModel } from '@schema/metrics/SessionSchema';
import { ProjectModel } from '@schema/ProjectSchema';
import { ProjectLimitModel } from '@schema/ProjectsLimits';
import { ProjectCountModel } from '@schema/ProjectsCounts';
import { EVENT_LOG_LIMIT_PERCENT } from '@data/broker/Limits';
import { checkLimitsForEmail } from './Controller';
import { lookup } from './lookup';
import { UAParser } from 'ua-parser-js';
import { VisitModel } from '@schema/metrics/VisitSchema';
export async function startStreamLoop() {
await RedisStreamService.connect();
await RedisStreamService.startReadingLoop({
streamName: requireEnv('STREAM_NAME'),
delay: { base: 100, empty: 5000 },
readBlock: 2500
}, processStreamEvent);
}
async function processStreamEvent(data: Record<string, string>) {
try {
const eventType = data._type;
if (!eventType) return;
const { pid, sessionHash } = data;
const project = await ProjectModel.exists({ _id: pid });
if (!project) return;
if (eventType === 'event') return await process_event(data, sessionHash);
if (eventType === 'keep_alive') return await process_keep_alive(data, sessionHash);
if (eventType === 'visit') return await process_visit(data, sessionHash);
} catch (ex: any) {
console.error('ERROR PROCESSING STREAM EVENT', ex.message);
}
}
async function process_visit(data: Record<string, string>, sessionHash: string) {
const { pid, ip, website, page, referrer, userAgent, flowHash } = data;
const projectLimits = await ProjectLimitModel.findOne({ project_id: pid });
if (!projectLimits) return;
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
const COUNT_LIMIT = projectLimits.limit;
if ((TOTAL_COUNT) > COUNT_LIMIT * EVENT_LOG_LIMIT_PERCENT) return;
await checkLimitsForEmail(projectLimits);
let referrerParsed;
try {
referrerParsed = new URL(referrer);
} catch (ex) {
referrerParsed = { hostname: referrer };
}
const geoLocation = lookup(ip);
const userAgentParsed = UAParser(userAgent);
const visit = new VisitModel({
project_id: pid, website, page, referrer: referrerParsed.hostname,
browser: userAgentParsed.browser.name || 'NO_BROWSER',
os: userAgentParsed.os.name || 'NO_OS',
device: userAgentParsed.device.type,
session: sessionHash,
flowHash,
continent: geoLocation[0],
country: geoLocation[1],
});
await visit.save();
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } }, { upsert: true });
await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } });
}
async function process_keep_alive(data: Record<string, string>, sessionHash: string) {
const { pid, instant, flowHash } = data;
if (instant == "true") {
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
$inc: { duration: 0 },
flowHash,
updated_at: Date.now()
}, { upsert: true });
} else {
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
$inc: { duration: 1 },
flowHash,
updated_at: Date.now()
}, { upsert: true });
}
}
async function process_event(data: Record<string, string>, sessionHash: string) {
const { name, metadata, pid, flowHash } = data;
let metadataObject;
try {
if (metadata) metadataObject = JSON.parse(metadata);
} catch (ex) {
metadataObject = { error: 'Error parsing metadata' }
}
const event = new EventModel({ project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash });
await event.save();
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true });
await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } });
}

View File

@@ -1,18 +0,0 @@
import express from 'express';
import cors from 'cors';
import { requireEnv } from '../../shared/utilts/requireEnv';
import { connectDatabase } from '@services/DatabaseService';
import { startStreamLoop } from './StreamLoopController';
const app = express();
app.use(cors());
connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
import HealthRouter from './routes/HealthRouter';
app.use('/health', HealthRouter);
app.listen(requireEnv('PORT'), () => console.log(`Listening on port ${requireEnv('PORT')}`));
startStreamLoop();

View File

@@ -1,15 +0,0 @@
import { Router } from "express";
const router = Router();
router.get('/', async (req, res) => {
try {
return res.json({ alive: true });
} catch (ex) {
console.error(ex);
return res.status(500).json({ error: 'ERROR' });
}
});
export default router;

View File

@@ -1,15 +0,0 @@
import { Request } from "express";
import crypto from 'crypto';
export function getIPFromRequest(req: Request) {
const ip = req.header('X-Real-IP') || req.header('X-Forwarded-For') || '0.0.0.0';
return ip;
}
export function createSessionHash(website: string, ip: string, userAgent: string) {
const dailySalt = new Date().toLocaleDateString('it-IT');
const sessionClean = dailySalt + website + ip + userAgent;
const sessionHash = crypto.createHash('md5').update(sessionClean).digest("hex");
return sessionHash;
}

View File

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

38
consumer/Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# Start with a minimal Node.js base image
FROM node:21-alpine as base
# Install pnpm globally with caching to avoid reinstalling if nothing has changed
RUN npm i -g pnpm
# Set the working directory
WORKDIR /home/app
# Copy only package-related files to leverage caching
COPY --link ./scripts/package.json ./scripts/pnpm-lock.yaml ./scripts/
COPY --link ./shared/package.json ./shared/pnpm-lock.yaml ./shared/
COPY --link ./consumer/package.json ./consumer/pnpm-lock.yaml ./consumer/
# Install dependencies for each package
WORKDIR /home/app/scripts
RUN pnpm install --frozen-lockfile
WORKDIR /home/app/shared
RUN pnpm install --frozen-lockfile
WORKDIR /home/app/consumer
RUN pnpm install --frozen-lockfile
# Now copy the rest of the source files
WORKDIR /home/app
COPY --link ../scripts ./scripts
COPY --link ../shared ./shared
COPY --link ../consumer ./consumer
# Build the consumer
WORKDIR /home/app/consumer
RUN pnpm run build_all
# Start the application
CMD ["node", "/home/app/consumer/dist/consumer/src/index.js"]

View File

@@ -0,0 +1,17 @@
module.exports = {
apps: [
{
name: 'consumer',
exec_mode: 'fork',
script: './dist/consumer/src/index.js',
env: {
MONGO_CONNECTION_STRING: "",
REDIS_URL: "",
REDIS_USERNAME: "",
REDIS_PASSWORD: "",
STREAM_NAME: "",
GROUP_NAME: ""
}
}
]
}

View File

@@ -1,37 +1,30 @@
{
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.2",
"@getbrevo/brevo": "^2.2.0",
"mongoose": "^8.3.2",
"nodemailer": "^6.9.13",
"redis": "^4.6.14",
"ua-parser-js": "^1.0.37"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.12.13",
"@types/nodemailer": "^6.4.15",
"@types/ua-parser-js": "^0.7.39",
"glob": "^10.4.1",
"node-ssh": "^13.2.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
},
"name": "litlyx-queue-broker",
"name": "consumer-database",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"dev": "node scripts/start_dev.js",
"compile": "tsc",
"build": "ts-node scripts/build.ts",
"build": "node ../scripts/build.js",
"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"
"docker-build": "docker build -t litlyx-consumer -f Dockerfile ../",
"docker-inspect": "docker run -it litlyx-consumer sh"
},
"keywords": [],
"author": "Emily",
"license": "MIT",
"description": "Queue broker for Litlyx - Saves events to database."
"description": "Database Consumer - Saves events to database."
}

1498
consumer/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
import { ProjectModel } from "@schema/ProjectSchema";
import { UserModel } from "@schema/UserSchema";
import { LimitNotifyModel } from "@schema/broker/LimitNotifySchema";
import EmailService from '@services/EmailService';
import { requireEnv } from "@utils/requireEnv";
import { TProjectLimit } from "@schema/ProjectsLimits";
if (process.env.EMAIL_SERVICE) {
EmailService.init(requireEnv('BREVO_API_KEY'));
}
export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
const project_id = projectCounts.project_id;
const hasNotifyEntry = await LimitNotifyModel.findOne({ project_id });
if (!hasNotifyEntry) {
await LimitNotifyModel.create({ project_id, limit1: false, limit2: false, limit3: false })
}
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit)) {
if (hasNotifyEntry.limit3 === true) return;
const project = await ProjectModel.findById(project_id);
if (!project) return;
const owner = await UserModel.findById(project.owner);
if (!owner) return;
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmailMax(owner.email, project.name);
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: true });
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.9)) {
if (hasNotifyEntry.limit2 === true) return;
const project = await ProjectModel.findById(project_id);
if (!project) return;
const owner = await UserModel.findById(project.owner);
if (!owner) return;
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail90(owner.email, project.name);
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: false });
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.5)) {
if (hasNotifyEntry.limit1 === true) return;
const project = await ProjectModel.findById(project_id);
if (!project) return;
const owner = await UserModel.findById(project.owner);
if (!owner) return;
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail50(owner.email, project.name);
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: false, limit3: false });
}
}

View File

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

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

@@ -0,0 +1,142 @@
import { requireEnv } from '@utils/requireEnv';
import { connectDatabase } from '@services/DatabaseService';
import { RedisStreamService } from '@services/RedisStreamService';
import { ProjectModel } from "@schema/ProjectSchema";
import { VisitModel } from "@schema/metrics/VisitSchema";
import { SessionModel } from "@schema/metrics/SessionSchema";
import { EventModel } from "@schema/metrics/EventSchema";
import { lookup } from './lookup';
import { UAParser } from 'ua-parser-js';
import { checkLimits } from './LimitChecker';
import { ProjectLimitModel } from '@schema/ProjectsLimits';
import { ProjectCountModel } from '@schema/ProjectsCounts';
connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
main();
async function main() {
await RedisStreamService.connect();
const stream_name = requireEnv('STREAM_NAME');
const group_name = requireEnv('GROUP_NAME') as any; // Checks are inside "startReadingLoop"
await RedisStreamService.startReadingLoop({
stream_name, group_name, consumer_name: `CONSUMER_${process.env.NODE_APP_INSTANCE || 'DEFAULT'}`
}, processStreamEntry);
}
async function processStreamEntry(data: Record<string, string>) {
try {
const start = Date.now();
const eventType = data._type;
if (!eventType) return;
const { pid, sessionHash } = data;
const project = await ProjectModel.exists({ _id: pid });
if (!project) return;
const canLog = await checkLimits(pid);
if (!canLog) return;
if (eventType === 'event') {
await process_event(data, sessionHash);
} else if (eventType === 'keep_alive') {
await process_keep_alive(data, sessionHash);
} else if (eventType === 'visit') {
await process_visit(data, sessionHash);
}
const duration = Date.now() - start;
// console.log('Entry processed in', duration, 'ms');
} catch (ex: any) {
console.error('ERROR PROCESSING STREAM EVENT', ex.message);
}
}
async function process_visit(data: Record<string, string>, sessionHash: string) {
const { pid, ip, website, page, referrer, userAgent, flowHash } = data;
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],
}),
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } }, { upsert: true }),
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } })
]);
}
async function process_keep_alive(data: Record<string, string>, sessionHash: string) {
const { pid, instant, flowHash } = data;
const existingSession = await SessionModel.findOne({ project_id: pid, session: sessionHash }, { _id: 1 });
if (!existingSession) {
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'sessions': 1 } }, { upsert: true });
}
if (instant == "true") {
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
$inc: { duration: 0 },
flowHash,
updated_at: Date.now()
}, { upsert: true });
} else {
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
$inc: { duration: 1 },
flowHash,
updated_at: Date.now()
}, { upsert: true });
}
}
async function process_event(data: Record<string, string>, sessionHash: string) {
const { name, metadata, pid, flowHash } = 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 }),
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true }),
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } })
]);
}

View File

@@ -1,5 +1,6 @@
{
"compilerOptions": {
"baseUrl": ".",
"module": "NodeNext",
"target": "ESNext",
"esModuleInterop": true,
@@ -16,12 +17,14 @@
],
"@functions/*": [
"../shared/functions/*"
],
"@utils/*": [
"../shared/utils/*"
]
}
},
"include": [
"src/**/*.ts",
"scripts/**/*.ts"
"src/**/*.ts"
],
"exclude": [
"node_modules"

View File

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

View File

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

View File

@@ -1,34 +1,50 @@
ARG NODE_VERSION=21
# Start with a minimal Node.js base image
FROM node:21-alpine AS base
FROM node:${NODE_VERSION}-alpine as base
# Create a distinct build environment
FROM base AS build
ENV NODE_ENV=production
# Build stage
# Install pnpm globally with caching to avoid reinstalling if nothing has changed
RUN npm i -g pnpm
# Set the working directory
WORKDIR /home/app
FROM base as build
# Copy only package-related files to leverage caching
COPY --link ./dashboard/package.json ./dashboard/pnpm-lock.yaml ./dashboard/
COPY --link ./lyx-ui/package.json ./lyx-ui/pnpm-lock.yaml ./lyx-ui/
COPY --link ./shared/package.json ./shared/pnpm-lock.yaml ./shared/
COPY --link dashboard/package.json dashboard/pnpm-lock.yaml ./
RUN npm install --production=false
# Install dependencies for each package
WORKDIR /home/app/lyx-ui
RUN pnpm install --frozen-lockfile
COPY --link dashboard/ ./
# WORKDIR /home/app/shared
# RUN pnpm install --frozen-lockfile
COPY --link shared/ /home/shared
WORKDIR /home/app/dashboard
RUN pnpm install --frozen-lockfile
ARG GOOGLE_AUTH_CLIENT_ID
ENV GOOGLE_AUTH_CLIENT_ID=$GOOGLE_AUTH_CLIENT_ID
# Now copy the rest of the source files
WORKDIR /home/app
RUN npm run build
RUN npm prune
COPY --link ./dashboard ./dashboard
COPY --link ./lyx-ui ./lyx-ui
COPY --link ./shared ./shared
# Final stage
# Build the dashboard
WORKDIR /home/app/dashboard
FROM base
RUN pnpm run build
COPY --from=build /home/app /home/app
# Use a smaller base image for the final production build
FROM node:21-alpine AS production
EXPOSE ${PORT}
# Set the working directory for the production container
WORKDIR /home/app
# Copy the built application from the build stage
COPY --from=build /home/app/dashboard/.output /home/app/.output
# Start the application
CMD ["node", "/home/app/.output/server/index.mjs"]

View File

@@ -6,15 +6,47 @@ Lit.init('6643cd08a1854e3b81722ab5');
const debugMode = process.dev;
const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDialog();
const { alerts, closeAlert } = useAlert();
const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dialogClosable } = useCustomDialog();
const { visible } = usePricingDrawer();
</script>
<template>
<div class="w-dvw h-dvh bg-[#151517] relative">
<div class="w-dvw h-dvh 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>
<div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0">
<div v-for="alert of alerts"
class="w-[30vw] min-w-[20rem] relative bg-[#151515] overflow-hidden border-solid border-[2px] border-[#262626] rounded-lg p-6 drop-shadow-lg">
<div class="flex items-start gap-4">
<div> <i :class="alert.icon"></i> </div>
<div class="grow">
<div class="poppins font-semibold">{{ alert.title }}</div>
<div class="poppins">
{{ alert.text }}
</div>
</div>
<div>
<i @click="closeAlert(alert.id)" class="fas fa-close hover:text-[#CCCCCC] cursor-pointer"></i>
</div>
</div>
<div :style="`width: ${Math.floor(100 / alert.ms * alert.remaining)}%; ${alert.transitionStyle}`"
class="absolute bottom-0 left-0 h-1 bg-lyx-primary z-100 alert-bar"></div>
</div>
</div>
<div v-if="debugMode"
class="absolute bottom-8 left-4 bg-red-400 text-white text-[.9rem] font-bold px-4 py-[.2rem] rounded-lg z-[100]">
class="absolute bottom-8 right-4 bg-red-400 text-white text-[.9rem] font-bold px-4 py-[.2rem] rounded-lg z-[100]">
<div class="poppins flex sm:hidden"> XS </div>
<div class="poppins hidden sm:max-md:flex"> SM - MOBILE </div>
<div class="poppins hidden md:max-lg:flex"> MD - TABLET </div>
@@ -24,9 +56,9 @@ const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDial
</div>
<div v-if="showDialog"
class="custom-dialog flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 w-full h-full z-[100] backdrop-blur-[2px] bg-black/50">
<div class="bg-menu w-full h-full rounded-xl relative">
<div class="flex justify-end absolute z-[100] right-8 top-8">
class="custom-dialog w-full h-full flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 z-[100] backdrop-blur-[2px] bg-black/50">
<div :style="dialogStyle" class="bg-lyx-widget rounded-xl relative outline outline-1 outline-lyx-widget-lighter">
<div v-if="dialogClosable" class="flex justify-end absolute z-[100] right-8 top-8">
<i @click="closeDialog()" class="fas fa-close text-[1.6rem] hover:text-gray-500 cursor-pointer"></i>
</div>
<div class="flex items-center justify-center w-full h-full p-4">
@@ -41,3 +73,20 @@ const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDial
</div>
</template>
<style scoped lang="scss">
.pdrawer-enter-active,
.pdrawer-leave-active {
transition: all .5s ease-in-out;
}
.pdrawer-enter-from,
.pdrawer-leave-to {
transform: translateX(100%)
}
.pdrawer-enter-to,
.pdrawer-leave-from {
transform: translateX(0)
}
</style>

View File

@@ -11,12 +11,26 @@
@import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0');
@font-face {
font-family: "Geist";
src: url("../fonts/GeistVF.ttf");
}
.actionable-visits-color-checkbox {
color: #5655d7;
}
.actionable-sessions-color-checkbox {
color: #4abde8;
}
.actionable-events-color-checkbox {
color: #fbbf24;
}
.geist {
font-family: "Geist";
}
@@ -70,10 +84,14 @@
.hide-scrollbars {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
&::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
display: none;
/* Chrome, Safari and Opera */
}
}

View File

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

View File

@@ -80,6 +80,7 @@ const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, op
onMounted(async () => {
const c = document.createElement('canvas');
const ctx = c.getContext("2d");
let gradient: any = `${props.color}22`;
@@ -95,7 +96,6 @@ onMounted(async () => {
chartData.value.datasets[0].backgroundColor = [gradient];
watch(props, () => {
console.log('UPDATE')
chartData.value.labels = props.labels;
chartData.value.datasets[0].data = props.data;
});

View File

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

View File

@@ -0,0 +1,171 @@
<script lang="ts" setup>
export type IconProvider = (id: string) => ['img' | 'icon', string] | undefined;
type Props = {
data: { _id: string, count: number }[],
iconProvider?: IconProvider,
elementTextTransformer?: (text: string) => string,
label: string,
subLabel: string,
desc: string,
loading?: boolean,
interactive?: boolean,
isDetailView?: boolean,
rawButton?: boolean,
hideShowMore?: boolean,
customIconStyle?: string,
showLink?: boolean
}
const props = defineProps<Props>();
const emits = defineEmits<{
(e: 'dataReload'): void,
(e: 'showDetails', id: string): void,
(e: 'showRawData'): void,
(e: 'showGeneral'): void,
(e: 'showMore'): void,
}>();
const maxData = computed(() => {
const counts = props.data.map(e => e.count);
return Math.max(...counts);
});
function reloadData() {
emits('dataReload');
}
function showDetails(id: string) {
emits('showDetails', id);
}
function openExternalLink(link: string) {
if (link === 'self') return;
return window.open('https://' + link, '_blank');
}
</script>
<template>
<LyxUiCard class="w-full h-full p-4 flex flex-col gap-8 relative">
<div class="flex justify-between mb-3">
<div class="flex flex-col gap-1">
<div class="flex gap-4 items-center">
<div class="poppins font-semibold text-[1.4rem] text-text">
{{ label }}
</div>
<div class="flex items-center">
<i @click="reloadData()"
class="hover:rotate-[50deg] transition-all duration-100 fas fa-refresh text-[1.2rem] cursor-pointer"></i>
</div>
</div>
<div class="poppins text-[1rem] text-text-sub/90">
{{ desc }}
</div>
</div>
<div v-if="rawButton" class="hidden lg:flex">
<LyxUiButton @click="$emit('showRawData')" type="primary" class="h-fit">
<div class="flex gap-1 items-center justify-center ">
<div> Show raw data </div>
<div class="flex items-center"> <i class="fas fa-arrow-up-right"></i> </div>
</div>
</LyxUiButton>
</div>
</div>
<div>
<div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4">
<div class="flex items-center gap-2">
<div v-if="isDetailView" class="flex items-center justify-center">
<i @click="$emit('showGeneral')"
class="fas fa-arrow-left text-[.9rem] hover:text-text cursor-pointer"></i>
</div>
<div> {{ subLabel }} </div>
</div>
<div> Count </div>
</div>
<div class="flex flex-col gap-1">
<div v-if="props.data.length > 0" class="flex justify-between items-center"
v-for="element of props.data">
<div class="flex items-center gap-2 w-10/12 relative">
<div v-if="showLink">
<i @click="openExternalLink(element._id)"
class="fas fa-link text-gray-300 hover:text-gray-400 cursor-pointer"></i>
</div>
<div class="flex gap-1 items-center" @click="showDetails(element._id)"
:class="{ 'cursor-pointer line-active': interactive }">
<div class="absolute rounded-sm w-full h-full bg-[#92abcf38]"
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
<div class="flex px-2 py-1 relative items-center gap-4">
<div v-if="iconProvider && iconProvider(element._id) != undefined"
class="flex items-center h-[1.3rem]">
<img v-if="iconProvider(element._id)?.[0] == 'img'" class="h-full"
:style="customIconStyle" :src="iconProvider(element._id)?.[1]">
<i v-else :class="iconProvider(element._id)?.[1]"></i>
</div>
<span class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
{{ elementTextTransformer?.(element._id) || element._id }}
</span>
</div>
</div>
</div>
<div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{
formatNumberK(element.count) }} </div>
</div>
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-bold text-[1.1rem]">
No 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]">
Show more
</div>
</div>
</div>
<div v-if="loading"
class="backdrop-blur-[1px] z-[20] left-0 top-0 w-full h-full flex items-center justify-center font-bold rockmann absolute">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
</LyxUiCard>
</template>
<style scoped lang="scss">
.line-active:hover {
.absolute {
@apply bg-accent/20
}
}
.ui-font {
font-feature-settings: normal;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
font-variation-settings: normal;
font-weight: 600;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4
}
</style>

View File

@@ -0,0 +1,35 @@
<script lang="ts" setup>
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 || [];
isDataLoading.value = false;
}
</script>
<template>
<div class="flex flex-col gap-2">
<BarCardBase @showMore="showMore()" @dataReload="browsersData.refresh()" :data="browsersData.data.value || []"
desc="The browsers most used to search your website." :dataIcons="false"
:loading="browsersData.pending.value" label="Top Browsers" sub-label="Browsers">
</BarCardBase>
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script lang="ts" setup>
function transform(data: { _id: string, count: number }[]) {
console.log(data);
return data.map(e => ({ ...e, _id: e._id == null ? 'unknown' : e._id }))
}
const devicesData = useFetch('/api/data/devices', {
headers: useComputedHeaders({ limit: 10, }), lazy: true,
transform
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
async function showMore() {
dialogBarData.value = [];
showDialog.value = true;
isDataLoading.value = true;
const res = await $fetch('/api/data/devices', {
headers: useComputedHeaders({ limit: 1000 }).value,
});
dialogBarData.value = transform(res || []);
isDataLoading.value = false;
}
</script>
<template>
<div class="flex flex-col gap-2">
<BarCardBase @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"></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,50 @@
<script lang="ts" setup>
import type { IconProvider } from '../BarCard/Base.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 geolocationData = useFetch('/api/data/countries', {
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/countries', {
headers: useComputedHeaders({limit: 1000}).value
});
dialogBarData.value = res?.map(e => {
return { ...e, icon: iconProvider(e._id) }
}) || [];
isDataLoading.value = false;
}
</script>
<template>
<div class="flex flex-col gap-2">
<BarCardBase @showMore="showMore()" @dataReload="geolocationData.refresh()"
:data="geolocationData.data.value || []" :dataIcons="false" :loading="geolocationData.pending.value"
label="Top 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="Top OS" sub-label="OSs"></BarCardBase>
</div>
</template>

View File

@@ -0,0 +1,53 @@
<script lang="ts" setup>
import type { IconProvider } from './Base.vue';
function iconProvider(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 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._id) }
}) || [];
isDataLoading.value = false;
}
</script>
<template>
<div class="flex flex-col gap-2">
<BarCardBase @showMore="showMore()" :elementTextTransformer="elementTextTransformer"
:iconProvider="iconProvider" @dataReload="referrersData.refresh()" :showLink=true
:data="referrersData.data.value || []" :interactive="false" desc="Where users find your website."
:dataIcons="true" :loading="referrersData.pending.value" label="Top Referrers" 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,5 +1,6 @@
<script lang="ts" setup>
import CreateSnapshot from './dialog/CreateSnapshot.vue';
export type Entry = {
label: string,
@@ -8,7 +9,9 @@ export type Entry = {
icon?: string,
action?: () => any,
adminOnly?: boolean,
external?: boolean
premiumOnly?: boolean,
external?: boolean,
grow?: boolean
}
export type Section = {
@@ -23,26 +26,113 @@ type Props = {
const route = useRoute();
const props = defineProps<Props>();
const { isAdmin } = useUserRoles();
const { userRoles, setLoggedUser } = useLoggedUser();
const { projectList } = useProject();
const debugMode = process.dev;
const { isOpen, close } = useMenu();
const { snapshots, snapshot, updateSnapshots } = useSnapshot();
const snapshotsItems = computed(() => {
if (!snapshots.value) return []
return snapshots.value as any[];
})
const { openDialogEx } = useCustomDialog();
function openSnapshotDialog() {
openDialogEx(CreateSnapshot, {
width: "24rem",
height: "16rem",
closable: false
});
}
const { createAlert } = useAlert()
async function deleteSnapshot(close: () => any) {
await $fetch("/api/snapshot/delete", {
method: 'DELETE',
...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 { data: maxProjects } = useFetch("/api/user/max_projects", {
headers: computed(() => {
return {
Authorization: authorizationHeaderComputed.value
}
})
});
const pricingDrawer = usePricingDrawer();
</script>
<template>
<div class="CVerticalNavigation h-full w-[20rem] bg-[#111111] flex shadow-[1px_0_10px_#000000] rounded-r-lg" :class="{
<div class="CVerticalNavigation border-solid border-[#202020] border-r-[1px] h-full w-[20rem] bg-lyx-background flex shadow-[1px_0_10px_#000000] rounded-r-lg"
:class="{
'absolute top-0 w-full md:w-[20rem] z-[45] open': isOpen,
'hidden lg:flex': !isOpen
}">
<div class="p-4 gap-6 flex flex-col w-full">
<div class="py-4 px-2 gap-6 flex flex-col w-full">
<div class="flex items-center gap-2 ml-2">
<div class="bg-black h-[2.4rem] aspect-[1/1] flex items-center justify-center rounded-lg">
<img class="h-[2rem]" :src="'/logo.png'">
</div>
<div class="font-bold text-[1.4rem] text-gray-300"> Litlyx </div>
<!-- <div class="flex px-2" v-if="!isPremium">
<LyxUiButton type="primary" class="w-full text-center text-[.8rem] font-medium" @click="pricingDrawer.visible.value = true;">
Upgrade plan
</LyxUiButton>
</div> -->
<div class="flex px-2 flex-col">
<div class="flex items-center gap-2 w-full">
<ProjectSelector></ProjectSelector>
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
<i @click="close()" class="fas fa-close"></i>
@@ -50,26 +140,128 @@ const { isOpen, close } = useMenu();
</div>
<div class="flex flex-col gap-4">
<div v-for="section of sections" class="flex flex-col gap-1">
<LyxUiButton to="/project_creation" v-if="projectList && (projectList.length < (maxProjects || 1))"
type="outlined" class="w-full py-1 mt-2 text-[.8rem]">
<div class="flex items-center gap-2 justify-center">
<div><i class="fas fa-plus"></i></div>
<div> Create new project </div>
</div>
</LyxUiButton>
<div v-for="entry of section.entries">
</div>
<div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))"
class="bg-[#111111] text-gray-300 hover:bg-[#1b1b1b] py-2 px-4 rounded-lg" :class="{
'text-gray-700 pointer-events-none': entry.disabled,
'bg-[#1b1b1b]': route.path == (entry.to || '#')
<div class="w-full flex-col px-2">
<div class="flex mb-2 items-center justify-between">
<div class="poppins text-[.8rem]">
Snapshots
</div>
<div class="flex gap-2">
<UTooltip text="Download report">
<LyxUiButton @click="generatePDF()" type="outlined" class="!px-3 !py-1">
<div><i class="far fa-download text-[.8rem]"></i></div>
</LyxUiButton>
</UTooltip>
<UTooltip text="Create new snapshot">
<LyxUiButton @click="openSnapshotDialog()" type="outlined" class="!px-3 !py-1">
<div><i class="fas fa-plus text-[.9rem]"></i></div>
</LyxUiButton>
</UTooltip>
</div>
</div>
<div class="flex items-center gap-2">
<USelectMenu :uiMenu="{
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
base: '!bg-lyx-widget',
option: {
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-widget-lighter'
}
}" class="w-full" v-model="snapshot" :options="snapshotsItems">
<template #label>
<div class="flex items-center gap-2">
<div :style="'background-color:' + snapshot?.color" class="w-2 h-2 rounded-full">
</div>
<div class="poppins"> {{ snapshot?.name }} </div>
</div>
</template>
<template #option="{ option }">
<div class="flex items-center gap-2">
<div :style="'background-color:' + option.color" class="w-2 h-2 rounded-full">
</div>
<div class="poppins"> {{ option.name }} </div>
</div>
</template>
</USelectMenu>
</div>
<div v-if="snapshot" class="flex flex-col text-[.7rem] mt-2">
<div class="flex gap-1 items-center justify-center text-lyx-text-dark">
<div class="poppins">
{{ new Date(snapshot.from).toLocaleString('it-IT').split(',')[0].trim().replace(/\//g, '-')
}}
</div>
<div class="poppins"> to </div>
<div class="poppins">
{{ new Date(snapshot.to).toLocaleString('it-IT').split(',')[0].trim().replace(/\//g, '-') }}
</div>
</div>
<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-[#202020] h-[1px] w-full"></div>
<div class="flex flex-col h-full">
<div v-for="section of sections" class="flex flex-col gap-1 h-full pb-6">
<div v-for="entry of section.entries" :class="{ 'grow flex items-end': entry.grow }">
<div v-if="(!entry.adminOnly || (userRoles.isAdmin && !isAdminHidden))"
class="bg-lyx-background w-full cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
:class="{
'!text-lyx-text-darker pointer-events-none': entry.disabled,
'bg-lyx-background-lighter !text-lyx-text/90': route.path == (entry.to || '#'),
'hover:bg-lyx-background-light hover:!text-lyx-text/90': route.path != (entry.to || '#'),
}">
<NuxtLink @click="close() && entry.action?.()" :target="entry.external ? '_blank' : ''"
tag="div" class="flex" :to="entry.to || '/'">
<div class="flex items-center w-[1.8rem] justify-start">
tag="div" class="w-full flex items-center" :to="entry.to || '/'">
<div class="flex items-center w-[1.4rem] mr-2 text-[1.1rem] justify-center">
<i :class="entry.icon"></i>
</div>
<div class="manrope">
<div class="manrope grow">
{{ entry.label }}
</div>
<div v-if="entry.premiumOnly && !userRoles.isPremium" class="flex items-center">
<i class="fal fa-lock"></i>
</div>
</NuxtLink>
</div>
@@ -78,6 +270,44 @@ const { isOpen, close } = useMenu();
</div>
<div class="grow"></div>
<div class="bg-[#202020] h-[1px] w-full px-4 mb-3"></div>
<div class="flex justify-end px-2">
<div class="grow flex gap-3">
<!-- <NuxtLink to="https://github.com/litlyx/litlyx" target="_blank"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fab fa-github"></i>
</NuxtLink> -->
<!-- <NuxtLink to="https://discord.gg/9cQykjsmWX" target="_blank"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fab fa-discord"></i>
</NuxtLink> -->
<NuxtLink to="https://x.com/litlyx" target="_blank"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fab fa-x-twitter"></i>
</NuxtLink>
<!-- <NuxtLink to="https://dev.to/litlyx-org" target="_blank"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fab fa-dev"></i>
</NuxtLink> -->
<NuxtLink to="/admin" v-if="userRoles.isAdmin"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fas fa-cat"></i>
</NuxtLink>
</div>
<UTooltip text="Logout" :popper="{ arrow: true, placement: 'top' }">
<div @click="onLogout()" class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="far fa-arrow-right-from-bracket scale-x-[-100%]"></i>
</div>
</UTooltip>
</div>
</div>
</div>
</div>
@@ -85,10 +315,6 @@ const { isOpen, close } = useMenu();
<style lang="scss" scoped>
.CVerticalNavigation * {
font-family: 'Geist';
}
input:focus {
outline: none;
}

View File

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

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
type CItem = { label: string, slot: string }
const props = defineProps<{ items: CItem[] }>();
const activeTabIndex = ref<number>(0);
</script>
<template>
<div>
<div class="flex">
<div v-for="(tab, index) of items" @click="activeTabIndex = index"
class="px-6 pb-3 poppins font-medium text-lyx-text-darker border-b-[1px] border-lyx-text-darker" :class="{
'!border-[#88A7FF] !text-[#88A7FF]': activeTabIndex === index,
'hover:border-lyx-text-dark hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
}">
{{ tab.label }}
</div>
<div class="border-b-[1px] border-lyx-text-darker w-full">
</div>
</div>
<div>
<slot :name="props.items[activeTabIndex].slot"></slot>
</div>
</div>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { DatePicker as VCalendarDatePicker } from 'v-calendar'
import type { DatePickerDate, DatePickerRangeObject } from 'v-calendar/dist/types/src/use/datePicker'
import 'v-calendar/dist/style.css'
const props = defineProps({
modelValue: {
type: [Date, Object] as PropType<DatePickerDate | DatePickerRangeObject | null>,
default: null
}
})
const emit = defineEmits(['update:model-value', 'close'])
const date = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:model-value', value)
emit('close')
}
})
const attrs = {
transparent: true,
borderless: true,
color: 'primary',
'is-dark': { selector: 'html', darkClass: 'dark' },
'first-day-of-week': 2,
}
</script>
<template>
<VCalendarDatePicker v-if="date && (typeof date === 'object')" v-model.range="date" :columns="2" v-bind="{ ...attrs, ...$attrs }" />
<VCalendarDatePicker v-else v-model="date" v-bind="{ ...attrs, ...$attrs }" />
</template>
<style>
:root {
--vc-gray-50: rgb(var(--color-gray-50));
--vc-gray-100: rgb(var(--color-gray-100));
--vc-gray-200: rgb(var(--color-gray-200));
--vc-gray-300: rgb(var(--color-gray-300));
--vc-gray-400: rgb(var(--color-gray-400));
--vc-gray-500: rgb(var(--color-gray-500));
--vc-gray-600: rgb(var(--color-gray-600));
--vc-gray-700: rgb(var(--color-gray-700));
--vc-gray-800: rgb(var(--color-gray-800));
--vc-gray-900: rgb(var(--color-gray-900));
}
.vc-primary {
--vc-accent-50: rgb(var(--color-primary-50));
--vc-accent-100: rgb(var(--color-primary-100));
--vc-accent-200: rgb(var(--color-primary-200));
--vc-accent-300: rgb(var(--color-primary-300));
--vc-accent-400: rgb(var(--color-primary-400));
--vc-accent-500: rgb(var(--color-primary-500));
--vc-accent-600: rgb(var(--color-primary-600));
--vc-accent-700: rgb(var(--color-primary-700));
--vc-accent-800: rgb(var(--color-primary-800));
--vc-accent-900: rgb(var(--color-primary-900));
}
</style>

View File

@@ -0,0 +1,292 @@
<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';
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() || '');
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('')
}
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 gap-4 items-center justify-center">
<div class="animate-pulse w-[1.5rem] h-[1.5rem] bg-accent rounded-full"> </div>
<div class="text-text/90 poppins text-[1.3rem] font-semibold">
Waiting for your first Visit or Event
</div>
</div>
<div class="flex justify-center mt-4">
<LyxUiButton type="primary" @click="reloadPage()">
<div class="flex items-center gap-2">
<i class="far fa-refresh"></i>
<div> Reload </div>
</div>
</LyxUiButton>
</div>
<div class="flex items-center justify-center mt-10">
<div class="flex flex-col gap-6">
<div class="flex gap-6">
<div>
<CardTitled class="h-full" title="Tutorial" sub="Coming soon. For now enjoy our launch video.">
<div class="flex items-center justify-center h-full">
<iframe width="560" height="315"
src="https://www.youtube.com/embed/GntyWMR7jsY?si=YGGkQwrk6-Iqmn8w" title="Litlyx"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
</div>
</CardTitled>
</div>
<div class="flex flex-col gap-6">
<div>
<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">
<pre><code class="language-html">{{ 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 flex-col items-end">
<div class="w-full text-[.9rem] text-[#acacac]"> {{ project?._id }} </div>
<LyxUiButton type="secondary" @click="copyProjectId()"> Copy </LyxUiButton>
</div>
</CardTitled>
</div>
</div>
</div>
<div>
<div>
<CardTitled class="w-full h-full" title="Documentation"
sub="Learn how to use Litlyx in every tech stack">
<div class="flex flex-col items-end">
<div class="flex justify-center w-full">
<svg width="680" height="100" viewBox="0 0 680 100" fill="none"
xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-inside-1_473_1361" fill="white">
<path
d="M0 12C0 5.37258 5.37258 0 12 0H88C94.6274 0 100 5.37258 100 12V88C100 94.6274 94.6274 100 88 100H12C5.37258 100 0 94.6274 0 88V12Z" />
</mask>
<path
d="M0 12C0 5.37258 5.37258 0 12 0H88C94.6274 0 100 5.37258 100 12V88C100 94.6274 94.6274 100 88 100H12C5.37258 100 0 94.6274 0 88V12Z"
fill="#0A0A0A" />
<path
d="M0 12C0 4.8203 5.8203 -1 13 -1H87C94.1797 -1 100 4.8203 100 12C100 5.92487 94.6274 1 88 1H12C5.37258 1 0 5.92487 0 12ZM100 100H0H100ZM0 100V0V100ZM100 0V100V0Z"
fill="#303246" mask="url(#path-1-inside-1_473_1361)" />
<mask id="path-3-inside-2_473_1361" fill="white">
<path
d="M348 12C348 5.37258 353.373 0 360 0H436C442.627 0 448 5.37258 448 12V88C448 94.6274 442.627 100 436 100H360C353.373 100 348 94.6274 348 88V12Z" />
</mask>
<path
d="M348 12C348 5.37258 353.373 0 360 0H436C442.627 0 448 5.37258 448 12V88C448 94.6274 442.627 100 436 100H360C353.373 100 348 94.6274 348 88V12Z"
fill="#0A0A0A" />
<path
d="M348 12C348 4.8203 353.82 -1 361 -1H435C442.18 -1 448 4.8203 448 12C448 5.92487 442.627 1 436 1H360C353.373 1 348 5.92487 348 12ZM448 100H348H448ZM348 100V0V100ZM448 0V100V0Z"
fill="#303246" mask="url(#path-3-inside-2_473_1361)" />
<path
d="M398 80C414.569 80 428 66.5685 428 50C428 33.4315 414.569 20 398 20C381.431 20 368 33.4315 368 50C368 66.5685 381.431 80 398 80Z"
fill="white" />
<path
d="M417.836 72.5068L391.047 38H386V61.99H390.038V43.1278L414.666 74.9484C415.778 74.2045 416.836 73.3884 417.836 72.5068Z"
fill="url(#paint0_linear_473_1361)" />
<path d="M410.333 38H406.333V62H410.333V38Z"
fill="url(#paint1_linear_473_1361)" />
<mask id="path-8-inside-3_473_1361" fill="white">
<path
d="M116 12C116 5.37258 121.373 0 128 0H204C210.627 0 216 5.37258 216 12V88C216 94.6274 210.627 100 204 100H128C121.373 100 116 94.6274 116 88V12Z" />
</mask>
<path
d="M116 12C116 5.37258 121.373 0 128 0H204C210.627 0 216 5.37258 216 12V88C216 94.6274 210.627 100 204 100H128C121.373 100 116 94.6274 116 88V12Z"
fill="#0A0A0A" />
<path
d="M116 12C116 4.8203 121.82 -1 129 -1H203C210.18 -1 216 4.8203 216 12C216 5.92487 210.627 1 204 1H128C121.373 1 116 5.92487 116 12ZM216 100H116H216ZM116 100V0V100ZM216 0V100V0Z"
fill="#303246" mask="url(#path-8-inside-3_473_1361)" />
<path d="M182.2 27H193L166 73.575L139 27H159.655L166 37.8L172.21 27H182.2Z"
fill="#41B883" />
<path d="M139 27L166 73.575L193 27H182.2L166 54.945L149.665 27H139Z"
fill="#41B883" />
<path d="M149.665 27L166 55.08L182.2 27H172.21L166 37.8L159.655 27H149.665Z"
fill="#35495E" />
<path
d="M53.6605 70H75.9651C76.6735 70.0001 77.3695 69.8153 77.983 69.4642C78.5965 69.1131 79.1059 68.6081 79.46 67.9999C79.8141 67.3918 80.0003 66.7019 80 65.9998C79.9997 65.2977 79.8128 64.608 79.4582 64.0002L64.4791 38.2859C64.1251 37.6779 63.6158 37.173 63.0024 36.8219C62.389 36.4709 61.6932 36.2861 60.9849 36.2861C60.2766 36.2861 59.5808 36.4709 58.9674 36.8219C58.354 37.173 57.8447 37.6779 57.4906 38.2859L53.6605 44.8653L46.1721 31.9995C45.8177 31.3916 45.3082 30.8867 44.6946 30.5358C44.0811 30.1848 43.3852 30 42.6767 30C41.9683 30 41.2724 30.1848 40.6588 30.5358C40.0453 30.8867 39.5357 31.3916 39.1814 31.9995L20.5418 64.0002C20.1872 64.608 20.0003 65.2977 20 65.9998C19.9997 66.7019 20.1859 67.3918 20.54 67.9999C20.8941 68.6081 21.4035 69.1131 22.017 69.4642C22.6305 69.8153 23.3265 70.0001 24.0349 70H38.0359C43.5832 70 47.6741 67.585 50.4891 62.8734L57.3233 51.143L60.9838 44.8653L71.9698 63.7222H57.3233L53.6605 70ZM37.8076 63.7158L28.0367 63.7136L42.6833 38.5724L49.9913 51.143L45.0983 59.545C43.2289 62.602 41.1051 63.7158 37.8076 63.7158Z"
fill="#00DC82" />
<mask id="path-14-inside-4_473_1361" fill="white">
<path
d="M464 12C464 5.37258 469.373 0 476 0H552C558.627 0 564 5.37258 564 12V88C564 94.6274 558.627 100 552 100H476C469.373 100 464 94.6274 464 88V12Z" />
</mask>
<path
d="M464 12C464 5.37258 469.373 0 476 0H552C558.627 0 564 5.37258 564 12V88C564 94.6274 558.627 100 552 100H476C469.373 100 464 94.6274 464 88V12Z"
fill="#0A0A0A" />
<path
d="M464 12C464 4.8203 469.82 -1 477 -1H551C558.18 -1 564 4.8203 564 12C564 5.92487 558.627 1 552 1H476C469.373 1 464 5.92487 464 12ZM564 100H464H564ZM464 100V0V100ZM564 0V100V0Z"
fill="#303246" mask="url(#path-14-inside-4_473_1361)" />
<path
d="M514 55.299C517.088 55.299 519.591 52.7959 519.591 49.7081C519.591 46.6203 517.088 44.1172 514 44.1172C510.912 44.1172 508.409 46.6203 508.409 49.7081C508.409 52.7959 510.912 55.299 514 55.299Z"
fill="#61DAFB" />
<path
d="M514 61.1625C530.569 61.1625 544 56.0341 544 49.708C544 43.3818 530.569 38.2534 514 38.2534C497.431 38.2534 484 43.3818 484 49.708C484 56.0341 497.431 61.1625 514 61.1625Z"
stroke="#61DAFB" stroke-width="5" />
<path
d="M504.08 55.4353C512.364 69.7841 523.521 78.8519 529 75.6888C534.479 72.5257 532.204 58.3295 523.92 43.9808C515.636 29.632 504.479 20.5642 499 23.7273C493.521 26.8904 495.796 41.0865 504.08 55.4353Z"
stroke="#61DAFB" stroke-width="5" />
<path
d="M504.08 43.9808C495.796 58.3296 493.521 72.5258 499 75.6888C504.479 78.8519 515.636 69.7841 523.92 55.4354C532.204 41.0866 534.479 26.8904 529 23.7273C523.521 20.5642 512.364 29.632 504.08 43.9808Z"
stroke="#61DAFB" stroke-width="5" />
<mask id="path-20-inside-5_473_1361" fill="white">
<path
d="M232 12C232 5.37258 237.373 0 244 0H320C326.627 0 332 5.37258 332 12V88C332 94.6274 326.627 100 320 100H244C237.373 100 232 94.6274 232 88V12Z" />
</mask>
<path
d="M232 12C232 5.37258 237.373 0 244 0H320C326.627 0 332 5.37258 332 12V88C332 94.6274 326.627 100 320 100H244C237.373 100 232 94.6274 232 88V12Z"
fill="#0A0A0A" />
<path
d="M232 12C232 4.8203 237.82 -1 245 -1H319C326.18 -1 332 4.8203 332 12C332 5.92487 326.627 1 320 1H244C237.373 1 232 5.92487 232 12ZM332 100H232H332ZM232 100V0V100ZM332 0V100V0Z"
fill="#303246" mask="url(#path-20-inside-5_473_1361)" />
<path
d="M282 20C298.569 20 312 33.4314 312 50C312 66.5686 298.569 80 282 80C265.431 80 252 66.5686 252 50C252 33.4314 265.431 20 282 20Z"
fill="black" />
<path
d="M281.327 64.6787C280.558 64.4713 279.766 64.9167 279.541 65.6761L279.531 65.7115L277.539 73.0943L277.53 73.1299C277.342 73.8995 277.802 74.6827 278.572 74.8901C279.341 75.0979 280.132 74.6525 280.357 73.8929L280.367 73.8577L282.359 66.4749L282.369 66.4391C282.382 66.3837 282.392 66.3279 282.399 66.2723L282.405 66.2167L282.358 65.9775L282.289 65.6331L282.245 65.4181C282.152 65.2379 282.022 65.0791 281.864 64.9517C281.706 64.8245 281.523 64.7315 281.327 64.6787ZM267.445 57.0757C267.408 57.1481 267.378 57.2245 267.353 57.3043L267.339 57.3525L265.347 64.7353L265.338 64.7711C265.15 65.5407 265.61 66.3237 266.38 66.5313C267.149 66.7389 267.941 66.2937 268.166 65.5341L268.176 65.4987L269.982 58.8045C269.036 58.3035 268.187 57.7255 267.445 57.0757ZM262.694 48.5857C261.925 48.3781 261.133 48.8233 260.908 49.5829L260.898 49.6183L258.906 57.0011L258.897 57.0367C258.709 57.8063 259.169 58.5893 259.939 58.7969C260.708 59.0045 261.499 58.5593 261.725 57.7997L261.734 57.7643L263.727 50.3815L263.736 50.3459C263.923 49.5763 263.463 48.7933 262.694 48.5857ZM307.364 46.9091C306.595 46.7015 305.803 47.1467 305.578 47.9063L305.568 47.9417L303.576 55.3245L303.567 55.3601C303.379 56.1297 303.839 56.9127 304.608 57.1203C305.378 57.3279 306.169 56.8827 306.394 56.1231L306.404 56.0877L308.396 48.7049L308.406 48.6693C308.593 47.8997 308.133 47.1167 307.364 46.9091ZM258.356 37.0504C256.687 40.0887 255.625 43.4223 255.228 46.8657C255.418 47.0823 255.668 47.2379 255.946 47.3125C256.715 47.5203 257.507 47.0749 257.732 46.3153L257.742 46.2801L259.734 38.8972L259.743 38.8616C259.931 38.0919 259.471 37.3088 258.701 37.1013C258.589 37.0708 258.472 37.0538 258.356 37.0504ZM302.318 37.1013C301.549 36.8936 300.757 37.3389 300.532 38.0985L300.522 38.1338L298.53 45.5167L298.521 45.5523C298.333 46.3219 298.793 47.1051 299.563 47.3125C300.332 47.5203 301.123 47.0749 301.349 46.3153L301.358 46.2801L303.351 38.8972L303.36 38.8616C303.547 38.0919 303.087 37.3088 302.318 37.1013Z"
fill="white" />
<path
d="M267.026 30.0813C266.256 29.8736 265.465 30.319 265.24 31.0786L265.23 31.1138L263.238 38.4967L263.229 38.5323C263.041 39.302 263.501 40.085 264.27 40.2926C265.04 40.5002 265.831 40.0548 266.056 39.2953L266.066 39.26L268.058 31.8772L268.067 31.8416C268.255 31.0719 267.795 30.2888 267.026 30.0813ZM292.623 31.4769C291.854 31.2692 291.062 31.7145 290.837 32.4742L290.827 32.5094L289.489 37.47C290.356 37.8983 291.183 38.4025 291.962 38.9768L292.091 39.0729L293.656 33.2728L293.665 33.2372C293.852 32.4675 293.393 31.6844 292.623 31.4769ZM279.594 23.1528C278.659 23.2354 277.729 23.3668 276.809 23.5463L276.613 23.5853L274.756 30.4684L274.747 30.504C274.56 31.2737 275.02 32.0567 275.789 32.2643C276.558 32.4719 277.35 32.0266 277.575 31.267L277.585 31.2317L279.577 23.8489L279.586 23.8133C279.639 23.5966 279.642 23.3707 279.594 23.1528ZM297.925 28.2526L297.534 29.7034L297.525 29.7389C297.337 30.5086 297.797 31.2916 298.566 31.4992C299.336 31.7068 300.127 31.2615 300.352 30.5019L300.362 30.4666L300.405 30.3092C299.672 29.6241 298.902 28.9802 298.098 28.3804L297.925 28.2526ZM286.334 23.3935L285.628 26.0119L285.619 26.0475C285.431 26.8172 285.891 27.6002 286.661 27.8078C287.43 28.0154 288.221 27.5701 288.447 26.8105L288.456 26.7752L289.2 24.0193C288.325 23.7773 287.438 23.58 286.543 23.4281L286.334 23.3935Z"
fill="white" />
<path
d="M271.382 69.2504C271.607 68.4908 272.398 68.0456 273.168 68.253C273.937 68.4604 274.397 69.2436 274.209 70.0134L274.2 70.049L272.774 75.3326L272.575 75.2592C271.717 74.9386 270.875 74.5744 270.054 74.1676L271.372 69.2856L271.382 69.2504Z"
fill="white" />
<path
d="M280.828 36.9814C272.104 36.9814 265.318 42.4734 265.318 49.3032C265.318 55.7536 271.562 59.8722 281.242 59.666C282.065 59.6484 282.303 60.2014 282.571 60.9466C282.839 61.6918 283.559 65.6192 284.133 68.6232C284.647 71.3118 285.168 74.0102 285.567 76.719C291.888 75.8834 297.733 72.7612 302.015 68.052L297.447 51.0174C296.309 46.9034 294.978 43.1126 291.457 40.3586C288.624 38.1431 285.024 36.9814 280.828 36.9814Z"
fill="white" />
<path
d="M282.703 41.9141C283.739 41.9141 284.578 42.7535 284.578 43.7891C284.578 44.8247 283.739 45.6641 282.703 45.6641C281.668 45.6641 280.828 44.8247 280.828 43.7891C280.828 42.7535 281.668 41.9141 282.703 41.9141Z"
fill="black" />
<mask id="path-28-inside-6_473_1361" fill="white">
<path
d="M580 12C580 5.37258 585.373 0 592 0H668C674.627 0 680 5.37258 680 12V88C680 94.6274 674.627 100 668 100H592C585.373 100 580 94.6274 580 88V12Z" />
</mask>
<path
d="M580 12C580 5.37258 585.373 0 592 0H668C674.627 0 680 5.37258 680 12V88C680 94.6274 674.627 100 668 100H592C585.373 100 580 94.6274 580 88V12Z"
fill="#0A0A0A" />
<path
d="M580 12C580 4.8203 585.82 -1 593 -1H667C674.18 -1 680 4.8203 680 12C680 5.92487 674.627 1 668 1H592C585.373 1 580 5.92487 580 12ZM680 100H580H680ZM580 100V0V100ZM680 0V100V0Z"
fill="#303246" mask="url(#path-28-inside-6_473_1361)" />
<path d="M655 25H605V75H655V25Z" fill="#F7DF1E" />
<path
d="M638.587 64.0625C639.594 65.7069 640.905 66.9156 643.222 66.9156C645.169 66.9156 646.413 65.9426 646.413 64.5982C646.413 62.9871 645.135 62.4164 642.992 61.4791L641.817 60.9752C638.427 59.5307 636.175 57.7212 636.175 53.8958C636.175 50.372 638.859 47.6895 643.056 47.6895C646.043 47.6895 648.19 48.7291 649.738 51.4514L646.079 53.8006C645.274 52.3561 644.405 51.7871 643.056 51.7871C641.679 51.7871 640.807 52.6601 640.807 53.8006C640.807 55.2101 641.68 55.7807 643.696 56.6537L644.871 57.1569C648.863 58.8688 651.117 60.6141 651.117 64.5379C651.117 68.768 647.794 71.0855 643.331 71.0855C638.967 71.0855 636.148 69.0061 634.769 66.2807L638.587 64.0625ZM621.99 64.4696C622.728 65.7791 623.399 66.8863 625.013 66.8863C626.557 66.8863 627.531 66.2823 627.531 63.9339V47.9577H632.229V63.9974C632.229 68.8625 629.377 71.0768 625.213 71.0768C621.452 71.0768 619.273 69.1299 618.165 66.7851L621.99 64.4696Z"
fill="black" />
<defs>
<linearGradient id="paint0_linear_473_1361" x1="404.333" y1="58.8334"
x2="416.167" y2="73.5" gradientUnits="userSpaceOnUse">
<stop />
<stop offset="1" stop-color="white" stop-opacity="0" />
</linearGradient>
<linearGradient id="paint1_linear_473_1361" x1="408.334" y1="38"
x2="408.267" y2="55.6251" gradientUnits="userSpaceOnUse">
<stop />
<stop offset="1" stop-color="white" stop-opacity="0" />
</linearGradient>
</defs>
</svg>
</div>
<LyxUiButton type="secondary" to="https://docs.litlyx.com"> Visit documentation
</LyxUiButton>
</div>
</CardTitled>
</div>
</div>
</div>
</div>
<!-- <div class="flex justify-center gap-10 flex-col lg:flex-row items-center lg: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 lg: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,55 @@
<script lang="ts" setup>
import type { TProject } from '@schema/ProjectSchema';
const { user } = useLoggedUser()
const { projectList, guestProjectList,allProjectList, actions, project } = useProject();
function isProjectMine(owner?: string) {
if (!owner) return false;
if (!user.value) return false;
if (!user.value.logged) return;
return user.value.id == owner;
}
function onChange(e: TProject) {
actions.setActiveProject(e._id.toString());
}
</script>
<template>
<USelectMenu :uiMenu="{
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
base: '!bg-lyx-widget',
option: {
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-widget-lighter'
}
}" class="w-full" v-if="allProjectList" @change="onChange" :value="project" :options="allProjectList">
<template #option="{ option, active, selected }">
<div class="flex items-center gap-2">
<div>
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
</div>
<div> {{ option.name }} {{ !isProjectMine(option.owner) ? '(Guest)' : '' }}</div>
</div>
</template>
<template #label>
<div class="flex items-center gap-2">
<div>
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
</div>
<div>
{{ project?.name || '-' }}
{{ !isProjectMine(project?.owner?.toString()) ? '(Guest)' : '' }}
</div>
</div>
</template>
</USelectMenu>
</template>

View File

@@ -16,10 +16,10 @@ const emits = defineEmits<{
<template>
<div class="flex gap-2 border-[1px] border-gray-400 p-1 md:p-2 rounded-xl">
<div class="flex gap-2 border-[1px] 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-white/10 select-btn-animated cursor-pointer rounded-lg poppins font-semibold px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
:class="{ 'bg-accent hover:!bg-accent': currentIndex == index }">
class="hover:bg-lyx-widget-lighter/60 select-btn-animated cursor-pointer rounded-lg poppins font-regular px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
:class="{ 'bg-lyx-widget-lighter hover:!bg-lyx-widget-lighter': currentIndex == index }">
{{ opt.label }}
</div>
</div>

View File

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

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
registerChartComponents();
const props = defineProps<{
data: any[],
labels: string[]
color: string,
}>();
const chartOptions = ref<ChartOptions<'line'>>({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'nearest',
axis: 'x',
includeInvisible: true
},
scales: {
y: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
// borderDash: [5, 10]
},
},
x: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
}
}
},
plugins: {
legend: { display: false },
title: { display: false },
tooltip: {
enabled: true,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleFont: { size: 16, weight: 'bold' },
bodyFont: { size: 14 },
padding: 10,
cornerRadius: 4,
boxPadding: 10,
caretPadding: 20,
yAlign: 'bottom',
xAlign: 'center',
}
},
});
const chartData = ref<ChartData<'line'>>({
labels: props.labels,
datasets: [
{
data: props.data,
backgroundColor: [props.color + '77'],
borderColor: props.color,
borderWidth: 4,
fill: true,
tension: 0.45,
pointRadius: 0,
pointHoverRadius: 10,
hoverBackgroundColor: props.color,
hoverBorderColor: 'white',
hoverBorderWidth: 2,
},
],
});
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
onMounted(async () => {
const c = document.createElement('canvas');
const ctx = c.getContext("2d");
let gradient: any = `${props.color}22`;
if (ctx) {
gradient = ctx.createLinearGradient(0, 25, 0, 300);
gradient.addColorStop(0, `${props.color}99`);
gradient.addColorStop(0.35, `${props.color}66`);
gradient.addColorStop(1, `${props.color}22`);
} else {
console.warn('Cannot get context for gradient');
}
chartData.value.datasets[0].backgroundColor = [gradient];
watch(props, () => {
chartData.value.labels = props.labels;
chartData.value.datasets[0].data = props.data;
});
});
</script>
<template>
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
</template>

View File

@@ -0,0 +1,33 @@
<script lang="ts" setup>
const limitsInfo = await useFetch("/api/project/limits_info", {
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
});
const pricingDrawer = usePricingDrawer();
function goToUpgrade() {
pricingDrawer.visible.value = true;
}
</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,37 @@
<script lang="ts" setup>
const pricingDrawer = usePricingDrawer();
function goToUpgrade() {
pricingDrawer.visible.value = true;
}
const { project } = useProject()
const isPremium = computed(() => {
return project.value?.premium ?? false;
});
</script>
<template>
<div v-if="!isPremium" class="w-full bg-[#5680f822] p-4 rounded-lg text-[.9rem] flex items-center">
<div class="flex flex-col grow">
<div class="poppins font-semibold text-lyx-primary">
Launch offer: 25% off
</div>
<div class="poppins text-lyx-primary">
We're offering an exclusive 25% discount forever on all plans starting from the Acceleration
Plan for our first 100 users who believe in our project.
<br>
Redeem Code: <span class="text-white font-bold text-[1rem]">LIT25</span> at checkout to
claim your discount.
</div>
</div>
<div>
<LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>
</div>
</div>
</template>

View File

@@ -0,0 +1,320 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import DateService, { type Slice } from '@services/DateService';
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
const errorData = ref<{ errored: boolean, text: string }>({ errored: false, text: '' })
const chartOptions = ref<ChartOptions<'line'>>({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'nearest',
axis: 'x',
includeInvisible: true
},
scales: {
y: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
// borderDash: [5, 10]
},
},
x: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
}
}
},
plugins: {
legend: { display: false },
title: { display: false },
tooltip: {
// enabled: true,
// backgroundColor: 'rgba(0, 0, 0, 0.8)',
// titleFont: { size: 16, weight: 'bold' },
// bodyFont: { size: 14 },
// padding: 10,
// cornerRadius: 4,
// boxPadding: 10,
// caretPadding: 20,
// yAlign: 'bottom',
// xAlign: 'center',
enabled: false,
position: 'nearest',
external: externalTooltipHandler
}
},
});
const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
labels: [],
datasets: [
{
label: 'Visits',
data: [],
backgroundColor: ['#5655d7'],
borderColor: '#5655d7',
borderWidth: 4,
fill: true,
tension: 0.45,
pointRadius: 0,
pointHoverRadius: 10,
hoverBackgroundColor: '#5655d7',
hoverBorderColor: 'white',
hoverBorderWidth: 2,
},
{
label: 'Unique sessions',
data: [],
backgroundColor: ['#4abde8'],
borderColor: '#4abde8',
borderWidth: 2,
hoverBackgroundColor: '#4abde8',
hoverBorderColor: '#4abde8',
hoverBorderWidth: 2,
type: 'bar'
},
{
label: 'Events',
data: [],
backgroundColor: ['#fbbf24'],
borderColor: '#fbbf24',
borderWidth: 2,
hoverBackgroundColor: '#fbbf24',
hoverBorderColor: '#fbbf24',
hoverBorderWidth: 2,
type: 'bubble',
stack: 'combined'
},
],
});
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
const externalTooltipElement = ref<null | HTMLDivElement>(null);
function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'line' | 'bar'> }) {
const { chart, tooltip } = context;
const tooltipEl = externalTooltipElement.value;
currentTooltipData.value.visits = (tooltip.dataPoints.find(e => e.datasetIndex == 0)?.raw) as number;
currentTooltipData.value.sessions = (tooltip.dataPoints.find(e => e.datasetIndex == 1)?.raw) as number;
currentTooltipData.value.events = ((tooltip.dataPoints.find(e => e.datasetIndex == 2)?.raw) as any)?.r2 as number;
currentTooltipData.value.date = new Date(allDatesFull.value[tooltip.dataPoints[0].dataIndex]).toLocaleDateString();
if (!tooltipEl) return;
if (tooltip.opacity === 0) {
tooltipEl.style.opacity = '0';
return;
}
const { left: positionX, top: positionY } = chart.canvas.getBoundingClientRect();
const xSwap = tooltip.caretX > (window.innerWidth * 0.5) ? -450 : -100;
tooltipEl.style.opacity = '1';
tooltipEl.style.left = positionX + (tooltip.caretX + xSwap) + 'px';
tooltipEl.style.top = positionY + tooltip.caretY + 'px';
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px';
}
const selectLabels: { label: string, value: Slice }[] = [
{ label: 'Hour', value: 'hour' },
{ label: 'Day', value: 'day' },
{ label: 'Month', value: 'month' },
];
const selectedSlice = computed(() => selectLabels[selectedLabelIndex.value].value);
const selectedLabelIndex = ref<number>(1);
const allDatesFull = ref<string[]>([]);
function transformResponse(input: { _id: string, count: number }[]) {
const data = input.map(e => e.count);
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, selectLabels[selectedLabelIndex.value].value));
allDatesFull.value = input.map(e => e._id.toString());
return { data, labels }
}
function onResponseError(e: any) {
errorData.value = { errored: true, text: e.response._data.message ?? 'Generic error' }
}
function onResponse(e: any) {
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
}
const visitsData = useFetch('/api/timeline/visits', {
headers: useComputedHeaders({ slice: selectedSlice }), lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const sessionsData = useFetch('/api/timeline/sessions', {
headers: useComputedHeaders({ slice: selectedSlice }), lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const eventsData = useFetch('/api/timeline/events', {
headers: useComputedHeaders({ slice: selectedSlice }), lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const readyToDisplay = computed(() => !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value);
watch(readyToDisplay, () => {
if (readyToDisplay.value === true) onDataReady();
})
function 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() {
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 = 25 / maxEventSize * e;
return { x: 0, y: maxChartY + 70, 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')];
updateChart();
}
const currentTooltipData = ref<{ visits: number, events: number, sessions: number, date: string }>({
visits: 0,
events: 0,
sessions: 0,
date: ''
});
const tooltipNameIndex = ['visits', 'sessions', 'events'];
function onLegendChange(dataset: any, index: number, checked: any) {
dataset.hidden = !checked;
}
const legendColors = ref<string[]>(['#5655d7', '#4abde8', '#fbbf24'])
const legendClasses = ref<string[]>([
'actionable-visits-color-checkbox',
'actionable-sessions-color-checkbox',
'actionable-events-color-checkbox'
])
</script>
<template>
<CardTitled title="Trend chart" sub="Easily match Visits, Unique sessions and Events trends." class="w-full">
<template #header>
<SelectButton class="w-fit" @changeIndex="selectedLabelIndex = $event" :currentIndex="selectedLabelIndex"
:options="selectLabels">
</SelectButton>
</template>
<div class="flex gap-6 w-full justify-between">
<LyxUiButton type="secondary" :to="isLiveDemo ? '#' : '/analyst'" :disabled="isLiveDemo">
<div class="flex items-center gap-2 px-10">
<i class="far fa-sparkles text-yellow-400"></i>
<div class="poppins text-lyx-text"> Ask AI </div>
</div>
</LyxUiButton>
<div class="flex gap-6">
<div v-for="(dataset, index) of chartData.datasets" class="flex gap-2 items-center text-[.9rem]">
<UCheckbox :ui="{
color: legendClasses[index]
}" :model-value="true" @change="onLegendChange(dataset, index, $event)"></UCheckbox>
<label class="mt-[2px]"> {{ dataset.label }} </label>
</div>
</div>
</div>
<div id='external-tooltip' ref="externalTooltipElement" class="z-[400]">
<LyxUiCard>
<div class="flex gap-2 items-center">
<div> Date: </div>
<div v-if="currentTooltipData"> {{ currentTooltipData.date }}</div>
</div>
<div v-for="(dataset, index) of chartData.datasets" class="flex gap-2 items-center">
<div :style="`background-color: ${legendColors[index]}`" class="h-4 w-4 rounded-full">
</div>
<div> {{ dataset.label }}</div>
<div v-if="currentTooltipData" class="grow text-right px-4">
{{ (currentTooltipData as any)[tooltipNameIndex[index]] }}
</div>
</div>
<!-- <div class="bg-lyx-background-lighter h-[2px] w-full my-2"> </div> -->
</LyxUiCard>
</div>
<div v-if="!readyToDisplay" class="flex justify-center py-40">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
<div class="flex flex-col items-end" v-if="readyToDisplay && !errorData.errored">
<LineChart ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
</div>
<div v-if="errorData.errored" class="flex items-center justify-center py-8">
{{ errorData.text }}
</div>
</CardTitled>
</template>
<style lang="scss" scoped>
#external-tooltip {
border-radius: 3px;
color: white;
opacity: 0;
pointer-events: none;
position: absolute;
transform: translate(-50%, 0);
transition: all .1s ease;
}
</style>

View File

@@ -1,181 +0,0 @@
<script lang="ts" setup>
export type IconProvider = (id: string) => ['img' | 'icon', string] | undefined;
type Props = {
data: { _id: string, count: number }[],
iconProvider?: IconProvider,
elementTextTransformer?: (text: string) => string,
label: string,
subLabel: string,
desc: string,
loading?: boolean,
interactive?: boolean,
isDetailView?: boolean,
rawButton?: boolean,
hideShowMore?: boolean,
customIconStyle?: string,
showLink?: boolean
}
const props = defineProps<Props>();
const emits = defineEmits<{
(e: 'dataReload'): void,
(e: 'showDetails', id: string): void,
(e: 'showRawData'): void,
(e: 'showGeneral'): void,
(e: 'showMore'): void,
}>();
const maxData = computed(() => {
const counts = props.data.map(e => e.count);
return Math.max(...counts);
});
function reloadData() {
emits('dataReload');
}
function showDetails(id: string) {
emits('showDetails', id);
}
function openExternalLink(link: string) {
if (link === 'self') return;
return window.open('https://' + link, '_blank');
}
</script>
<template>
<div class="flex h-full">
<div class="text-text flex flex-col items-start gap-4 w-full relative">
<div class="w-full h-full p-4 flex flex-col bg-card rounded-xl gap-8 card-shadow">
<div class="flex justify-between mb-3">
<div class="flex flex-col gap-1">
<div class="flex gap-4 items-center">
<div class="poppins font-semibold text-[1.4rem] text-text">
{{ label }}
</div>
<div class="flex items-center">
<i @click="reloadData()"
class="hover:rotate-[50deg] transition-all duration-100 fas fa-refresh text-[1.2rem] cursor-pointer"></i>
</div>
</div>
<div class="poppins text-[1rem] text-text-sub/90">
{{ desc }}
</div>
</div>
<div v-if="rawButton" class="hidden lg:flex">
<div @click="$emit('showRawData')"
class="cursor-pointer flex gap-1 items-center justify-center font-semibold poppins rounded-lg text-[#5680f8] hover:text-[#5681f8ce]">
<div> Raw data </div>
<div class="flex items-center"> <i class="fas fa-arrow-up-right"></i> </div>
</div>
</div>
</div>
<div>
<div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4">
<div class="flex items-center gap-2">
<div v-if="isDetailView" class="flex items-center justify-center">
<i @click="$emit('showGeneral')"
class="fas fa-arrow-left text-[.9rem] hover:text-text cursor-pointer"></i>
</div>
<div> {{ subLabel }} </div>
</div>
<div> Count </div>
</div>
<div class="flex flex-col gap-1">
<div v-if="props.data.length > 0" class="flex justify-between items-center"
v-for="element of props.data">
<div class="flex items-center gap-2 w-10/12 relative">
<div v-if="showLink">
<i @click="openExternalLink(element._id)"
class="fas fa-link text-gray-300 hover:text-gray-400 cursor-pointer"></i>
</div>
<div class="flex gap-1 items-center" @click="showDetails(element._id)"
:class="{ 'cursor-pointer line-active': interactive }">
<div class="absolute rounded-sm w-full h-full bg-[#92abcf38]"
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
<div class="flex px-2 py-1 relative items-center gap-4">
<div v-if="iconProvider && iconProvider(element._id) != undefined"
class="flex items-center h-[1.3rem]">
<img v-if="iconProvider(element._id)?.[0] == 'img'" class="h-full"
:style="customIconStyle" :src="iconProvider(element._id)?.[1]">
<i v-else :class="iconProvider(element._id)?.[1]"></i>
</div>
<span
class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
{{ elementTextTransformer?.(element._id) || element._id }}
</span>
</div>
</div>
</div>
<div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{
formatNumberK(element.count) }} </div>
</div>
<div v-if="props.data.length == 0"
class="flex justify-center text-text-sub font-bold text-[1.1rem]">
No visits yet
</div>
</div>
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 ">
<div @click="$emit('showMore')"
class="poppins hover:bg-black cursor-pointer w-fit px-6 py-1 rounded-lg border-[1px] border-text-sub text-[.9rem]">
Show more
</div>
</div>
</div>
<div v-if="loading"
class="backdrop-blur-[1px] z-[20] left-0 top-0 w-full h-full flex items-center justify-center font-bold rockmann absolute">
<i
class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.line-active:hover {
.absolute {
@apply bg-accent/20
}
}
.ui-font {
font-feature-settings: normal;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
font-variation-settings: normal;
font-weight: 600;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4
}
</style>

View File

@@ -1,39 +0,0 @@
<script lang="ts" setup>
import type { BrowsersAggregated } from '~/server/api/metrics/[project_id]/data/browsers';
const activeProject = await useActiveProject();
const { data: events, pending, refresh } = await useFetch<BrowsersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`, {
...signHeaders(),
lazy: true
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() {
showDialog.value = true;
dialogBarData.value = [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data;
isDataLoading.value = false;
});
}
</script>
<template>
<div class="flex flex-col gap-2">
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []"
desc="The browsers most used to search your website." :dataIcons="false" :loading="pending"
label="Top Browsers" sub-label="Browsers"></DashboardBarsCard>
</div>
</template>

View File

@@ -9,63 +9,64 @@ const props = defineProps<{
color: string,
data?: number[],
labels?: string[],
ready?: boolean
ready?: boolean,
slow?: boolean
}>();
const { snapshotDuration } = useSnapshot()
const uTooltipText = computed(() => {
const duration = snapshotDuration.value;
if (!duration) return '';
if (duration > 25) return 'Monthly trend';
if (duration > 7) return 'Weekly trend';
return 'Daily trend';
})
</script>
<template>
<Card class="flex flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
<div class="flex p-4 items-start">
<LyxUiCard class="flex !p-0 flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
<div v-if="ready" class="flex p-4 items-start">
<div class="flex items-center mt-2 mr-4">
<i :style="`color: ${props.color}`" :class="icon" class="text-[1.6rem] 2xl:text-[2rem]"></i>
<i :style="`color: ${props.color}`" :class="icon" class="text-[1.3rem] 2xl:text-[1.5rem]"></i>
</div>
<div class="flex flex-col grow">
<div class="flex items-end gap-2">
<div class="brockmann text-text-dirty text-[1.6rem] 2xl:text-[1.9rem]"> {{ value }} </div>
<div class="poppins text-text-sub text-[.7rem] 2xl:text-[.85rem] mb-2"> {{ avg }} </div>
<div class="flex items-center gap-2">
<div class="brockmann text-text-dirty text-[1.2rem] 2xl:text-[1.4rem]">
{{ value }}
</div>
<div class="poppins text-text-sub text-[.9rem] 2xl:text-base"> {{ text }} </div>
<div class="poppins text-text-sub text-[.65rem] 2xl:text-[.8rem]"> {{ avg }} </div>
</div>
<div class="poppins text-text-sub text-[.9rem] 2xl:text-[1rem]"> {{ 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`">
<UTooltip :text="uTooltipText">
<div class="flex items-center gap-3 rounded-md px-2 py-1"
:style="`background-color: ${props.color}33`">
<i :class="trend > 0 ? 'fa-arrow-trend-up' : 'fa-arrow-trend-down'"
class="far text-[.9rem] 2xl:text-[1rem]" :style="`color: ${props.color}`"></i>
<div :style="`color: ${props.color}`" class="font-semibold text-[.75rem] 2xl:text-[.875rem]">
{{ trend.toFixed(0) }} %
</div>
</div>
<div class="poppins text-text-sub text-[.7rem]"> Daily variation </div>
</UTooltip>
<!-- <div class="poppins text-text-sub text-[.7rem]"> Trend </div> -->
</div>
</div>
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end" v-if="(props.data?.length || 0) > 0">
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end"
v-if="((props.data?.length || 0) > 0) && ready">
<DashboardEmbedChartCard v-if="ready" :data="props.data || []" :labels="props.labels || []"
:color="props.color">
</DashboardEmbedChartCard>
</div>
</Card>
<div v-if="!ready" class="flex justify-center items-center w-full h-full flex-col gap-2">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
<div v-if="props.slow"> Can be very slow on large snapshots </div>
</div>
</LyxUiCard>
<!-- <div class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full lg:w-[20rem] relative pb-2 lg:pb-4">
<div class="gap-4 flex flex-row items-center lg:items-start lg:gap-2 lg:flex-col">
<div class="w-[2.5rem] h-[2.5rem] lg:w-[3.5rem] lg:h-[3.5rem] flex items-center justify-center rounded-lg"
:style="`background: ${props.color}`">
<i :class="icon" class="text-[1rem] lg:text-[1.5rem]"></i>
</div>
<div class="text-[1rem] lg:text-[1.3rem] text-text-sub/90 poppins">
{{ title }}
</div>
</div>
<div class="flex gap-2 items-center lg:items-end">
<div class="brockmann text-text text-[2rem] lg:text-[2.8rem] grow">
{{ text }}
</div>
<div class="poppins text-text-sub/90 text-[.9rem] lg:text-[1rem]"> {{ sub }} </div>
</div>
</div> -->
</template>

View File

@@ -1,39 +0,0 @@
<script lang="ts" setup>
import type { DevicesAggregated } from '~/server/api/metrics/[project_id]/data/devices';
const activeProject = await useActiveProject();
const { data: events, pending, refresh } = await useFetch<DevicesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/devices`, {
...signHeaders(),
lazy: true
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() {
showDialog.value = true;
dialogBarData.value = [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/devices`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data;
isDataLoading.value = false;
});
}
</script>
<template>
<div class="flex flex-col gap-2">
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []" :dataIcons="false"
desc="The devices most used to access your website." :loading="pending" label="Top Devices"
sub-label="Devices"></DashboardBarsCard>
</div>
</template>

View File

@@ -1,45 +0,0 @@
<script lang="ts" setup>
import type { CustomEventsAggregated } from '~/server/api/metrics/[project_id]/visits/events';
const activeProject = await useActiveProject();
const { data: events, pending, refresh } = await useFetch<CustomEventsAggregated[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, {
...signHeaders(),
lazy: true
});
const router = useRouter();
function goToView() {
router.push('/dashboard/events');
}
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() {
showDialog.value = true;
dialogBarData.value = [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data;
isDataLoading.value = false;
});
}
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<DashboardBarsCard @showMore="showMore()" @showRawData="goToView()"
desc="Most frequent user events triggered in this project" @dataReload="refresh" :data="events || []"
:loading="pending" label="Top Events" sub-label="Events" :rawButton="!isLiveDemo()"></DashboardBarsCard>
</div>
</template>

View File

@@ -2,7 +2,7 @@
import { Chart, registerables, type ChartData, type ChartOptions } from 'chart.js';
import { DoughnutChart, useDoughnutChart } from 'vue-chart-3';
import type { EventsPie } from '~/server/api/metrics/[project_id]/events_pie';
import type { CustomEventsAggregated } from '~/server/api/metrics/[project_id]/data/events';
definePageMeta({ layout: 'dashboard' });
@@ -20,15 +20,6 @@ const chartOptions = ref<ChartOptions<'doughnut'>>({
ticks: { display: false },
grid: { display: false, drawBorder: false },
},
// r: {
// ticks: { display: false },
// grid: {
// display: true,
// drawBorder: false,
// color: '#CCCCCC22',
// borderDash: [20, 8]
// },
// }
},
plugins: {
legend: {
@@ -55,7 +46,28 @@ const chartData = ref<ChartData<'doughnut'>>({
{
rotation: 1,
data: [],
backgroundColor: ['#6bbbe3','#5655d0', '#a6d5cb', '#fae0b9'],
backgroundColor: [
"#5655d0",
"#6bbbe3",
"#a6d5cb",
"#fae0b9",
"#f28e8e",
"#e3a7e4",
"#c4a8e1",
"#8cc1d8",
"#f9c2cd",
"#b4e3b2",
"#ffdfba",
"#e9c3b5",
"#d5b8d6",
"#add7f6",
"#ffd1dc",
"#ffe7a1",
"#a8e6cf",
"#d4a5a5",
"#f3d6e4",
"#c3aed6"
],
borderColor: ['#1d1d1f'],
borderWidth: 2
},
@@ -65,15 +77,18 @@ const chartData = ref<ChartData<'doughnut'>>({
const { doughnutChartProps, doughnutChartRef } = useDoughnutChart({ chartData: chartData, options: chartOptions });
onMounted(async () => {
const { projectId } = useProject();
const activeProject = useActiveProject()
const { safeSnapshotDates } = useSnapshot();
const eventsData = await $fetch<EventsPie[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, signHeaders());
chartData.value.labels = eventsData.map(e => {
function transformResponse(input: CustomEventsAggregated[]) {
chartData.value.labels = input.map(e => {
return `${e._id}`;
});
chartData.value.datasets[0].data = eventsData.map(e => e.count);
chartData.value.datasets[0].data = input.map(e => e.count);
doughnutChartRef.value?.update();
if (window.innerWidth < 800) {
@@ -81,11 +96,25 @@ onMounted(async () => {
chartOptions.value.plugins.legend.display = false;
}
}
})
}
const eventsData = useFetch(`/api/data/events`, {
headers: useComputedHeaders({ limit: 6 }), lazy: true, immediate: false, transform: transformResponse
});
onMounted(() => {
eventsData.execute();
});
</script>
<template>
<DoughnutChart v-bind="doughnutChartProps"> </DoughnutChart>
<div>
<div v-if="eventsData.pending.value" class="flex justify-center py-40">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
<DoughnutChart v-if="!eventsData.pending.value" v-bind="doughnutChartProps"> </DoughnutChart>
</div>
</template>

View File

@@ -1,50 +0,0 @@
<script lang="ts" setup>
import type { CountriesAggregated } from '~/server/api/metrics/[project_id]/data/countries';
import type { IconProvider } from './BarsCard.vue';
const activeProject = await useActiveProject();
const { data: countries, pending, refresh } = await useFetch<CountriesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/countries`, {
...signHeaders(),
lazy: true
});
function iconProvider(id: string): ReturnType<IconProvider> {
if (id === 'self') return ['icon', 'fas fa-link'];
return [
'img',
`https://raw.githubusercontent.com/hampusborgos/country-flags/main/png250px/${id.toLowerCase()}.png`
]
}
const customIconStyle = `width: 2rem; padding: 1px;`
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() {
showDialog.value = true;
dialogBarData.value = [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/countries`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data;
isDataLoading.value = false;
});
}
</script>
<template>
<div class="flex flex-col gap-2">
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="countries || []" :dataIcons="false"
:loading="pending" label="Top Countries" sub-label="Countries" :iconProvider="iconProvider"
:customIconStyle="customIconStyle" desc=" Lists the countries where users access your website.">
</DashboardBarsCard>
</div>
</template>

View File

@@ -1,39 +0,0 @@
<script lang="ts" setup>
import type { OssAggregated } from '~/server/api/metrics/[project_id]/data/oss';
const activeProject = await useActiveProject();
const { data: events, pending, refresh } = await useFetch<OssAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/oss`, {
...signHeaders(),
lazy: true
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() {
showDialog.value = true;
dialogBarData.value = [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/oss`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data;
isDataLoading.value = false;
});
}
</script>
<template>
<div class="flex flex-col gap-2">
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []"
desc="The operating systems most commonly used by your website's visitors." :dataIcons="false"
:loading="pending" label="Top OS" sub-label="OSs"></DashboardBarsCard>
</div>
</template>

View File

@@ -1,65 +0,0 @@
<script lang="ts" setup>
import type { ReferrersAggregated } from '~/server/api/metrics/[project_id]/data/referrers';
import type { IconProvider } from './BarsCard.vue';
import ReferrerBarChart from '../referrer/ReferrerBarChart.vue';
const activeProject = await useActiveProject();
const { data: events, pending, refresh } = await useFetch<ReferrersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`, {
...signHeaders(),
lazy: true
});
function iconProvider(id: string): ReturnType<IconProvider> {
if (id === 'self') return ['icon', 'fas fa-link'];
return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${id}&sz=64`]
}
function elementTextTransformer(element: string) {
if (element === 'self') return 'Direct Link';
return element;
}
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
const customDialog = useCustomDialog();
function onShowDetails(referrer: string) {
customDialog.openDialog(ReferrerBarChart, { slice: 'day', referrer });
}
function showMore() {
showDialog.value = true;
dialogBarData.value = [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data.map(e => {
return { ...e, icon: iconProvider(e._id) }
});
isDataLoading.value = false;
});
}
</script>
<template>
<div class="flex flex-col gap-2">
<DashboardBarsCard @showDetails="onShowDetails" @showMore="showMore()"
:elementTextTransformer="elementTextTransformer" :iconProvider="iconProvider" @dataReload="refresh"
:showLink=true :data="events || []" :interactive="true" desc="Where users find your website."
:dataIcons="true" :loading="pending" label="Top Referrers" sub-label="Referrers"></DashboardBarsCard>
</div>
</template>

View File

@@ -1,31 +1,46 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import DateService, { type Slice } from '@services/DateService';
const data = ref<number[]>([]);
const labels = ref<string[]>([]);
const ready = ref<boolean>(false);
const props = defineProps<{ slice: Slice }>();
async function loadData() {
const response = await useTimeline('sessions', props.slice);
if (!response) return;
data.value = response.map(e => e.count);
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
ready.value = true;
const activeProject = useActiveProject();
const { safeSnapshotDates } = useSnapshot()
function transformResponse(input: { _id: string, count: number }[]) {
const data = input.map(e => e.count);
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
return { data, labels }
}
const body = computed(() => {
return {
from: safeSnapshotDates.value.from,
to: safeSnapshotDates.value.to,
slice: props.slice
}
});
const sessionsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
lazy: true, immediate: false
});
onMounted(async () => {
await loadData();
watch(props, async () => { await loadData(); });
})
sessionsData.execute();
});
</script>
<template>
<div>
<AdvancedLineChart v-if="ready" :data="data" :labels="labels" color="#f56523"></AdvancedLineChart>
<div v-if="sessionsData.pending.value" class="flex justify-center py-40">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
<AdvancedLineChart v-if="!sessionsData.pending.value" :data="sessionsData.data.value?.data || []"
:labels="sessionsData.data.value?.labels || []" color="#f56523"></AdvancedLineChart>
</div>
</template>

View File

@@ -1,120 +1,122 @@
<script lang="ts" setup>
import DateService from '@services/DateService';
import type { Slice } from '@services/DateService';
const { data: metricsInfo } = useMetricsData();
const { snapshot, safeSnapshotDates } = useSnapshot()
const avgVisitDay = computed(() => {
if (!metricsInfo.value) return '0.00';
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24;
const avg = metricsInfo.value.visitsCount / Math.max(days, 1);
return avg.toFixed(2);
const snapshotDays = computed(() => {
const to = new Date(safeSnapshotDates.value.to).getTime();
const from = new Date(safeSnapshotDates.value.from).getTime();
return (to - from) / 1000 / 60 / 60 / 24;
});
const avgEventsDay = computed(() => {
if (!metricsInfo.value) return '0.00';
const days = (Date.now() - (metricsInfo.value?.firstEventDate || 0)) / 1000 / 60 / 60 / 24;
const avg = metricsInfo.value.eventsCount / Math.max(days, 1);
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 || 0);
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, chartSlice.value));
const pool = [...input.map(e => e.count || 0)];
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 visitsData = useFetch('/api/timeline/visits', {
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
});
const sessionsData = useFetch('/api/timeline/sessions', {
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
});
const sessionsDurationData = useFetch('/api/timeline/sessions_duration', {
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
});
const bouncingRateData = useFetch('/api/timeline/bouncing_rate', {
headers: useComputedHeaders({ slice: chartSlice.value }), 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 avgSessionsDay = computed(() => {
if (!metricsInfo.value) return '0.00';
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24;
const avg = metricsInfo.value.sessionsVisitsCount / Math.max(days, 1);
if (!sessionsData.data.value) return '0.00';
const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
const avg = counts / Math.max(snapshotDays.value, 1);
return avg.toFixed(2);
});
const avgBouncingRate = computed(() => {
if (!bouncingRateData.data.value) return '0.00 %'
const counts = bouncingRateData.data.value.data
.filter(e => e > 0)
.reduce((a, e) => e + a, 0);
const avg = counts / Math.max(bouncingRateData.data.value.data.filter(e => e > 0).length, 1);
return avg.toFixed(2) + ' %';
})
const avgSessionDuration = computed(() => {
if (!metricsInfo.value) return '0.00';
const avg = metricsInfo.value.avgSessionDuration;
if (!sessionsDurationData.data.value) return '0.00 %'
const counts = sessionsDurationData.data.value.data
.filter(e => e > 0)
.reduce((a, e) => e + a, 0);
const avg = counts / Math.max(sessionsDurationData.data.value.data.filter(e => e > 0).length, 1);
let hours = 0;
let minutes = 0;
let seconds = 0;
seconds += avg * 60;
while (seconds > 60) {
seconds -= 60;
minutes += 1;
}
while (minutes > 60) {
minutes -= 60;
hours += 1;
}
while (seconds > 60) { seconds -= 60; minutes += 1; }
while (minutes > 60) { minutes -= 60; hours += 1; }
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
});
type Data = {
data: number[],
labels: string[],
trend: number,
ready: boolean
}
const visitsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
const eventsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
const sessionsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
const sessionsDurationData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
async function loadData(timelineEndpointName: string, target: Data) {
const response = await useTimeline(timelineEndpointName as any, 'day');
if (!response) return;
target.data = response.map(e => e.count);
target.labels = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, 'day'));
const pool = [...response.map(e => e.count)];
pool.pop();
const avg = pool.reduce((a, e) => a + e, 0) / pool.length;
const diffPercent: number = (100 / avg * (response.at(-1)?.count || 0)) - 100;
target.trend = Math.max(Math.min(diffPercent, 99), -99);
target.ready = true;
}
onMounted(async () => {
await loadData('visits', visitsData);
await loadData('events', eventsData);
await loadData('sessions', sessionsData);
await loadData('sessions_duration', sessionsDurationData);
});
</script>
<template>
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4" v-if="metricsInfo">
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4">
<DashboardCountCard :ready="visitsData.ready" icon="far fa-earth" text="Total page visits"
:value="formatNumberK(metricsInfo.visitsCount)" :avg="formatNumberK(avgVisitDay) + '/day'"
:trend="visitsData.trend" :data="visitsData.data" :labels="visitsData.labels" color="#5655d7">
<DashboardCountCard :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>
<DashboardCountCard :ready="eventsData.ready" icon="far fa-flag" text="Total custom events"
:value="formatNumberK(metricsInfo.eventsCount)" :avg="formatNumberK(avgEventsDay) + '/day'"
:trend="eventsData.trend" :data="eventsData.data" :labels="eventsData.labels" color="#1e9b86">
<DashboardCountCard :ready="!bouncingRateData.pending.value" icon="far fa-chart-user" text="Bouncing rate"
:value="avgBouncingRate" :trend="bouncingRateData.data.value?.trend" :slow="true"
:data="bouncingRateData.data.value?.data" :labels="bouncingRateData.data.value?.labels" color="#1e9b86">
</DashboardCountCard>
<DashboardCountCard :ready="sessionsData.ready" icon="far fa-user" text="Unique visits sessions"
:value="formatNumberK(metricsInfo.sessionsVisitsCount)" :avg="formatNumberK(avgSessionsDay) + '/day'"
:trend="sessionsData.trend" :data="sessionsData.data" :labels="sessionsData.labels" color="#4abde8">
<DashboardCountCard :ready="!sessionsData.pending.value" icon="far fa-user" text="Unique visits sessions"
:value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
:avg="formatNumberK(avgSessionsDay) + '/day'" :trend="sessionsData.data.value?.trend"
:data="sessionsData.data.value?.data" :labels="sessionsData.data.value?.labels" color="#4abde8">
</DashboardCountCard>
<DashboardCountCard :ready="sessionsDurationData.ready" icon="far fa-timer" text="Avg session time"
:value="avgSessionDuration" :trend="sessionsDurationData.trend" :data="sessionsDurationData.data"
:labels="sessionsDurationData.labels" color="#f56523">
<DashboardCountCard :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"
color="#f56523">
</DashboardCountCard>
</div>

View File

@@ -1,44 +1,75 @@
<script lang="ts" setup>
const activeProject = useActiveProject();
const { project } = useProject();
const { onlineUsers, stopWatching, startWatching } = useOnlineUsers();
onMounted(() => startWatching());
onUnmounted(() => stopWatching());
const { createAlert } = useAlert();
function copyProjectId() {
if (!navigator.clipboard) alert('NON PUOI COPIARE IN HTTP');
navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
alert('Copiato !');
if (!navigator.clipboard) return alert('You can\'t copy in HTTP');
if (!project.value) return alert('Project not loaded');
navigator.clipboard.writeText((project.value._id).toString());
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
}
function showAnomalyInfoAlert() {
createAlert('AI Anomaly Detector info',
`Anomaly detector is running. It helps you detect a spike in visits or events, it could mean an
attack or simply higher traffic due to good performance. Additionally, it can detect if someone is
stealing parts of your website and hosting a duplicate version—an unfortunately common practice.
Litlyx will notify you via email with actionable advices`,
'far fa-shield',
10000
)
}
</script>
<template>
<div
class="w-full px-6 py-2 lg:py-6 font-bold text-text-sub/40 flex flex-col xl:flex-row text-lg lg:text-2xl gap-2 xl:gap-12">
class="w-full px-6 pb-2 lg:pb-6 font-bold text-text-sub/40 flex flex-col xl:flex-row text-lg gap-2 xl:gap-12 lg:text-2xl">
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
<div> {{ onlineUsers }} Online users</div>
<div class="poppins font-medium text-[.9rem]"> {{ onlineUsers.data }} Online users</div>
</div>
<div class="grow"></div>
<div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row">
<div>Project:</div>
<div class="text-text/90"> {{ activeProject?.name || 'Loading...' }} </div>
<!-- <div 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>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-text/90 text-[.9rem] lg:text-2xl">
{{ 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 hover:text-text cursor-pointer text-[1.2rem]"></i>
<i @click="copyProjectId()"
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 class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
<div class="poppins font-regular text-[.9rem]"> AI Anomaly Detector </div>
<div class="flex items-center">
<i class="far fa-info-circle text-[.9rem] hover:text-lyx-primary cursor-pointer"
@click="showAnomalyInfoAlert"></i>
</div>
</div>
</div>
</template>

View File

@@ -2,29 +2,45 @@
import { onMounted } from 'vue';
import DateService, { type Slice } from '@services/DateService';
const data = ref<number[]>([]);
const labels = ref<string[]>([]);
const ready = ref<boolean>(false);
const props = defineProps<{ slice: Slice }>();
async function loadData() {
const response = await useTimeline('visits', props.slice);
if (!response) return;
data.value = response.map(e => e.count);
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
ready.value = true;
const activeProject = useActiveProject();
const { safeSnapshotDates } = useSnapshot()
function transformResponse(input: { _id: string, count: number }[]) {
const data = input.map(e => e.count);
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
return { data, labels }
}
const body = computed(() => {
return {
from: safeSnapshotDates.value.from,
to: safeSnapshotDates.value.to,
slice: props.slice
}
});
const visitsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
lazy: true, immediate: false
});
onMounted(async () => {
await loadData();
watch(props, async () => { await loadData(); });
})
visitsData.execute();
});
</script>
<template>
<div>
<AdvancedLineChart v-if="ready" :data="data" :labels="labels" color="#5655d7"></AdvancedLineChart>
<div v-if="visitsData.pending.value" class="flex justify-center py-40">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
<AdvancedLineChart v-if="!visitsData.pending.value" :data="visitsData.data.value?.data || []"
:labels="visitsData.data.value?.labels || []" color="#5655d7">
</AdvancedLineChart>
</div>
</template>

View File

@@ -1,61 +0,0 @@
<script lang="ts" setup>
import type { VisitsWebsiteAggregated } from '~/server/api/metrics/[project_id]/data/websites';
const { data: websites, pending, refresh } = useWebsitesData();
const currentViewData = ref<(VisitsWebsiteAggregated[] | null)>(websites.value);
watch(pending, () => {
currentViewData.value = websites.value;
})
const isPagesView = ref<boolean>(false);
const isLoading = ref<boolean>(false);
async function showDetails(website: string) {
if (isPagesView.value == true) return;
isLoading.value = true;
isPagesView.value = true;
const { data: pagesData, pending } = usePagesData(website, 10);
watch(pending, () => {
currentViewData.value = pagesData.value;
isLoading.value = false;
})
}
const router = useRouter();
function goToView() {
router.push('/dashboard/visits');
}
function setDefaultData() {
currentViewData.value = websites.value;
isPagesView.value = false;
}
async function dataReload() {
await refresh();
setDefaultData();
}
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<DashboardBarsCard :hideShowMore="true" @showGeneral="setDefaultData()" @showRawData="goToView()"
@dataReload="dataReload()" @showDetails="showDetails" :data="currentViewData || []"
:loading="pending || isLoading" :label="isPagesView ? 'Top pages' : 'Top Websites'"
:sub-label="isPagesView ? 'Page' : 'Website'"
:desc="isPagesView ? 'Most visited pages' : 'Most visited website in this project'"
:interactive="!isPagesView" :rawButton="!isLiveDemo()" :isDetailView="isPagesView">
</DashboardBarsCard>
</div>
</template>

View File

@@ -0,0 +1,113 @@
<script lang="ts" setup>
const { closeDialog } = useCustomDialog();
import { sub, format, isSameDay, type Duration } from 'date-fns'
const ranges = [
{ label: 'Last 7 days', duration: { days: 7 } },
{ label: 'Last 14 days', duration: { days: 14 } },
{ label: 'Last 30 days', duration: { days: 30 } },
{ label: 'Last 3 months', duration: { months: 3 } },
{ label: 'Last 6 months', duration: { months: 6 } },
{ label: 'Last year', duration: { years: 1 } }
]
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
function isRangeSelected(duration: Duration) {
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
}
function selectRange(duration: Duration) {
selected.value = { start: sub(new Date(), duration), end: new Date() }
}
const currentColor = ref<string>("#5680F8");
const colorpicker = ref<HTMLInputElement | null>(null);
function showColorPicker() {
colorpicker.value?.click();
}
function onColorChange() {
currentColor.value = colorpicker.value?.value || '#000000';
}
const snapshotName = ref<string>("");
const { updateSnapshots } = useSnapshot();
const { createAlert } = useAlert()
async function confirmSnapshot() {
await $fetch("/api/snapshot/create", {
method: 'POST',
...signHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
name: snapshotName.value,
color: currentColor.value,
from: selected.value.start.toISOString(),
to: selected.value.end.toISOString()
})
});
await updateSnapshots();
closeDialog();
createAlert('Snapshot created','Snapshot created successfully', 'far fa-circle-check', 5000);
}
</script>
<template>
<div class="w-full h-full flex flex-col">
<div class="poppins text-center">
Create a snapshot
</div>
<div class="mt-10 flex items-center gap-2">
<div :style="`background-color: ${currentColor};`" @click="showColorPicker"
class="w-6 h-6 rounded-full aspect-[1/1] relative cursor-pointer">
<input @input="onColorChange" ref="colorpicker" class="relative w-0 h-0 z-[-100]" type="color">
</div>
<div class="grow">
<LyxUiInput placeholder="Snapshot name" v-model="snapshotName" class="px-4 py-1 w-full"></LyxUiInput>
</div>
</div>
<div class="mt-4 justify-center flex w-full">
<UPopover class="w-full" :popper="{ placement: 'bottom' }">
<UButton class="w-full" color="primary" variant="solid">
<div class="flex items-center justify-center w-full gap-2">
<i class="i-heroicons-calendar-days-20-solid"></i>
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
</div>
</UButton>
<template #panel="{ close }">
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
<div class="hidden sm:flex flex-col py-4">
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
variant="ghost" class="rounded-none px-6"
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
truncate @click="selectRange(range.duration)" />
</div>
<DatePicker v-model="selected" @close="close" />
</div>
</template>
</UPopover>
</div>
<div class="grow"></div>
<div class="flex items-center justify-around gap-4">
<LyxUiButton @click="closeDialog()" type="secondary" class="w-full text-center">
Cancel
</LyxUiButton>
<LyxUiButton @click="confirmSnapshot()" type="primary" class="w-full text-center"
:disabled="snapshotName.length == 0">
Confirm
</LyxUiButton>
</div>
</div>
</template>

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js';
import { defineChartComponent } from 'vue-chart-3';
const FunnelChart = defineChartComponent('funnel', 'funnel');
const chartOptions = ref<ChartOptions<'funnel'>>({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'nearest',
axis: 'x',
includeInvisible: true
},
scales: {
y: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
// borderDash: [5, 10]
},
},
x: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
}
}
},
plugins: {
legend: { display: false },
title: { display: false },
tooltip: {
enabled: true,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleFont: { size: 16, weight: 'bold' },
bodyFont: { size: 14 },
padding: 10,
cornerRadius: 4,
boxPadding: 10,
caretPadding: 20,
yAlign: 'bottom',
xAlign: 'center',
}
},
});
const chartData = ref<ChartData<'funnel'>>({
labels: [],
datasets: [
{
data: [],
backgroundColor: [
'#5680F877',
"#6bbbe377",
"#a6d5cb77",
"#fae0b977",
"#f28e8e77",
"#e3a7e477",
"#c4a8e177",
"#8cc1d877",
"#f9c2cd77",
"#b4e3b277",
"#ffdfba77",
"#e9c3b577",
"#d5b8d677",
"#add7f677",
"#ffd1dc77",
"#ffe7a177",
"#a8e6cf77",
"#d4a5a577",
"#f3d6e477",
"#c3aed677"
],
// borderColor: '#0000CC',
// borderWidth: 4,
fill: true,
tension: 0.45,
pointRadius: 0,
pointHoverRadius: 10,
hoverBackgroundColor: '#26262677',
// hoverBorderColor: 'white',
// hoverBorderWidth: 2,
},
],
});
onMounted(async () => {
// const c = document.createElement('canvas');
// const ctx = c.getContext("2d");
// let gradient: any = `${'#0000CC'}22`;
// if (ctx) {
// gradient = ctx.createLinearGradient(0, 25, 0, 300);
// gradient.addColorStop(0, `${'#0000CC'}99`);
// gradient.addColorStop(0.35, `${'#0000CC'}66`);
// gradient.addColorStop(1, `${'#0000CC'}22`);
// } else {
// console.warn('Cannot get context for gradient');
// }
// chartData.value.datasets[0].backgroundColor = [gradient];
});
const eventsData = useFetch(`/api/data/events`, {
headers: useComputedHeaders(), lazy: true, immediate: false
});
const enabledEvents = ref<string[]>([]);
async function onEventCheck(eventName: string) {
const index = enabledEvents.value.indexOf(eventName);
if (index == -1) {
enabledEvents.value.push(eventName);
} else {
enabledEvents.value.splice(index, 1);
}
chartData.value.labels = enabledEvents.value;
chartData.value.datasets[0].data = [];
for (const enabledEvent of enabledEvents.value) {
const target = (eventsData.data.value ?? []).find(e => e._id == enabledEvent);
chartData.value.datasets[0].data.push(target?.count || 0);
}
}
</script>
<template>
<CardTitled title="Funnel"
sub="Monitor and analyze the actions your users are performing on your platform to gain insights into their behavior and optimize the user experience">
<div class="flex gap-2 justify-between">
<div class="flex flex-col gap-1">
<div class="min-w-[20rem] text-lyx-text-darker">
Select two or more events
</div>
<div class="flex flex-col gap-1">
<div v-for="event of eventsData.data.value">
<UCheckbox color="secondary" @change="onEventCheck(event._id)"
:value="enabledEvents.includes(event._id)" :label="event._id">
</UCheckbox>
</div>
</div>
</div>
<div class="grow">
<FunnelChart :chart-data="chartData" :options="chartOptions"> </FunnelChart>
</div>
</div>
</CardTitled>
</template>

View File

@@ -1,16 +1,15 @@
<script lang="ts" setup>
const activeProject = useActiveProject();
const eventNames = ref<string[]>([]);
const eventNames = await useFetch<string[]>(`/api/data/events_data/names`, {
headers: useComputedHeaders()
});
const selectedEventName = ref<string>();
const metadataFields = ref<string[]>([]);
const selectedMetadataField = ref<string>();
const metadataFieldGrouped = ref<any[]>([]);
onMounted(async () => {
eventNames.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/names`, signHeaders());
});
watch(selectedEventName, () => {
getMetadataFields();
@@ -21,16 +20,35 @@ watch(selectedMetadataField, () => {
});
async function getMetadataFields() {
metadataFields.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/metadata_fields?name=${selectedEventName.value}`, signHeaders());
metadataFields.value = await $fetch<string[]>(`/api/data/events_data/metadata_fields?name=${selectedEventName.value}`, {
headers: useComputedHeaders().value
});
selectedMetadataField.value = undefined;
currentSearchText.value = "";
}
const { safeSnapshotDates } = useSnapshot();
async function getMetadataFieldGrouped() {
if (!selectedMetadataField.value) return;
metadataFieldGrouped.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/metadata_field_group?name=${selectedEventName.value}&field=${selectedMetadataField.value}`, signHeaders());
const queryParams: Record<string, any> = {
from: safeSnapshotDates.value.from,
to: safeSnapshotDates.value.to,
name: selectedEventName.value,
field: selectedMetadataField.value
}
const queryParamsString = Object.keys(queryParams).map((key) => `${key}=${queryParams[key]}`).join('&');
metadataFieldGrouped.value = await $fetch<string[]>(`/api/data/events_data/metadata_field_group?${queryParamsString}`, {
headers: useComputedHeaders().value
});
}
const metadataFieldGroupedFiltered = computed(() => {
if (currentSearchText.value.length == 0) return metadataFieldGrouped.value;
@@ -56,11 +74,80 @@ const canSearch = computed(() => {
<CardTitled title="Event metadata analyzer" sub="Filter events metadata fields to analyze them" class="w-full p-4">
<div class="p-2 flex flex-col">
<div class="">
<LyxUiCard class="h-full w-full flex gap-2">
<div class="flex-[2]">
<div class="flex flex-col gap-2">
<USelectMenu :uiMenu="{
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
base: '!bg-lyx-widget',
option: {
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-widget-lighter'
}
}" searchable searchable-placeholder="Search an event..." class="w-full"
placeholder="Select an event" :options="eventNames.data.value || []"
v-model="selectedEventName">
</USelectMenu>
<USelectMenu :uiMenu="{
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
base: '!bg-lyx-widget',
option: {
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-widget-lighter'
}
}" searchable searchable-placeholder="Search a field..." class="w-full"
placeholder="Select a field" :options="metadataFields" v-model="selectedMetadataField">
</USelectMenu>
</div>
<div class="text-lyx-text-darker poppins mt-4 flex items-center gap-4">
<div class="w-[10rem]">
Search results: {{ metadataFieldGroupedFiltered.length }}
</div>
<div v-if="canSearch" class="h-full flex items-center text-[1.2rem]">
<div class="bg-lyx-widget-light flex items-center rounded-md pl-4">
<div><i class="far fa-search"></i></div>
<input class="bg-transparent px-4 py-2 text-[1rem] outline-none" type="text"
placeholder="Filter by metadata name" v-model="currentSearchText">
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 mt-4">
<div class="bg-lyx-widget-light text-lyx-text-dark px-3 py-2 rounded-md w-fit"
v-for="item of metadataFieldGroupedFiltered">
<div class="flex gap-2">
<div> {{ item._id || 'OLD_EVENTS' }} </div>
<div> {{ item.count }} </div>
</div>
</div>
</div>
</div>
<!-- <div class="border-solid border-[#212121] border-l-[1px]"></div> -->
<!-- <div class="flex-[1]">
<div class="poppins font-semibold"> </div>
</div> -->
</LyxUiCard>
</div>
<!-- <div class="p-2 flex flex-col">
<div class="flex flex-col gap-2">
<USelectMenu searchable searchable-placeholder="Search an event..." class="w-full"
placeholder="Select an event" :options="eventNames" v-model="selectedEventName">
placeholder="Select an event" :options="eventNames.data.value || []" v-model="selectedEventName">
</USelectMenu>
<USelectMenu v-if="metadataFields.length > 0" searchable searchable-placeholder="Search a field..."
@@ -85,17 +172,11 @@ const canSearch = computed(() => {
Search results: {{ metadataFieldGroupedFiltered.length }}
</div>
<div class="flex flex-col">
<div v-for="item of metadataFieldGroupedFiltered">
<div class="flex gap-2">
<div> {{ item._id || 'OLD_EVENTS' }} </div>
<div> {{ item.count }} </div>
</div>
</div>
</div>
</div>
</div>
</div> -->
</CardTitled>

View File

@@ -1,20 +1,48 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
const datasets = ref<any[]>([]);
const labels = ref<string[]>([]);
const ready = ref<boolean>(false);
import DateService, { type Slice } from '@services/DateService';
const props = defineProps<{ slice: SliceName }>();
const props = defineProps<{ slice: Slice }>();
const slice = computed(() => props.slice);
async function loadData() {
const response = await useTimelineDataRaw('events_stacked', props.slice);
if (!response) return;
const { safeSnapshotDates } = useSnapshot()
const fixed = fixMetrics(response, props.slice, { advanced: true, advancedGroupKey: 'name' });
function transformResponse(input: { _id: string, name: string, count: number }[]) {
const fixed = fixMetrics({
data: input,
from: input[0]._id,
to: safeSnapshotDates.value.to
}, slice.value, {
advanced: true,
advancedGroupKey: 'name'
});
const parsedDatasets: any[] = [];
const colors = ['#5655d0', '#6bbbe3', '#a6d5cb', '#fae0b9'];
const colors = [
"#5655d0",
"#6bbbe3",
"#a6d5cb",
"#fae0b9",
"#f28e8e",
"#e3a7e4",
"#c4a8e1",
"#8cc1d8",
"#f9c2cd",
"#b4e3b2",
"#ffdfba",
"#e9c3b5",
"#d5b8d6",
"#add7f6",
"#ffd1dc",
"#ffe7a1",
"#a8e6cf",
"#d4a5a5",
"#f3d6e4",
"#c3aed6"
];
for (let i = 0; i < fixed.allKeys.length; i++) {
const line: any = {
@@ -28,25 +56,57 @@ async function loadData() {
if (!target) return;
line.data.push(target.value);
});
}
datasets.value = parsedDatasets;
labels.value = fixed.labels;
ready.value = true;
return {
datasets: parsedDatasets,
labels: fixed.labels
}
}
const errorData = ref<{ errored: boolean, text: string }>({
errored: false,
text: ''
})
function onResponseError(e: any) {
console.log('ON RESPONSE ERROR')
errorData.value = { errored: true, text: e.response._data.message ?? 'Generic error' }
}
function onResponse(e: any) {
console.log('ON RESPONSE')
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
}
const eventsStackedData = useFetch(`/api/timeline/events_stacked`, {
lazy: true, immediate: false,
transform: transformResponse,
headers: useComputedHeaders({slice}),
onResponseError,
onResponse
});
onMounted(async () => {
await loadData();
watch(props, async () => { await loadData(); });
})
eventsStackedData.execute();
});
</script>
<template>
<div>
<AdvancedStackedBarChart v-if="ready" :datasets="datasets" :labels="labels">
<div class="h-full">
<div v-if="eventsStackedData.pending.value" class="flex justify-center py-40">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
<AdvancedStackedBarChart v-if="!eventsStackedData.pending.value && !errorData.errored"
:datasets="eventsStackedData.data.value?.datasets || []"
:labels="eventsStackedData.data.value?.labels || []">
</AdvancedStackedBarChart>
<div v-if="errorData.errored" class="flex items-center justify-center py-8 h-full">
{{ errorData.text }}
</div>
</div>
</template>

View File

@@ -1,57 +1,83 @@
<script lang="ts" setup>
const activeProject = useActiveProject();
const eventNames = await useFetch<string[]>(`/api/data/events_data/names`, {
headers: useComputedHeaders()
});
const eventNames = ref<string[]>([]);
const selectedEventName = ref<string>();
onMounted(async () => {
eventNames.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/names`, signHeaders());
});
const userFlowData = ref<any>();
const analyzing = ref<boolean>(false);
async function analyzeEvent() {
const { safeSnapshotDates } = useSnapshot();
async function getUserFlowData() {
userFlowData.value = undefined;
analyzing.value = true;
userFlowData.value = await $fetch(`/api/metrics/${activeProject.value?._id.toString()}/events/flow_from_name?name=${selectedEventName.value}`, signHeaders());
const queryParams: Record<string, any> = {
from: safeSnapshotDates.value.from,
to: safeSnapshotDates.value.to,
name: selectedEventName.value
}
const queryParamsString = Object.keys(queryParams).map((key) => `${key}=${queryParams[key]}`).join('&');
userFlowData.value = await $fetch(`/api/data/events_data/flow_from_name?${queryParamsString}`, {
headers: useComputedHeaders().value
});
analyzing.value = false;
}
async function analyzeEvent() {
getUserFlowData();
}
</script>
<template>
<CardTitled title="Event User Flow"
sub="Track your user's journey from external links to custom events within your platform." class="w-full p-4">
sub="Track your user's journey from external links to in-app events, maintaining a complete view of their path from entry to engagement."
class="w-full p-4">
<div class="p-2 flex flex-col gap-3">
<USelectMenu searchable searchable-placeholder="Search an event..." class="w-full"
placeholder="Select an event" :options="eventNames" v-model="selectedEventName">
<div class="flex flex-col gap-4">
<div class="py-2 flex items-center gap-3">
<USelectMenu :uiMenu="{
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
base: '!bg-lyx-widget',
option: {
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-widget-lighter'
}
}" searchable searchable-placeholder="Search an event..." class="w-full" placeholder="Select an event"
:options="eventNames.data.value || []" v-model="selectedEventName">
</USelectMenu>
<div v-if="selectedEventName && !analyzing" class="flex justify-center">
<div @click="analyzeEvent()"
class="bg-bg w-fit px-8 py-2 poppins rounded-lg hover:bg-bg/80 cursor-pointer">
<LyxUiButton @click="analyzeEvent()" type="primary" class="w-fit px-8 py-1">
Analyze
</LyxUiButton>
</div>
</div>
<div v-if="analyzing">
Analyzing...
</div>
<div v-if="analyzing"> Analyzing... </div>
<div class="flex flex-col gap-2" v-if="userFlowData">
<div class="flex gap-4 items-center bg-bg py-1 px-2 rounded-lg" v-for="(count, referrer) in userFlowData">
<div class="flex gap-4 items-center bg-bg py-2 px-2 bg-lyx-widget-light rounded-lg"
v-for="(count, referrer) in userFlowData">
<div class="w-5 h-5 flex items-center justify-center">
<img :src="`https://s2.googleusercontent.com/s2/favicons?domain=${referrer}&sz=64`" :alt="'referrer'">
<img :src="`https://s2.googleusercontent.com/s2/favicons?domain=${referrer}&sz=64`"
:alt="'referrer'">
</div>
<div> {{ referrer }} </div>
<div class="grow"></div>
<div> {{ count }} </div>
<div> {{ count.toFixed(2).replace('.', ',') }} % </div>
</div>
</div>
</div>
</div>
</CardTitled>
</template>

View File

@@ -0,0 +1,157 @@
<script lang="ts" setup>
import { sub, isSameDay, type Duration } from 'date-fns'
type ChartType = 'bar' | 'line';
const chartTypeOptions: { value: ChartType, label: string }[] = [
{ value: 'bar', label: 'Bar chart' },
{ value: 'line', label: 'Line chart' },
]
type yAxisMode = 'count';
const yAxisModeOptions: { value: yAxisMode, label: string }[] = [
{ value: 'count', label: 'Count fields' },
]
type Slice = 'day' | 'month';
const sliceOptions: Slice[] = ['day', 'month'];
const chartType = ref<ChartType>('line');
const tableName = ref<string>('');
const xAxis = ref<string>('');
const yAxisMode = ref<yAxisMode>('count');
const slice = ref<Slice>('day');
const visualizationName = ref<string>('');
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 timeframe = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
function isRangeSelected(duration: Duration) {
return isSameDay(timeframe.value.start, sub(new Date(), duration)) && isSameDay(timeframe.value.end, new Date())
}
function selectRange(duration: Duration) {
timeframe.value = { start: sub(new Date(), duration), end: new Date() }
}
const { createAlert } = useAlert();
const { closeDialog } = useCustomDialog();
const activeProjectId = useActiveProjectId();
const { integrationsCredentials,testConnection } = useSupabase();
async function generate() {
const credentials = integrationsCredentials.data.value;
if (!credentials?.supabase) return createAlert('Credentials not found', 'Please add supabase credentials on the integration page', 'far fa-error', 5000);
const connectionStatus = await testConnection();
if (!connectionStatus) return createAlert('Invalid supabase credentials', 'Please check your supabase credentials on the integration page', 'far fa-error', 5000);
try {
const creation = await $fetch('/api/integrations/supabase/add', {
...signHeaders({
'x-pid': activeProjectId.data.value || '',
'Content-Type': 'application/json'
}),
method: 'POST',
body: JSON.stringify({
name: visualizationName.value,
chart_type: chartType.value,
table_name: tableName.value,
xField: xAxis.value,
yMode: yAxisMode.value,
from: timeframe.value.start,
to: timeframe.value.end,
slice: slice.value
})
})
createAlert('Integration generated', 'Integration generated successfully', 'far fa-check-circle', 5000);
closeDialog();
} catch (ex: any) {
createAlert('Error generating integrations', ex.response._data.message.toString(), 'far fa-error', 5000);
}
}
</script>
<template>
<div class="flex flex-col gap-4">
<div>
<div> Visualization name </div>
<div>
<LyxUiInput class="w-full px-2 py-1" v-model="visualizationName"></LyxUiInput>
</div>
</div>
<div>
<div> Chart type </div>
<USelect v-model="chartType" :options="chartTypeOptions" />
</div>
<div>
<div> Table name </div>
<div>
<LyxUiInput class="w-full px-2 py-1" v-model="tableName"></LyxUiInput>
</div>
</div>
<div>
<div> X axis field </div>
<div>
<LyxUiInput class="w-full px-2 py-1" v-model="xAxis"></LyxUiInput>
</div>
</div>
<div>
<div> Y axis mode </div>
<div>
<USelect v-model="yAxisMode" :options="yAxisModeOptions" />
</div>
</div>
<div>
<div> Timeframe </div>
<div>
<UPopover class="w-full" :popper="{ placement: 'bottom' }">
<UButton class="w-full" color="primary" variant="solid">
<div class="flex items-center justify-center w-full gap-2">
<i class="i-heroicons-calendar-days-20-solid"></i>
{{ timeframe.start.toLocaleDateString() }} - {{ timeframe.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="timeframe" @close="close" />
</div>
</template>
</UPopover>
</div>
</div>
<div>
<div> View mode </div>
<div>
<USelect v-model="slice" :options="sliceOptions" />
</div>
</div>
<LyxUiButton type="primary" @click="generate()">
Generate
</LyxUiButton>
</div>
</template>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import type { TSupabaseIntegration } from '@schema/integrations/SupabaseIntegrationSchema';
import type { ChartData, ChartOptions } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
const props = defineProps<{ integration_id: string }>();
const activeProjectId = useActiveProjectId();
const supabaseData = ref<{ labels: string[], data: number[] }>();
const supabaseError = ref<string | undefined>(undefined);
const supabaseFetching = ref<boolean>(false);
const { getRemoteData } = useSupabase();
function createGradient() {
const c = document.createElement('canvas');
const ctx = c.getContext("2d");
let gradient: any = `#34B67C22`;
if (ctx) {
gradient = ctx.createLinearGradient(0, 25, 0, 300);
gradient.addColorStop(0, `#34B67C99`);
gradient.addColorStop(0.35, `#34B67C66`);
gradient.addColorStop(1, `#34B67C22`);
} else {
console.warn('Cannot get context for gradient');
}
chartData.value.datasets[0].backgroundColor = [gradient];
}
const chartOptions = ref<ChartOptions<'line'>>({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'nearest',
axis: 'x',
includeInvisible: true
},
scales: {
y: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
// borderDash: [5, 10]
},
},
x: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
}
}
},
plugins: {
legend: { display: false },
title: { display: false },
tooltip: {
enabled: true,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleFont: { size: 16, weight: 'bold' },
bodyFont: { size: 14 },
padding: 10,
cornerRadius: 4,
boxPadding: 10,
caretPadding: 20,
yAlign: 'bottom',
xAlign: 'center',
}
},
});
const chartData = ref<ChartData<'line'>>({
labels: [],
datasets: [
{
data: [],
backgroundColor: ['#34B67C' + '77'],
borderColor: '#34B67C',
borderWidth: 4,
fill: true,
tension: 0.45,
pointRadius: 0,
pointHoverRadius: 10,
hoverBackgroundColor: '#34B67C',
hoverBorderColor: 'white',
hoverBorderWidth: 2,
},
],
});
onMounted(async () => {
supabaseFetching.value = true;
supabaseError.value = undefined;
const integrationData = await $fetch<TSupabaseIntegration>('/api/integrations/supabase/get', {
...signHeaders({
'x-pid': activeProjectId.data.value || '',
'x-integration': props.integration_id
})
});
if (!integrationData) {
supabaseError.value = 'Cannot get integration data';
supabaseFetching.value = false;
return;
}
try {
const data = await getRemoteData(
integrationData.table_name,
integrationData.xField,
integrationData.yMode,
integrationData.from.toString(),
integrationData.to.toString(),
integrationData.slice,
);
if (data.error) {
supabaseError.value = data.error;
supabaseFetching.value = false;
return;
}
supabaseFetching.value = false;
supabaseData.value = data.result;
chartData.value.labels = data.result?.labels || [];
chartData.value.datasets[0].data = data.result?.data || [];
console.log(data.result);
createGradient();
} catch (ex: any) {
if (!ex.response._data) {
supabaseError.value = ex.message.toString();
supabaseFetching.value = false;
} else {
supabaseError.value = ex.response._data.message.toString();
supabaseFetching.value = false;
}
}
});
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
</script>
<template>
<div v-if="!supabaseFetching">
<div v-if="!supabaseError">
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
</div>
<div v-if="supabaseError"> {{ supabaseError }} </div>
</div>
<div v-if="supabaseFetching">
Getting remote data...
</div>
</template>

View File

@@ -1,82 +0,0 @@
<script lang="ts" setup>
export type PricingCardProp = {
title: string,
cost: string,
features: string[],
desc: string,
active: boolean,
planId: number,
isDowngrade: boolean
}
const props = defineProps<{ data: PricingCardProp }>();
const activeProject = useActiveProject();
async function onUpgradeClick() {
const res = await $fetch<string>(`/api/pay/${activeProject.value?._id.toString()}/create`, {
...signHeaders({ 'content-type': 'application/json' }),
method: 'POST',
body: JSON.stringify({ planId: props.data.planId })
})
if (!res) alert('Something went wrong');
window.open(res);
}
</script>
<template>
<div class="p-6 bg-[#303030] rounded-xl pricing-card flex flex-col">
<div class="flex flex-col">
<div class="text-[1.1rem] font-semibold mb-4">
{{ data.title }}
</div>
<div class="flex gap-1 items-end mb-2">
<div class="text-[1.1rem] font-semibold">
{{ data.cost }}
</div>
<div class="text-text-sub text-[.9rem] mb-[.15rem]">
per month
</div>
</div>
<div v-if="data.active" class="text-[1rem] bg-[#1f1f22] rounded-md py-2 text-center">
Current active plan
</div>
<div @click="onUpgradeClick()" v-if="!data.active && !data.isDowngrade"
class="cursor-pointer text-[1rem] font-semibold bg-[#3a3af5] rounded-md py-2 text-center">
Upgrade
</div>
<div @click="onUpgradeClick()" v-if="!data.active && data.isDowngrade"
class="cursor-pointer text-[1rem] font-semibold bg-[#1f1f22] text-red-400 rounded-md py-2 text-center">
Downgrade
</div>
</div>
<div class="bg-gray-400 h-[1px] w-full my-4"></div>
<div class="flex flex-col gap-1 grow">
<div class="flex gap-2 items-center" v-for="feature of data.features">
<i class="fas fa-check"></i>
<div>
{{ feature }}
</div>
</div>
</div>
<div class="bg-gray-400 h-[1px] w-full my-4"></div>
<div class="text-text-sub text-[.9rem] h-[20%]">
{{ data.desc }}
</div>
</div>
</template>
<style scoped lang="scss">
.pricing-card * {
font-family: "Poppins";
}
</style>

View File

@@ -13,19 +13,24 @@ export type PricingCardProp = {
planId: number
}
const props = defineProps<{ datas: PricingCardProp[] }>();
const props = defineProps<{ datas: PricingCardProp[], defaultIndex?: number }>();
const activeProject = useActiveProject();
const { project } = useProject();
const currentIndex = ref<number>(0);
const currentIndex = ref<number>(props.defaultIndex || 0);
const data = computed(() => {
return props.datas[currentIndex.value];
})
async function onUpgradeClick() {
const res = await $fetch<string>(`/api/pay/${activeProject.value?._id.toString()}/create`, {
...signHeaders({ 'content-type': 'application/json' }),
const res = await $fetch<string>(`/api/pay/create`, {
headers: useComputedHeaders({
useSnapshotDates: false,
custom: {
'content-type': 'application/json'
}
}).value,
method: 'POST',
body: JSON.stringify({ planId: data.value.planId })
})
@@ -37,13 +42,19 @@ async function onUpgradeClick() {
<template>
<div class="relative bg-[#151515] outline outline-[1px] outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]">
<div
class="relative bg-[#151515] outline outline-[1px] outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]">
<div class="flex flex-col gap-3 text-center">
<div class="poppins text-xl font-light"> {{ data.title }} </div>
<div v-if="data.active" class="absolute right-6 top-3 poppins text-[.75rem] bg-[#222A42] outline outline-[1px] outline-[#5680F8] px-3 py-[.1rem] rounded-xl">
<div class="flex flex-col gap-3 text-center pt-3">
<div v-if="data.active"
class="absolute right-6 top-3 poppins text-[.75rem] bg-[#222A42] outline outline-[1px] outline-[#5680F8] px-3 py-[.1rem] rounded-sm">
Active
</div>
<div v-if="!data.active && data.title === 'Growth'"
class="absolute right-6 top-3 poppins text-[.75rem] bg-[#fbbe244f] outline outline-[1px] outline-[#fbbf24] px-3 py-[.1rem] rounded-sm">
Most popular
</div>
<div class="poppins text-xl font-light"> {{ data.title }} </div>
<div class="poppins text-4xl font-medium"> {{ data.price }} </div>
</div>
@@ -61,7 +72,10 @@ async function onUpgradeClick() {
}" :min="0" :max="datas.length - 1" v-model="currentIndex">
</URange>
</div>
<div class="poppins" v-for="sub of data.subs"> {{ sub }} </div>
<div :class="{ '!text-[.8rem] !text-lyx-text-darker': sub.includes('€') }" class="poppins text-[.9rem]"
v-for="sub of data.subs">
{{ sub }}
</div>
</div>
<div class="sep bg-[#262626] h-[1px] my-8"></div>
@@ -69,27 +83,34 @@ async function onUpgradeClick() {
<div class="flex flex-col gap-2">
<div class="flex gap-2" v-for="feature of data.features">
<div class="h-6 w-6">
<img class="w-full h-full" :src="'check.png'" alt="Check">
<img class="w-full h-full" :src="'/check.png'" alt="Check">
</div>
<div>{{ feature }}</div>
</div>
</div>
<div class="mt-10 flex">
<div class="w-full" v-if="data.planId > -1">
<div @click="onUpgradeClick()" v-if="!data.active && !data.isDowngrade"
class="cursor-pointer text-[1rem] font-semibold bg-[#3a3af5] rounded-md py-2 text-center">
<div class="w-full flex" v-if="data.planId > -1">
<LyxUiButton class="rounded-md py-2 w-full text-center" type="primary" @click="onUpgradeClick()"
v-if="!data.active && !data.isDowngrade">
Upgrade
</div>
<div @click="onUpgradeClick()" v-if="!data.active && data.isDowngrade"
class="w-full cursor-pointer text-[1rem] font-semibold bg-[#1f1f22] text-red-400 rounded-md py-2 text-center">
</LyxUiButton>
<LyxUiButton class="rounded-md py-2 w-full text-center" type="danger" @click="onUpgradeClick()"
v-if="!data.active && data.isDowngrade">
Downgrade
</LyxUiButton>
</div>
</div>
<NuxtLink v-if="data.planId === -1" :to="data.link || 'https://dashboard.litlyx.com'"
class="bg-[#222A42] cursor-pointer outline outline-[1px] outline-[#5680F8] w-full !rounded-md text-center text-[.9rem] !py-2 ">
<LyxUiButton v-if="data.planId === -1" :to="data.link || 'https://dashboard.litlyx.com'"
class="rounded-md py-2 w-full text-center" type="primary">
{{ data.cta }}
</NuxtLink>
</LyxUiButton>
</div>
</div>

View File

@@ -2,9 +2,12 @@
import type { PricingCardProp } from './PricingCardGeneric.vue';
const activeProject = useActiveProject();
const props = defineProps<{ currentSub: number }>();
const { data: planData, refresh: refreshPlanData } = useFetch('/api/project/plan', {
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
});
function getPricingsData() {
const freePricing: PricingCardProp[] = [
{
@@ -12,7 +15,7 @@ const freePricing: PricingCardProp[] = [
price: '€0 / mo',
subs: [
'Up to 5000 visits/events per month',
'CPM 0€ per visit/event'
],
features: [
'Email support',
@@ -20,12 +23,11 @@ const freePricing: PricingCardProp[] = [
'Unlimited reports',
'AI Tokens: 10',
'Server type: SHARED',
'Projects: max 2',
'Data retention: 2 Months'
],
cta: 'Start For Free now!',
active: props.currentSub == 0,
isDowngrade: props.currentSub > 0,
active: (planData.value?.premium_type || 0) == 0,
isDowngrade: (planData.value?.premium_type || 0) > 0,
planId: 0
},
]
@@ -44,7 +46,6 @@ const customPricing: PricingCardProp[] = [
'DB instance: DEDICATED',
'Dedicated operator',
'White label',
'Custom Charts',
'Custom Data Aggregation'
],
cta: 'Let\'s Talk!',
@@ -61,20 +62,19 @@ const slidePricings: PricingCardProp[] = [
price: '€4,99 / mo',
subs: [
'Up to 50.000 visits/events per month',
'CPM 0,10€ per visit/event'
'0,00010€ per visit/event'
],
features: [
'Discord support',
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 30',
'Server type: SHARED',
'Projects: max 3',
'Data retention: 6 Months'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 101,
isDowngrade: props.currentSub > 101,
active: (planData.value?.premium_type || 0) == 101,
isDowngrade: (planData.value?.premium_type || 0) > 101,
planId: 101
},
{
@@ -82,20 +82,19 @@ const slidePricings: PricingCardProp[] = [
price: '€9,99 / mo',
subs: [
'Up to 150.000 visits/events per month',
'CPM 0,06€ per visit/event'
'0,00006€ per visit/event'
],
features: [
'Discord support',
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 100',
'Server type: SHARED',
'Projects: max 3',
'Data retention: 9 Months'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 102,
isDowngrade: props.currentSub > 102,
active: (planData.value?.premium_type || 0) == 102,
isDowngrade: (planData.value?.premium_type || 0) > 102,
planId: 102
},
{
@@ -103,20 +102,19 @@ const slidePricings: PricingCardProp[] = [
price: '€29,99 / mo',
subs: [
'Up to 500.000 visits/events per month',
'CPM 0,059€ per visit/event'
'0,000059€ per visit/event'
],
features: [
'Discord support',
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 3.000',
'Server type: SHARED',
'Projects: max 3',
'Data retention: 1 Year'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 103,
isDowngrade: props.currentSub > 103,
active: (planData.value?.premium_type || 0) == 103,
isDowngrade: (planData.value?.premium_type || 0) > 103,
planId: 103
},
{
@@ -124,20 +122,19 @@ const slidePricings: PricingCardProp[] = [
price: '€59,99 / mo',
subs: [
'Up to 1.000.000 visits/events per month',
'CPM 0,059€ per visit/event'
'0,000059€ per visit/event'
],
features: [
'Discord support',
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 5.000',
'Server type: SHARED',
'Projects: max 3',
'Data retention: 1 Year'
'Data retention: 3 Year'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 104,
isDowngrade: props.currentSub > 104,
active: (planData.value?.premium_type || 0) == 104,
isDowngrade: (planData.value?.premium_type || 0) > 104,
planId: 104
},
{
@@ -145,20 +142,19 @@ const slidePricings: PricingCardProp[] = [
price: '€99,99 / mo',
subs: [
'Up to 2.500.000 visits/events per month',
'CPM 0,039€ per visit/event'
'0,000039€ per visit/event'
],
features: [
'Discord support',
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 10.000',
'Server type: DEDICATED',
'Projects: max 3',
'Data retention: 2 Years'
'Data retention: 7 Years'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 105,
isDowngrade: props.currentSub > 105,
active: (planData.value?.premium_type || 0) == 105,
isDowngrade: (planData.value?.premium_type || 0) > 105,
planId: 105
},
{
@@ -166,33 +162,49 @@ const slidePricings: PricingCardProp[] = [
price: '€149,99 / mo',
subs: [
'Up to 5.000.000 visits/events per month',
'CPM 0,029€ per visit/event'
'0,000029€ per visit/event'
],
features: [
'Discord support',
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 20.000',
'Server type: DEDICATED',
'Projects: max 3',
'Data retention: 3 Years'
'Data retention: 8 Years'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 106,
isDowngrade: props.currentSub > 106,
active: (planData.value?.premium_type || 0) == 106,
isDowngrade: (planData.value?.premium_type || 0) > 106,
planId: 106
}
]
return { freePricing, customPricing, slidePricings }
}
const { projectId } = useProject();
const emits = defineEmits<{
(evt: 'onCloseClick'): void
}>();
async function onLifetimeUpgradeClick() {
const res = await $fetch<string>(`/api/pay/create-onetime`, {
...signHeaders({
'content-type': 'application/json',
'x-pid': projectId.value ?? ''
}),
method: 'POST',
body: JSON.stringify({ planId: 2001 })
})
if (!res) alert('Something went wrong');
window.open(res);
}
</script>
<template>
<div class="p-8 overflow-y-auto xl:overflow-y-hidden">
<div class="p-8 overflow-y-auto">
<div @click="$emit('onCloseClick')"
class="cursor-pointer fixed top-4 right-4 rounded-full bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
@@ -200,11 +212,58 @@ const emits = defineEmits<{
</div>
<div class="flex gap-8 mt-10 h-max xl:flex-row flex-col">
<PricingCardGeneric class="flex-1" :datas="freePricing"></PricingCardGeneric>
<PricingCardGeneric class="flex-1" :datas="slidePricings"></PricingCardGeneric>
<PricingCardGeneric class="flex-1" :datas="customPricing"></PricingCardGeneric>
<PricingCardGeneric class="flex-1" :datas="getPricingsData().freePricing"></PricingCardGeneric>
<PricingCardGeneric class="flex-1" :datas="getPricingsData().slidePricings" :default-index="2">
</PricingCardGeneric>
<PricingCardGeneric class="flex-1" :datas="getPricingsData().customPricing"></PricingCardGeneric>
</div>
<!-- <LyxUiCard class="w-full mt-6">
<div class="flex">
<div class="flex flex-col gap-3">
<div>
<span class="text-lyx-primary font-semibold text-[1.4rem]">
LIFETIME DEAL
</span>
<span class="text-lyx-text-dark text-[.8rem]"> (Growth plan) </span>
</div>
<div class="text-[2rem]"> 2.399,00 </div>
<div> Up to 500.000 visits/events per month </div>
<LyxUiButton type="primary" @click="onLifetimeUpgradeClick()"> Purchase </LyxUiButton>
</div>
<div class="flex justify-evenly grow">
<div class="flex flex-col justify-evenly">
<div class="flex items-center gap-2">
<img class="h-6" :src="'/check.png'" alt="Check">
<div> Slack support </div>
</div>
<div class="flex items-center gap-2">
<img class="h-6" :src="'/check.png'" alt="Check">
<div> Unlimited domanis </div>
</div>
<div class="flex items-center gap-2">
<img class="h-6" :src="'/check.png'" alt="Check">
<div> Unlimited reports </div>
</div>
</div>
<div class="flex flex-col justify-evenly">
<div class="flex items-center gap-2">
<img class="h-6" :src="'/check.png'" alt="Check">
<div> AI Tokens: 3.000 / month </div>
</div>
<div class="flex items-center gap-2">
<img class="h-6" :src="'/check.png'" alt="Check">
<div> Server type: SHARED </div>
</div>
<div class="flex items-center gap-2">
<img class="h-6" :src="'/check.png'" alt="Check">
<div> Data retention: 5 Years </div>
</div>
</div>
</div>
</div>
</LyxUiCard> -->
<div class="flex justify-between items-center mt-10 flex-col xl:flex-row">
<div class="flex flex-col gap-2">
<div class="poppins text-[2rem] font-semibold">
@@ -215,12 +274,15 @@ const emits = defineEmits<{
</div>
</div>
<div class="mt-2">
<div class="rounded-lg px-10 py-3 bg-[#303030]">
<div class="rounded-lg px-10 py-3 bg-[#151515]">
<a href="mailto:help@litlyx.com" class="poppins text-[1.3rem]">
help@litlyx.com
</a>
</div>
</div>
</div>
</div>
</template>

View File

@@ -2,30 +2,30 @@
import { onMounted } from 'vue';
import DateService, { type Slice } from '@services/DateService';
const data = ref<number[]>([]);
const labels = ref<string[]>([]);
const ready = ref<boolean>(false);
// const data = ref<number[]>([]);
// const labels = ref<string[]>([]);
// const ready = ref<boolean>(false);
const props = defineProps<{ slice: Slice, referrer: string }>();
// const props = defineProps<{ slice: Slice, referrer: string }>();
async function loadData() {
const response = await useReferrersTimeline(props.referrer, props.slice);
if (!response) return;
data.value = response.map(e => e.count);
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
ready.value = true;
}
// async function loadData() {
// const response = await useReferrersTimeline(props.referrer, props.slice);
// if (!response) return;
// data.value = response.map(e => e.count);
// labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
// ready.value = true;
// }
onMounted(async () => {
await loadData();
watch(props, async () => { await loadData(); });
})
// onMounted(async () => {
// await loadData();
// watch(props, async () => { await loadData(); });
// })
</script>
<template>
<div>
<AdvancedBarChart v-if="ready" :data="data" :labels="labels" color="#5680f8">
</AdvancedBarChart>
<!-- <AdvancedBarChart v-if="ready" :data="data" :labels="labels" color="#5680f8">
</AdvancedBarChart> -->
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
import type { SettingsTemplateEntry } from './Template.vue';
const entries: SettingsTemplateEntry[] = [
{ id: 'delete', title: 'Delete account', text: 'Delete your account' },
]
const { setToken } = useAccessToken();
async function deleteAccount() {
const sure = confirm("Are you sure you want to delete this account ?");
if (!sure) return;
await $fetch("/api/user/delete_account", {
...signHeaders(),
method: "DELETE"
})
setToken('');
location.href = "/login"
}
</script>
<template>
<SettingsTemplate :entries="entries">
<template #delete>
<div
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-[#1e1412]">
<div class="poppins font-semibold"> Deleting this account will also remove its projects </div>
<div @click="deleteAccount()"
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-[#291415]">
Delete account
</div>
</div>
</template>
</SettingsTemplate>
</template>

View File

@@ -0,0 +1,212 @@
<script lang="ts" setup>
import type { TApiSettings } from '@schema/ApiSettingsSchema';
import type { SettingsTemplateEntry } from './Template.vue';
const { project, actions, projectList, isGuest, projectId } = useProject();
const entries: SettingsTemplateEntry[] = [
{ id: 'pname', title: 'Name', text: 'Project name' },
{ id: 'api', title: 'ApiKeys', text: 'Manage your authorization token' },
{ id: 'pid', title: 'Id', text: 'Project id' },
{ id: 'pscript', title: 'Script', text: 'Universal javascript integration' },
{ id: 'pdelete', title: 'Delete', text: 'Delete current project' },
]
const projectNameInputVal = ref<string>(project.value?.name || '');
const apiKeys = ref<TApiSettings[]>([]);
const newApiKeyName = ref<string>('');
async function updateApiKeys() {
newApiKeyName.value = '';
apiKeys.value = await $fetch<TApiSettings[]>('/api/keys/get_all', signHeaders({
'x-pid': project.value?._id.toString() ?? ''
}));
}
async function createApiKey() {
try {
const res = await $fetch<TApiSettings>('/api/keys/create', {
method: 'POST', ...signHeaders({
'Content-Type': 'application/json',
'x-pid': project.value?._id.toString() ?? ''
}),
body: JSON.stringify({ name: newApiKeyName.value })
});
apiKeys.value.push(res);
newApiKeyName.value = '';
} catch (ex: any) {
alert(ex.message);
}
}
async function deleteApiKey(api_id: string) {
try {
const res = await $fetch<TApiSettings>('/api/keys/delete', {
method: 'DELETE', ...signHeaders({
'Content-Type': 'application/json',
'x-pid': project.value?._id.toString() ?? ''
}),
body: JSON.stringify({ api_id })
});
newApiKeyName.value = '';
await updateApiKeys();
} catch (ex: any) {
alert(ex.message);
}
}
onMounted(() => {
updateApiKeys();
});
watch(project, () => {
projectNameInputVal.value = project.value?.name || "";
updateApiKeys();
});
const canChange = computed(() => {
if (project.value?.name == projectNameInputVal.value) return false;
if (projectNameInputVal.value.length === 0) return false;
return true;
});
async function changeProjectName() {
await $fetch("/api/project/change_name", {
method: 'POST',
...signHeaders({
'Content-Type': 'application/json',
'x-pid': project.value?._id.toString() ?? ''
}),
body: JSON.stringify({ name: projectNameInputVal.value })
});
location.reload();
}
const router = useRouter();
async function deleteProject() {
if (!project.value) return;
const sure = confirm(`Are you sure to delete the project ${project.value.name} ?`);
if (!sure) return;
try {
await $fetch('/api/project/delete', {
method: 'DELETE',
...signHeaders({
'Content-Type': 'application/json',
'x-pid': project.value?._id.toString() ?? ''
}),
body: JSON.stringify({ project_id: project.value._id.toString() })
});
await actions.refreshProjectsList()
const firstProjectId = projectList.value?.[0]?._id.toString();
if (firstProjectId) {
await actions.setActiveProject(firstProjectId);
router.push('/')
}
} catch (ex: any) {
alert(ex.message);
}
}
const { createAlert } = useAlert()
function copyScript() {
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
const createScriptText = () => {
return [
'<script defer ',
`data-project="${projectId.value}" `,
'src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></',
'script>'
].join('')
}
navigator.clipboard.writeText(createScriptText());
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
}
function copyProjectId() {
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
navigator.clipboard.writeText(projectId.value || '');
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
}
</script>
<template>
<SettingsTemplate :entries="entries" :key="project?.name || 'NONE'">
<template #pname>
<div class="flex items-center gap-4">
<LyxUiInput class="w-full px-4 py-2" :disabled="isGuest" v-model="projectNameInputVal"></LyxUiInput>
<LyxUiButton v-if="!isGuest" @click="changeProjectName()" :disabled="!canChange" type="primary"> Change
</LyxUiButton>
</div>
</template>
<template #api>
<div class="flex items-center gap-4" v-if="apiKeys && apiKeys.length < 5">
<LyxUiInput class="grow px-4 py-2" :disabled="isGuest" placeholder="ApiKeyName" v-model="newApiKeyName">
</LyxUiInput>
<LyxUiButton v-if="!isGuest" @click="createApiKey()" :disabled="newApiKeyName.length < 3"
type="primary">
<i class="far fa-plus"></i>
</LyxUiButton>
</div>
<LyxUiCard v-if="apiKeys && apiKeys.length > 0" class="w-full flex flex-col gap-4 items-center mt-4">
<div v-for="apiKey of apiKeys" class="flex flex-col w-full">
<div class="flex gap-8 items-center">
<div class="grow">Name: {{ apiKey.apiName }}</div>
<div>{{ apiKey.apiKey }}</div>
<div class="flex justify-end" v-if="!isGuest">
<i class="far fa-trash cursor-pointer" @click="deleteApiKey(apiKey._id.toString())"></i>
</div>
</div>
</div>
</LyxUiCard>
</template>
<template #pid>
<LyxUiCard class="w-full flex items-center">
<div class="grow">{{ project?._id.toString() }}</div>
<div><i class="far fa-copy" @click="copyProjectId()"></i></div>
</LyxUiCard>
</template>
<template #pscript>
<LyxUiCard class="w-full flex items-center">
<div class="grow">
{{ `
<script defer data-project="${project?._id}"
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }}
</div>
<div><i class="far fa-copy" @click="copyScript()"></i></div>
</LyxUiCard>
</template>
<template #pdelete>
<div class="flex justify-end" v-if="!isGuest">
<LyxUiButton type="danger" @click="deleteProject()">
Delete project
</LyxUiButton>
</div>
</template>
</SettingsTemplate>
</template>

View File

@@ -0,0 +1,37 @@
<script lang="ts" setup>
export type SettingsTemplateEntry = {
title: string,
text: string,
id: string
}
type SettingsTemplateProp = {
entries: SettingsTemplateEntry[]
}
const props = defineProps<SettingsTemplateProp>();
</script>
<template>
<div class="mt-10 px-4">
<div v-for="(entry, index) of props.entries" class="flex flex-col">
<div class="flex">
<div class="flex-[2]">
<div class="poppins font-medium text-lyx-text">
{{ entry.title }}
</div>
<div class="poppins font-regular text-lyx-text-dark whitespace-pre-wrap">
{{ entry.text }}
</div>
</div>
<div class="flex-[3]">
<slot :name="entry.id"></slot>
</div>
</div>
<div v-if="index < props.entries.length - 1" class="h-[2px] bg-lyx-widget-lighter w-full my-10"></div>
</div>
</div>
</template>

View File

@@ -0,0 +1,286 @@
<script lang="ts" setup>
import dayjs from 'dayjs';
import type { SettingsTemplateEntry } from './Template.vue';
import { getPlanFromId, PREMIUM_PLAN, type PREMIUM_TAG } from '@data/PREMIUM';
const { projectId, isGuest } = useProject();
definePageMeta({ layout: 'dashboard' });
const { data: planData, pending: planPending } = useFetch('/api/project/plan', {
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
});
const { data: customerAddress } = useFetch(`/api/pay/customer_info`, {
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
});
const percent = computed(() => {
if (!planData.value) return '-';
return (100 / planData.value.limit * planData.value.count).toFixed(2) + '%';
});
const color = computed(() => {
if (!planData.value) return 'blue';
if (planData.value.count >= planData.value.limit) return 'red';
return 'blue';
});
const daysLeft = computed(() => {
if (!planData.value) return '-';
return (-dayjs().diff(planData.value.billing_expire_at, 'days')).toString();
});
const leftPercent = computed(() => {
if (!planData.value) return 0;
const left = dayjs().diff(planData.value.billing_expire_at, 'days');
const total = dayjs(planData.value.billing_start_at).diff(planData.value.billing_expire_at, 'days');
const percent = 100 - (100 / total * left);
return percent;
});
const prettyExpireDate = computed(() => {
if (!planData.value) return '';
return dayjs(planData.value.billing_expire_at).format('DD/MM/YYYY');
});
const { data: invoices, pending: invoicesPending } = useFetch(`/api/pay/invoices`, {
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
})
function openInvoice(link: string) {
window.open(link, '_blank');
}
function getPremiumName(type: number) {
return Object.keys(PREMIUM_PLAN).map(e => ({
...PREMIUM_PLAN[e as PREMIUM_TAG], name: e
})).find(e => e.ID == type)?.name;
}
function getPremiumPrice(type: number) {
const PLAN = getPlanFromId(type);
if (!PLAN) return '0,00';
return (PLAN.COST / 100).toFixed(2).replace('.', ',')
}
const entries: SettingsTemplateEntry[] = [
{ id: 'plan', title: 'Current plan', text: 'Manage current plat for this project' },
{ id: 'usage', title: 'Usage', text: 'Show usage of current project' },
{ id: 'info', title: 'Billing address', text: 'This will be reflected in every upcoming invoice,\npast invoices are not affected' },
{ id: 'invoices', title: 'Invoices', text: 'Manage invoices of current project' },
]
watch(customerAddress, () => {
console.log('UPDATE', customerAddress.value)
if (!customerAddress.value) return;
currentBillingInfo.value = customerAddress.value;
});
const currentBillingInfo = ref<any>({
line1: '',
line2: '',
city: '',
country: '',
postal_code: '',
state: ''
});
const { createAlert } = useAlert()
async function saveBillingInfo() {
try {
const res = await $fetch(`/api/pay/update_customer`, {
method: 'POST',
...signHeaders({
'Content-Type': 'application/json',
'x-pid': projectId.value ?? ''
}),
body: JSON.stringify(currentBillingInfo.value)
});
createAlert('Customer updated', 'Customer updated successfully', 'far fa-check', 5000);
} catch (ex) {
createAlert('Error updating customer', 'An error occurred while updating the customer', 'far fa-error', 8000);
}
}
const { visible } = usePricingDrawer();
</script>
<template>
<div class="relative">
<div v-if="invoicesPending || planPending"
class="backdrop-blur-[1px] z-[20] mt-20 w-full h-full flex items-center justify-center font-bold">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
<SettingsTemplate v-if="!invoicesPending && !planPending" :entries="entries">
<template #info>
<div v-if="!isGuest">
<div class="flex flex-col gap-4">
<LyxUiInput class="px-2 py-2 !bg-[#161616]" placeholder="Address line 1" v-model="currentBillingInfo.line1">
</LyxUiInput>
<LyxUiInput class="px-2 py-2 !bg-[#161616]" placeholder="Address line 2" v-model="currentBillingInfo.line2">
</LyxUiInput>
<div class="flex gap-4 w-full">
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="Country"
v-model="currentBillingInfo.country">
</LyxUiInput>
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="Postal code"
v-model="currentBillingInfo.postal_code">
</LyxUiInput>
</div>
<div class="flex gap-4 w-full">
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="City" v-model="currentBillingInfo.city">
</LyxUiInput>
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="State" v-model="currentBillingInfo.state">
</LyxUiInput>
</div>
</div>
<div class="mt-5 flex justify-end">
<LyxUiButton type="primary" @click="saveBillingInfo">
Save
</LyxUiButton>
</div>
</div>
</template>
<template #plan>
<LyxUiCard v-if="planData" class="flex flex-col w-full">
<div class="flex flex-col gap-6 px-8 grow">
<div class="flex justify-between items-center flex-col sm:flex-row">
<div class="flex flex-col">
<div class="flex gap-3 items-center">
<div class="poppins font-semibold text-[1.1rem]">
{{ planData.premium ? 'Premium plan' : 'Basic plan' }}
</div>
<div
class="flex lato text-[.7rem] bg-accent/25 border-accent/40 border-[1px] px-[.6rem] rounded-sm">
{{ planData.premium ? getPremiumName(planData.premium_type) : 'FREE' }}
</div>
</div>
</div>
<div class="flex items-center gap-1">
<div class="poppins font-semibold text-[2rem]">
{{ getPremiumPrice(planData.premium_type) }} </div>
<div class="poppins text-text-sub mt-2"> per month </div>
</div>
</div>
<div class="flex flex-col">
<div class="poppins"> Billing period:</div>
<div class="flex items-center gap-2 md:gap-4 flex-col pt-4 md:pt-0 md:flex-row">
<div class="grow w-full md:w-auto">
<UProgress color="green" :min="0" :max="100" :value="leftPercent"></UProgress>
</div>
<div class="poppins"> {{ daysLeft }} days left </div>
</div>
<div class="flex justify-center">
Subscription: {{ planData.subscription_status }}
</div>
</div>
</div>
<div class="my-4 w-full bg-gray-400/30 h-[1px]">
</div>
<div class="flex justify-between px-8 flex-col sm:flex-row">
<div class="flex gap-2 text-text-sub text-[.9rem]">
<div class="poppins"> Expire date:</div>
<div> {{ prettyExpireDate }}</div>
</div>
<LyxUiButton v-if="!isGuest" @click="visible = true" type="primary">
Upgrade plan
</LyxUiButton>
</div>
</LyxUiCard>
</template>
<template #usage>
<LyxUiCard v-if="planData" class="flex flex-col w-full">
<div class="flex flex-col gap-6 px-8">
<div class="flex justify-between">
<div class="flex flex-col">
<div class="poppins font-semibold text-[1.1rem]">
Usage
</div>
<div class="poppins text-text-sub text-[.9rem]">
Check the usage limits of your project.
</div>
</div>
</div>
<div class="flex flex-col">
<div class="poppins"> Usage:</div>
<div class="flex items-center gap-2 md:gap-4 flex-col pt-4 md:pt-0 md:flex-row">
<div class="grow w-full md:w-auto">
<UProgress :color="color" :min="0" :max="planData.limit" :value="planData.count">
</UProgress>
</div>
<div class="poppins"> {{ percent }}</div>
</div>
<div class="flex justify-center">
{{ formatNumberK(planData.count) }} / {{ formatNumberK(planData.limit) }}
</div>
</div>
</div>
</LyxUiCard>
</template>
<template #invoices>
<CardTitled v-if="!isGuest" title="Invoices"
:sub="(invoices && invoices.length == 0) ? 'No invoices yet' : ''" class="p-4 mt-8 w-full">
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center bg-[#161616] p-4 rounded-lg"
v-for="invoice of invoices">
<div> <i class="fal fa-file-invoice"></i> </div>
<div class="flex flex-col md:flex-row md:justify-around md:grow items-center gap-2">
<div> {{ new Date(invoice.date).toLocaleString() }} </div>
<div> {{ invoice.cost / 100 }} </div>
<div> {{ invoice.id }} </div>
<div
class="flex items-center lato text-[.8rem] bg-accent/25 border-accent/40 border-[1px] px-[.6rem] rounded-lg">
{{ invoice.status }}
</div>
</div>
<div>
<i @click="openInvoice(invoice.link)"
class="far fa-download cursor-pointer hover:text-white/80"></i>
</div>
</div>
</div>
</CardTitled>
</template>
</SettingsTemplate>
</div>
</template>
<style scoped lang="scss">
.pdrawer-enter-active,
.pdrawer-leave-active {
transition: all .5s ease-in-out;
}
.pdrawer-enter-from,
.pdrawer-leave-to {
transform: translateX(100%)
}
.pdrawer-enter-to,
.pdrawer-leave-from {
transform: translateX(0)
}
</style>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import type { SettingsTemplateEntry } from './Template.vue';
const { projectId, isGuest } = useProject();
definePageMeta({ layout: 'dashboard' });
const activeProject = useActiveProject();
const columns = [
{ key: 'me', label: '' },
{ key: 'email', label: 'Email' },
@@ -13,7 +14,9 @@ const columns = [
// { key: 'pending', label: 'Pending' },
]
const { data: members, refresh: refreshMembers } = useFetch('/api/project/members/list', signHeaders());
const { data: members, refresh: refreshMembers } = useFetch('/api/project/members/list', {
headers: useComputedHeaders({ useSnapshotDates: false })
});
const showAddMember = ref<boolean>(false);
@@ -28,7 +31,10 @@ async function kickMember(email: string) {
await $fetch('/api/project/members/kick', {
method: 'POST',
...signHeaders({ 'Content-Type': 'application/json' }),
...signHeaders({
'Content-Type': 'application/json',
'x-pid': projectId.value ?? ''
}),
body: JSON.stringify({ email }),
onResponseError({ request, response, options }) {
alert(response.statusText);
@@ -53,7 +59,10 @@ async function addMember() {
await $fetch('/api/project/members/add', {
method: 'POST',
...signHeaders({ 'Content-Type': 'application/json' }),
...signHeaders({
'Content-Type': 'application/json',
'x-pid': projectId.value ?? ''
}),
body: JSON.stringify({ email: addMemberEmail.value }),
onResponseError({ request, response, options }) {
alert(response.statusText);
@@ -67,32 +76,31 @@ async function addMember() {
} catch (ex: any) { }
}
const entries: SettingsTemplateEntry[] = [
{ id: 'add', title: 'Add member', text: 'Add new member to project' },
{ id: 'members', title: 'Members', text: 'Manage members of current project' },
]
</script>
<template>
<div class="home w-full h-full px-10 lg:px-6 overflow-y-auto pb-[12rem] md:pb-0 py-2">
<div class="flex flex-col gap-4">
<div v-if="!isGuest" @click="showAddMember = !showAddMember;"
class="flex items-center gap-2 bg-menu w-fit px-3 py-2 rounded-lg hover:bg-menu/80 cursor-pointer">
<i class="fas fa-plus"></i>
<div> Add member </div>
<SettingsTemplate :entries="entries">
<template #add>
<div v-if="!isGuest" class="flex flex-col">
<div class="flex gap-4 items-center">
<LyxUiInput class="px-4 py-1 w-full" placeholder="User email" v-model="addMemberEmail"></LyxUiInput>
<LyxUiButton @click="addMember" type="secondary"> Add </LyxUiButton>
</div>
<div v-if="showAddMember" class="flex gap-4 items-center">
<input v-model="addMemberEmail" class="focus:outline-none bg-menu px-4 py-1 rounded-lg" type="text"
placeholder="user email">
<div @click="addMember" class="bg-menu w-fit py-1 px-4 rounded-lg hover:bg-menu/80 cursor-pointer">
Add
<div class="poppins text-[.8rem] mt-2 text-lyx-text-darker">
User should have been registered to Litlyx
</div>
</div>
</template>
<template #members>
<UTable :rows="members || []" :columns="columns">
<template #me-data="e">
@@ -108,9 +116,7 @@ async function addMember() {
</template>
</UTable>
</div>
</div>
</template>
</SettingsTemplate>
</template>

View File

@@ -1,54 +0,0 @@
import type { TProject } from "@schema/ProjectSchema";
const projects = useFetch<TProject[]>('/api/project/list', {
key: 'projectslist', ...signHeaders()
});
export function useProjectsList() {
return { ...projects, projects: projects.data }
}
const guestProjects = useFetch<TProject[]>('/api/project/list_guest', {
key: 'guestProjectslist', ...signHeaders()
});
export function useGuestProjectsList() {
return { ...guestProjects, guestProjects: guestProjects.data }
}
const activeProjectId = useFetch<string>(`/api/user/active_project`, {
key: 'activeProjectId', ...signHeaders(),
});
export const isGuest = computed(() => {
if (!guestProjects.data.value) return false;
const guestTarget = guestProjects.data.value.find(e => e._id.toString() == activeProjectId.data.value);
if (guestTarget) return true;
return false;
});
export function useActiveProjectId() {
return { ...activeProjectId, pid: activeProjectId.data }
}
export function useActiveProject() {
if (isLiveDemo()) {
const { data: liveDemoProject } = useLiveDemo();
return liveDemoProject;
}
return computed(() => {
if (!projects.data.value) return;
if (!activeProjectId.data.value) return;
const target = projects.data.value.find(e => e._id.toString() == activeProjectId.data.value);
if (target) return target;
if (!guestProjects.data.value) return;
const guestTarget = guestProjects.data.value.find(e => e._id.toString() == activeProjectId.data.value);
return guestTarget;
});
}
export async function setActiveProject(project_id: string) {
await $fetch<string>(`/api/user/set_active_project?project_id=${project_id}`, signHeaders());
await activeProjectId.refresh();
}

View File

@@ -1,11 +1,7 @@
import { Chart, registerables } from 'chart.js';
let registered = false;
export async function registerChartComponents() {
if (registered) return;
if (process.client) {
Chart.register(...registerables);
console.log('registerChartComponents is deprecated. Plugin is now used');
registered = true;
}
}

View File

@@ -1,34 +1,25 @@
const ACCESS_TOKEN_STATE_KEY = 'access_token';
const ACCESS_TOKEN_COOKIE_KEY = 'access_token';
const tokenCookie = useCookie(ACCESS_TOKEN_COOKIE_KEY, { expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) });
const token = ref<string | undefined>();
export function signHeaders(headers?: Record<string, string>) {
const { token } = useAccessToken()
return { headers: { ...(headers || {}), 'Authorization': 'Bearer ' + token.value } }
}
export const authorizationHeaderComputed = computed(() => {
const { token } = useAccessToken()
return token.value ? 'Bearer ' + token.value : '';
});
function setToken(value: string) {
tokenCookie.value = value;
token.value = value;
}
export function useAccessToken() {
const tokenCookie = useCookie(ACCESS_TOKEN_COOKIE_KEY, { expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) })
const token = useState<string | undefined | null>(ACCESS_TOKEN_STATE_KEY);
const needLoad = useState<boolean>('needAccessTokenLoad', () => true);
const readToken = () => {
token.value = tokenCookie.value;
needLoad.value = false;
}
const setToken = (newToken: string) => {
tokenCookie.value = newToken;
token.value = tokenCookie.value;
needLoad.value = false;
}
if (needLoad.value == true) readToken();
return { token, readToken, setToken, needLoad }
if (!token.value) token.value = tokenCookie.value as any;
return { setToken, token }
}

View File

@@ -0,0 +1,43 @@
export type Alert = {
title: string,
text: string,
icon: string,
ms: number,
id: number,
remaining: number,
transitionStyle: string
}
const alerts = ref<Alert[]>([]);
const idPool = {
id: 0,
getId() {
return idPool.id++;
}
}
function createAlert(title: string, text: string, icon: string, ms: number) {
const alert = reactive<Alert>({
title, text, icon, ms, id: idPool.getId(), remaining: ms,
transitionStyle: 'transition: all 250ms linear;'
});
alerts.value.push(alert);
const timeout = setInterval(() => {
alert.remaining -= 250;
if (alert.remaining <= 0) {
closeAlert(alert.id);
clearInterval(timeout);
}
}, 250)
}
function closeAlert(id: number) {
alerts.value = alerts.value.filter(e => e.id != id);
}
export function useAlert() {
return { alerts, createAlert, closeAlert }
}

View File

@@ -4,17 +4,42 @@ import type { Component } from "vue";
const showDialog = ref<boolean>(false);
const dialogParams = ref<any>({});
const dialogComponent = ref<Component>();
const dialogWidth = ref<string>("100%");
const dialogHeight = ref<string>("100%");
const dialogClosable = ref<boolean>(true);
function closeDialog() {
showDialog.value = false;
}
export type CustomDialogOptions = {
params?: any,
width?: string,
height?: string,
closable?: boolean
}
function openDialogEx(component: Component, options?: CustomDialogOptions) {
dialogComponent.value = component;
dialogParams.value = options?.params || {};
showDialog.value = true;
dialogWidth.value = options?.width || '100%';
dialogHeight.value = options?.height || '100%';
dialogClosable.value = options?.closable ?? true;
}
function openDialog(component: Component, params: any) {
dialogComponent.value = component;
dialogParams.value = params;
showDialog.value = true;
dialogWidth.value = '100%';
dialogHeight.value = '100%';
}
const dialogStyle = computed(() => {
return `width: ${dialogWidth.value}; height: ${dialogHeight.value}`;
});
export function useCustomDialog() {
return { showDialog, closeDialog, openDialog, dialogParams, dialogComponent };
return { showDialog, openDialogEx, closeDialog, openDialog, dialogParams, dialogComponent, dialogStyle, dialogClosable };
}

View File

@@ -0,0 +1,47 @@
import type { StringExpressionOperator } from "mongoose";
type RefOrPrimitive<T> = T | Ref<T> | ComputedRef<T>
export type CustomOptions = {
useSnapshotDates?: boolean,
useActivePid?: boolean,
slice?: RefOrPrimitive<string>,
limit?: RefOrPrimitive<number | string>,
custom?: Record<string, RefOrPrimitive<string>>
}
const { token } = useAccessToken();
const { projectId } = useProject();
const { safeSnapshotDates } = useSnapshot()
function getValueFromRefOrPrimitive<T>(data?: T | Ref<T> | ComputedRef<T>) {
if (!data) return;
if (isRef(data)) return data.value;
return data;
}
export function useComputedHeaders(customOptions?: CustomOptions) {
const useSnapshotDates = customOptions?.useSnapshotDates || true;
const useActivePid = customOptions?.useActivePid || true;
const headers = computed<Record<string, string>>(() => {
const parsedCustom: Record<string, string> = {}
const customKeys = Object.keys(customOptions?.custom || {});
for (const key of customKeys) {
parsedCustom[key] = getValueFromRefOrPrimitive((customOptions?.custom || {})[key]) ?? ''
}
return {
'Authorization': `Bearer ${token.value}`,
'x-pid': useActivePid ? (projectId.value ?? '') : '',
'x-from': useSnapshotDates ? (safeSnapshotDates.value.from ?? '') : '',
'x-to': useSnapshotDates ? (safeSnapshotDates.value.to ?? '') : '',
'x-slice': getValueFromRefOrPrimitive(customOptions?.slice) ?? '',
'x-limit': getValueFromRefOrPrimitive(customOptions?.limit)?.toString() ?? '',
...parsedCustom
}
})
return headers;
}

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