Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4134d33dc4 | ||
|
|
5172ad4f4d | ||
|
|
be45448288 | ||
|
|
73739dde9d | ||
|
|
30b3ed80e2 | ||
|
|
8e56069b1a | ||
|
|
3ecdec9ca9 | ||
|
|
7b41a3ed0d | ||
|
|
5804d7a73b | ||
|
|
8b026099de | ||
|
|
d7e18d570f | ||
|
|
023f2b5f4a | ||
|
|
c003b655ec | ||
|
|
d499aa2f39 | ||
|
|
944996eb15 | ||
|
|
87b1f9caf9 | ||
|
|
748894b946 | ||
|
|
01e8a9ab1d | ||
|
|
a2034551ec | ||
|
|
6d26c3c8af | ||
|
|
518b4ce6c1 | ||
|
|
71bd4d0e58 | ||
|
|
0563a833eb | ||
|
|
ab07ffb108 | ||
|
|
79309cc537 | ||
|
|
9b9ed3e9ad | ||
|
|
1cb6b92d5c | ||
|
|
c1bdc30933 | ||
|
|
887ed45b4d | ||
|
|
1b88bad32d | ||
|
|
4c9efda9ca | ||
|
|
0c8ec73722 | ||
|
|
02db836003 | ||
|
|
46774bd114 | ||
|
|
ba1d6c4bd0 | ||
|
|
5a26c8c788 | ||
|
|
cc39043a68 | ||
|
|
93f22dfc54 | ||
|
|
376b39e247 | ||
|
|
6c32b64ac6 | ||
|
|
7cb10f5aa1 | ||
|
|
4bede171fa | ||
|
|
f72bc33871 | ||
|
|
bc27d7cded | ||
|
|
7b54c109f0 | ||
|
|
229c341d7a | ||
|
|
985b3af2e0 | ||
|
|
fc78b3bb43 | ||
|
|
af32669b32 | ||
|
|
e9505e24a0 | ||
|
|
d25bc72623 | ||
|
|
2c9f5c45f8 | ||
|
|
b5b92b947c | ||
|
|
7ae4766771 | ||
|
|
895ebb197d | ||
|
|
39b58c65ca | ||
|
|
b5f1783050 | ||
|
|
e6c9ad9470 | ||
|
|
3eb32145aa | ||
|
|
f3542f711b | ||
|
|
31c10b13b2 | ||
|
|
2e12b3ef73 | ||
|
|
10e0075044 | ||
|
|
96f4c991b1 | ||
|
|
a22aaba3fe | ||
|
|
5300da90f2 | ||
|
|
094e191900 | ||
|
|
807816d0f6 | ||
|
|
3a58b1d91c | ||
|
|
5c8f173600 | ||
|
|
669898986b | ||
|
|
cc1e1741fe | ||
|
|
d7b8e9f575 | ||
|
|
1842842029 |
47
CONTRIBUTING.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Contributing Guide
|
||||
|
||||
Contributions are welcome from anyone interested in improving this project, and there are multiple ways you can contribute that we will explain in detail. We know that working on Litlyx can be intimidating at first, so you can contact us and talk with the team on [Discord](https://discord.com/invite/9cQykjsmWX).
|
||||
|
||||
## Contributing - Code 🧑💻
|
||||
|
||||
Take a look at the [issues tab](https://github.com/litlyx/litlyx/issues) and look for those labeled with `help wanted`.
|
||||
|
||||
Please ensure that you are familiar with the technology we used in this project where it applies to your code. The technology stack used in this project includes:
|
||||
|
||||
- [TypeScript](https://www.typescriptlang.org)
|
||||
- [Nuxt](https://nuxt.com)
|
||||
- [Vue](https://vuejs.org/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [Mintlify](https://mintlify.com/)
|
||||
|
||||
## Contributing - Ideas 💫
|
||||
|
||||
If you have an **Idea** or just want to share a thought with us, open a [Discussion](https://github.com/orgs/Litlyx/discussions) to discuss the changes that you would like to make or implement yourself contributing to this project.
|
||||
|
||||
## Contributing - Documentation 📚
|
||||
|
||||
We are changing our documentation. We decided to use [Mintlify](https://mintlify.com/).
|
||||
In the future, we will need help in these areas:
|
||||
|
||||
- Use Cases
|
||||
- Community Libraries
|
||||
- General Documentation
|
||||
- Improvements in Design
|
||||
|
||||
## Code of Conduct 👮♀️
|
||||
|
||||
Please read our [Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project, you agree to follow and accept these terms.
|
||||
|
||||
## License 👩⚖️
|
||||
|
||||
By contributing to this project, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE).
|
||||
|
||||
## Discord
|
||||
|
||||
If you want to interact with the team behind Litlyx (Founding Team) join our [Discord](https://discord.com/invite/9cQykjsmWX).
|
||||
|
||||
## Note
|
||||
|
||||
Note that this file can change multiple times based on the needs of the project in future phases. If you decide to contribute, always keep an eye on this file.
|
||||
|
||||
Thank you for your interest in contributing to this project!
|
||||
141
README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/claim.png"/>
|
||||
</p>
|
||||
|
||||
<h4 align="center">
|
||||
🌐 <a href="https://litlyx.com">Website</a> 📚 <a href="https://docs.litlyx.com">Docs</a> 👾 <a href="https://discord.gg/9cQykjsmWX">Join Discord</a> 🔥 <a href="https://dashboard.litlyx.com">Start for 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!
|
||||
</p>
|
||||
|
||||
#
|
||||
|
||||
<br />
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/dashboard-clip.png"/>
|
||||
</p>
|
||||
|
||||
#
|
||||
|
||||
## Pre-Requisites on 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>
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
# All Javascript Runtimes
|
||||
|
||||
You can install Litlyx using `npm`, `yarn`, or `pnpm`:
|
||||
|
||||
```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.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/tech.png" />
|
||||
</p>
|
||||
|
||||
# Import
|
||||
|
||||
Import litlyx-js library into your code:
|
||||
|
||||
```js
|
||||
import { Lit } from 'litlyx-js';
|
||||
```
|
||||
|
||||
Once imported, you need to initialize Litlyx:
|
||||
|
||||
```js
|
||||
Lit.init('your_project_id');
|
||||
```
|
||||
|
||||
After initialization, Litlyx will automatically track analytics such as `Page visits`, `Browsers`, `Devices`, `Operating Systems`, `Real-Time Online Users`, `Unique Sessions`, `Countries`, and `Average Session Time`.
|
||||
|
||||
# Custom Events
|
||||
|
||||
You aren't just limited to the built-in KPIs. With Litlyx, you can create your own events to track in your project.
|
||||
|
||||
```js
|
||||
Lit.event('click_on_buy_item');
|
||||
```
|
||||
|
||||
If you want more specific tracking, you can use the `metadata` field, like this:
|
||||
|
||||
```js
|
||||
Lit.event('click_on_buy_item', {
|
||||
metadata: {
|
||||
'product-name': 'Coca-Cola',
|
||||
'price': 1.50,
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Litlyx makes it easy for you to tailor your analytics to your project's needs.
|
||||
|
||||
|
||||
# Fire Your First Event with cURL
|
||||
|
||||
Want to quickly see how Litlyx works with events? Use the cURL command below to send a test event. Just replace the `project_id` with your actual project ID in your terminal.
|
||||
|
||||
```bash
|
||||
curl -X POST "https://broker.litlyx.com/event" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"pid": "project_id",
|
||||
"name": "testEvent1",
|
||||
"metadata": "{\"test\": \"something\"}",
|
||||
"website": "something",
|
||||
"userAgent": "something"
|
||||
}'
|
||||
```
|
||||
|
||||
# Self-Hosting with Docker
|
||||
|
||||
To self-host the Litlyx dashboard, first **fork** this repository.
|
||||
|
||||
Then run the following command:
|
||||
```bash
|
||||
docker-compose build
|
||||
```
|
||||
|
||||
after the build finishes, run:
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
at localhost:3000 you will see your own instance of the Litlyx Dashboard.
|
||||
|
||||
# Official Docs
|
||||
|
||||
For more info read our [documentation](https://docs.litlyx.com). (will be improved in the near future using Mintlify!)
|
||||
|
||||
# Join Discord
|
||||
|
||||
If you need more information, interact with us or the community, help, or want to provide feedbacks, feel free to join us on the Litlyx [Discord](https://discord.gg/9cQykjsmWX)
|
||||
|
||||
# Contributors
|
||||
|
||||
Every kind of contribution is accepted in this stage of the project. In the future we will improve the contributor onboarding process.
|
||||
|
||||
### Thank you!
|
||||
<a href="https://github.com/litlyx/litlyx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=litlyx/litlyx" />
|
||||
</a>
|
||||
|
||||
# License
|
||||
|
||||
Litlyx is licensed under the [Apache 2.0](/LICENSE) license.
|
||||
BIN
assets/bg.png
|
Before Width: | Height: | Size: 180 KiB |
BIN
assets/claim.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/dashboard-clip.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 144 KiB |
BIN
assets/tech.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/techs.png
|
Before Width: | Height: | Size: 29 KiB |
3
broker/.gitignore
vendored
@@ -4,4 +4,5 @@ ecosystem.config.cjs
|
||||
dist
|
||||
scripts/start_dev.js
|
||||
package-lock.json
|
||||
build_all.bat
|
||||
build_all.bat
|
||||
tests
|
||||
@@ -7,9 +7,7 @@ module.exports = {
|
||||
script: './dist/producer/src/index.js',
|
||||
env: {
|
||||
EMAIL_SERVICE: "",
|
||||
EMAIL_HOST: "",
|
||||
EMAIL_USER: "",
|
||||
EMAIL_PASS: "",
|
||||
BREVO_API_KEY: "",
|
||||
PORT: "",
|
||||
MONGO_CONNECTION_STRING: "",
|
||||
REDIS_URL: "",
|
||||
|
||||
13
broker/jest.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/** @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,5 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@getbrevo/brevo": "^2.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"mongoose": "^8.3.2",
|
||||
@@ -8,13 +9,17 @@
|
||||
"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"
|
||||
},
|
||||
@@ -28,10 +33,11 @@
|
||||
"create_db": "cd scripts && ts-node create_database.ts",
|
||||
"build_all": "npm run compile && npm run build && npm run create_db",
|
||||
"docker-build": "docker build -t litlyx-broker -f Dockerfile ../",
|
||||
"docker-inspect": "docker run -it litlyx-broker sh"
|
||||
"docker-inspect": "docker run -it litlyx-broker sh",
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Emily",
|
||||
"license": "MIT",
|
||||
"description": "Queue broker for Litlyx - Saves events to database."
|
||||
}
|
||||
}
|
||||
|
||||
3218
broker/pnpm-lock.yaml
generated
@@ -6,25 +6,68 @@ import { requireEnv } from "../../shared/utilts/requireEnv";
|
||||
import { TProjectLimit } from "@schema/ProjectsLimits";
|
||||
|
||||
if (process.env.EMAIL_SERVICE) {
|
||||
EmailService.createTransport(
|
||||
requireEnv('EMAIL_SERVICE'),
|
||||
requireEnv('EMAIL_HOST'),
|
||||
requireEnv('EMAIL_USER'),
|
||||
requireEnv('EMAIL_PASS'),
|
||||
);
|
||||
EmailService.init(requireEnv('BREVO_API_KEY'));
|
||||
}
|
||||
|
||||
export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
||||
|
||||
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit / 2)) {
|
||||
const notify = await LimitNotifyModel.findOne({ project_id: projectCounts._id });
|
||||
if (notify && notify.limit1 === true) return;
|
||||
const project = await ProjectModel.findById(projectCounts.project_id);
|
||||
if (!project) return;
|
||||
const owner = await UserModel.findById(project.owner);
|
||||
if (!owner) return;
|
||||
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail50(owner.email);
|
||||
await LimitNotifyModel.updateOne({ project_id: projectCounts._id }, { limit1: true, limit2: false, limit3: false }, { upsert: true });
|
||||
console.log('CHECK LIMIT EMAIL');
|
||||
|
||||
const project_id = projectCounts.project_id;
|
||||
const hasNotifyEntry = await LimitNotifyModel.findOne({ project_id });
|
||||
if (!hasNotifyEntry) {
|
||||
await LimitNotifyModel.create({ project_id, limit1: false, limit2: false, limit3: false })
|
||||
}
|
||||
|
||||
}
|
||||
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit)) {
|
||||
console.log('LIMIT 3');
|
||||
|
||||
const notify = await LimitNotifyModel.findOne({ project_id });
|
||||
if (notify && notify.limit3 === true) return;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return;
|
||||
|
||||
const owner = await UserModel.findById(project.owner);
|
||||
if (!owner) return;
|
||||
|
||||
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmailMax(owner.email, project.name);
|
||||
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: true });
|
||||
|
||||
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.9)) {
|
||||
console.log('LIMIT 2');
|
||||
|
||||
const notify = await LimitNotifyModel.findOne({ project_id });
|
||||
if (notify && notify.limit2 === true) return;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return;
|
||||
|
||||
const owner = await UserModel.findById(project.owner);
|
||||
if (!owner) return;
|
||||
|
||||
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail90(owner.email, project.name);
|
||||
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: false });
|
||||
|
||||
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.5)) {
|
||||
|
||||
console.log('LIMIT 1');
|
||||
|
||||
const notify = await LimitNotifyModel.findOne({ project_id });
|
||||
if (notify && notify.limit1 === true) return;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return;
|
||||
|
||||
const owner = await UserModel.findById(project.owner);
|
||||
if (!owner) return;
|
||||
|
||||
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail50(owner.email, project.name);
|
||||
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: false, limit3: false });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -19,27 +19,24 @@ export async function startStreamLoop() {
|
||||
|
||||
await RedisStreamService.startReadingLoop({
|
||||
streamName: requireEnv('STREAM_NAME'),
|
||||
delay: { base: 100, empty: 5000 },
|
||||
readBlock: 2500
|
||||
delay: { base: 10, empty: 5000 },
|
||||
readBlock: 2000
|
||||
}, processStreamEvent);
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
async function processStreamEvent(data: Record<string, string>) {
|
||||
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);
|
||||
@@ -50,17 +47,23 @@ async function processStreamEvent(data: Record<string, string>) {
|
||||
}
|
||||
|
||||
|
||||
|
||||
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 projectLimits = await ProjectLimitModel.findOne({ project_id: pid });
|
||||
if (!projectLimits) return;
|
||||
|
||||
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
|
||||
const COUNT_LIMIT = projectLimits.limit;
|
||||
if ((TOTAL_COUNT) > COUNT_LIMIT * EVENT_LOG_LIMIT_PERCENT) return;
|
||||
await checkLimitsForEmail(projectLimits);
|
||||
const canLog = await checkLimits(pid);
|
||||
if (!canLog) return;
|
||||
|
||||
let referrerParsed;
|
||||
try {
|
||||
@@ -73,11 +76,13 @@ async function process_visit(data: Record<string, string>, sessionHash: string)
|
||||
|
||||
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: userAgentParsed.device.type,
|
||||
device: device ? device : (userAgentParsed.browser.name ? 'desktop' : undefined),
|
||||
session: sessionHash,
|
||||
flowHash,
|
||||
continent: geoLocation[0],
|
||||
@@ -97,6 +102,14 @@ async function process_keep_alive(data: Record<string, string>, sessionHash: str
|
||||
|
||||
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 },
|
||||
@@ -118,6 +131,9 @@ 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);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"module": "NodeNext",
|
||||
"target": "ESNext",
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"types": [
|
||||
"node",
|
||||
"jest"
|
||||
],
|
||||
"paths": {
|
||||
"@schema/*": [
|
||||
"../shared/schema/*"
|
||||
@@ -21,7 +26,9 @@
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"scripts/**/*.ts"
|
||||
"scripts/**/*.ts",
|
||||
"tests/**/*.test.ts",
|
||||
"tests/utils.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
@@ -10,9 +10,7 @@ AI_PROJECT=
|
||||
AI_KEY=
|
||||
|
||||
EMAIL_SERVICE=
|
||||
EMAIL_HOST=
|
||||
EMAIL_USER=
|
||||
EMAIL_PASS=
|
||||
BREVO_API_KEY=
|
||||
|
||||
AUTH_JWT_SECRET=
|
||||
|
||||
|
||||
6
dashboard/.gitignore
vendored
@@ -31,4 +31,8 @@ logs
|
||||
out.pdf
|
||||
|
||||
# TESTS - TO REMOVE
|
||||
tests
|
||||
tests
|
||||
|
||||
# EXPLAINS MONGODB
|
||||
|
||||
explains
|
||||
@@ -6,15 +6,53 @@ Lit.init('6643cd08a1854e3b81722ab5');
|
||||
|
||||
const debugMode = process.dev;
|
||||
|
||||
const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDialog();
|
||||
const { alerts, closeAlert } = useAlert();
|
||||
|
||||
const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dialogClosable } = useCustomDialog();
|
||||
|
||||
const { visible } = usePricingDrawer();
|
||||
|
||||
const { data: planData } = useFetch('/api/project/plan', {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="w-dvw h-dvh bg-[#151517] relative">
|
||||
<div class="w-dvw h-dvh bg-lyx-background-light relative">
|
||||
|
||||
<Transition name="pdrawer">
|
||||
<LazyPricingDrawer @onCloseClick="visible = false" :currentSub="planData?.premium_type || 0"
|
||||
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if=visible>
|
||||
</LazyPricingDrawer>
|
||||
</Transition>
|
||||
|
||||
|
||||
<div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0">
|
||||
<div v-for="alert of alerts"
|
||||
class="w-[30vw] min-w-[20rem] relative bg-[#151515] overflow-hidden border-solid border-[2px] border-[#262626] rounded-lg p-6 drop-shadow-lg">
|
||||
<div class="flex items-start gap-4">
|
||||
<div> <i :class="alert.icon"></i> </div>
|
||||
<div class="grow">
|
||||
<div class="poppins font-semibold">{{ alert.title }}</div>
|
||||
<div class="poppins">
|
||||
{{ alert.text }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<i @click="closeAlert(alert.id)" class="fas fa-close hover:text-[#CCCCCC] cursor-pointer"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="`width: ${Math.floor(100 / alert.ms * alert.remaining)}%; ${alert.transitionStyle}`"
|
||||
class="absolute bottom-0 left-0 h-1 bg-lyx-primary z-100 alert-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="debugMode"
|
||||
class="absolute bottom-8 left-4 bg-red-400 text-white text-[.9rem] font-bold px-4 py-[.2rem] rounded-lg z-[100]">
|
||||
class="absolute bottom-8 right-4 bg-red-400 text-white text-[.9rem] font-bold px-4 py-[.2rem] rounded-lg z-[100]">
|
||||
<div class="poppins flex sm:hidden"> XS </div>
|
||||
<div class="poppins hidden sm:max-md:flex"> SM - MOBILE </div>
|
||||
<div class="poppins hidden md:max-lg:flex"> MD - TABLET </div>
|
||||
@@ -24,9 +62,9 @@ const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDial
|
||||
</div>
|
||||
|
||||
<div v-if="showDialog"
|
||||
class="custom-dialog flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 w-full h-full z-[100] backdrop-blur-[2px] bg-black/50">
|
||||
<div class="bg-menu w-full h-full rounded-xl relative">
|
||||
<div class="flex justify-end absolute z-[100] right-8 top-8">
|
||||
class="custom-dialog w-full h-full flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 z-[100] backdrop-blur-[2px] bg-black/50">
|
||||
<div :style="dialogStyle" class="bg-lyx-widget rounded-xl relative outline outline-1 outline-lyx-widget-lighter">
|
||||
<div v-if="dialogClosable" class="flex justify-end absolute z-[100] right-8 top-8">
|
||||
<i @click="closeDialog()" class="fas fa-close text-[1.6rem] hover:text-gray-500 cursor-pointer"></i>
|
||||
</div>
|
||||
<div class="flex items-center justify-center w-full h-full p-4">
|
||||
@@ -41,3 +79,20 @@ const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDial
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pdrawer-enter-active,
|
||||
.pdrawer-leave-active {
|
||||
transition: all .5s ease-in-out;
|
||||
}
|
||||
|
||||
.pdrawer-enter-from,
|
||||
.pdrawer-leave-to {
|
||||
transform: translateX(100%)
|
||||
}
|
||||
|
||||
.pdrawer-enter-to,
|
||||
.pdrawer-leave-from {
|
||||
transform: translateX(0)
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0');
|
||||
|
||||
|
||||
@font-face {
|
||||
font-family: "Geist";
|
||||
|
||||
@@ -80,6 +80,7 @@ const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, op
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext("2d");
|
||||
let gradient: any = `${props.color}22`;
|
||||
@@ -95,7 +96,6 @@ onMounted(async () => {
|
||||
chartData.value.datasets[0].backgroundColor = [gradient];
|
||||
|
||||
watch(props, () => {
|
||||
console.log('UPDATE')
|
||||
chartData.value.labels = props.labels;
|
||||
chartData.value.datasets[0].data = props.data;
|
||||
});
|
||||
@@ -106,5 +106,5 @@ onMounted(async () => {
|
||||
|
||||
|
||||
<template>
|
||||
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { TProject } from '@schema/ProjectSchema';
|
||||
import CreateSnapshot from './dialog/CreateSnapshot.vue';
|
||||
|
||||
export type Entry = {
|
||||
label: string,
|
||||
@@ -8,7 +10,9 @@ export type Entry = {
|
||||
icon?: string,
|
||||
action?: () => any,
|
||||
adminOnly?: boolean,
|
||||
external?: boolean
|
||||
premiumOnly?:boolean,
|
||||
external?: boolean,
|
||||
grow?: boolean
|
||||
}
|
||||
|
||||
export type Section = {
|
||||
@@ -29,47 +33,254 @@ const debugMode = process.dev;
|
||||
|
||||
const { isOpen, close } = useMenu();
|
||||
|
||||
const { snapshots, snapshot, updateSnapshots } = useSnapshot();
|
||||
|
||||
const snapshotsItems = computed(() => {
|
||||
if (!snapshots.value) return []
|
||||
return snapshots.value as any[];
|
||||
})
|
||||
|
||||
|
||||
const { openDialogEx } = useCustomDialog();
|
||||
|
||||
function openSnapshotDialog() {
|
||||
openDialogEx(CreateSnapshot, {
|
||||
width: "24rem",
|
||||
height: "16rem",
|
||||
closable: false
|
||||
});
|
||||
}
|
||||
|
||||
const { createAlert } = useAlert()
|
||||
|
||||
async function deleteSnapshot(close: () => any) {
|
||||
await $fetch("/api/snapshot/delete", {
|
||||
method: 'DELETE',
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
id: snapshot.value._id.toString(),
|
||||
})
|
||||
});
|
||||
await updateSnapshots();
|
||||
snapshot.value = snapshots.value[1];
|
||||
createAlert('Snapshot deleted', 'Snapshot deleted successfully', 'far fa-circle-check', 5000);
|
||||
close();
|
||||
}
|
||||
|
||||
async function generatePDF() {
|
||||
|
||||
try {
|
||||
const res = await $fetch<Blob>('/api/project/generate_pdf', {
|
||||
...signHeaders({
|
||||
'x-snapshot-name': snapshot.value.name,
|
||||
'x-from': snapshot.value.from.toISOString(),
|
||||
'x-to': snapshot.value.to.toISOString(),
|
||||
}),
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(res);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `Report.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
const { setToken } = useAccessToken();
|
||||
const router = useRouter();
|
||||
|
||||
function onLogout() {
|
||||
console.log('LOGOUT')
|
||||
setToken('');
|
||||
setLoggedUser(undefined);
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
const { projects } = useProjectsList();
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
|
||||
const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||
headers: computed(() => {
|
||||
return {
|
||||
Authorization: authorizationHeaderComputed.value
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const selected = ref<TProject>(activeProject.value as TProject);
|
||||
watch(selected, () => {
|
||||
setActiveProject(selected.value._id.toString())
|
||||
})
|
||||
|
||||
const isPremium = computed(()=>{
|
||||
return activeProject.value?.premium;
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="CVerticalNavigation h-full w-[20rem] bg-[#111111] flex shadow-[1px_0_10px_#000000] rounded-r-lg" :class="{
|
||||
'absolute top-0 w-full md:w-[20rem] z-[45] open': isOpen,
|
||||
'hidden lg:flex': !isOpen
|
||||
}">
|
||||
<div class="p-4 gap-6 flex flex-col w-full">
|
||||
<div class="CVerticalNavigation h-full w-[20rem] bg-lyx-background flex shadow-[1px_0_10px_#000000] rounded-r-lg"
|
||||
:class="{
|
||||
'absolute top-0 w-full md:w-[20rem] z-[45] open': isOpen,
|
||||
'hidden lg:flex': !isOpen
|
||||
}">
|
||||
<div class="py-4 px-2 gap-6 flex flex-col w-full">
|
||||
|
||||
<div class="flex items-center gap-2 ml-2">
|
||||
<div class="bg-black h-[2.4rem] aspect-[1/1] flex items-center justify-center rounded-lg">
|
||||
<img class="h-[2rem]" :src="'/logo.png'">
|
||||
</div>
|
||||
<div class="font-bold text-[1.4rem] text-gray-300"> Litlyx </div>
|
||||
<div class="flex px-2 flex-col">
|
||||
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
|
||||
<USelectMenu :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" class="w-full" v-if="projects" v-model="selected" :options="projects">
|
||||
|
||||
<template #option="{ option, active, selected }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
|
||||
</div>
|
||||
<div> {{ option.name }} </div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
|
||||
</div>
|
||||
<div> {{ activeProject?.name || '???' }} </div>
|
||||
</div>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
|
||||
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
|
||||
<i @click="close()" class="fas fa-close"></i>
|
||||
</div>
|
||||
|
||||
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
|
||||
<i @click="close()" class="fas fa-close"></i>
|
||||
</div>
|
||||
|
||||
<NuxtLink to="/project_creation" v-if="projects && (projects.length < (maxProjects || 1))"
|
||||
class="flex items-center text-[.8rem] gap-1 justify-end pt-2 pr-2 text-lyx-text-dark hover:text-lyx-text cursor-pointer">
|
||||
<div><i class="fas fa-plus"></i></div>
|
||||
<div> Create new project </div>
|
||||
</NuxtLink>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div v-for="section of sections" class="flex flex-col gap-1">
|
||||
|
||||
<div v-for="entry of section.entries">
|
||||
<div class="w-full flex-col px-2">
|
||||
|
||||
<div class="flex mb-2 items-center justify-between">
|
||||
<div class="poppins text-[.8rem]">
|
||||
Snapshots
|
||||
</div>
|
||||
<div @click="openSnapshotDialog()"
|
||||
class="poppins text-[.8rem] px-2 rounded-lg outline outline-[2px] outline-lyx-widget-lighter cursor-pointer hover:bg-lyx-widget-lighter">
|
||||
<i class="far fa-plus"></i>
|
||||
Add
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<USelectMenu :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" class="w-full" v-model="snapshot" :options="snapshotsItems">
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div :style="'background-color:' + snapshot?.color" class="w-2 h-2 rounded-full">
|
||||
</div>
|
||||
<div class="poppins"> {{ snapshot?.name }} </div>
|
||||
</div>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div :style="'background-color:' + option.color" class="w-2 h-2 rounded-full">
|
||||
</div>
|
||||
<div class="poppins"> {{ option.name }} </div>
|
||||
</div>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
|
||||
<div v-if="snapshot" class="flex flex-col text-[.8rem] mt-2">
|
||||
<div class="flex">
|
||||
<div class="grow poppins"> From:</div>
|
||||
<div class="poppins"> {{ new Date(snapshot.from).toLocaleString('it-IT').split(',')[0].trim() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="grow poppins"> To:</div>
|
||||
<div class="poppins"> {{ new Date(snapshot.to).toLocaleString('it-IT').split(',')[0].trim() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LyxUiButton @click="generatePDF()" type="secondary" class="w-full text-center mt-4">
|
||||
Download report
|
||||
</LyxUiButton>
|
||||
|
||||
<div class="mt-2" v-if="snapshot._id.toString().startsWith('default') === false">
|
||||
<UPopover placement="bottom">
|
||||
<LyxUiButton type="danger" class="w-full text-center">
|
||||
Delete current snapshot
|
||||
</LyxUiButton>
|
||||
|
||||
<template #panel="{ close }">
|
||||
<div class="p-4 bg-lyx-widget">
|
||||
<div class="poppins text-center font-medium">
|
||||
Are you sure?
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<LyxUiButton @click="close()" type="secondary"> Cancel </LyxUiButton>
|
||||
<LyxUiButton type="danger" @click="deleteSnapshot(close)"> Delete </LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-lyx-widget-lighter h-[2px] w-full"></div>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
|
||||
<div v-for="section of sections" class="flex flex-col gap-1 h-full pb-6">
|
||||
|
||||
<div v-for="entry of section.entries" :class="{ 'grow flex items-end': entry.grow }">
|
||||
|
||||
<div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))"
|
||||
class="bg-[#111111] text-gray-300 hover:bg-[#1b1b1b] py-2 px-4 rounded-lg" :class="{
|
||||
'text-gray-700 pointer-events-none': entry.disabled,
|
||||
'bg-[#1b1b1b]': route.path == (entry.to || '#')
|
||||
class="bg-lyx-background cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
|
||||
:class="{
|
||||
'!text-lyx-text-darker pointer-events-none': entry.disabled,
|
||||
'bg-lyx-background-lighter !text-lyx-text/90': route.path == (entry.to || '#'),
|
||||
'hover:bg-lyx-background-light hover:!text-lyx-text/90': route.path != (entry.to || '#'),
|
||||
}">
|
||||
|
||||
<NuxtLink @click="close() && entry.action?.()" :target="entry.external ? '_blank' : ''"
|
||||
tag="div" class="flex" :to="entry.to || '/'">
|
||||
<div class="flex items-center w-[1.8rem] justify-start">
|
||||
tag="div" class="w-full flex items-center" :to="entry.to || '/'">
|
||||
<div class="flex items-center w-[1.4rem] mr-2 text-[1.1rem] justify-center">
|
||||
<i :class="entry.icon"></i>
|
||||
</div>
|
||||
<div class="manrope">
|
||||
<div class="manrope grow">
|
||||
{{ entry.label }}
|
||||
</div>
|
||||
<div v-if="entry.premiumOnly && !isPremium" class="flex items-center">
|
||||
<i class="fal fa-lock"></i>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
</div>
|
||||
@@ -78,6 +289,42 @@ const { isOpen, close } = useMenu();
|
||||
|
||||
</div>
|
||||
|
||||
<div class="grow"></div>
|
||||
<div class="bg-lyx-widget-lighter h-[2px] px-4 w-full mb-3"></div>
|
||||
<div class="flex justify-end px-2">
|
||||
|
||||
<div class="grow flex gap-3">
|
||||
<NuxtLink to="https://github.com/litlyx/litlyx" target="_blank"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fab fa-github"></i>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="https://discord.gg/9cQykjsmWX" target="_blank"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fab fa-discord"></i>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="https://x.com/litlyx" target="_blank"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fab fa-x-twitter"></i>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="https://dev.to/litlyx-org" target="_blank"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fab fa-dev"></i>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin" v-if="isAdmin"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fas fa-cat"></i>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<UTooltip text="Logout" :popper="{ arrow: true, placement: 'top' }">
|
||||
<div @click="onLogout()" class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="far fa-arrow-right-from-bracket scale-x-[-100%]"></i>
|
||||
</div>
|
||||
</UTooltip>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,10 +332,6 @@ const { isOpen, close } = useMenu();
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.CVerticalNavigation * {
|
||||
font-family: 'Geist';
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ const props = defineProps<{ title: string, sub?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<LyxUiCard>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-col grow">
|
||||
@@ -23,5 +23,5 @@ const props = defineProps<{ title: string, sub?: string }>();
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
28
dashboard/components/CustomTab.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
type CItem = { label: string, slot: string }
|
||||
const props = defineProps<{ items: CItem[] }>();
|
||||
|
||||
const activeTabIndex = ref<number>(0);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex">
|
||||
<div v-for="(tab, index) of items" @click="activeTabIndex = index"
|
||||
class="px-6 pb-3 poppins font-medium text-lyx-text-darker border-b-[1px] border-lyx-text-darker" :class="{
|
||||
'!border-[#88A7FF] !text-[#88A7FF]': activeTabIndex === index,
|
||||
'hover:border-lyx-text-dark hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
|
||||
}">
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
<div class="border-b-[1px] border-lyx-text-darker w-full">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<slot :name="props.items[activeTabIndex].slot"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
63
dashboard/components/DatePicker.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { DatePicker as VCalendarDatePicker } from 'v-calendar'
|
||||
import type { DatePickerDate, DatePickerRangeObject } from 'v-calendar/dist/types/src/use/datePicker'
|
||||
import 'v-calendar/dist/style.css'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Date, Object] as PropType<DatePickerDate | DatePickerRangeObject | null>,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:model-value', 'close'])
|
||||
|
||||
const date = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
emit('update:model-value', value)
|
||||
emit('close')
|
||||
}
|
||||
})
|
||||
|
||||
const attrs = {
|
||||
transparent: true,
|
||||
borderless: true,
|
||||
color: 'primary',
|
||||
'is-dark': { selector: 'html', darkClass: 'dark' },
|
||||
'first-day-of-week': 2,
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCalendarDatePicker v-if="date && (typeof date === 'object')" v-model.range="date" :columns="2" v-bind="{ ...attrs, ...$attrs }" />
|
||||
<VCalendarDatePicker v-else v-model="date" v-bind="{ ...attrs, ...$attrs }" />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--vc-gray-50: rgb(var(--color-gray-50));
|
||||
--vc-gray-100: rgb(var(--color-gray-100));
|
||||
--vc-gray-200: rgb(var(--color-gray-200));
|
||||
--vc-gray-300: rgb(var(--color-gray-300));
|
||||
--vc-gray-400: rgb(var(--color-gray-400));
|
||||
--vc-gray-500: rgb(var(--color-gray-500));
|
||||
--vc-gray-600: rgb(var(--color-gray-600));
|
||||
--vc-gray-700: rgb(var(--color-gray-700));
|
||||
--vc-gray-800: rgb(var(--color-gray-800));
|
||||
--vc-gray-900: rgb(var(--color-gray-900));
|
||||
}
|
||||
|
||||
.vc-primary {
|
||||
--vc-accent-50: rgb(var(--color-primary-50));
|
||||
--vc-accent-100: rgb(var(--color-primary-100));
|
||||
--vc-accent-200: rgb(var(--color-primary-200));
|
||||
--vc-accent-300: rgb(var(--color-primary-300));
|
||||
--vc-accent-400: rgb(var(--color-primary-400));
|
||||
--vc-accent-500: rgb(var(--color-primary-500));
|
||||
--vc-accent-600: rgb(var(--color-primary-600));
|
||||
--vc-accent-700: rgb(var(--color-primary-700));
|
||||
--vc-accent-800: rgb(var(--color-primary-800));
|
||||
--vc-accent-900: rgb(var(--color-primary-900));
|
||||
}
|
||||
</style>
|
||||
@@ -50,112 +50,99 @@ function openExternalLink(link: string) {
|
||||
|
||||
<template>
|
||||
|
||||
<div class="flex h-full">
|
||||
|
||||
<div class="text-text flex flex-col items-start gap-4 w-full relative">
|
||||
|
||||
<div class="w-full h-full p-4 flex flex-col bg-card rounded-xl gap-8 card-shadow">
|
||||
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="poppins font-semibold text-[1.4rem] text-text">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i @click="reloadData()"
|
||||
class="hover:rotate-[50deg] transition-all duration-100 fas fa-refresh text-[1.2rem] cursor-pointer"></i>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="poppins text-[1rem] text-text-sub/90">
|
||||
{{ desc }}
|
||||
</div>
|
||||
<LyxUiCard class="w-full h-full p-4 flex flex-col gap-8 relative">
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="poppins font-semibold text-[1.4rem] text-text">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div v-if="rawButton" class="hidden lg:flex">
|
||||
<div @click="$emit('showRawData')"
|
||||
class="cursor-pointer flex gap-1 items-center justify-center font-semibold poppins rounded-lg text-[#5680f8] hover:text-[#5681f8ce]">
|
||||
<div> Raw data </div>
|
||||
<div class="flex items-center"> <i class="fas fa-arrow-up-right"></i> </div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i @click="reloadData()"
|
||||
class="hover:rotate-[50deg] transition-all duration-100 fas fa-refresh text-[1.2rem] cursor-pointer"></i>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="isDetailView" class="flex items-center justify-center">
|
||||
<i @click="$emit('showGeneral')"
|
||||
class="fas fa-arrow-left text-[.9rem] hover:text-text cursor-pointer"></i>
|
||||
</div>
|
||||
<div> {{ subLabel }} </div>
|
||||
</div>
|
||||
<div> Count </div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
|
||||
<div v-if="props.data.length > 0" class="flex justify-between items-center"
|
||||
v-for="element of props.data">
|
||||
|
||||
<div class="flex items-center gap-2 w-10/12 relative">
|
||||
|
||||
<div v-if="showLink">
|
||||
<i @click="openExternalLink(element._id)"
|
||||
class="fas fa-link text-gray-300 hover:text-gray-400 cursor-pointer"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 items-center" @click="showDetails(element._id)"
|
||||
:class="{ 'cursor-pointer line-active': interactive }">
|
||||
|
||||
<div class="absolute rounded-sm w-full h-full bg-[#92abcf38]"
|
||||
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
|
||||
|
||||
<div class="flex px-2 py-1 relative items-center gap-4">
|
||||
<div v-if="iconProvider && iconProvider(element._id) != undefined"
|
||||
class="flex items-center h-[1.3rem]">
|
||||
|
||||
<img v-if="iconProvider(element._id)?.[0] == 'img'" class="h-full"
|
||||
:style="customIconStyle" :src="iconProvider(element._id)?.[1]">
|
||||
|
||||
<i v-else :class="iconProvider(element._id)?.[1]"></i>
|
||||
</div>
|
||||
<span
|
||||
class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
|
||||
{{ elementTextTransformer?.(element._id) || element._id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{
|
||||
formatNumberK(element.count) }} </div>
|
||||
</div>
|
||||
<div v-if="props.data.length == 0"
|
||||
class="flex justify-center text-text-sub font-bold text-[1.1rem]">
|
||||
No visits yet
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 ">
|
||||
<div @click="$emit('showMore')"
|
||||
class="poppins hover:bg-black cursor-pointer w-fit px-6 py-1 rounded-lg border-[1px] border-text-sub text-[.9rem]">
|
||||
Show more
|
||||
</div>
|
||||
</div>
|
||||
<div class="poppins text-[1rem] text-text-sub/90">
|
||||
{{ desc }}
|
||||
</div>
|
||||
|
||||
<div v-if="loading"
|
||||
class="backdrop-blur-[1px] z-[20] left-0 top-0 w-full h-full flex items-center justify-center font-bold rockmann absolute">
|
||||
<i
|
||||
class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
<div v-if="rawButton" class="hidden lg:flex">
|
||||
<div @click="$emit('showRawData')"
|
||||
class="cursor-pointer flex gap-1 items-center justify-center font-semibold poppins rounded-lg text-[#5680f8] hover:text-[#5681f8ce]">
|
||||
<div> Raw data </div>
|
||||
<div class="flex items-center"> <i class="fas fa-arrow-up-right"></i> </div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="isDetailView" class="flex items-center justify-center">
|
||||
<i @click="$emit('showGeneral')"
|
||||
class="fas fa-arrow-left text-[.9rem] hover:text-text cursor-pointer"></i>
|
||||
</div>
|
||||
<div> {{ subLabel }} </div>
|
||||
</div>
|
||||
<div> Count </div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
|
||||
<div v-if="props.data.length > 0" class="flex justify-between items-center"
|
||||
v-for="element of props.data">
|
||||
|
||||
<div class="flex items-center gap-2 w-10/12 relative">
|
||||
|
||||
<div v-if="showLink">
|
||||
<i @click="openExternalLink(element._id)"
|
||||
class="fas fa-link text-gray-300 hover:text-gray-400 cursor-pointer"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 items-center" @click="showDetails(element._id)"
|
||||
:class="{ 'cursor-pointer line-active': interactive }">
|
||||
|
||||
<div class="absolute rounded-sm w-full h-full bg-[#92abcf38]"
|
||||
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
|
||||
|
||||
<div class="flex px-2 py-1 relative items-center gap-4">
|
||||
<div v-if="iconProvider && iconProvider(element._id) != undefined"
|
||||
class="flex items-center h-[1.3rem]">
|
||||
|
||||
<img v-if="iconProvider(element._id)?.[0] == 'img'" class="h-full"
|
||||
:style="customIconStyle" :src="iconProvider(element._id)?.[1]">
|
||||
|
||||
<i v-else :class="iconProvider(element._id)?.[1]"></i>
|
||||
</div>
|
||||
<span class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
|
||||
{{ elementTextTransformer?.(element._id) || element._id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{
|
||||
formatNumberK(element.count) }} </div>
|
||||
</div>
|
||||
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-bold text-[1.1rem]">
|
||||
No visits yet
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 ">
|
||||
<div @click="$emit('showMore')"
|
||||
class="poppins hover:bg-black cursor-pointer w-fit px-6 py-1 rounded-lg border-[1px] border-text-sub text-[.9rem]">
|
||||
Show more
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading"
|
||||
class="backdrop-blur-[1px] z-[20] left-0 top-0 w-full h-full flex items-center justify-center font-bold rockmann absolute">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,39 +1,46 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { BrowsersAggregated } from '~/server/api/metrics/[project_id]/data/browsers';
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: events, pending, refresh } = await useFetch<BrowsersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
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 = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data;
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
dialogBarData.value = browsersData.data.value || [];
|
||||
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
browsersData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []"
|
||||
desc="The browsers most used to search your website." :dataIcons="false" :loading="pending"
|
||||
label="Top Browsers" sub-label="Browsers"></DashboardBarsCard>
|
||||
<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>
|
||||
|
||||
@@ -16,8 +16,8 @@ const props = defineProps<{
|
||||
|
||||
<template>
|
||||
|
||||
<Card class="flex flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
|
||||
<div class="flex p-4 items-start">
|
||||
<LyxUiCard class="flex !p-0 flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
|
||||
<div v-if="ready" class="flex p-4 items-start">
|
||||
<div class="flex items-center mt-2 mr-4">
|
||||
<i :style="`color: ${props.color}`" :class="icon" class="text-[1.6rem] 2xl:text-[2rem]"></i>
|
||||
</div>
|
||||
@@ -36,36 +36,20 @@ const props = defineProps<{
|
||||
{{ trend.toFixed(0) }} %
|
||||
</div>
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.7rem]"> Daily variation </div>
|
||||
<div class="poppins text-text-sub text-[.7rem]"> Trend </div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end" v-if="(props.data?.length || 0) > 0">
|
||||
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end"
|
||||
v-if="((props.data?.length || 0) > 0) && ready">
|
||||
<DashboardEmbedChartCard v-if="ready" :data="props.data || []" :labels="props.labels || []"
|
||||
:color="props.color">
|
||||
</DashboardEmbedChartCard>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- <div class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full lg:w-[20rem] relative pb-2 lg:pb-4">
|
||||
|
||||
<div class="gap-4 flex flex-row items-center lg:items-start lg:gap-2 lg:flex-col">
|
||||
<div class="w-[2.5rem] h-[2.5rem] lg:w-[3.5rem] lg:h-[3.5rem] flex items-center justify-center rounded-lg"
|
||||
:style="`background: ${props.color}`">
|
||||
<i :class="icon" class="text-[1rem] lg:text-[1.5rem]"></i>
|
||||
</div>
|
||||
<div class="text-[1rem] lg:text-[1.3rem] text-text-sub/90 poppins">
|
||||
{{ title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center lg:items-end">
|
||||
<div class="brockmann text-text text-[2rem] lg:text-[2.8rem] grow">
|
||||
{{ text }}
|
||||
</div>
|
||||
<div class="poppins text-text-sub/90 text-[.9rem] lg:text-[1rem]"> {{ sub }} </div>
|
||||
<div v-if="!ready" class="flex justify-center items-center w-full h-full">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
|
||||
|
||||
</div> -->
|
||||
|
||||
</template>
|
||||
@@ -1,39 +1,46 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { DevicesAggregated } from '~/server/api/metrics/[project_id]/data/devices';
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: events, pending, refresh } = await useFetch<DevicesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/devices`, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
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 = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/devices`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data;
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
dialogBarData.value = devicesData.data.value || [];
|
||||
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
devicesData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []" :dataIcons="false"
|
||||
desc="The devices most used to access your website." :loading="pending" label="Top Devices"
|
||||
<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>
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { CustomEventsAggregated } from '~/server/api/metrics/[project_id]/visits/events';
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: events, pending, refresh } = await useFetch<CustomEventsAggregated[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goToView() {
|
||||
router.push('/dashboard/events');
|
||||
}
|
||||
|
||||
const 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 = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data;
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
|
||||
dialogBarData.value = eventsData.data.value || [];
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
eventsData.execute();
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -39,7 +44,8 @@ function showMore() {
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<DashboardBarsCard @showMore="showMore()" @showRawData="goToView()"
|
||||
desc="Most frequent user events triggered in this project" @dataReload="refresh" :data="events || []"
|
||||
:loading="pending" label="Top Events" sub-label="Events" :rawButton="!isLiveDemo()"></DashboardBarsCard>
|
||||
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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Chart, registerables, type ChartData, type ChartOptions } from 'chart.js';
|
||||
import { DoughnutChart, useDoughnutChart } from 'vue-chart-3';
|
||||
import type { EventsPie } from '~/server/api/metrics/[project_id]/events_pie';
|
||||
import type { CustomEventsAggregated } from '~/server/api/metrics/[project_id]/data/events';
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
@@ -20,15 +20,6 @@ const chartOptions = ref<ChartOptions<'doughnut'>>({
|
||||
ticks: { display: false },
|
||||
grid: { display: false, drawBorder: false },
|
||||
},
|
||||
// r: {
|
||||
// ticks: { display: false },
|
||||
// grid: {
|
||||
// display: true,
|
||||
// drawBorder: false,
|
||||
// color: '#CCCCCC22',
|
||||
// borderDash: [20, 8]
|
||||
// },
|
||||
// }
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
@@ -55,7 +46,7 @@ const chartData = ref<ChartData<'doughnut'>>({
|
||||
{
|
||||
rotation: 1,
|
||||
data: [],
|
||||
backgroundColor: ['#6bbbe3','#5655d0', '#a6d5cb', '#fae0b9'],
|
||||
backgroundColor: ['#6bbbe3', '#5655d0', '#a6d5cb', '#fae0b9'],
|
||||
borderColor: ['#1d1d1f'],
|
||||
borderWidth: 2
|
||||
},
|
||||
@@ -65,15 +56,18 @@ const chartData = ref<ChartData<'doughnut'>>({
|
||||
|
||||
const { doughnutChartProps, doughnutChartRef } = useDoughnutChart({ chartData: chartData, options: chartOptions });
|
||||
|
||||
onMounted(async () => {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const activeProject = useActiveProject()
|
||||
const { safeSnapshotDates } = useSnapshot();
|
||||
|
||||
const eventsData = await $fetch<EventsPie[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, signHeaders());
|
||||
chartData.value.labels = eventsData.map(e => {
|
||||
|
||||
|
||||
function transformResponse(input: CustomEventsAggregated[]) {
|
||||
|
||||
chartData.value.labels = input.map(e => {
|
||||
return `${e._id}`;
|
||||
});
|
||||
chartData.value.datasets[0].data = eventsData.map(e => e.count);
|
||||
chartData.value.datasets[0].data = input.map(e => e.count);
|
||||
doughnutChartRef.value?.update();
|
||||
|
||||
if (window.innerWidth < 800) {
|
||||
@@ -81,11 +75,34 @@ onMounted(async () => {
|
||||
chartOptions.value.plugins.legend.display = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
eventsData.execute();
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<DoughnutChart v-bind="doughnutChartProps"> </DoughnutChart>
|
||||
<div>
|
||||
<div v-if="eventsData.pending.value" class="flex justify-center py-40">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
<DoughnutChart v-if="!eventsData.pending.value" v-bind="doughnutChartProps"> </DoughnutChart>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { CountriesAggregated } from '~/server/api/metrics/[project_id]/data/countries';
|
||||
import type { IconProvider } from './BarsCard.vue';
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: countries, pending, refresh } = await useFetch<CountriesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/countries`, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
});
|
||||
|
||||
function iconProvider(id: string): ReturnType<IconProvider> {
|
||||
if (id === 'self') return ['icon', 'fas fa-link'];
|
||||
return [
|
||||
@@ -19,31 +12,51 @@ function iconProvider(id: string): ReturnType<IconProvider> {
|
||||
|
||||
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 = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/countries`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data;
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
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="refresh" :data="countries || []" :dataIcons="false"
|
||||
:loading="pending" label="Top Countries" sub-label="Countries" :iconProvider="iconProvider"
|
||||
<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>
|
||||
|
||||
@@ -1,39 +1,44 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { OssAggregated } from '~/server/api/metrics/[project_id]/data/oss';
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: events, pending, refresh } = await useFetch<OssAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/oss`, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
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 = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/oss`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data;
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
|
||||
dialogBarData.value = ossData.data.value || [];
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
ossData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []"
|
||||
<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="pending" label="Top OS" sub-label="OSs"></DashboardBarsCard>
|
||||
:loading="ossData.pending.value" label="Top OS" sub-label="OSs"></DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { ReferrersAggregated } from '~/server/api/metrics/[project_id]/data/referrers';
|
||||
import type { IconProvider } from './BarsCard.vue';
|
||||
import ReferrerBarChart from '../referrer/ReferrerBarChart.vue';
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: events, pending, refresh } = await useFetch<ReferrersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
});
|
||||
|
||||
|
||||
function iconProvider(id: string): ReturnType<IconProvider> {
|
||||
if (id === 'self') return ['icon', 'fas fa-link'];
|
||||
return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${id}&sz=64`]
|
||||
@@ -21,45 +13,56 @@ function elementTextTransformer(element: string) {
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
// const customDialog = useCustomDialog();
|
||||
|
||||
// function onShowDetails(referrer: string) {
|
||||
// customDialog.openDialog(ReferrerBarChart, { slice: 'day', referrer });
|
||||
// }
|
||||
|
||||
function showMore() {
|
||||
|
||||
|
||||
isShowMore.value = true;
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data.map(e => {
|
||||
return { ...e, icon: iconProvider(e._id) }
|
||||
});
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
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 @showDetails="onShowDetails" @showMore="showMore()"
|
||||
:elementTextTransformer="elementTextTransformer" :iconProvider="iconProvider" @dataReload="refresh"
|
||||
:showLink=true :data="events || []" :interactive="true" desc="Where users find your website."
|
||||
:dataIcons="true" :loading="pending" label="Top Referrers" sub-label="Referrers"></DashboardBarsCard>
|
||||
<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>
|
||||
|
||||
@@ -1,31 +1,46 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
|
||||
const data = ref<number[]>([]);
|
||||
const labels = ref<string[]>([]);
|
||||
const ready = ref<boolean>(false);
|
||||
|
||||
const props = defineProps<{ slice: Slice }>();
|
||||
|
||||
async function loadData() {
|
||||
const response = await useTimeline('sessions', props.slice);
|
||||
if (!response) return;
|
||||
data.value = response.map(e => e.count);
|
||||
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
ready.value = true;
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
const data = input.map(e => e.count);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
return { data, labels }
|
||||
}
|
||||
|
||||
const body = computed(() => {
|
||||
return {
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to,
|
||||
slice: props.slice
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const sessionsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
watch(props, async () => { await loadData(); });
|
||||
})
|
||||
sessionsData.execute();
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AdvancedLineChart v-if="ready" :data="data" :labels="labels" color="#f56523"></AdvancedLineChart>
|
||||
<div v-if="sessionsData.pending.value" class="flex justify-center py-40">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
<AdvancedLineChart v-if="!sessionsData.pending.value" :data="sessionsData.data.value?.data || []"
|
||||
:labels="sessionsData.data.value?.labels || []" color="#f56523"></AdvancedLineChart>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,27 +1,37 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import DateService from '@services/DateService';
|
||||
import type { Slice } from '@services/DateService';
|
||||
|
||||
const { data: metricsInfo } = useMetricsData();
|
||||
|
||||
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 snapshotDays = computed(() => {
|
||||
return (snapshotTo.value - snapshotFrom.value) / 1000 / 60 / 60 / 24;
|
||||
});
|
||||
|
||||
const avgVisitDay = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24;
|
||||
const avg = metricsInfo.value.visitsCount / Math.max(days, 1);
|
||||
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 (!metricsInfo.value) return '0.00';
|
||||
const days = (Date.now() - (metricsInfo.value?.firstEventDate || 0)) / 1000 / 60 / 60 / 24;
|
||||
const avg = metricsInfo.value.eventsCount / Math.max(days, 1);
|
||||
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);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
const avgSessionsDay = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24;
|
||||
const avg = metricsInfo.value.sessionsVisitsCount / Math.max(days, 1);
|
||||
if (!sessionsData.data.value) return '0.00';
|
||||
const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||
const avg = counts / Math.max(snapshotDays.value, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
@@ -29,92 +39,105 @@ const avgSessionsDay = computed(() => {
|
||||
const avgSessionDuration = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const avg = metricsInfo.value.avgSessionDuration;
|
||||
|
||||
let hours = 0;
|
||||
let minutes = 0;
|
||||
let seconds = 0;
|
||||
seconds += avg * 60;
|
||||
|
||||
while (seconds > 60) {
|
||||
seconds -= 60;
|
||||
minutes += 1;
|
||||
}
|
||||
|
||||
while (minutes > 60) {
|
||||
minutes -= 60;
|
||||
hours += 1;
|
||||
}
|
||||
|
||||
|
||||
while (seconds > 60) { seconds -= 60; minutes += 1; }
|
||||
while (minutes > 60) { minutes -= 60; hours += 1; }
|
||||
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
|
||||
});
|
||||
|
||||
type Data = {
|
||||
data: number[],
|
||||
labels: string[],
|
||||
trend: number,
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
|
||||
const visitsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
|
||||
const eventsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
|
||||
const sessionsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
|
||||
const sessionsDurationData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
|
||||
|
||||
async function loadData(timelineEndpointName: string, target: Data) {
|
||||
|
||||
const response = await useTimeline(timelineEndpointName as any, 'day');
|
||||
if (!response) return;
|
||||
target.data = response.map(e => e.count);
|
||||
target.labels = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, 'day'));
|
||||
|
||||
const pool = [...response.map(e => e.count)];
|
||||
pool.pop();
|
||||
const avg = pool.reduce((a, e) => a + e, 0) / pool.length;
|
||||
|
||||
const diffPercent: number = (100 / avg * (response.at(-1)?.count || 0)) - 100;
|
||||
|
||||
target.trend = Math.max(Math.min(diffPercent, 99), -99);
|
||||
|
||||
target.ready = true;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
await loadData('visits', visitsData);
|
||||
await loadData('events', eventsData);
|
||||
await loadData('sessions', sessionsData);
|
||||
await loadData('sessions_duration', sessionsDurationData);
|
||||
|
||||
const 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();
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4" v-if="metricsInfo">
|
||||
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4">
|
||||
|
||||
<DashboardCountCard :ready="visitsData.ready" icon="far fa-earth" text="Total page visits"
|
||||
:value="formatNumberK(metricsInfo.visitsCount)" :avg="formatNumberK(avgVisitDay) + '/day'"
|
||||
:trend="visitsData.trend" :data="visitsData.data" :labels="visitsData.labels" color="#5655d7">
|
||||
<DashboardCountCard :ready="!visitsData.pending.value" icon="far fa-earth" text="Total page visits"
|
||||
:value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
:avg="formatNumberK(avgVisitDay) + '/day'" :trend="visitsData.data.value?.trend"
|
||||
:data="visitsData.data.value?.data" :labels="visitsData.data.value?.labels" color="#5655d7">
|
||||
</DashboardCountCard>
|
||||
|
||||
<DashboardCountCard :ready="eventsData.ready" icon="far fa-flag" text="Total custom events"
|
||||
:value="formatNumberK(metricsInfo.eventsCount)" :avg="formatNumberK(avgEventsDay) + '/day'"
|
||||
:trend="eventsData.trend" :data="eventsData.data" :labels="eventsData.labels" color="#1e9b86">
|
||||
<DashboardCountCard :ready="!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>
|
||||
|
||||
<DashboardCountCard :ready="sessionsData.ready" icon="far fa-user" text="Unique visits sessions"
|
||||
:value="formatNumberK(metricsInfo.sessionsVisitsCount)" :avg="formatNumberK(avgSessionsDay) + '/day'"
|
||||
:trend="sessionsData.trend" :data="sessionsData.data" :labels="sessionsData.labels" color="#4abde8">
|
||||
|
||||
<DashboardCountCard :ready="!sessionsData.pending.value" icon="far fa-user" text="Unique visits sessions"
|
||||
:value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
:avg="formatNumberK(avgSessionsDay) + '/day'" :trend="sessionsData.data.value?.trend"
|
||||
:data="sessionsData.data.value?.data" :labels="sessionsData.data.value?.labels" color="#4abde8">
|
||||
</DashboardCountCard>
|
||||
|
||||
<DashboardCountCard :ready="sessionsDurationData.ready" icon="far fa-timer" text="Avg session time"
|
||||
:value="avgSessionDuration" :trend="sessionsDurationData.trend" :data="sessionsDurationData.data"
|
||||
:labels="sessionsDurationData.labels" color="#f56523">
|
||||
|
||||
<DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer" text="Total avg session time"
|
||||
:value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
|
||||
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
|
||||
color="#f56523">
|
||||
</DashboardCountCard>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,12 @@ onMounted(() => startWatching());
|
||||
onUnmounted(() => stopWatching());
|
||||
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
|
||||
function copyProjectId() {
|
||||
if (!navigator.clipboard) alert('NON PUOI COPIARE IN HTTP');
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
|
||||
alert('Copiato !');
|
||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -20,23 +22,23 @@ function copyProjectId() {
|
||||
|
||||
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
|
||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||
<div> {{ onlineUsers }} Online users</div>
|
||||
<div class="poppins font-medium text-[1.2rem]"> {{ onlineUsers }} Online users</div>
|
||||
</div>
|
||||
|
||||
<div class="grow"></div>
|
||||
|
||||
<div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row">
|
||||
<div>Project:</div>
|
||||
<div class="text-text/90"> {{ activeProject?.name || 'Loading...' }} </div>
|
||||
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project:</div>
|
||||
<div class="text-lyx-text poppins font-medium text-[1.2rem]"> {{ activeProject?.name || 'Loading...' }} </div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row md:gap-2 items-center md:justify-start">
|
||||
<div>Project id:</div>
|
||||
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project id:</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-text/90 text-[.9rem] lg:text-2xl">
|
||||
<div class="text-lyx-text poppins font-medium text-[1.2rem]">
|
||||
{{ activeProject?._id || 'Loading...' }}
|
||||
</div>
|
||||
<div class="flex items-center ml-3">
|
||||
<i @click="copyProjectId()" class="far fa-copy hover:text-text cursor-pointer text-[1.2rem]"></i>
|
||||
<i @click="copyProjectId()" class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[1.2rem]"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,29 +2,45 @@
|
||||
import { onMounted } from 'vue';
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
|
||||
const data = ref<number[]>([]);
|
||||
const labels = ref<string[]>([]);
|
||||
const ready = ref<boolean>(false);
|
||||
|
||||
const props = defineProps<{ slice: Slice }>();
|
||||
|
||||
async function loadData() {
|
||||
const response = await useTimeline('visits', props.slice);
|
||||
if (!response) return;
|
||||
data.value = response.map(e => e.count);
|
||||
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
ready.value = true;
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
const data = input.map(e => e.count);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
return { data, labels }
|
||||
}
|
||||
|
||||
const body = computed(() => {
|
||||
return {
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to,
|
||||
slice: props.slice
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const visitsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
watch(props, async () => { await loadData(); });
|
||||
})
|
||||
visitsData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AdvancedLineChart v-if="ready" :data="data" :labels="labels" color="#5655d7"></AdvancedLineChart>
|
||||
<div v-if="visitsData.pending.value" class="flex justify-center py-40">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
<AdvancedLineChart v-if="!visitsData.pending.value" :data="visitsData.data.value?.data || []"
|
||||
:labels="visitsData.data.value?.labels || []" color="#5655d7">
|
||||
</AdvancedLineChart>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,29 +2,57 @@
|
||||
|
||||
import type { VisitsWebsiteAggregated } from '~/server/api/metrics/[project_id]/data/websites';
|
||||
|
||||
const { data: websites, pending, refresh } = useWebsitesData();
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const currentViewData = ref<(VisitsWebsiteAggregated[] | null)>(websites.value);
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
watch(pending, () => {
|
||||
currentViewData.value = websites.value;
|
||||
})
|
||||
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 isLoading = ref<boolean>(false);
|
||||
|
||||
const currentData = computed(() => {
|
||||
return isPagesView.value ? pagesData : websitesData
|
||||
})
|
||||
|
||||
|
||||
async function showDetails(website: string) {
|
||||
if (isPagesView.value == true) return;
|
||||
isLoading.value = true;
|
||||
currentWebsite.value = website;
|
||||
pagesData.execute();
|
||||
isPagesView.value = true;
|
||||
}
|
||||
|
||||
const { data: pagesData, pending } = usePagesData(website, 10);
|
||||
|
||||
watch(pending, () => {
|
||||
currentViewData.value = pagesData.value;
|
||||
isLoading.value = false;
|
||||
})
|
||||
|
||||
async function showGeneral() {
|
||||
websitesData.execute();
|
||||
isPagesView.value = false;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
@@ -33,26 +61,18 @@ function goToView() {
|
||||
router.push('/dashboard/visits');
|
||||
}
|
||||
|
||||
function setDefaultData() {
|
||||
currentViewData.value = websites.value;
|
||||
isPagesView.value = false;
|
||||
}
|
||||
|
||||
async function dataReload() {
|
||||
await refresh();
|
||||
setDefaultData();
|
||||
}
|
||||
|
||||
|
||||
onMounted(()=>{
|
||||
websitesData.execute();
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<DashboardBarsCard :hideShowMore="true" @showGeneral="setDefaultData()" @showRawData="goToView()"
|
||||
@dataReload="dataReload()" @showDetails="showDetails" :data="currentViewData || []"
|
||||
:loading="pending || isLoading" :label="isPagesView ? 'Top pages' : 'Top Websites'"
|
||||
<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">
|
||||
|
||||
116
dashboard/components/dialog/CreateSnapshot.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { closeDialog } = useCustomDialog();
|
||||
|
||||
import { sub, format, isSameDay, type Duration } from 'date-fns'
|
||||
|
||||
const ranges = [
|
||||
{ label: 'Last 7 days', duration: { days: 7 } },
|
||||
{ label: 'Last 14 days', duration: { days: 14 } },
|
||||
{ label: 'Last 30 days', duration: { days: 30 } },
|
||||
{ label: 'Last 3 months', duration: { months: 3 } },
|
||||
{ label: 'Last 6 months', duration: { months: 6 } },
|
||||
{ label: 'Last year', duration: { years: 1 } }
|
||||
]
|
||||
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
|
||||
|
||||
function isRangeSelected(duration: Duration) {
|
||||
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
|
||||
}
|
||||
|
||||
function selectRange(duration: Duration) {
|
||||
selected.value = { start: sub(new Date(), duration), end: new Date() }
|
||||
}
|
||||
|
||||
const currentColor = ref<string>("#5680F8");
|
||||
|
||||
const colorpicker = ref<HTMLInputElement | null>(null);
|
||||
|
||||
function showColorPicker() {
|
||||
colorpicker.value?.click();
|
||||
}
|
||||
|
||||
function onColorChange() {
|
||||
currentColor.value = colorpicker.value?.value || '#000000';
|
||||
}
|
||||
|
||||
const snapshotName = ref<string>("");
|
||||
|
||||
const { updateSnapshots } = useSnapshot();
|
||||
const { createAlert } = useAlert()
|
||||
|
||||
async function confirmSnapshot() {
|
||||
await $fetch("/api/snapshot/create", {
|
||||
method: 'POST',
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
name: snapshotName.value,
|
||||
color: currentColor.value,
|
||||
from: selected.value.start.toISOString(),
|
||||
to: selected.value.end.toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
await updateSnapshots();
|
||||
closeDialog();
|
||||
createAlert('Snapshot created','Snapshot created successfully', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
|
||||
<div class="poppins text-center">
|
||||
Create a snapshot
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex items-center gap-2">
|
||||
<div :style="`background-color: ${currentColor};`" @click="showColorPicker"
|
||||
class="w-6 h-6 rounded-full aspect-[1/1] relative cursor-pointer">
|
||||
<input @input="onColorChange" ref="colorpicker" class="relative w-0 h-0 z-[-100]" type="color">
|
||||
</div>
|
||||
<div class="grow">
|
||||
<LyxUiInput placeholder="Snapshot name" v-model="snapshotName" class="px-4 py-1 w-full"></LyxUiInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 justify-center flex w-full">
|
||||
|
||||
<UPopover class="w-full" :popper="{ placement: 'bottom' }">
|
||||
<UButton class="w-full" color="primary" variant="solid">
|
||||
<div class="flex items-center justify-center w-full gap-2">
|
||||
<i class="i-heroicons-calendar-days-20-solid"></i>
|
||||
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
|
||||
</div>
|
||||
</UButton>
|
||||
<template #panel="{ close }">
|
||||
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
|
||||
<div class="hidden sm:flex flex-col py-4">
|
||||
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
|
||||
variant="ghost" class="rounded-none px-6"
|
||||
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
|
||||
truncate @click="selectRange(range.duration)" />
|
||||
</div>
|
||||
|
||||
<DatePicker v-model="selected" @close="close" />
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="grow"></div>
|
||||
<div class="flex items-center justify-around gap-4">
|
||||
<LyxUiButton @click="closeDialog()" type="secondary" class="w-full text-center">
|
||||
Cancel
|
||||
</LyxUiButton>
|
||||
<LyxUiButton @click="confirmSnapshot()" type="primary" class="w-full text-center"
|
||||
:disabled="snapshotName.length == 0">
|
||||
Confirm
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -26,12 +26,27 @@ async function getMetadataFields() {
|
||||
currentSearchText.value = "";
|
||||
}
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot();
|
||||
|
||||
async function getMetadataFieldGrouped() {
|
||||
if (!selectedMetadataField.value) return;
|
||||
metadataFieldGrouped.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/metadata_field_group?name=${selectedEventName.value}&field=${selectedMetadataField.value}`, signHeaders());
|
||||
|
||||
|
||||
const queryParams: Record<string, any> = {
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to,
|
||||
name: selectedEventName.value,
|
||||
field: selectedMetadataField.value
|
||||
}
|
||||
|
||||
const queryParamsString = Object.keys(queryParams).map((key) => `${key}=${queryParams[key]}`).join('&');
|
||||
|
||||
metadataFieldGrouped.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/metadata_field_group?${queryParamsString}`, signHeaders());
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
const metadataFieldGroupedFiltered = computed(() => {
|
||||
if (currentSearchText.value.length == 0) return metadataFieldGrouped.value;
|
||||
return metadataFieldGrouped.value.filter(e => {
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
const datasets = ref<any[]>([]);
|
||||
const labels = ref<string[]>([]);
|
||||
const ready = ref<boolean>(false);
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
|
||||
const props = defineProps<{ slice: SliceName }>();
|
||||
const props = defineProps<{ slice: Slice }>();
|
||||
const slice = computed(() => props.slice);
|
||||
|
||||
async function loadData() {
|
||||
const response = await useTimelineDataRaw('events_stacked', props.slice);
|
||||
if (!response) return;
|
||||
const activeProject = useActiveProject();
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const fixed = fixMetrics(response, props.slice, { advanced: true, advancedGroupKey: 'name' });
|
||||
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'
|
||||
});
|
||||
|
||||
const parsedDatasets: any[] = [];
|
||||
const colors = ['#5655d0', '#6bbbe3', '#a6d5cb', '#fae0b9'];
|
||||
@@ -28,25 +44,33 @@ async function loadData() {
|
||||
if (!target) return;
|
||||
line.data.push(target.value);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
datasets.value = parsedDatasets;
|
||||
labels.value = fixed.labels;
|
||||
ready.value = true;
|
||||
|
||||
return {
|
||||
datasets: parsedDatasets,
|
||||
labels: fixed.labels
|
||||
}
|
||||
}
|
||||
|
||||
const eventsStackedData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events_stacked`, {
|
||||
method: 'POST', body, lazy: true, immediate: false, transform: transformResponse, ...signHeaders()
|
||||
});
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
watch(props, async () => { await loadData(); });
|
||||
})
|
||||
eventsStackedData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AdvancedStackedBarChart v-if="ready" :datasets="datasets" :labels="labels">
|
||||
<div v-if="eventsStackedData.pending.value" class="flex justify-center py-40">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
<AdvancedStackedBarChart v-if="!eventsStackedData.pending.value"
|
||||
:datasets="eventsStackedData.data.value?.datasets || []"
|
||||
:labels="eventsStackedData.data.value?.labels || []">
|
||||
</AdvancedStackedBarChart>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,13 +12,28 @@ onMounted(async () => {
|
||||
const userFlowData = ref<any>();
|
||||
const analyzing = ref<boolean>(false);
|
||||
|
||||
async function analyzeEvent() {
|
||||
const { safeSnapshotDates } = useSnapshot();
|
||||
|
||||
async function getUserFlowData() {
|
||||
userFlowData.value = undefined;
|
||||
analyzing.value = true;
|
||||
userFlowData.value = await $fetch(`/api/metrics/${activeProject.value?._id.toString()}/events/flow_from_name?name=${selectedEventName.value}`, signHeaders());
|
||||
|
||||
const queryParams: Record<string, any> = {
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to,
|
||||
name: selectedEventName.value
|
||||
}
|
||||
|
||||
const queryParamsString = Object.keys(queryParams).map((key) => `${key}=${queryParams[key]}`).join('&');
|
||||
|
||||
userFlowData.value = await $fetch(`/api/metrics/${activeProject.value?._id.toString()}/events/flow_from_name?${queryParamsString}`, signHeaders());
|
||||
analyzing.value = false;
|
||||
}
|
||||
|
||||
async function analyzeEvent() {
|
||||
getUserFlowData();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -41,13 +56,15 @@ async function analyzeEvent() {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2" v-if="userFlowData">
|
||||
<div class="flex gap-4 items-center bg-bg py-1 px-2 rounded-lg" v-for="(count, referrer) in userFlowData">
|
||||
<div class="flex gap-4 items-center bg-bg py-1 px-2 rounded-lg"
|
||||
v-for="(count, referrer) in userFlowData">
|
||||
<div class="w-5 h-5 flex items-center justify-center">
|
||||
<img :src="`https://s2.googleusercontent.com/s2/favicons?domain=${referrer}&sz=64`" :alt="'referrer'">
|
||||
<img :src="`https://s2.googleusercontent.com/s2/favicons?domain=${referrer}&sz=64`"
|
||||
:alt="'referrer'">
|
||||
</div>
|
||||
<div> {{ referrer }} </div>
|
||||
<div class="grow"></div>
|
||||
<div> {{ count }} </div>
|
||||
<div> {{ count.toFixed(2).replace('.', ',') }} % </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
102
dashboard/components/pricing/PricingCardGeneric.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
export type PricingCardProp = {
|
||||
title: string,
|
||||
price: string,
|
||||
subs: string[],
|
||||
features: string[],
|
||||
cta: string,
|
||||
link?: string,
|
||||
isDowngrade: boolean,
|
||||
active: boolean,
|
||||
planId: number
|
||||
}
|
||||
|
||||
const props = defineProps<{ datas: PricingCardProp[], defaultIndex?: number }>();
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const currentIndex = ref<number>(props.defaultIndex || 0);
|
||||
|
||||
const data = computed(() => {
|
||||
return props.datas[currentIndex.value];
|
||||
})
|
||||
|
||||
async function onUpgradeClick() {
|
||||
const res = await $fetch<string>(`/api/pay/${activeProject.value?._id.toString()}/create`, {
|
||||
...signHeaders({ 'content-type': 'application/json' }),
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ planId: data.value.planId })
|
||||
})
|
||||
if (!res) alert('Something went wrong');
|
||||
window.open(res);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative bg-[#151515] outline outline-[1px] outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]">
|
||||
|
||||
<div class="flex flex-col gap-3 text-center pt-3">
|
||||
<div v-if="data.active"
|
||||
class="absolute right-6 top-3 poppins text-[.75rem] bg-[#222A42] outline outline-[1px] outline-[#5680F8] px-3 py-[.1rem] rounded-sm">
|
||||
Active
|
||||
</div>
|
||||
<div v-if="!data.active && data.title === 'Growth'"
|
||||
class="absolute right-6 top-3 poppins text-[.75rem] bg-[#fbbe244f] outline outline-[1px] outline-[#fbbf24] px-3 py-[.1rem] rounded-sm">
|
||||
Most popular
|
||||
</div>
|
||||
<div class="poppins text-xl font-light"> {{ data.title }} </div>
|
||||
<div class="poppins text-4xl font-medium"> {{ data.price }} </div>
|
||||
</div>
|
||||
|
||||
<div class="sep bg-[#262626] h-[1px] my-8"></div>
|
||||
|
||||
<div class="flex flex-col text-center h-[6rem] justify-center gap-2">
|
||||
<div v-if="datas.length > 1">
|
||||
<URange :ui="{
|
||||
thumb: {
|
||||
color: 'text-[#5680f8]'
|
||||
},
|
||||
progress: {
|
||||
background: '!bg-[#5680f8]'
|
||||
}
|
||||
}" :min="0" :max="datas.length - 1" v-model="currentIndex">
|
||||
</URange>
|
||||
</div>
|
||||
<div class="poppins" v-for="sub of data.subs"> {{ sub }} </div>
|
||||
</div>
|
||||
|
||||
<div class="sep bg-[#262626] h-[1px] my-8"></div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2" v-for="feature of data.features">
|
||||
<div class="h-6 w-6">
|
||||
<img class="w-full h-full" :src="'/check.png'" alt="Check">
|
||||
</div>
|
||||
<div>{{ feature }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex">
|
||||
<div class="w-full" 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">
|
||||
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">
|
||||
Downgrade
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLink v-if="data.planId === -1" :to="data.link || 'https://dashboard.litlyx.com'"
|
||||
class="bg-[#222A42] cursor-pointer outline outline-[1px] outline-[#5680F8] w-full !rounded-md text-center text-[.9rem] !py-2 ">
|
||||
{{ data.cta }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,81 +1,199 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PricingCardProp } from './PricingCard.vue';
|
||||
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
import type { PricingCardProp } from './PricingCardGeneric.vue';
|
||||
|
||||
const props = defineProps<{ currentSub: number }>();
|
||||
|
||||
const freePricing: PricingCardProp[] = [
|
||||
{
|
||||
title: 'Free',
|
||||
price: '€0 / mo',
|
||||
subs: [
|
||||
'Up to 5000 visits/events per month',
|
||||
'CPM 0€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Email support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 10',
|
||||
'Server type: SHARED',
|
||||
'Data retention: 2 Months'
|
||||
],
|
||||
cta: 'Start For Free now!',
|
||||
active: props.currentSub == 0,
|
||||
isDowngrade: props.currentSub > 0,
|
||||
planId: 0
|
||||
},
|
||||
]
|
||||
|
||||
const starterTierCardData = ref<PricingCardProp>({
|
||||
title: 'STARTER',
|
||||
cost: '0',
|
||||
features: [
|
||||
"3K visits/events per month",
|
||||
"10 AI Interaction per month",
|
||||
"1 month data retention",
|
||||
"Limited reports",
|
||||
"1 Team member",
|
||||
"Limited Automatic Email Report",
|
||||
"Shared Server & DB",
|
||||
"Low priority email support",
|
||||
],
|
||||
desc: `Free project are not reliable and sometimes
|
||||
can experience some data loss.To have a
|
||||
dedicated server we suggest to upgrade the
|
||||
plan to an higher one!`,
|
||||
active: activeProject.value?.premium === false,
|
||||
isDowngrade: props.currentSub > 0,
|
||||
planId: 0
|
||||
});
|
||||
const customPricing: PricingCardProp[] = [
|
||||
{
|
||||
title: 'Enterprise',
|
||||
price: 'Custom',
|
||||
subs: [
|
||||
'Unlimited visits/events per month',
|
||||
'Service Tailor-made on needs'
|
||||
],
|
||||
features: [
|
||||
'Priority support',
|
||||
'Server type: DEDICATED',
|
||||
'DB instance: DEDICATED',
|
||||
'Dedicated operator',
|
||||
'White label',
|
||||
'Custom Data Aggregation'
|
||||
],
|
||||
cta: 'Let\'s Talk!',
|
||||
link: 'mailto:help@litlyx.com',
|
||||
active: false,
|
||||
isDowngrade: false,
|
||||
planId: -1
|
||||
}
|
||||
]
|
||||
|
||||
const accelerationTierCardData = ref<PricingCardProp>({
|
||||
title: 'ACCELERATION',
|
||||
cost: '9,99',
|
||||
features: [
|
||||
"150K visits/events per month",
|
||||
"100 AI Interaction per month",
|
||||
"6 months data retention",
|
||||
"Limited reports",
|
||||
"1 Team member",
|
||||
"Limited Automatic Email Report",
|
||||
"Shared Server & DB",
|
||||
"Low priority email support"
|
||||
],
|
||||
desc: `Your project is entering a growth phase. We simplify data analysis for you. For more support, try our Expansion plan—it's worth it!`,
|
||||
active: activeProject.value?.premium_type === 1,
|
||||
isDowngrade: props.currentSub > 1,
|
||||
planId: 1
|
||||
});
|
||||
|
||||
const expansionTierCardData = ref<PricingCardProp>({
|
||||
title: 'EXPANSION',
|
||||
cost: '39,99',
|
||||
features: [
|
||||
"500K visits/events per month",
|
||||
"5000 AI Interaction per month",
|
||||
"2 years data retention",
|
||||
"Unlimited reports",
|
||||
"10 Team member",
|
||||
"Unlimited Automatic Email Report",
|
||||
"Dedicated Server & DB",
|
||||
"high priority email support"
|
||||
],
|
||||
desc: `We will support you with everything we can offer and give you the full power of our service. If you need more space and are growing, contact us for a custom offer!`,
|
||||
active: activeProject.value?.premium_type === 2,
|
||||
isDowngrade: props.currentSub > 2,
|
||||
planId: 2
|
||||
});
|
||||
const slidePricings: PricingCardProp[] = [
|
||||
{
|
||||
title: 'Incubation',
|
||||
price: '€4,99 / mo',
|
||||
subs: [
|
||||
'Up to 50.000 visits/events per month',
|
||||
'CPM 0,10€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 30',
|
||||
'Server type: SHARED',
|
||||
'Data retention: 6 Months'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
active: props.currentSub == 101,
|
||||
isDowngrade: props.currentSub > 101,
|
||||
planId: 101
|
||||
},
|
||||
{
|
||||
title: 'Acceleration',
|
||||
price: '€9,99 / mo',
|
||||
subs: [
|
||||
'Up to 150.000 visits/events per month',
|
||||
'CPM 0,06€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 100',
|
||||
'Server type: SHARED',
|
||||
'Data retention: 9 Months'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
active: props.currentSub == 102,
|
||||
isDowngrade: props.currentSub > 102,
|
||||
planId: 102
|
||||
},
|
||||
{
|
||||
title: 'Growth',
|
||||
price: '€29,99 / mo',
|
||||
subs: [
|
||||
'Up to 500.000 visits/events per month',
|
||||
'CPM 0,059€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 3.000',
|
||||
'Server type: SHARED',
|
||||
'Data retention: 1 Year'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
active: props.currentSub == 103,
|
||||
isDowngrade: props.currentSub > 103,
|
||||
planId: 103
|
||||
},
|
||||
{
|
||||
title: 'Expansion',
|
||||
price: '€59,99 / mo',
|
||||
subs: [
|
||||
'Up to 1.000.000 visits/events per month',
|
||||
'CPM 0,059€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 5.000',
|
||||
'Server type: SHARED',
|
||||
'Data retention: 1 Year'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
active: props.currentSub == 104,
|
||||
isDowngrade: props.currentSub > 104,
|
||||
planId: 104
|
||||
},
|
||||
{
|
||||
title: 'Scaling',
|
||||
price: '€99,99 / mo',
|
||||
subs: [
|
||||
'Up to 2.500.000 visits/events per month',
|
||||
'CPM 0,039€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 10.000',
|
||||
'Server type: DEDICATED',
|
||||
'Data retention: 2 Years'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
active: props.currentSub == 105,
|
||||
isDowngrade: props.currentSub > 105,
|
||||
planId: 105
|
||||
},
|
||||
{
|
||||
title: 'Unicorn',
|
||||
price: '€149,99 / mo',
|
||||
subs: [
|
||||
'Up to 5.000.000 visits/events per month',
|
||||
'CPM 0,029€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 20.000',
|
||||
'Server type: DEDICATED',
|
||||
'Data retention: 3 Years'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
active: props.currentSub == 106,
|
||||
isDowngrade: props.currentSub > 106,
|
||||
planId: 106
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
const emits = defineEmits<{
|
||||
(evt: 'onCloseClick'): void
|
||||
}>();
|
||||
|
||||
const activeProject = useActiveProject()
|
||||
|
||||
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 xl:overflow-y-hidden">
|
||||
<div class="p-8 overflow-y-auto">
|
||||
|
||||
<div @click="$emit('onCloseClick')"
|
||||
class="cursor-pointer fixed top-4 right-4 rounded-full bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
|
||||
@@ -83,11 +201,57 @@ const emits = defineEmits<{
|
||||
</div>
|
||||
|
||||
<div class="flex gap-8 mt-10 h-max xl:flex-row flex-col">
|
||||
<PricingCard class="flex-1" :data="starterTierCardData"></PricingCard>
|
||||
<PricingCard class="flex-1" :data="accelerationTierCardData"></PricingCard>
|
||||
<PricingCard class="flex-1" :data="expansionTierCardData"></PricingCard>
|
||||
<PricingCardGeneric class="flex-1" :datas="freePricing"></PricingCardGeneric>
|
||||
<PricingCardGeneric class="flex-1" :datas="slidePricings" :default-index="2"></PricingCardGeneric>
|
||||
<PricingCardGeneric class="flex-1" :datas="customPricing"></PricingCardGeneric>
|
||||
</div>
|
||||
|
||||
<LyxUiCard class="w-full mt-6">
|
||||
<div class="flex">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<span class="text-lyx-primary font-semibold text-[1.4rem]">
|
||||
LIFETIME DEAL
|
||||
</span>
|
||||
<span class="text-lyx-text-dark text-[.8rem]"> (Growth plan) </span>
|
||||
</div>
|
||||
<div class="text-[2rem]"> € 2.399,00 </div>
|
||||
<div> Up to 500.000 visits/events per month </div>
|
||||
<LyxUiButton type="primary" @click="onLifetimeUpgradeClick()"> Purchase </LyxUiButton>
|
||||
</div>
|
||||
<div class="flex justify-evenly grow">
|
||||
<div class="flex flex-col justify-evenly">
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Slack support </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Unlimited domanis </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Unlimited reports </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-evenly">
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> AI Tokens: 3.000 / month </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Server type: SHARED </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Data retention: 5 Years </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
|
||||
<div class="flex justify-between items-center mt-10 flex-col xl:flex-row">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="poppins text-[2rem] font-semibold">
|
||||
@@ -105,5 +269,8 @@ const emits = defineEmits<{
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,30 +2,30 @@
|
||||
import { onMounted } from 'vue';
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
|
||||
const data = ref<number[]>([]);
|
||||
const labels = ref<string[]>([]);
|
||||
const ready = ref<boolean>(false);
|
||||
// const data = ref<number[]>([]);
|
||||
// const labels = ref<string[]>([]);
|
||||
// const ready = ref<boolean>(false);
|
||||
|
||||
const props = defineProps<{ slice: Slice, referrer: string }>();
|
||||
// const props = defineProps<{ slice: Slice, referrer: string }>();
|
||||
|
||||
async function loadData() {
|
||||
const response = await useReferrersTimeline(props.referrer, props.slice);
|
||||
if (!response) return;
|
||||
data.value = response.map(e => e.count);
|
||||
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
ready.value = true;
|
||||
}
|
||||
// async function loadData() {
|
||||
// const response = await useReferrersTimeline(props.referrer, props.slice);
|
||||
// if (!response) return;
|
||||
// data.value = response.map(e => e.count);
|
||||
// labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
// ready.value = true;
|
||||
// }
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
watch(props, async () => { await loadData(); });
|
||||
})
|
||||
// onMounted(async () => {
|
||||
// await loadData();
|
||||
// watch(props, async () => { await loadData(); });
|
||||
// })
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AdvancedBarChart v-if="ready" :data="data" :labels="labels" color="#5680f8">
|
||||
</AdvancedBarChart>
|
||||
<!-- <AdvancedBarChart v-if="ready" :data="data" :labels="labels" color="#5680f8">
|
||||
</AdvancedBarChart> -->
|
||||
</div>
|
||||
</template>
|
||||
39
dashboard/components/settings/Account.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
{ id: 'delete', title: 'Delete account', text: 'Delete your account' },
|
||||
]
|
||||
|
||||
|
||||
const { setToken } = useAccessToken();
|
||||
|
||||
async function deleteAccount() {
|
||||
const sure = confirm("Are you sure you want to delete this account ?");
|
||||
if (!sure) return;
|
||||
await $fetch("/api/user/delete_account", {
|
||||
...signHeaders(),
|
||||
method: "DELETE"
|
||||
})
|
||||
|
||||
setToken('');
|
||||
location.href = "/login"
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<SettingsTemplate :entries="entries">
|
||||
<template #delete>
|
||||
<div
|
||||
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-[#1e1412]">
|
||||
<div class="poppins font-semibold"> Deleting this account will also remove its projects </div>
|
||||
<div @click="deleteAccount()"
|
||||
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-[#291415]">
|
||||
Delete account
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsTemplate>
|
||||
</template>
|
||||
196
dashboard/components/settings/General.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TApiSettings } from '@schema/ApiSettingsSchema';
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
{ id: 'pname', title: 'Name', text: 'Project name' },
|
||||
{ id: 'api', title: 'ApiKeys', text: 'Manage your authorization token' },
|
||||
{ id: 'pid', title: 'Id', text: 'Project id' },
|
||||
{ id: 'pscript', title: 'Script', text: 'Universal javascript integration' },
|
||||
{ id: 'pdelete', title: 'Delete', text: 'Delete current project' },
|
||||
]
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const projectNameInputVal = ref<string>(activeProject.value?.name || '');
|
||||
|
||||
|
||||
const apiKeys = ref<TApiSettings[]>([]);
|
||||
|
||||
const newApiKeyName = ref<string>('');
|
||||
|
||||
async function updateApiKeys() {
|
||||
newApiKeyName.value = '';
|
||||
apiKeys.value = await $fetch<TApiSettings[]>('/api/keys/get_all', signHeaders());
|
||||
}
|
||||
|
||||
async function createApiKey() {
|
||||
try {
|
||||
const res = await $fetch<TApiSettings>('/api/keys/create', {
|
||||
method: 'POST', ...signHeaders({
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
body: JSON.stringify({ name: newApiKeyName.value })
|
||||
});
|
||||
apiKeys.value.push(res);
|
||||
newApiKeyName.value = '';
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteApiKey(api_id: string) {
|
||||
try {
|
||||
const res = await $fetch<TApiSettings>('/api/keys/delete', {
|
||||
method: 'DELETE', ...signHeaders({
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
body: JSON.stringify({ api_id })
|
||||
});
|
||||
newApiKeyName.value = '';
|
||||
await updateApiKeys();
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateApiKeys();
|
||||
})
|
||||
|
||||
watch(activeProject, () => {
|
||||
projectNameInputVal.value = activeProject.value?.name || "";
|
||||
updateApiKeys();
|
||||
})
|
||||
|
||||
const canChange = computed(() => {
|
||||
if (activeProject.value?.name == projectNameInputVal.value) return false;
|
||||
if (projectNameInputVal.value.length === 0) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
async function changeProjectName() {
|
||||
await $fetch("/api/project/change_name", {
|
||||
method: 'POST',
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ name: projectNameInputVal.value })
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function deleteProject() {
|
||||
if (!activeProject.value) return;
|
||||
const sure = confirm(`Are you sure to delete the project ${activeProject.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() })
|
||||
});
|
||||
|
||||
const projectsList = useProjectsList()
|
||||
await projectsList.refresh();
|
||||
|
||||
const firstProjectId = projectsList.data.value?.[0]?._id.toString();
|
||||
if (firstProjectId) {
|
||||
await setActiveProject(firstProjectId);
|
||||
}
|
||||
|
||||
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
const { createAlert } = useAlert()
|
||||
|
||||
function copyScript() {
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
|
||||
|
||||
const createScriptText = () => {
|
||||
return [
|
||||
'<script defer ',
|
||||
`data-project="${activeProject.value?._id}" `,
|
||||
'src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></',
|
||||
'script>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(createScriptText());
|
||||
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
|
||||
function copyProjectId() {
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
navigator.clipboard.writeText(activeProject.value?._id?.toString() || '');
|
||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<SettingsTemplate :entries="entries" :key="activeProject?.name || 'NONE'">
|
||||
<template #pname>
|
||||
<div class="flex items-center gap-4">
|
||||
<LyxUiInput class="w-full px-4 py-2" v-model="projectNameInputVal"></LyxUiInput>
|
||||
<LyxUiButton @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>
|
||||
<LyxUiButton @click="createApiKey()" :disabled="newApiKeyName.length < 3" type="primary">
|
||||
<i class="far fa-plus"></i>
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
<LyxUiCard v-if="apiKeys && apiKeys.length > 0" class="w-full flex flex-col gap-4 items-center mt-4">
|
||||
<div v-for="apiKey of apiKeys" class="flex flex-col w-full">
|
||||
|
||||
<div class="flex gap-8 items-center">
|
||||
<div class="grow">Name: {{ apiKey.apiName }}</div>
|
||||
<div>{{ apiKey.apiKey }}</div>
|
||||
<div class="flex justify-end">
|
||||
<i class="far fa-trash cursor-pointer" @click="deleteApiKey(apiKey._id.toString())"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
<template #pid>
|
||||
<LyxUiCard class="w-full flex items-center">
|
||||
<div class="grow">{{ activeProject?._id.toString() }}</div>
|
||||
<div><i class="far fa-copy" @click="copyProjectId()"></i></div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
<template #pscript>
|
||||
<LyxUiCard class="w-full flex items-center">
|
||||
<div class="grow">
|
||||
{{ `
|
||||
<script defer data-project="${activeProject?._id}"
|
||||
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }}
|
||||
</div>
|
||||
<div><i class="far fa-copy" @click="copyScript()"></i></div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
<template #pdelete>
|
||||
<div class="flex justify-end">
|
||||
<LyxUiButton type="danger" @click="deleteProject()">
|
||||
Delete project
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsTemplate>
|
||||
</template>
|
||||
37
dashboard/components/settings/Template.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
export type SettingsTemplateEntry = {
|
||||
title: string,
|
||||
text: string,
|
||||
id: string
|
||||
}
|
||||
|
||||
type SettingsTemplateProp = {
|
||||
entries: SettingsTemplateEntry[]
|
||||
}
|
||||
|
||||
const props = defineProps<SettingsTemplateProp>();
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="mt-10 px-4">
|
||||
<div v-for="(entry, index) of props.entries" class="flex flex-col">
|
||||
<div class="flex">
|
||||
<div class="flex-[2]">
|
||||
<div class="poppins font-medium text-lyx-text">
|
||||
{{ entry.title }}
|
||||
</div>
|
||||
<div class="poppins font-regular text-lyx-text-dark">
|
||||
{{ entry.text }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-[3]">
|
||||
<slot :name="entry.id"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="index < props.entries.length - 1" class="h-[2px] bg-lyx-widget-lighter w-full my-10"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
221
dashboard/components/settings/billing.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<script lang="ts" setup>
|
||||
import dayjs from 'dayjs';
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const { data: planData, refresh: planRefresh, pending: planPending } = useFetch('/api/project/plan', {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
});
|
||||
|
||||
const percent = computed(() => {
|
||||
if (!planData.value) return '-';
|
||||
return (100 / planData.value.limit * planData.value.count).toFixed(2) + '%';
|
||||
});
|
||||
|
||||
const color = computed(() => {
|
||||
if (!planData.value) return 'blue';
|
||||
if (planData.value.count >= planData.value.limit) return 'red';
|
||||
return 'blue';
|
||||
});
|
||||
|
||||
const daysLeft = computed(() => {
|
||||
if (!planData.value) return '-';
|
||||
return (-dayjs().diff(planData.value.billing_expire_at, 'days')).toString();
|
||||
});
|
||||
|
||||
const leftPercent = computed(() => {
|
||||
if (!planData.value) return 0;
|
||||
const left = dayjs().diff(planData.value.billing_expire_at, 'days');
|
||||
const total = dayjs(planData.value.billing_start_at).diff(planData.value.billing_expire_at, 'days');
|
||||
const percent = 100 - (100 / total * left);
|
||||
return percent;
|
||||
});
|
||||
|
||||
const prettyExpireDate = computed(() => {
|
||||
if (!planData.value) return '';
|
||||
return dayjs(planData.value.billing_expire_at).format('DD/MM/YYYY');
|
||||
});
|
||||
|
||||
|
||||
const { data: invoices, refresh: invoicesRefresh, pending: invoicesPending } = useFetch(`/api/pay/${activeProject.value?._id.toString()}/invoices`, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
})
|
||||
|
||||
function openInvoice(link: string) {
|
||||
window.open(link, '_blank');
|
||||
}
|
||||
|
||||
function getPremiumName(type: number) {
|
||||
if (type === 0) return 'FREE';
|
||||
if (type === 1) return 'ACCELERATION';
|
||||
if (type === 2) return 'EXPANSION';
|
||||
return 'CUSTOM';
|
||||
|
||||
}
|
||||
|
||||
|
||||
watch(activeProject, () => {
|
||||
invoicesRefresh();
|
||||
planRefresh();
|
||||
})
|
||||
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
{ id: 'plan', title: 'Current plan', text: 'Manage current plat for this project' },
|
||||
{ id: 'usage', title: 'Usage', text: 'Show usage of current project' },
|
||||
{ id: 'invoices', title: 'Invoices', text: 'Manage invoices of current project' },
|
||||
]
|
||||
|
||||
|
||||
const { visible } = usePricingDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
|
||||
<div v-if="invoicesPending || planPending"
|
||||
class="backdrop-blur-[1px] z-[20] mt-20 w-full h-full flex items-center justify-center font-bold">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
|
||||
<SettingsTemplate v-if="!invoicesPending && !planPending" :entries="entries">
|
||||
<template #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 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">
|
||||
{{ 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]"> $0 </div>
|
||||
<div class="poppins text-text-sub mt-2"> per month </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="poppins"> Billing period:</div>
|
||||
<div class="flex items-center gap-2 md:gap-4 flex-col pt-4 md:pt-0 md:flex-row">
|
||||
<div class="grow w-full md:w-auto">
|
||||
<UProgress color="green" :min="0" :max="100" :value="leftPercent"></UProgress>
|
||||
</div>
|
||||
<div class="poppins"> {{ daysLeft }} days left </div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
Subscription: {{ planData.subscription_status }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-4 w-full bg-gray-400/30 h-[1px]">
|
||||
</div>
|
||||
<div class="flex justify-between px-8 flex-col sm:flex-row">
|
||||
<div class="flex gap-2 text-text-sub text-[.9rem]">
|
||||
<div class="poppins"> Expire date:</div>
|
||||
<div> {{ prettyExpireDate }}</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
<template #usage>
|
||||
<LyxUiCard v-if="planData" class="flex flex-col w-full">
|
||||
<div class="flex flex-col gap-6 px-8">
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col">
|
||||
<div class="poppins font-semibold text-[1.1rem]">
|
||||
Usage
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.9rem]">
|
||||
Check the usage limits of your project.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="poppins"> Usage:</div>
|
||||
<div class="flex items-center gap-2 md:gap-4 flex-col pt-4 md:pt-0 md:flex-row">
|
||||
<div class="grow w-full md:w-auto">
|
||||
<UProgress :color="color" :min="0" :max="planData.limit" :value="planData.count">
|
||||
</UProgress>
|
||||
</div>
|
||||
<div class="poppins"> {{ percent }}</div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
{{ formatNumberK(planData.count) }} / {{ formatNumberK(planData.limit) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
<template #invoices>
|
||||
|
||||
<CardTitled v-if="!isGuest" title="Invoices"
|
||||
:sub="(invoices && invoices.length == 0) ? 'No invoices yet' : ''" class="p-4 mt-8 w-full">
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
<div class="flex justify-between items-center bg-[#161616] p-4 rounded-lg"
|
||||
v-for="invoice of invoices">
|
||||
|
||||
<div> <i class="fal fa-file-invoice"></i> </div>
|
||||
|
||||
<div class="flex flex-col md:flex-row md:justify-around md:grow items-center gap-2">
|
||||
<div> {{ new Date(invoice.date).toLocaleString() }} </div>
|
||||
<div> € {{ invoice.cost / 100 }} </div>
|
||||
<div> {{ invoice.id }} </div>
|
||||
<div
|
||||
class="flex items-center lato text-[.8rem] bg-accent/25 border-accent/40 border-[1px] px-[.6rem] rounded-lg">
|
||||
{{ invoice.status }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<i @click="openInvoice(invoice.link)"
|
||||
class="far fa-download cursor-pointer hover:text-white/80"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</CardTitled>
|
||||
</template>
|
||||
</SettingsTemplate>
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pdrawer-enter-active,
|
||||
.pdrawer-leave-active {
|
||||
transition: all .5s ease-in-out;
|
||||
}
|
||||
|
||||
.pdrawer-enter-from,
|
||||
.pdrawer-leave-to {
|
||||
transform: translateX(100%)
|
||||
}
|
||||
|
||||
.pdrawer-enter-to,
|
||||
.pdrawer-leave-from {
|
||||
transform: translateX(0)
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const columns = [
|
||||
{ key: 'me', label: '' },
|
||||
@@ -13,7 +15,7 @@ const columns = [
|
||||
// { key: 'pending', label: 'Pending' },
|
||||
]
|
||||
|
||||
const { data: members, refresh: refreshMembers } = useFetch('/api/project/members/list', signHeaders());
|
||||
const { data: members, refresh: refreshMembers, pending: pendingMembers } = useFetch('/api/project/members/list', signHeaders());
|
||||
|
||||
const showAddMember = ref<boolean>(false);
|
||||
|
||||
@@ -67,32 +69,35 @@ async function addMember() {
|
||||
} catch (ex: any) { }
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div class="home w-full h-full px-10 lg:px-6 overflow-y-auto pb-[12rem] md:pb-0 py-2">
|
||||
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div v-if="!isGuest" @click="showAddMember = !showAddMember;"
|
||||
class="flex items-center gap-2 bg-menu w-fit px-3 py-2 rounded-lg hover:bg-menu/80 cursor-pointer">
|
||||
<i class="fas fa-plus"></i>
|
||||
<div> Add member </div>
|
||||
</div>
|
||||
|
||||
<div v-if="showAddMember" class="flex gap-4 items-center">
|
||||
<input v-model="addMemberEmail" class="focus:outline-none bg-menu px-4 py-1 rounded-lg" type="text"
|
||||
placeholder="user email">
|
||||
<div @click="addMember" class="bg-menu w-fit py-1 px-4 rounded-lg hover:bg-menu/80 cursor-pointer">
|
||||
Add
|
||||
<SettingsTemplate :entries="entries">
|
||||
<template #add>
|
||||
<div v-if="!isGuest" class="flex flex-col">
|
||||
<div class="flex gap-4 items-center">
|
||||
<LyxUiInput class="px-4 py-1 w-full" placeholder="User email" v-model="addMemberEmail"></LyxUiInput>
|
||||
<LyxUiButton @click="addMember" type="secondary"> Add </LyxUiButton>
|
||||
</div>
|
||||
<div class="poppins text-[.8rem] mt-2 text-lyx-text-darker">
|
||||
User should have been registered to Litlyx
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #members>
|
||||
|
||||
|
||||
<UTable :rows="members || []" :columns="columns">
|
||||
<template #me-data="e">
|
||||
@@ -108,9 +113,7 @@ async function addMember() {
|
||||
</template>
|
||||
|
||||
</UTable>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</SettingsTemplate>
|
||||
|
||||
</template>
|
||||
@@ -49,6 +49,11 @@ export function useActiveProject() {
|
||||
|
||||
|
||||
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);
|
||||
|
||||
@@ -9,6 +9,11 @@ export function signHeaders(headers?: Record<string, string>) {
|
||||
return { headers: { ...(headers || {}), 'Authorization': 'Bearer ' + token.value } }
|
||||
}
|
||||
|
||||
export const authorizationHeaderComputed = computed(() => {
|
||||
const { token } = useAccessToken()
|
||||
return token.value ? 'Bearer ' + token.value : '';
|
||||
});
|
||||
|
||||
export function useAccessToken() {
|
||||
|
||||
const tokenCookie = useCookie(ACCESS_TOKEN_COOKIE_KEY, { expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) })
|
||||
|
||||
43
dashboard/composables/useAlert.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
|
||||
|
||||
export type Alert = {
|
||||
title: string,
|
||||
text: string,
|
||||
icon: string,
|
||||
ms: number,
|
||||
id: number,
|
||||
remaining: number,
|
||||
transitionStyle: string
|
||||
}
|
||||
|
||||
const alerts = ref<Alert[]>([]);
|
||||
|
||||
const idPool = {
|
||||
id: 0,
|
||||
getId() {
|
||||
return idPool.id++;
|
||||
}
|
||||
}
|
||||
|
||||
function createAlert(title: string, text: string, icon: string, ms: number) {
|
||||
const alert = reactive<Alert>({
|
||||
title, text, icon, ms, id: idPool.getId(), remaining: ms,
|
||||
transitionStyle: 'transition: all 250ms linear;'
|
||||
});
|
||||
alerts.value.push(alert);
|
||||
const timeout = setInterval(() => {
|
||||
alert.remaining -= 250;
|
||||
if (alert.remaining <= 0) {
|
||||
closeAlert(alert.id);
|
||||
clearInterval(timeout);
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function closeAlert(id: number) {
|
||||
alerts.value = alerts.value.filter(e => e.id != id);
|
||||
}
|
||||
|
||||
export function useAlert() {
|
||||
return { alerts, createAlert, closeAlert }
|
||||
}
|
||||
@@ -4,17 +4,42 @@ import type { Component } from "vue";
|
||||
const showDialog = ref<boolean>(false);
|
||||
const dialogParams = ref<any>({});
|
||||
const dialogComponent = ref<Component>();
|
||||
const dialogWidth = ref<string>("100%");
|
||||
const dialogHeight = ref<string>("100%");
|
||||
const dialogClosable = ref<boolean>(true);
|
||||
|
||||
function closeDialog() {
|
||||
showDialog.value = false;
|
||||
}
|
||||
|
||||
export type CustomDialogOptions = {
|
||||
params?: any,
|
||||
width?: string,
|
||||
height?: string,
|
||||
closable?: boolean
|
||||
}
|
||||
|
||||
function openDialogEx(component: Component, options?: CustomDialogOptions) {
|
||||
dialogComponent.value = component;
|
||||
dialogParams.value = options?.params || {};
|
||||
showDialog.value = true;
|
||||
dialogWidth.value = options?.width || '100%';
|
||||
dialogHeight.value = options?.height || '100%';
|
||||
dialogClosable.value = options?.closable ?? true;
|
||||
}
|
||||
|
||||
function openDialog(component: Component, params: any) {
|
||||
dialogComponent.value = component;
|
||||
dialogParams.value = params;
|
||||
showDialog.value = true;
|
||||
dialogWidth.value = '100%';
|
||||
dialogHeight.value = '100%';
|
||||
}
|
||||
|
||||
const dialogStyle = computed(() => {
|
||||
return `width: ${dialogWidth.value}; height: ${dialogHeight.value}`;
|
||||
});
|
||||
|
||||
export function useCustomDialog() {
|
||||
return { showDialog, closeDialog, openDialog, dialogParams, dialogComponent };
|
||||
return { showDialog, openDialogEx, closeDialog, openDialog, dialogParams, dialogComponent, dialogStyle, dialogClosable };
|
||||
}
|
||||
80
dashboard/composables/useCustomFetch.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { InternalApi } from 'nitropack';
|
||||
import type { WatchSource, WatchStopHandle } from 'vue';
|
||||
|
||||
|
||||
type NitroFetchRequest = Exclude<keyof InternalApi, `/_${string}` | `/api/_${string}`> | (string & {});
|
||||
|
||||
export type CustomFetchOptions = {
|
||||
watchProps?: WatchSource[],
|
||||
lazy?: boolean,
|
||||
method?: string,
|
||||
getBody?: () => Record<string, any>,
|
||||
watchKey?: string
|
||||
}
|
||||
|
||||
type OnResponseCallback<TData> = (data: Ref<TData | undefined>) => any
|
||||
type OnRequestCallback = () => any
|
||||
|
||||
|
||||
const watchStopHandles: Record<string, WatchStopHandle> = {}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
||||
}
|
||||
|
||||
const refresh = execute;
|
||||
|
||||
return { pending, execute, data, error, refresh, onResponse, onRequest };
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { Slice } from "@services/DateService";
|
||||
import DateService from "@services/DateService";
|
||||
import type { MetricsCounts } from "~/server/api/metrics/[project_id]/counts";
|
||||
import type { BrowsersAggregated } from "~/server/api/metrics/[project_id]/data/browsers";
|
||||
import type { CountriesAggregated } from "~/server/api/metrics/[project_id]/data/countries";
|
||||
import type { DevicesAggregated } from "~/server/api/metrics/[project_id]/data/devices";
|
||||
import type { CustomEventsAggregated } from "~/server/api/metrics/[project_id]/data/events";
|
||||
import type { OssAggregated } from "~/server/api/metrics/[project_id]/data/oss";
|
||||
import type { ReferrersAggregated } from "~/server/api/metrics/[project_id]/data/referrers";
|
||||
import type { VisitsWebsiteAggregated } from "~/server/api/metrics/[project_id]/data/websites";
|
||||
import type { MetricsTimeline } from "~/server/api/metrics/[project_id]/timeline/generic";
|
||||
|
||||
@@ -13,86 +19,151 @@ export function useMetricsData() {
|
||||
return metricsInfo;
|
||||
}
|
||||
|
||||
export function useFirstInteractionData() {
|
||||
const activeProject = useActiveProject();
|
||||
const metricsInfo = useFetch<boolean>(`/api/metrics/${activeProject.value?._id}/first_interaction`, signHeaders());
|
||||
return metricsInfo;
|
||||
}
|
||||
|
||||
|
||||
export async function useTimelineAdvanced(endpoint: string, slice: Slice, fromDate?: string, toDate?: string, customBody: Object = {}) {
|
||||
|
||||
const { from, to } = DateService.prepareDateRange(
|
||||
fromDate || DateService.getDefaultRange(slice).from,
|
||||
toDate || DateService.getDefaultRange(slice).to,
|
||||
slice
|
||||
);
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const response = await $fetch(
|
||||
`/api/metrics/${activeProject.value?._id}/timeline/${endpoint}`, {
|
||||
method: 'POST',
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ slice, from, to, ...customBody })
|
||||
});
|
||||
|
||||
return response as { _id: string, count: number }[];
|
||||
|
||||
}
|
||||
|
||||
|
||||
export async function useTimeline(endpoint: 'visits' | 'sessions' | 'referrers', slice: Slice, fromDate?: string, toDate?: string) {
|
||||
return await useTimelineAdvanced(endpoint, slice, fromDate, toDate, {});
|
||||
}
|
||||
|
||||
export async function useReferrersTimeline(referrer: string, slice: Slice, fromDate?: string, toDate?: string) {
|
||||
return await useTimelineAdvanced('referrers', slice, fromDate, toDate, { referrer });
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function useTimelineDataRaw(timelineEndpointName: string, slice: SliceName) {
|
||||
const activeProject = useActiveProject();
|
||||
// const { safeSnapshotDates, snapshot } = useSnapshot()
|
||||
// const activeProject = useActiveProject();
|
||||
|
||||
const response = await $fetch<{ data: MetricsTimeline[], from: string, to: string }>(
|
||||
`/api/metrics/${activeProject.value?._id}/timeline/${timelineEndpointName}`, {
|
||||
method: 'POST',
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ slice }),
|
||||
});
|
||||
// const createFromToHeaders = (headers: Record<string, string> = {}) => ({
|
||||
// 'x-from': safeSnapshotDates.value.from,
|
||||
// 'x-to': safeSnapshotDates.value.to,
|
||||
// ...headers
|
||||
// });
|
||||
|
||||
return response;
|
||||
}
|
||||
// const createFromToBody = (body: Record<string, any> = {}) => ({
|
||||
// from: safeSnapshotDates.value.from,
|
||||
// to: safeSnapshotDates.value.to,
|
||||
// ...body
|
||||
// });
|
||||
|
||||
export async function useTimelineData(timelineEndpointName: string, slice: SliceName) {
|
||||
const response = await useTimelineDataRaw(timelineEndpointName, slice);
|
||||
if (!response) return;
|
||||
const fixed = fixMetrics(response, slice);
|
||||
return fixed;
|
||||
}
|
||||
|
||||
export function usePagesData(website: string, limit: number = 10) {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const res = useFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/pages`, {
|
||||
...signHeaders({
|
||||
'x-query-limit': limit.toString(),
|
||||
'x-website-name': website
|
||||
}),
|
||||
key: `pages_data:${website}:${limit}`,
|
||||
lazy: true
|
||||
});
|
||||
// export function useFirstInteractionData() {
|
||||
// const activeProject = useActiveProject();
|
||||
// const metricsInfo = useFetch<boolean>(`/api/metrics/${activeProject.value?._id}/first_interaction`, signHeaders());
|
||||
// return metricsInfo;
|
||||
// }
|
||||
|
||||
return res;
|
||||
|
||||
}
|
||||
// export function useTimelineAdvanced<T>(endpoint: string, slice: Ref<Slice>, customBody: Object = {}) {
|
||||
// const response = useCustomFetch<T>(
|
||||
// `/api/metrics/${activeProject.value?._id}/timeline/${endpoint}`,
|
||||
// () => signHeaders({ 'Content-Type': 'application/json' }).headers, {
|
||||
// method: 'POST',
|
||||
// getBody: () => createFromToBody({ slice: slice.value, ...customBody }),
|
||||
// lazy: true,
|
||||
// watchProps: [snapshot, slice]
|
||||
// });
|
||||
// return response;
|
||||
// }
|
||||
|
||||
export function useWebsitesData(limit: number = 10) {
|
||||
const activeProject = useActiveProject();
|
||||
const res = useFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/websites`, {
|
||||
...signHeaders({ 'x-query-limit': limit.toString() }),
|
||||
key: `websites_data:${limit}`,
|
||||
lazy: true
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
// export function useTimeline(endpoint: 'visits' | 'sessions' | 'referrers' | 'events_stacked', slice: Ref<Slice>) {
|
||||
// return useTimelineAdvanced<{ _id: string, count: number }[]>(endpoint, slice);
|
||||
// }
|
||||
|
||||
// export async function useReferrersTimeline(referrer: string, slice: Ref<Slice>) {
|
||||
// return await useTimelineAdvanced<{ _id: string, count: number }[]>('referrers', slice, { referrer });
|
||||
// }
|
||||
|
||||
// export function useEventsStackedTimeline(slice: Ref<Slice>) {
|
||||
// return useTimelineAdvanced<{ _id: string, name: string, count: number }[]>('events_stacked', slice);
|
||||
// }
|
||||
|
||||
|
||||
|
||||
// export async function useTimelineDataRaw(timelineEndpointName: string, slice: SliceName) {
|
||||
// const activeProject = useActiveProject();
|
||||
|
||||
// const response = await $fetch<{ data: MetricsTimeline[], from: string, to: string }>(
|
||||
// `/api/metrics/${activeProject.value?._id}/timeline/${timelineEndpointName}`, {
|
||||
// method: 'POST',
|
||||
// ...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
// body: JSON.stringify({ slice }),
|
||||
// });
|
||||
|
||||
// return response;
|
||||
// }
|
||||
|
||||
// export async function useTimelineData(timelineEndpointName: string, slice: SliceName) {
|
||||
// const response = await useTimelineDataRaw(timelineEndpointName, slice);
|
||||
// if (!response) return;
|
||||
// const fixed = fixMetrics(response, slice);
|
||||
// return fixed;
|
||||
// }
|
||||
|
||||
// export function usePagesData(website: string, limit: number = 10) {
|
||||
// const activeProject = useActiveProject();
|
||||
|
||||
// const res = useFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/pages`, {
|
||||
// ...signHeaders({
|
||||
// 'x-query-limit': limit.toString(),
|
||||
// 'x-website-name': website
|
||||
// }),
|
||||
// key: `pages_data:${website}:${limit}`,
|
||||
// lazy: true
|
||||
// });
|
||||
|
||||
// return res;
|
||||
|
||||
// }
|
||||
|
||||
|
||||
|
||||
|
||||
// export function useWebsitesData(limit: number = 10) {
|
||||
// const res = useCustomFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/websites`,
|
||||
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||
// { lazy: false, watchProps: [snapshot] }
|
||||
// );
|
||||
// return res;
|
||||
// }
|
||||
|
||||
// export function useEventsData(limit: number = 10) {
|
||||
// const res = useCustomFetch<CustomEventsAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/events`,
|
||||
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||
// { lazy: false, watchProps: [snapshot] }
|
||||
// );
|
||||
// return res;
|
||||
// }
|
||||
|
||||
// export function useReferrersData(limit: number = 10) {
|
||||
// const res = useCustomFetch<ReferrersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`,
|
||||
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||
// { lazy: false, watchProps: [snapshot] }
|
||||
// );
|
||||
// return res;
|
||||
// }
|
||||
|
||||
// export function useBrowsersData(limit: number = 10) {
|
||||
// const res = useCustomFetch<BrowsersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`,
|
||||
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||
// { lazy: false, watchProps: [snapshot] }
|
||||
// );
|
||||
// return res;
|
||||
// }
|
||||
|
||||
// export function useOssData(limit: number = 10) {
|
||||
// const res = useCustomFetch<OssAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/oss`,
|
||||
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||
// { lazy: false, watchProps: [snapshot] }
|
||||
// );
|
||||
// return res;
|
||||
// }
|
||||
|
||||
// export function useGeolocationData(limit: number = 10) {
|
||||
// const res = useCustomFetch<CountriesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/countries`,
|
||||
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||
// { lazy: false, watchProps: [snapshot] }
|
||||
// );
|
||||
// return res;
|
||||
// }
|
||||
|
||||
// export function useDevicesData(limit: number = 10) {
|
||||
// const res = useCustomFetch<DevicesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/devices`,
|
||||
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||
// { lazy: false, watchProps: [snapshot] }
|
||||
// );
|
||||
// return res;
|
||||
// }
|
||||
|
||||
9
dashboard/composables/usePricingDrawer.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
|
||||
|
||||
const pricingDrawerVisible = ref<boolean>(false);
|
||||
|
||||
|
||||
export function usePricingDrawer() {
|
||||
return { visible: pricingDrawerVisible };
|
||||
}
|
||||
76
dashboard/composables/useSnapshot.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { TProjectSnapshot } from "@schema/ProjectSnapshot";
|
||||
|
||||
const remoteSnapshots = useFetch<TProjectSnapshot[]>('/api/project/snapshots', {
|
||||
...signHeaders(),
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
watch(activeProject, async () => {
|
||||
await remoteSnapshots.refresh();
|
||||
snapshot.value = isLiveDemo() ? snapshots.value[0] : snapshots.value[1];
|
||||
});
|
||||
|
||||
const snapshots = computed(() => {
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const getDefaultSnapshots: () => TProjectSnapshot[] = () => [
|
||||
{
|
||||
project_id: activeProject.value?._id as any,
|
||||
_id: 'default0' as any,
|
||||
name: 'All',
|
||||
from: new Date(activeProject.value?.created_at || 0),
|
||||
to: new Date(Date.now()),
|
||||
color: '#CCCCCC'
|
||||
},
|
||||
{
|
||||
project_id: activeProject.value?._id as any,
|
||||
_id: 'default1' as any,
|
||||
name: 'Last month',
|
||||
from: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30),
|
||||
to: new Date(Date.now()),
|
||||
color: '#00CC00'
|
||||
},
|
||||
{
|
||||
project_id: activeProject.value?._id as any,
|
||||
_id: 'default2' as any,
|
||||
name: 'Last week',
|
||||
from: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
|
||||
to: new Date(Date.now()),
|
||||
color: '#0F02D2'
|
||||
},
|
||||
{
|
||||
project_id: activeProject.value?._id as any,
|
||||
_id: 'default3' as any,
|
||||
name: 'Last day',
|
||||
from: new Date(Date.now() - 1000 * 60 * 60 * 24),
|
||||
to: new Date(Date.now()),
|
||||
color: '#CC11CC'
|
||||
}
|
||||
]
|
||||
|
||||
return [
|
||||
...getDefaultSnapshots(),
|
||||
...(remoteSnapshots.data.value || [])
|
||||
];
|
||||
})
|
||||
|
||||
const snapshot = ref<TProjectSnapshot>(isLiveDemo() ? snapshots.value[0] : snapshots.value[1]);
|
||||
|
||||
const safeSnapshotDates = computed(() => {
|
||||
const from = new Date(snapshot.value?.from || 0).toISOString();
|
||||
const to = new Date(snapshot.value?.to || Date.now()).toISOString();
|
||||
return { from, to }
|
||||
})
|
||||
|
||||
async function updateSnapshots() {
|
||||
await remoteSnapshots.refresh();
|
||||
}
|
||||
|
||||
export function useSnapshot() {
|
||||
if (remoteSnapshots.status.value === 'idle') {
|
||||
remoteSnapshots.execute();
|
||||
}
|
||||
return { snapshot, snapshots, safeSnapshotDates, updateSnapshots }
|
||||
}
|
||||
@@ -2,64 +2,47 @@
|
||||
|
||||
import type { Section } from '~/components/CVerticalNavigation.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const { setToken } = useAccessToken();
|
||||
|
||||
import { Lit } from 'litlyx-js';
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const isPremium = computed(() => {
|
||||
return activeProject.value?.premium;
|
||||
});
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
title: 'General',
|
||||
entries: [
|
||||
{ label: 'Projects', icon: 'far fa-table-layout', to: '/project_selector' },
|
||||
{ label: 'Members', icon: 'far fa-users', to: '/members' },
|
||||
{ label: 'Admin', icon: 'fas fa-cat', adminOnly: true, to: '/admin' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Project',
|
||||
entries: [
|
||||
{ label: 'Dashboard', to: '/', icon: 'far fa-home' },
|
||||
{ label: 'Events', to: '/events', icon: 'far fa-bolt' },
|
||||
{ label: 'Analyst', to: '/analyst', icon: 'far fa-microchip-ai' },
|
||||
{ label: 'Report', to: '/report', icon: 'far fa-notes' },
|
||||
// { label: 'AI', to: '/dashboard/settings', icon: 'far fa-robot brightness-[.4]' },
|
||||
// { label: 'Visits', to: '/dashboard/visits', icon: 'far fa-eye' },
|
||||
// { label: 'Events', to: '/dashboard/events', icon: 'far fa-line-chart' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Non si vede',
|
||||
title: '',
|
||||
entries: [
|
||||
{ label: 'Dashboard', to: '/', icon: 'fal fa-table-layout' },
|
||||
{ label: 'Events', to: '/events', icon: 'fal fa-square-bolt' },
|
||||
{ label: 'Analyst', to: '/analyst', icon: 'fal fa-microchip-ai' },
|
||||
{ label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true },
|
||||
{ label: 'Links (soon)', to: '#', icon: 'fal fa-globe-pointer', disabled: true },
|
||||
{ label: 'Integrations (soon)', to: '#', icon: 'fal fa-cube', disabled: true },
|
||||
{ label: 'Settings', to: '/settings', icon: 'fal fa-gear' },
|
||||
{
|
||||
label: 'Docs', to: 'https://docs.litlyx.com', icon: 'far fa-book-open', external: true,
|
||||
grow: true,
|
||||
label: 'Documentation', to: 'https://docs.litlyx.com', icon: 'fal fa-book', external: true,
|
||||
action() { Lit.event('docs_clicked') },
|
||||
},
|
||||
{
|
||||
label: 'Github', to: 'https://github.com/litlyx/litlyx', icon: 'fab fa-github', external: true,
|
||||
action() { Lit.event('git_clicked') },
|
||||
},
|
||||
{ label: 'Billing', to: '/plans', icon: 'far fa-wallet' },
|
||||
{ label: 'Book a demo', to: '/book_demo', icon: 'far fa-calendar' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
entries: [
|
||||
{
|
||||
label: 'Logout',
|
||||
icon: 'far fa-arrow-right-from-bracket',
|
||||
action: () => {
|
||||
console.log('LOGOUT')
|
||||
setToken('');
|
||||
setLoggedUser(undefined);
|
||||
router.push('/login');
|
||||
}
|
||||
label: 'Slack support', icon: 'fab fa-slack',
|
||||
premiumOnly: true,
|
||||
action() {
|
||||
if (isPremium.value === true) {
|
||||
window.open('https://join.slack.com/t/litlyx/shared_invite/zt-2q3oawn29-hZlu_fBUBlc4052Ooe3FZg', '_blank');
|
||||
} else {
|
||||
pricingDrawer.visible.value = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
const { showDialog, closeDialog } = useBarCardDialog();
|
||||
|
||||
const { isOpen, close, open } = useMenu();
|
||||
@@ -92,7 +75,7 @@ const { isOpen, close, open } = useMenu();
|
||||
</CVerticalNavigation>
|
||||
|
||||
|
||||
<div class="overflow-hidden w-full bg-bg relative h-full">
|
||||
<div class="overflow-hidden w-full bg-lyx-background-light relative h-full">
|
||||
|
||||
<div v-if="showDialog" class="barrier w-full h-full z-[34] absolute bg-black/50 backdrop-blur-[2px]">
|
||||
<i
|
||||
|
||||
@@ -9,6 +9,12 @@ const gooleSignInConfig: any = {
|
||||
}
|
||||
|
||||
export default defineNuxtConfig({
|
||||
postcss: {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
},
|
||||
colorMode: {
|
||||
preference: 'dark',
|
||||
},
|
||||
@@ -33,9 +39,7 @@ export default defineNuxtConfig({
|
||||
AI_PROJECT: process.env.AI_PROJECT,
|
||||
AI_KEY: process.env.AI_KEY,
|
||||
EMAIL_SERVICE: process.env.EMAIL_SERVICE,
|
||||
EMAIL_HOST: process.env.EMAIL_HOST,
|
||||
EMAIL_USER: process.env.EMAIL_USER,
|
||||
EMAIL_PASS: process.env.EMAIL_PASS,
|
||||
BREVO_API_KEY: process.env.BREVO_API_KEY,
|
||||
AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET,
|
||||
GOOGLE_AUTH_CLIENT_ID: process.env.GOOGLE_AUTH_CLIENT_ID,
|
||||
GOOGLE_AUTH_CLIENT_SECRET: process.env.GOOGLE_AUTH_CLIENT_SECRET,
|
||||
@@ -58,4 +62,6 @@ export default defineNuxtConfig({
|
||||
devServer: {
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
components: true,
|
||||
extends: ['../lyx-ui']
|
||||
})
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
"postinstall": "nuxt prepare",
|
||||
"test": "vitest",
|
||||
"docker-build": "docker build -t litlyx-dashboard -f Dockerfile ../",
|
||||
"docker-inspect": "docker run -it litlyx-dashboard sh"
|
||||
"docker-inspect": "docker run -it litlyx-dashboard sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getbrevo/brevo": "^2.2.0",
|
||||
"@nuxtjs/tailwindcss": "^6.12.0",
|
||||
"chart.js": "^3.9.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dayjs": "^1.11.11",
|
||||
"google-auth-library": "^9.9.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
@@ -29,6 +31,7 @@
|
||||
"redis": "^4.6.13",
|
||||
"sass": "^1.75.0",
|
||||
"stripe": "^15.8.0",
|
||||
"v-calendar": "^3.1.2",
|
||||
"vue": "^3.4.21",
|
||||
"vue-chart-3": "^3.1.8",
|
||||
"vue-router": "^4.3.0"
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { AdminProjectsList } from '~/server/api/admin/projects';
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const { data: projects } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
|
||||
const { data: counts } = await useFetch('/api/admin/counts', signHeaders());
|
||||
|
||||
|
||||
type TProjectsGrouped = {
|
||||
user: {
|
||||
@@ -22,6 +24,7 @@ type TProjectsGrouped = {
|
||||
project_name: string,
|
||||
total_visits: number,
|
||||
total_events: number,
|
||||
total_sessions: number
|
||||
}[]
|
||||
}
|
||||
|
||||
@@ -47,7 +50,8 @@ const projectsGrouped = computed(() => {
|
||||
premium: project.premium,
|
||||
project_name: project.project_name,
|
||||
total_events: project.total_events,
|
||||
total_visits: project.total_visits
|
||||
total_visits: project.total_visits,
|
||||
total_sessions: project.total_sessions
|
||||
});
|
||||
|
||||
} else {
|
||||
@@ -61,7 +65,8 @@ const projectsGrouped = computed(() => {
|
||||
premium_type: project.premium_type,
|
||||
project_name: project.project_name,
|
||||
total_events: project.total_events,
|
||||
total_visits: project.total_visits
|
||||
total_visits: project.total_visits,
|
||||
total_sessions: project.total_sessions
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -71,6 +76,12 @@ const projectsGrouped = computed(() => {
|
||||
|
||||
}
|
||||
|
||||
result.sort((sa, sb) => {
|
||||
const ca = sa.projects.reduce((a, e) => a + (e.total_visits + e.total_events), 0);
|
||||
const cb = sb.projects.reduce((a, e) => a + (e.total_visits + e.total_events), 0);
|
||||
return cb - ca;
|
||||
})
|
||||
|
||||
return result;
|
||||
|
||||
});
|
||||
@@ -79,11 +90,6 @@ function onHideClicked() {
|
||||
isAdminHidden.value = true;
|
||||
}
|
||||
|
||||
|
||||
const projectsCount = computed(() => {
|
||||
return projects.value?.length || 0;
|
||||
});
|
||||
|
||||
const premiumCount = computed(() => {
|
||||
let premiums = 0;
|
||||
projects.value?.forEach(e => {
|
||||
@@ -93,12 +99,6 @@ const premiumCount = computed(() => {
|
||||
})
|
||||
|
||||
|
||||
const usersCount = computed(() => {
|
||||
const uniqueUsers = new Set<string>();
|
||||
projects.value?.forEach(e => uniqueUsers.add(e.user.email));
|
||||
return uniqueUsers.size;
|
||||
});
|
||||
|
||||
|
||||
const totalVisits = computed(() => {
|
||||
return projects.value?.reduce((a, e) => a + e.total_visits, 0) || 0;
|
||||
@@ -107,7 +107,6 @@ const totalEvents = computed(() => {
|
||||
return projects.value?.reduce((a, e) => a + e.total_events, 0) || 0;
|
||||
});
|
||||
|
||||
|
||||
const details = ref<any>();
|
||||
const showDetails = ref<boolean>(false);
|
||||
async function getProjectDetails(project_id: string) {
|
||||
@@ -147,10 +146,10 @@ async function resetCount(project_id: string) {
|
||||
|
||||
<div class="grid grid-cols-2">
|
||||
<div>
|
||||
Users: {{ usersCount }}
|
||||
Users: {{ counts?.users }}
|
||||
</div>
|
||||
<div>
|
||||
Projects: {{ projectsCount }} ( {{ premiumCount }} premium )
|
||||
Projects: {{ counts?.projects }} ( {{ premiumCount }} premium )
|
||||
</div>
|
||||
<div>
|
||||
Total visits: {{ formatNumberK(totalVisits) }}
|
||||
@@ -188,17 +187,17 @@ async function resetCount(project_id: string) {
|
||||
<div> {{ project.total_visits }} </div>
|
||||
<div> Events: </div>
|
||||
<div> {{ project.total_events }} </div>
|
||||
<div> Sessions: </div>
|
||||
<div> {{ project.total_sessions }} </div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<div class="bg-[#272727] hover:bg-[#313131] cursor-pointer px-8 py-2 mt-3 rounded-lg"
|
||||
@click="getProjectDetails(project._id)">
|
||||
Get details
|
||||
</div>
|
||||
<div class="bg-[#272727] hover:bg-[#313131] cursor-pointer px-8 py-2 mt-3 rounded-lg"
|
||||
@click="resetCount(project._id)">
|
||||
Reset counts
|
||||
</div>
|
||||
<div class="flex gap-4 items-center mt-4">
|
||||
<LyxUiButton type="secondary" @click="getProjectDetails(project._id)">
|
||||
Payment details
|
||||
</LyxUiButton>
|
||||
<LyxUiButton type="danger" @click="resetCount(project._id)">
|
||||
Refresh counts
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,8 @@ definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const isPremium = computed(() => (activeProject.value?.premium_type || 0) > 0);
|
||||
|
||||
const metricsInfo = ref<number>(0);
|
||||
|
||||
const columns = [
|
||||
@@ -36,7 +38,36 @@ onMounted(async () => {
|
||||
metricsInfo.value = counts.eventsCount;
|
||||
});
|
||||
|
||||
const creatingCsv = ref<boolean>(false);
|
||||
|
||||
async function downloadCSV() {
|
||||
creatingCsv.value = true;
|
||||
const result = await $fetch(`/api/project/generate_csv?mode=events&slice=${options.indexOf(selectedTimeFrom.value)}`, signHeaders());
|
||||
const blob = new Blob([result], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'ReportVisits.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
creatingCsv.value = false;
|
||||
}
|
||||
|
||||
const options = ['Last day', 'Last week', 'Last month', 'Total']
|
||||
const selectedTimeFrom = ref<string>(options[0]);
|
||||
|
||||
const showWarning = computed(() => {
|
||||
return options.indexOf(selectedTimeFrom.value) > 1
|
||||
})
|
||||
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
|
||||
function goToUpgrade() {
|
||||
pricingDrawer.visible.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -47,14 +78,38 @@ onMounted(async () => {
|
||||
|
||||
<div class="w-full h-dvh flex flex-col">
|
||||
|
||||
<div v-if="creatingCsv"
|
||||
class="fixed z-[100] flex items-center justify-center left-0 top-0 w-full h-full bg-black/60 backdrop-blur-[4px]">
|
||||
<div class="poppins text-[2rem]">
|
||||
Creating csv...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end px-12 py-3">
|
||||
<div
|
||||
|
||||
<div class="flex justify-end px-12 py-3 items-center gap-2">
|
||||
|
||||
<div v-if="showWarning" class="text-orange-400 flex gap-2 items-center">
|
||||
<i class="far fa-warning "></i>
|
||||
<div> It can take a few minutes </div>
|
||||
</div>
|
||||
<div class="w-[15rem] flex flex-col gap-0">
|
||||
<USelectMenu v-model="selectedTimeFrom" :options="options"></USelectMenu>
|
||||
</div>
|
||||
|
||||
<div v-if="isPremium" @click="downloadCSV()"
|
||||
class="bg-[#57c78fc0] hover:bg-[#57c78fab] cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
||||
Download CSV
|
||||
</div>
|
||||
|
||||
<div v-if="!isPremium" @click="goToUpgrade()"
|
||||
class="bg-[#57c78f46] hover:bg-[#57c78f42] flex gap-4 items-center cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
||||
<i class="far fa-lock"></i>
|
||||
Upgrade plan for CSV
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<UTable v-if="tableData" class="utable px-8" :ui="{
|
||||
wrapper: 'overflow-auto w-full h-full',
|
||||
thead: 'sticky top-0 bg-menu',
|
||||
|
||||
@@ -6,6 +6,8 @@ definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const isPremium = computed(() => (activeProject.value?.premium_type || 0) > 0);
|
||||
|
||||
const metricsInfo = ref<number>(0);
|
||||
|
||||
const columns = [
|
||||
@@ -68,6 +70,12 @@ const showWarning = computed(() => {
|
||||
return options.indexOf(selectedTimeFrom.value) > 1
|
||||
})
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
|
||||
function goToUpgrade() {
|
||||
pricingDrawer.visible.value = true;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -83,7 +91,9 @@ const showWarning = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex justify-end px-12 py-3 items-center gap-2">
|
||||
|
||||
<div v-if="showWarning" class="text-orange-400 flex gap-2 items-center">
|
||||
<i class="far fa-warning "></i>
|
||||
<div> It can take a few minutes </div>
|
||||
@@ -91,12 +101,21 @@ const showWarning = computed(() => {
|
||||
<div class="w-[15rem] flex flex-col gap-0">
|
||||
<USelectMenu v-model="selectedTimeFrom" :options="options"></USelectMenu>
|
||||
</div>
|
||||
<div @click="downloadCSV()"
|
||||
|
||||
<div v-if="isPremium" @click="downloadCSV()"
|
||||
class="bg-[#57c78fc0] hover:bg-[#57c78fab] cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
||||
Download CSV
|
||||
</div>
|
||||
|
||||
<div v-if="!isPremium" @click="goToUpgrade()"
|
||||
class="bg-[#57c78f46] hover:bg-[#57c78f42] flex gap-4 items-center cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
||||
<i class="far fa-lock"></i>
|
||||
Upgrade plan for CSV
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<UTable v-if="tableData" class="utable px-8" :ui="{
|
||||
wrapper: 'overflow-auto w-full h-full',
|
||||
thead: 'sticky top-0 bg-menu',
|
||||
|
||||
@@ -8,6 +8,11 @@ const selectLabelsEvents = [
|
||||
];
|
||||
const eventsStackedSelectIndex = ref<number>(0);
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const { snapshot } = useSnapshot();
|
||||
|
||||
const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProject.value?._id.toString()}`);
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -15,9 +20,9 @@ const eventsStackedSelectIndex = ref<number>(0);
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto pb-20 p-6 gap-6 flex flex-col">
|
||||
|
||||
|
||||
<div class="flex gap-6 flex-col xl:flex-row">
|
||||
<CardTitled class="p-4 flex-[4]" title="Events" sub="Events stacked bar chart.">
|
||||
|
||||
<CardTitled :key="refreshKey" class="p-4 flex-[4] w-full" title="Events" sub="Events stacked bar chart.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="eventsStackedSelectIndex = $event"
|
||||
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
|
||||
@@ -29,27 +34,19 @@ const eventsStackedSelectIndex = ref<number>(0);
|
||||
</div>
|
||||
</CardTitled>
|
||||
|
||||
<div class="bg-card p-4 rounded-xl flex-[2] flex flex-col gap-10 h-full">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="poppins font-semibold text-[1.4rem] text-text">
|
||||
Top events
|
||||
</div>
|
||||
<div class="poppins text-[1rem] text-text-sub/90">
|
||||
Displays key events.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardTitled :key="refreshKey" class="p-4 flex-[2] w-full h-full" title="Top events"
|
||||
sub="Displays key events.">
|
||||
<DashboardEventsChart class="w-full"> </DashboardEventsChart>
|
||||
</CardTitled>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<EventsUserFlow></EventsUserFlow>
|
||||
<EventsUserFlow :key="refreshKey"></EventsUserFlow>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<EventsMetadataAnalyzer></EventsMetadataAnalyzer>
|
||||
<EventsMetadataAnalyzer :key="refreshKey"></EventsMetadataAnalyzer>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -24,18 +24,24 @@ const limitsInfo = ref<{
|
||||
onMounted(async () => {
|
||||
if (route.query.just_logged) return location.href = '/';
|
||||
limitsInfo.value = await $fetch<any>("/api/project/limits_info", signHeaders());
|
||||
watch(activeProject, async () => {
|
||||
limitsInfo.value = await $fetch<any>("/api/project/limits_info", signHeaders());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
|
||||
|
||||
function copyProjectId() {
|
||||
if (!navigator.clipboard) alert('NON PUOI COPIARE IN HTTP');
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
navigator.clipboard.writeText(activeProject.value?._id?.toString() || '');
|
||||
alert('Copiato !');
|
||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
|
||||
function copyScript() {
|
||||
if (!navigator.clipboard) alert('NON PUOI COPIARE IN HTTP');
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
|
||||
|
||||
const createScriptText = () => {
|
||||
@@ -48,18 +54,17 @@ function copyScript() {
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(createScriptText());
|
||||
alert('Copiato !');
|
||||
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
const { data: firstInteraction, pending, refresh } = useFirstInteractionData();
|
||||
const firstInteractionUrl = computed(() => {
|
||||
return `/api/metrics/${activeProject.value?._id}/first_interaction`
|
||||
});
|
||||
|
||||
|
||||
watch(pending, () => {
|
||||
if (pending.value === true) return;
|
||||
if (firstInteraction.value === false) {
|
||||
setTimeout(() => { refresh(); }, 2000);
|
||||
}
|
||||
})
|
||||
const firstInteraction = useFetch<boolean>(firstInteractionUrl, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
});
|
||||
|
||||
const selectLabels = [
|
||||
{ label: 'Hour', value: 'hour' },
|
||||
@@ -68,32 +73,56 @@ const selectLabels = [
|
||||
];
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
const { snapshot } = useSnapshot();
|
||||
|
||||
const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProject.value?._id.toString()}`);
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
|
||||
function goToUpgrade() {
|
||||
pricingDrawer.visible.value = true;
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="dashboard w-full h-full overflow-y-auto pb-20 md:pt-4 lg:pt-0">
|
||||
|
||||
<div :key="'home-' + isLiveDemo()" v-if="projects && activeProject && firstInteraction">
|
||||
<div :key="'home-' + isLiveDemo()" v-if="projects && activeProject && firstInteraction.data.value">
|
||||
|
||||
<div class="w-full px-4 py-2">
|
||||
|
||||
|
||||
<div v-if="limitsInfo && limitsInfo.limited"
|
||||
class="bg-orange-600 justify-center flex gap-2 py-2 px-4 font-semibold text-[1.2rem] rounded-lg">
|
||||
<div class="poppins text-text"> Limit reached </div>
|
||||
<NuxtLink to="/plans" class="poppins text-[#393972] underline cursor-pointer">
|
||||
Upgrade project
|
||||
</NuxtLink>
|
||||
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>
|
||||
|
||||
</div>
|
||||
|
||||
<DashboardTopSection></DashboardTopSection>
|
||||
<DashboardTopCards></DashboardTopCards>
|
||||
<DashboardTopCards :key="refreshKey"></DashboardTopCards>
|
||||
|
||||
|
||||
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row">
|
||||
|
||||
<CardTitled class="p-4 flex-1" title="Visits trends" sub="Shows trends in page visits.">
|
||||
<CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Visits trends"
|
||||
sub="Shows trends in page visits.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="mainChartSelectIndex = $event" :currentIndex="mainChartSelectIndex"
|
||||
:options="selectLabels">
|
||||
@@ -105,7 +134,8 @@ const selectLabels = [
|
||||
</div>
|
||||
</CardTitled>
|
||||
|
||||
<CardTitled class="p-4 flex-1" title="Sessions" sub="Shows trends in sessions.">
|
||||
<!-- <CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Sessions"
|
||||
sub="Shows trends in sessions.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="sessionsChartSelectIndex = $event"
|
||||
:currentIndex="sessionsChartSelectIndex" :options="selectLabels">
|
||||
@@ -115,30 +145,29 @@ const selectLabels = [
|
||||
<DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)">
|
||||
</DashboardSessionsLineChart>
|
||||
</div>
|
||||
</CardTitled>
|
||||
</CardTitled> -->
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex w-full justify-center mt-6 px-6">
|
||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||
<div class="flex-1">
|
||||
<DashboardWebsitesBarCard></DashboardWebsitesBarCard>
|
||||
<DashboardWebsitesBarCard :key="refreshKey"></DashboardWebsitesBarCard>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<DashboardEventsBarCard></DashboardEventsBarCard>
|
||||
<DashboardEventsBarCard :key="refreshKey"></DashboardEventsBarCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="flex w-full justify-center mt-6 px-6">
|
||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||
<div class="flex-1">
|
||||
<DashboardReferrersBarCard></DashboardReferrersBarCard>
|
||||
<DashboardReferrersBarCard :key="refreshKey"></DashboardReferrersBarCard>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<DashboardBrowsersBarCard></DashboardBrowsersBarCard>
|
||||
<DashboardBrowsersBarCard :key="refreshKey"></DashboardBrowsersBarCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,10 +175,10 @@ const selectLabels = [
|
||||
<div class="flex w-full justify-center mt-6 px-6">
|
||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||
<div class="flex-1">
|
||||
<DashboardOssBarCard></DashboardOssBarCard>
|
||||
<DashboardOssBarCard :key="refreshKey"></DashboardOssBarCard>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<DashboardGeolocationBarCard></DashboardGeolocationBarCard>
|
||||
<DashboardGeolocationBarCard :key="refreshKey"></DashboardGeolocationBarCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,19 +186,19 @@ const selectLabels = [
|
||||
<div class="flex w-full justify-center mt-6 px-6">
|
||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||
<div class="flex-1">
|
||||
<DashboardDevicesBarCard></DashboardDevicesBarCard>
|
||||
<DashboardDevicesBarCard :key="refreshKey"></DashboardDevicesBarCard>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="!firstInteraction && activeProject" class="mt-[36vh] flex flex-col gap-6">
|
||||
<div v-if="!firstInteraction.data.value && activeProject" class="mt-[20vh] lg:mt-[36vh] flex flex-col gap-6">
|
||||
<div class="flex gap-4 items-center justify-center">
|
||||
<div class="animate-pulse w-[1.5rem] h-[1.5rem] bg-accent rounded-full"> </div>
|
||||
<div class="text-text/90 poppins text-[1.4rem] font-bold">
|
||||
<div class="text-text/90 poppins text-[1.3rem] font-semibold">
|
||||
Waiting for your first Visit or Event
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,7 +229,11 @@ const selectLabels = [
|
||||
|
||||
</div>
|
||||
|
||||
<div></div>
|
||||
<NuxtLink to="https://docs.litlyx.com" target="_blank"
|
||||
class="flex justify-center poppins text-[1.2rem] text-accent gap-2 items-center">
|
||||
<div> <i class="far fa-book"></i> </div>
|
||||
<div class="poppins"> Go to docs </div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
definePageMeta({ layout: 'none' });
|
||||
|
||||
const { snapshot, snapshots } = useSnapshot();
|
||||
|
||||
const { data: project } = useLiveDemo();
|
||||
|
||||
@@ -9,7 +10,7 @@ let interval: any;
|
||||
|
||||
onMounted(async () => {
|
||||
await getOnlineUsers();
|
||||
|
||||
snapshot.value = snapshots.value[0];
|
||||
interval = setInterval(async () => {
|
||||
await getOnlineUsers();
|
||||
}, 5000);
|
||||
@@ -71,14 +72,10 @@ const selectLabelsEvents = [
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
<div class="flex gap-2 md:pt-0 pt-4">
|
||||
<NuxtLink target="_blank" to="https://cal.com/litlyx/30min"
|
||||
class="bg-white hover:bg-white/90 px-4 py-3 text-black poppins font-semibold text-[.9rem] lg:text-[1.2rem] rounded-lg">
|
||||
Book a demo
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/"
|
||||
class="bg-accent hover:bg-accent/90 px-4 py-3 poppins font-semibold text-[.9rem] lg:text-[1.2rem] rounded-lg">
|
||||
Go to dashboard
|
||||
</NuxtLink>
|
||||
<LyxUiButton link="/" type="primary"
|
||||
class="poppins font-semibold text-[.9rem] lg:text-[1.2rem] flex items-center !px-14 py-4">
|
||||
Get started for free
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,7 +87,7 @@ const selectLabelsEvents = [
|
||||
|
||||
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row">
|
||||
|
||||
<CardTitled class="p-4 flex-1" title="Visits trends" sub="Shows trends in page visits.">
|
||||
<CardTitled class="p-4 flex-1 w-full" title="Visits trends" sub="Shows trends in page visits.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="mainChartSelectIndex = $event" :currentIndex="mainChartSelectIndex"
|
||||
:options="selectLabels">
|
||||
@@ -102,7 +99,7 @@ const selectLabelsEvents = [
|
||||
</div>
|
||||
</CardTitled>
|
||||
|
||||
<CardTitled class="p-4 flex-1" title="Sessions" sub="Shows trends in sessions.">
|
||||
<!-- <CardTitled class="p-4 flex-1" title="Sessions" sub="Shows trends in sessions.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="sessionsChartSelectIndex = $event"
|
||||
:currentIndex="sessionsChartSelectIndex" :options="selectLabels">
|
||||
@@ -112,13 +109,14 @@ const selectLabelsEvents = [
|
||||
<DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)">
|
||||
</DashboardSessionsLineChart>
|
||||
</div>
|
||||
</CardTitled>
|
||||
</CardTitled> -->
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex gap-6 flex-col xl:flex-row p-6">
|
||||
<CardTitled class="p-4 flex-[4]" title="Events" sub="Events stacked bar chart.">
|
||||
|
||||
<CardTitled class="p-4 flex-[4] w-full h-full" title="Events" sub="Events stacked bar chart.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="eventsStackedSelectIndex = $event"
|
||||
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
|
||||
@@ -130,23 +128,15 @@ const selectLabelsEvents = [
|
||||
</div>
|
||||
</CardTitled>
|
||||
|
||||
<div class="bg-menu p-4 rounded-xl flex-[2] flex flex-col gap-10 h-full">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="poppins font-semibold text-[1.4rem] text-text">
|
||||
Top events
|
||||
</div>
|
||||
<div class="poppins text-[1rem] text-text-sub/90">
|
||||
Displays key events.
|
||||
</div>
|
||||
<CardTitled title="Top events" sub=" Displays key events." class="p-4 flex-[2] w-full h-full">
|
||||
<div>
|
||||
<DashboardEventsChart class="w-full"> </DashboardEventsChart>
|
||||
</div>
|
||||
|
||||
<DashboardEventsChart class="w-full"> </DashboardEventsChart>
|
||||
|
||||
</div>
|
||||
</CardTitled>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex w-full justify-center mt-6 px-6">
|
||||
<div class="flex w-full justify-center px-6">
|
||||
<div class="flex w-full gap-6 flex-col lg:flex-row">
|
||||
<div class="flex-1">
|
||||
<DashboardWebsitesBarCard></DashboardWebsitesBarCard>
|
||||
@@ -203,15 +193,15 @@ const selectLabelsEvents = [
|
||||
Do you want this KPIs for your website ?
|
||||
</div>
|
||||
<div class="poppins font-semibold text-text-sub">
|
||||
Start now ! It's free.
|
||||
Start now! It's free.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 flex-col md:flex-row">
|
||||
<NuxtLink to="/"
|
||||
class="bg-accent hover:bg-accent/90 px-14 py-4 poppins font-semibold text-[1.1rem] lg:text-[1.6rem] rounded-lg">
|
||||
<LyxUiButton link="/" type="primary"
|
||||
class="poppins font-semibold text-[1.1rem] lg:text-[1.6rem] flex items-center !px-14">
|
||||
Get started
|
||||
</NuxtLink>
|
||||
</LyxUiButton>
|
||||
<NuxtLink target="_blank" to="https://cal.com/litlyx/30min"
|
||||
class="bg-white hover:bg-white/90 text-black px-14 py-4 poppins font-semibold text-[1.1rem] lg:text-[1.6rem] rounded-lg">
|
||||
Book a demo
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const { data: planData } = useFetch('/api/project/plan', signHeaders());
|
||||
|
||||
const percent = computed(() => {
|
||||
if (!planData.value) return '-';
|
||||
return (100 / planData.value.limit * planData.value.count).toFixed(2) + '%';
|
||||
});
|
||||
|
||||
const color = computed(() => {
|
||||
if (!planData.value) return 'blue';
|
||||
if (planData.value.count >= planData.value.limit) return 'red';
|
||||
return 'blue';
|
||||
});
|
||||
|
||||
const daysLeft = computed(() => {
|
||||
if (!planData.value) return '-';
|
||||
return (-dayjs().diff(planData.value.billing_expire_at, 'days')).toString();
|
||||
});
|
||||
|
||||
const leftPercent = computed(() => {
|
||||
if (!planData.value) return 0;
|
||||
const left = dayjs().diff(planData.value.billing_expire_at, 'days');
|
||||
const total = dayjs(planData.value.billing_start_at).diff(planData.value.billing_expire_at, 'days');
|
||||
const percent = 100 - (100 / total * left);
|
||||
return percent;
|
||||
});
|
||||
|
||||
const prettyExpireDate = computed(() => {
|
||||
if (!planData.value) return '';
|
||||
return dayjs(planData.value.billing_expire_at).format('DD/MM/YYYY');
|
||||
});
|
||||
|
||||
|
||||
const { data: invoices } = await useFetch(`/api/pay/${activeProject.value?._id.toString()}/invoices`, signHeaders())
|
||||
|
||||
const showPricingDrawer = ref<boolean>(false);
|
||||
function onPlanUpgradeClick() {
|
||||
showPricingDrawer.value = true;
|
||||
}
|
||||
|
||||
function openInvoice(link: string) {
|
||||
window.open(link, '_blank');
|
||||
}
|
||||
|
||||
function getPremiumName(type: number) {
|
||||
if (type === 0) return 'FREE';
|
||||
if (type === 1) return 'ACCELERATION';
|
||||
if (type === 2) return 'EXPANSION';
|
||||
return 'CUSTOM';
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="w-full h-full p-8 overflow-y-auto pb-40 lg:pb-0 relative overflow-x-hidden">
|
||||
|
||||
<Transition name="pdrawer">
|
||||
<PricingDrawer @onCloseClick="showPricingDrawer = false" :currentSub="planData?.premium_type || 0"
|
||||
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]"
|
||||
v-if=showPricingDrawer>
|
||||
</PricingDrawer>
|
||||
</Transition>
|
||||
|
||||
<div @click="showPricingDrawer = false" v-if="showPricingDrawer"
|
||||
class="barrier absolute left-0 top-0 w-full h-full z-[19] bg-black/40 backdrop-blur-[1px]">
|
||||
</div>
|
||||
|
||||
<div class="poppins font-semibold text-[1.8rem]">
|
||||
Billing
|
||||
</div>
|
||||
|
||||
<div class="poppins text-[1.3rem] text-text-sub">
|
||||
Manage your billing cycle for the project
|
||||
<span class="font-bold">
|
||||
{{ activeProject?.name || '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="my-4 mb-10 w-full bg-gray-400/30 h-[1px]">
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-start gap-8">
|
||||
<Card v-if="planData" class="px-0 pt-6 pb-4 w-[35rem] flex flex-col">
|
||||
<div class="flex flex-col gap-6 px-8 grow">
|
||||
<div class="flex justify-between 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">
|
||||
{{ 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]"> $0 </div>
|
||||
<div class="poppins text-text-sub mt-2"> per month </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="poppins"> Billing period:</div>
|
||||
<div class="flex items-center gap-2 md:gap-4 flex-col pt-4 md:pt-0 md:flex-row">
|
||||
<div class="grow w-full md:w-auto">
|
||||
<UProgress color="green" :min="0" :max="100" :value="leftPercent"></UProgress>
|
||||
</div>
|
||||
<div class="poppins"> {{ daysLeft }} days left </div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
Subscription: {{ planData.subscription_status }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-4 w-full bg-gray-400/30 h-[1px]">
|
||||
</div>
|
||||
<div class="flex justify-between px-8 flex-col sm:flex-row">
|
||||
<div class="flex gap-2 text-text-sub text-[.9rem]">
|
||||
<div class="poppins"> Expire date:</div>
|
||||
<div> {{ prettyExpireDate }}</div>
|
||||
</div>
|
||||
<div v-if="!isGuest" @click="onPlanUpgradeClick()"
|
||||
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>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card v-if="planData" class="px-0 pt-6 pb-4 w-[35rem] flex flex-col">
|
||||
<div class="flex flex-col gap-6 px-8">
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col">
|
||||
<div class="poppins font-semibold text-[1.1rem]">
|
||||
Usage
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.9rem]">
|
||||
Check the usage limits of your project.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="poppins"> Usage:</div>
|
||||
<div class="flex items-center gap-2 md:gap-4 flex-col pt-4 md:pt-0 md:flex-row">
|
||||
<div class="grow w-full md:w-auto">
|
||||
<UProgress :color="color" :min="0" :max="planData.limit" :value="planData.count">
|
||||
</UProgress>
|
||||
</div>
|
||||
<div class="poppins"> {{ percent }}</div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
{{ formatNumberK(planData.count) }} / {{ formatNumberK(planData.limit) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
<CardTitled v-if="!isGuest" title="Invoices" :sub="(invoices && invoices.length == 0) ? 'No invoices yet' : ''"
|
||||
class="p-4 mt-8 max-w-[72rem]">
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
<div class="flex justify-between items-center bg-[#161616] p-4 rounded-lg" v-for="invoice of invoices">
|
||||
|
||||
<div> <i class="fal fa-file-invoice"></i> </div>
|
||||
|
||||
<div class="flex flex-col md:flex-row md:justify-around md:grow items-center gap-2">
|
||||
<div> {{ new Date(invoice.date).toLocaleString() }} </div>
|
||||
<div> € {{ invoice.cost / 100 }} </div>
|
||||
<div> {{ invoice.id }} </div>
|
||||
<div
|
||||
class="flex items-center lato text-[.8rem] bg-accent/25 border-accent/40 border-[1px] px-[.6rem] rounded-lg">
|
||||
{{ invoice.status }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<i @click="openInvoice(invoice.link)"
|
||||
class="far fa-download cursor-pointer hover:text-white/80"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</CardTitled>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pdrawer-enter-active,
|
||||
.pdrawer-leave-active {
|
||||
transition: all .5s ease-in-out;
|
||||
}
|
||||
|
||||
.pdrawer-enter-from,
|
||||
.pdrawer-leave-to {
|
||||
transform: translateX(100%)
|
||||
}
|
||||
|
||||
.pdrawer-enter-to,
|
||||
.pdrawer-leave-from {
|
||||
transform: translateX(0)
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@ definePageMeta({ layout: 'header' });
|
||||
|
||||
<template>
|
||||
|
||||
<div class="home h-full overflow-y-auto relative">
|
||||
<!-- <div class="home h-full overflow-y-auto relative">
|
||||
|
||||
<div class="absolute top-0 left-0 w-full h-full flex flex-col items-center z-0 overflow-hidden">
|
||||
<HomeBgGrid :size="50" :spacing="18" opacity="0.3" class="w-fit h-fit"></HomeBgGrid>
|
||||
@@ -96,6 +96,6 @@ definePageMeta({ layout: 'header' });
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const { projects, refresh } = useProjectsList();
|
||||
@@ -97,15 +96,19 @@ async function deleteAccount() {
|
||||
{{ projects?.length ?? '-' }} / {{ maxProjects || 3 }}
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLink v-if="(projects?.length || 0) < (maxProjects || 3)" to="/project_creation"
|
||||
<NuxtLink v-if="(projects?.length || 0) < (maxProjects || 3)" to="/project_creation"
|
||||
class="bg-blue-500/20 hover:bg-blue-500/30 px-4 py-1 flex items-center gap-4 rounded-xl cursor-pointer">
|
||||
<div class="h-full aspect-[1/1] flex items-center justify-center">
|
||||
<i class="fas fa-plus text-[1rem] text-text-sub/80"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-text font-semibold manrope text-[1.3rem]"> Create new project</div>
|
||||
<div class="text-text font-semibold manrope text-[1.3rem]">
|
||||
Create new project
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="text-text/85 mt-8 poppis text-[1.2rem]" v-if="projects.length == 0">
|
||||
|
||||
@@ -31,7 +31,9 @@ async function generatePDF() {
|
||||
|
||||
<div class="home w-full h-full px-10 lg:px-0 overflow-y-auto pb-[12rem] md:pb-0">
|
||||
|
||||
<div class="flex flex-col items-center justify-center mt-20 gap-20">
|
||||
<DialogCreateSnapshot></DialogCreateSnapshot>
|
||||
|
||||
<!-- <div class="flex flex-col items-center justify-center mt-20 gap-20">
|
||||
|
||||
<div class="flex flex-col items-center justify-center gap-10">
|
||||
<div class="poppins text-[2.4rem] font-bold text-text">
|
||||
@@ -84,7 +86,7 @@ async function generatePDF() {
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
49
dashboard/pages/settings.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
|
||||
const items = [
|
||||
{ label: 'General', slot: 'general' },
|
||||
{ label: 'Members', slot: 'members' },
|
||||
{ label: 'Billing', slot: 'billing' },
|
||||
{ label: 'Account', slot: 'account' }
|
||||
]
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-10 py-8 h-dvh overflow-y-auto hide-scrollbars">
|
||||
<div class="poppins font-semibold text-[1.3rem]"> Settings </div>
|
||||
|
||||
<CustomTab :items="items" class="mt-8">
|
||||
<template #general>
|
||||
<SettingsGeneral></SettingsGeneral>
|
||||
</template>
|
||||
<template #members>
|
||||
<SettingsMembers></SettingsMembers>
|
||||
</template>
|
||||
<template #billing>
|
||||
<SettingsBilling></SettingsBilling>
|
||||
</template>
|
||||
<template #account>
|
||||
<SettingsAccount></SettingsAccount>
|
||||
</template>
|
||||
</CustomTab>
|
||||
|
||||
<!-- <UTabs :items="items" class="mt-8">
|
||||
<template #general>
|
||||
<SettingsGeneral></SettingsGeneral>
|
||||
</template>
|
||||
<template #members>
|
||||
<SettingsMembers></SettingsMembers>
|
||||
</template>
|
||||
<template #billing>
|
||||
<SettingsBilling></SettingsBilling>
|
||||
</template>
|
||||
<template #account>
|
||||
<SettingsAccount></SettingsAccount>
|
||||
</template>
|
||||
</UTabs> -->
|
||||
</div>
|
||||
</template>
|
||||
BIN
dashboard/pdf_fonts/Poppins-Italic.ttf
Normal file
782
dashboard/pnpm-lock.yaml
generated
BIN
dashboard/public/check.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
dashboard/public/logo_32.png
Normal file
|
After Width: | Height: | Size: 568 B |
17
dashboard/server/api/admin/counts.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
if (!userData?.logged) return;
|
||||
if (!userData.user.roles.includes('ADMIN')) return;
|
||||
|
||||
|
||||
const projectsCount = await ProjectModel.countDocuments({});
|
||||
const usersCount = await UserModel.countDocuments({});
|
||||
|
||||
return { users: usersCount, projects: projectsCount }
|
||||
|
||||
});
|
||||
@@ -14,7 +14,8 @@ export type AdminProjectsList = {
|
||||
created_at: Date
|
||||
},
|
||||
total_visits: number,
|
||||
total_events: number
|
||||
total_events: number,
|
||||
total_sessions: number
|
||||
}
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
@@ -54,6 +55,9 @@ export default defineEventHandler(async event => {
|
||||
},
|
||||
total_events: {
|
||||
$arrayElemAt: ["$counts.events", 0]
|
||||
},
|
||||
total_sessions: {
|
||||
$arrayElemAt: ["$counts.sessions", 0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
import { ProjectCountModel } from "@schema/ProjectsCounts";
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
@@ -13,8 +14,9 @@ export default defineEventHandler(async event => {
|
||||
|
||||
const events = await EventModel.countDocuments({ project_id });
|
||||
const visits = await VisitModel.countDocuments({ project_id });
|
||||
const sessions = await SessionModel.countDocuments({ project_id });
|
||||
|
||||
await ProjectCountModel.updateOne({ project_id, events, visits }, {}, { upsert: true });
|
||||
await ProjectCountModel.updateOne({ project_id, events, visits, sessions }, {}, { upsert: true });
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -51,6 +51,7 @@ export default defineEventHandler(async event => {
|
||||
const savedUser = await newUser.save();
|
||||
|
||||
setImmediate(() => {
|
||||
console.log('SENDING WELCOME EMAIL TO', payload.email);
|
||||
if (payload.email) EmailService.sendWelcomeEmail(payload.email);
|
||||
});
|
||||
|
||||
|
||||
47
dashboard/server/api/keys/create.post.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { ApiSettingsModel, TApiSettings } from "@schema/ApiSettingsSchema";
|
||||
import { UserSettingsModel } from "@schema/UserSettings";
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
function generateApiKey() {
|
||||
return 'lit_' + crypto.randomBytes(6).toString('hex');
|
||||
}
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
if (body.name.length == 0) return setResponseStatus(event, 400, 'name is required');
|
||||
|
||||
if (body.name.length < 3) return setResponseStatus(event, 400, 'name too short');
|
||||
if (body.name.length > 32) return setResponseStatus(event, 400, 'name too long');
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||
|
||||
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
|
||||
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
|
||||
|
||||
const project_id = currentActiveProject.active_project_id;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||
|
||||
if (project.owner.toString() != userData.id) {
|
||||
return setResponseStatus(event, 400, 'You are not the owner');
|
||||
}
|
||||
|
||||
const key = generateApiKey();
|
||||
|
||||
const keyNumbers = await ApiSettingsModel.countDocuments({ project_id });
|
||||
|
||||
if (keyNumbers >= 5) return setResponseStatus(event, 400, 'Api key limit reached');
|
||||
|
||||
const newApiSettings = await ApiSettingsModel.create({ project_id, apiKey: key, apiName: body.name, created_at: Date.now(), usage: 0 });
|
||||
|
||||
return newApiSettings.toJSON();
|
||||
|
||||
});
|
||||
28
dashboard/server/api/keys/delete.delete.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
import { ApiSettingsModel } from "@schema/ApiSettingsSchema";
|
||||
import { UserSettingsModel } from "@schema/UserSettings";
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||
|
||||
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
|
||||
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
|
||||
|
||||
const project_id = currentActiveProject.active_project_id;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||
|
||||
if (project.owner.toString() != userData.id) {
|
||||
return setResponseStatus(event, 400, 'You are not the owner');
|
||||
}
|
||||
|
||||
const deletation = await ApiSettingsModel.deleteOne({ _id: body.api_id });
|
||||
return { ok: deletation.acknowledged };
|
||||
|
||||
});
|
||||
33
dashboard/server/api/keys/get_all.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { ApiSettingsModel, TApiSettings } from "@schema/ApiSettingsSchema";
|
||||
import { UserSettingsModel } from "@schema/UserSettings";
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
|
||||
|
||||
function cryptApiKeyName(apiSettings: TApiSettings): TApiSettings {
|
||||
return { ...apiSettings, apiKey: apiSettings.apiKey.substring(0, 6) + '******' }
|
||||
}
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||
|
||||
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
|
||||
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
|
||||
|
||||
const project_id = currentActiveProject.active_project_id;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||
|
||||
if (project.owner.toString() != userData.id) {
|
||||
return setResponseStatus(event, 400, 'You are not the owner');
|
||||
}
|
||||
|
||||
const apiKeys = await ApiSettingsModel.find({ project_id }, { project_id: 0 })
|
||||
|
||||
return apiKeys.map(e => cryptApiKeyName(e.toJSON())) as TApiSettings[];
|
||||
|
||||
});
|
||||
@@ -36,12 +36,12 @@ export default defineEventHandler(async event => {
|
||||
$group: {
|
||||
_id: "$project_id",
|
||||
events: { $sum: "$events" },
|
||||
visits: { $sum: "$visits" }
|
||||
visits: { $sum: "$visits" },
|
||||
sessions: { $sum: "$sessions" },
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
|
||||
const sessionsVisitsCount: any[] = await Redis.useCache({
|
||||
key: `counts:${project_id}:sessions_count`,
|
||||
exp: COUNTS_SESSIONS_EXPIRE_TIME
|
||||
|
||||
@@ -22,17 +22,31 @@ export default defineEventHandler(async event => {
|
||||
const limit = getRequestHeader(event, 'x-query-limit');
|
||||
const numLimit = parseInt(limit || '10');
|
||||
|
||||
|
||||
const from = getRequestHeader(event, 'x-from');
|
||||
const to = getRequestHeader(event, 'x-to');
|
||||
|
||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `browsers:${project_id}:${numLimit}`,
|
||||
key: `browsers:${project_id}:${numLimit}:${from}:${to}`,
|
||||
exp: DATA_EXPIRE_TIME
|
||||
}, async () => {
|
||||
const browsers: BrowsersAggregated[] = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id }, },
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id,
|
||||
created_at: {
|
||||
$gte: new Date(from),
|
||||
$lte: new Date(to)
|
||||
}
|
||||
},
|
||||
},
|
||||
{ $group: { _id: "$browser", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: numLimit }
|
||||
]);
|
||||
|
||||
|
||||
return browsers;
|
||||
});
|
||||
|
||||
|
||||
@@ -21,13 +21,26 @@ export default defineEventHandler(async event => {
|
||||
const limit = getRequestHeader(event, 'x-query-limit');
|
||||
const numLimit = parseInt(limit || '10');
|
||||
|
||||
const from = getRequestHeader(event, 'x-from');
|
||||
const to = getRequestHeader(event, 'x-to');
|
||||
|
||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `countries:${project_id}:${numLimit}`,
|
||||
key: `countries:${project_id}:${numLimit}:${from}:${to}`,
|
||||
exp: DATA_EXPIRE_TIME
|
||||
}, async () => {
|
||||
const countries: CountriesAggregated[] = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id, country: { $ne: null } }, },
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id,
|
||||
country: { $ne: null },
|
||||
created_at: {
|
||||
$gte: new Date(from),
|
||||
$lte: new Date(to)
|
||||
}
|
||||
},
|
||||
},
|
||||
{ $group: { _id: "$country", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: numLimit }
|
||||
|
||||
@@ -20,13 +20,26 @@ export default defineEventHandler(async event => {
|
||||
const limit = getRequestHeader(event, 'x-query-limit');
|
||||
const numLimit = parseInt(limit || '10');
|
||||
|
||||
const from = getRequestHeader(event, 'x-from');
|
||||
const to = getRequestHeader(event, 'x-to');
|
||||
|
||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `devices:${project_id}:${numLimit}`,
|
||||
key: `devices:${project_id}:${numLimit}:${from}:${to}`,
|
||||
exp: DATA_EXPIRE_TIME
|
||||
}, async () => {
|
||||
const devices: DevicesAggregated[] = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id, device: { $ne: null } }, },
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id,
|
||||
device: { $ne: null },
|
||||
created_at: {
|
||||
$gte: new Date(from),
|
||||
$lte: new Date(to)
|
||||
}
|
||||
},
|
||||
},
|
||||
{ $group: { _id: "$device", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: numLimit }
|
||||
|
||||
49
dashboard/server/api/metrics/[project_id]/data/events.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { DATA_EXPIRE_TIME, Redis } from "~/server/services/CacheService";
|
||||
|
||||
export type CustomEventsAggregated = {
|
||||
_id: string,
|
||||
count: number
|
||||
}
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
const project_id = getRequestProjectId(event);
|
||||
if (!project_id) return;
|
||||
|
||||
const user = getRequestUser(event);
|
||||
const project = await getUserProjectFromId(project_id, user);
|
||||
if (!project) return;
|
||||
|
||||
const from = getRequestHeader(event, 'x-from');
|
||||
const to = getRequestHeader(event, 'x-to');
|
||||
|
||||
|
||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
|
||||
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `events:${project_id}:${from}:${to}`,
|
||||
exp: DATA_EXPIRE_TIME
|
||||
}, async () => {
|
||||
|
||||
const events: CustomEventsAggregated[] = await EventModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id, created_at: {
|
||||
$gte: new Date(from),
|
||||
$lte: new Date(to)
|
||||
}
|
||||
},
|
||||
},
|
||||
{ $group: { _id: "$name", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } }
|
||||
]);
|
||||
|
||||
return events;
|
||||
});
|
||||
|
||||
|
||||
|
||||
});
|
||||
@@ -21,14 +21,26 @@ export default defineEventHandler(async event => {
|
||||
|
||||
const limit = getRequestHeader(event, 'x-query-limit');
|
||||
const numLimit = parseInt(limit || '10');
|
||||
|
||||
const from = getRequestHeader(event, 'x-from');
|
||||
const to = getRequestHeader(event, 'x-to');
|
||||
|
||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `oss:${project_id}:${numLimit}`,
|
||||
key: `oss:${project_id}:${numLimit}:${from}:${to}`,
|
||||
exp: DATA_EXPIRE_TIME
|
||||
}, async () => {
|
||||
const oss: OssAggregated[] = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id }, },
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id,
|
||||
created_at: {
|
||||
$gte: new Date(from),
|
||||
$lte: new Date(to)
|
||||
}
|
||||
},
|
||||
},
|
||||
{ $group: { _id: "$os", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: numLimit }
|
||||
|
||||
@@ -22,13 +22,25 @@ export default defineEventHandler(async event => {
|
||||
const limit = getRequestHeader(event, 'x-query-limit');
|
||||
const numLimit = parseInt(limit || '10');
|
||||
|
||||
const from = getRequestHeader(event, 'x-from');
|
||||
const to = getRequestHeader(event, 'x-to');
|
||||
|
||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `referrers:${project_id}:${numLimit}`,
|
||||
key: `referrers:${project_id}:${numLimit}:${from}:${to}`,
|
||||
exp: DATA_EXPIRE_TIME
|
||||
}, async () => {
|
||||
const referrers: ReferrersAggregated[] = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id }, },
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id,
|
||||
created_at: {
|
||||
$gte: new Date(from),
|
||||
$lte: new Date(to)
|
||||
}
|
||||
},
|
||||
},
|
||||
{ $group: { _id: "$referrer", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: numLimit }
|
||||
|
||||
@@ -22,12 +22,25 @@ export default defineEventHandler(async event => {
|
||||
const limit = getRequestHeader(event, 'x-query-limit');
|
||||
const numLimit = parseInt(limit || '10');
|
||||
|
||||
const from = getRequestHeader(event, 'x-from');
|
||||
const to = getRequestHeader(event, 'x-to');
|
||||
|
||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `websites:${project_id}:${numLimit}`,
|
||||
exp: DATA_EXPIRE_TIME
|
||||
}, async () => {
|
||||
const websites: VisitsWebsiteAggregated[] = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id }, },
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id,
|
||||
created_at: {
|
||||
$gte: new Date(from),
|
||||
$lte: new Date(to)
|
||||
}
|
||||
},
|
||||
},
|
||||
{ $group: { _id: "$website", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: numLimit }
|
||||
|
||||
@@ -15,12 +15,24 @@ export default defineEventHandler(async event => {
|
||||
const project = await getUserProjectFromId(project_id, user);
|
||||
if (!project) return;
|
||||
|
||||
const { name: eventName } = getQuery(event);
|
||||
if (!eventName) return [];
|
||||
const { name: eventName, from, to } = getQuery(event);
|
||||
|
||||
if (!from) return setResponseStatus(event, 400, 'from is required');
|
||||
if (!to) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!eventName) return setResponseStatus(event, 400, 'name is required');
|
||||
|
||||
|
||||
|
||||
const allEvents = await EventModel.find({ project_id: project_id, name: eventName }, { flowHash: 1 });
|
||||
const allEvents = await EventModel.find({
|
||||
project_id: project_id,
|
||||
name: eventName,
|
||||
created_at: {
|
||||
$gte: new Date(from.toString()),
|
||||
$lte: new Date(to.toString()),
|
||||
}
|
||||
}, { flowHash: 1 });
|
||||
|
||||
|
||||
const allFlowHashes = new Map<string, number>();
|
||||
|
||||
allEvents.forEach(e => {
|
||||
@@ -71,6 +83,17 @@ export default defineEventHandler(async event => {
|
||||
grouped[referrer]++;
|
||||
}
|
||||
|
||||
|
||||
const eventsCount = allEvents.length;
|
||||
|
||||
const allGroupedValue = Object.keys(grouped)
|
||||
.map(key => grouped[key])
|
||||
.reduce((a, e) => a + e, 0);
|
||||
|
||||
for (const key in grouped) {
|
||||
grouped[key] = 100 / allGroupedValue * grouped[key];
|
||||
}
|
||||
|
||||
return grouped;
|
||||
|
||||
});
|
||||
|
||||
@@ -15,11 +15,24 @@ export default defineEventHandler(async event => {
|
||||
const project = await getUserProjectFromId(project_id, user);
|
||||
if (!project) return;
|
||||
|
||||
const { name: eventName, field } = getQuery(event);
|
||||
if (!eventName || !field) return [];
|
||||
const { name: eventName, field, from, to } = getQuery(event);
|
||||
|
||||
if (!from) return setResponseStatus(event, 400, 'from is required');
|
||||
if (!to) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!eventName) return setResponseStatus(event, 400, 'name is required');
|
||||
if (!field) return setResponseStatus(event, 400, 'field is required');
|
||||
|
||||
|
||||
const aggregation: PipelineStage[] = [
|
||||
{ $match: { project_id: project._id, name: eventName } },
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id, name: eventName,
|
||||
created_at: {
|
||||
$gte: new Date(from.toString()),
|
||||
$lte: new Date(to.toString()),
|
||||
}
|
||||
}
|
||||
},
|
||||
{ $group: { _id: `$metadata.${field}`, count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } }
|
||||
]
|
||||
|
||||
@@ -15,8 +15,8 @@ export default defineEventHandler(async event => {
|
||||
const { slice, from, to } = await readBody(event);
|
||||
|
||||
if (!from) return setResponseStatus(event, 400, 'from is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'slice is required');
|
||||
if (!to) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!slice) return setResponseStatus(event, 400, 'slice is required');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `timeline:events:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { getTimeline } from "./generic";
|
||||
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { executeAdvancedTimelineAggregation } from "~/server/services/TimelineService";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
const project_id = getRequestProjectId(event);
|
||||
@@ -11,16 +12,23 @@ export default defineEventHandler(async event => {
|
||||
if (!project) return;
|
||||
|
||||
|
||||
const { slice, duration } = await readBody(event);
|
||||
const { slice, from, to } = await readBody(event);
|
||||
|
||||
if (!from) return setResponseStatus(event, 400, 'from is required');
|
||||
if (!to) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!slice) return setResponseStatus(event, 400, 'slice is required');
|
||||
|
||||
|
||||
return await Redis.useCache({ key: `timeline:events_stacked:${project_id}:${slice}`, exp: TIMELINE_EXPIRE_TIME }, async () => {
|
||||
const timelineStackedEvents = await getTimeline(EventModel, project_id, slice, duration,
|
||||
{},
|
||||
{},
|
||||
{ name: "$_id.name" },
|
||||
{ name: '$name' }
|
||||
);
|
||||
return await Redis.useCache({ key: `timeline:events_stacked:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`, exp: TIMELINE_EXPIRE_TIME }, async () => {
|
||||
|
||||
const timelineStackedEvents = await executeAdvancedTimelineAggregation<{ name: String }>({
|
||||
model: EventModel,
|
||||
projectId: project._id,
|
||||
from, to, slice,
|
||||
customProjection: { name: "$_id.name" },
|
||||
customIdGroup: { name: '$name' },
|
||||
})
|
||||
|
||||
return timelineStackedEvents;
|
||||
});
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ export default defineEventHandler(async event => {
|
||||
const { slice, from, to, referrer } = await readBody(event);
|
||||
|
||||
if (!from) return setResponseStatus(event, 400, 'from is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'slice is required');
|
||||
if (!to) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!slice) return setResponseStatus(event, 400, 'slice is required');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `timeline:referrers:${referrer}:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,
|
||||
|
||||
@@ -16,8 +16,8 @@ export default defineEventHandler(async event => {
|
||||
const { slice, from, to } = await readBody(event);
|
||||
|
||||
if (!from) return setResponseStatus(event, 400, 'from is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'slice is required');
|
||||
if (!to) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!slice) return setResponseStatus(event, 400, 'slice is required');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `timeline:sessions:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,
|
||||
|
||||
@@ -27,8 +27,8 @@ export default defineEventHandler(async event => {
|
||||
const { slice, from, to } = await readBody(event);
|
||||
|
||||
if (!from) return setResponseStatus(event, 400, 'from is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'slice is required');
|
||||
if (!to) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!slice) return setResponseStatus(event, 400, 'slice is required');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `timeline:sessions_duration:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,
|
||||
|
||||