mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 15:58:38 +01:00
Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b5d23566c | ||
|
|
dbcda95823 | ||
|
|
fb89c87489 | ||
|
|
b59eea47e9 | ||
|
|
473331047d | ||
|
|
5af77ff63e | ||
|
|
e6e2340432 | ||
|
|
0b90c2fe3c | ||
|
|
a6d1797a4f | ||
|
|
d1abe1a91f | ||
|
|
b733cd2a68 | ||
|
|
88ebfc188c | ||
|
|
ab95772dd4 | ||
|
|
0d5dbc69ad | ||
|
|
8a359936d1 | ||
|
|
ffd2e96138 | ||
|
|
b8e434be9a | ||
|
|
835ab6208e | ||
|
|
617de36fec | ||
|
|
fb31fdcfff | ||
|
|
745a332e56 | ||
|
|
a10755f998 | ||
|
|
46bca2f787 | ||
|
|
cb928977c3 | ||
|
|
3b5a46a64a | ||
|
|
7d05a9d157 | ||
|
|
edc897d62a | ||
|
|
39c42e7bd5 | ||
|
|
7009a0ad02 | ||
|
|
3f26f1ab68 | ||
|
|
7082b88523 | ||
|
|
29bae329b4 | ||
|
|
f908b0b4a9 | ||
|
|
b38363ddf5 | ||
|
|
68d362d1b3 | ||
|
|
0a9474d00c | ||
|
|
6307e09dc3 | ||
|
|
f358bb9bb6 | ||
|
|
23b8f7229a | ||
|
|
78f979d23a | ||
|
|
ad8e9e1ead | ||
|
|
06768b6cdc | ||
|
|
91f69baacd | ||
|
|
0964ec4250 | ||
|
|
9ce2c89575 | ||
|
|
b630bddef0 | ||
|
|
30e428a8dc | ||
|
|
b700b96191 | ||
|
|
606eb0b035 | ||
|
|
ec974c3599 | ||
|
|
4c811c160b | ||
|
|
e140585362 | ||
|
|
7d56b7a6a2 | ||
|
|
070560c1e2 | ||
|
|
41037a01a1 | ||
|
|
caef67a0e1 | ||
|
|
5ac43dec6b | ||
|
|
9de299d841 | ||
|
|
2929b229c4 | ||
|
|
f06d7d78fc | ||
|
|
4d7cfbb7b9 | ||
|
|
b4c0620f17 | ||
|
|
b8c2e40f7a | ||
|
|
e866a1c22b | ||
|
|
f86a399840 | ||
|
|
36c4406af2 | ||
|
|
b2afd585bb | ||
|
|
24ae9d0e0d | ||
|
|
b479ca1bbf | ||
|
|
0a748346c5 | ||
|
|
fa7880552a | ||
|
|
06fb8bfab0 | ||
|
|
a876d77d42 | ||
|
|
e6bb58693f | ||
|
|
00e63cc80b | ||
|
|
e43f138945 | ||
|
|
73309e7021 | ||
|
|
80e3b0caa9 | ||
|
|
0a7f2b58d0 | ||
|
|
e953af2c1b | ||
|
|
126296d28f | ||
|
|
8dd10deecc | ||
|
|
f22e65ccc5 | ||
|
|
dfbc64fe33 | ||
|
|
9568566361 | ||
|
|
634cb641f1 | ||
|
|
204e1348b4 | ||
|
|
b73155a176 | ||
|
|
62c72b3ff9 | ||
|
|
79e956e930 | ||
|
|
b27cacf4e6 | ||
|
|
c2846ca595 | ||
|
|
e1953f2f9f | ||
|
|
314660d8a3 | ||
|
|
f516c53b7b | ||
|
|
dad8c521ee | ||
|
|
089d1a418e | ||
|
|
a08624b69b | ||
|
|
3ba6cd171b | ||
|
|
1828edf98b | ||
|
|
96c39dbba1 | ||
|
|
9403aebbb9 | ||
|
|
69bb6fb03c | ||
|
|
33b730e66b | ||
|
|
0ba44a406d | ||
|
|
3c77a727cd | ||
|
|
8e3ad2920f | ||
|
|
f4401d74a2 | ||
|
|
375330bac4 | ||
|
|
3b1ee0fd13 | ||
|
|
f5edf187fd | ||
|
|
5b7e93bcbb | ||
|
|
3b6a202538 | ||
|
|
cf1aa103e4 | ||
|
|
4eeebaa0c3 | ||
|
|
f285e92132 | ||
|
|
ac7ba7abd3 | ||
|
|
3c59551f88 | ||
|
|
628e471cec | ||
|
|
0be3dbecbf |
@@ -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
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
steps
|
||||
PROCESS_EVENT
|
||||
**/node_modules/
|
||||
docker
|
||||
dev
|
||||
docker-compose.admin.yml
|
||||
|
||||
39
README.md
39
README.md
@@ -4,13 +4,13 @@
|
||||
</p>
|
||||
|
||||
<h4 align="center">
|
||||
🌐 <a href="https://litlyx.com">Website</a> 📚 <a href="https://docs.litlyx.com">Docs</a> 👾 <a href="https://discord.gg/9cQykjsmWX">Join Discord</a> 🔥 <a href="https://dashboard.litlyx.com">Start for free!</a>
|
||||
🌐 <a href="https://litlyx.com">Website</a> 📚 <a href="https://docs.litlyx.com">Docs</a> 👾 <a href="https://discord.gg/9cQykjsmWX">Join Discord</a> 🔥 <a href="https://dashboard.litlyx.com">Try Litlyx Cloud. It's Free.</a>
|
||||
</h4>
|
||||
|
||||
#
|
||||
<p align="center">
|
||||
The easiest, developer-centric analytics tool.<br>
|
||||
Litlyxis an open-source, self-hostable analytics solution for modern framework. Setup takes less than 30 seconds!
|
||||
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>
|
||||
|
||||
#
|
||||
@@ -23,27 +23,27 @@
|
||||
|
||||
#
|
||||
|
||||
## Pre-Requisites on Cloud Version
|
||||
## Get Started on our Cloud Version
|
||||
|
||||
Sign-up on [Litlyx.com](https://dashboard.litlyx.com) and create a project. Then simply use your project_id to connect Litlyx to your website OR Self-Host Litlyx with Docker.
|
||||
|
||||
## Universal Installation
|
||||
|
||||
```html
|
||||
<script defer data-project="project_id_here" src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>
|
||||
<script defer data-project="your_project_id" src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>
|
||||
```
|
||||
|
||||
Importing Litlyx with a direct script instantly starts tracking 10 KPIs, including `Page visits`, `Browsers`, `Devices`, `Operating Systems`, `Real-Time Online Users`, `Unique Sessions`, `Countries`, and `Average Session Time`.
|
||||
Importing Litlyx with a direct script instantly starts tracking `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 works with all JavaScript / TypeScript frameworks. You can use Litlyx in all WordPress Websites by injecting JS code using a plug-in. Litlyx also works in serverless enviroments with Cloud (or Edge) Functions.
|
||||
Litlyx natively works with all JavaScript / TypeScript frameworks. You can use Litlyx in all WordPress Websites by injecting JS code using a plug-in. Litlyx also works in serverless environments with Cloud (or Edge) Functions.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/tech.png" />
|
||||
@@ -65,7 +65,7 @@ Lit.init('your_project_id');
|
||||
|
||||
After initialization, Litlyx will automatically track analytics such as `Page visits`, `Browsers`, `Devices`, `Operating Systems`, `Real-Time Online Users`, `Unique Sessions`, `Countries`, and `Average Session Time`.
|
||||
|
||||
# Custom Events
|
||||
# Track Custom Events
|
||||
|
||||
You aren't just limited to the built-in KPIs. With Litlyx, you can create your own events to track in your project.
|
||||
|
||||
@@ -80,6 +80,7 @@ Lit.event('click_on_buy_item', {
|
||||
metadata: {
|
||||
'product-name': 'Coca-Cola',
|
||||
'price': 1.50,
|
||||
'currency': 'EUR'
|
||||
}
|
||||
});
|
||||
```
|
||||
@@ -107,18 +108,28 @@ curl -X POST "https://broker.litlyx.com/event" \
|
||||
|
||||
To self-host the Litlyx dashboard, first **fork** this repository.
|
||||
|
||||
Then run the following command:
|
||||
```bash
|
||||
docker-compose build
|
||||
```
|
||||
You can find our Docker images on DockerHub for more.
|
||||
|
||||
after the build finishes, run:
|
||||
Then run the following command:
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
at localhost:3000 you will see your own instance of the Litlyx Dashboard.
|
||||
|
||||
## Forward data to your local instance with script tag
|
||||
|
||||
To forward your data on your self-hosted instance, you need to set up the following variables: add your `data-host`, add your `data-port`, and add your `data-secure`, setting it to true if it is HTTPS, and false if it is HTTP.
|
||||
|
||||
```html
|
||||
<script defer data-project="your_project_id"
|
||||
data-host="your-host-name"
|
||||
data-port="your-port"
|
||||
data-secure="true/false"
|
||||
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js">
|
||||
</script>
|
||||
```
|
||||
|
||||
# Official Docs
|
||||
|
||||
For more info read our [documentation](https://docs.litlyx.com). (will be improved in the near future using Mintlify!)
|
||||
|
||||
BIN
assets/claim.png
BIN
assets/claim.png
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 5.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 123 KiB |
@@ -1,43 +0,0 @@
|
||||
ARG NODE_VERSION=21
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine as base
|
||||
|
||||
ENV NODE_ENV=development
|
||||
|
||||
# Build stage
|
||||
|
||||
FROM base as build
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
COPY --link broker/package.json broker/pnpm-lock.yaml home/app/
|
||||
|
||||
COPY --link shared/package.json shared/pnpm-lock.yaml /home/shared/
|
||||
|
||||
WORKDIR /home/app
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
WORKDIR /home/shared
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY --link ../broker /home/app
|
||||
|
||||
COPY --link ../shared /home/shared
|
||||
|
||||
WORKDIR /home/app
|
||||
|
||||
RUN pnpm run build_all
|
||||
|
||||
RUN pnpm prune
|
||||
|
||||
# Final stage
|
||||
|
||||
FROM base
|
||||
|
||||
COPY --from=build /home/app /home/app
|
||||
|
||||
WORKDIR /home/app
|
||||
|
||||
EXPOSE ${PORT}
|
||||
|
||||
CMD ["node", "dist/app/src/index.js"]
|
||||
@@ -1,20 +0,0 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'QueueBroker',
|
||||
port: '3999',
|
||||
exec_mode: 'fork',
|
||||
script: './dist/producer/src/index.js',
|
||||
env: {
|
||||
EMAIL_SERVICE: "",
|
||||
BREVO_API_KEY: "",
|
||||
PORT: "",
|
||||
MONGO_CONNECTION_STRING: "",
|
||||
REDIS_URL: "",
|
||||
REDIS_USERNAME: "",
|
||||
REDIS_PASSWORD: "",
|
||||
STREAM_NAME: ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} **/
|
||||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+.tsx?$": ["ts-jest",{}],
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'@services/(.*)': '<rootDir>/../shared/services/$1',
|
||||
'@data/(.*)': '<rootDir>/../shared/data/$1',
|
||||
'@functions/(.*)': '<rootDir>/../shared/functions/$1',
|
||||
'@schema/(.*)': '<rootDir>/../shared/schema/$1',
|
||||
}
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@getbrevo/brevo": "^2.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"mongoose": "^8.3.2",
|
||||
"nodemailer": "^6.9.13",
|
||||
"redis": "^4.6.14",
|
||||
"ua-parser-js": "^1.0.37"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.12.13",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"glob": "^10.4.1",
|
||||
"jest": "^29.7.0",
|
||||
"node-ssh": "^13.2.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"name": "litlyx-queue-broker",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start_dev.js",
|
||||
"compile": "tsc",
|
||||
"build": "ts-node scripts/build.ts",
|
||||
"create_db": "cd scripts && ts-node create_database.ts",
|
||||
"build_all": "npm run compile && npm run build && npm run create_db",
|
||||
"docker-build": "docker build -t litlyx-broker -f Dockerfile ../",
|
||||
"docker-inspect": "docker run -it litlyx-broker sh",
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Emily",
|
||||
"license": "MIT",
|
||||
"description": "Queue broker for Litlyx - Saves events to database."
|
||||
}
|
||||
4685
broker/pnpm-lock.yaml
generated
4685
broker/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import { globSync } from 'glob';
|
||||
const tsconfigContent = fs.readFileSync('tsconfig.json', 'utf8');
|
||||
const tsconfigObject = JSON.parse(tsconfigContent);
|
||||
const paths = tsconfigObject.compilerOptions.paths;
|
||||
const filesList = globSync('dist/**/*.js');
|
||||
filesList.forEach(file => {
|
||||
let raw = fs.readFileSync(file, 'utf8');
|
||||
for (const path in paths) {
|
||||
const deep = (file.match(/\\|\//g) || []).length;
|
||||
const pathText = path.replace('*', '');
|
||||
const toReplaceText = new RegExp(`"${pathText}(.*?)"`, 'g');
|
||||
raw = raw.replace(toReplaceText, `"${new Array(deep - 2).fill('../').join('')}${paths[path][0].replace('*', '')}${'$1'}"`);
|
||||
}
|
||||
fs.writeFileSync(file, raw);
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
|
||||
|
||||
export function getDeviceFromScreenSize(width: number, height: number) {
|
||||
const totalArea = width * height;
|
||||
|
||||
const mobileArea = 375 * 667;
|
||||
const tabletMinArea = 768 * 1366
|
||||
const tabletMaxArea = 1024 * 1366
|
||||
|
||||
const isMobile = totalArea <= mobileArea;
|
||||
const isTablet = totalArea >= tabletMinArea && totalArea <= tabletMaxArea;
|
||||
|
||||
if (isMobile) return 'mobile';
|
||||
if (isTablet) return 'tablet'
|
||||
return 'desktop';
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
|
||||
import { RedisStreamService } from '@services/RedisStreamService';
|
||||
import { requireEnv } from '../../shared/utilts/requireEnv';
|
||||
import { EventModel } from '@schema/metrics/EventSchema';
|
||||
import { SessionModel } from '@schema/metrics/SessionSchema';
|
||||
import { ProjectModel } from '@schema/ProjectSchema';
|
||||
import { ProjectLimitModel } from '@schema/ProjectsLimits';
|
||||
import { ProjectCountModel } from '@schema/ProjectsCounts';
|
||||
import { EVENT_LOG_LIMIT_PERCENT } from '@data/broker/Limits';
|
||||
import { checkLimitsForEmail } from './Controller';
|
||||
import { lookup } from './lookup';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { VisitModel } from '@schema/metrics/VisitSchema';
|
||||
|
||||
|
||||
export async function startStreamLoop() {
|
||||
|
||||
await RedisStreamService.connect();
|
||||
|
||||
await RedisStreamService.startReadingLoop({
|
||||
streamName: requireEnv('STREAM_NAME'),
|
||||
delay: { base: 10, empty: 5000 },
|
||||
readBlock: 2000,
|
||||
consumer: 'consumer_' + process.env.NODE_APP_INSTANCE
|
||||
}, processStreamEvent);
|
||||
|
||||
}
|
||||
|
||||
|
||||
export async function processStreamEvent(data: Record<string, string>) {
|
||||
try {
|
||||
const eventType = data._type;
|
||||
if (!eventType) return;
|
||||
|
||||
const { pid, sessionHash } = data;
|
||||
|
||||
const project = await ProjectModel.exists({ _id: pid });
|
||||
if (!project) return;
|
||||
|
||||
|
||||
if (eventType === 'event') return await process_event(data, sessionHash);
|
||||
if (eventType === 'keep_alive') return await process_keep_alive(data, sessionHash);
|
||||
if (eventType === 'visit') return await process_visit(data, sessionHash);
|
||||
|
||||
} catch (ex: any) {
|
||||
console.error('ERROR PROCESSING STREAM EVENT', ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function checkLimits(project_id: string) {
|
||||
const projectLimits = await ProjectLimitModel.findOne({ project_id });
|
||||
if (!projectLimits) return false;
|
||||
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
|
||||
const COUNT_LIMIT = projectLimits.limit;
|
||||
if ((TOTAL_COUNT) > COUNT_LIMIT * EVENT_LOG_LIMIT_PERCENT) return false;
|
||||
await checkLimitsForEmail(projectLimits);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function process_visit(data: Record<string, string>, sessionHash: string) {
|
||||
|
||||
const { pid, ip, website, page, referrer, userAgent, flowHash } = data;
|
||||
|
||||
const canLog = await checkLimits(pid);
|
||||
if (!canLog) return;
|
||||
|
||||
let referrerParsed;
|
||||
try {
|
||||
referrerParsed = new URL(referrer);
|
||||
} catch (ex) {
|
||||
referrerParsed = { hostname: referrer };
|
||||
}
|
||||
|
||||
const geoLocation = lookup(ip);
|
||||
|
||||
const userAgentParsed = UAParser(userAgent);
|
||||
|
||||
const device = userAgentParsed.device.type;
|
||||
|
||||
const visit = new VisitModel({
|
||||
project_id: pid, website, page, referrer: referrerParsed.hostname,
|
||||
browser: userAgentParsed.browser.name || 'NO_BROWSER',
|
||||
os: userAgentParsed.os.name || 'NO_OS',
|
||||
device: device ? device : (userAgentParsed.browser.name ? 'desktop' : undefined),
|
||||
session: sessionHash,
|
||||
flowHash,
|
||||
continent: geoLocation[0],
|
||||
country: geoLocation[1],
|
||||
});
|
||||
|
||||
await visit.save();
|
||||
|
||||
|
||||
|
||||
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } }, { upsert: true });
|
||||
await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } });
|
||||
|
||||
}
|
||||
|
||||
async function process_keep_alive(data: Record<string, string>, sessionHash: string) {
|
||||
|
||||
const { pid, instant, flowHash } = data;
|
||||
|
||||
const canLog = await checkLimits(pid);
|
||||
if (!canLog) return;
|
||||
|
||||
const existingSession = await SessionModel.findOne({ project_id: pid, session: sessionHash }, { _id: 1 });
|
||||
if (!existingSession) {
|
||||
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'sessions': 1 } }, { upsert: true });
|
||||
}
|
||||
|
||||
if (instant == "true") {
|
||||
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
||||
$inc: { duration: 0 },
|
||||
flowHash,
|
||||
updated_at: Date.now()
|
||||
}, { upsert: true });
|
||||
} else {
|
||||
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
||||
$inc: { duration: 1 },
|
||||
flowHash,
|
||||
updated_at: Date.now()
|
||||
}, { upsert: true });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
async function process_event(data: Record<string, string>, sessionHash: string) {
|
||||
|
||||
const { name, metadata, pid, flowHash } = data;
|
||||
|
||||
const canLog = await checkLimits(pid);
|
||||
if (!canLog) return;
|
||||
|
||||
let metadataObject;
|
||||
try {
|
||||
if (metadata) metadataObject = JSON.parse(metadata);
|
||||
} catch (ex) {
|
||||
metadataObject = { error: 'Error parsing metadata' }
|
||||
}
|
||||
|
||||
const event = new EventModel({ project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash });
|
||||
await event.save();
|
||||
|
||||
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true });
|
||||
await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } });
|
||||
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
|
||||
import { requireEnv } from '../../shared/utilts/requireEnv';
|
||||
import { connectDatabase } from '@services/DatabaseService';
|
||||
import { startStreamLoop } from './StreamLoopController';
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
|
||||
connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
|
||||
|
||||
import HealthRouter from './routes/HealthRouter';
|
||||
app.use('/health', HealthRouter);
|
||||
|
||||
app.listen(requireEnv('PORT'), () => console.log(`Listening on port ${requireEnv('PORT')}`));
|
||||
|
||||
startStreamLoop();
|
||||
@@ -1,15 +0,0 @@
|
||||
|
||||
import { Router } from "express";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
return res.json({ alive: true });
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
return res.status(500).json({ error: 'ERROR' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Request } from "express";
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function getIPFromRequest(req: Request) {
|
||||
const ip = req.header('X-Real-IP') || req.header('X-Forwarded-For') || '0.0.0.0';
|
||||
return ip;
|
||||
}
|
||||
|
||||
|
||||
export function createSessionHash(website: string, ip: string, userAgent: string) {
|
||||
const dailySalt = new Date().toLocaleDateString('it-IT');
|
||||
const sessionClean = dailySalt + website + ip + userAgent;
|
||||
const sessionHash = crypto.createHash('md5').update(sessionClean).digest("hex");
|
||||
return sessionHash;
|
||||
}
|
||||
0
broker/.gitignore → consumer/.gitignore
vendored
0
broker/.gitignore → consumer/.gitignore
vendored
28
consumer/Dockerfile
Normal file
28
consumer/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
FROM node:21-alpine as base
|
||||
|
||||
RUN npm i -g pnpm
|
||||
|
||||
WORKDIR /home/app
|
||||
|
||||
COPY --link ./package.json ./tsconfig.json ./pnpm-lock.yaml ./
|
||||
COPY --link ./scripts/package.json ./scripts/pnpm-lock.yaml ./scripts/
|
||||
COPY --link ./consumer/package.json ./consumer/pnpm-lock.yaml ./consumer/
|
||||
|
||||
RUN pnpm install
|
||||
RUN pnpm install --filter consumer
|
||||
|
||||
WORKDIR /home/app/scripts
|
||||
RUN pnpm install
|
||||
|
||||
WORKDIR /home/app
|
||||
|
||||
COPY --link ../scripts ./scripts
|
||||
COPY --link ../shared ./shared
|
||||
COPY --link ../consumer ./consumer
|
||||
|
||||
WORKDIR /home/app/consumer
|
||||
|
||||
RUN pnpm run build
|
||||
|
||||
CMD ["node", "/home/app/consumer/dist/consumer/src/index.js"]
|
||||
21
consumer/ecosystem.config.example.cjs
Normal file
21
consumer/ecosystem.config.example.cjs
Normal file
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'consumer',
|
||||
port: '3031',
|
||||
exec_mode: 'cluster',
|
||||
instances: '2',
|
||||
script: './dist/consumer/src/index.js',
|
||||
env: {
|
||||
EMAIL_SERVICE: '',
|
||||
BREVO_API_KEY: '',
|
||||
MONGO_CONNECTION_STRING: '',
|
||||
REDIS_URL: "",
|
||||
REDIS_USERNAME: "",
|
||||
REDIS_PASSWORD: "",
|
||||
STREAM_NAME: "",
|
||||
GROUP_NAME: ''
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
28
consumer/package.json
Normal file
28
consumer/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"ua-parser-js": "^1.0.37"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.13",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"name": "consumer",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start_dev.js",
|
||||
"compile": "tsc",
|
||||
"build_project": "node ../scripts/build.js",
|
||||
"build": "npm run compile && npm run build_project && npm run create_db",
|
||||
"create_db": "cd scripts && ts-node create_database.ts",
|
||||
"docker-build": "docker build -t litlyx-consumer -f Dockerfile ../",
|
||||
"docker-inspect": "docker run -it litlyx-consumer sh"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Emily",
|
||||
"license": "MIT",
|
||||
"description": "Database Consumer - Saves events to database."
|
||||
}
|
||||
1498
consumer/pnpm-lock.yaml
generated
Normal file
1498
consumer/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
|
Can't render this file because it is too large.
|
@@ -1,9 +1,9 @@
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
import { LimitNotifyModel } from "@schema/broker/LimitNotifySchema";
|
||||
import EmailService from '@services/EmailService';
|
||||
import { requireEnv } from "../../shared/utilts/requireEnv";
|
||||
import { TProjectLimit } from "@schema/ProjectsLimits";
|
||||
import { requireEnv } from "@utils/requireEnv";
|
||||
import { TProjectLimit } from "@schema/project/ProjectsLimits";
|
||||
|
||||
if (process.env.EMAIL_SERVICE) {
|
||||
EmailService.init(requireEnv('BREVO_API_KEY'));
|
||||
@@ -19,8 +19,7 @@ export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
||||
|
||||
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit)) {
|
||||
|
||||
const notify = await LimitNotifyModel.findOne({ project_id });
|
||||
if (notify && notify.limit3 === true) return;
|
||||
if (hasNotifyEntry.limit3 === true) return;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return;
|
||||
@@ -33,8 +32,7 @@ export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
||||
|
||||
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.9)) {
|
||||
|
||||
const notify = await LimitNotifyModel.findOne({ project_id });
|
||||
if (notify && notify.limit2 === true) return;
|
||||
if (hasNotifyEntry.limit2 === true) return;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return;
|
||||
@@ -47,8 +45,7 @@ export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
||||
|
||||
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.5)) {
|
||||
|
||||
const notify = await LimitNotifyModel.findOne({ project_id });
|
||||
if (notify && notify.limit1 === true) return;
|
||||
if (hasNotifyEntry.limit1 === true) return;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return;
|
||||
15
consumer/src/LimitChecker.ts
Normal file
15
consumer/src/LimitChecker.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
|
||||
import { ProjectLimitModel } from '@schema/project/ProjectsLimits';
|
||||
import { MAX_LOG_LIMIT_PERCENT } from '@data/broker/Limits';
|
||||
import { checkLimitsForEmail } from './EmailController';
|
||||
|
||||
export async function checkLimits(project_id: string) {
|
||||
const projectLimits = await ProjectLimitModel.findOne({ project_id });
|
||||
if (!projectLimits) return false;
|
||||
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
|
||||
const COUNT_LIMIT = projectLimits.limit;
|
||||
if ((TOTAL_COUNT) > COUNT_LIMIT * MAX_LOG_LIMIT_PERCENT) return false;
|
||||
await checkLimitsForEmail(projectLimits);
|
||||
return true;
|
||||
}
|
||||
172
consumer/src/index.ts
Normal file
172
consumer/src/index.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
|
||||
import { requireEnv } from '@utils/requireEnv';
|
||||
import { connectDatabase } from '@services/DatabaseService';
|
||||
import { RedisStreamService } from '@services/RedisStreamService';
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { lookup } from './lookup';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { checkLimits } from './LimitChecker';
|
||||
import express from 'express';
|
||||
|
||||
import { ProjectLimitModel } from '@schema/project/ProjectsLimits';
|
||||
import { ProjectCountModel } from '@schema/project/ProjectsCounts';
|
||||
|
||||
|
||||
const app = express();
|
||||
|
||||
let durations: number[] = [];
|
||||
|
||||
app.get('/status', async (req, res) => {
|
||||
try {
|
||||
return res.json({ status: 'ALIVE', durations })
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
return res.setStatus(500).json({ error: ex.message });
|
||||
}
|
||||
})
|
||||
|
||||
app.listen(process.env.PORT);
|
||||
|
||||
connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
|
||||
main();
|
||||
|
||||
|
||||
|
||||
async function main() {
|
||||
|
||||
await RedisStreamService.connect();
|
||||
|
||||
const stream_name = requireEnv('STREAM_NAME');
|
||||
const group_name = requireEnv('GROUP_NAME') as any; // Checks are inside "startReadingLoop"
|
||||
|
||||
await RedisStreamService.startReadingLoop({
|
||||
stream_name, group_name, consumer_name: `CONSUMER_${process.env.NODE_APP_INSTANCE || 'DEFAULT'}`
|
||||
}, processStreamEntry);
|
||||
|
||||
}
|
||||
|
||||
async function processStreamEntry(data: Record<string, string>) {
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
|
||||
const eventType = data._type;
|
||||
if (!eventType) return;
|
||||
|
||||
const { pid, sessionHash } = data;
|
||||
|
||||
const project = await ProjectModel.exists({ _id: pid });
|
||||
if (!project) return;
|
||||
|
||||
const canLog = await checkLimits(pid);
|
||||
if (!canLog) return;
|
||||
|
||||
if (eventType === 'event') {
|
||||
await process_event(data, sessionHash);
|
||||
} else if (eventType === 'keep_alive') {
|
||||
await process_keep_alive(data, sessionHash);
|
||||
} else if (eventType === 'visit') {
|
||||
await process_visit(data, sessionHash);
|
||||
}
|
||||
|
||||
// console.log('Entry processed in', duration, 'ms');
|
||||
|
||||
} catch (ex: any) {
|
||||
console.error('ERROR PROCESSING STREAM EVENT', ex.message);
|
||||
}
|
||||
|
||||
const duration = Date.now() - start;
|
||||
|
||||
durations.push(duration);
|
||||
if (durations.length > 1000) {
|
||||
durations = durations.splice(500);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function process_visit(data: Record<string, string>, sessionHash: string) {
|
||||
|
||||
const { pid, ip, website, page, referrer, userAgent, flowHash, timestamp } = data;
|
||||
|
||||
let referrerParsed;
|
||||
try {
|
||||
referrerParsed = new URL(referrer);
|
||||
} catch (ex) {
|
||||
referrerParsed = { hostname: referrer };
|
||||
}
|
||||
|
||||
const geoLocation = lookup(ip);
|
||||
|
||||
const userAgentParsed = UAParser(userAgent);
|
||||
|
||||
const device = userAgentParsed.device.type;
|
||||
|
||||
await Promise.all([
|
||||
VisitModel.create({
|
||||
project_id: pid, website, page, referrer: referrerParsed.hostname,
|
||||
browser: userAgentParsed.browser.name || 'NO_BROWSER',
|
||||
os: userAgentParsed.os.name || 'NO_OS',
|
||||
device: device ? device : (userAgentParsed.browser.name ? 'desktop' : undefined),
|
||||
session: sessionHash,
|
||||
flowHash,
|
||||
continent: geoLocation[0],
|
||||
country: geoLocation[1],
|
||||
created_at: new Date(parseInt(timestamp))
|
||||
}),
|
||||
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } }, { upsert: true }),
|
||||
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } })
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
async function process_keep_alive(data: Record<string, string>, sessionHash: string) {
|
||||
|
||||
const { pid, instant, flowHash, timestamp } = data;
|
||||
|
||||
const existingSession = await SessionModel.findOne({ project_id: pid, session: sessionHash }, { _id: 1 });
|
||||
if (!existingSession) {
|
||||
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'sessions': 1 } }, { upsert: true });
|
||||
}
|
||||
|
||||
if (instant == "true") {
|
||||
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
||||
$inc: { duration: 0 },
|
||||
flowHash,
|
||||
updated_at: Date.now()
|
||||
}, { upsert: true });
|
||||
} else {
|
||||
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
||||
$inc: { duration: 1 },
|
||||
flowHash,
|
||||
updated_at: Date.now()
|
||||
}, { upsert: true });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function process_event(data: Record<string, string>, sessionHash: string) {
|
||||
|
||||
const { name, metadata, pid, flowHash, timestamp } = data;
|
||||
|
||||
let metadataObject;
|
||||
try {
|
||||
if (metadata) metadataObject = JSON.parse(metadata);
|
||||
} catch (ex) {
|
||||
metadataObject = { error: 'Error parsing metadata' }
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
EventModel.create({
|
||||
project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash,
|
||||
created_at: new Date(parseInt(timestamp))
|
||||
}),
|
||||
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true }),
|
||||
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } })
|
||||
]);
|
||||
|
||||
|
||||
}
|
||||
15
consumer/tsconfig.json
Normal file
15
consumer/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"target": "ESNext",
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -1,34 +1,31 @@
|
||||
ARG NODE_VERSION=21
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine as base
|
||||
FROM node:21-alpine AS base
|
||||
|
||||
ENV NODE_ENV=production
|
||||
FROM base AS build
|
||||
|
||||
# Build stage
|
||||
RUN npm i -g pnpm
|
||||
|
||||
WORKDIR /home/app
|
||||
|
||||
FROM base as build
|
||||
COPY --link ./package.json ./tsconfig.json ./pnpm-lock.yaml ./
|
||||
COPY --link ./dashboard/package.json ./dashboard/pnpm-lock.yaml ./dashboard/
|
||||
|
||||
COPY --link dashboard/package.json dashboard/pnpm-lock.yaml ./
|
||||
RUN npm install --production=false
|
||||
RUN pnpm install
|
||||
RUN pnpm install --filter dashboard
|
||||
|
||||
COPY --link dashboard/ ./
|
||||
WORKDIR /home/app
|
||||
|
||||
COPY --link shared/ /home/shared
|
||||
COPY --link ./dashboard ./dashboard
|
||||
COPY --link ./shared ./shared
|
||||
|
||||
ARG GOOGLE_AUTH_CLIENT_ID
|
||||
ENV GOOGLE_AUTH_CLIENT_ID=$GOOGLE_AUTH_CLIENT_ID
|
||||
WORKDIR /home/app/dashboard
|
||||
|
||||
RUN npm run build
|
||||
RUN npm prune
|
||||
RUN pnpm run build
|
||||
|
||||
# Final stage
|
||||
FROM node:21-alpine AS production
|
||||
|
||||
FROM base
|
||||
WORKDIR /home/app
|
||||
|
||||
COPY --from=build /home/app /home/app
|
||||
COPY --from=build /home/app/dashboard/.output /home/app/.output
|
||||
|
||||
EXPOSE ${PORT}
|
||||
|
||||
CMD [ "node", "/home/app/.output/server/index.mjs" ]
|
||||
CMD ["node", "/home/app/.output/server/index.mjs"]
|
||||
@@ -10,24 +10,24 @@ const { alerts, closeAlert } = useAlert();
|
||||
|
||||
const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dialogClosable } = useCustomDialog();
|
||||
|
||||
const { visible } = usePricingDrawer();
|
||||
const { drawerVisible, hideDrawer, drawerClasses } = useDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="w-dvw h-dvh bg-lyx-background-light relative">
|
||||
<div class="w-dvw h-dvh bg-lyx-lightmode-background-light dark:bg-lyx-background-light relative">
|
||||
|
||||
<Transition name="pdrawer">
|
||||
<LazyPricingDrawer @onCloseClick="visible = false"
|
||||
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if="visible">
|
||||
</LazyPricingDrawer>
|
||||
<Transition name="drawer">
|
||||
<LazyDrawerGeneric @onCloseClick="hideDrawer()" :class="drawerClasses"
|
||||
class="bg-lyx-lightmode-background-light dark:bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if="drawerVisible">
|
||||
</LazyDrawerGeneric>
|
||||
</Transition>
|
||||
|
||||
|
||||
<div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0">
|
||||
<div v-for="alert of alerts"
|
||||
class="w-[30vw] min-w-[20rem] relative bg-[#151515] overflow-hidden border-solid border-[2px] border-[#262626] rounded-lg p-6 drop-shadow-lg">
|
||||
class="w-[30vw] min-w-[20rem] relative bg-lyx-lightmode-background dark:bg-[#151515] overflow-hidden border-solid border-[2px] border-lyx-lightmode-widget dark:border-[#262626] rounded-lg p-6 drop-shadow-lg">
|
||||
<div class="flex items-start gap-4">
|
||||
<div> <i :class="alert.icon"></i> </div>
|
||||
<div class="grow">
|
||||
@@ -56,8 +56,8 @@ const { visible } = usePricingDrawer();
|
||||
</div>
|
||||
|
||||
<div v-if="showDialog"
|
||||
class="custom-dialog w-full h-full flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 z-[100] backdrop-blur-[2px] bg-black/50">
|
||||
<div :style="dialogStyle" class="bg-lyx-widget rounded-xl relative outline outline-1 outline-lyx-widget-lighter">
|
||||
class="custom-dialog w-full h-full flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 z-[100] backdrop-blur-[2px] dark:bg-black/50">
|
||||
<div :style="dialogStyle" class="bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget dark:bg-lyx-widget dark:outline-lyx-widget-lighter rounded-xl relative outline outline-1">
|
||||
<div v-if="dialogClosable" class="flex justify-end absolute z-[100] right-8 top-8">
|
||||
<i @click="closeDialog()" class="fas fa-close text-[1.6rem] hover:text-gray-500 cursor-pointer"></i>
|
||||
</div>
|
||||
@@ -67,6 +67,11 @@ const { visible } = usePricingDrawer();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<UModals />
|
||||
|
||||
<LazyOnboarding> </LazyOnboarding>
|
||||
|
||||
<NuxtLayout>
|
||||
<NuxtPage></NuxtPage>
|
||||
</NuxtLayout>
|
||||
@@ -75,18 +80,18 @@ const { visible } = usePricingDrawer();
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pdrawer-enter-active,
|
||||
.pdrawer-leave-active {
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: all .5s ease-in-out;
|
||||
}
|
||||
|
||||
.pdrawer-enter-from,
|
||||
.pdrawer-leave-to {
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
transform: translateX(100%)
|
||||
}
|
||||
|
||||
.pdrawer-enter-to,
|
||||
.pdrawer-leave-from {
|
||||
.drawer-enter-to,
|
||||
.drawer-leave-from {
|
||||
transform: translateX(0)
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
@use './utilities.scss';
|
||||
@use './colors.scss';
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;0,1000;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900;1,1000&display=swap');
|
||||
@import url('https://fonts.cdnfonts.com/css/brockmann');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
|
||||
|
||||
@import '../font-awesome/css/all.css';
|
||||
@import './utilities.scss';
|
||||
@import './colors.scss';
|
||||
|
||||
@import url('https://fonts.cdnfonts.com/css/geometric-sans-serif-v1');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap');
|
||||
@@ -19,6 +20,18 @@
|
||||
src: url("../fonts/GeistVF.ttf");
|
||||
}
|
||||
|
||||
|
||||
.actionable-visits-color-checkbox {
|
||||
color: #5655d7;
|
||||
}
|
||||
|
||||
.actionable-sessions-color-checkbox {
|
||||
color: #4abde8;
|
||||
}
|
||||
.actionable-events-color-checkbox {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.geist {
|
||||
font-family: "Geist";
|
||||
}
|
||||
@@ -72,10 +85,14 @@
|
||||
|
||||
|
||||
.hide-scrollbars {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none;
|
||||
/* IE and Edge */
|
||||
scrollbar-width: none;
|
||||
|
||||
/* Firefox */
|
||||
&::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
display: none;
|
||||
/* Chrome, Safari and Opera */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,4 +12,17 @@
|
||||
|
||||
.test3 {
|
||||
border: 3px solid green !important;
|
||||
}
|
||||
|
||||
|
||||
.bgtest {
|
||||
background-color: yellow;
|
||||
}
|
||||
|
||||
.bgtest2 {
|
||||
background-color: blue;
|
||||
}
|
||||
|
||||
.bgtest3 {
|
||||
background-color: green;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
export type IconProvider = (id: string) => ['img' | 'icon', string] | undefined;
|
||||
export type IconProvider = (e: { _id: string, count: string } & any) => ['img' | 'icon', string] | undefined;
|
||||
|
||||
|
||||
type Props = {
|
||||
@@ -54,7 +54,7 @@ function openExternalLink(link: string) {
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="poppins font-semibold text-[1.4rem] text-text">
|
||||
<div class="poppins font-semibold text-[1.4rem] text-lyx-lightmode-text dark:text-lyx-text">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
@@ -63,22 +63,26 @@ function openExternalLink(link: string) {
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="poppins text-[1rem] text-text-sub/90">
|
||||
<div class="poppins text-[1rem] text-lyx-ligtmode-text-darker dark:text-text-sub/90">
|
||||
{{ desc }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="rawButton" class="hidden lg:flex">
|
||||
<div @click="$emit('showRawData')"
|
||||
class="cursor-pointer flex gap-1 items-center justify-center font-semibold poppins rounded-lg text-[#5680f8] hover:text-[#5681f8ce]">
|
||||
<div> Raw data </div>
|
||||
<div class="flex items-center"> <i class="fas fa-arrow-up-right"></i> </div>
|
||||
</div>
|
||||
|
||||
<LyxUiButton @click="$emit('showRawData')" type="primary" class="h-fit">
|
||||
<div class="flex gap-1 items-center justify-center ">
|
||||
<div> Show raw data </div>
|
||||
<div class="flex items-center"> <i class="fas fa-arrow-up-right"></i> </div>
|
||||
</div>
|
||||
</LyxUiButton>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4">
|
||||
<div class="h-full flex flex-col">
|
||||
<div
|
||||
class="flex justify-between font-bold lyx-text-lightmode-text-dark dark:text-text-sub/80 text-[1.1rem] mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="isDetailView" class="flex items-center justify-center">
|
||||
<i @click="$emit('showGeneral')"
|
||||
@@ -104,37 +108,40 @@ function openExternalLink(link: string) {
|
||||
<div class="flex gap-1 items-center" @click="showDetails(element._id)"
|
||||
:class="{ 'cursor-pointer line-active': interactive }">
|
||||
|
||||
<div class="absolute rounded-sm w-full h-full bg-[#92abcf38]"
|
||||
<div class="absolute rounded-sm w-full h-full bg-[#6f829c38] dark:bg-[#92abcf38]"
|
||||
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
|
||||
|
||||
<div class="flex px-2 py-1 relative items-center gap-4">
|
||||
<div v-if="iconProvider && iconProvider(element._id) != undefined"
|
||||
<div v-if="iconProvider && iconProvider(element) != undefined"
|
||||
class="flex items-center h-[1.3rem]">
|
||||
|
||||
<img v-if="iconProvider(element._id)?.[0] == 'img'" class="h-full"
|
||||
:style="customIconStyle" :src="iconProvider(element._id)?.[1]">
|
||||
<img v-if="iconProvider(element)?.[0] == 'img'" class="h-full"
|
||||
:style="customIconStyle" :src="iconProvider(element)?.[1]">
|
||||
|
||||
<i v-else :class="iconProvider(element._id)?.[1]"></i>
|
||||
<i v-else :class="iconProvider(element)?.[1]"></i>
|
||||
</div>
|
||||
<span class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
|
||||
<span
|
||||
class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-lyx-lightmode-text-dark dark:text-text/70">
|
||||
{{ elementTextTransformer?.(element._id) || element._id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{
|
||||
formatNumberK(element.count) }} </div>
|
||||
<div
|
||||
class="text-lyx-lightmode-text dark:text-lyx-text font-semibold text-[.9rem] md:text-[1rem] manrope">
|
||||
{{
|
||||
formatNumberK(element.count) }} </div>
|
||||
</div>
|
||||
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-bold text-[1.1rem]">
|
||||
No visits yet
|
||||
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-light text-[1.1rem]">
|
||||
No data yet
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 ">
|
||||
<div @click="$emit('showMore')"
|
||||
class="poppins hover:bg-black cursor-pointer w-fit px-6 py-1 rounded-lg border-[1px] border-text-sub text-[.9rem]">
|
||||
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 items-end grow">
|
||||
|
||||
<LyxUiButton type="outline" @click="$emit('showMore')">
|
||||
Show more
|
||||
</div>
|
||||
</LyxUiButton>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
66
dashboard/components/BarCard/Browsers.vue
Normal file
66
dashboard/components/BarCard/Browsers.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { IconProvider } from './Base.vue';
|
||||
|
||||
function iconProvider(e: { _id: string, flag: string, count: number }): ReturnType<IconProvider> {
|
||||
let name = e._id.toLowerCase().replace(/ /g, '-');
|
||||
|
||||
if (name === 'mobile-safari') name = 'safari';
|
||||
if (name === 'chrome-headless') name = 'chrome'
|
||||
if (name === 'chrome-webview') name = 'chrome'
|
||||
|
||||
if (name === 'duckduckgo') return ['icon', 'far fa-duck']
|
||||
if (name === 'avast-secure-browser') return ['icon', 'far fa-bug']
|
||||
if (name === 'avg-secure-browser') return ['icon', 'far fa-bug']
|
||||
|
||||
if (name === 'no_browser') return ['icon', 'far fa-question']
|
||||
if (name === 'gsa') return ['icon', 'far fa-question']
|
||||
if (name === 'miui-browser') return ['icon', 'far fa-question']
|
||||
|
||||
if (name === 'vivo-browser') return ['icon', 'far fa-question']
|
||||
if (name === 'whale') return ['icon', 'far fa-question']
|
||||
|
||||
if (name === 'twitter') return ['icon', 'fab fa-twitter']
|
||||
if (name === 'linkedin') return ['icon', 'fab fa-linkedin']
|
||||
if (name === 'facebook') return ['icon', 'fab fa-facebook']
|
||||
|
||||
return [
|
||||
'img',
|
||||
`https://github.com/alrra/browser-logos/blob/main/src/${name}/${name}_256x256.png?raw=true`
|
||||
]
|
||||
}
|
||||
|
||||
const browsersData = useFetch('/api/data/browsers', {
|
||||
headers: useComputedHeaders({ limit: 10, }), lazy: true
|
||||
});
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
async function showMore() {
|
||||
dialogBarData.value = [];
|
||||
showDialog.value = true;
|
||||
isDataLoading.value = true;
|
||||
|
||||
const res = await $fetch('/api/data/browsers', {
|
||||
headers: useComputedHeaders({ limit: 1000 }).value
|
||||
});
|
||||
|
||||
dialogBarData.value = res?.map(e => {
|
||||
return { ...e, icon: iconProvider(e as any) }
|
||||
}) || [];
|
||||
|
||||
isDataLoading.value = false;
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<BarCardBase @showMore="showMore()" @dataReload="browsersData.refresh()" :data="browsersData.data.value || []"
|
||||
desc="The browsers most used to search your website." :dataIcons="true" :iconProvider="iconProvider"
|
||||
:loading="browsersData.pending.value" label="Browsers" sub-label="Browsers">
|
||||
</BarCardBase>
|
||||
</div>
|
||||
</template>
|
||||
54
dashboard/components/BarCard/Devices.vue
Normal file
54
dashboard/components/BarCard/Devices.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
import type { IconProvider } from './Base.vue';
|
||||
|
||||
function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> {
|
||||
if (e._id === 'desktop') return ['icon','far fa-desktop'];
|
||||
if (e._id === 'tablet') return ['icon','far fa-tablet'];
|
||||
if (e._id === 'mobile') return ['icon','far fa-mobile'];
|
||||
if (e._id === 'smarttv') return ['icon','far fa-tv'];
|
||||
if (e._id === 'console') return ['icon','far fa-game-console-handheld'];
|
||||
return ['icon', 'far fa-question']
|
||||
}
|
||||
|
||||
|
||||
function transform(data: { _id: string, count: number }[]) {
|
||||
console.log(data);
|
||||
return data.map(e => ({ ...e, _id: e._id == null ? 'unknown' : e._id }))
|
||||
}
|
||||
|
||||
const devicesData = useFetch('/api/data/devices', {
|
||||
headers: useComputedHeaders({ limit: 10, }), lazy: true,
|
||||
transform
|
||||
});
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
async function showMore() {
|
||||
dialogBarData.value = [];
|
||||
showDialog.value = true;
|
||||
isDataLoading.value = true;
|
||||
|
||||
const res = await $fetch('/api/data/devices', {
|
||||
headers: useComputedHeaders({ limit: 1000 }).value,
|
||||
});
|
||||
|
||||
|
||||
dialogBarData.value = transform(res || []);
|
||||
|
||||
isDataLoading.value = false;
|
||||
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<BarCardBase @showMore="showMore()" @dataReload="devicesData.refresh()" :data="devicesData.data.value || []"
|
||||
:iconProvider="iconProvider" :dataIcons="true" desc="The devices most used to access your website."
|
||||
:loading="devicesData.pending.value" label="Devices" sub-label="Devices"></BarCardBase>
|
||||
</div>
|
||||
</template>
|
||||
42
dashboard/components/BarCard/Events.vue
Normal file
42
dashboard/components/BarCard/Events.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goToView() {
|
||||
router.push('/dashboard/events');
|
||||
}
|
||||
|
||||
const eventsData = useFetch('/api/data/events', {
|
||||
headers: useComputedHeaders({
|
||||
limit: 10,
|
||||
}), lazy: true
|
||||
});
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
async function showMore() {
|
||||
dialogBarData.value=[];
|
||||
showDialog.value = true;
|
||||
isDataLoading.value = true;
|
||||
|
||||
const res = await $fetch('/api/data/events', {
|
||||
headers: useComputedHeaders({ limit: 1000 }).value
|
||||
});
|
||||
|
||||
dialogBarData.value = res || [];
|
||||
|
||||
isDataLoading.value = false;
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<BarCardBase @showMore="showMore()" @showRawData="goToView()"
|
||||
desc="Most frequent user events triggered in this project" @dataReload="eventsData.refresh()"
|
||||
:data="eventsData.data.value || []" :loading="eventsData.pending.value" label="Top Events"
|
||||
sub-label="Events" :rawButton="!isLiveDemo"></BarCardBase>
|
||||
</div>
|
||||
</template>
|
||||
59
dashboard/components/BarCard/Geolocations.vue
Normal file
59
dashboard/components/BarCard/Geolocations.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { IconProvider } from '../BarCard/Base.vue';
|
||||
|
||||
function iconProvider(e: { _id: string, flag: string, count: number }): ReturnType<IconProvider> {
|
||||
if (!e.flag) return ['icon', 'far fa-question']
|
||||
return [
|
||||
'img',
|
||||
`https://raw.githubusercontent.com/hampusborgos/country-flags/refs/heads/main/svg/${e.flag.toLowerCase()}.svg`
|
||||
// `https://raw.githubusercontent.com/hampusborgos/country-flags/main/png250px/${e.flag.toLowerCase()}.png`
|
||||
]
|
||||
}
|
||||
|
||||
const customIconStyle = `width: 2rem; padding: 1px;`
|
||||
|
||||
const geolocationData = useFetch('/api/data/countries', {
|
||||
headers: useComputedHeaders({ limit: 10, }), lazy: true,
|
||||
transform: (e) => {
|
||||
if (!e) return e;
|
||||
return e.map(k => {
|
||||
return { ...k, flag: k._id, _id: getCountryName(k._id) ?? k._id }
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
async function showMore() {
|
||||
dialogBarData.value = [];
|
||||
showDialog.value = true;
|
||||
isDataLoading.value = true;
|
||||
|
||||
const res = await $fetch('/api/data/countries', {
|
||||
headers: useComputedHeaders({ limit: 1000 }).value
|
||||
});
|
||||
|
||||
dialogBarData.value = res?.map(k => {
|
||||
return { ...k, flag: k._id, _id: getCountryName(k._id) ?? k._id }
|
||||
}).map(e => {
|
||||
return { ...e, icon: iconProvider(e) }
|
||||
}) || [];
|
||||
|
||||
isDataLoading.value = false;
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<BarCardBase @showMore="showMore()" @dataReload="geolocationData.refresh()"
|
||||
:data="geolocationData.data.value || []" :dataIcons="false" :loading="geolocationData.pending.value"
|
||||
label="Countries" sub-label="Countries" :iconProvider="iconProvider" :customIconStyle="customIconStyle"
|
||||
desc=" Lists the countries where users access your website.">
|
||||
</BarCardBase>
|
||||
</div>
|
||||
</template>
|
||||
36
dashboard/components/BarCard/OperatingSystems.vue
Normal file
36
dashboard/components/BarCard/OperatingSystems.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
const ossData = useFetch('/api/data/oss', {
|
||||
headers: useComputedHeaders({
|
||||
limit: 10,
|
||||
}), lazy: true
|
||||
});
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
|
||||
async function showMore() {
|
||||
dialogBarData.value=[];
|
||||
showDialog.value = true;
|
||||
isDataLoading.value = true;
|
||||
|
||||
const res = await $fetch('/api/data/oss', {
|
||||
headers: useComputedHeaders({ limit: 1000 }).value
|
||||
});
|
||||
|
||||
dialogBarData.value = res || [];
|
||||
|
||||
isDataLoading.value = false;
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<BarCardBase @showMore="showMore()" @dataReload="ossData.refresh()" :data="ossData.data.value || []"
|
||||
desc="The operating systems most commonly used by your website's visitors." :dataIcons="false"
|
||||
:loading="ossData.pending.value" label="OS" sub-label="OSs"></BarCardBase>
|
||||
</div>
|
||||
</template>
|
||||
53
dashboard/components/BarCard/Referrers.vue
Normal file
53
dashboard/components/BarCard/Referrers.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { IconProvider } from './Base.vue';
|
||||
|
||||
function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> {
|
||||
if (e._id === 'self') return ['icon', 'fas fa-link'];
|
||||
return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${e._id}&sz=64`]
|
||||
}
|
||||
|
||||
function elementTextTransformer(element: string) {
|
||||
if (element === 'self') return 'Direct Link';
|
||||
return element;
|
||||
}
|
||||
|
||||
const referrersData = useFetch('/api/data/referrers', {
|
||||
headers: useComputedHeaders({
|
||||
limit: 10,
|
||||
}), lazy: true
|
||||
});
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
async function showMore() {
|
||||
|
||||
dialogBarData.value = [];
|
||||
|
||||
showDialog.value = true;
|
||||
isDataLoading.value = true;
|
||||
|
||||
const res = await $fetch('/api/data/referrers', {
|
||||
headers: useComputedHeaders({ limit: 1000 }).value
|
||||
});
|
||||
|
||||
|
||||
dialogBarData.value = res?.map(e => {
|
||||
return { ...e, icon: iconProvider(e as any) }
|
||||
}) || [];
|
||||
|
||||
isDataLoading.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<BarCardBase @showMore="showMore()" :elementTextTransformer="elementTextTransformer"
|
||||
:iconProvider="iconProvider" @dataReload="referrersData.refresh()" :showLink=true
|
||||
:data="referrersData.data.value || []" :interactive="false" desc="Where users find your website."
|
||||
:dataIcons="true" :loading="referrersData.pending.value" label="Top Sources" sub-label="Referrers">
|
||||
</BarCardBase>
|
||||
</div>
|
||||
</template>
|
||||
57
dashboard/components/BarCard/Websites.vue
Normal file
57
dashboard/components/BarCard/Websites.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const currentWebsite = ref<string>("");
|
||||
|
||||
const websitesData = useFetch('/api/data/websites', {
|
||||
headers: useComputedHeaders({
|
||||
limit: 10,
|
||||
}), lazy: true
|
||||
});
|
||||
|
||||
const pagesData = useFetch('/api/data/websites_pages', {
|
||||
headers: useComputedHeaders({
|
||||
limit: 10,
|
||||
custom: {
|
||||
'x-website-name': currentWebsite
|
||||
}
|
||||
}), lazy: true
|
||||
});
|
||||
|
||||
|
||||
const isPagesView = ref<boolean>(false);
|
||||
|
||||
const currentData = computed(() => {
|
||||
return isPagesView.value ? pagesData : websitesData
|
||||
})
|
||||
|
||||
|
||||
async function showDetails(website: string) {
|
||||
currentWebsite.value = website;
|
||||
isPagesView.value = true;
|
||||
}
|
||||
|
||||
async function showGeneral() {
|
||||
websitesData.execute();
|
||||
isPagesView.value = false;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goToView() {
|
||||
router.push('/dashboard/visits');
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<BarCardBase :hideShowMore="true" @showGeneral="showGeneral()" @showRawData="goToView()"
|
||||
@dataReload="currentData.refresh()" @showDetails="showDetails" :data="currentData.data.value || []"
|
||||
:loading="currentData.pending.value" :label="isPagesView ? 'Top pages' : 'Top Domains'"
|
||||
:sub-label="isPagesView ? 'Page' : 'Domains'"
|
||||
:desc="isPagesView ? 'Most visited pages' : 'Most visited domains in this project'"
|
||||
:interactive="!isPagesView" :rawButton="!isLiveDemo" :isDetailView="isPagesView">
|
||||
</BarCardBase>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { TProject } from '@schema/ProjectSchema';
|
||||
import CreateSnapshot from './dialog/CreateSnapshot.vue';
|
||||
|
||||
export type Entry = {
|
||||
@@ -24,11 +23,24 @@ type Props = {
|
||||
sections: Section[]
|
||||
}
|
||||
|
||||
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const isDark = computed({
|
||||
get() {
|
||||
return colorMode.value === 'dark'
|
||||
},
|
||||
set() {
|
||||
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const route = useRoute();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { isAdmin } = useUserRoles();
|
||||
const loggedUser = useLoggedUser()
|
||||
const { userRoles, setLoggedUser } = useLoggedUser();
|
||||
const { projectList } = useProject();
|
||||
|
||||
const debugMode = process.dev;
|
||||
|
||||
@@ -57,7 +69,7 @@ const { createAlert } = useAlert()
|
||||
async function deleteSnapshot(close: () => any) {
|
||||
await $fetch("/api/snapshot/delete", {
|
||||
method: 'DELETE',
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }).value,
|
||||
body: JSON.stringify({
|
||||
id: snapshot.value._id.toString(),
|
||||
})
|
||||
@@ -72,11 +84,7 @@ 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(),
|
||||
}),
|
||||
headers: useComputedHeaders({ useSnapshotDates: false, custom: { 'x-snapshot-name': snapshot.value.name } }).value,
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
@@ -101,17 +109,6 @@ function onLogout() {
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
const { projects } = useProjectsList();
|
||||
const { data: guestProjects } = useGuestProjectsList()
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const selectorProjects = computed(() => {
|
||||
const result: TProject[] = [];
|
||||
if (projects.value) result.push(...projects.value);
|
||||
if (guestProjects.value) result.push(...guestProjects.value);
|
||||
return result;
|
||||
});
|
||||
|
||||
const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||
headers: computed(() => {
|
||||
return {
|
||||
@@ -120,27 +117,11 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||
})
|
||||
});
|
||||
|
||||
const selected = ref<TProject>(activeProject.value as TProject);
|
||||
watch(selected, () => {
|
||||
setActiveProject(selected.value._id.toString())
|
||||
})
|
||||
|
||||
const isPremium = computed(() => {
|
||||
return activeProject.value?.premium;
|
||||
})
|
||||
|
||||
function isProjectMine(owner?: string) {
|
||||
if (!owner) return false;
|
||||
if (!loggedUser.value?.logged) return;
|
||||
return loggedUser.value.id == owner;
|
||||
}
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="CVerticalNavigation h-full w-[20rem] bg-lyx-background flex shadow-[1px_0_10px_#000000] rounded-r-lg"
|
||||
<div class="CVerticalNavigation border-solid border-[#D9D9E0] dark:border-[#202020] border-r-[1px] h-full w-[20rem] bg-lyx-lightmode-background dark:bg-lyx-background flex shadow-[1px_0_10px_#000000]"
|
||||
:class="{
|
||||
'absolute top-0 w-full md:w-[20rem] z-[45] open': isOpen,
|
||||
'hidden lg:flex': !isOpen
|
||||
@@ -158,36 +139,7 @@ const pricingDrawer = usePricingDrawer();
|
||||
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
|
||||
<USelectMenu :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" class="w-full" v-if="selectorProjects" v-model="selected" :options="selectorProjects">
|
||||
|
||||
<template #option="{ option, active, selected }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
|
||||
</div>
|
||||
<div> {{ option.name }} {{ !isProjectMine(option.owner) ? '(Guest)' : '' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
|
||||
</div>
|
||||
<div>
|
||||
{{ activeProject?.name || '-' }}
|
||||
{{ !isProjectMine(activeProject?.owner?.toString()) ? '(Guest)' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
<ProjectSelector></ProjectSelector>
|
||||
|
||||
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
|
||||
<i @click="close()" class="fas fa-close"></i>
|
||||
@@ -196,69 +148,87 @@ const pricingDrawer = usePricingDrawer();
|
||||
</div>
|
||||
|
||||
|
||||
<NuxtLink to="/project_creation" v-if="projects && (projects.length < (maxProjects || 1))"
|
||||
class="flex items-center text-[.8rem] gap-1 justify-end pt-2 pr-2 text-lyx-text-dark hover:text-lyx-text cursor-pointer">
|
||||
<div><i class="fas fa-plus"></i></div>
|
||||
<div> Create new project </div>
|
||||
</NuxtLink>
|
||||
<LyxUiButton to="/project_creation" v-if="projectList && (projectList.length < (maxProjects || 1))"
|
||||
type="outlined" class="w-full py-1 mt-2 text-[.8rem]">
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<div><i class="fas fa-plus text-[.7rem]"></i></div>
|
||||
<div class="poppins"> New Project </div>
|
||||
</div>
|
||||
</LyxUiButton>
|
||||
|
||||
<LyxUiButton v-if="projectList && (projectList.length >= (maxProjects || 1))" type="outlined"
|
||||
class="w-full py-1 mt-2 text-[.7rem]">
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<div><i class="text-lyx-text-darker far fa-lock"></i></div>
|
||||
<div class="text-lyx-text-darker"> Projects limit reached </div>
|
||||
</div>
|
||||
</LyxUiButton>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="w-full flex-col px-2">
|
||||
|
||||
<div class="flex mb-2 items-center justify-between">
|
||||
<div class="flex mb-2 items-center justify-between text-lyx-lightmode-text dark:text-lyx-text">
|
||||
<div class="poppins text-[.8rem]">
|
||||
Snapshots
|
||||
</div>
|
||||
<div @click="openSnapshotDialog()"
|
||||
class="poppins text-[.8rem] px-2 rounded-lg outline outline-[2px] outline-lyx-widget-lighter cursor-pointer hover:bg-lyx-widget-lighter">
|
||||
<i class="far fa-plus"></i>
|
||||
Add
|
||||
|
||||
<div class="flex gap-2">
|
||||
<!-- <UTooltip text="Download report">
|
||||
<LyxUiButton @click="generatePDF()" type="outlined" class="!px-3 !py-1">
|
||||
<div><i class="far fa-download text-[.8rem]"></i></div>
|
||||
</LyxUiButton>
|
||||
</UTooltip> -->
|
||||
<UTooltip text="Create new snapshot">
|
||||
<LyxUiButton @click="openSnapshotDialog()" type="outlined" class="!px-3 !py-1">
|
||||
<div><i class="fas fa-plus text-[.8rem]"></i></div>
|
||||
</LyxUiButton>
|
||||
</UTooltip>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<USelectMenu :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" class="w-full" v-model="snapshot" :options="snapshotsItems">
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div :style="'background-color:' + snapshot?.color" class="w-2 h-2 rounded-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<USelectMenu :uiMenu="{
|
||||
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" class="w-full" v-model="snapshot" :options="snapshotsItems">
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div :style="'background-color:' + snapshot?.color" class="w-2 h-2 rounded-full">
|
||||
</div>
|
||||
<div class="poppins"> {{ snapshot?.name }} </div>
|
||||
</div>
|
||||
<div class="poppins"> {{ snapshot?.name }} </div>
|
||||
</div>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div :style="'background-color:' + option.color" class="w-2 h-2 rounded-full">
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div :style="'background-color:' + option.color" class="w-2 h-2 rounded-full">
|
||||
</div>
|
||||
<div class="poppins"> {{ option.name }} </div>
|
||||
</div>
|
||||
<div class="poppins"> {{ option.name }} </div>
|
||||
</div>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</div>
|
||||
|
||||
<div v-if="snapshot" class="flex flex-col text-[.8rem] mt-2">
|
||||
<div class="flex">
|
||||
<div class="grow poppins"> From:</div>
|
||||
<div class="poppins"> {{ new Date(snapshot.from).toLocaleString('it-IT').split(',')[0].trim() }}
|
||||
<div v-if="snapshot" class="flex flex-col text-[.7rem] mt-2">
|
||||
<div
|
||||
class="flex gap-1 items-center justify-center text-lyx-lightmode-text-dark dark:text-lyx-text-dark">
|
||||
<div class="poppins">
|
||||
{{ new Date(snapshot.from).toLocaleString().split(',')[0].trim() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="grow poppins"> To:</div>
|
||||
<div class="poppins"> {{ new Date(snapshot.to).toLocaleString('it-IT').split(',')[0].trim() }}
|
||||
<div class="poppins"> to </div>
|
||||
<div class="poppins">
|
||||
{{ new Date(snapshot.to).toLocaleString().split(',')[0].trim() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LyxUiButton @click="generatePDF()" type="secondary" class="w-full text-center mt-4">
|
||||
Download report
|
||||
</LyxUiButton>
|
||||
|
||||
<div class="mt-2" v-if="snapshot._id.toString().startsWith('default') === false">
|
||||
<div class="mt-2" v-if="('default' in snapshot == false)">
|
||||
<UPopover placement="bottom">
|
||||
<LyxUiButton type="danger" class="w-full text-center">
|
||||
Delete current snapshot
|
||||
@@ -279,9 +249,16 @@ const pricingDrawer = usePricingDrawer();
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex mt-4">
|
||||
<LyxUiButton type="outline" class="w-full text-center text-[.8rem]">
|
||||
Export report
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="bg-lyx-widget-lighter h-[2px] w-full"></div>
|
||||
<div class="bg-lyx-lightmode-widget dark:bg-[#202020] h-[1px] w-full"></div>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
|
||||
@@ -289,12 +266,12 @@ const pricingDrawer = usePricingDrawer();
|
||||
|
||||
<div v-for="entry of section.entries" :class="{ 'grow flex items-end': entry.grow }">
|
||||
|
||||
<div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))"
|
||||
class="bg-lyx-background cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
|
||||
<div v-if="(!entry.adminOnly || (userRoles.isAdmin.value && !isAdminHidden))"
|
||||
class="bg-lyx-lightmode-background text-lyx-lightmode-text-dark dark:bg-lyx-background dark:text-lyx-text-dark w-full cursor-pointer py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
|
||||
:class="{
|
||||
'!text-lyx-text-darker pointer-events-none': entry.disabled,
|
||||
'bg-lyx-background-lighter !text-lyx-text/90': route.path == (entry.to || '#'),
|
||||
'hover:bg-lyx-background-light hover:!text-lyx-text/90': route.path != (entry.to || '#'),
|
||||
'bg-lyx-lightmode-background-light !text-lyx-lightmode-text dark:bg-lyx-background-lighter dark:!text-lyx-text': route.path == (entry.to || '#'),
|
||||
'hover:bg-lyx-lightmode-background-light hover:!text-lyx-lightmode-text dark:hover:bg-lyx-background-light dark:hover:!text-lyx-text': route.path != (entry.to || '#'),
|
||||
}">
|
||||
|
||||
<NuxtLink @click="close() && entry.action?.()" :target="entry.external ? '_blank' : ''"
|
||||
@@ -305,7 +282,7 @@ const pricingDrawer = usePricingDrawer();
|
||||
<div class="manrope grow">
|
||||
{{ entry.label }}
|
||||
</div>
|
||||
<div v-if="entry.premiumOnly && !isPremium" class="flex items-center">
|
||||
<div v-if="entry.premiumOnly && !userRoles.isPremium.value" class="flex items-center">
|
||||
<i class="fal fa-lock"></i>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
@@ -317,34 +294,26 @@ const pricingDrawer = usePricingDrawer();
|
||||
</div>
|
||||
|
||||
<div class="grow"></div>
|
||||
<div class="bg-lyx-widget-lighter h-[2px] px-4 w-full mb-3"></div>
|
||||
|
||||
<div class="bg-lyx-lightmode-widget dark:bg-[#202020] h-[1px] w-full px-4 mb-3"></div>
|
||||
|
||||
<div class="flex justify-end px-2">
|
||||
|
||||
<div class="grow flex gap-3">
|
||||
<NuxtLink to="https://github.com/litlyx/litlyx" target="_blank"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fab fa-github"></i>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="https://discord.gg/9cQykjsmWX" target="_blank"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fab fa-discord"></i>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="https://x.com/litlyx" target="_blank"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fab fa-x-twitter"></i>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="https://dev.to/litlyx-org" target="_blank"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fab fa-dev"></i>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin" v-if="isAdmin"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fas fa-cat"></i>
|
||||
|
||||
<div>
|
||||
<i @click="isDark = !isDark" class="cursor-pointer hover:text-lyx-lightmode-text text-lyx-lightmode-text-dark dark:hover:text-lyx-text dark:text-lyx-text-dark"
|
||||
:class="isDark ? 'far fa-moon' : 'far fa-sun'"></i>
|
||||
</div>
|
||||
|
||||
<NuxtLink to="/admin" v-if="userRoles.isAdmin.value"
|
||||
class="cursor-pointer hover:text-lyx-lightmode-text text-lyx-lightmode-text-dark dark:hover:text-lyx-text dark:text-lyx-text-dark">
|
||||
<i class="far fa-cat"></i>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<UTooltip text="Logout" :popper="{ arrow: true, placement: 'top' }">
|
||||
<div @click="onLogout()" class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<div @click="onLogout()" class="cursor-pointer hover:text-lyx-lightmode-text text-lyx-lightmode-text-dark dark:hover:text-lyx-text dark:text-lyx-text-dark">
|
||||
<i class="far fa-arrow-right-from-bracket scale-x-[-100%]"></i>
|
||||
</div>
|
||||
</UTooltip>
|
||||
|
||||
@@ -6,20 +6,19 @@ const props = defineProps<{ title: string, sub?: string }>();
|
||||
|
||||
<template>
|
||||
<LyxUiCard>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-4 h-full">
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-col grow">
|
||||
<div class="poppins font-semibold text-[1rem] md:text-[1.3rem] text-text">
|
||||
<div class="poppins font-semibold text-[1rem] md:text-[1.3rem] text-lyx-lightmode-text-dark dark:text-text">
|
||||
{{ props.title }}
|
||||
</div>
|
||||
<div v-if="props.sub" class="poppins text-[.7rem] md:text-[1rem] text-text-sub">
|
||||
<div v-if="props.sub" class="poppins text-[.7rem] md:text-[1rem] text-lyx-lightmode-text-darker dark:text-text-sub">
|
||||
{{ props.sub }}
|
||||
</div>
|
||||
</div>
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<div class="h-full">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,9 @@ const activeTabIndex = ref<number>(0);
|
||||
<div>
|
||||
<div class="flex">
|
||||
<div v-for="(tab, index) of items" @click="activeTabIndex = index"
|
||||
class="px-6 pb-3 poppins font-medium text-lyx-text-darker border-b-[1px] border-lyx-text-darker" :class="{
|
||||
class="px-6 pb-3 poppins font-medium text-lyx-lightmode-text dark:text-lyx-text-darker border-b-[1px] border-lyx-text-darker" :class="{
|
||||
'!border-[#88A7FF] !text-[#88A7FF]': activeTabIndex === index,
|
||||
'hover:border-lyx-text-dark hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
|
||||
'hover:border-lyx-lightmode-text-dark hover:text-lyx-lightmode-text-dark/60 dark:hover:border-lyx-text-dark dark:hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
|
||||
}">
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
|
||||
212
dashboard/components/FirstInteraction.vue
Normal file
212
dashboard/components/FirstInteraction.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { project } = useProject();
|
||||
const { createAlert } = useAlert();
|
||||
|
||||
import 'highlight.js/styles/stackoverflow-dark.css';
|
||||
import hljs from 'highlight.js';
|
||||
import CardTitled from './CardTitled.vue';
|
||||
|
||||
import { Lit } from 'litlyx-js';
|
||||
|
||||
const props = defineProps<{
|
||||
firstInteraction: boolean,
|
||||
refreshInteraction: () => any
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
hljs.highlightAll();
|
||||
})
|
||||
|
||||
function copyProjectId() {
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
navigator.clipboard.writeText(project.value?._id?.toString() || '');
|
||||
Lit.event('no_visit_copy_id');
|
||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
|
||||
function copyScript() {
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
|
||||
|
||||
const createScriptText = () => {
|
||||
return [
|
||||
'<script defer ',
|
||||
`data-project="${project.value?._id}" `,
|
||||
'src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></',
|
||||
'script>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
Lit.event('no_visit_copy_script');
|
||||
navigator.clipboard.writeText(createScriptText());
|
||||
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
|
||||
const scriptText = computed(() => {
|
||||
return [
|
||||
`<script defer data-project="${project.value?._id.toString()}"`,
|
||||
`\nsrc="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js">\n<`,
|
||||
`/script>`
|
||||
].join('');
|
||||
})
|
||||
|
||||
|
||||
function reloadPage() {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div v-if="!firstInteraction && project" class="mt-[5vh] flex flex-col">
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
|
||||
<div class="mr-4 animate-pulse w-[1rem] h-[1rem] bg-accent rounded-full"> </div>
|
||||
<div class="text-lyx-lightmode-text dark:text-text/90 poppins text-[1.1rem] font-medium">
|
||||
Waiting for your first visit
|
||||
</div>
|
||||
<LyxUiButton class="ml-6" type="secondary" @click="reloadPage()">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="far fa-refresh"></i>
|
||||
<div> Refresh </div>
|
||||
</div>
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center justify-center mt-10">
|
||||
<div class="flex flex-col-reverse gap-6">
|
||||
|
||||
<div class="flex gap-6 xl:flex-row flex-col">
|
||||
|
||||
<div class="h-full w-full">
|
||||
<CardTitled class="h-full w-full xl:min-w-[500px] xl:h-[35rem]" title="Quick setup tutorial"
|
||||
sub="Quickly Set Up Litlyx in 30 Seconds!">
|
||||
|
||||
<div class="flex items-center justify-center h-full w-full">
|
||||
|
||||
<iframe class="w-full h-full min-h-[400px]"
|
||||
src="https://www.youtube.com/embed/LInFoNLJ-CI?si=a97HVXpXFDgFg2Yp" title="Litlyx"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
||||
</div>
|
||||
|
||||
</CardTitled>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<div class="w-full">
|
||||
<CardTitled title="Quick Integration"
|
||||
sub="Start tracking web analytics in one line. (works everywhere js is supported)">
|
||||
<div class="flex flex-col items-end gap-4">
|
||||
<div class="w-full xl:text-[1rem] text-[.8rem]">
|
||||
<pre>
|
||||
<code class="language-html rounded-md">{{ scriptText }}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<LyxUiButton type="secondary" @click="copyScript()">
|
||||
Copy
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</CardTitled>
|
||||
</div>
|
||||
|
||||
<div class="h-full w-full">
|
||||
<CardTitled class="h-full w-full" title="Project id"
|
||||
sub="This is the identifier for this project, used to forward data">
|
||||
<div class="flex items-center justify-between gap-4 mt-6">
|
||||
<div class="p-2 bg-lyx-lightmode-widget dark:bg-[#1c1b1b] rounded-md w-full">
|
||||
<div class="w-full text-[.9rem] dark:text-[#acacac]"> {{ project?._id }} </div>
|
||||
</div>
|
||||
<LyxUiButton type="secondary" @click="copyProjectId()"> Copy </LyxUiButton>
|
||||
</div>
|
||||
|
||||
</CardTitled>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<CardTitled class="w-full h-full" title="Documentation"
|
||||
sub="Learn how to use Litlyx in every tech stack">
|
||||
<template #header>
|
||||
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
|
||||
to="https://docs.litlyx.com">
|
||||
Visit documentation
|
||||
</LyxUiButton>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col items-end">
|
||||
<div class="justify-center w-full hidden xl:flex gap-3">
|
||||
<a href="https://docs.litlyx.com/techs/js" target="_blank">
|
||||
<img class="cursor-pointer" :src="'tech-icons/js.png'" alt="Litlyx-Javascript-Analytics">
|
||||
</a>
|
||||
<a href="https://docs.litlyx.com/techs/nuxt" target="_blank">
|
||||
<img class="cursor-pointer" :src="'tech-icons/nuxt.png'" alt="Litlyx-Nuxt-Analytics">
|
||||
</a>
|
||||
<a href="https://docs.litlyx.com/techs/next" target="_blank">
|
||||
<img class="cursor-pointer" :src="'tech-icons/next.png'" alt="Litlyx-Next-Analytics">
|
||||
</a>
|
||||
<a href="https://docs.litlyx.com/techs/react" target="_blank">
|
||||
<img class="cursor-pointer" :src="'tech-icons/react.png'" alt="Litlyx-React-Analytics">
|
||||
</a>
|
||||
<a href="https://docs.litlyx.com/techs/vue" target="_blank">
|
||||
<img class="cursor-pointer" :src="'tech-icons/vue.png'" alt="Litlyx-Vue-Analytics">
|
||||
</a>
|
||||
<a href="https://docs.litlyx.com/techs/angular" target="_blank">
|
||||
<img class="cursor-pointer" :src="'tech-icons/angular.png'" alt="Litlyx-Angular-Analytics">
|
||||
</a>
|
||||
<a href="https://docs.litlyx.com/techs/python" target="_blank">
|
||||
<img class="cursor-pointer" :src="'tech-icons/py.png'" alt="Litlyx-Python-Analytics">
|
||||
</a>
|
||||
<a href="https://docs.litlyx.com/techs/serverless" target="_blank">
|
||||
<img class="cursor-pointer" :src="'tech-icons/serverless.png'" alt="Litlyx-Serverless-Analytics">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</CardTitled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- <div class="flex justify-center gap-10 flex-col xl:flex-row items-center xl:items-stretch px-10">
|
||||
|
||||
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full">
|
||||
<div class="poppins font-semibold"> Copy your project_id: </div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div> <i @click="copyProjectId()" class="cursor-pointer hover:text-text far fa-copy"></i> </div>
|
||||
<div class="text-[.9rem] text-[#acacac]"> {{ activeProject?._id }} </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full xl:max-w-[40vw]">
|
||||
<div class="poppins font-semibold">
|
||||
Start logging visits in 1 click | Plug anywhere !
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div> <i @click="copyScript()" class="cursor-pointer hover:text-text far fa-copy"></i> </div>
|
||||
|
||||
<pre><code class="language-html">{{ scriptText }}</code></pre>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> -->
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
26
dashboard/components/LyxUi/Button.vue
Normal file
26
dashboard/components/LyxUi/Button.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
export type ButtonType = 'primary' | 'secondary' | 'outline' | 'outlined' | 'danger';
|
||||
|
||||
const props = defineProps<{ type: ButtonType, link?: string, target?: string, disabled?: boolean }>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink tag="div" :to="disabled ? '' : link" :target="target"
|
||||
class="poppins w-fit cursor-pointer px-4 py-1 rounded-md outline outline-[1px] text-lyx-lightmode-text dark:text-lyx-text"
|
||||
:class="{
|
||||
|
||||
'bg-[#85a3ff] hover:bg-[#9db5fc] outline-lyx-lightmode-widget-light dark:bg-lyx-primary-dark dark:outline-lyx-primary dark:hover:bg-lyx-primary-hover': type === 'primary',
|
||||
|
||||
'bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget dark:bg-lyx-widget-lighter hover:bg-lyx-lightmode-widget dark:outline-lyx-widget-lighter dark:hover:bg-lyx-widget-light': type === 'secondary',
|
||||
|
||||
'bg-lyx-transparent outline-lyx-lightmode-widget hover:bg-lyx-lightmode-widget-light dark:outline-lyx-widget-lighter dark:hover:bg-lyx-widget-light': (type === 'outline' || type === 'outlined'),
|
||||
|
||||
'bg-[#fcd1cb] hover:bg-[#f8c5be] dark:bg-lyx-danger-dark outline-lyx-danger dark:hover:bg-lyx-danger': type === 'danger',
|
||||
|
||||
'text-lyx-text !bg-lyx-widget !outline-lyx-widget-lighter !cursor-not-allowed': disabled === true,
|
||||
}">
|
||||
<slot></slot>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
10
dashboard/components/LyxUi/Card.vue
Normal file
10
dashboard/components/LyxUi/Card.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-fit h-fit rounded-md bg-lyx-lightmode-background outline-lyx-lightmode-widget dark:bg-lyx-widget dark:outline-lyx-background-lighter p-4 outline outline-[1px] ">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
const props = defineProps<{ placeholder?: string, modelValue: string }>();
|
||||
const props = defineProps<{ placeholder?: string, modelValue: string, type?: string }>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: "update:modelValue", value: string): void
|
||||
@@ -18,8 +18,7 @@ const handleChange = (event: Event) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input class="bg-lyx-widget-light text-lyx-text-dark poppins rounded-md outline outline-[1px] outline-lyx-widget-lighter" type="text"
|
||||
:placeholder="props.placeholder"
|
||||
:value="props.modelValue"
|
||||
@input="handleChange">
|
||||
<input
|
||||
class="bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget text-lyx-lightmode-text dark:bg-lyx-widget-light dark:text-lyx-text-dark poppins rounded-md outline outline-[1px] dark:outline-lyx-widget-lighter"
|
||||
:type="props.type ?? 'text'" :placeholder="props.placeholder" :value="props.modelValue" @input="handleChange">
|
||||
</template>
|
||||
175
dashboard/components/Onboarding.vue
Normal file
175
dashboard/components/Onboarding.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { data: needsOnboarding } = useFetch("/api/onboarding/exist", {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false, useTimeOffset: false })
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const analyticsList = [
|
||||
"I have no prior analytics tool",
|
||||
"Google Analytics 4",
|
||||
"Plausible",
|
||||
"Umami",
|
||||
"MixPanel",
|
||||
"Simple Analytics",
|
||||
"Matomo",
|
||||
"Fathom",
|
||||
"Adobe Analytics",
|
||||
"Other"
|
||||
]
|
||||
|
||||
const jobsList = [
|
||||
"Developer",
|
||||
"Marketing",
|
||||
"Product",
|
||||
"Startup founder",
|
||||
"Indie hacker",
|
||||
"Other",
|
||||
]
|
||||
|
||||
const selectedIndex = ref<number>(-1);
|
||||
const otherFieldVisisble = ref<boolean>(false);
|
||||
const otherText = ref<string>('');
|
||||
function selectIndex(index: number) {
|
||||
selectedIndex.value = index;
|
||||
otherFieldVisisble.value = index == analyticsList.length - 1;
|
||||
}
|
||||
|
||||
const selectedIndex2 = ref<number>(-1);
|
||||
const otherFieldVisisble2 = ref<boolean>(false);
|
||||
const otherText2 = ref<string>('');
|
||||
function selectIndex2(index: number) {
|
||||
selectedIndex2.value = index;
|
||||
otherFieldVisisble2.value = index == jobsList.length - 1;
|
||||
}
|
||||
|
||||
const page = ref<number>(0);
|
||||
|
||||
function onNextPage() {
|
||||
if (selectedIndex.value == -1) return;
|
||||
saveAnalyticsType();
|
||||
page.value = 1;
|
||||
}
|
||||
|
||||
function onFinish(skipped?: boolean) {
|
||||
if (skipped) return location.reload();
|
||||
if (selectedIndex2.value == -1) return;
|
||||
saveJobTitle();
|
||||
page.value = 2;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function saveAnalyticsType() {
|
||||
await $fetch('/api/onboarding/add', {
|
||||
headers: useComputedHeaders({
|
||||
useSnapshotDates: false, useTimeOffset: false,
|
||||
custom: { 'Content-Type': 'application/json' }
|
||||
}).value,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
analytics:
|
||||
selectedIndex.value == analyticsList.length - 1 ?
|
||||
otherText.value :
|
||||
analyticsList[selectedIndex.value]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function saveJobTitle() {
|
||||
|
||||
await $fetch('/api/onboarding/add', {
|
||||
headers: useComputedHeaders({
|
||||
useSnapshotDates: false, useTimeOffset: false,
|
||||
custom: { 'Content-Type': 'application/json' }
|
||||
}).value,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
job:
|
||||
selectedIndex2.value == jobsList.length - 1 ?
|
||||
otherText2.value :
|
||||
jobsList[selectedIndex2.value]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
const showOnboarding = computed(() => {
|
||||
if (route.path === '/login') return false;
|
||||
if (route.path === '/register') return false;
|
||||
if (needsOnboarding.value?.exist === false) return true;
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div v-if="showOnboarding" class="absolute top-0 left-0 w-full h-full z-[30] bg-black/80 flex justify-center">
|
||||
|
||||
|
||||
|
||||
<div v-if="page == 0" class="bg-lyx-lightmode-background-light dark:bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
|
||||
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
|
||||
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text mt-4">
|
||||
For the current project do you already have other Analytics tools implemented (e.g. GA4) or Litlyx is
|
||||
going to be your first/main analytics?
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 mt-8">
|
||||
<div v-for="(e, i) of analyticsList">
|
||||
<div @click="selectIndex(i)"
|
||||
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex == i }"
|
||||
class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
|
||||
{{ e }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<LyxUiInput v-if="otherFieldVisisble" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
|
||||
v-model="otherText"></LyxUiInput>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-center flex-col items-center">
|
||||
<LyxUiButton @click="onNextPage()" class="px-[8rem] py-2" :disabled="selectedIndex == -1"
|
||||
type="primary"> Next </LyxUiButton>
|
||||
<!-- <div class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div> -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div v-if="page == 1" class="bg-lyx-lightmode-background-light dark:bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
|
||||
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
|
||||
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text mt-4">
|
||||
What is your job title ?
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 mt-8">
|
||||
<div v-for="(e, i) of jobsList">
|
||||
<div @click="selectIndex2(i)"
|
||||
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex2 == i }"
|
||||
class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
|
||||
{{ e }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<LyxUiInput v-if="otherFieldVisisble2" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
|
||||
v-model="otherText2"></LyxUiInput>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-center flex-col items-center">
|
||||
<LyxUiButton @click="onFinish()" class="px-[8rem] py-2" :disabled="selectedIndex2 == -1" type="primary">
|
||||
Finish </LyxUiButton>
|
||||
<div @click="onFinish(true)" class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
55
dashboard/components/ProjectSelector.vue
Normal file
55
dashboard/components/ProjectSelector.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { TProject } from '@schema/project/ProjectSchema';
|
||||
|
||||
const { user } = useLoggedUser()
|
||||
|
||||
const { projectList, guestProjectList, allProjectList, actions, project } = useProject();
|
||||
|
||||
|
||||
function isProjectMine(owner?: string) {
|
||||
if (!owner) return false;
|
||||
if (!user.value) return false;
|
||||
if (!user.value.logged) return;
|
||||
return user.value.id == owner;
|
||||
}
|
||||
|
||||
function onChange(e: TProject) {
|
||||
actions.setActiveProject(e._id.toString());
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<USelectMenu :uiMenu="{
|
||||
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" class="w-full" v-if="allProjectList" @change="onChange" :value="project" :options="allProjectList">
|
||||
|
||||
<template #option="{ option, active, selected }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
|
||||
</div>
|
||||
<div> {{ option.name }} {{ !isProjectMine(option.owner) ? '(Guest)' : '' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
|
||||
</div>
|
||||
<div>
|
||||
{{ project?.name || '-' }}
|
||||
{{ !isProjectMine(project?.owner?.toString()) ? '(Guest)' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
type Props = {
|
||||
options: { label: string }[],
|
||||
options: { label: string, disabled?: boolean }[],
|
||||
currentIndex: number
|
||||
}
|
||||
|
||||
@@ -16,10 +16,13 @@ const emits = defineEmits<{
|
||||
|
||||
<template>
|
||||
|
||||
<div class="flex gap-2 border-[1px] border-lyx-widget-lighter p-1 md:p-2 rounded-xl bg-lyx-widget">
|
||||
<div @click="$emit('changeIndex', index)" v-for="(opt, index) of options"
|
||||
class="hover:bg-lyx-widget-lighter/60 select-btn-animated cursor-pointer rounded-lg poppins font-regular px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
|
||||
:class="{ 'bg-lyx-widget-lighter hover:!bg-lyx-widget-lighter': currentIndex == index }">
|
||||
<div class="flex gap-2 border-[1px] p-1 md:p-2 rounded-xl bg-lyx-lightmode-widget-light border-lyx-lightmode-widget dark:bg-lyx-widget dark:border-lyx-widget-lighter">
|
||||
<div @click="opt.disabled ? ()=>{}: $emit('changeIndex', index)" v-for="(opt, index) of options"
|
||||
class="hover:bg-lyx-lightmode-widget dark:hover:bg-lyx-widget-lighter/60 select-btn-animated cursor-pointer rounded-lg poppins font-regular px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
|
||||
:class="{
|
||||
'bg-lyx-lightmode-widget hover:!bg-lyx-lightmode-widget dark:bg-lyx-widget-lighter dark:hover:!bg-lyx-widget-lighter': currentIndex == index && !opt.disabled,
|
||||
'hover:!bg-lyx-lightmode-widget-light text-lyx-lightmode-widget dark:hover:!bg-lyx-widget !cursor-not-allowed dark:!text-lyx-widget-lighter': opt.disabled
|
||||
}">
|
||||
{{ opt.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
33
dashboard/components/banner/LimitsInfo.vue
Normal file
33
dashboard/components/banner/LimitsInfo.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
const limitsInfo = await useFetch("/api/project/limits_info", {
|
||||
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
|
||||
});
|
||||
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
function goToUpgrade() {
|
||||
showDrawer('PRICING');
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div v-if="limitsInfo.data.value && limitsInfo.data.value.limited"
|
||||
class="w-full bg-[#fbbf2422] p-4 rounded-lg text-[.9rem] flex items-center">
|
||||
<div class="flex flex-col grow">
|
||||
<div class="poppins font-semibold text-[#fbbf24]">
|
||||
Limit reached
|
||||
</div>
|
||||
<div class="poppins text-[#fbbf24]">
|
||||
Litlyx cannot receive new data as you reached your plan's limit. Resume all the great
|
||||
features and collect even more data with a higher plan.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
39
dashboard/components/banner/Offer.vue
Normal file
39
dashboard/components/banner/Offer.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
function goToUpgrade() {
|
||||
showDrawer('PRICING');
|
||||
}
|
||||
|
||||
const { project } = useProject()
|
||||
|
||||
const isPremium = computed(() => {
|
||||
return project.value?.premium ?? false;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div v-if="!isPremium" class="w-full bg-[#5680f822] p-4 rounded-lg text-[.9rem] flex items-center">
|
||||
<div class="flex flex-col grow">
|
||||
<div class="poppins font-semibold text-lyx-primary">
|
||||
Launch offer: 25% off forever with code <span class="text-white font-bold text-[1rem]">LIT25</span> at
|
||||
checkout
|
||||
from Acceleration Plan and beyond.
|
||||
</div>
|
||||
<!-- <div class="poppins text-lyx-primary">
|
||||
We're offering an exclusive 25% discount forever on all plans starting from the Acceleration
|
||||
Plan for our first 100 users who believe in our project.
|
||||
<br>
|
||||
Redeem Code: <span class="text-white font-bold text-[1rem]">LIT25</span> at checkout to
|
||||
claim your discount.
|
||||
</div> -->
|
||||
</div>
|
||||
<div>
|
||||
<LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,8 +3,25 @@ import { onMounted } from 'vue';
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
|
||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
registerChartComponents();
|
||||
|
||||
const errorData = ref<{ errored: boolean, text: string }>({ errored: false, text: '' })
|
||||
|
||||
|
||||
function createGradient(startColor: string) {
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext("2d");
|
||||
let gradient: any = `${startColor}22`;
|
||||
if (ctx) {
|
||||
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
gradient.addColorStop(0, `${startColor}99`);
|
||||
gradient.addColorStop(0.35, `${startColor}66`);
|
||||
gradient.addColorStop(1, `${startColor}22`);
|
||||
} else {
|
||||
console.warn('Cannot get context for gradient');
|
||||
}
|
||||
|
||||
return gradient;
|
||||
}
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
@@ -24,9 +41,12 @@ const chartOptions = ref<ChartOptions<'line'>>({
|
||||
color: '#CCCCCC22',
|
||||
// borderDash: [5, 10]
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
x: {
|
||||
ticks: { display: true },
|
||||
stacked: false,
|
||||
offset: false,
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
@@ -65,12 +85,32 @@ const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
|
||||
borderColor: '#5655d7',
|
||||
borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
hoverBackgroundColor: '#5655d7',
|
||||
hoverBorderColor: 'white',
|
||||
hoverBorderWidth: 2,
|
||||
segment: {
|
||||
borderColor(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return '#5655d7';
|
||||
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||
return '#5655d7'
|
||||
},
|
||||
borderDash(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return undefined;
|
||||
if (ctx.p1DataIndex == todayIndex - 1) return [3, 5];
|
||||
return undefined;
|
||||
},
|
||||
backgroundColor(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return createGradient('#5655d7');
|
||||
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||
return createGradient('#5655d7');
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Unique sessions',
|
||||
@@ -81,24 +121,25 @@ const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
|
||||
hoverBackgroundColor: '#4abde8',
|
||||
hoverBorderColor: '#4abde8',
|
||||
hoverBorderWidth: 2,
|
||||
type: 'bar'
|
||||
type: 'bar',
|
||||
// barThickness: 20,
|
||||
borderSkipped: ['bottom']
|
||||
},
|
||||
{
|
||||
label: 'Events',
|
||||
data: [],
|
||||
backgroundColor: ['#fbbf24'],
|
||||
borderColor: '#fbbf24',
|
||||
borderWidth: 2,
|
||||
hoverBackgroundColor: '#fbbf24',
|
||||
hoverBorderColor: '#fbbf24',
|
||||
hoverBorderWidth: 2,
|
||||
type: 'bubble',
|
||||
stack: 'combined'
|
||||
stack: 'combined',
|
||||
borderColor: ["#fbbf24"]
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
|
||||
|
||||
const externalTooltipElement = ref<null | HTMLDivElement>(null);
|
||||
@@ -107,6 +148,17 @@ function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'li
|
||||
const { chart, tooltip } = context;
|
||||
const tooltipEl = externalTooltipElement.value;
|
||||
|
||||
const currentIndex = tooltip.dataPoints[0].parsed.x;
|
||||
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (todayIndex && todayIndex >= 0) {
|
||||
if (currentIndex > todayIndex - 1) {
|
||||
if (!tooltipEl) return;
|
||||
return tooltipEl.style.opacity = '0';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
currentTooltipData.value.visits = (tooltip.dataPoints.find(e => e.datasetIndex == 0)?.raw) as number;
|
||||
currentTooltipData.value.sessions = (tooltip.dataPoints.find(e => e.datasetIndex == 1)?.raw) as number;
|
||||
currentTooltipData.value.events = ((tooltip.dataPoints.find(e => e.datasetIndex == 2)?.raw) as any)?.r2 as number;
|
||||
@@ -119,94 +171,96 @@ function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'li
|
||||
return;
|
||||
}
|
||||
const { left: positionX, top: positionY } = chart.canvas.getBoundingClientRect();
|
||||
|
||||
|
||||
const xSwap = tooltip.caretX > (window.innerWidth * 0.5) ? -450 : -100;
|
||||
|
||||
tooltipEl.style.opacity = '1';
|
||||
tooltipEl.style.left = positionX + tooltip.caretX + 'px';
|
||||
|
||||
tooltipEl.style.left = positionX + (tooltip.caretX + xSwap) + 'px';
|
||||
|
||||
tooltipEl.style.top = positionY + tooltip.caretY + 'px';
|
||||
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px';
|
||||
|
||||
}
|
||||
|
||||
const { snapshotDuration } = useSnapshot();
|
||||
|
||||
const selectLabels: { label: string, value: Slice }[] = [
|
||||
{ label: 'Hour', value: 'hour' },
|
||||
{ label: 'Day', value: 'day' },
|
||||
{ label: 'Month', value: 'month' },
|
||||
];
|
||||
|
||||
const selectLabelsAvailable = computed<{ label: string, value: Slice, disabled: boolean }[]>(() => {
|
||||
return selectLabels.map(e => {
|
||||
return { ...e, disabled: !DateService.canUseSliceFromDays(snapshotDuration.value, e.value)[0] }
|
||||
});
|
||||
})
|
||||
|
||||
const selectedSlice = computed<Slice>(() => {
|
||||
const targetValue = selectLabelsAvailable.value[selectedLabelIndex.value];
|
||||
if (!targetValue) return 'day';
|
||||
if (targetValue.disabled) {
|
||||
selectedLabelIndex.value = selectLabelsAvailable.value.findIndex(e => !e.disabled);
|
||||
}
|
||||
return selectLabelsAvailable.value[selectedLabelIndex.value].value
|
||||
});
|
||||
|
||||
const selectedLabelIndex = ref<number>(1);
|
||||
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const allDatesFull = ref<string[]>([]);
|
||||
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
const data = input.map(e => e.count);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, selectLabels[selectedLabelIndex.value].value));
|
||||
allDatesFull.value = input.map(e => e._id.toString());
|
||||
return { data, labels }
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice.value));
|
||||
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
|
||||
|
||||
const todayIndex = input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
|
||||
|
||||
return { data, labels, todayIndex }
|
||||
}
|
||||
|
||||
const body = computed(() => {
|
||||
return {
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to,
|
||||
slice: selectLabels[selectedLabelIndex.value].value
|
||||
}
|
||||
function onResponseError(e: any) {
|
||||
let message = e.response._data.message ?? 'Generic error';
|
||||
if (message == 'internal server error') message = 'Please change slice';
|
||||
errorData.value = { errored: true, text: message }
|
||||
}
|
||||
|
||||
function onResponse(e: any) {
|
||||
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
|
||||
}
|
||||
|
||||
|
||||
const visitsData = useFetch('/api/timeline/visits', {
|
||||
headers: useComputedHeaders({ slice: selectedSlice }), lazy: true,
|
||||
transform: transformResponse, onResponseError, onResponse
|
||||
});
|
||||
|
||||
|
||||
const visitsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
const sessionsData = useFetch('/api/timeline/sessions', {
|
||||
headers: useComputedHeaders({ slice: selectedSlice }), lazy: true,
|
||||
transform: transformResponse, onResponseError, onResponse
|
||||
});
|
||||
|
||||
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
const eventsData = useFetch('/api/timeline/events', {
|
||||
headers: useComputedHeaders({ slice: selectedSlice }), lazy: true,
|
||||
transform: transformResponse, onResponseError, onResponse
|
||||
});
|
||||
|
||||
const sessionsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/sessions`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
});
|
||||
|
||||
|
||||
const readyToDisplay = computed(() => {
|
||||
return !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value;
|
||||
});
|
||||
const readyToDisplay = computed(() => !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value);
|
||||
|
||||
watch(readyToDisplay, () => {
|
||||
if (readyToDisplay.value === true) onDataReady();
|
||||
})
|
||||
|
||||
|
||||
function createGradient(startColor: string) {
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext("2d");
|
||||
let gradient: any = `${startColor}22`;
|
||||
if (ctx) {
|
||||
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
gradient.addColorStop(0, `${startColor}99`);
|
||||
gradient.addColorStop(0.35, `${startColor}66`);
|
||||
gradient.addColorStop(1, `${startColor}22`);
|
||||
} else {
|
||||
console.warn('Cannot get context for gradient');
|
||||
}
|
||||
|
||||
return gradient;
|
||||
}
|
||||
|
||||
function onDataReady() {
|
||||
console.log('DATA READY');
|
||||
|
||||
if (!visitsData.data.value) return;
|
||||
if (!eventsData.data.value) return;
|
||||
if (!sessionsData.data.value) return;
|
||||
|
||||
console.log('DATA READY 2');
|
||||
|
||||
chartData.value.labels = visitsData.data.value.labels;
|
||||
|
||||
const maxChartY = Math.max(...visitsData.data.value.data, ...sessionsData.data.value.data);
|
||||
@@ -214,18 +268,34 @@ function onDataReady() {
|
||||
|
||||
chartData.value.datasets[0].data = visitsData.data.value.data;
|
||||
chartData.value.datasets[1].data = sessionsData.data.value.data;
|
||||
|
||||
chartData.value.datasets[2].data = eventsData.data.value.data.map(e => {
|
||||
const rValue = 25 / maxEventSize * e;
|
||||
return { x: 0, y: maxChartY + 70, r: isNaN(rValue) ? 0 : rValue, r2: e }
|
||||
const rValue = 20 / maxEventSize * e;
|
||||
return { x: 0, y: maxChartY + 20, r: isNaN(rValue) ? 0 : rValue, r2: e }
|
||||
});
|
||||
|
||||
|
||||
chartData.value.datasets[0].backgroundColor = [createGradient('#5655d7')];
|
||||
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
|
||||
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
|
||||
|
||||
console.log('UPDATE CHART');
|
||||
updateChart();
|
||||
|
||||
(chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => {
|
||||
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||
if (i == todayIndex - 1) return true;
|
||||
return 'bottom';
|
||||
});
|
||||
|
||||
chartData.value.datasets[2].borderColor = eventsData.data.value.data.map((e, i) => {
|
||||
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||
if (i == todayIndex - 1) return '#fbbf2400';
|
||||
return '#fbbf24';
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
updateChart();
|
||||
}
|
||||
|
||||
const currentTooltipData = ref<{ visits: number, events: number, sessions: number, date: string }>({
|
||||
@@ -238,23 +308,16 @@ const currentTooltipData = ref<{ visits: number, events: number, sessions: numbe
|
||||
const tooltipNameIndex = ['visits', 'sessions', 'events'];
|
||||
|
||||
function onLegendChange(dataset: any, index: number, checked: any) {
|
||||
dataset.hidden = !checked;
|
||||
const newValue = !checked;
|
||||
dataset.hidden = newValue;
|
||||
}
|
||||
|
||||
const legendColors = [
|
||||
'#5655d7',
|
||||
'#4abde8',
|
||||
'#fbbf24'
|
||||
]
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
visitsData.execute();
|
||||
eventsData.execute();
|
||||
sessionsData.execute();
|
||||
});
|
||||
|
||||
|
||||
const legendColors = ref<string[]>(['#5655d7', '#4abde8', '#fbbf24'])
|
||||
const legendClasses = ref<string[]>([
|
||||
'actionable-visits-color-checkbox',
|
||||
'actionable-sessions-color-checkbox',
|
||||
'actionable-events-color-checkbox'
|
||||
])
|
||||
|
||||
|
||||
</script>
|
||||
@@ -262,23 +325,25 @@ onMounted(async () => {
|
||||
<template>
|
||||
<CardTitled title="Trend chart" sub="Easily match Visits, Unique sessions and Events trends." class="w-full">
|
||||
<template #header>
|
||||
<SelectButton class="w-fit" @changeIndex="selectedLabelIndex = $event"
|
||||
:currentIndex="selectedLabelIndex" :options="selectLabels">
|
||||
</SelectButton>
|
||||
<SelectButton class="w-fit" @changeIndex="selectedLabelIndex = $event" :currentIndex="selectedLabelIndex"
|
||||
:options="selectLabelsAvailable">
|
||||
</SelectButton>
|
||||
</template>
|
||||
|
||||
<div class="flex gap-6 w-full justify-between">
|
||||
<LyxUiButton type="secondary" to="/analyst">
|
||||
<div class="flex gap-6 w-full justify-between lg:flex-row flex-col">
|
||||
<LyxUiButton type="secondary" :to="isLiveDemo ? '#' : '/analyst'" :disabled="isLiveDemo">
|
||||
<div class="flex items-center gap-2 px-10">
|
||||
<i class="far fa-sparkles text-yellow-400"></i>
|
||||
<div class="poppins text-lyx-text"> Ask AI </div>
|
||||
<i class="far fa-sparkles text-yellow-600 dark:text-yellow-400"></i>
|
||||
<div class="poppins text-lyx-lightmode-text dark:text-lyx-text"> Ask AI </div>
|
||||
</div>
|
||||
</LyxUiButton>
|
||||
<div class="flex gap-6">
|
||||
<div v-for="(dataset, index) of chartData.datasets" class="flex gap-2 items-center text-[.9rem]">
|
||||
|
||||
<UCheckbox :ui="{
|
||||
color: `text-[${legendColors[index]}]`
|
||||
color: legendClasses[index]
|
||||
}" :model-value="true" @change="onLegendChange(dataset, index, $event)"></UCheckbox>
|
||||
|
||||
<label class="mt-[2px]"> {{ dataset.label }} </label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,7 +352,7 @@ onMounted(async () => {
|
||||
|
||||
|
||||
<div id='external-tooltip' ref="externalTooltipElement" class="z-[400]">
|
||||
<LyxUiCard>
|
||||
<LyxUiCard class="text-lyx-lightmode-text dark:text-lyx-text">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div> Date: </div>
|
||||
<div v-if="currentTooltipData"> {{ currentTooltipData.date }}</div>
|
||||
@@ -310,9 +375,14 @@ onMounted(async () => {
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end" v-if="readyToDisplay">
|
||||
<div class="flex flex-col items-end" v-if="readyToDisplay && !errorData.errored">
|
||||
<LineChart ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
|
||||
</div>
|
||||
|
||||
<div v-if="errorData.errored" class="flex items-center justify-center py-8">
|
||||
{{ errorData.text }}
|
||||
</div>
|
||||
|
||||
</CardTitled>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const isShowMore = ref<boolean>(false);
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: isShowMore.value === true ? '200' : '10'
|
||||
}
|
||||
});
|
||||
|
||||
const browsersData = useFetch(`/api/metrics/${activeProject.value?._id}/data/browsers`, {
|
||||
method: 'POST', headers, lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
|
||||
function showMore() {
|
||||
isShowMore.value = true;
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = browsersData.data.value || [];
|
||||
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
browsersData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="browsersData.refresh()"
|
||||
:data="browsersData.data.value || []" desc="The browsers most used to search your website."
|
||||
:dataIcons="false" :loading="browsersData.pending.value" label="Top Browsers" sub-label="Browsers">
|
||||
</DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,13 +5,19 @@ const props = defineProps<{
|
||||
value: string,
|
||||
text: string,
|
||||
avg?: string,
|
||||
trend?: number,
|
||||
color: string,
|
||||
data?: number[],
|
||||
labels?: string[],
|
||||
ready?: boolean
|
||||
ready?: boolean,
|
||||
slow?: boolean,
|
||||
todayIndex: number,
|
||||
tooltipText: string
|
||||
}>();
|
||||
|
||||
const { snapshotDuration } = useSnapshot()
|
||||
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,35 +25,34 @@ const props = defineProps<{
|
||||
<LyxUiCard class="flex !p-0 flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
|
||||
<div v-if="ready" class="flex p-4 items-start">
|
||||
<div class="flex items-center mt-2 mr-4">
|
||||
<i :style="`color: ${props.color}`" :class="icon" class="text-[1.6rem] 2xl:text-[2rem]"></i>
|
||||
<i :style="`color: ${props.color}`" :class="icon" class="text-[1.3rem] 2xl:text-[1.5rem]"></i>
|
||||
</div>
|
||||
<div class="flex flex-col grow">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="brockmann text-text-dirty text-[1.6rem] 2xl:text-[1.9rem]"> {{ value }} </div>
|
||||
<div class="poppins text-text-sub text-[.7rem] 2xl:text-[.85rem] mb-2"> {{ avg }} </div>
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.9rem] 2xl:text-base"> {{ text }} </div>
|
||||
</div>
|
||||
<div v-if="trend" class="flex flex-col items-center gap-1">
|
||||
<div class="flex items-center gap-3 rounded-xl px-2 py-1" :style="`background-color: ${props.color}33`">
|
||||
<i :class="trend > 0 ? 'fa-arrow-trend-up' : 'fa-arrow-trend-down'"
|
||||
class="far text-[.9rem] 2xl:text-[1rem]" :style="`color: ${props.color}`"></i>
|
||||
<div :style="`color: ${props.color}`" class="font-semibold text-[.75rem] 2xl:text-[.875rem]">
|
||||
{{ trend.toFixed(0) }} %
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="brockmann text-lyx-lightmode-text-dark dark:text-text-dirty text-[1.2rem] 2xl:text-[1.4rem]">
|
||||
{{ value }}
|
||||
</div>
|
||||
<div class="poppins text-lyx-lightmode-darker dark:text-text-sub text-[.65rem] 2xl:text-[.8rem]"> {{ avg }} </div>
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.7rem]"> Trend </div>
|
||||
<div class="poppins text-lyx-lightmode-darker dark:text-text-sub text-[.9rem] 2xl:text-[1rem]"> {{ text }} </div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<UTooltip :text="props.tooltipText">
|
||||
<i class="far fa-info-circle text-lyx-text-darker text-[1rem]"></i>
|
||||
</UTooltip>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end"
|
||||
v-if="((props.data?.length || 0) > 0) && ready">
|
||||
<DashboardEmbedChartCard v-if="ready" :data="props.data || []" :labels="props.labels || []"
|
||||
:color="props.color">
|
||||
<DashboardEmbedChartCard v-if="ready" :todayIndex="todayIndex" :data="props.data || []"
|
||||
:labels="props.labels || []" :color="props.color">
|
||||
</DashboardEmbedChartCard>
|
||||
</div>
|
||||
<div v-if="!ready" class="flex justify-center items-center w-full h-full">
|
||||
<div v-if="!ready" class="flex justify-center items-center w-full h-full flex-col gap-2">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
<div v-if="props.slow"> Can be very slow on large snapshots </div>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const props = defineProps<{
|
||||
icon: string,
|
||||
title: string,
|
||||
text: string,
|
||||
sub: string,
|
||||
color: string
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full lg:w-[20rem] relative pb-2 lg:pb-4">
|
||||
|
||||
<!-- <div class="absolute flex items-center justify-center right-4 top-4 cursor-pointer hover:text-blue-400">
|
||||
<i class="fal fa-info-circle text-[.9rem] lg:text-[1.4rem]"></i>
|
||||
</div> -->
|
||||
|
||||
<div class="gap-4 flex flex-row items-center lg:items-start lg:gap-2 lg:flex-col">
|
||||
<div class="w-[2.5rem] h-[2.5rem] lg:w-[3.5rem] lg:h-[3.5rem] flex items-center justify-center rounded-lg"
|
||||
:style="`background: ${props.color}`">
|
||||
<i :class="icon" class="text-[1rem] lg:text-[1.5rem]"></i>
|
||||
</div>
|
||||
<div class="text-[1rem] lg:text-[1.3rem] text-text-sub/90 poppins">
|
||||
{{ title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center lg:items-end">
|
||||
<div class="brockmann text-text text-[2rem] lg:text-[2.8rem] grow">
|
||||
{{ text }}
|
||||
</div>
|
||||
<div class="poppins text-text-sub/90 text-[.9rem] lg:text-[1rem]"> {{ sub }} </div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -1,46 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const isShowMore = ref<boolean>(false);
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: isShowMore.value === true ? '200' : '10'
|
||||
}
|
||||
});
|
||||
|
||||
const devicesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/devices`, {
|
||||
method: 'POST', headers, lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
|
||||
function showMore() {
|
||||
isShowMore.value = true;
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = devicesData.data.value || [];
|
||||
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
devicesData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="devicesData.refresh()" :data="devicesData.data.value || []" :dataIcons="false"
|
||||
desc="The devices most used to access your website." :loading="devicesData.pending.value" label="Top Devices"
|
||||
sub-label="Devices"></DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,8 +13,8 @@ const columns = [
|
||||
|
||||
<template>
|
||||
|
||||
<div class="w-full h-full bg-bg rounded-xl p-8">
|
||||
<div class="full h-full overflow-y-auto">
|
||||
<div class="w-full h-full bg-lyx-lightmode-background dark:bg-lyx-background-light rounded-xl p-8">
|
||||
<div class="full h-full overflow-y-auto text-lyx-lightmode-text dark:text-lyx-text">
|
||||
<UTable :columns="columns" :rows="dialogBarData" :loading="isDataLoading" v-if="dialogBarData">
|
||||
<template #count-data="{ row }">
|
||||
<div class="font-bold"> {{ formatNumberK(row.count) }} </div>
|
||||
|
||||
@@ -7,8 +7,10 @@ const props = defineProps<{
|
||||
data: any[],
|
||||
labels: string[]
|
||||
color: string,
|
||||
todayIndex: number
|
||||
}>();
|
||||
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -48,10 +50,22 @@ const chartData = ref<ChartData<'line'>>({
|
||||
data: props.data,
|
||||
backgroundColor: [props.color + '77'],
|
||||
borderColor: props.color,
|
||||
borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
pointRadius: 0
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
segment: {
|
||||
borderColor(ctx, options) {
|
||||
if (!props.todayIndex || props.todayIndex == -1) return props.color;
|
||||
if (ctx.p1DataIndex >= props.todayIndex) return props.color + '00';
|
||||
return props.color;
|
||||
},
|
||||
borderDash(ctx, options) {
|
||||
if (!props.todayIndex || props.todayIndex == -1) return undefined;
|
||||
if (ctx.p1DataIndex == props.todayIndex -1) return [2, 4];
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goToView() {
|
||||
router.push('/dashboard/events');
|
||||
}
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const isShowMore = ref<boolean>(false);
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: isShowMore.value === true ? '200' : '10'
|
||||
}
|
||||
});
|
||||
|
||||
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/data/events`, {
|
||||
method: 'POST', headers, lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
function showMore() {
|
||||
isShowMore.value = true;
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = eventsData.data.value || [];
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
eventsData.execute();
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<DashboardBarsCard @showMore="showMore()" @showRawData="goToView()"
|
||||
desc="Most frequent user events triggered in this project" @dataReload="eventsData.refresh()"
|
||||
:data="eventsData.data.value || []" :loading="eventsData.pending.value" label="Top Events"
|
||||
sub-label="Events" :rawButton="!isLiveDemo()"></DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -27,7 +27,6 @@ const chartOptions = ref<ChartOptions<'doughnut'>>({
|
||||
position: 'top',
|
||||
align: 'center',
|
||||
labels: {
|
||||
color: 'white',
|
||||
font: {
|
||||
family: 'Poppins',
|
||||
size: 16
|
||||
@@ -77,7 +76,7 @@ const chartData = ref<ChartData<'doughnut'>>({
|
||||
|
||||
const { doughnutChartProps, doughnutChartRef } = useDoughnutChart({ chartData: chartData, options: chartOptions });
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const { projectId } = useProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot();
|
||||
|
||||
@@ -98,17 +97,8 @@ function transformResponse(input: CustomEventsAggregated[]) {
|
||||
}
|
||||
}
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: "10"
|
||||
}
|
||||
});
|
||||
|
||||
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/data/events`, {
|
||||
method: 'POST', headers, lazy: true, immediate: false, transform: transformResponse
|
||||
const eventsData = useFetch(`/api/data/events`, {
|
||||
headers: useComputedHeaders({ limit: 6 }), lazy: true, immediate: false, transform: transformResponse
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { IconProvider } from './BarsCard.vue';
|
||||
|
||||
function iconProvider(id: string): ReturnType<IconProvider> {
|
||||
if (id === 'self') return ['icon', 'fas fa-link'];
|
||||
return [
|
||||
'img',
|
||||
`https://raw.githubusercontent.com/hampusborgos/country-flags/main/png250px/${id.toLowerCase()}.png`
|
||||
]
|
||||
}
|
||||
|
||||
const customIconStyle = `width: 2rem; padding: 1px;`
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const isShowMore = ref<boolean>(false);
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: isShowMore.value === true ? '200' : '10'
|
||||
}
|
||||
});
|
||||
|
||||
const geolocationData = useFetch(`/api/metrics/${activeProject.value?._id}/data/countries`, {
|
||||
method: 'POST', headers, lazy: true, immediate: false
|
||||
});
|
||||
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
function showMore() {
|
||||
|
||||
isShowMore.value = true;
|
||||
showDialog.value = true;
|
||||
|
||||
dialogBarData.value = geolocationData.data.value?.map(e => {
|
||||
return { ...e, icon: iconProvider(e._id) }
|
||||
}) || [];
|
||||
isDataLoading.value = false;
|
||||
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
geolocationData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="geolocationData.refresh()" :data="geolocationData.data.value || []" :dataIcons="false"
|
||||
:loading="geolocationData.pending.value" label="Top Countries" sub-label="Countries" :iconProvider="iconProvider"
|
||||
:customIconStyle="customIconStyle" desc=" Lists the countries where users access your website.">
|
||||
</DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,44 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const isShowMore = ref<boolean>(false);
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: isShowMore.value === true ? '200' : '10'
|
||||
}
|
||||
});
|
||||
|
||||
const ossData = useFetch(`/api/metrics/${activeProject.value?._id}/data/oss`, {
|
||||
method: 'POST', headers, lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
|
||||
function showMore() {
|
||||
isShowMore.value = true;
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = ossData.data.value || [];
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
ossData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="ossData.refresh()" :data="ossData.data.value || []"
|
||||
desc="The operating systems most commonly used by your website's visitors." :dataIcons="false"
|
||||
:loading="ossData.pending.value" label="Top OS" sub-label="OSs"></DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { IconProvider } from './BarsCard.vue';
|
||||
import ReferrerBarChart from '../referrer/ReferrerBarChart.vue';
|
||||
|
||||
function iconProvider(id: string): ReturnType<IconProvider> {
|
||||
if (id === 'self') return ['icon', 'fas fa-link'];
|
||||
return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${id}&sz=64`]
|
||||
}
|
||||
|
||||
function elementTextTransformer(element: string) {
|
||||
if (element === 'self') return 'Direct Link';
|
||||
return element;
|
||||
}
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const isShowMore = ref<boolean>(false);
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: isShowMore.value === true ? '200' : '10'
|
||||
}
|
||||
});
|
||||
|
||||
const referrersData = useFetch(`/api/metrics/${activeProject.value?._id}/data/referrers`, {
|
||||
method: 'POST', headers, lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
// const customDialog = useCustomDialog();
|
||||
|
||||
// function onShowDetails(referrer: string) {
|
||||
// customDialog.openDialog(ReferrerBarChart, { slice: 'day', referrer });
|
||||
// }
|
||||
|
||||
function showMore() {
|
||||
|
||||
isShowMore.value = true;
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = referrersData.data.value?.map(e => {
|
||||
return { ...e, icon: iconProvider(e._id) }
|
||||
}) || [];
|
||||
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
referrersData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()"
|
||||
:elementTextTransformer="elementTextTransformer" :iconProvider="iconProvider"
|
||||
@dataReload="referrersData.refresh()" :showLink=true :data="referrersData.data.value || []"
|
||||
:interactive="false" desc="Where users find your website." :dataIcons="true" :loading="referrersData.pending.value"
|
||||
label="Top Referrers" sub-label="Referrers"></DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,7 +10,7 @@ const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
const data = input.map(e => e.count);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), props.slice));
|
||||
return { data, labels }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,42 +3,80 @@
|
||||
import DateService from '@services/DateService';
|
||||
import type { Slice } from '@services/DateService';
|
||||
|
||||
const { data: metricsInfo } = useMetricsData();
|
||||
const { snapshot, safeSnapshotDates, snapshotDuration } = useSnapshot()
|
||||
|
||||
const { snapshot, safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const snapshotFrom = computed(() => new Date(snapshot.value?.from || '0').getTime());
|
||||
const snapshotTo = computed(() => new Date(snapshot.value?.to || Date.now()).getTime());
|
||||
const chartSlice = computed(() => {
|
||||
if (snapshotDuration.value <= 3) return 'hour' as Slice;
|
||||
if (snapshotDuration.value <= 32) return 'day' as Slice;
|
||||
return 'month' as Slice;
|
||||
});
|
||||
|
||||
const snapshotDays = computed(() => {
|
||||
return (snapshotTo.value - snapshotFrom.value) / 1000 / 60 / 60 / 24;
|
||||
|
||||
function findFirstZeroOrNullIndex(arr: (number | null)[]) {
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr.slice(i).every(val => val === 0 || val === null)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
|
||||
const data = input.map(e => e.count || 0);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), chartSlice.value));
|
||||
|
||||
return { data, labels, input }
|
||||
|
||||
}
|
||||
|
||||
const visitsData = useFetch('/api/timeline/visits', {
|
||||
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||
});
|
||||
|
||||
const sessionsData = useFetch('/api/timeline/sessions', {
|
||||
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||
});
|
||||
const sessionsDurationData = useFetch('/api/timeline/sessions_duration', {
|
||||
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||
});
|
||||
const bouncingRateData = useFetch('/api/timeline/bouncing_rate', {
|
||||
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||
});
|
||||
|
||||
const avgVisitDay = computed(() => {
|
||||
if (!visitsData.data.value) return '0.00';
|
||||
const counts = visitsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||
const avg = counts / Math.max(snapshotDays.value, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
const avgEventsDay = computed(() => {
|
||||
if (!eventsData.data.value) return '0.00';
|
||||
const counts = eventsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||
const avg = counts / Math.max(snapshotDays.value, 1);
|
||||
const avg = counts / Math.max(snapshotDuration.value, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
const avgSessionsDay = computed(() => {
|
||||
if (!sessionsData.data.value) return '0.00';
|
||||
const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||
const avg = counts / Math.max(snapshotDays.value, 1);
|
||||
const avg = counts / Math.max(snapshotDuration.value, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
const avgBouncingRate = computed(() => {
|
||||
if (!bouncingRateData.data.value) return '0.00 %'
|
||||
|
||||
const counts = bouncingRateData.data.value.data
|
||||
.filter(e => e > 0)
|
||||
.reduce((a, e) => e + a, 0);
|
||||
|
||||
const avg = counts / Math.max(bouncingRateData.data.value.data.filter(e => e > 0).length, 1);
|
||||
return avg.toFixed(2) + ' %';
|
||||
})
|
||||
|
||||
const avgSessionDuration = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const avg = metricsInfo.value.avgSessionDuration;
|
||||
if (!sessionsDurationData.data.value) return '0.00 %'
|
||||
|
||||
const counts = sessionsDurationData.data.value.data
|
||||
.filter(e => e > 0)
|
||||
.reduce((a, e) => e + a, 0);
|
||||
|
||||
const avg = counts / Math.max(sessionsDurationData.data.value.data.filter(e => e > 0).length, 1);
|
||||
|
||||
let hours = 0;
|
||||
let minutes = 0;
|
||||
let seconds = 0;
|
||||
@@ -48,64 +86,10 @@ const avgSessionDuration = computed(() => {
|
||||
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
|
||||
});
|
||||
|
||||
|
||||
const chartSlice = computed(() => {
|
||||
const snapshotSizeMs = new Date(snapshot.value.to).getTime() - new Date(snapshot.value.from).getTime();
|
||||
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 6) return 'hour' as Slice;
|
||||
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 30) return 'day' as Slice;
|
||||
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 90) return 'day' as Slice;
|
||||
return 'month' as Slice;
|
||||
});
|
||||
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
const data = input.map(e => e.count);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, chartSlice.value));
|
||||
const pool = [...input.map(e => e.count)];
|
||||
pool.pop();
|
||||
const avg = pool.reduce((a, e) => a + e, 0) / pool.length;
|
||||
const diffPercent: number = (100 / avg * (input.at(-1)?.count || 0)) - 100;
|
||||
const trend = Math.max(Math.min(diffPercent, 99), -99);
|
||||
return { data, labels, trend }
|
||||
}
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
function getBody() {
|
||||
return JSON.stringify({
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to,
|
||||
slice: chartSlice.value
|
||||
});
|
||||
}
|
||||
|
||||
const visitsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const sessionsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/sessions`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const sessionsDurationData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/sessions_duration`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
});
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
visitsData.execute();
|
||||
eventsData.execute();
|
||||
sessionsData.execute();
|
||||
sessionsDurationData.execute();
|
||||
});
|
||||
const todayIndex = computed(() => {
|
||||
if (!visitsData.data.value) return -1;
|
||||
return visitsData.data.value.input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
@@ -114,32 +98,34 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4">
|
||||
|
||||
<DashboardCountCard :ready="!visitsData.pending.value" icon="far fa-earth" text="Total page visits"
|
||||
:value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
:avg="formatNumberK(avgVisitDay) + '/day'" :trend="visitsData.data.value?.trend"
|
||||
:data="visitsData.data.value?.data" :labels="visitsData.data.value?.labels" color="#5655d7">
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!visitsData.pending.value" icon="far fa-earth"
|
||||
text="Total visits" :value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
:avg="formatNumberK(avgVisitDay) + '/day'" :data="visitsData.data.value?.data"
|
||||
tooltipText="Sum of all page views on your website." :labels="visitsData.data.value?.labels"
|
||||
color="#5655d7">
|
||||
</DashboardCountCard>
|
||||
|
||||
<DashboardCountCard :ready="!eventsData.pending.value" icon="far fa-flag" text="Total custom events"
|
||||
:value="formatNumberK(eventsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
:avg="formatNumberK(avgEventsDay) + '/day'" :trend="eventsData.data.value?.trend"
|
||||
:data="eventsData.data.value?.data" :labels="eventsData.data.value?.labels" color="#1e9b86">
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!bouncingRateData.pending.value" icon="far fa-chart-user"
|
||||
text="Bouncing rate" :value="avgBouncingRate" :slow="true" :data="bouncingRateData.data.value?.data"
|
||||
tooltipText="Percentage of users who leave quickly (lower is better)."
|
||||
:labels="bouncingRateData.data.value?.labels" color="#1e9b86">
|
||||
</DashboardCountCard>
|
||||
|
||||
|
||||
<DashboardCountCard :ready="!sessionsData.pending.value" icon="far fa-user" text="Unique visits sessions"
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsData.pending.value" icon="far fa-user"
|
||||
text="Unique visitors"
|
||||
:value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
:avg="formatNumberK(avgSessionsDay) + '/day'" :trend="sessionsData.data.value?.trend"
|
||||
tooltipText="Count of distinct users visiting your website." :avg="formatNumberK(avgSessionsDay) + '/day'"
|
||||
:data="sessionsData.data.value?.data" :labels="sessionsData.data.value?.labels" color="#4abde8">
|
||||
</DashboardCountCard>
|
||||
|
||||
|
||||
<DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer" text="Total avg session time"
|
||||
:value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
|
||||
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsDurationData.pending.value" icon="far fa-timer"
|
||||
text="Visit duration" :value="avgSessionDuration" :data="sessionsDurationData.data.value?.data"
|
||||
tooltipText="Average time users spend on your website." :labels="sessionsDurationData.data.value?.labels"
|
||||
color="#f56523">
|
||||
</DashboardCountCard>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,16 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { project } = useProject();
|
||||
|
||||
const { onlineUsers, stopWatching, startWatching } = useOnlineUsers();
|
||||
onMounted(() => startWatching());
|
||||
onUnmounted(() => stopWatching());
|
||||
|
||||
const selfhosted = useSelfhosted();
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
|
||||
function copyProjectId() {
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
|
||||
if (!navigator.clipboard) return alert('You can\'t copy in HTTP');
|
||||
if (!project.value) return alert('Project not loaded');
|
||||
navigator.clipboard.writeText((project.value._id).toString());
|
||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
@@ -21,7 +25,7 @@ function showAnomalyInfoAlert() {
|
||||
attack or simply higher traffic due to good performance. Additionally, it can detect if someone is
|
||||
stealing parts of your website and hosting a duplicate version—an unfortunately common practice.
|
||||
Litlyx will notify you via email with actionable advices`,
|
||||
'far fa-bug',
|
||||
'far fa-shield',
|
||||
10000
|
||||
)
|
||||
|
||||
@@ -30,38 +34,39 @@ function showAnomalyInfoAlert() {
|
||||
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full px-6 py-2 lg:py-6 font-bold text-text-sub/40 flex flex-col xl:flex-row text-lg lg:text-2xl gap-2 xl:gap-12">
|
||||
<div class="w-full px-6 pb-2 lg:pb-6 font-bold flex flex-col xl:flex-row text-lg gap-2 xl:gap-12 lg:text-2xl">
|
||||
|
||||
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
|
||||
<div
|
||||
class="flex gap-2 items-center text-lyx-lightmode-text/90 dark:text-lyx-text/90 justify-center md:justify-start">
|
||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||
<div class="poppins font-medium text-[1.2rem]"> {{ onlineUsers }} Online users</div>
|
||||
<div class="poppins font-medium text-[.9rem]"> {{ onlineUsers.data }} Online users</div>
|
||||
</div>
|
||||
|
||||
<div class="grow"></div>
|
||||
|
||||
<div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row">
|
||||
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project:</div>
|
||||
<div class="text-lyx-text poppins font-medium text-[1.2rem]"> {{ activeProject?.name || 'Loading...' }}
|
||||
<!-- <div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row">
|
||||
<div class="poppins font-medium text-lyx-text-darker text-[.9rem]">Project:</div>
|
||||
<div class="text-lyx-text poppins font-medium text-[.9rem]"> {{ project?.name || 'Loading...' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row md:gap-2 items-center md:justify-start">
|
||||
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project id:</div>
|
||||
<div class="poppins font-medium text-lyx-text-darker text-[.9rem]">Project id:</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-lyx-text poppins font-medium text-[1.2rem]">
|
||||
{{ activeProject?._id || 'Loading...' }}
|
||||
<div class="text-lyx-text poppins font-medium text-[.9rem]">
|
||||
{{ project?._id || 'Loading...' }}
|
||||
</div>
|
||||
<div class="flex items-center ml-3">
|
||||
<i @click="copyProjectId()"
|
||||
class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[1.2rem]"></i>
|
||||
class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[.9rem]"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
|
||||
<div v-if="!selfhosted"
|
||||
class="flex gap-2 items-center text-lyx-lightmode-text/90 dark:text-lyx-text/90 justify-center md:justify-start">
|
||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||
<div class="poppins font-regular text-[1rem]"> AI Anomaly Detector </div>
|
||||
<div class="poppins font-regular text-[.9rem]"> AI Anomaly Detector </div>
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-info-circle text-[.9rem] hover:text-lyx-primary cursor-pointer"
|
||||
@click="showAnomalyInfoAlert"></i>
|
||||
|
||||
@@ -10,7 +10,7 @@ const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
const data = input.map(e => e.count);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), props.slice));
|
||||
return { data, labels }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { VisitsWebsiteAggregated } from '~/server/api/metrics/[project_id]/data/websites';
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const isShowMore = ref<boolean>(false);
|
||||
|
||||
const currentWebsite = ref<string>("");
|
||||
|
||||
const websitesHeaders = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: isShowMore.value === true ? '200' : '10'
|
||||
}
|
||||
});
|
||||
|
||||
const pagesHeaders = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: isShowMore.value === true ? '200' : '10',
|
||||
'x-website-name': currentWebsite.value
|
||||
}
|
||||
});
|
||||
|
||||
const websitesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/websites`, {
|
||||
method: 'POST', headers: websitesHeaders, lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const pagesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/pages`, {
|
||||
method: 'POST', headers: pagesHeaders, lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const isPagesView = ref<boolean>(false);
|
||||
|
||||
const currentData = computed(() => {
|
||||
return isPagesView.value ? pagesData : websitesData
|
||||
})
|
||||
|
||||
|
||||
async function showDetails(website: string) {
|
||||
currentWebsite.value = website;
|
||||
pagesData.execute();
|
||||
isPagesView.value = true;
|
||||
}
|
||||
|
||||
async function showGeneral() {
|
||||
websitesData.execute();
|
||||
isPagesView.value = false;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goToView() {
|
||||
router.push('/dashboard/visits');
|
||||
}
|
||||
|
||||
onMounted(()=>{
|
||||
websitesData.execute();
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<DashboardBarsCard :hideShowMore="true" @showGeneral="showGeneral()" @showRawData="goToView()"
|
||||
@dataReload="currentData.refresh()" @showDetails="showDetails" :data="currentData.data.value || []"
|
||||
:loading="currentData.pending.value" :label="isPagesView ? 'Top pages' : 'Top Websites'"
|
||||
:sub-label="isPagesView ? 'Page' : 'Website'"
|
||||
:desc="isPagesView ? 'Most visited pages' : 'Most visited website in this project'"
|
||||
:interactive="!isPagesView" :rawButton="!isLiveDemo()" :isDetailView="isPagesView">
|
||||
</DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const { closeDialog } = useCustomDialog();
|
||||
|
||||
import { sub, format, isSameDay, type Duration } from 'date-fns'
|
||||
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||
|
||||
const ranges = [
|
||||
{ label: 'Last 7 days', duration: { days: 7 } },
|
||||
@@ -36,24 +36,27 @@ function onColorChange() {
|
||||
|
||||
const snapshotName = ref<string>("");
|
||||
|
||||
const { updateSnapshots } = useSnapshot();
|
||||
const { updateSnapshots, snapshot, snapshots } = useSnapshot();
|
||||
const { createAlert } = useAlert()
|
||||
|
||||
async function confirmSnapshot() {
|
||||
await $fetch("/api/snapshot/create", {
|
||||
method: 'POST',
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }).value,
|
||||
body: JSON.stringify({
|
||||
name: snapshotName.value,
|
||||
color: currentColor.value,
|
||||
from: selected.value.start.toISOString(),
|
||||
to: selected.value.end.toISOString()
|
||||
from: startOfDay(selected.value.start),
|
||||
to: endOfDay(selected.value.end)
|
||||
})
|
||||
});
|
||||
|
||||
await updateSnapshots();
|
||||
closeDialog();
|
||||
createAlert('Snapshot created','Snapshot created successfully', 'far fa-circle-check', 5000);
|
||||
createAlert('Snapshot created', 'Snapshot created successfully', 'far fa-circle-check', 5000);
|
||||
const newSnapshot = snapshots.value.at(-1);
|
||||
if (newSnapshot) snapshot.value = newSnapshot;
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -61,7 +64,7 @@ async function confirmSnapshot() {
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
|
||||
<div class="poppins text-center">
|
||||
<div class="poppins text-center text-lyx-lightmode-text dark:text-lyx-text">
|
||||
Create a snapshot
|
||||
</div>
|
||||
|
||||
@@ -76,7 +79,6 @@ async function confirmSnapshot() {
|
||||
</div>
|
||||
|
||||
<div class="mt-4 justify-center flex w-full">
|
||||
|
||||
<UPopover class="w-full" :popper="{ placement: 'bottom' }">
|
||||
<UButton class="w-full" color="primary" variant="solid">
|
||||
<div class="flex items-center justify-center w-full gap-2">
|
||||
@@ -97,8 +99,6 @@ async function confirmSnapshot() {
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="grow"></div>
|
||||
|
||||
82
dashboard/components/dialog/DeleteDomainData.vue
Normal file
82
dashboard/components/dialog/DeleteDomainData.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const emit = defineEmits(['success', 'cancel'])
|
||||
|
||||
const props = defineProps<{
|
||||
buttonType: string,
|
||||
message: string,
|
||||
deleteData: { isAll: boolean, visits: boolean, sessions: boolean, events: boolean, domain: string }
|
||||
}>();
|
||||
|
||||
const isDone = ref<boolean>(false);
|
||||
const canDelete = ref<boolean>(false);
|
||||
|
||||
async function deleteData() {
|
||||
|
||||
try {
|
||||
if (props.deleteData.isAll) {
|
||||
await $fetch('/api/settings/delete_all', {
|
||||
method: 'DELETE',
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }).value,
|
||||
})
|
||||
} else {
|
||||
await $fetch('/api/settings/delete_domain', {
|
||||
method: 'DELETE',
|
||||
headers: useComputedHeaders({ useSnapshotDates: false, custom: { 'Content-Type': 'application/json' } }).value,
|
||||
body: JSON.stringify({
|
||||
domain: props.deleteData.domain,
|
||||
visits: props.deleteData.visits,
|
||||
sessions: props.deleteData.sessions,
|
||||
events: props.deleteData.events,
|
||||
})
|
||||
})
|
||||
}
|
||||
} catch (ex) {
|
||||
alert('Something went wrong');
|
||||
console.error(ex);
|
||||
}
|
||||
|
||||
isDone.value = true;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal :ui="{
|
||||
strategy: 'override',
|
||||
overlay: {
|
||||
background: 'bg-lyx-background/85'
|
||||
},
|
||||
background: 'bg-lyx-widget',
|
||||
ring: 'border-solid border-[1px] border-[#262626]'
|
||||
}">
|
||||
<div class="h-full flex flex-col gap-2 p-4">
|
||||
|
||||
<div class="font-semibold text-[1.2rem]"> {{ isDone ? "Data Deletion Scheduled" : "Are you sure ?" }}</div>
|
||||
|
||||
<div v-if="!isDone">
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
<div v-if="isDone">
|
||||
Your data deletion request is being processed and will be reflected in your project dashboard within a
|
||||
few minutes.
|
||||
</div>
|
||||
|
||||
<div class="grow"></div>
|
||||
|
||||
<div v-if="!isDone">
|
||||
<UCheckbox v-model="canDelete" label="Confirm data delete"></UCheckbox>
|
||||
</div>
|
||||
|
||||
<div v-if="!isDone" class="flex justify-end gap-2">
|
||||
<LyxUiButton type="secondary" @click="emit('cancel')"> Cancel </LyxUiButton>
|
||||
<LyxUiButton :disabled="!canDelete" @click="canDelete ? deleteData() : () => { }" :type="buttonType"> Confirm </LyxUiButton>
|
||||
</div>
|
||||
|
||||
<div v-if="isDone" class="flex justify-end w-full">
|
||||
<LyxUiButton type="secondary" @click="emit('success')"> Dismiss </LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</UModal>
|
||||
</template>
|
||||
56
dashboard/components/dialog/Feedback.vue
Normal file
56
dashboard/components/dialog/Feedback.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
const { close } = useModal()
|
||||
|
||||
const text = ref<string>("");
|
||||
|
||||
async function sendFeedback() {
|
||||
if (text.value.length < 5) return;
|
||||
try {
|
||||
|
||||
const res = await $fetch('/api/feedback/add', {
|
||||
headers: useComputedHeaders({
|
||||
useSnapshotDates: false,
|
||||
custom: { 'Content-Type': 'application/json' }
|
||||
}).value,
|
||||
method:'POST',
|
||||
body: JSON.stringify({ text: text.value })
|
||||
});
|
||||
|
||||
createAlert('Success', 'Feedback sent successfully.', 'far fa-circle-check', 5000);
|
||||
|
||||
close();
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
createAlert('Error', 'Error sending feedback. Please try again later', 'far fa-triangle-exclamation', 5000);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal :ui="{
|
||||
strategy: 'override',
|
||||
overlay: {
|
||||
background: 'bg-lyx-background/85'
|
||||
},
|
||||
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
|
||||
ring: 'border-solid border-[1px] border-[#262626]'
|
||||
}">
|
||||
<div class="h-full flex flex-col gap-2 p-4">
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div> Share everything with us. </div>
|
||||
<textarea v-model="text" placeholder="Leave your feedback"
|
||||
class="p-2 w-full h-[8rem] dark:bg-lyx-widget bg-lyx-lightmode-widget-light resize-none rounded-md outline outline-[2px] outline-[#3a3f47]"></textarea>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>Need help ? Check the docs <a href="https://docs.litlyx.com" target="_blank"
|
||||
class="text-blue-500">here</a> </div>
|
||||
<LyxUiButton :disabled="text.length < 5" @click="sendFeedback()" type="primary"> Send </LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UModal>
|
||||
|
||||
</template>
|
||||
13
dashboard/components/drawer/Docs.vue
Normal file
13
dashboard/components/drawer/Docs.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const emits = defineEmits<{
|
||||
(evt: 'onCloseClick'): void
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<iframe class="w-full h-full" src="https://docs.litlyx.com/introduction" frameborder="0"></iframe>
|
||||
</div>
|
||||
</template>
|
||||
20
dashboard/components/drawer/Generic.vue
Normal file
20
dashboard/components/drawer/Generic.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const emits = defineEmits<{ (evt: 'onCloseClick'): void }>();
|
||||
|
||||
const { drawerComponent } = useDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 overflow-y-auto">
|
||||
|
||||
<div @click="$emit('onCloseClick')"
|
||||
class="cursor-pointer fixed top-4 right-4 rounded-full dark:bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
|
||||
<i class="fas fa-close text-[1.6rem]"></i>
|
||||
</div>
|
||||
|
||||
<Component v-if="drawerComponent" :is="drawerComponent"></Component>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,20 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PricingCardProp } from './PricingCardGeneric.vue';
|
||||
|
||||
import type { PricingCardProp } from '../pricing/PricingCardGeneric.vue';
|
||||
|
||||
|
||||
const { data: planData, refresh: refreshPlanData } = useFetch('/api/project/plan', {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
|
||||
});
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
watch(activeProject, () => {
|
||||
refreshPlanData();
|
||||
});
|
||||
|
||||
|
||||
function getPricingsData() {
|
||||
|
||||
const freePricing: PricingCardProp[] = [
|
||||
@@ -23,7 +14,7 @@ function getPricingsData() {
|
||||
price: '€0 / mo',
|
||||
subs: [
|
||||
'Up to 5000 visits/events per month',
|
||||
'CPM 0€ per visit/event'
|
||||
|
||||
],
|
||||
features: [
|
||||
'Email support',
|
||||
@@ -70,7 +61,7 @@ function getPricingsData() {
|
||||
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: [
|
||||
'Slack support',
|
||||
@@ -90,7 +81,7 @@ function getPricingsData() {
|
||||
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: [
|
||||
'Slack support',
|
||||
@@ -110,7 +101,7 @@ function getPricingsData() {
|
||||
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: [
|
||||
'Slack support',
|
||||
@@ -130,7 +121,7 @@ function getPricingsData() {
|
||||
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: [
|
||||
'Slack support',
|
||||
@@ -138,7 +129,7 @@ function getPricingsData() {
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 5.000',
|
||||
'Server type: SHARED',
|
||||
'Data retention: 1 Year'
|
||||
'Data retention: 3 Year'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
active: (planData.value?.premium_type || 0) == 104,
|
||||
@@ -150,7 +141,7 @@ function getPricingsData() {
|
||||
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: [
|
||||
'Slack support',
|
||||
@@ -158,7 +149,7 @@ function getPricingsData() {
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 10.000',
|
||||
'Server type: DEDICATED',
|
||||
'Data retention: 2 Years'
|
||||
'Data retention: 7 Years'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
active: (planData.value?.premium_type || 0) == 105,
|
||||
@@ -170,7 +161,7 @@ function getPricingsData() {
|
||||
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: [
|
||||
'Slack support',
|
||||
@@ -178,7 +169,7 @@ function getPricingsData() {
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 20.000',
|
||||
'Server type: DEDICATED',
|
||||
'Data retention: 3 Years'
|
||||
'Data retention: 8 Years'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
active: (planData.value?.premium_type || 0) == 106,
|
||||
@@ -190,103 +181,43 @@ function getPricingsData() {
|
||||
return { freePricing, customPricing, slidePricings }
|
||||
}
|
||||
|
||||
|
||||
|
||||
const emits = defineEmits<{
|
||||
(evt: 'onCloseClick'): void
|
||||
}>();
|
||||
|
||||
async function onLifetimeUpgradeClick() {
|
||||
const res = await $fetch<string>(`/api/pay/${activeProject.value?._id.toString()}/create-onetime`, {
|
||||
...signHeaders({ 'content-type': 'application/json' }),
|
||||
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">
|
||||
|
||||
<div @click="$emit('onCloseClick')"
|
||||
class="cursor-pointer fixed top-4 right-4 rounded-full bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
|
||||
<i class="fas fa-close text-[1.6rem]"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-8 mt-10 h-max xl:flex-row flex-col">
|
||||
<PricingCardGeneric class="flex-1" :datas="getPricingsData().freePricing"></PricingCardGeneric>
|
||||
<PricingCardGeneric class="flex-1" :datas="getPricingsData().slidePricings" :default-index="2"></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-[1.1rem] text-lyx-lightmode-text dark:text-yellow-400 mb-2">
|
||||
*Plan upgrades are applicable exclusively to this project(workspace).
|
||||
</div>
|
||||
<div class="poppins text-[2rem] font-semibold">
|
||||
Do you need help ?
|
||||
</div>
|
||||
<div class="poppins text-[1.2rem] text-text/90">
|
||||
<div class="poppins text-[1.2rem]">
|
||||
We respond in max. 1-2 days
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="rounded-lg px-10 py-3 bg-[#303030]">
|
||||
<a href="mailto:help@litlyx.com" class="poppins text-[1.3rem]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<LyxUiButton type="secondary">
|
||||
<a href="mailto:help@litlyx.com" class="poppins text-[1.1rem]">
|
||||
help@litlyx.com
|
||||
</a>
|
||||
</div>
|
||||
</LyxUiButton>
|
||||
<LyxUiButton type="secondary">
|
||||
<a href="https://discord.com/invite/9cQykjsmWX" class="poppins text-[1.1rem]">
|
||||
Discord support
|
||||
</a>
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
161
dashboard/components/events/EventsFunnelChart.vue
Normal file
161
dashboard/components/events/EventsFunnelChart.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js';
|
||||
import { defineChartComponent } from 'vue-chart-3';
|
||||
|
||||
const FunnelChart = defineChartComponent('funnel', 'funnel');
|
||||
|
||||
const chartOptions = ref<ChartOptions<'funnel'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
includeInvisible: true
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
// borderDash: [5, 10]
|
||||
},
|
||||
},
|
||||
x: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleFont: { size: 16, weight: 'bold' },
|
||||
bodyFont: { size: 14 },
|
||||
padding: 10,
|
||||
cornerRadius: 4,
|
||||
boxPadding: 10,
|
||||
caretPadding: 20,
|
||||
yAlign: 'bottom',
|
||||
xAlign: 'center',
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const chartData = ref<ChartData<'funnel'>>({
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
'#5680F877',
|
||||
"#6bbbe377",
|
||||
"#a6d5cb77",
|
||||
"#fae0b977",
|
||||
"#f28e8e77",
|
||||
"#e3a7e477",
|
||||
"#c4a8e177",
|
||||
"#8cc1d877",
|
||||
"#f9c2cd77",
|
||||
"#b4e3b277",
|
||||
"#ffdfba77",
|
||||
"#e9c3b577",
|
||||
"#d5b8d677",
|
||||
"#add7f677",
|
||||
"#ffd1dc77",
|
||||
"#ffe7a177",
|
||||
"#a8e6cf77",
|
||||
"#d4a5a577",
|
||||
"#f3d6e477",
|
||||
"#c3aed677"
|
||||
],
|
||||
// borderColor: '#0000CC',
|
||||
// borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
hoverBackgroundColor: '#26262677',
|
||||
// hoverBorderColor: 'white',
|
||||
// hoverBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
// const c = document.createElement('canvas');
|
||||
// const ctx = c.getContext("2d");
|
||||
// let gradient: any = `${'#0000CC'}22`;
|
||||
// if (ctx) {
|
||||
// gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
// gradient.addColorStop(0, `${'#0000CC'}99`);
|
||||
// gradient.addColorStop(0.35, `${'#0000CC'}66`);
|
||||
// gradient.addColorStop(1, `${'#0000CC'}22`);
|
||||
// } else {
|
||||
// console.warn('Cannot get context for gradient');
|
||||
// }
|
||||
|
||||
// chartData.value.datasets[0].backgroundColor = [gradient];
|
||||
|
||||
});
|
||||
|
||||
const eventsData = useFetch(`/api/data/events`, {
|
||||
headers: useComputedHeaders(), lazy: true
|
||||
});
|
||||
|
||||
const enabledEvents = ref<string[]>([]);
|
||||
|
||||
async function onEventCheck(eventName: string) {
|
||||
const index = enabledEvents.value.indexOf(eventName);
|
||||
if (index == -1) {
|
||||
enabledEvents.value.push(eventName);
|
||||
} else {
|
||||
enabledEvents.value.splice(index, 1);
|
||||
}
|
||||
|
||||
|
||||
chartData.value.labels = enabledEvents.value;
|
||||
chartData.value.datasets[0].data = [];
|
||||
|
||||
for (const enabledEvent of enabledEvents.value) {
|
||||
const target = (eventsData.data.value ?? []).find(e => e._id == enabledEvent);
|
||||
chartData.value.datasets[0].data.push(target?.count || 0);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<CardTitled title="Funnel"
|
||||
sub="Monitor and analyze the actions your users are performing on your platform to gain insights into their behavior and optimize the user experience">
|
||||
<div class="flex gap-2 justify-between lg:flex-row flex-col">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="min-w-[20rem] text-lyx-text-darker">
|
||||
Select two or more events
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div v-for="event of eventsData.data.value">
|
||||
<UCheckbox color="secondary" @change="onEventCheck(event._id)"
|
||||
:value="enabledEvents.includes(event._id)" :label="event._id">
|
||||
</UCheckbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grow">
|
||||
<FunnelChart :chart-data="chartData" :options="chartOptions"> </FunnelChart>
|
||||
</div>
|
||||
</div>
|
||||
</CardTitled>
|
||||
</template>
|
||||
@@ -1,16 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const eventNames = ref<string[]>([]);
|
||||
const eventNames = await useFetch<string[]>(`/api/data/events_data/names`, {
|
||||
headers: useComputedHeaders()
|
||||
});
|
||||
|
||||
const selectedEventName = ref<string>();
|
||||
const metadataFields = ref<string[]>([]);
|
||||
const selectedMetadataField = ref<string>();
|
||||
const metadataFieldGrouped = ref<any[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
eventNames.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/names`, signHeaders());
|
||||
});
|
||||
|
||||
watch(selectedEventName, () => {
|
||||
getMetadataFields();
|
||||
@@ -21,7 +20,9 @@ watch(selectedMetadataField, () => {
|
||||
});
|
||||
|
||||
async function getMetadataFields() {
|
||||
metadataFields.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/metadata_fields?name=${selectedEventName.value}`, signHeaders());
|
||||
metadataFields.value = await $fetch<string[]>(`/api/data/events_data/metadata_fields?name=${selectedEventName.value}`, {
|
||||
headers: useComputedHeaders().value
|
||||
});
|
||||
selectedMetadataField.value = undefined;
|
||||
currentSearchText.value = "";
|
||||
}
|
||||
@@ -38,10 +39,12 @@ async function getMetadataFieldGrouped() {
|
||||
name: selectedEventName.value,
|
||||
field: selectedMetadataField.value
|
||||
}
|
||||
|
||||
|
||||
const queryParamsString = Object.keys(queryParams).map((key) => `${key}=${queryParams[key]}`).join('&');
|
||||
|
||||
metadataFieldGrouped.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/metadata_field_group?${queryParamsString}`, signHeaders());
|
||||
metadataFieldGrouped.value = await $fetch<string[]>(`/api/data/events_data/metadata_field_group?${queryParamsString}`, {
|
||||
headers: useComputedHeaders().value
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -71,11 +74,80 @@ const canSearch = computed(() => {
|
||||
|
||||
<CardTitled title="Event metadata analyzer" sub="Filter events metadata fields to analyze them" class="w-full p-4">
|
||||
|
||||
<div class="p-2 flex flex-col">
|
||||
<div class="">
|
||||
|
||||
<LyxUiCard class="h-full w-full flex gap-2">
|
||||
|
||||
<div class="flex-[2]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<USelectMenu :uiMenu="{
|
||||
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" searchable searchable-placeholder="Search an event..." class="w-full"
|
||||
placeholder="Select an event" :options="eventNames.data.value || []"
|
||||
v-model="selectedEventName">
|
||||
</USelectMenu>
|
||||
|
||||
<USelectMenu :uiMenu="{
|
||||
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" searchable searchable-placeholder="Search a field..." class="w-full"
|
||||
placeholder="Select a field" :options="metadataFields" v-model="selectedMetadataField">
|
||||
</USelectMenu>
|
||||
</div>
|
||||
|
||||
<div class="text-lyx-text-darker poppins mt-4 flex items-center gap-4 lg:flex-row flex-col">
|
||||
<div class="w-[10rem]">
|
||||
Search results: {{ metadataFieldGroupedFiltered.length }}
|
||||
</div>
|
||||
<div v-if="canSearch" class="h-full flex items-center text-[1.2rem]">
|
||||
|
||||
<div class="bg-lyx-lightmode-widget dark:bg-lyx-widget-light flex items-center rounded-md pl-4">
|
||||
<div><i class="far fa-search"></i></div>
|
||||
<input class="bg-transparent px-4 py-2 text-[1rem] outline-none" type="text"
|
||||
placeholder="Filter by metadata name" v-model="currentSearchText">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 lg:mt-4 mt-10">
|
||||
|
||||
<div class="bg-lyx-lightmode-widget dark:bg-lyx-widget-light text-lyx-lightmode-text dark:text-lyx-text-dark px-3 py-2 rounded-md w-fit"
|
||||
v-for="item of metadataFieldGroupedFiltered">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div> {{ item._id || 'OLD_EVENTS' }} </div>
|
||||
<div class="px-1"> {{ item.count }} </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- <div class="border-solid border-[#212121] border-l-[1px]"></div> -->
|
||||
|
||||
<!-- <div class="flex-[1]">
|
||||
<div class="poppins font-semibold"> </div>
|
||||
</div> -->
|
||||
|
||||
</LyxUiCard>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- <div class="p-2 flex flex-col">
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<USelectMenu searchable searchable-placeholder="Search an event..." class="w-full"
|
||||
placeholder="Select an event" :options="eventNames" v-model="selectedEventName">
|
||||
placeholder="Select an event" :options="eventNames.data.value || []" v-model="selectedEventName">
|
||||
</USelectMenu>
|
||||
|
||||
<USelectMenu v-if="metadataFields.length > 0" searchable searchable-placeholder="Search a field..."
|
||||
@@ -100,17 +172,11 @@ const canSearch = computed(() => {
|
||||
Search results: {{ metadataFieldGroupedFiltered.length }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div v-for="item of metadataFieldGroupedFiltered">
|
||||
<div class="flex gap-2">
|
||||
<div> {{ item._id || 'OLD_EVENTS' }} </div>
|
||||
<div> {{ item.count }} </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
</CardTitled>
|
||||
|
||||
|
||||
|
||||
@@ -6,52 +6,41 @@ import DateService, { type Slice } from '@services/DateService';
|
||||
const props = defineProps<{ slice: Slice }>();
|
||||
const slice = computed(() => props.slice);
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const body = computed(() => {
|
||||
return {
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to,
|
||||
slice: slice.value,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function transformResponse(input: { _id: string, name: string, count: number }[]) {
|
||||
|
||||
const fixed = fixMetrics({
|
||||
data: input,
|
||||
from: input[0]._id,
|
||||
to: safeSnapshotDates.value.to
|
||||
}, slice.value, {
|
||||
advanced: true,
|
||||
advancedGroupKey: 'name'
|
||||
});
|
||||
},
|
||||
slice.value,
|
||||
{ advanced: true, advancedGroupKey: 'name' });
|
||||
|
||||
const parsedDatasets: any[] = [];
|
||||
|
||||
const colors = [
|
||||
"#5655d0",
|
||||
"#6bbbe3",
|
||||
"#a6d5cb",
|
||||
"#fae0b9",
|
||||
"#f28e8e",
|
||||
"#e3a7e4",
|
||||
"#c4a8e1",
|
||||
"#8cc1d8",
|
||||
"#f9c2cd",
|
||||
"#b4e3b2",
|
||||
"#ffdfba",
|
||||
"#e9c3b5",
|
||||
"#d5b8d6",
|
||||
"#add7f6",
|
||||
"#ffd1dc",
|
||||
"#ffe7a1",
|
||||
"#a8e6cf",
|
||||
"#d4a5a5",
|
||||
"#f3d6e4",
|
||||
"#c3aed6"
|
||||
"#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++) {
|
||||
@@ -72,10 +61,31 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
|
||||
datasets: parsedDatasets,
|
||||
labels: fixed.labels
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const eventsStackedData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events_stacked`, {
|
||||
method: 'POST', body, lazy: true, immediate: false, transform: transformResponse, ...signHeaders()
|
||||
const errorData = ref<{ errored: boolean, text: string }>({
|
||||
errored: false,
|
||||
text: ''
|
||||
})
|
||||
|
||||
|
||||
function onResponseError(e: any) {
|
||||
console.log('ON RESPONSE ERROR')
|
||||
errorData.value = { errored: true, text: e.response._data.message ?? 'Generic error' }
|
||||
}
|
||||
|
||||
function onResponse(e: any) {
|
||||
console.log('ON RESPONSE')
|
||||
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
|
||||
}
|
||||
|
||||
const eventsStackedData = useFetch(`/api/timeline/events_stacked`, {
|
||||
lazy: true, immediate: false,
|
||||
transform: transformResponse,
|
||||
headers: useComputedHeaders({ slice }),
|
||||
onResponseError,
|
||||
onResponse
|
||||
});
|
||||
|
||||
|
||||
@@ -86,13 +96,17 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="h-full">
|
||||
<div v-if="eventsStackedData.pending.value" class="flex justify-center py-40">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
<AdvancedStackedBarChart v-if="!eventsStackedData.pending.value"
|
||||
<AdvancedStackedBarChart v-if="!eventsStackedData.pending.value && !errorData.errored"
|
||||
:datasets="eventsStackedData.data.value?.datasets || []"
|
||||
:labels="eventsStackedData.data.value?.labels || []">
|
||||
</AdvancedStackedBarChart>
|
||||
<div v-if="errorData.errored" class="flex items-center justify-center py-8 h-full">
|
||||
{{ errorData.text }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,13 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const eventNames = await useFetch<string[]>(`/api/data/events_data/names`, {
|
||||
headers: useComputedHeaders()
|
||||
});
|
||||
|
||||
const eventNames = ref<string[]>([]);
|
||||
const selectedEventName = ref<string>();
|
||||
|
||||
onMounted(async () => {
|
||||
eventNames.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/names`, signHeaders());
|
||||
});
|
||||
|
||||
const userFlowData = ref<any>();
|
||||
const analyzing = ref<boolean>(false);
|
||||
@@ -26,7 +24,10 @@ async function getUserFlowData() {
|
||||
|
||||
const queryParamsString = Object.keys(queryParams).map((key) => `${key}=${queryParams[key]}`).join('&');
|
||||
|
||||
userFlowData.value = await $fetch(`/api/metrics/${activeProject.value?._id.toString()}/events/flow_from_name?${queryParamsString}`, signHeaders());
|
||||
userFlowData.value = await $fetch(`/api/data/events_data/flow_from_name?${queryParamsString}`, {
|
||||
headers: useComputedHeaders().value
|
||||
});
|
||||
|
||||
analyzing.value = false;
|
||||
}
|
||||
|
||||
@@ -38,25 +39,39 @@ async function analyzeEvent() {
|
||||
|
||||
<template>
|
||||
<CardTitled title="Event User Flow"
|
||||
sub="Track your user's journey from external links to in-app events, maintaining a complete view of their path from entry to engagement." class="w-full p-4">
|
||||
sub="Track your user's journey from external links to in-app events, maintaining a complete view of their path from entry to engagement."
|
||||
class="w-full p-4">
|
||||
|
||||
<div class="p-2 flex flex-col gap-3">
|
||||
<USelectMenu searchable searchable-placeholder="Search an event..." class="w-full"
|
||||
placeholder="Select an event" :options="eventNames" v-model="selectedEventName">
|
||||
</USelectMenu>
|
||||
<div v-if="selectedEventName && !analyzing" class="flex justify-center">
|
||||
<div @click="analyzeEvent()"
|
||||
class="bg-bg w-fit px-8 py-2 poppins rounded-lg hover:bg-bg/80 cursor-pointer">
|
||||
Analyze
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div class="py-2 flex items-center gap-3">
|
||||
<USelectMenu :uiMenu="{
|
||||
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" searchable searchable-placeholder="Search an event..." class="w-full" placeholder="Select an event"
|
||||
:options="eventNames.data.value || []" v-model="selectedEventName">
|
||||
</USelectMenu>
|
||||
<div v-if="selectedEventName && !analyzing" class="flex justify-center">
|
||||
<LyxUiButton @click="analyzeEvent()" type="primary" class="w-fit px-8 py-1">
|
||||
Analyze
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="analyzing">
|
||||
Analyzing...
|
||||
<div
|
||||
class="backdrop-blur-[1px] z-[20] w-full h-full flex items-center justify-center font-bold rockmann">
|
||||
<i
|
||||
class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2" v-if="userFlowData">
|
||||
<div class="flex gap-4 items-center bg-bg py-1 px-2 rounded-lg"
|
||||
<div class="flex gap-4 items-center bg-bg py-2 px-2 bg-lyx-lightmode-widget dark:bg-lyx-widget-light rounded-lg"
|
||||
v-for="(count, referrer) in userFlowData">
|
||||
<div class="w-5 h-5 flex items-center justify-center">
|
||||
<img :src="`https://s2.googleusercontent.com/s2/favicons?domain=${referrer}&sz=64`"
|
||||
@@ -67,8 +82,8 @@ async function analyzeEvent() {
|
||||
<div> {{ count.toFixed(2).replace('.', ',') }} % </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</CardTitled>
|
||||
</template>
|
||||
@@ -22,86 +22,27 @@ const widthHeight = computed(() => {
|
||||
return 9 + props.size * props.spacing;
|
||||
});
|
||||
|
||||
|
||||
|
||||
const colorMode = useColorMode();
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="w-fit h-fit">
|
||||
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" :width="widthHeight" :height="widthHeight" :style="`opacity: ${props.opacity};`"
|
||||
fill="none">
|
||||
|
||||
<template v-for="(p, x) of sizeArr">
|
||||
<template v-for="(p, y) of sizeArr">
|
||||
<circle :cx="9 + (spacing * x)" :cy="9 + (spacing * y)" r="1" fill="#fff"
|
||||
<circle :cx="9 + (spacing * x)" :cy="9 + (spacing * y)" r="1" :fill="colorMode.value === 'light' ? '#000' : '#FFF'"
|
||||
:fill-opacity="calculateOpacity(x, y)" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- <circle cx="27" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="45" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="63" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="81" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="99" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="117" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="135" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="9" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="27" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="45" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="63" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="81" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="99" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="117" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="135" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="9" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="27" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="45" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="63" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="81" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="99" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="117" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="135" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="9" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="27" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="45" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="63" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="81" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="99" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="117" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="135" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="9" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="27" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="45" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="63" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="81" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="99" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="117" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="135" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="9" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="27" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="45" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="63" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="81" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="99" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="117" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="135" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="9" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="27" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="45" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="63" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="81" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="99" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="117" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="135" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="9" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="27" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="45" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="63" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="81" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="99" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="117" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="135" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
||||
-->
|
||||
</svg>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -15,8 +15,6 @@ export type PricingCardProp = {
|
||||
|
||||
const props = defineProps<{ datas: PricingCardProp[], defaultIndex?: number }>();
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const currentIndex = ref<number>(props.defaultIndex || 0);
|
||||
|
||||
const data = computed(() => {
|
||||
@@ -24,8 +22,13 @@ const data = computed(() => {
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
@@ -38,11 +41,11 @@ 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]">
|
||||
class="relative bg-lyx-lightmode-widget-light dark:bg-[#151515] outline outline-[1px] outline-lyx-lightmode-widget dark:outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]">
|
||||
|
||||
<div class="flex flex-col gap-3 text-center 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">
|
||||
class="absolute right-6 top-3 poppins text-[.75rem] bg-transparent border-[#262626] border-solid border-[1px] px-3 py-[.1rem] rounded-sm">
|
||||
Active
|
||||
</div>
|
||||
<div v-if="!data.active && data.title === 'Growth'"
|
||||
@@ -53,7 +56,7 @@ async function onUpgradeClick() {
|
||||
<div class="poppins text-4xl font-medium"> {{ data.price }} </div>
|
||||
</div>
|
||||
|
||||
<div class="sep bg-[#262626] h-[1px] my-8"></div>
|
||||
<div class="sep bg-lyx-lightmode-widget dark:bg-[#262626] h-[1px] my-8"></div>
|
||||
|
||||
<div class="flex flex-col text-center h-[6rem] justify-center gap-2">
|
||||
<div v-if="datas.length > 1">
|
||||
@@ -67,10 +70,13 @@ async function onUpgradeClick() {
|
||||
}" :min="0" :max="datas.length - 1" v-model="currentIndex">
|
||||
</URange>
|
||||
</div>
|
||||
<div class="poppins" v-for="sub of data.subs"> {{ sub }} </div>
|
||||
<div :class="{ '!text-[.8rem] !text-lyx-text-darker': sub.includes('€') }" class="poppins text-[.9rem]"
|
||||
v-for="sub of data.subs">
|
||||
{{ sub }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sep bg-[#262626] h-[1px] my-8"></div>
|
||||
<div class="sep bg-lyx-lightmode-widget dark:bg-[#262626] h-[1px] my-8"></div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2" v-for="feature of data.features">
|
||||
@@ -82,20 +88,27 @@ async function onUpgradeClick() {
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex">
|
||||
<div class="w-full" v-if="data.planId > -1">
|
||||
<div @click="onUpgradeClick()" v-if="!data.active && !data.isDowngrade"
|
||||
class="cursor-pointer text-[1rem] font-semibold bg-[#3a3af5] rounded-md py-2 text-center">
|
||||
|
||||
<div class="w-full flex" v-if="data.planId > -1">
|
||||
|
||||
|
||||
<LyxUiButton class="rounded-md py-2 w-full text-center" type="primary" @click="onUpgradeClick()"
|
||||
v-if="!data.active && !data.isDowngrade">
|
||||
Upgrade
|
||||
</div>
|
||||
<div @click="onUpgradeClick()" v-if="!data.active && data.isDowngrade"
|
||||
class="w-full cursor-pointer text-[1rem] font-semibold bg-[#1f1f22] text-red-400 rounded-md py-2 text-center">
|
||||
</LyxUiButton>
|
||||
|
||||
<LyxUiButton class="rounded-md py-2 w-full text-center" type="danger" @click="onUpgradeClick()"
|
||||
v-if="!data.active && data.isDowngrade">
|
||||
Downgrade
|
||||
</div>
|
||||
</LyxUiButton>
|
||||
|
||||
</div>
|
||||
<NuxtLink v-if="data.planId === -1" :to="data.link || 'https://dashboard.litlyx.com'"
|
||||
class="bg-[#222A42] cursor-pointer outline outline-[1px] outline-[#5680F8] w-full !rounded-md text-center text-[.9rem] !py-2 ">
|
||||
|
||||
<LyxUiButton v-if="data.planId === -1" :to="data.link || 'https://dashboard.litlyx.com'"
|
||||
class="rounded-md py-2 w-full text-center" type="primary">
|
||||
{{ data.cta }}
|
||||
</NuxtLink>
|
||||
</LyxUiButton>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
{ id: 'change_pass', title: 'Change password', text: 'Change your password' },
|
||||
{ id: 'delete', title: 'Delete account', text: 'Delete your account' },
|
||||
]
|
||||
|
||||
|
||||
const { user } = useLoggedUser();
|
||||
const { setToken } = useAccessToken();
|
||||
|
||||
const canChangePassword = useFetch('/api/user/password/can_change', {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false })
|
||||
});
|
||||
|
||||
async function deleteAccount() {
|
||||
const sure = confirm("Are you sure you want to delete this account ?");
|
||||
if (!sure) return;
|
||||
@@ -20,17 +26,63 @@ async function deleteAccount() {
|
||||
location.href = "/login"
|
||||
}
|
||||
|
||||
const old_password = ref<string>("");
|
||||
const new_password = ref<string>("");
|
||||
|
||||
const { createAlert } = useAlert()
|
||||
|
||||
async function changePassword() {
|
||||
|
||||
|
||||
try {
|
||||
const res = await $fetch("/api/user/password/change", {
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
method: "POST",
|
||||
body: JSON.stringify({ old_password: old_password.value, new_password: new_password.value })
|
||||
})
|
||||
|
||||
if (!res) throw Error('No response');
|
||||
|
||||
if (res.error) return createAlert('Error', res.message, 'far fa-triangle-exclamation', 5000);
|
||||
|
||||
|
||||
old_password.value = '';
|
||||
new_password.value = '';
|
||||
|
||||
return createAlert('Success', 'Password changed successfully', 'far fa-circle-check', 5000);
|
||||
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
createAlert('Error', 'Internal error', 'far fa-triangle-exclamation', 5000);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<SettingsTemplate :entries="entries">
|
||||
<template #change_pass>
|
||||
<div v-if="canChangePassword.data.value?.can_change">
|
||||
<div class="flex flex-col gap-4">
|
||||
<LyxUiInput type="password" class="py-1 px-2" v-model="old_password" placeholder="Current password"></LyxUiInput>
|
||||
<LyxUiInput type="password" class="py-1 px-2" v-model="new_password" placeholder="New password"></LyxUiInput>
|
||||
<LyxUiButton type="primary" @click="changePassword()"> Change password </LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!canChangePassword.data.value?.can_change">
|
||||
You cannot change the password for accounts created using social login options.
|
||||
</div>
|
||||
</template>
|
||||
<template #delete>
|
||||
<div
|
||||
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-[#1e1412]">
|
||||
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-lyx-lightmode-widget-light dark:bg-[#1e1412]">
|
||||
<div class="poppins font-semibold"> Deleting this account will also remove its projects </div>
|
||||
<div @click="deleteAccount()"
|
||||
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-[#291415]">
|
||||
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-lyx-lightmode-widget-light dark:bg-[#291415]">
|
||||
Delete account
|
||||
</div>
|
||||
</div>
|
||||
|
||||
61
dashboard/components/settings/Codes.vue
Normal file
61
dashboard/components/settings/Codes.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TApiSettings } from '@schema/ApiSettingsSchema';
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
const { project } = useProject();
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
{ id: 'acodes', title: 'Appsumo codes', text: 'Redeem appsumo codes' },
|
||||
]
|
||||
|
||||
const { createAlert } = useAlert()
|
||||
|
||||
const currentCode = ref<string>("");
|
||||
const redeeming = ref<boolean>(false);
|
||||
|
||||
const valid_codes = useFetch('/api/pay/valid_codes', signHeaders({ 'x-pid': project.value?._id.toString() ?? '' }));
|
||||
|
||||
async function redeemCode() {
|
||||
redeeming.value = true;
|
||||
try {
|
||||
const res = await $fetch<TApiSettings>('/api/pay/redeem_appsumo_code', {
|
||||
method: 'POST', ...signHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'x-pid': project.value?._id.toString() ?? ''
|
||||
}),
|
||||
body: JSON.stringify({ code: currentCode.value })
|
||||
});
|
||||
createAlert('Success', 'Code redeem success.', 'far fa-check', 5000);
|
||||
valid_codes.refresh();
|
||||
} catch (ex: any) {
|
||||
createAlert('Error', ex?.response?.statusText || 'Unexpected error. Contact support.', 'far fa-error', 5000);
|
||||
} finally {
|
||||
currentCode.value = '';
|
||||
}
|
||||
redeeming.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<SettingsTemplate :entries="entries" :key="project?.name || 'NONE'">
|
||||
<template #acodes>
|
||||
<div class="flex items-center gap-4">
|
||||
<LyxUiInput class="w-full px-4 py-2" placeholder="Appsumo code" v-model="currentCode"></LyxUiInput>
|
||||
<LyxUiButton v-if="!redeeming" :disabled="currentCode.length == 0" @click="redeemCode()" type="primary">
|
||||
Redeem
|
||||
</LyxUiButton>
|
||||
<div v-if="redeeming">
|
||||
Redeeming...
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-lyx-text-darker mt-1 text-[.9rem] poppins">
|
||||
Redeemed codes: {{ valid_codes.data.value?.count || '0' }}
|
||||
</div>
|
||||
<div class="poppins text-[1.1rem] text-lyx-lightmode-text dark:text-yellow-400 mb-2">
|
||||
*Plan upgrades are applicable exclusively to this project(workspace).
|
||||
</div>
|
||||
</template>
|
||||
</SettingsTemplate>
|
||||
</template>
|
||||
156
dashboard/components/settings/Data.vue
Normal file
156
dashboard/components/settings/Data.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script lang="ts" setup>
|
||||
import DeleteDomainData from '../dialog/DeleteDomainData.vue';
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
{ id: 'delete_dns', title: 'Delete domain data', text: 'Delete data of a specific domain from this project' },
|
||||
{ id: 'delete_data', title: 'Delete project data', text: 'Delete all data from this project' },
|
||||
]
|
||||
|
||||
const domains = useFetch('/api/settings/domains', {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }),
|
||||
transform: (e) => {
|
||||
if (!e) return [];
|
||||
return e.sort((a, b) => {
|
||||
return a.count - b.count;
|
||||
}).map(e => {
|
||||
return { id: e._id, label: `${e._id} - ${e.count} visits` }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const selectedDomain = ref<{ id: string, label: string }>();
|
||||
const selectedVisits = ref<boolean>(true);
|
||||
const selectedSessions = ref<boolean>(true);
|
||||
const selectedEvents = ref<boolean>(true);
|
||||
|
||||
|
||||
const domainCounts = useFetch(() => `/api/settings/domain_counts?domain=${selectedDomain.value?.id}`, {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }),
|
||||
})
|
||||
|
||||
|
||||
const { setToken } = useAccessToken();
|
||||
|
||||
|
||||
const modal = useModal();
|
||||
|
||||
function openDeleteDomainDataDialog() {
|
||||
modal.open(DeleteDomainData, {
|
||||
preventClose: true,
|
||||
deleteData: {
|
||||
isAll: false,
|
||||
domain: selectedDomain.value?.id as string,
|
||||
visits: selectedVisits.value,
|
||||
sessions: selectedSessions.value,
|
||||
events: selectedEvents.value,
|
||||
},
|
||||
buttonType: 'primary',
|
||||
message: 'This action is irreversable and will wipe all the data from the selected domain.',
|
||||
onSuccess: () => {
|
||||
modal.close()
|
||||
},
|
||||
onCancel: () => {
|
||||
modal.close()
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openDeleteAllDomainDataDialog() {
|
||||
modal.open(DeleteDomainData, {
|
||||
preventClose: true,
|
||||
deleteData: {
|
||||
isAll: true,
|
||||
domain: '',
|
||||
visits: false,
|
||||
sessions: false,
|
||||
events: false,
|
||||
},
|
||||
buttonType: 'danger',
|
||||
message: 'This action is irreversable and will wipe all the data from the entire project.',
|
||||
onSuccess: () => {
|
||||
modal.close()
|
||||
},
|
||||
onCancel: () => {
|
||||
modal.close()
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const visitsLabel = computed(() => {
|
||||
if (domainCounts.pending.value === true) return 'Visits loading...';
|
||||
if (domainCounts.data.value?.error === true) return 'Visits (too many to compute)';
|
||||
return 'Visits ' + (domainCounts.data.value?.visits ?? '');
|
||||
})
|
||||
|
||||
const eventsLabel = computed(() => {
|
||||
if (domainCounts.pending.value === true) return 'Events loading...';
|
||||
if (domainCounts.data.value?.error === true) return 'Events (too many to compute)';
|
||||
return 'Events ' + (domainCounts.data.value?.events ?? '');
|
||||
})
|
||||
|
||||
const sessionsLabel = computed(() => {
|
||||
if (domainCounts.pending.value === true) return 'Sessions loading...';
|
||||
if (domainCounts.data.value?.error === true) return 'Sessions (too many to compute)';
|
||||
return 'Sessions ' + (domainCounts.data.value?.sessions ?? '');
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<SettingsTemplate :entries="entries">
|
||||
<template #delete_dns>
|
||||
<div class="flex flex-col">
|
||||
|
||||
<!-- <div class="text-[.9rem] text-lyx-text-darker"> Select a domain </div> -->
|
||||
<USelectMenu placeholder="Select a domain" :uiMenu="{
|
||||
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" :options="domains.data.value ?? []" v-model="selectedDomain"></USelectMenu>
|
||||
|
||||
<div v-if="selectedDomain" class="flex flex-col gap-2 mt-4">
|
||||
<div class="text-[.9rem] text-lyx-text-dark"> Select data to delete </div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
|
||||
|
||||
<UCheckbox :ui="{ color: 'actionable-visits-color-checkbox' }" v-model="selectedVisits"
|
||||
:label="visitsLabel" />
|
||||
<UCheckbox :ui="{ color: 'actionable-sessions-color-checkbox' }" v-model="selectedSessions"
|
||||
:label="sessionsLabel" />
|
||||
<UCheckbox :ui="{ color: 'actionable-events-color-checkbox' }" v-model="selectedEvents"
|
||||
:label="eventsLabel" />
|
||||
|
||||
</div>
|
||||
|
||||
<LyxUiButton class="mt-2" v-if="selectedVisits || selectedSessions || selectedEvents"
|
||||
@click="openDeleteDomainDataDialog()" type="outline">
|
||||
Delete data
|
||||
</LyxUiButton>
|
||||
<div class="text-lyx-text-dark">
|
||||
This action will delete all data from the project creation date.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #delete_data>
|
||||
|
||||
<div
|
||||
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-lyx-lightmode-widget-light dark:bg-[#1e1412]">
|
||||
<div class="poppins font-semibold"> This operation will reset this project to it's initial state (0
|
||||
visits 0 events 0 sessions) </div>
|
||||
<div @click="openDeleteAllDomainDataDialog()"
|
||||
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-lyx-lightmode-widget-light dark:bg-[#291415]">
|
||||
Delete all data
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</SettingsTemplate>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
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' },
|
||||
@@ -11,8 +12,7 @@ const entries: SettingsTemplateEntry[] = [
|
||||
{ id: 'pdelete', title: 'Delete', text: 'Delete current project' },
|
||||
]
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const projectNameInputVal = ref<string>(activeProject.value?.name || '');
|
||||
const projectNameInputVal = ref<string>(project.value?.name || '');
|
||||
|
||||
const apiKeys = ref<TApiSettings[]>([]);
|
||||
|
||||
@@ -20,14 +20,17 @@ const newApiKeyName = ref<string>('');
|
||||
|
||||
async function updateApiKeys() {
|
||||
newApiKeyName.value = '';
|
||||
apiKeys.value = await $fetch<TApiSettings[]>('/api/keys/get_all', signHeaders());
|
||||
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'
|
||||
'Content-Type': 'application/json',
|
||||
'x-pid': project.value?._id.toString() ?? ''
|
||||
}),
|
||||
body: JSON.stringify({ name: newApiKeyName.value })
|
||||
});
|
||||
@@ -42,7 +45,8 @@ async function deleteApiKey(api_id: string) {
|
||||
try {
|
||||
const res = await $fetch<TApiSettings>('/api/keys/delete', {
|
||||
method: 'DELETE', ...signHeaders({
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
'x-pid': project.value?._id.toString() ?? ''
|
||||
}),
|
||||
body: JSON.stringify({ api_id })
|
||||
});
|
||||
@@ -56,15 +60,15 @@ async function deleteApiKey(api_id: string) {
|
||||
|
||||
onMounted(() => {
|
||||
updateApiKeys();
|
||||
})
|
||||
});
|
||||
|
||||
watch(activeProject, () => {
|
||||
projectNameInputVal.value = activeProject.value?.name || "";
|
||||
watch(project, () => {
|
||||
projectNameInputVal.value = project.value?.name || "";
|
||||
updateApiKeys();
|
||||
})
|
||||
});
|
||||
|
||||
const canChange = computed(() => {
|
||||
if (activeProject.value?.name == projectNameInputVal.value) return false;
|
||||
if (project.value?.name == projectNameInputVal.value) return false;
|
||||
if (projectNameInputVal.value.length === 0) return false;
|
||||
return true;
|
||||
});
|
||||
@@ -73,31 +77,41 @@ const canChange = computed(() => {
|
||||
async function changeProjectName() {
|
||||
await $fetch("/api/project/change_name", {
|
||||
method: 'POST',
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
...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 (!activeProject.value) return;
|
||||
const sure = confirm(`Are you sure to delete the project ${activeProject.value.name} ?`);
|
||||
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' }),
|
||||
body: JSON.stringify({ project_id: activeProject.value._id.toString() })
|
||||
...signHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'x-pid': project.value?._id.toString() ?? ''
|
||||
}),
|
||||
body: JSON.stringify({ project_id: project.value._id.toString() })
|
||||
});
|
||||
|
||||
const projectsList = useProjectsList()
|
||||
await projectsList.refresh();
|
||||
|
||||
const firstProjectId = projectsList.data.value?.[0]?._id.toString();
|
||||
await actions.refreshProjectsList()
|
||||
|
||||
const firstProjectId = projectList.value?.[0]?._id.toString();
|
||||
if (firstProjectId) {
|
||||
await setActiveProject(firstProjectId);
|
||||
await actions.setActiveProject(firstProjectId);
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +131,7 @@ function copyScript() {
|
||||
const createScriptText = () => {
|
||||
return [
|
||||
'<script defer ',
|
||||
`data-project="${activeProject.value?._id}" `,
|
||||
`data-project="${projectId.value}" `,
|
||||
'src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></',
|
||||
'script>'
|
||||
].join('')
|
||||
@@ -130,7 +144,7 @@ function copyScript() {
|
||||
|
||||
function copyProjectId() {
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
navigator.clipboard.writeText(activeProject.value?._id?.toString() || '');
|
||||
navigator.clipboard.writeText(projectId.value || '');
|
||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
@@ -140,17 +154,18 @@ function copyProjectId() {
|
||||
|
||||
|
||||
<template>
|
||||
<SettingsTemplate :entries="entries" :key="activeProject?.name || 'NONE'">
|
||||
<SettingsTemplate :entries="entries" :key="project?.name || 'NONE'">
|
||||
<template #pname>
|
||||
<div class="flex items-center gap-4">
|
||||
<LyxUiInput class="w-full px-4 py-2" v-model="projectNameInputVal"></LyxUiInput>
|
||||
<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" placeholder="ApiKeyName" v-model="newApiKeyName"></LyxUiInput>
|
||||
<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>
|
||||
@@ -162,7 +177,7 @@ function copyProjectId() {
|
||||
<div class="flex gap-8 items-center">
|
||||
<div class="grow">Name: {{ apiKey.apiName }}</div>
|
||||
<div>{{ apiKey.apiKey }}</div>
|
||||
<div class="flex justify-end">
|
||||
<div class="flex justify-end" v-if="!isGuest">
|
||||
<i class="far fa-trash cursor-pointer" @click="deleteApiKey(apiKey._id.toString())"></i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,7 +187,7 @@ function copyProjectId() {
|
||||
</template>
|
||||
<template #pid>
|
||||
<LyxUiCard class="w-full flex items-center">
|
||||
<div class="grow">{{ activeProject?._id.toString() }}</div>
|
||||
<div class="grow">{{ project?._id.toString() }}</div>
|
||||
<div><i class="far fa-copy" @click="copyProjectId()"></i></div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
@@ -180,14 +195,19 @@ function copyProjectId() {
|
||||
<LyxUiCard class="w-full flex items-center">
|
||||
<div class="grow">
|
||||
{{ `
|
||||
<script defer data-project="${activeProject?._id}"
|
||||
<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>
|
||||
<div class="hidden lg:flex"><i class="far fa-copy" @click="copyScript()"></i></div>
|
||||
</LyxUiCard>
|
||||
<div class="flex justify-end w-full">
|
||||
<LyxUiButton type="outline" class="flex lg:hidden mt-4">
|
||||
Copy script
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</template>
|
||||
<template #pdelete >
|
||||
<div class="flex justify-end" v-if="!isGuest">
|
||||
<template #pdelete>
|
||||
<div class="flex lg:justify-end" v-if="!isGuest">
|
||||
<LyxUiButton type="danger" @click="deleteProject()">
|
||||
Delete project
|
||||
</LyxUiButton>
|
||||
|
||||
@@ -16,22 +16,22 @@ const props = defineProps<SettingsTemplateProp>();
|
||||
|
||||
|
||||
<template>
|
||||
<div class="mt-10 px-4">
|
||||
<div class="mt-10 px-4 xl:pb-0 pb-[10rem]">
|
||||
<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">
|
||||
<div class="flex xl:flex-row flex-col gap-4 xl:gap-0">
|
||||
<div class="xl:flex-[2]">
|
||||
<div class="poppins font-medium text-lyx-lightmode-text dark:text-lyx-text">
|
||||
{{ entry.title }}
|
||||
</div>
|
||||
<div class="poppins font-regular text-lyx-text-dark">
|
||||
<div class="poppins font-regular text-lyx-lightmode-text-dark dark:text-lyx-text-dark whitespace-pre-wrap">
|
||||
{{ entry.text }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-[3]">
|
||||
<div class="xl: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 v-if="index < props.entries.length - 1" class="h-[2px] bg-lyx-lightmode-widget dark:bg-lyx-widget-lighter w-full my-10"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,13 +3,16 @@ import dayjs from 'dayjs';
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
import { getPlanFromId, PREMIUM_PLAN, type PREMIUM_TAG } from '@data/PREMIUM';
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const { projectId, isGuest } = useProject();
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const { data: planData, refresh: planRefresh, pending: planPending } = useFetch('/api/project/plan', {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
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(() => {
|
||||
@@ -42,9 +45,8 @@ const prettyExpireDate = computed(() => {
|
||||
});
|
||||
|
||||
|
||||
const { data: invoices, refresh: invoicesRefresh, pending: invoicesPending } = useFetch(`/api/pay/${activeProject.value?._id.toString()}/invoices`, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
const { data: invoices, pending: invoicesPending } = useFetch(`/api/pay/invoices`, {
|
||||
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
|
||||
})
|
||||
|
||||
function openInvoice(link: string) {
|
||||
@@ -65,26 +67,51 @@ function getPremiumPrice(type: number) {
|
||||
return (PLAN.COST / 100).toFixed(2).replace('.', ',')
|
||||
}
|
||||
|
||||
|
||||
watch(activeProject, () => {
|
||||
invoicesRefresh();
|
||||
planRefresh();
|
||||
})
|
||||
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
// { id: 'info', title: 'Billing informations', text: 'Manage billing informations for this project' },
|
||||
{ id: 'plan', title: 'Current plan', text: 'Manage current plat for this project' },
|
||||
{ id: 'plan', title: 'Current plan', text: 'Manage current plan 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' },
|
||||
]
|
||||
|
||||
|
||||
const currentBillingInfo = ref<any>({
|
||||
address: ''
|
||||
watch(customerAddress, () => {
|
||||
console.log('UPDATE', customerAddress.value)
|
||||
if (!customerAddress.value) return;
|
||||
currentBillingInfo.value = customerAddress.value;
|
||||
});
|
||||
|
||||
const { visible } = usePricingDrawer();
|
||||
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 { showDrawer } = useDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
@@ -97,31 +124,58 @@ const { visible } = usePricingDrawer();
|
||||
</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 dark:!bg-[#161616]" placeholder="Address line 1"
|
||||
v-model="currentBillingInfo.line1">
|
||||
</LyxUiInput>
|
||||
<LyxUiInput class="px-2 py-2 dark:!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 dark:!bg-[#161616]" placeholder="Country"
|
||||
v-model="currentBillingInfo.country">
|
||||
</LyxUiInput>
|
||||
<LyxUiInput class="px-2 py-2 w-full dark:!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 dark:!bg-[#161616]" placeholder="City"
|
||||
v-model="currentBillingInfo.city">
|
||||
</LyxUiInput>
|
||||
<LyxUiInput class="px-2 py-2 w-full dark:!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 flex-col sm:flex-row">
|
||||
<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-lg">
|
||||
class="flex lato text-[.7rem] bg-transparent border-[#262626] border-[1px] px-[.6rem] rounded-sm">
|
||||
{{ planData.premium ? getPremiumName(planData.premium_type) : 'FREE' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.9rem]">
|
||||
Our free plan for testing the product.
|
||||
</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 class="flex items-center ml-2">
|
||||
<i class="far fa-info-circle text-[.8rem]"></i>
|
||||
</div>
|
||||
<div class="poppins text-lyx-lightmode-text-dark dark:text-text-sub mt-2"> per month </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
@@ -139,16 +193,14 @@ const { visible } = usePricingDrawer();
|
||||
</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="flex justify-between px-8 flex-col lg:flex-row gap-2 lg:gap-0 items-center">
|
||||
<div class="flex gap-2 text-lyx-lightmode-text-dark dark:text-text-sub text-[.9rem]">
|
||||
<div class="poppins"> Expire date:</div>
|
||||
<div> {{ prettyExpireDate }}</div>
|
||||
</div>
|
||||
<div v-if="!isGuest" @click="visible = true"
|
||||
class="cursor-pointer flex items-center gap-2 text-[.9rem] text-white font-semibold bg-accent px-4 py-1 rounded-lg drop-shadow-[0_0_8px_#000000]">
|
||||
<div class="poppins"> Upgrade plan </div>
|
||||
<i class="fas fa-arrow-up-right"></i>
|
||||
</div>
|
||||
<LyxUiButton v-if="!isGuest" @click="showDrawer('PRICING')" type="primary">
|
||||
Upgrade plan
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
@@ -160,7 +212,7 @@ const { visible } = usePricingDrawer();
|
||||
<div class="poppins font-semibold text-[1.1rem]">
|
||||
Usage
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.9rem]">
|
||||
<div class="poppins text-lyx-lightmode-text-dark dark:text-text-sub text-[.9rem]">
|
||||
Check the usage limits of your project.
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +240,7 @@ const { visible } = usePricingDrawer();
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
<div class="flex justify-between items-center bg-[#161616] p-4 rounded-lg"
|
||||
<div class="flex justify-between items-center outline-[1px] outline outline-lyx-lightmode-widget dark:outline-none bg-lyx-lightmode-widget-light dark:bg-[#161616] p-4 rounded-lg"
|
||||
v-for="invoice of invoices">
|
||||
|
||||
<div> <i class="fal fa-file-invoice"></i> </div>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const { projectId, isGuest } = useProject();
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
|
||||
const columns = [
|
||||
{ key: 'me', label: '' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
@@ -15,7 +14,9 @@ const columns = [
|
||||
// { key: 'pending', label: 'Pending' },
|
||||
]
|
||||
|
||||
const { data: members, refresh: refreshMembers, pending: pendingMembers } = 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);
|
||||
|
||||
@@ -30,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);
|
||||
@@ -55,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);
|
||||
@@ -71,10 +78,6 @@ async function addMember() {
|
||||
|
||||
}
|
||||
|
||||
watch(activeProject, () => {
|
||||
refreshMembers();
|
||||
})
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
{ id: 'add', title: 'Add member', text: 'Add new member to project' },
|
||||
{ id: 'members', title: 'Members', text: 'Manage members of current project' },
|
||||
|
||||
@@ -1,59 +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) {
|
||||
changingProject.value = true;
|
||||
await new Promise(e => setTimeout(e, 500));
|
||||
await $fetch<string>(`/api/user/set_active_project?project_id=${project_id}`, signHeaders());
|
||||
await activeProjectId.refresh();
|
||||
changingProject.value = false;
|
||||
}
|
||||
|
||||
export const changingProject = ref<boolean>(false);
|
||||
@@ -1,12 +1,7 @@
|
||||
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import annotaionPlugin from 'chartjs-plugin-annotation';
|
||||
|
||||
let registered = false;
|
||||
export async function registerChartComponents() {
|
||||
if (registered) return;
|
||||
if (process.client) {
|
||||
Chart.register(...registerables, annotaionPlugin);
|
||||
registered = true;
|
||||
}
|
||||
console.log('registerChartComponents is deprecated. Plugin is now used');
|
||||
registered = true;
|
||||
}
|
||||
91
dashboard/composables/snapshots/BaseSnapshots.ts
Normal file
91
dashboard/composables/snapshots/BaseSnapshots.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
|
||||
import type { TProjectSnapshot } from "@schema/project/ProjectSnapshot";
|
||||
|
||||
import * as fns from 'date-fns';
|
||||
|
||||
export type DefaultSnapshot = TProjectSnapshot & { default: true }
|
||||
export type GenericSnapshot = TProjectSnapshot | DefaultSnapshot;
|
||||
|
||||
export function getDefaultSnapshots(project_id: TProjectSnapshot['project_id'], project_created_at: Date | string) {
|
||||
|
||||
const today: DefaultSnapshot = {
|
||||
project_id,
|
||||
_id: '___today' as any,
|
||||
name: 'Today',
|
||||
from: fns.startOfDay(Date.now()),
|
||||
to: fns.endOfDay(Date.now()),
|
||||
color: '#FFA600',
|
||||
default: true
|
||||
}
|
||||
|
||||
const lastDay: DefaultSnapshot = {
|
||||
project_id,
|
||||
_id: '___lastDay' as any,
|
||||
name: 'Yesterday',
|
||||
from: fns.startOfDay(fns.subDays(Date.now(), 1)),
|
||||
to: fns.endOfDay(fns.subDays(Date.now(), 1)),
|
||||
color: '#FF8531',
|
||||
default: true
|
||||
}
|
||||
|
||||
|
||||
const lastMonth: DefaultSnapshot = {
|
||||
project_id,
|
||||
_id: '___lastMonth' as any,
|
||||
name: 'Last Month',
|
||||
from: fns.startOfMonth(fns.subMonths(Date.now(), 1)),
|
||||
to: fns.endOfMonth(fns.subMonths(Date.now(), 1)),
|
||||
color: '#BC5090',
|
||||
default: true
|
||||
}
|
||||
const currentMonth: DefaultSnapshot = {
|
||||
project_id,
|
||||
_id: '___currentMonth' as any,
|
||||
name: 'Current Month',
|
||||
from: fns.startOfMonth(Date.now()),
|
||||
to: fns.endOfMonth(Date.now()),
|
||||
color: '#58508D',
|
||||
default: true
|
||||
}
|
||||
|
||||
|
||||
const lastWeek: DefaultSnapshot = {
|
||||
project_id,
|
||||
_id: '___lastWeek' as any,
|
||||
name: 'Last Week',
|
||||
from: fns.startOfWeek(fns.subWeeks(Date.now(), 1)),
|
||||
to: fns.endOfWeek(fns.subWeeks(Date.now(), 1)),
|
||||
color: '#3E909D',
|
||||
default: true
|
||||
}
|
||||
|
||||
|
||||
const currentWeek: DefaultSnapshot = {
|
||||
project_id,
|
||||
_id: '___currentWeek' as any,
|
||||
name: 'Current Week',
|
||||
from: fns.startOfWeek(Date.now()),
|
||||
to: fns.endOfWeek(Date.now()),
|
||||
color: '#007896',
|
||||
default: true
|
||||
}
|
||||
|
||||
|
||||
|
||||
const allTime: DefaultSnapshot = {
|
||||
project_id,
|
||||
_id: '___allTime' as any,
|
||||
name: 'All Time',
|
||||
from: new Date(project_created_at.toString()),
|
||||
to: new Date(Date.now()),
|
||||
color: '#9362FF',
|
||||
default: true
|
||||
}
|
||||
|
||||
|
||||
|
||||
const snapshotList = [lastDay, today, lastMonth, currentMonth, lastWeek, currentWeek, allTime]
|
||||
|
||||
return snapshotList;
|
||||
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
|
||||
|
||||
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 } }
|
||||
@@ -14,26 +14,12 @@ export const authorizationHeaderComputed = computed(() => {
|
||||
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 }
|
||||
}
|
||||
42
dashboard/composables/useCountryName.ts
Normal file
42
dashboard/composables/useCountryName.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
const countryMap: Record<string, string> = {
|
||||
RW: "Rwanda", SO: "Somalia", YE: "Yemen", IQ: "Iraq", SA: "Saudi Arabia", IR: "Iran", CY: "Cyprus", TZ: "Tanzania",
|
||||
SY: "Syria", AM: "Armenia", KE: "Kenya", CD: "Congo", DJ: "Djibouti", UG: "Uganda", CF: "Central African Republic",
|
||||
SC: "Seychelles", JO: "Jordan", LB: "Lebanon", KW: "Kuwait", OM: "Oman", QA: "Qatar", BH: "Bahrain", AE: "United Arab Emirates",
|
||||
IL: "Israel", TR: "Türkiye", ET: "Ethiopia", ER: "Eritrea", EG: "Egypt", SD: "Sudan", GR: "Greece", BI: "Burundi",
|
||||
EE: "Estonia", LV: "Latvia", AZ: "Azerbaijan", LT: "Lithuania", SJ: "Svalbard and Jan Mayen", GE: "Georgia", MD: "Moldova",
|
||||
BY: "Belarus", FI: "Finland", AX: "Åland Islands", UA: "Ukraine", MK: "North Macedonia", HU: "Hungary", BG: "Bulgaria",
|
||||
AL: "Albania", PL: "Poland", RO: "Romania", XK: "Kosovo", ZW: "Zimbabwe", ZM: "Zambia", KM: "Comoros", MW: "Malawi",
|
||||
LS: "Lesotho", BW: "Botswana", MU: "Mauritius", SZ: "Eswatini", RE: "Réunion", ZA: "South Africa", YT: "Mayotte",
|
||||
MZ: "Mozambique", MG: "Madagascar", AF: "Afghanistan", PK: "Pakistan", BD: "Bangladesh", TM: "Turkmenistan", TJ: "Tajikistan",
|
||||
LK: "Sri Lanka", BT: "Bhutan", IN: "India", MV: "Maldives", IO: "British Indian Ocean Territory", NP: "Nepal", MM: "Myanmar",
|
||||
UZ: "Uzbekistan", KZ: "Kazakhstan", KG: "Kyrgyzstan", TF: "French Southern Territories", HM: "Heard and McDonald Islands",
|
||||
CC: "Cocos (Keeling) Islands", PW: "Palau", VN: "Vietnam", TH: "Thailand", ID: "Indonesia", LA: "Laos", TW: "Taiwan",
|
||||
PH: "Philippines", MY: "Malaysia", CN: "China", HK: "Hong Kong", BN: "Brunei", MO: "Macao", KH: "Cambodia", KR: "South Korea",
|
||||
JP: "Japan", KP: "North Korea", SG: "Singapore", CK: "Cook Islands", TL: "Timor-Leste", RU: "Russia", MN: "Mongolia",
|
||||
AU: "Australia", CX: "Christmas Island", MH: "Marshall Islands", FM: "Federated States of Micronesia", PG: "Papua New Guinea",
|
||||
SB: "Solomon Islands", TV: "Tuvalu", NR: "Nauru", VU: "Vanuatu", NC: "New Caledonia", NF: "Norfolk Island", NZ: "New Zealand",
|
||||
FJ: "Fiji", LY: "Libya", CM: "Cameroon", SN: "Senegal", CG: "Congo Republic", PT: "Portugal", LR: "Liberia", CI: "Ivory Coast", GH: "Ghana",
|
||||
GQ: "Equatorial Guinea", NG: "Nigeria", BF: "Burkina Faso", TG: "Togo", GW: "Guinea-Bissau", MR: "Mauritania", BJ: "Benin", GA: "Gabon",
|
||||
SL: "Sierra Leone", ST: "São Tomé and Príncipe", GI: "Gibraltar", GM: "Gambia", GN: "Guinea", TD: "Chad", NE: "Niger", ML: "Mali",
|
||||
EH: "Western Sahara", TN: "Tunisia", ES: "Spain", MA: "Morocco", MT: "Malta", DZ: "Algeria", FO: "Faroe Islands", DK: "Denmark",
|
||||
IS: "Iceland", GB: "United Kingdom", CH: "Switzerland", SE: "Sweden", NL: "The Netherlands", AT: "Austria", BE: "Belgium",
|
||||
DE: "Germany", LU: "Luxembourg", IE: "Ireland", MC: "Monaco", FR: "France", AD: "Andorra", LI: "Liechtenstein", JE: "Jersey",
|
||||
IM: "Isle of Man", GG: "Guernsey", SK: "Slovakia", CZ: "Czechia", NO: "Norway", VA: "Vatican City", SM: "San Marino",
|
||||
IT: "Italy", SI: "Slovenia", ME: "Montenegro", HR: "Croatia", BA: "Bosnia and Herzegovina", AO: "Angola", NA: "Namibia",
|
||||
SH: "Saint Helena", BV: "Bouvet Island", BB: "Barbados", CV: "Cabo Verde", GY: "Guyana", GF: "French Guiana", SR: "Suriname",
|
||||
PM: "Saint Pierre and Miquelon", GL: "Greenland", PY: "Paraguay", UY: "Uruguay", BR: "Brazil", FK: "Falkland Islands",
|
||||
GS: "South Georgia and the South Sandwich Islands", JM: "Jamaica", DO: "Dominican Republic", CU: "Cuba", MQ: "Martinique",
|
||||
BS: "Bahamas", BM: "Bermuda", AI: "Anguilla", TT: "Trinidad and Tobago", KN: "St Kitts and Nevis", DM: "Dominica",
|
||||
AG: "Antigua and Barbuda", LC: "Saint Lucia", TC: "Turks and Caicos Islands", AW: "Aruba", VG: "British Virgin Islands",
|
||||
VC: "St Vincent and Grenadines", MS: "Montserrat", MF: "Saint Martin", BL: "Saint Barthélemy", GP: "Guadeloupe",
|
||||
GD: "Grenada", KY: "Cayman Islands", BZ: "Belize", SV: "El Salvador", GT: "Guatemala", HN: "Honduras", NI: "Nicaragua",
|
||||
CR: "Costa Rica", VE: "Venezuela", EC: "Ecuador", CO: "Colombia", PA: "Panama", HT: "Haiti", AR: "Argentina", CL: "Chile",
|
||||
BO: "Bolivia", PE: "Peru", MX: "Mexico", PF: "French Polynesia", PN: "Pitcairn Islands", KI: "Kiribati", TK: "Tokelau",
|
||||
TO: "Tonga", WF: "Wallis and Futuna", WS: "Samoa", NU: "Niue", MP: "Northern Mariana Islands", GU: "Guam", PR: "Puerto Rico",
|
||||
VI: "U.S. Virgin Islands", UM: "U.S. Outlying Islands", AS: "American Samoa", CA: "Canada", US: "United States",
|
||||
PS: "Palestine", RS: "Serbia", AQ: "Antarctica", SX: "Sint Maarten", CW: "Curaçao", BQ: "Bonaire", SS: "South Sudan"
|
||||
}
|
||||
|
||||
export function getCountryName(iso: string) {
|
||||
return countryMap[iso] as string | undefined;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export type CustomDialogOptions = {
|
||||
params?: any,
|
||||
width?: string,
|
||||
height?: string,
|
||||
closable?: boolean
|
||||
closable?: boolean,
|
||||
}
|
||||
|
||||
function openDialogEx(component: Component, options?: CustomDialogOptions) {
|
||||
|
||||
@@ -1,80 +1,51 @@
|
||||
import type { InternalApi } from 'nitropack';
|
||||
import type { WatchSource, WatchStopHandle } from 'vue';
|
||||
|
||||
type RefOrPrimitive<T> = T | Ref<T> | ComputedRef<T>
|
||||
|
||||
type NitroFetchRequest = Exclude<keyof InternalApi, `/_${string}` | `/api/_${string}`> | (string & {});
|
||||
|
||||
export type CustomFetchOptions = {
|
||||
watchProps?: WatchSource[],
|
||||
lazy?: boolean,
|
||||
method?: string,
|
||||
getBody?: () => Record<string, any>,
|
||||
watchKey?: string
|
||||
export type CustomOptions = {
|
||||
useSnapshotDates?: boolean,
|
||||
useActivePid?: boolean,
|
||||
useTimeOffset?: boolean,
|
||||
slice?: RefOrPrimitive<string>,
|
||||
limit?: RefOrPrimitive<number | string>,
|
||||
custom?: Record<string, RefOrPrimitive<string>>
|
||||
}
|
||||
|
||||
type OnResponseCallback<TData> = (data: Ref<TData | undefined>) => any
|
||||
type OnRequestCallback = () => any
|
||||
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;
|
||||
}
|
||||
|
||||
const watchStopHandles: Record<string, WatchStopHandle> = {}
|
||||
export function useComputedHeaders(customOptions?: CustomOptions) {
|
||||
const useSnapshotDates = customOptions?.useSnapshotDates || true;
|
||||
const useActivePid = customOptions?.useActivePid || true;
|
||||
const useTimeOffset = customOptions?.useTimeOffset || true;
|
||||
|
||||
export function useCustomFetch<T>(url: NitroFetchRequest, getHeaders: () => Record<string, string>, options?: CustomFetchOptions) {
|
||||
|
||||
const pending = ref<boolean>(false);
|
||||
const data = ref<T | undefined>();
|
||||
const error = ref<Error | undefined>();
|
||||
|
||||
let onResponseCallback: OnResponseCallback<T> = () => { }
|
||||
let onRequestCallback: OnRequestCallback = () => { }
|
||||
|
||||
const onResponse = (callback: OnResponseCallback<T>) => {
|
||||
onResponseCallback = callback;
|
||||
}
|
||||
|
||||
const onRequest = (callback: OnRequestCallback) => {
|
||||
onRequestCallback = callback;
|
||||
}
|
||||
|
||||
const execute = async () => {
|
||||
onRequestCallback();
|
||||
pending.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
|
||||
data.value = await $fetch<T>(url, {
|
||||
headers: getHeaders(),
|
||||
method: (options?.method || 'GET') as any,
|
||||
body: options?.getBody ? JSON.stringify(options.getBody()) : undefined
|
||||
});
|
||||
|
||||
onResponseCallback(data);
|
||||
} catch (err) {
|
||||
error.value = err as Error;
|
||||
} finally {
|
||||
pending.value = false;
|
||||
const headers = computed<Record<string, string>>(() => {
|
||||
// console.trace('Computed recalculated');
|
||||
const parsedCustom: Record<string, string> = {}
|
||||
const customKeys = Object.keys(customOptions?.custom || {});
|
||||
for (const key of customKeys) {
|
||||
parsedCustom[key] = getValueFromRefOrPrimitive((customOptions?.custom || {})[key]) ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.lazy !== true) {
|
||||
execute();
|
||||
}
|
||||
|
||||
if (options?.watchProps) {
|
||||
|
||||
const watchStop = watch(options.watchProps, () => {
|
||||
execute();
|
||||
});
|
||||
|
||||
const key = options?.watchKey || `${url}`;
|
||||
if (watchStopHandles[key]) watchStopHandles[key]();
|
||||
watchStopHandles[key] = watchStop;
|
||||
|
||||
console.log('Watchers:', Object.keys(watchStopHandles).length);
|
||||
return {
|
||||
'Authorization': `Bearer ${token.value}`,
|
||||
'x-pid': useActivePid ? (projectId.value ?? '') : '',
|
||||
'x-from': useSnapshotDates ? (safeSnapshotDates.value.from ?? '') : '',
|
||||
'x-to': useSnapshotDates ? (safeSnapshotDates.value.to ?? '') : '',
|
||||
'x-time-offset': useTimeOffset ? (new Date().getTimezoneOffset().toString()) : '',
|
||||
'x-slice': getValueFromRefOrPrimitive(customOptions?.slice) ?? '',
|
||||
'x-limit': getValueFromRefOrPrimitive(customOptions?.limit)?.toString() ?? '',
|
||||
...parsedCustom
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
|
||||
const refresh = execute;
|
||||
|
||||
return { pending, execute, data, error, refresh, onResponse, onRequest };
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
|
||||
|
||||
export function createRequestOptions(method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', sign: boolean, body?: Record<string, any>, headers: Record<string, string> = {}) {
|
||||
|
||||
|
||||
const requestHeaders = sign ? signHeaders(headers) : headers;
|
||||
let requestBody;
|
||||
|
||||
if (method === 'POST' || method == 'PUT' || method == 'PATCH') {
|
||||
requestBody = body ? JSON.stringify(body) : undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
body: requestBody
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user