mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 15:58:38 +01:00
Compare commits
137 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1b3e997c1 | ||
|
|
be82f7046f | ||
|
|
45e9a9c6a7 | ||
|
|
942d074f99 | ||
|
|
63fa3995c5 | ||
|
|
76e5e07f79 | ||
|
|
b8f9e598a7 | ||
|
|
0ee4895e1a | ||
|
|
72d6b97383 | ||
|
|
3f22c655a5 | ||
|
|
4fea549a5a | ||
|
|
4c1d10f8b7 | ||
|
|
50d275e0ff | ||
|
|
98238fa180 | ||
|
|
c07bccd0dd | ||
|
|
7192c31136 | ||
|
|
56d7e71d90 | ||
|
|
30229d4b97 | ||
|
|
b2303468a4 | ||
|
|
af6dff57ed | ||
|
|
dd8b089c46 | ||
|
|
a5750d556a | ||
|
|
f5882bff9f | ||
|
|
f18cdc8278 | ||
|
|
a7ebbc22c0 | ||
|
|
346eecc928 | ||
|
|
abc485a9ef | ||
|
|
0292829805 | ||
|
|
4e2c8468f8 | ||
|
|
38cfd4315d | ||
|
|
b592695a49 | ||
|
|
0963201a32 | ||
|
|
4da840f2ec | ||
|
|
a1718875d9 | ||
|
|
e931235533 | ||
|
|
881a7800ce | ||
|
|
487c3ac7b4 | ||
|
|
0dd94be6e6 | ||
|
|
29a220b21e | ||
|
|
8cc2f07b95 | ||
|
|
88cec21df1 | ||
|
|
8183ae1e68 | ||
|
|
0f39cab26a | ||
|
|
a2e4ed9ee0 | ||
|
|
30b5db4200 | ||
|
|
bfeee8673c | ||
|
|
39b8dd84f1 | ||
|
|
19b7c7664a | ||
|
|
a3e74adf9c | ||
|
|
ad9aabcbf6 | ||
|
|
510bc2545a | ||
|
|
65c682c75d | ||
|
|
04acc0b18e | ||
|
|
852fea45a5 | ||
|
|
6f3e59e72e | ||
|
|
3960eaa8ad | ||
|
|
e4bdf7e4c3 | ||
|
|
afeaac1b0d | ||
|
|
8922507a64 | ||
|
|
13e94cb0f0 | ||
|
|
3923a06e9b | ||
|
|
6b5d23566c | ||
|
|
dbcda95823 | ||
|
|
fb89c87489 | ||
|
|
b59eea47e9 | ||
|
|
473331047d | ||
|
|
5af77ff63e | ||
|
|
e6e2340432 | ||
|
|
0b90c2fe3c | ||
|
|
a6d1797a4f | ||
|
|
d1abe1a91f | ||
|
|
b733cd2a68 | ||
|
|
88ebfc188c | ||
|
|
ab95772dd4 | ||
|
|
0d5dbc69ad | ||
|
|
8a359936d1 | ||
|
|
ffd2e96138 | ||
|
|
b8e434be9a | ||
|
|
835ab6208e | ||
|
|
617de36fec | ||
|
|
fb31fdcfff | ||
|
|
745a332e56 | ||
|
|
a10755f998 | ||
|
|
46bca2f787 | ||
|
|
cb928977c3 | ||
|
|
3b5a46a64a | ||
|
|
7d05a9d157 | ||
|
|
edc897d62a | ||
|
|
39c42e7bd5 | ||
|
|
7009a0ad02 | ||
|
|
3f26f1ab68 | ||
|
|
7082b88523 | ||
|
|
29bae329b4 | ||
|
|
f908b0b4a9 | ||
|
|
b38363ddf5 | ||
|
|
68d362d1b3 | ||
|
|
0a9474d00c | ||
|
|
6307e09dc3 | ||
|
|
f358bb9bb6 | ||
|
|
23b8f7229a | ||
|
|
78f979d23a | ||
|
|
ad8e9e1ead | ||
|
|
06768b6cdc | ||
|
|
91f69baacd | ||
|
|
0964ec4250 | ||
|
|
9ce2c89575 | ||
|
|
b630bddef0 | ||
|
|
30e428a8dc | ||
|
|
b700b96191 | ||
|
|
606eb0b035 | ||
|
|
ec974c3599 | ||
|
|
4c811c160b | ||
|
|
e140585362 | ||
|
|
7d56b7a6a2 | ||
|
|
070560c1e2 | ||
|
|
41037a01a1 | ||
|
|
caef67a0e1 | ||
|
|
5ac43dec6b | ||
|
|
9de299d841 | ||
|
|
2929b229c4 | ||
|
|
f06d7d78fc | ||
|
|
4d7cfbb7b9 | ||
|
|
b4c0620f17 | ||
|
|
b8c2e40f7a | ||
|
|
e866a1c22b | ||
|
|
f86a399840 | ||
|
|
36c4406af2 | ||
|
|
b2afd585bb | ||
|
|
24ae9d0e0d | ||
|
|
b479ca1bbf | ||
|
|
0a748346c5 | ||
|
|
fa7880552a | ||
|
|
06fb8bfab0 | ||
|
|
a876d77d42 | ||
|
|
e6bb58693f | ||
|
|
00e63cc80b | ||
|
|
e43f138945 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,6 +1,11 @@
|
|||||||
steps
|
steps
|
||||||
PROCESS_EVENT
|
PROCESS_EVENT
|
||||||
|
**/node_modules/
|
||||||
docker
|
docker
|
||||||
dev
|
dev
|
||||||
docker-compose.admin.yml
|
docker-compose.admin.yml
|
||||||
full_reload.sh
|
full_reload.sh
|
||||||
|
build-all.sh
|
||||||
|
tmp
|
||||||
|
ecosystem.config.js
|
||||||
|
todo
|
||||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"files.exclude": {
|
|
||||||
"**/node_modules": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
30
Dockerfile
30
Dockerfile
@@ -1,30 +0,0 @@
|
|||||||
FROM node:21-alpine as base
|
|
||||||
|
|
||||||
FROM base as build
|
|
||||||
|
|
||||||
RUN npm i -g pnpm
|
|
||||||
RUN npm i -g pm2
|
|
||||||
|
|
||||||
# COPY --link dashboard/package.json dashboard/pnpm-lock.yaml ./
|
|
||||||
# RUN npm install --production=false
|
|
||||||
|
|
||||||
WORKDIR /home/app
|
|
||||||
|
|
||||||
COPY --link dashboard ./dashboard
|
|
||||||
COPY --link lyx-ui ./lyx-ui
|
|
||||||
COPY --link consumer ./consumer
|
|
||||||
COPY --link producer ./producer
|
|
||||||
COPY --link shared ./shared
|
|
||||||
|
|
||||||
WORKDIR /home/app/producer
|
|
||||||
RUN pnpm install
|
|
||||||
|
|
||||||
WORKDIR /home/app/consumer
|
|
||||||
RUN pnpm install
|
|
||||||
|
|
||||||
WORKDIR /home/app/dashboard
|
|
||||||
RUN pnpm install
|
|
||||||
RUN pnpm run dev
|
|
||||||
|
|
||||||
|
|
||||||
# CMD [ "node", "/home/app/.output/server/index.mjs" ]
|
|
||||||
54
README.md
54
README.md
@@ -4,13 +4,14 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h4 align="center">
|
<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">Try Litlyx Cloud. It's Free.</a>
|
📚 <a href="https://docs.litlyx.com">Docs</a> 👾 <a href="https://discord.gg/9cQykjsmWX">Join Discord</a> 🌐 <a href="https://litlyx.com">Website</a> 🔥 <a href="https://dashboard.litlyx.com">Try Litlyx Cloud. It's Free forever.</a>
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
#
|
#
|
||||||
<p align="center">
|
<p align="center">
|
||||||
The freshest, developer-friendly analytics tool.<br>
|
Litlys is a modern, developer-friendly, cookie-free analytics tool.<br>
|
||||||
Litlyx is an open-source, self-hostable analytics solution for modern frameworks. Setup takes less than 30 seconds!
|
Setup takes less than 30 seconds! Completely self-hostable with docker.<br>
|
||||||
|
Alternative to Google Analytics, Matomo, Umami, Plausible & Simple Analytics.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
|
|
||||||
## Get Started on our Cloud Version
|
## Get Started on our Cloud Version
|
||||||
|
|
||||||
Sign-up on [Litlyx.com](https://dashboard.litlyx.com) and create a project. Then simply use your project_id to connect Litlyx to your website OR Self-Host Litlyx with Docker.
|
Sign-up on [Litlyx.com](https://dashboard.litlyx.com) and create a project. Then simply use your `project_id` to connect Litlyx to your website.
|
||||||
|
|
||||||
## Universal Installation
|
## Universal Installation
|
||||||
|
|
||||||
@@ -33,25 +34,25 @@ Sign-up on [Litlyx.com](https://dashboard.litlyx.com) and create a project. Then
|
|||||||
<script defer data-project="your_project_id" src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>
|
<script defer data-project="your_project_id" src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
Importing Litlyx with a direct script instantly starts tracking `Page visits`, `Browsers`, `Devices`, `Operating Systems`, `Bouncing Rate`, `Real-Time Online Users`, `Unique Sessions`, `Countries`, and `Average Session Time`.
|
Importing Litlyx with a direct script instantly starts tracking `Visits`, `Top Pages`, `Bouncing Rate`, `Real-Time Online Users`, `Unique Visitors`, `Countries`, and `Average Session Duration`.
|
||||||
|
|
||||||
# All Javascript Runtimes
|
# All Javascript Runtimes
|
||||||
|
|
||||||
You can install Litlyx using `npm`, `pnpm`, `yarn` or any modern package managers:
|
You can install Litlyx using `npm`, `pnpm` or any modern package managers:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm i litlyx-js
|
npm i litlyx-js
|
||||||
```
|
```
|
||||||
|
|
||||||
Litlyx natively works with all JavaScript / TypeScript frameworks. You can use Litlyx in all WordPress Websites by injecting JS code using a plug-in. Litlyx also works in serverless enviroments with Cloud (or Edge) Functions.
|
Litlyx natively works with all JavaScript / TypeScript frameworks. You can use Litlyx in all WordPress Websites by injecting JS code using a third party plug-in.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/tech.png" />
|
<img src="assets/tech.png" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# Import
|
# Import using a package manager
|
||||||
|
|
||||||
Import litlyx-js library into your code:
|
First, Import litlyx-js library into your code:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { Lit } from 'litlyx-js';
|
import { Lit } from 'litlyx-js';
|
||||||
@@ -63,9 +64,9 @@ Once imported, you need to initialize Litlyx:
|
|||||||
Lit.init('your_project_id');
|
Lit.init('your_project_id');
|
||||||
```
|
```
|
||||||
|
|
||||||
After initialization, Litlyx will automatically track analytics such as `Page visits`, `Browsers`, `Devices`, `Operating Systems`, `Real-Time Online Users`, `Unique Sessions`, `Countries`, and `Average Session Time`.
|
After initialization, Litlyx will automatically track web analytics such as `Page visits`, `Real-Time Online Users`, `Unique Vistors`, and many more.
|
||||||
|
|
||||||
# Track Custom Events
|
# Track Custom Events (Actions)
|
||||||
|
|
||||||
You aren't just limited to the built-in KPIs. With Litlyx, you can create your own events to track in your project.
|
You aren't just limited to the built-in KPIs. With Litlyx, you can create your own events to track in your project.
|
||||||
|
|
||||||
@@ -104,25 +105,20 @@ curl -X POST "https://broker.litlyx.com/event" \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
# Self-Hosting with Docker
|
# Self-hosting with docker
|
||||||
|
|
||||||
To self-host the Litlyx dashboard, first **fork** this repository.
|
To self-host the Litlyx dashboard, first **clone** this repository. (Litlyx's Docker images are hosted on DockerHub).
|
||||||
|
|
||||||
Then run the following command:
|
Then run the following command:
|
||||||
```bash
|
```bash
|
||||||
docker-compose build
|
|
||||||
```
|
|
||||||
|
|
||||||
after the build finishes, run:
|
|
||||||
```bash
|
|
||||||
docker-compose up
|
docker-compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
at localhost:3000 you will see your own instance of the Litlyx Dashboard.
|
at localhost:3000 you will see your own instance of the Litlyx Dashboard.
|
||||||
|
|
||||||
## Forward data to your local instance with script tag
|
## Forward data to your self-hosted instance with script tag
|
||||||
|
|
||||||
To forward your data on your self-hosted instance, you need to set up the following variables: add your `data-host`, add your `data-port`, and add your `data-secure`, setting it to true if it is HTTPS, and false if it is HTTP.
|
To forward your data on your self-hosted instance, you need to set up the following variables: `data-host`, `data-port`, `data-secure`(`true` if it is HTTPS or `false` if it is HTTP).
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script defer data-project="your_project_id"
|
<script defer data-project="your_project_id"
|
||||||
@@ -133,17 +129,23 @@ To forward your data on your self-hosted instance, you need to set up the follow
|
|||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
# Official Docs
|
# Read our docs
|
||||||
|
|
||||||
For more info read our [documentation](https://docs.litlyx.com). (will be improved in the near future using Mintlify!)
|
For more info on how to use litlyx read our [documentation](https://docs.litlyx.com).
|
||||||
|
|
||||||
# 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)
|
# Stay updated with our roadmap
|
||||||
|
|
||||||
# Contributors
|
To keep track on what we are cooking behind the scene we have a public [Roadmap](https://litlyx.com/roadmap) for you to check.
|
||||||
|
|
||||||
Every kind of contribution is accepted in this stage of the project. In the future we will improve the contributor onboarding process.
|
|
||||||
|
# Join discord
|
||||||
|
|
||||||
|
If you need more information, want to interact with us or the community, need help, or have feedback to share, feel free to join us on Litlyx's [Discord](https://discord.gg/9cQykjsmWX) channel.
|
||||||
|
|
||||||
|
# Contribution
|
||||||
|
|
||||||
|
If you want to contribute to Litlyx's development, reach out to us on [Discord](https://discord.gg/9cQykjsmWX) in our `#contribution` channel.
|
||||||
|
|
||||||
### Thank you!
|
### Thank you!
|
||||||
<a href="https://github.com/litlyx/litlyx/graphs/contributors">
|
<a href="https://github.com/litlyx/litlyx/graphs/contributors">
|
||||||
|
|||||||
BIN
assets/.DS_Store
vendored
Normal file
BIN
assets/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
assets/agent.png
BIN
assets/agent.png
Binary file not shown.
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 34 KiB |
BIN
assets/tech.png
BIN
assets/tech.png
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.7 KiB |
12
consumer/.gitignore
vendored
12
consumer/.gitignore
vendored
@@ -1,8 +1,10 @@
|
|||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
static
|
|
||||||
ecosystem.config.cjs
|
ecosystem.config.cjs
|
||||||
dist
|
ecosystem.config.js
|
||||||
|
|
||||||
scripts/start_dev.js
|
scripts/start_dev.js
|
||||||
package-lock.json
|
scripts/start_dev_prod.js
|
||||||
build_all.bat
|
dist
|
||||||
tests
|
src/shared
|
||||||
@@ -1,38 +1,17 @@
|
|||||||
# Start with a minimal Node.js base image
|
|
||||||
FROM node:21-alpine as base
|
FROM node:21-alpine as base
|
||||||
|
|
||||||
# Install pnpm globally with caching to avoid reinstalling if nothing has changed
|
|
||||||
RUN npm i -g pnpm
|
RUN npm i -g pnpm
|
||||||
|
|
||||||
# Set the working directory
|
|
||||||
WORKDIR /home/app
|
WORKDIR /home/app
|
||||||
|
|
||||||
# Copy only package-related files to leverage caching
|
|
||||||
COPY --link ./scripts/package.json ./scripts/pnpm-lock.yaml ./scripts/
|
|
||||||
COPY --link ./shared/package.json ./shared/pnpm-lock.yaml ./shared/
|
|
||||||
COPY --link ./consumer/package.json ./consumer/pnpm-lock.yaml ./consumer/
|
COPY --link ./consumer/package.json ./consumer/pnpm-lock.yaml ./consumer/
|
||||||
|
|
||||||
# Install dependencies for each package
|
|
||||||
WORKDIR /home/app/scripts
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
WORKDIR /home/app/shared
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
WORKDIR /home/app/consumer
|
WORKDIR /home/app/consumer
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install
|
||||||
|
|
||||||
# Now copy the rest of the source files
|
COPY --link ../consumer ./
|
||||||
WORKDIR /home/app
|
|
||||||
|
|
||||||
COPY --link ../scripts ./scripts
|
RUN pnpm run build
|
||||||
COPY --link ../shared ./shared
|
|
||||||
COPY --link ../consumer ./consumer
|
|
||||||
|
|
||||||
# Build the consumer
|
CMD ["node", "/home/app/consumer/dist/index.js"]
|
||||||
WORKDIR /home/app/consumer
|
|
||||||
|
|
||||||
RUN pnpm run build_all
|
|
||||||
|
|
||||||
# Start the application
|
|
||||||
CMD ["node", "/home/app/consumer/dist/consumer/src/index.js"]
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
name: 'consumer',
|
|
||||||
exec_mode: 'fork',
|
|
||||||
script: './dist/consumer/src/index.js',
|
|
||||||
env: {
|
|
||||||
MONGO_CONNECTION_STRING: "",
|
|
||||||
REDIS_URL: "",
|
|
||||||
REDIS_USERNAME: "",
|
|
||||||
REDIS_PASSWORD: "",
|
|
||||||
STREAM_NAME: "",
|
|
||||||
GROUP_NAME: ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,31 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getbrevo/brevo": "^2.2.0",
|
"axios": "^1.7.9",
|
||||||
"mongoose": "^8.3.2",
|
"express": "^4.19.2",
|
||||||
"redis": "^4.6.14",
|
"mongoose": "^8.9.5",
|
||||||
|
"redis": "^4.7.0",
|
||||||
"ua-parser-js": "^1.0.37"
|
"ua-parser-js": "^1.0.37"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^20.12.13",
|
"@types/node": "^20.12.13",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
},
|
},
|
||||||
"name": "consumer-database",
|
"name": "consumer",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/start_dev.js",
|
"dev": "node scripts/start_dev.js",
|
||||||
|
"dev_prod": "node scripts/start_dev_prod.js",
|
||||||
"compile": "tsc",
|
"compile": "tsc",
|
||||||
"build": "node ../scripts/build.js",
|
"build": "npm run compile && npm run create_db",
|
||||||
"create_db": "cd scripts && ts-node create_database.ts",
|
"create_db": "cd scripts && ts-node create_database.ts",
|
||||||
"build_all": "npm run compile && npm run build && npm run create_db",
|
|
||||||
"docker-build": "docker build -t litlyx-consumer -f Dockerfile ../",
|
"docker-build": "docker build -t litlyx-consumer -f Dockerfile ../",
|
||||||
"docker-inspect": "docker run -it litlyx-consumer sh"
|
"docker-inspect": "docker run -it litlyx-consumer sh",
|
||||||
|
"workspace:shared": "ts-node ../scripts/consumer/shared.ts",
|
||||||
|
"workspace:deploy": "ts-node ../scripts/consumer/deploy.ts"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Emily",
|
"author": "Emily",
|
||||||
|
|||||||
1493
consumer/pnpm-lock.yaml
generated
1493
consumer/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,10 @@
|
|||||||
import { ProjectModel } from "@schema/ProjectSchema";
|
import { ProjectModel } from "./shared/schema/project/ProjectSchema";
|
||||||
import { UserModel } from "@schema/UserSchema";
|
import { UserModel } from "./shared/schema/UserSchema";
|
||||||
import { LimitNotifyModel } from "@schema/broker/LimitNotifySchema";
|
import { LimitNotifyModel } from "./shared/schema/broker/LimitNotifySchema";
|
||||||
import EmailService from '@services/EmailService';
|
import { EmailService } from './shared/services/EmailService';
|
||||||
import { requireEnv } from "@utils/requireEnv";
|
import { TProjectLimit } from "./shared/schema/project/ProjectsLimits";
|
||||||
import { TProjectLimit } from "@schema/ProjectsLimits";
|
import { EmailServiceHelper } from "./EmailServiceHelper";
|
||||||
|
|
||||||
if (process.env.EMAIL_SERVICE) {
|
|
||||||
EmailService.init(requireEnv('BREVO_API_KEY'));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
||||||
|
|
||||||
@@ -27,7 +24,14 @@ export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
|||||||
const owner = await UserModel.findById(project.owner);
|
const owner = await UserModel.findById(project.owner);
|
||||||
if (!owner) return;
|
if (!owner) return;
|
||||||
|
|
||||||
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmailMax(owner.email, project.name);
|
setImmediate(() => {
|
||||||
|
const emailData = EmailService.getEmailServerInfo('limit_max', {
|
||||||
|
target: owner.email,
|
||||||
|
projectName: project.name
|
||||||
|
});
|
||||||
|
EmailServiceHelper.sendEmail(emailData);
|
||||||
|
});
|
||||||
|
|
||||||
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: true });
|
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: true });
|
||||||
|
|
||||||
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.9)) {
|
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.9)) {
|
||||||
@@ -40,7 +44,14 @@ export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
|||||||
const owner = await UserModel.findById(project.owner);
|
const owner = await UserModel.findById(project.owner);
|
||||||
if (!owner) return;
|
if (!owner) return;
|
||||||
|
|
||||||
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail90(owner.email, project.name);
|
setImmediate(() => {
|
||||||
|
const emailData = EmailService.getEmailServerInfo('limit_90', {
|
||||||
|
target: owner.email,
|
||||||
|
projectName: project.name
|
||||||
|
});
|
||||||
|
EmailServiceHelper.sendEmail(emailData);
|
||||||
|
});
|
||||||
|
|
||||||
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: false });
|
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: false });
|
||||||
|
|
||||||
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.5)) {
|
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.5)) {
|
||||||
@@ -53,7 +64,14 @@ export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
|||||||
const owner = await UserModel.findById(project.owner);
|
const owner = await UserModel.findById(project.owner);
|
||||||
if (!owner) return;
|
if (!owner) return;
|
||||||
|
|
||||||
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail50(owner.email, project.name);
|
setImmediate(() => {
|
||||||
|
const emailData = EmailService.getEmailServerInfo('limit_50', {
|
||||||
|
target: owner.email,
|
||||||
|
projectName: project.name
|
||||||
|
});
|
||||||
|
EmailServiceHelper.sendEmail(emailData);
|
||||||
|
});
|
||||||
|
|
||||||
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: false, limit3: false });
|
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: false, limit3: false });
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
19
consumer/src/EmailServiceHelper.ts
Normal file
19
consumer/src/EmailServiceHelper.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
import { EmailServerInfo } from './shared/services/EmailService'
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const EMAIL_SECRET = process.env.EMAIL_SECRET;
|
||||||
|
|
||||||
|
export class EmailServiceHelper {
|
||||||
|
static async sendEmail(data: EmailServerInfo) {
|
||||||
|
try {
|
||||||
|
await axios(data.url, {
|
||||||
|
method: 'POST',
|
||||||
|
data: data.body,
|
||||||
|
headers: { ...data.headers, 'x-litlyx-token': EMAIL_SECRET }
|
||||||
|
})
|
||||||
|
} catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import { ProjectLimitModel } from '@schema/ProjectsLimits';
|
import { ProjectLimitModel } from './shared/schema/project/ProjectsLimits';
|
||||||
import { MAX_LOG_LIMIT_PERCENT } from '@data/broker/Limits';
|
import { MAX_LOG_LIMIT_PERCENT } from './shared/data/broker/Limits';
|
||||||
import { checkLimitsForEmail } from './EmailController';
|
import { checkLimitsForEmail } from './EmailController';
|
||||||
|
|
||||||
export async function checkLimits(project_id: string) {
|
export async function checkLimits(project_id: string) {
|
||||||
|
|||||||
28
consumer/src/Metrics.ts
Normal file
28
consumer/src/Metrics.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { RedisStreamService } from './shared/services/RedisStreamService';
|
||||||
|
import { requireEnv } from './shared/utils/requireEnv';
|
||||||
|
|
||||||
|
const stream_name = requireEnv('STREAM_NAME');
|
||||||
|
|
||||||
|
export const metricsRouter = Router();
|
||||||
|
|
||||||
|
metricsRouter.get('/queue', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const size = await RedisStreamService.getQueueInfo(stream_name);
|
||||||
|
res.json({ size });
|
||||||
|
} catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
res.status(500).json({ error: ex.message });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
metricsRouter.get('/durations', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const durations = await RedisStreamService.METRICS_get()
|
||||||
|
res.json({ durations });
|
||||||
|
} catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
res.status(500).json({ error: ex.message });
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,21 +1,32 @@
|
|||||||
|
|
||||||
import { requireEnv } from '@utils/requireEnv';
|
import { requireEnv } from './shared/utils/requireEnv';
|
||||||
import { connectDatabase } from '@services/DatabaseService';
|
import { connectDatabase } from './shared/services/DatabaseService';
|
||||||
import { RedisStreamService } from '@services/RedisStreamService';
|
import { RedisStreamService } from './shared/services/RedisStreamService';
|
||||||
import { ProjectModel } from "@schema/ProjectSchema";
|
import { ProjectModel } from "./shared/schema/project/ProjectSchema";
|
||||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
import { VisitModel } from "./shared/schema/metrics/VisitSchema";
|
||||||
import { SessionModel } from "@schema/metrics/SessionSchema";
|
import { SessionModel } from "./shared/schema/metrics/SessionSchema";
|
||||||
import { EventModel } from "@schema/metrics/EventSchema";
|
import { EventModel } from "./shared/schema/metrics/EventSchema";
|
||||||
import { lookup } from './lookup';
|
import { lookup } from './lookup';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
import { checkLimits } from './LimitChecker';
|
import { checkLimits } from './LimitChecker';
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
import { ProjectLimitModel } from '@schema/ProjectsLimits';
|
import { ProjectLimitModel } from './shared/schema/project/ProjectsLimits';
|
||||||
import { ProjectCountModel } from '@schema/ProjectsCounts';
|
import { ProjectCountModel } from './shared/schema/project/ProjectsCounts';
|
||||||
|
import { metricsRouter } from './Metrics';
|
||||||
|
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use('/metrics', metricsRouter);
|
||||||
|
|
||||||
|
app.listen(process.env.PORT, () => console.log(`Listening on port ${process.env.PORT}`));
|
||||||
|
|
||||||
connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
|
connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
|
||||||
main();
|
main();
|
||||||
|
|
||||||
|
const CONSUMER_NAME = `CONSUMER_${process.env.NODE_APP_INSTANCE || 'DEFAULT'}`
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|
||||||
await RedisStreamService.connect();
|
await RedisStreamService.connect();
|
||||||
@@ -24,18 +35,19 @@ async function main() {
|
|||||||
const group_name = requireEnv('GROUP_NAME') as any; // Checks are inside "startReadingLoop"
|
const group_name = requireEnv('GROUP_NAME') as any; // Checks are inside "startReadingLoop"
|
||||||
|
|
||||||
await RedisStreamService.startReadingLoop({
|
await RedisStreamService.startReadingLoop({
|
||||||
stream_name, group_name, consumer_name: `CONSUMER_${process.env.NODE_APP_INSTANCE || 'DEFAULT'}`
|
stream_name, group_name, consumer_name: CONSUMER_NAME
|
||||||
}, processStreamEntry);
|
}, processStreamEntry);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processStreamEntry(data: Record<string, string>) {
|
async function processStreamEntry(data: Record<string, string>) {
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const start = Date.now();
|
|
||||||
|
|
||||||
const eventType = data._type;
|
const eventType = data._type;
|
||||||
if (!eventType) return;
|
if (!eventType) return console.log('No type');
|
||||||
|
|
||||||
const { pid, sessionHash } = data;
|
const { pid, sessionHash } = data;
|
||||||
|
|
||||||
@@ -53,18 +65,19 @@ async function processStreamEntry(data: Record<string, string>) {
|
|||||||
await process_visit(data, sessionHash);
|
await process_visit(data, sessionHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = Date.now() - start;
|
|
||||||
|
|
||||||
// console.log('Entry processed in', duration, 'ms');
|
|
||||||
|
|
||||||
} catch (ex: any) {
|
} catch (ex: any) {
|
||||||
console.error('ERROR PROCESSING STREAM EVENT', ex.message);
|
console.error('ERROR PROCESSING STREAM EVENT', ex.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
RedisStreamService.METRICS_onProcess(CONSUMER_NAME, duration);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function process_visit(data: Record<string, string>, sessionHash: string) {
|
async function process_visit(data: Record<string, string>, sessionHash: string) {
|
||||||
|
|
||||||
const { pid, ip, website, page, referrer, userAgent, flowHash } = data;
|
const { pid, ip, website, page, referrer, userAgent, flowHash, timestamp } = data;
|
||||||
|
|
||||||
let referrerParsed;
|
let referrerParsed;
|
||||||
try {
|
try {
|
||||||
@@ -89,6 +102,7 @@ async function process_visit(data: Record<string, string>, sessionHash: string)
|
|||||||
flowHash,
|
flowHash,
|
||||||
continent: geoLocation[0],
|
continent: geoLocation[0],
|
||||||
country: geoLocation[1],
|
country: geoLocation[1],
|
||||||
|
created_at: new Date(parseInt(timestamp))
|
||||||
}),
|
}),
|
||||||
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } }, { upsert: true }),
|
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } }, { upsert: true }),
|
||||||
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } })
|
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } })
|
||||||
@@ -98,7 +112,7 @@ async function process_visit(data: Record<string, string>, sessionHash: string)
|
|||||||
|
|
||||||
async function process_keep_alive(data: Record<string, string>, sessionHash: string) {
|
async function process_keep_alive(data: Record<string, string>, sessionHash: string) {
|
||||||
|
|
||||||
const { pid, instant, flowHash } = data;
|
const { pid, instant, flowHash, timestamp, website } = data;
|
||||||
|
|
||||||
const existingSession = await SessionModel.findOne({ project_id: pid, session: sessionHash }, { _id: 1 });
|
const existingSession = await SessionModel.findOne({ project_id: pid, session: sessionHash }, { _id: 1 });
|
||||||
if (!existingSession) {
|
if (!existingSession) {
|
||||||
@@ -109,13 +123,15 @@ async function process_keep_alive(data: Record<string, string>, sessionHash: str
|
|||||||
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
||||||
$inc: { duration: 0 },
|
$inc: { duration: 0 },
|
||||||
flowHash,
|
flowHash,
|
||||||
updated_at: Date.now()
|
website,
|
||||||
|
updated_at: new Date(parseInt(timestamp))
|
||||||
}, { upsert: true });
|
}, { upsert: true });
|
||||||
} else {
|
} else {
|
||||||
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
||||||
$inc: { duration: 1 },
|
$inc: { duration: 1 },
|
||||||
flowHash,
|
flowHash,
|
||||||
updated_at: Date.now()
|
website,
|
||||||
|
updated_at: new Date(parseInt(timestamp))
|
||||||
}, { upsert: true });
|
}, { upsert: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +139,7 @@ async function process_keep_alive(data: Record<string, string>, sessionHash: str
|
|||||||
|
|
||||||
async function process_event(data: Record<string, string>, sessionHash: string) {
|
async function process_event(data: Record<string, string>, sessionHash: string) {
|
||||||
|
|
||||||
const { name, metadata, pid, flowHash } = data;
|
const { name, metadata, pid, flowHash, timestamp, website } = data;
|
||||||
|
|
||||||
let metadataObject;
|
let metadataObject;
|
||||||
try {
|
try {
|
||||||
@@ -133,7 +149,11 @@ async function process_event(data: Record<string, string>, sessionHash: string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
EventModel.create({ project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash }),
|
EventModel.create({
|
||||||
|
project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash,
|
||||||
|
website,
|
||||||
|
created_at: new Date(parseInt(timestamp))
|
||||||
|
}),
|
||||||
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true }),
|
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true }),
|
||||||
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } })
|
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } })
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,27 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"esModuleInterop": true,
|
"outDir": "dist"
|
||||||
"outDir": "dist",
|
|
||||||
"paths": {
|
|
||||||
"@schema/*": [
|
|
||||||
"../shared/schema/*"
|
|
||||||
],
|
|
||||||
"@services/*": [
|
|
||||||
"../shared/services/*"
|
|
||||||
],
|
|
||||||
"@data/*": [
|
|
||||||
"../shared/data/*"
|
|
||||||
],
|
|
||||||
"@functions/*": [
|
|
||||||
"../shared/functions/*"
|
|
||||||
],
|
|
||||||
"@utils/*": [
|
|
||||||
"../shared/utils/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.ts"
|
"src/**/*.ts"
|
||||||
|
|||||||
8
dashboard/.gitignore
vendored
8
dashboard/.gitignore
vendored
@@ -24,7 +24,6 @@ winston-*.ndjson
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
|
||||||
# Test reports
|
# Test reports
|
||||||
*.report.txt
|
*.report.txt
|
||||||
|
|
||||||
@@ -35,5 +34,10 @@ out.pdf
|
|||||||
tests
|
tests
|
||||||
|
|
||||||
# EXPLAINS MONGODB
|
# EXPLAINS MONGODB
|
||||||
|
explains
|
||||||
|
|
||||||
explains
|
#Ecosystem
|
||||||
|
ecosystem.config.cjs
|
||||||
|
ecosystem.config.js
|
||||||
|
|
||||||
|
shared
|
||||||
|
|||||||
@@ -1,50 +1,25 @@
|
|||||||
# Start with a minimal Node.js base image
|
|
||||||
FROM node:21-alpine AS base
|
FROM node:21-alpine AS base
|
||||||
|
|
||||||
# Create a distinct build environment
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
|
|
||||||
# Install pnpm globally with caching to avoid reinstalling if nothing has changed
|
|
||||||
RUN npm i -g pnpm
|
RUN npm i -g pnpm
|
||||||
|
|
||||||
# Set the working directory
|
|
||||||
WORKDIR /home/app
|
WORKDIR /home/app
|
||||||
|
|
||||||
# Copy only package-related files to leverage caching
|
|
||||||
COPY --link ./dashboard/package.json ./dashboard/pnpm-lock.yaml ./dashboard/
|
COPY --link ./dashboard/package.json ./dashboard/pnpm-lock.yaml ./dashboard/
|
||||||
COPY --link ./lyx-ui/package.json ./lyx-ui/pnpm-lock.yaml ./lyx-ui/
|
|
||||||
COPY --link ./shared/package.json ./shared/pnpm-lock.yaml ./shared/
|
|
||||||
|
|
||||||
# Install dependencies for each package
|
|
||||||
WORKDIR /home/app/lyx-ui
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# WORKDIR /home/app/shared
|
|
||||||
# RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
WORKDIR /home/app/dashboard
|
WORKDIR /home/app/dashboard
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install
|
||||||
|
|
||||||
# Now copy the rest of the source files
|
COPY --link ./dashboard ./
|
||||||
WORKDIR /home/app
|
|
||||||
|
|
||||||
COPY --link ./dashboard ./dashboard
|
RUN pnpm run build:compose
|
||||||
COPY --link ./lyx-ui ./lyx-ui
|
|
||||||
COPY --link ./shared ./shared
|
|
||||||
|
|
||||||
# Build the dashboard
|
|
||||||
WORKDIR /home/app/dashboard
|
|
||||||
|
|
||||||
RUN pnpm run build
|
|
||||||
|
|
||||||
# Use a smaller base image for the final production build
|
|
||||||
FROM node:21-alpine AS production
|
FROM node:21-alpine AS production
|
||||||
|
|
||||||
# Set the working directory for the production container
|
|
||||||
WORKDIR /home/app
|
WORKDIR /home/app
|
||||||
|
|
||||||
# Copy the built application from the build stage
|
|
||||||
COPY --from=build /home/app/dashboard/.output /home/app/.output
|
COPY --from=build /home/app/dashboard/.output /home/app/.output
|
||||||
|
|
||||||
# Start the application
|
|
||||||
CMD ["node", "/home/app/.output/server/index.mjs"]
|
CMD ["node", "/home/app/.output/server/index.mjs"]
|
||||||
@@ -10,24 +10,24 @@ const { alerts, closeAlert } = useAlert();
|
|||||||
|
|
||||||
const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dialogClosable } = useCustomDialog();
|
const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dialogClosable } = useCustomDialog();
|
||||||
|
|
||||||
const { visible } = usePricingDrawer();
|
const { drawerVisible, hideDrawer, drawerClasses } = useDrawer();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div class="w-dvw h-dvh bg-lyx-background-light relative">
|
<div class="w-dvw h-dvh bg-lyx-lightmode-background-light dark:bg-lyx-background-light relative">
|
||||||
|
|
||||||
<Transition name="pdrawer">
|
<Transition name="drawer">
|
||||||
<LazyPricingDrawer @onCloseClick="visible = false"
|
<LazyDrawerGeneric @onCloseClick="hideDrawer()" :class="drawerClasses"
|
||||||
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if="visible">
|
class="bg-lyx-lightmode-background-light dark:bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if="drawerVisible">
|
||||||
</LazyPricingDrawer>
|
</LazyDrawerGeneric>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
|
||||||
<div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0">
|
<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"
|
<div v-for="alert of alerts"
|
||||||
class="w-[30vw] min-w-[20rem] relative bg-[#151515] overflow-hidden border-solid border-[2px] border-[#262626] rounded-lg p-6 drop-shadow-lg">
|
class="w-[30vw] min-w-[20rem] relative bg-lyx-lightmode-background dark:bg-[#151515] overflow-hidden border-solid border-[2px] border-lyx-lightmode-widget dark:border-[#262626] rounded-lg p-6 drop-shadow-lg">
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div> <i :class="alert.icon"></i> </div>
|
<div> <i :class="alert.icon"></i> </div>
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
@@ -56,8 +56,8 @@ const { visible } = usePricingDrawer();
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showDialog"
|
<div v-if="showDialog"
|
||||||
class="custom-dialog w-full h-full flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 z-[100] backdrop-blur-[2px] bg-black/50">
|
class="custom-dialog w-full h-full flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 z-[100] backdrop-blur-[2px] dark:bg-black/50">
|
||||||
<div :style="dialogStyle" class="bg-lyx-widget rounded-xl relative outline outline-1 outline-lyx-widget-lighter">
|
<div :style="dialogStyle" class="bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget dark:bg-lyx-widget dark:outline-lyx-widget-lighter rounded-xl relative outline outline-1">
|
||||||
<div v-if="dialogClosable" class="flex justify-end absolute z-[100] right-8 top-8">
|
<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>
|
<i @click="closeDialog()" class="fas fa-close text-[1.6rem] hover:text-gray-500 cursor-pointer"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,6 +67,11 @@ const { visible } = usePricingDrawer();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<UModals />
|
||||||
|
|
||||||
|
<LazyOnboarding> </LazyOnboarding>
|
||||||
|
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage></NuxtPage>
|
<NuxtPage></NuxtPage>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
@@ -75,18 +80,18 @@ const { visible } = usePricingDrawer();
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.pdrawer-enter-active,
|
.drawer-enter-active,
|
||||||
.pdrawer-leave-active {
|
.drawer-leave-active {
|
||||||
transition: all .5s ease-in-out;
|
transition: all .5s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdrawer-enter-from,
|
.drawer-enter-from,
|
||||||
.pdrawer-leave-to {
|
.drawer-leave-to {
|
||||||
transform: translateX(100%)
|
transform: translateX(100%)
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdrawer-enter-to,
|
.drawer-enter-to,
|
||||||
.pdrawer-leave-from {
|
.drawer-leave-from {
|
||||||
transform: translateX(0)
|
transform: translateX(0)
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
14
dashboard/assets/main.css
Normal file
14
dashboard/assets/main.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@import './font-awesome/css/all.css';
|
||||||
|
/* Are these many fonts required? For the time being switching to privacy friendly bunny.net for Google fonts. NOTE: No variable font support in bunnet.net yet. */
|
||||||
|
|
||||||
|
@import url('https://fonts.bunny.net/css?family=nunito:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
|
||||||
|
@import url('https://fonts.cdnfonts.com/css/brockmann');
|
||||||
|
@import url('https://fonts.bunny.net/css?family=inter:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
|
||||||
|
|
||||||
|
|
||||||
|
@import url('https://fonts.cdnfonts.com/css/geometric-sans-serif-v1');
|
||||||
|
@import url('https://fonts.bunny.net/css?family=manrope:300,400,500,600,700,800');
|
||||||
|
@import url('https://fonts.bunny.net/css?family=lato:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
|
||||||
|
@import url('https://fonts.bunny.net/css?family=poppins:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
|
||||||
|
|
||||||
|
@import url('https://cdn.jsdelivr.net/npm/material-symbols@0.28.2/index.css');
|
||||||
@@ -1,17 +1,9 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;0,1000;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900;1,1000&display=swap');
|
@use './utilities.scss';
|
||||||
@import url('https://fonts.cdnfonts.com/css/brockmann');
|
@use './colors.scss';
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
|
|
||||||
|
|
||||||
@import '../font-awesome/css/all.css';
|
:root{
|
||||||
@import './utilities.scss';
|
--font-sans: "SF Pro Text","SF Pro Icons", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", "Google Sans", "Helvetica Neue", Helvetica, "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol"
|
||||||
@import './colors.scss';
|
}
|
||||||
|
|
||||||
@import url('https://fonts.cdnfonts.com/css/geometric-sans-serif-v1');
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap');
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap');
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
|
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0');
|
|
||||||
|
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
@@ -19,7 +11,6 @@
|
|||||||
src: url("../fonts/GeistVF.ttf");
|
src: url("../fonts/GeistVF.ttf");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.actionable-visits-color-checkbox {
|
.actionable-visits-color-checkbox {
|
||||||
color: #5655d7;
|
color: #5655d7;
|
||||||
}
|
}
|
||||||
@@ -32,7 +23,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.geist {
|
.geist {
|
||||||
font-family: "Geist";
|
font-family: "Geist", var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -47,38 +38,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brockmann {
|
.brockmann {
|
||||||
font-family: "Brockmann" !important;
|
font-family: "Brockmann", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nunito {
|
.nunito {
|
||||||
font-family: "Nunito" !important;
|
font-family: "Nunito",var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inter {
|
.inter {
|
||||||
font-family: "Inter" !important;
|
font-family: "Inter", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.geometric {
|
.geometric {
|
||||||
font-family: 'Geometric Sans Serif v1' !important;
|
font-family: "Geometric Sans Serif v1", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.manrope {
|
.manrope {
|
||||||
font-family: 'Manrope' !important;
|
font-family: "Manrope", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lato {
|
.lato {
|
||||||
font-family: 'Lato' !important;
|
font-family: "Lato", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poppins {
|
.poppins {
|
||||||
font-family: 'Poppins' !important;
|
font-family: "Poppins", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poppins-childs {
|
.poppins-childs {
|
||||||
font-family: 'Poppins' !important;
|
font-family: "Poppins", var(--font-sans)!important;
|
||||||
|
|
||||||
* {
|
* {
|
||||||
font-family: 'Poppins' !important;
|
font-family: "Poppins", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,5 +109,5 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
font-family: 'Nunito';
|
font-family: 'Nunito', var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
export type IconProvider = (id: string) => ['img' | 'icon', string] | undefined;
|
export type IconProvider = (e: { _id: string, count: string } & any) => ['img' | 'icon', string] | undefined;
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -54,7 +54,7 @@ function openExternalLink(link: string) {
|
|||||||
<div class="flex justify-between mb-3">
|
<div class="flex justify-between mb-3">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div class="flex gap-4 items-center">
|
<div class="flex gap-4 items-center">
|
||||||
<div class="poppins font-semibold text-[1.4rem] text-text">
|
<div class="poppins font-semibold text-[1.4rem] text-lyx-lightmode-text dark:text-lyx-text">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -63,7 +63,7 @@ function openExternalLink(link: string) {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="poppins text-[1rem] text-text-sub/90">
|
<div class="poppins text-[1rem] text-lyx-ligtmode-text-darker dark:text-text-sub/90">
|
||||||
{{ desc }}
|
{{ desc }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,8 +80,9 @@ function openExternalLink(link: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="h-full flex flex-col">
|
||||||
<div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4">
|
<div
|
||||||
|
class="flex justify-between font-bold lyx-text-lightmode-text-dark dark:text-text-sub/80 text-[1.1rem] mb-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div v-if="isDetailView" class="flex items-center justify-center">
|
<div v-if="isDetailView" class="flex items-center justify-center">
|
||||||
<i @click="$emit('showGeneral')"
|
<i @click="$emit('showGeneral')"
|
||||||
@@ -107,37 +108,40 @@ function openExternalLink(link: string) {
|
|||||||
<div class="flex gap-1 items-center" @click="showDetails(element._id)"
|
<div class="flex gap-1 items-center" @click="showDetails(element._id)"
|
||||||
:class="{ 'cursor-pointer line-active': interactive }">
|
:class="{ 'cursor-pointer line-active': interactive }">
|
||||||
|
|
||||||
<div class="absolute rounded-sm w-full h-full bg-[#92abcf38]"
|
<div class="absolute rounded-sm w-full h-full bg-[#6f829c38] dark:bg-[#92abcf38]"
|
||||||
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
|
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
|
||||||
|
|
||||||
<div class="flex px-2 py-1 relative items-center gap-4">
|
<div class="flex px-2 py-1 relative items-center gap-4">
|
||||||
<div v-if="iconProvider && iconProvider(element._id) != undefined"
|
<div v-if="iconProvider && iconProvider(element) != undefined"
|
||||||
class="flex items-center h-[1.3rem]">
|
class="flex items-center h-[1.3rem]">
|
||||||
|
|
||||||
<img v-if="iconProvider(element._id)?.[0] == 'img'" class="h-full"
|
<img v-if="iconProvider(element)?.[0] == 'img'" class="h-full"
|
||||||
:style="customIconStyle" :src="iconProvider(element._id)?.[1]">
|
:style="customIconStyle" :src="iconProvider(element)?.[1]">
|
||||||
|
|
||||||
<i v-else :class="iconProvider(element._id)?.[1]"></i>
|
<i v-else :class="iconProvider(element)?.[1]"></i>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
|
<span
|
||||||
|
class="text-ellipsis line-clamp-1 ui-font z-[19] text-[.95rem] text-lyx-lightmode-text-dark dark:text-text/70">
|
||||||
{{ elementTextTransformer?.(element._id) || element._id }}
|
{{ elementTextTransformer?.(element._id) || element._id }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
<div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{
|
class="text-lyx-lightmode-text dark:text-lyx-text font-semibold text-[.9rem] md:text-[1rem] manrope">
|
||||||
formatNumberK(element.count) }} </div>
|
{{
|
||||||
|
formatNumberK(element.count) }} </div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-bold text-[1.1rem]">
|
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-light text-[1.1rem]">
|
||||||
No data yet
|
No data yet
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 ">
|
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 items-end grow">
|
||||||
<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]">
|
<LyxUiButton type="outline" @click="$emit('showMore')">
|
||||||
Show more
|
Show more
|
||||||
</div>
|
</LyxUiButton>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import type { IconProvider } from './Base.vue';
|
||||||
|
|
||||||
|
function iconProvider(e: { _id: string, flag: string, count: number }): ReturnType<IconProvider> {
|
||||||
|
let name = e._id.toLowerCase().replace(/ /g, '-');
|
||||||
|
|
||||||
|
if (name === 'mobile-safari') name = 'safari';
|
||||||
|
if (name === 'chrome-headless') name = 'chrome'
|
||||||
|
if (name === 'chrome-webview') name = 'chrome'
|
||||||
|
|
||||||
|
if (name === 'duckduckgo') return ['icon', 'far fa-duck']
|
||||||
|
if (name === 'avast-secure-browser') return ['icon', 'far fa-bug']
|
||||||
|
if (name === 'avg-secure-browser') return ['icon', 'far fa-bug']
|
||||||
|
|
||||||
|
if (name === 'no_browser') return ['icon', 'far fa-question']
|
||||||
|
if (name === 'gsa') return ['icon', 'far fa-question']
|
||||||
|
if (name === 'miui-browser') return ['icon', 'far fa-question']
|
||||||
|
|
||||||
|
if (name === 'vivo-browser') return ['icon', 'far fa-question']
|
||||||
|
if (name === 'whale') return ['icon', 'far fa-question']
|
||||||
|
|
||||||
|
if (name === 'twitter') return ['icon', 'fab fa-twitter']
|
||||||
|
if (name === 'linkedin') return ['icon', 'fab fa-linkedin']
|
||||||
|
if (name === 'facebook') return ['icon', 'fab fa-facebook']
|
||||||
|
|
||||||
|
return [
|
||||||
|
'img',
|
||||||
|
`https://github.com/alrra/browser-logos/blob/main/src/${name}/${name}_256x256.png?raw=true`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
const browsersData = useFetch('/api/data/browsers', {
|
const browsersData = useFetch('/api/data/browsers', {
|
||||||
headers: useComputedHeaders({ limit: 10, }), lazy: true
|
headers: useComputedHeaders({ limit: 10, }), lazy: true
|
||||||
@@ -8,7 +37,7 @@ const browsersData = useFetch('/api/data/browsers', {
|
|||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
async function showMore() {
|
async function showMore() {
|
||||||
dialogBarData.value=[];
|
dialogBarData.value = [];
|
||||||
showDialog.value = true;
|
showDialog.value = true;
|
||||||
isDataLoading.value = true;
|
isDataLoading.value = true;
|
||||||
|
|
||||||
@@ -16,7 +45,9 @@ async function showMore() {
|
|||||||
headers: useComputedHeaders({ limit: 1000 }).value
|
headers: useComputedHeaders({ limit: 1000 }).value
|
||||||
});
|
});
|
||||||
|
|
||||||
dialogBarData.value = res || [];
|
dialogBarData.value = res?.map(e => {
|
||||||
|
return { ...e, icon: iconProvider(e as any) }
|
||||||
|
}) || [];
|
||||||
|
|
||||||
isDataLoading.value = false;
|
isDataLoading.value = false;
|
||||||
|
|
||||||
@@ -26,10 +57,10 @@ async function showMore() {
|
|||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2 h-full">
|
||||||
<BarCardBase @showMore="showMore()" @dataReload="browsersData.refresh()" :data="browsersData.data.value || []"
|
<BarCardBase @showMore="showMore()" @dataReload="browsersData.refresh()" :data="browsersData.data.value || []"
|
||||||
desc="The browsers most used to search your website." :dataIcons="false"
|
desc="The browsers most used to search your website." :dataIcons="true" :iconProvider="iconProvider"
|
||||||
:loading="browsersData.pending.value" label="Top Browsers" sub-label="Browsers">
|
:loading="browsersData.pending.value" label="Browsers" sub-label="Browsers">
|
||||||
</BarCardBase>
|
</BarCardBase>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
import type { IconProvider } from './Base.vue';
|
||||||
|
|
||||||
|
function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> {
|
||||||
|
if (e._id === 'desktop') return ['icon','far fa-desktop'];
|
||||||
|
if (e._id === 'tablet') return ['icon','far fa-tablet ml-1'];
|
||||||
|
if (e._id === 'mobile') return ['icon','far fa-mobile ml-1'];
|
||||||
|
if (e._id === 'smarttv') return ['icon','far fa-tv'];
|
||||||
|
if (e._id === 'console') return ['icon','far fa-game-console-handheld'];
|
||||||
|
return ['icon', 'far fa-question ml-1 mr-1']
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function transform(data: { _id: string, count: number }[]) {
|
function transform(data: { _id: string, count: number }[]) {
|
||||||
console.log(data);
|
console.log(data);
|
||||||
return data.map(e => ({ ...e, _id: e._id == null ? 'unknown' : e._id }))
|
return data.map(e => ({ ...e, _id: e._id == null ? 'others' : e._id }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const devicesData = useFetch('/api/data/devices', {
|
const devicesData = useFetch('/api/data/devices', {
|
||||||
@@ -34,9 +46,9 @@ async function showMore() {
|
|||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2 h-full">
|
||||||
<BarCardBase @showMore="showMore()" @dataReload="devicesData.refresh()" :data="devicesData.data.value || []"
|
<BarCardBase @showMore="showMore()" @dataReload="devicesData.refresh()" :data="devicesData.data.value || []"
|
||||||
:dataIcons="false" desc="The devices most used to access your website." :loading="devicesData.pending.value"
|
:iconProvider="iconProvider" :dataIcons="true" desc="The devices most used to access your website."
|
||||||
label="Top Devices" sub-label="Devices"></BarCardBase>
|
:loading="devicesData.pending.value" label="Devices" sub-label="Devices"></BarCardBase>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,34 +2,43 @@
|
|||||||
|
|
||||||
import type { IconProvider } from '../BarCard/Base.vue';
|
import type { IconProvider } from '../BarCard/Base.vue';
|
||||||
|
|
||||||
function iconProvider(id: string): ReturnType<IconProvider> {
|
function iconProvider(e: { _id: string, flag: string, count: number }): ReturnType<IconProvider> {
|
||||||
if (id === 'self') return ['icon', 'fas fa-link'];
|
if (!e.flag) return ['icon', 'far fa-question']
|
||||||
return [
|
return [
|
||||||
'img',
|
'img',
|
||||||
`https://raw.githubusercontent.com/hampusborgos/country-flags/main/png250px/${id.toLowerCase()}.png`
|
`https://raw.githubusercontent.com/hampusborgos/country-flags/refs/heads/main/svg/${e.flag.toLowerCase()}.svg`
|
||||||
|
// `https://raw.githubusercontent.com/hampusborgos/country-flags/main/png250px/${e.flag.toLowerCase()}.png`
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const customIconStyle = `width: 2rem; padding: 1px;`
|
const customIconStyle = `width: 2rem; padding: 1px;`
|
||||||
|
|
||||||
const geolocationData = useFetch('/api/data/countries', {
|
const geolocationData = useFetch('/api/data/countries', {
|
||||||
headers: useComputedHeaders({ limit: 10, }), lazy: true
|
headers: useComputedHeaders({ limit: 10, }), lazy: true,
|
||||||
|
transform: (e) => {
|
||||||
|
if (!e) return e;
|
||||||
|
return e.map(k => {
|
||||||
|
return { ...k, flag: k._id, _id: getCountryName(k._id) ?? k._id }
|
||||||
|
})
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
async function showMore() {
|
async function showMore() {
|
||||||
dialogBarData.value=[];
|
dialogBarData.value = [];
|
||||||
showDialog.value = true;
|
showDialog.value = true;
|
||||||
isDataLoading.value = true;
|
isDataLoading.value = true;
|
||||||
|
|
||||||
const res = await $fetch('/api/data/countries', {
|
const res = await $fetch('/api/data/countries', {
|
||||||
headers: useComputedHeaders({limit: 1000}).value
|
headers: useComputedHeaders({ limit: 1000 }).value
|
||||||
});
|
});
|
||||||
|
|
||||||
dialogBarData.value = res?.map(e => {
|
dialogBarData.value = res?.map(k => {
|
||||||
return { ...e, icon: iconProvider(e._id) }
|
return { ...k, flag: k._id, _id: getCountryName(k._id) ?? k._id }
|
||||||
|
}).map(e => {
|
||||||
|
return { ...e, icon: iconProvider(e) }
|
||||||
}) || [];
|
}) || [];
|
||||||
|
|
||||||
isDataLoading.value = false;
|
isDataLoading.value = false;
|
||||||
@@ -40,10 +49,10 @@ async function showMore() {
|
|||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2 h-full">
|
||||||
<BarCardBase @showMore="showMore()" @dataReload="geolocationData.refresh()"
|
<BarCardBase @showMore="showMore()" @dataReload="geolocationData.refresh()"
|
||||||
:data="geolocationData.data.value || []" :dataIcons="false" :loading="geolocationData.pending.value"
|
:data="geolocationData.data.value || []" :dataIcons="false" :loading="geolocationData.pending.value"
|
||||||
label="Top Countries" sub-label="Countries" :iconProvider="iconProvider" :customIconStyle="customIconStyle"
|
label="Countries" sub-label="Countries" :iconProvider="iconProvider" :customIconStyle="customIconStyle"
|
||||||
desc=" Lists the countries where users access your website.">
|
desc=" Lists the countries where users access your website.">
|
||||||
</BarCardBase>
|
</BarCardBase>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,6 @@ async function showMore() {
|
|||||||
<div class="flex flex-col gap-2 h-full">
|
<div class="flex flex-col gap-2 h-full">
|
||||||
<BarCardBase @showMore="showMore()" @dataReload="ossData.refresh()" :data="ossData.data.value || []"
|
<BarCardBase @showMore="showMore()" @dataReload="ossData.refresh()" :data="ossData.data.value || []"
|
||||||
desc="The operating systems most commonly used by your website's visitors." :dataIcons="false"
|
desc="The operating systems most commonly used by your website's visitors." :dataIcons="false"
|
||||||
:loading="ossData.pending.value" label="Top OS" sub-label="OSs"></BarCardBase>
|
:loading="ossData.pending.value" label="OS" sub-label="OSs"></BarCardBase>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
44
dashboard/components/BarCard/Pages.vue
Normal file
44
dashboard/components/BarCard/Pages.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const pagesData = useFetch('/api/data/pages', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
limit: 10,
|
||||||
|
}), lazy: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
|
||||||
|
dialogBarData.value = [];
|
||||||
|
|
||||||
|
showDialog.value = true;
|
||||||
|
isDataLoading.value = true;
|
||||||
|
|
||||||
|
const res = await $fetch('/api/data/pages', {
|
||||||
|
headers: useComputedHeaders({ limit: 1000 }).value
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogBarData.value = (res || []);
|
||||||
|
|
||||||
|
isDataLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToView() {
|
||||||
|
router.push('/dashboard/visits');
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase @showRawData="goToView()" @showMore="showMore()" @dataReload="pagesData.refresh()" :showLink=true
|
||||||
|
:data="pagesData.data.value || []" :interactive="false" desc="Most visited pages."
|
||||||
|
:rawButton="!isLiveDemo"
|
||||||
|
:dataIcons="true" :loading="pagesData.pending.value" label="Top Pages" sub-label="Referrers">
|
||||||
|
</BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import type { IconProvider } from './Base.vue';
|
import type { IconProvider } from './Base.vue';
|
||||||
|
|
||||||
function iconProvider(id: string): ReturnType<IconProvider> {
|
function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> {
|
||||||
if (id === 'self') return ['icon', 'fas fa-link'];
|
if (e._id === 'self') return ['icon', 'fas fa-link'];
|
||||||
return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${id}&sz=64`]
|
return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${e._id}&sz=64`]
|
||||||
}
|
}
|
||||||
|
|
||||||
function elementTextTransformer(element: string) {
|
function elementTextTransformer(element: string) {
|
||||||
@@ -22,18 +22,18 @@ const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
|||||||
|
|
||||||
async function showMore() {
|
async function showMore() {
|
||||||
|
|
||||||
dialogBarData.value=[];
|
dialogBarData.value = [];
|
||||||
|
|
||||||
showDialog.value = true;
|
showDialog.value = true;
|
||||||
isDataLoading.value = true;
|
isDataLoading.value = true;
|
||||||
|
|
||||||
const res = await $fetch('/api/data/referrers', {
|
const res = await $fetch('/api/data/referrers', {
|
||||||
headers: useComputedHeaders({limit: 1000}).value
|
headers: useComputedHeaders({ limit: 1000 }).value
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
dialogBarData.value = res?.map(e => {
|
dialogBarData.value = res?.map(e => {
|
||||||
return { ...e, icon: iconProvider(e._id) }
|
return { ...e, icon: iconProvider(e as any) }
|
||||||
}) || [];
|
}) || [];
|
||||||
|
|
||||||
isDataLoading.value = false;
|
isDataLoading.value = false;
|
||||||
@@ -43,11 +43,11 @@ async function showMore() {
|
|||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2 h-full">
|
||||||
<BarCardBase @showMore="showMore()" :elementTextTransformer="elementTextTransformer"
|
<BarCardBase @showMore="showMore()" :elementTextTransformer="elementTextTransformer"
|
||||||
:iconProvider="iconProvider" @dataReload="referrersData.refresh()" :showLink=true
|
:iconProvider="iconProvider" @dataReload="referrersData.refresh()" :showLink=true
|
||||||
:data="referrersData.data.value || []" :interactive="false" desc="Where users find your website."
|
:data="referrersData.data.value || []" :interactive="false" desc="Where users find your website."
|
||||||
:dataIcons="true" :loading="referrersData.pending.value" label="Top Referrers" sub-label="Referrers">
|
:dataIcons="true" :loading="referrersData.pending.value" label="Top Sources" sub-label="Referrers">
|
||||||
</BarCardBase>
|
</BarCardBase>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ const props = defineProps<{ title: string, sub?: string }>();
|
|||||||
<div class="flex flex-col gap-4 h-full">
|
<div class="flex flex-col gap-4 h-full">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex flex-col grow">
|
<div class="flex flex-col grow">
|
||||||
<div class="poppins font-semibold text-[1rem] md:text-[1.3rem] text-text">
|
<div class="poppins font-semibold text-[1rem] md:text-[1.3rem] text-lyx-lightmode-text-dark dark:text-text">
|
||||||
{{ props.title }}
|
{{ props.title }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="props.sub" class="poppins text-[.7rem] md:text-[1rem] text-text-sub">
|
<div v-if="props.sub" class="poppins text-[.7rem] md:text-[1rem] text-lyx-lightmode-text-darker dark:text-text-sub">
|
||||||
{{ props.sub }}
|
{{ props.sub }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,27 +1,72 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
type CItem = { label: string, slot: string }
|
|
||||||
const props = defineProps<{ items: CItem[] }>();
|
|
||||||
|
export type CItem = { label: string, slot: string, tab?: string }
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: CItem[],
|
||||||
|
manualScroll?: boolean,
|
||||||
|
route?: boolean
|
||||||
|
}>();
|
||||||
|
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
const activeTabIndex = ref<number>(0);
|
const activeTabIndex = ref<number>(0);
|
||||||
|
|
||||||
|
|
||||||
|
function updateTab() {
|
||||||
|
const target = props.items.findIndex(e => e.tab == route.query.tab);
|
||||||
|
if (target == -1) {
|
||||||
|
activeTabIndex.value = 0;
|
||||||
|
} else {
|
||||||
|
activeTabIndex.value = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeTab(newIndex: number) {
|
||||||
|
activeTabIndex.value = newIndex;
|
||||||
|
const target = props.items[newIndex];
|
||||||
|
if (!target) return;
|
||||||
|
router.push({ query: { tab: target.tab } });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
|
||||||
|
if (props.route !== true) return;
|
||||||
|
|
||||||
|
updateTab();
|
||||||
|
|
||||||
|
watch(route, () => {
|
||||||
|
updateTab();
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="h-full flex flex-col">
|
||||||
<div class="flex">
|
<div class="flex overflow-x-auto hide-scrollbars">
|
||||||
<div v-for="(tab, index) of items" @click="activeTabIndex = index"
|
<div class="flex">
|
||||||
class="px-6 pb-3 poppins font-medium text-lyx-text-darker border-b-[1px] border-lyx-text-darker" :class="{
|
<div v-for="(tab, index) of items" @click="onChangeTab(index)"
|
||||||
'!border-[#88A7FF] !text-[#88A7FF]': activeTabIndex === index,
|
class="px-6 pb-3 poppins font-medium text-lyx-lightmode-text dark:text-lyx-text-darker border-b-[1px] border-lyx-text-darker"
|
||||||
'hover:border-lyx-text-dark hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
|
:class="{
|
||||||
}">
|
'dark:!border-[#FFFFFF] dark:!text-[#FFFFFF] !border-lyx-primary !text-lyx-primary': activeTabIndex === index,
|
||||||
{{ tab.label }}
|
'hover:border-lyx-lightmode-text-dark hover:text-lyx-lightmode-text-dark/60 dark:hover:border-lyx-text-dark dark:hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
|
||||||
|
}">
|
||||||
|
{{ tab.label }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-b-[1px] border-lyx-text-darker w-full">
|
<div class="border-b-[1px] border-lyx-text-darker w-full">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div :class="{ 'overflow-y-hidden': manualScroll }" class="overflow-y-auto h-full">
|
||||||
<slot :name="props.items[activeTabIndex].slot"></slot>
|
<slot :name="props.items[activeTabIndex].slot"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import 'highlight.js/styles/stackoverflow-dark.css';
|
|||||||
import hljs from 'highlight.js';
|
import hljs from 'highlight.js';
|
||||||
import CardTitled from './CardTitled.vue';
|
import CardTitled from './CardTitled.vue';
|
||||||
|
|
||||||
|
import { Lit } from 'litlyx-js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
firstInteraction: boolean,
|
firstInteraction: boolean,
|
||||||
refreshInteraction: () => any
|
refreshInteraction: () => any
|
||||||
@@ -19,6 +21,7 @@ onMounted(() => {
|
|||||||
function copyProjectId() {
|
function copyProjectId() {
|
||||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||||
navigator.clipboard.writeText(project.value?._id?.toString() || '');
|
navigator.clipboard.writeText(project.value?._id?.toString() || '');
|
||||||
|
Lit.event('no_visit_copy_id');
|
||||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +39,7 @@ function copyScript() {
|
|||||||
].join('')
|
].join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Lit.event('no_visit_copy_script');
|
||||||
navigator.clipboard.writeText(createScriptText());
|
navigator.clipboard.writeText(createScriptText());
|
||||||
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
|
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
|
||||||
}
|
}
|
||||||
@@ -53,50 +57,59 @@ const scriptText = computed(() => {
|
|||||||
function reloadPage() {
|
function reloadPage() {
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div v-if="!firstInteraction && project" class="mt-[5vh] flex flex-col">
|
<div v-if="!firstInteraction && project" class="mt-[5vh] flex flex-col">
|
||||||
|
|
||||||
<div class="flex gap-4 items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<div class="animate-pulse w-[1.5rem] h-[1.5rem] bg-accent rounded-full"> </div>
|
|
||||||
<div class="text-text/90 poppins text-[1.3rem] font-semibold">
|
<div class="mr-4 animate-pulse w-[1rem] h-[1rem] bg-accent rounded-full"> </div>
|
||||||
Waiting for your first Visit or Event
|
<div class="text-lyx-lightmode-text dark:text-text/90 poppins text-[1.1rem] font-medium">
|
||||||
|
Waiting for your first visit
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<LyxUiButton class="ml-6" type="secondary" @click="reloadPage()">
|
||||||
<div class="flex justify-center mt-4">
|
|
||||||
<LyxUiButton type="primary" @click="reloadPage()">
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<i class="far fa-refresh"></i>
|
<i class="far fa-refresh"></i>
|
||||||
<div> Reload </div>
|
<div> Refresh </div>
|
||||||
</div>
|
</div>
|
||||||
</LyxUiButton>
|
</LyxUiButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-center mt-10">
|
|
||||||
|
<div class="flex items-center justify-center mt-10 w-full px-10">
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="flex gap-6">
|
|
||||||
<div>
|
<div class="flex gap-6 xl:flex-row flex-col">
|
||||||
<CardTitled class="h-full" title="Tutorial" sub="Coming soon. For now enjoy our launch video.">
|
|
||||||
<div class="flex items-center justify-center h-full">
|
<div class="h-full w-full">
|
||||||
<iframe width="560" height="315"
|
<CardTitled class="h-full w-full xl:min-w-[400px] xl:h-[35rem]" title="Quick setup tutorial"
|
||||||
src="https://www.youtube.com/embed/GntyWMR7jsY?si=YGGkQwrk6-Iqmn8w" title="Litlyx"
|
sub="Quickly Set Up Litlyx in 30 Seconds!">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center h-full w-full">
|
||||||
|
|
||||||
|
<iframe class="w-full h-full min-h-[400px]"
|
||||||
|
src="https://www.youtube.com/embed/LInFoNLJ-CI?si=a97HVXpXFDgFg2Yp" title="Litlyx"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</CardTitled>
|
</CardTitled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
|
|
||||||
<div>
|
<div class="w-full">
|
||||||
<CardTitled title="Quick Integration"
|
<CardTitled title="Quick Integration"
|
||||||
sub="Start tracking web analytics in one line. (works everywhere js is supported)">
|
sub="Start tracking web analytics in one line. (works everywhere js is supported)">
|
||||||
<div class="flex flex-col items-end gap-4">
|
<div class="flex flex-col items-end gap-4">
|
||||||
<div class="w-full">
|
<div class="w-full xl:text-[1rem] text-[.8rem]">
|
||||||
<pre><code class="language-html">{{ scriptText }}</code></pre>
|
<pre>
|
||||||
|
<code class="language-html rounded-md">{{ scriptText }}</code>
|
||||||
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
<LyxUiButton type="secondary" @click="copyScript()">
|
<LyxUiButton type="secondary" @click="copyScript()">
|
||||||
Copy
|
Copy
|
||||||
@@ -108,10 +121,13 @@ function reloadPage() {
|
|||||||
<div class="h-full w-full">
|
<div class="h-full w-full">
|
||||||
<CardTitled class="h-full w-full" title="Project id"
|
<CardTitled class="h-full w-full" title="Project id"
|
||||||
sub="This is the identifier for this project, used to forward data">
|
sub="This is the identifier for this project, used to forward data">
|
||||||
<div class="flex flex-col items-end">
|
<div class="flex items-center justify-between gap-4 mt-6">
|
||||||
<div class="w-full text-[.9rem] text-[#acacac]"> {{ project?._id }} </div>
|
<div class="p-2 bg-lyx-lightmode-widget dark:bg-[#1c1b1b] rounded-md w-full">
|
||||||
|
<div class="w-full text-[.9rem] dark:text-[#acacac]"> {{ project?._id }} </div>
|
||||||
|
</div>
|
||||||
<LyxUiButton type="secondary" @click="copyProjectId()"> Copy </LyxUiButton>
|
<LyxUiButton type="secondary" @click="copyProjectId()"> Copy </LyxUiButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</CardTitled>
|
</CardTitled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -119,139 +135,65 @@ function reloadPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitled class="w-full h-full" title="Documentation"
|
<CardTitled class="w-full h-full" title="Wordpress + Elementor"
|
||||||
sub="Learn how to use Litlyx in every tech stack">
|
sub="Our WordPress plugin is coming soon!.">
|
||||||
<div class="flex flex-col items-end">
|
<template #header>
|
||||||
<div class="flex justify-center w-full">
|
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
|
||||||
<svg width="680" height="100" viewBox="0 0 680 100" fill="none"
|
to="https://docs.litlyx.com">
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
Visit documentation
|
||||||
<mask id="path-1-inside-1_473_1361" fill="white">
|
|
||||||
<path
|
|
||||||
d="M0 12C0 5.37258 5.37258 0 12 0H88C94.6274 0 100 5.37258 100 12V88C100 94.6274 94.6274 100 88 100H12C5.37258 100 0 94.6274 0 88V12Z" />
|
|
||||||
</mask>
|
|
||||||
<path
|
|
||||||
d="M0 12C0 5.37258 5.37258 0 12 0H88C94.6274 0 100 5.37258 100 12V88C100 94.6274 94.6274 100 88 100H12C5.37258 100 0 94.6274 0 88V12Z"
|
|
||||||
fill="#0A0A0A" />
|
|
||||||
<path
|
|
||||||
d="M0 12C0 4.8203 5.8203 -1 13 -1H87C94.1797 -1 100 4.8203 100 12C100 5.92487 94.6274 1 88 1H12C5.37258 1 0 5.92487 0 12ZM100 100H0H100ZM0 100V0V100ZM100 0V100V0Z"
|
|
||||||
fill="#303246" mask="url(#path-1-inside-1_473_1361)" />
|
|
||||||
<mask id="path-3-inside-2_473_1361" fill="white">
|
|
||||||
<path
|
|
||||||
d="M348 12C348 5.37258 353.373 0 360 0H436C442.627 0 448 5.37258 448 12V88C448 94.6274 442.627 100 436 100H360C353.373 100 348 94.6274 348 88V12Z" />
|
|
||||||
</mask>
|
|
||||||
<path
|
|
||||||
d="M348 12C348 5.37258 353.373 0 360 0H436C442.627 0 448 5.37258 448 12V88C448 94.6274 442.627 100 436 100H360C353.373 100 348 94.6274 348 88V12Z"
|
|
||||||
fill="#0A0A0A" />
|
|
||||||
<path
|
|
||||||
d="M348 12C348 4.8203 353.82 -1 361 -1H435C442.18 -1 448 4.8203 448 12C448 5.92487 442.627 1 436 1H360C353.373 1 348 5.92487 348 12ZM448 100H348H448ZM348 100V0V100ZM448 0V100V0Z"
|
|
||||||
fill="#303246" mask="url(#path-3-inside-2_473_1361)" />
|
|
||||||
<path
|
|
||||||
d="M398 80C414.569 80 428 66.5685 428 50C428 33.4315 414.569 20 398 20C381.431 20 368 33.4315 368 50C368 66.5685 381.431 80 398 80Z"
|
|
||||||
fill="white" />
|
|
||||||
<path
|
|
||||||
d="M417.836 72.5068L391.047 38H386V61.99H390.038V43.1278L414.666 74.9484C415.778 74.2045 416.836 73.3884 417.836 72.5068Z"
|
|
||||||
fill="url(#paint0_linear_473_1361)" />
|
|
||||||
<path d="M410.333 38H406.333V62H410.333V38Z"
|
|
||||||
fill="url(#paint1_linear_473_1361)" />
|
|
||||||
<mask id="path-8-inside-3_473_1361" fill="white">
|
|
||||||
<path
|
|
||||||
d="M116 12C116 5.37258 121.373 0 128 0H204C210.627 0 216 5.37258 216 12V88C216 94.6274 210.627 100 204 100H128C121.373 100 116 94.6274 116 88V12Z" />
|
|
||||||
</mask>
|
|
||||||
<path
|
|
||||||
d="M116 12C116 5.37258 121.373 0 128 0H204C210.627 0 216 5.37258 216 12V88C216 94.6274 210.627 100 204 100H128C121.373 100 116 94.6274 116 88V12Z"
|
|
||||||
fill="#0A0A0A" />
|
|
||||||
<path
|
|
||||||
d="M116 12C116 4.8203 121.82 -1 129 -1H203C210.18 -1 216 4.8203 216 12C216 5.92487 210.627 1 204 1H128C121.373 1 116 5.92487 116 12ZM216 100H116H216ZM116 100V0V100ZM216 0V100V0Z"
|
|
||||||
fill="#303246" mask="url(#path-8-inside-3_473_1361)" />
|
|
||||||
<path d="M182.2 27H193L166 73.575L139 27H159.655L166 37.8L172.21 27H182.2Z"
|
|
||||||
fill="#41B883" />
|
|
||||||
<path d="M139 27L166 73.575L193 27H182.2L166 54.945L149.665 27H139Z"
|
|
||||||
fill="#41B883" />
|
|
||||||
<path d="M149.665 27L166 55.08L182.2 27H172.21L166 37.8L159.655 27H149.665Z"
|
|
||||||
fill="#35495E" />
|
|
||||||
<path
|
|
||||||
d="M53.6605 70H75.9651C76.6735 70.0001 77.3695 69.8153 77.983 69.4642C78.5965 69.1131 79.1059 68.6081 79.46 67.9999C79.8141 67.3918 80.0003 66.7019 80 65.9998C79.9997 65.2977 79.8128 64.608 79.4582 64.0002L64.4791 38.2859C64.1251 37.6779 63.6158 37.173 63.0024 36.8219C62.389 36.4709 61.6932 36.2861 60.9849 36.2861C60.2766 36.2861 59.5808 36.4709 58.9674 36.8219C58.354 37.173 57.8447 37.6779 57.4906 38.2859L53.6605 44.8653L46.1721 31.9995C45.8177 31.3916 45.3082 30.8867 44.6946 30.5358C44.0811 30.1848 43.3852 30 42.6767 30C41.9683 30 41.2724 30.1848 40.6588 30.5358C40.0453 30.8867 39.5357 31.3916 39.1814 31.9995L20.5418 64.0002C20.1872 64.608 20.0003 65.2977 20 65.9998C19.9997 66.7019 20.1859 67.3918 20.54 67.9999C20.8941 68.6081 21.4035 69.1131 22.017 69.4642C22.6305 69.8153 23.3265 70.0001 24.0349 70H38.0359C43.5832 70 47.6741 67.585 50.4891 62.8734L57.3233 51.143L60.9838 44.8653L71.9698 63.7222H57.3233L53.6605 70ZM37.8076 63.7158L28.0367 63.7136L42.6833 38.5724L49.9913 51.143L45.0983 59.545C43.2289 62.602 41.1051 63.7158 37.8076 63.7158Z"
|
|
||||||
fill="#00DC82" />
|
|
||||||
<mask id="path-14-inside-4_473_1361" fill="white">
|
|
||||||
<path
|
|
||||||
d="M464 12C464 5.37258 469.373 0 476 0H552C558.627 0 564 5.37258 564 12V88C564 94.6274 558.627 100 552 100H476C469.373 100 464 94.6274 464 88V12Z" />
|
|
||||||
</mask>
|
|
||||||
<path
|
|
||||||
d="M464 12C464 5.37258 469.373 0 476 0H552C558.627 0 564 5.37258 564 12V88C564 94.6274 558.627 100 552 100H476C469.373 100 464 94.6274 464 88V12Z"
|
|
||||||
fill="#0A0A0A" />
|
|
||||||
<path
|
|
||||||
d="M464 12C464 4.8203 469.82 -1 477 -1H551C558.18 -1 564 4.8203 564 12C564 5.92487 558.627 1 552 1H476C469.373 1 464 5.92487 464 12ZM564 100H464H564ZM464 100V0V100ZM564 0V100V0Z"
|
|
||||||
fill="#303246" mask="url(#path-14-inside-4_473_1361)" />
|
|
||||||
<path
|
|
||||||
d="M514 55.299C517.088 55.299 519.591 52.7959 519.591 49.7081C519.591 46.6203 517.088 44.1172 514 44.1172C510.912 44.1172 508.409 46.6203 508.409 49.7081C508.409 52.7959 510.912 55.299 514 55.299Z"
|
|
||||||
fill="#61DAFB" />
|
|
||||||
<path
|
|
||||||
d="M514 61.1625C530.569 61.1625 544 56.0341 544 49.708C544 43.3818 530.569 38.2534 514 38.2534C497.431 38.2534 484 43.3818 484 49.708C484 56.0341 497.431 61.1625 514 61.1625Z"
|
|
||||||
stroke="#61DAFB" stroke-width="5" />
|
|
||||||
<path
|
|
||||||
d="M504.08 55.4353C512.364 69.7841 523.521 78.8519 529 75.6888C534.479 72.5257 532.204 58.3295 523.92 43.9808C515.636 29.632 504.479 20.5642 499 23.7273C493.521 26.8904 495.796 41.0865 504.08 55.4353Z"
|
|
||||||
stroke="#61DAFB" stroke-width="5" />
|
|
||||||
<path
|
|
||||||
d="M504.08 43.9808C495.796 58.3296 493.521 72.5258 499 75.6888C504.479 78.8519 515.636 69.7841 523.92 55.4354C532.204 41.0866 534.479 26.8904 529 23.7273C523.521 20.5642 512.364 29.632 504.08 43.9808Z"
|
|
||||||
stroke="#61DAFB" stroke-width="5" />
|
|
||||||
<mask id="path-20-inside-5_473_1361" fill="white">
|
|
||||||
<path
|
|
||||||
d="M232 12C232 5.37258 237.373 0 244 0H320C326.627 0 332 5.37258 332 12V88C332 94.6274 326.627 100 320 100H244C237.373 100 232 94.6274 232 88V12Z" />
|
|
||||||
</mask>
|
|
||||||
<path
|
|
||||||
d="M232 12C232 5.37258 237.373 0 244 0H320C326.627 0 332 5.37258 332 12V88C332 94.6274 326.627 100 320 100H244C237.373 100 232 94.6274 232 88V12Z"
|
|
||||||
fill="#0A0A0A" />
|
|
||||||
<path
|
|
||||||
d="M232 12C232 4.8203 237.82 -1 245 -1H319C326.18 -1 332 4.8203 332 12C332 5.92487 326.627 1 320 1H244C237.373 1 232 5.92487 232 12ZM332 100H232H332ZM232 100V0V100ZM332 0V100V0Z"
|
|
||||||
fill="#303246" mask="url(#path-20-inside-5_473_1361)" />
|
|
||||||
<path
|
|
||||||
d="M282 20C298.569 20 312 33.4314 312 50C312 66.5686 298.569 80 282 80C265.431 80 252 66.5686 252 50C252 33.4314 265.431 20 282 20Z"
|
|
||||||
fill="black" />
|
|
||||||
<path
|
|
||||||
d="M281.327 64.6787C280.558 64.4713 279.766 64.9167 279.541 65.6761L279.531 65.7115L277.539 73.0943L277.53 73.1299C277.342 73.8995 277.802 74.6827 278.572 74.8901C279.341 75.0979 280.132 74.6525 280.357 73.8929L280.367 73.8577L282.359 66.4749L282.369 66.4391C282.382 66.3837 282.392 66.3279 282.399 66.2723L282.405 66.2167L282.358 65.9775L282.289 65.6331L282.245 65.4181C282.152 65.2379 282.022 65.0791 281.864 64.9517C281.706 64.8245 281.523 64.7315 281.327 64.6787ZM267.445 57.0757C267.408 57.1481 267.378 57.2245 267.353 57.3043L267.339 57.3525L265.347 64.7353L265.338 64.7711C265.15 65.5407 265.61 66.3237 266.38 66.5313C267.149 66.7389 267.941 66.2937 268.166 65.5341L268.176 65.4987L269.982 58.8045C269.036 58.3035 268.187 57.7255 267.445 57.0757ZM262.694 48.5857C261.925 48.3781 261.133 48.8233 260.908 49.5829L260.898 49.6183L258.906 57.0011L258.897 57.0367C258.709 57.8063 259.169 58.5893 259.939 58.7969C260.708 59.0045 261.499 58.5593 261.725 57.7997L261.734 57.7643L263.727 50.3815L263.736 50.3459C263.923 49.5763 263.463 48.7933 262.694 48.5857ZM307.364 46.9091C306.595 46.7015 305.803 47.1467 305.578 47.9063L305.568 47.9417L303.576 55.3245L303.567 55.3601C303.379 56.1297 303.839 56.9127 304.608 57.1203C305.378 57.3279 306.169 56.8827 306.394 56.1231L306.404 56.0877L308.396 48.7049L308.406 48.6693C308.593 47.8997 308.133 47.1167 307.364 46.9091ZM258.356 37.0504C256.687 40.0887 255.625 43.4223 255.228 46.8657C255.418 47.0823 255.668 47.2379 255.946 47.3125C256.715 47.5203 257.507 47.0749 257.732 46.3153L257.742 46.2801L259.734 38.8972L259.743 38.8616C259.931 38.0919 259.471 37.3088 258.701 37.1013C258.589 37.0708 258.472 37.0538 258.356 37.0504ZM302.318 37.1013C301.549 36.8936 300.757 37.3389 300.532 38.0985L300.522 38.1338L298.53 45.5167L298.521 45.5523C298.333 46.3219 298.793 47.1051 299.563 47.3125C300.332 47.5203 301.123 47.0749 301.349 46.3153L301.358 46.2801L303.351 38.8972L303.36 38.8616C303.547 38.0919 303.087 37.3088 302.318 37.1013Z"
|
|
||||||
fill="white" />
|
|
||||||
<path
|
|
||||||
d="M267.026 30.0813C266.256 29.8736 265.465 30.319 265.24 31.0786L265.23 31.1138L263.238 38.4967L263.229 38.5323C263.041 39.302 263.501 40.085 264.27 40.2926C265.04 40.5002 265.831 40.0548 266.056 39.2953L266.066 39.26L268.058 31.8772L268.067 31.8416C268.255 31.0719 267.795 30.2888 267.026 30.0813ZM292.623 31.4769C291.854 31.2692 291.062 31.7145 290.837 32.4742L290.827 32.5094L289.489 37.47C290.356 37.8983 291.183 38.4025 291.962 38.9768L292.091 39.0729L293.656 33.2728L293.665 33.2372C293.852 32.4675 293.393 31.6844 292.623 31.4769ZM279.594 23.1528C278.659 23.2354 277.729 23.3668 276.809 23.5463L276.613 23.5853L274.756 30.4684L274.747 30.504C274.56 31.2737 275.02 32.0567 275.789 32.2643C276.558 32.4719 277.35 32.0266 277.575 31.267L277.585 31.2317L279.577 23.8489L279.586 23.8133C279.639 23.5966 279.642 23.3707 279.594 23.1528ZM297.925 28.2526L297.534 29.7034L297.525 29.7389C297.337 30.5086 297.797 31.2916 298.566 31.4992C299.336 31.7068 300.127 31.2615 300.352 30.5019L300.362 30.4666L300.405 30.3092C299.672 29.6241 298.902 28.9802 298.098 28.3804L297.925 28.2526ZM286.334 23.3935L285.628 26.0119L285.619 26.0475C285.431 26.8172 285.891 27.6002 286.661 27.8078C287.43 28.0154 288.221 27.5701 288.447 26.8105L288.456 26.7752L289.2 24.0193C288.325 23.7773 287.438 23.58 286.543 23.4281L286.334 23.3935Z"
|
|
||||||
fill="white" />
|
|
||||||
<path
|
|
||||||
d="M271.382 69.2504C271.607 68.4908 272.398 68.0456 273.168 68.253C273.937 68.4604 274.397 69.2436 274.209 70.0134L274.2 70.049L272.774 75.3326L272.575 75.2592C271.717 74.9386 270.875 74.5744 270.054 74.1676L271.372 69.2856L271.382 69.2504Z"
|
|
||||||
fill="white" />
|
|
||||||
<path
|
|
||||||
d="M280.828 36.9814C272.104 36.9814 265.318 42.4734 265.318 49.3032C265.318 55.7536 271.562 59.8722 281.242 59.666C282.065 59.6484 282.303 60.2014 282.571 60.9466C282.839 61.6918 283.559 65.6192 284.133 68.6232C284.647 71.3118 285.168 74.0102 285.567 76.719C291.888 75.8834 297.733 72.7612 302.015 68.052L297.447 51.0174C296.309 46.9034 294.978 43.1126 291.457 40.3586C288.624 38.1431 285.024 36.9814 280.828 36.9814Z"
|
|
||||||
fill="white" />
|
|
||||||
<path
|
|
||||||
d="M282.703 41.9141C283.739 41.9141 284.578 42.7535 284.578 43.7891C284.578 44.8247 283.739 45.6641 282.703 45.6641C281.668 45.6641 280.828 44.8247 280.828 43.7891C280.828 42.7535 281.668 41.9141 282.703 41.9141Z"
|
|
||||||
fill="black" />
|
|
||||||
<mask id="path-28-inside-6_473_1361" fill="white">
|
|
||||||
<path
|
|
||||||
d="M580 12C580 5.37258 585.373 0 592 0H668C674.627 0 680 5.37258 680 12V88C680 94.6274 674.627 100 668 100H592C585.373 100 580 94.6274 580 88V12Z" />
|
|
||||||
</mask>
|
|
||||||
<path
|
|
||||||
d="M580 12C580 5.37258 585.373 0 592 0H668C674.627 0 680 5.37258 680 12V88C680 94.6274 674.627 100 668 100H592C585.373 100 580 94.6274 580 88V12Z"
|
|
||||||
fill="#0A0A0A" />
|
|
||||||
<path
|
|
||||||
d="M580 12C580 4.8203 585.82 -1 593 -1H667C674.18 -1 680 4.8203 680 12C680 5.92487 674.627 1 668 1H592C585.373 1 580 5.92487 580 12ZM680 100H580H680ZM580 100V0V100ZM680 0V100V0Z"
|
|
||||||
fill="#303246" mask="url(#path-28-inside-6_473_1361)" />
|
|
||||||
<path d="M655 25H605V75H655V25Z" fill="#F7DF1E" />
|
|
||||||
<path
|
|
||||||
d="M638.587 64.0625C639.594 65.7069 640.905 66.9156 643.222 66.9156C645.169 66.9156 646.413 65.9426 646.413 64.5982C646.413 62.9871 645.135 62.4164 642.992 61.4791L641.817 60.9752C638.427 59.5307 636.175 57.7212 636.175 53.8958C636.175 50.372 638.859 47.6895 643.056 47.6895C646.043 47.6895 648.19 48.7291 649.738 51.4514L646.079 53.8006C645.274 52.3561 644.405 51.7871 643.056 51.7871C641.679 51.7871 640.807 52.6601 640.807 53.8006C640.807 55.2101 641.68 55.7807 643.696 56.6537L644.871 57.1569C648.863 58.8688 651.117 60.6141 651.117 64.5379C651.117 68.768 647.794 71.0855 643.331 71.0855C638.967 71.0855 636.148 69.0061 634.769 66.2807L638.587 64.0625ZM621.99 64.4696C622.728 65.7791 623.399 66.8863 625.013 66.8863C626.557 66.8863 627.531 66.2823 627.531 63.9339V47.9577H632.229V63.9974C632.229 68.8625 629.377 71.0768 625.213 71.0768C621.452 71.0768 619.273 69.1299 618.165 66.7851L621.99 64.4696Z"
|
|
||||||
fill="black" />
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="paint0_linear_473_1361" x1="404.333" y1="58.8334"
|
|
||||||
x2="416.167" y2="73.5" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop />
|
|
||||||
<stop offset="1" stop-color="white" stop-opacity="0" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint1_linear_473_1361" x1="408.334" y1="38"
|
|
||||||
x2="408.267" y2="55.6251" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop />
|
|
||||||
<stop offset="1" stop-color="white" stop-opacity="0" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<LyxUiButton type="secondary" to="https://docs.litlyx.com"> Visit documentation
|
|
||||||
</LyxUiButton>
|
</LyxUiButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<div class="justify-center w-full hidden xl:flex gap-3">
|
||||||
|
<a href="#">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/wpel.png'" alt="Litlyx-Wordpress-Elementor">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</CardTitled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<CardTitled class="w-full h-full" title="Modules"
|
||||||
|
sub="Get started with your favorite framework.">
|
||||||
|
<template #header>
|
||||||
|
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
|
||||||
|
to="https://docs.litlyx.com">
|
||||||
|
Visit documentation
|
||||||
|
</LyxUiButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<div class="justify-center w-full hidden xl:flex gap-3">
|
||||||
|
<a href="https://docs.litlyx.com/techs/js" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/js.png'" alt="Litlyx-Javascript-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/nuxt" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/nuxt.png'" alt="Litlyx-Nuxt-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/next" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/next.png'" alt="Litlyx-Next-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/react" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/react.png'" alt="Litlyx-React-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/vue" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/vue.png'" alt="Litlyx-Vue-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/angular" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/angular.png'" alt="Litlyx-Angular-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/python" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/py.png'" alt="Litlyx-Python-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/serverless" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/serverless.png'" alt="Litlyx-Serverless-Analytics">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</CardTitled>
|
</CardTitled>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,7 +205,7 @@ function reloadPage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- <div class="flex justify-center gap-10 flex-col lg:flex-row items-center lg:items-stretch px-10">
|
<!-- <div class="flex justify-center gap-10 flex-col xl:flex-row items-center xl:items-stretch px-10">
|
||||||
|
|
||||||
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full">
|
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full">
|
||||||
<div class="poppins font-semibold"> Copy your project_id: </div>
|
<div class="poppins font-semibold"> Copy your project_id: </div>
|
||||||
@@ -273,7 +215,7 @@ function reloadPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full lg:max-w-[40vw]">
|
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full xl:max-w-[40vw]">
|
||||||
<div class="poppins font-semibold">
|
<div class="poppins font-semibold">
|
||||||
Start logging visits in 1 click | Plug anywhere !
|
Start logging visits in 1 click | Plug anywhere !
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
26
dashboard/components/LyxUi/Button.vue
Normal file
26
dashboard/components/LyxUi/Button.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
export type ButtonType = 'primary' | 'secondary' | 'outline' | 'outlined' | 'danger';
|
||||||
|
|
||||||
|
const props = defineProps<{ type: ButtonType, link?: string, target?: string, disabled?: boolean }>();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLink tag="div" :to="disabled ? '' : link" :target="target"
|
||||||
|
class="poppins w-fit cursor-pointer px-4 py-1 rounded-md outline outline-[1px] text-lyx-lightmode-text dark:text-lyx-text"
|
||||||
|
:class="{
|
||||||
|
|
||||||
|
'bg-[#85a3ff] hover:bg-[#9db5fc] outline-lyx-lightmode-widget-light dark:bg-lyx-primary-dark dark:outline-lyx-primary dark:hover:bg-lyx-primary-hover': type === 'primary',
|
||||||
|
|
||||||
|
'bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget dark:bg-lyx-widget-lighter hover:bg-lyx-lightmode-widget dark:outline-lyx-widget-lighter dark:hover:bg-lyx-widget-light': type === 'secondary',
|
||||||
|
|
||||||
|
'bg-lyx-transparent outline-lyx-lightmode-widget hover:bg-lyx-lightmode-widget-light dark:outline-lyx-widget-lighter dark:hover:bg-lyx-widget-light': (type === 'outline' || type === 'outlined'),
|
||||||
|
|
||||||
|
'bg-[#fcd1cb] hover:bg-[#f8c5be] dark:bg-lyx-danger-dark outline-lyx-danger dark:hover:bg-lyx-danger': type === 'danger',
|
||||||
|
|
||||||
|
'text-lyx-text !bg-lyx-widget !outline-lyx-widget-lighter !cursor-not-allowed': disabled === true,
|
||||||
|
}">
|
||||||
|
<slot></slot>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
10
dashboard/components/LyxUi/Card.vue
Normal file
10
dashboard/components/LyxUi/Card.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-fit h-fit rounded-md bg-lyx-lightmode-background outline-lyx-lightmode-widget dark:bg-lyx-widget dark:outline-lyx-background-lighter p-4 outline outline-[1px] ">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -19,6 +19,6 @@ const handleChange = (event: Event) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<input
|
<input
|
||||||
class="bg-lyx-widget-light text-lyx-text-dark poppins rounded-md outline outline-[1px] outline-lyx-widget-lighter"
|
class="bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget text-lyx-lightmode-text dark:bg-lyx-widget-light dark:text-lyx-text-dark poppins rounded-md outline outline-[1px] dark:outline-lyx-widget-lighter"
|
||||||
:type="props.type ?? 'text'" :placeholder="props.placeholder" :value="props.modelValue" @input="handleChange">
|
:type="props.type ?? 'text'" :placeholder="props.placeholder" :value="props.modelValue" @input="handleChange">
|
||||||
</template>
|
</template>
|
||||||
15
dashboard/components/LyxUi/Separator.vue
Normal file
15
dashboard/components/LyxUi/Separator.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const props = defineProps<{ size?: string }>();
|
||||||
|
|
||||||
|
const widgetStyle = computed(() => {
|
||||||
|
return `height: ${props.size ?? '1px'}`;
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :style="widgetStyle" class="bg-lyx-widget-light"></div>
|
||||||
|
</template>
|
||||||
176
dashboard/components/Onboarding.vue
Normal file
176
dashboard/components/Onboarding.vue
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { data: needsOnboarding } = useFetch("/api/onboarding/exist", {
|
||||||
|
headers: useComputedHeaders({ useSnapshotDates: false, useTimeOffset: false })
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const analyticsList = [
|
||||||
|
"I have no prior analytics tool",
|
||||||
|
"Google Analytics 4",
|
||||||
|
"Plausible",
|
||||||
|
"Umami",
|
||||||
|
"MixPanel",
|
||||||
|
"Simple Analytics",
|
||||||
|
"Matomo",
|
||||||
|
"Fathom",
|
||||||
|
"Adobe Analytics",
|
||||||
|
"Other"
|
||||||
|
]
|
||||||
|
|
||||||
|
const jobsList = [
|
||||||
|
"Developer",
|
||||||
|
"Marketing",
|
||||||
|
"Product",
|
||||||
|
"Startup founder",
|
||||||
|
"Indie hacker",
|
||||||
|
"Other",
|
||||||
|
]
|
||||||
|
|
||||||
|
const selectedIndex = ref<number>(-1);
|
||||||
|
const otherFieldVisisble = ref<boolean>(false);
|
||||||
|
const otherText = ref<string>('');
|
||||||
|
function selectIndex(index: number) {
|
||||||
|
selectedIndex.value = index;
|
||||||
|
otherFieldVisisble.value = index == analyticsList.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIndex2 = ref<number>(-1);
|
||||||
|
const otherFieldVisisble2 = ref<boolean>(false);
|
||||||
|
const otherText2 = ref<string>('');
|
||||||
|
function selectIndex2(index: number) {
|
||||||
|
selectedIndex2.value = index;
|
||||||
|
otherFieldVisisble2.value = index == jobsList.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = ref<number>(0);
|
||||||
|
|
||||||
|
function onNextPage() {
|
||||||
|
if (selectedIndex.value == -1) return;
|
||||||
|
saveAnalyticsType();
|
||||||
|
page.value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFinish(skipped?: boolean) {
|
||||||
|
if (skipped) return location.reload();
|
||||||
|
if (selectedIndex2.value == -1) return;
|
||||||
|
saveJobTitle();
|
||||||
|
page.value = 2;
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAnalyticsType() {
|
||||||
|
await $fetch('/api/onboarding/add', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
useSnapshotDates: false, useTimeOffset: false,
|
||||||
|
custom: { 'Content-Type': 'application/json' }
|
||||||
|
}).value,
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
analytics:
|
||||||
|
selectedIndex.value == analyticsList.length - 1 ?
|
||||||
|
otherText.value :
|
||||||
|
analyticsList[selectedIndex.value]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveJobTitle() {
|
||||||
|
|
||||||
|
await $fetch('/api/onboarding/add', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
useSnapshotDates: false, useTimeOffset: false,
|
||||||
|
custom: { 'Content-Type': 'application/json' }
|
||||||
|
}).value,
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
job:
|
||||||
|
selectedIndex2.value == jobsList.length - 1 ?
|
||||||
|
otherText2.value :
|
||||||
|
jobsList[selectedIndex2.value]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const showOnboarding = computed(() => {
|
||||||
|
if (route.path === '/login') return false;
|
||||||
|
if (route.path === '/register') return false;
|
||||||
|
if ((needsOnboarding.value as any)?.exist === false) return true;
|
||||||
|
if ((needsOnboarding.value as any)?.exists === false) return true;
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="showOnboarding" class="absolute top-0 left-0 w-full h-full z-[30] bg-black/80 flex justify-center">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="page == 0" class="bg-lyx-lightmode-background-light dark:bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
|
||||||
|
|
||||||
|
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
|
||||||
|
|
||||||
|
<div class="text-lyx-lightmode-text dark:text-lyx-text mt-4">
|
||||||
|
For the current project do you already have other Analytics tools implemented (e.g. GA4) or Litlyx is
|
||||||
|
going to be your first/main analytics?
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 mt-8">
|
||||||
|
<div v-for="(e, i) of analyticsList">
|
||||||
|
<div @click="selectIndex(i)"
|
||||||
|
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex == i }"
|
||||||
|
class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
|
||||||
|
{{ e }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<LyxUiInput v-if="otherFieldVisisble" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
|
||||||
|
v-model="otherText"></LyxUiInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-center flex-col items-center">
|
||||||
|
<LyxUiButton @click="onNextPage()" class="px-[8rem] py-2" :disabled="selectedIndex == -1"
|
||||||
|
type="primary"> Next </LyxUiButton>
|
||||||
|
<!-- <div class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div v-if="page == 1" class="bg-lyx-lightmode-background-light dark:bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
|
||||||
|
|
||||||
|
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
|
||||||
|
|
||||||
|
<div class="text-lyx-lightmode-text dark:text-lyx-text mt-4">
|
||||||
|
What is your job title ?
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 mt-8">
|
||||||
|
<div v-for="(e, i) of jobsList">
|
||||||
|
<div @click="selectIndex2(i)"
|
||||||
|
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex2 == i }"
|
||||||
|
class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
|
||||||
|
{{ e }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<LyxUiInput v-if="otherFieldVisisble2" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
|
||||||
|
v-model="otherText2"></LyxUiInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-center flex-col items-center">
|
||||||
|
<LyxUiButton @click="onFinish()" class="px-[8rem] py-2" :disabled="selectedIndex2 == -1" type="primary">
|
||||||
|
Finish </LyxUiButton>
|
||||||
|
<div @click="onFinish(true)" class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
options: { label: string }[],
|
options: { label: string, disabled?: boolean }[],
|
||||||
currentIndex: number
|
currentIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,10 +16,13 @@ const emits = defineEmits<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div class="flex gap-2 border-[1px] border-lyx-widget-lighter p-1 md:p-2 rounded-xl bg-lyx-widget">
|
<div class="flex gap-2 border-[1px] p-1 md:p-2 rounded-xl bg-lyx-lightmode-widget-light border-lyx-lightmode-widget dark:bg-lyx-widget dark:border-lyx-widget-lighter">
|
||||||
<div @click="$emit('changeIndex', index)" v-for="(opt, index) of options"
|
<div @click="opt.disabled ? ()=>{}: $emit('changeIndex', index)" v-for="(opt, index) of options"
|
||||||
class="hover:bg-lyx-widget-lighter/60 select-btn-animated cursor-pointer rounded-lg poppins font-regular px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
|
class="hover:bg-lyx-lightmode-widget dark:hover:bg-lyx-widget-lighter/60 select-btn-animated cursor-pointer rounded-lg poppins font-regular px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
|
||||||
:class="{ 'bg-lyx-widget-lighter hover:!bg-lyx-widget-lighter': currentIndex == index }">
|
:class="{
|
||||||
|
'bg-lyx-lightmode-widget hover:!bg-lyx-lightmode-widget dark:bg-lyx-widget-lighter dark:hover:!bg-lyx-widget-lighter': currentIndex == index && !opt.disabled,
|
||||||
|
'hover:!bg-lyx-lightmode-widget-light text-lyx-lightmode-widget dark:hover:!bg-lyx-widget !cursor-not-allowed dark:!text-lyx-widget-lighter': opt.disabled
|
||||||
|
}">
|
||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
72
dashboard/components/admin/Backend.vue
Normal file
72
dashboard/components/admin/Backend.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const { data: backendData, pending: backendPending, refresh: refreshBackend } = useFetch<any>(() => `/api/admin/backend`, signHeaders());
|
||||||
|
|
||||||
|
const avgDuration = computed(() => {
|
||||||
|
if (!backendData?.value?.durations) return -1;
|
||||||
|
return (backendData.value.durations.durations.reduce((a: any, e: any) => a + parseInt(e[1]), 0) / backendData.value.durations.durations.length);
|
||||||
|
})
|
||||||
|
|
||||||
|
const labels = new Array(650).fill('-');
|
||||||
|
|
||||||
|
const durationsDatasets = computed(() => {
|
||||||
|
if (!backendData?.value?.durations) return [];
|
||||||
|
|
||||||
|
const colors = ['#2200DD', '#CC0022', '#0022CC', '#FF0000', '#00FF00', '#0000FF'];
|
||||||
|
|
||||||
|
const datasets = [];
|
||||||
|
|
||||||
|
const uniqueConsumers: string[] = Array.from(new Set(backendData.value.durations.durations.map((e: any) => e[0])));
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueConsumers.length; i++) {
|
||||||
|
|
||||||
|
const consumerDurations = backendData.value.durations.durations.filter((e: any) => e[0] == uniqueConsumers[i]);
|
||||||
|
|
||||||
|
datasets.push({
|
||||||
|
points: consumerDurations.map((e: any) => {
|
||||||
|
return 1000 / parseInt(e[1])
|
||||||
|
}),
|
||||||
|
color: colors[i],
|
||||||
|
chartType: 'line',
|
||||||
|
name: uniqueConsumers[i]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return datasets;
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
<div class="cursor-default flex justify-center w-full">
|
||||||
|
|
||||||
|
<div v-if="backendData" class="flex flex-col mt-8 gap-6 px-20 items-center w-full">
|
||||||
|
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div> Queue size: {{ backendData.queue?.size || 'ERROR' }} </div>
|
||||||
|
<div> Avg consumer time: {{ avgDuration.toFixed(1) }} ms </div>
|
||||||
|
<div> Avg processed/s: {{ (1000 / avgDuration).toFixed(1) }} </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<AdminBackendLineChart :labels="labels" title="Avg Processed/s" :datasets="durationsDatasets">
|
||||||
|
</AdminBackendLineChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div @click="refreshBackend()"> Refresh </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="backendPending">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
31
dashboard/components/admin/Feedbacks.vue
Normal file
31
dashboard/components/admin/Feedbacks.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { data: feedbacks, pending: pendingFeedbacks } = useFetch<any[]>(() => `/api/admin/feedbacks`, signHeaders());
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||||
|
|
||||||
|
<div v-if="feedbacks" class="flex flex-col-reverse gap-4 px-20">
|
||||||
|
<div class="flex flex-col text-center outline outline-[1px] outline-lyx-widget-lighter p-4 gap-2"
|
||||||
|
v-for="feedback of feedbacks">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-lyx-text-dark"> {{ feedback.user[0]?.email || 'DELETED USER' }} </div>
|
||||||
|
<div class="text-lyx-text-dark"> {{ feedback.project[0]?.name || 'DELETED PROJECT' }} </div>
|
||||||
|
</div>
|
||||||
|
{{ feedback.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="pendingFeedbacks"> Loading...</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
271
dashboard/components/admin/MiniChart.vue
Normal file
271
dashboard/components/admin/MiniChart.vue
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import DateService, { type Slice } from '@services/DateService';
|
||||||
|
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
|
||||||
|
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||||
|
import * as fns from 'date-fns';
|
||||||
|
|
||||||
|
const props = defineProps<{ pid: string }>();
|
||||||
|
|
||||||
|
const errorData = ref<{ errored: boolean, text: string }>({ errored: false, text: '' })
|
||||||
|
|
||||||
|
function createGradient(startColor: string) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
const ctx = c.getContext("2d");
|
||||||
|
let gradient: any = `${startColor}22`;
|
||||||
|
if (ctx) {
|
||||||
|
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||||
|
gradient.addColorStop(0, `${startColor}99`);
|
||||||
|
gradient.addColorStop(0.35, `${startColor}66`);
|
||||||
|
gradient.addColorStop(1, `${startColor}22`);
|
||||||
|
} else {
|
||||||
|
console.warn('Cannot get context for gradient');
|
||||||
|
}
|
||||||
|
|
||||||
|
return gradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartOptions = ref<ChartOptions<'line'>>({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: (false as any),
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
// borderDash: [5, 10]
|
||||||
|
},
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: { display: true },
|
||||||
|
stacked: false,
|
||||||
|
offset: false,
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
title: { display: false },
|
||||||
|
tooltip: { enabled: false }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Visits',
|
||||||
|
data: [],
|
||||||
|
backgroundColor: ['#5655d7'],
|
||||||
|
borderColor: '#5655d7',
|
||||||
|
borderWidth: 4,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.35,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 10,
|
||||||
|
hoverBackgroundColor: '#5655d7',
|
||||||
|
hoverBorderColor: 'white',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
segment: {
|
||||||
|
borderColor(ctx, options) {
|
||||||
|
const todayIndex = visitsData.data.value?.todayIndex;
|
||||||
|
if (!todayIndex || todayIndex == -1) return '#5655d7';
|
||||||
|
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||||
|
return '#5655d7'
|
||||||
|
},
|
||||||
|
borderDash(ctx, options) {
|
||||||
|
const todayIndex = visitsData.data.value?.todayIndex;
|
||||||
|
if (!todayIndex || todayIndex == -1) return undefined;
|
||||||
|
if (ctx.p1DataIndex == todayIndex - 1) return [3, 5];
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
backgroundColor(ctx, options) {
|
||||||
|
const todayIndex = visitsData.data.value?.todayIndex;
|
||||||
|
if (!todayIndex || todayIndex == -1) return createGradient('#5655d7');
|
||||||
|
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||||
|
return createGradient('#5655d7');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Unique visitors',
|
||||||
|
data: [],
|
||||||
|
backgroundColor: ['#4abde8'],
|
||||||
|
borderColor: '#4abde8',
|
||||||
|
borderWidth: 2,
|
||||||
|
hoverBackgroundColor: '#4abde8',
|
||||||
|
hoverBorderColor: '#4abde8',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
type: 'bar',
|
||||||
|
// barThickness: 20,
|
||||||
|
borderSkipped: ['bottom'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Events',
|
||||||
|
data: [],
|
||||||
|
backgroundColor: ['#fbbf24'],
|
||||||
|
borderWidth: 2,
|
||||||
|
hoverBackgroundColor: '#fbbf24',
|
||||||
|
hoverBorderColor: '#fbbf24',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
type: 'bubble',
|
||||||
|
stack: 'combined',
|
||||||
|
borderColor: ["#fbbf24"]
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
|
||||||
|
|
||||||
|
const selectedSlice: Slice = 'day'
|
||||||
|
|
||||||
|
const allDatesFull = ref<string[]>([]);
|
||||||
|
|
||||||
|
|
||||||
|
function transformResponse(input: { _id: string, count: number }[]) {
|
||||||
|
const data = input.map(e => e.count);
|
||||||
|
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice));
|
||||||
|
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
|
||||||
|
|
||||||
|
const todayIndex = input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
|
||||||
|
|
||||||
|
return { data, labels, todayIndex }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResponseError(e: any) {
|
||||||
|
let message = e.response._data.message ?? 'Generic error';
|
||||||
|
if (message == 'internal server error') message = 'Please change slice';
|
||||||
|
errorData.value = { errored: true, text: message }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResponse(e: any) {
|
||||||
|
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const headers = computed(() => {
|
||||||
|
return {
|
||||||
|
'x-from': fns.startOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
|
||||||
|
'x-to': fns.endOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
|
||||||
|
'x-pid': props.pid
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const visitsData = useFetch(`/api/timeline/visits?pid=${props.pid}`, {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
slice: selectedSlice,
|
||||||
|
custom: { ...headers.value },
|
||||||
|
useActivePid: false,
|
||||||
|
useActiveDomain: false
|
||||||
|
}),
|
||||||
|
lazy: true,
|
||||||
|
transform: transformResponse, onResponseError, onResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionsData = useFetch(`/api/timeline/sessions?pid=${props.pid}`, {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
slice: selectedSlice,
|
||||||
|
custom: { ...headers.value },
|
||||||
|
useActivePid: false,
|
||||||
|
useActiveDomain: false
|
||||||
|
}), lazy: true,
|
||||||
|
transform: transformResponse, onResponseError, onResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventsData = useFetch(`/api/timeline/events?pid=${props.pid}`, {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
slice: selectedSlice,
|
||||||
|
custom: { ...headers.value },
|
||||||
|
useActivePid: false,
|
||||||
|
useActiveDomain: false
|
||||||
|
}), lazy: true,
|
||||||
|
transform: transformResponse, onResponseError, onResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
const readyToDisplay = computed(() => !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value);
|
||||||
|
|
||||||
|
watch(readyToDisplay, () => {
|
||||||
|
if (readyToDisplay.value === true) onDataReady();
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
function onDataReady() {
|
||||||
|
if (!visitsData.data.value) return;
|
||||||
|
if (!eventsData.data.value) return;
|
||||||
|
if (!sessionsData.data.value) return;
|
||||||
|
|
||||||
|
chartData.value.labels = visitsData.data.value.labels;
|
||||||
|
|
||||||
|
const maxChartY = Math.max(...visitsData.data.value.data, ...sessionsData.data.value.data);
|
||||||
|
const maxEventSize = Math.max(...eventsData.data.value.data)
|
||||||
|
|
||||||
|
chartData.value.datasets[0].data = visitsData.data.value.data;
|
||||||
|
chartData.value.datasets[1].data = sessionsData.data.value.data;
|
||||||
|
|
||||||
|
chartData.value.datasets[2].data = eventsData.data.value.data.map(e => {
|
||||||
|
const rValue = 20 / maxEventSize * e;
|
||||||
|
return { x: 0, y: maxChartY + 20, r: isNaN(rValue) ? 0 : rValue, r2: e }
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
chartData.value.datasets[0].backgroundColor = [createGradient('#5655d7')];
|
||||||
|
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
|
||||||
|
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
|
||||||
|
|
||||||
|
|
||||||
|
(chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => {
|
||||||
|
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||||
|
if (i == todayIndex - 1) return true;
|
||||||
|
return 'bottom';
|
||||||
|
});
|
||||||
|
|
||||||
|
chartData.value.datasets[2].borderColor = eventsData.data.value.data.map((e, i) => {
|
||||||
|
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||||
|
if (i == todayIndex - 1) return '#fbbf2400';
|
||||||
|
return '#fbbf24';
|
||||||
|
});
|
||||||
|
|
||||||
|
updateChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div class="h-[10rem] w-full flex">
|
||||||
|
<div v-if="!readyToDisplay" class="w-full flex justify-center items-center">
|
||||||
|
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end w-full" v-if="readyToDisplay && !errorData.errored">
|
||||||
|
<LineChart ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorData.errored" class="flex items-center justify-center py-8">
|
||||||
|
{{ errorData.text }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
#external-tooltip {
|
||||||
|
border-radius: 3px;
|
||||||
|
color: white;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
transition: all .1s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
45
dashboard/components/admin/Onboardings.vue
Normal file
45
dashboard/components/admin/Onboardings.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { data: onboardings, pending: pendingOnboardings } = useFetch<any>(() => `/api/admin/onboardings`, signHeaders());
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
<div class="cursor-default flex flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||||
|
|
||||||
|
<div v-if="onboardings" class="flex gap-40 px-20">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-lyx-primary"> Anaytics </div>
|
||||||
|
<div class="flex items-center gap-2"
|
||||||
|
v-for="e of onboardings.analytics.sort((a: any, b: any) => b.count - a.count)">
|
||||||
|
<div>{{ e._id }}</div>
|
||||||
|
<div>{{ e.count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-lyx-primary"> Jobs </div>
|
||||||
|
<div class="flex items-center gap-2"
|
||||||
|
v-for="e of onboardings.jobs.sort((a: any, b: any) => b.count - a.count)">
|
||||||
|
<div>{{ e._id }}</div>
|
||||||
|
<div>{{ e.count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="onboardings" class="flex flex-col gap-8">
|
||||||
|
<AdminOnboardingPieChart :data="onboardings.analytics" title="Analytics"></AdminOnboardingPieChart>
|
||||||
|
<AdminOnboardingPieChart :data="onboardings.jobs" title="Jobs"></AdminOnboardingPieChart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="pendingOnboardings"> Loading...</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
204
dashboard/components/admin/Overview.vue
Normal file
204
dashboard/components/admin/Overview.vue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||||
|
|
||||||
|
import { PREMIUM_PLAN, getPlanFromId } from '@data/PREMIUM'
|
||||||
|
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
|
||||||
|
|
||||||
|
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||||
|
|
||||||
|
|
||||||
|
const page = ref<number>(1);
|
||||||
|
|
||||||
|
const ordersList = [
|
||||||
|
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
|
||||||
|
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'active -->', id: '{ "last_log_at": 1 }' },
|
||||||
|
{ label: 'active <--', id: '{ "last_log_at": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'visits -->', id: '{ "visits": 1 }' },
|
||||||
|
{ label: 'visits <--', id: '{ "visits": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'events -->', id: '{ "events": 1 }' },
|
||||||
|
{ label: 'events <--', id: '{ "events": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'sessions -->', id: '{ "sessions": 1 }' },
|
||||||
|
{ label: 'sessions <--', id: '{ "sessions": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'usage total -->', id: '{ "limit_total": 1 }' },
|
||||||
|
{ label: 'usage total <--', id: '{ "limit_total": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'usage visits -->', id: '{ "limit_visits": 1 }' },
|
||||||
|
{ label: 'usage visits <--', id: '{ "limit_visits": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'usage events -->', id: '{ "limit_events": 1 }' },
|
||||||
|
{ label: 'usage events <--', id: '{ "limit_events": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'usage ai -->', id: '{ "limit_ai_messages": 1 }' },
|
||||||
|
{ label: 'usage ai <--', id: '{ "limit_ai_messages": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'plan -->', id: '{ "premium_type": 1 }' },
|
||||||
|
{ label: 'plan <--', id: '{ "premium_type": -1 }' },
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
const order = ref<string>('{ "created_at": -1 }');
|
||||||
|
|
||||||
|
const limitList = [
|
||||||
|
{ label: '10', id: 10 },
|
||||||
|
{ label: '20', id: 20 },
|
||||||
|
{ label: '50', id: 50 },
|
||||||
|
{ label: '100', id: 100 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const limit = ref<number>(20);
|
||||||
|
|
||||||
|
const filterList = [
|
||||||
|
{ label: 'ALL', id: '{}' },
|
||||||
|
{ label: 'PREMIUM', id: '{ "premium_type": { "$gt": 0, "$lt": 1000 } }' },
|
||||||
|
{ label: 'APPSUMO', id: '{ "premium_type": { "$gt": 6000, "$lt": 7000 } }' },
|
||||||
|
{ label: 'PREMIUM+APPSUMO', id: '{ "premium_type": { "$gt": 0, "$lt": 7000 } }' },
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
function isRangeSelected(duration: Duration) {
|
||||||
|
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRange(duration: Duration) {
|
||||||
|
selected.value = { start: sub(new Date(), duration), end: new Date() }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranges = [
|
||||||
|
{ label: 'Last 7 days', duration: { days: 7 } },
|
||||||
|
{ label: 'Last 14 days', duration: { days: 14 } },
|
||||||
|
{ label: 'Last 30 days', duration: { days: 30 } },
|
||||||
|
{ label: 'Last 3 months', duration: { months: 3 } },
|
||||||
|
{ label: 'Last 6 months', duration: { months: 6 } },
|
||||||
|
{ label: 'Last year', duration: { years: 1 } }
|
||||||
|
]
|
||||||
|
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
for (const key in PREMIUM_PLAN) {
|
||||||
|
filterList.push({ label: key, id: `{"premium_type": ${(PREMIUM_PLAN as any)[key].ID}}` });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const filter = ref<string>('{}');
|
||||||
|
|
||||||
|
const { data: projectsInfo, pending: pendingProjects } = useFetch<{ count: number, projects: TAdminProject[] }>(
|
||||||
|
() => `/api/admin/projects?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
|
||||||
|
signHeaders()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: metrics, pending: pendingMetrics } = useFetch(
|
||||||
|
() => `/api/admin/metrics?filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
|
||||||
|
signHeaders()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { uiMenu } = useSelectMenuStyle();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-8">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-10 px-10">
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div>Order:</div>
|
||||||
|
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
|
||||||
|
value-attribute="id" option-attribute="label" v-model="order">
|
||||||
|
</USelectMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div>Limit:</div>
|
||||||
|
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
|
||||||
|
value-attribute="id" option-attribute="label" v-model="limit">
|
||||||
|
</USelectMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div>Filter:</div>
|
||||||
|
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Filter" :options="filterList"
|
||||||
|
value-attribute="id" option-attribute="label" v-model="filter">
|
||||||
|
</USelectMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-10 justify-center px-10 w-full">
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center shrink-0">
|
||||||
|
<div>Page {{ page }} </div>
|
||||||
|
<div> {{ Math.min(limit, projectsInfo?.count || 0) }} of {{ projectsInfo?.count || 0
|
||||||
|
}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<UPagination v-model="page" :page-count="limit" :total="projectsInfo?.count || 0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UPopover class="w-[20rem]" :popper="{ placement: 'bottom' }">
|
||||||
|
<UButton class="w-full" color="primary" variant="solid">
|
||||||
|
<div class="flex items-center justify-center w-full gap-2">
|
||||||
|
<i class="i-heroicons-calendar-days-20-solid"></i>
|
||||||
|
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
|
||||||
|
</div>
|
||||||
|
</UButton>
|
||||||
|
<template #panel="{ close }">
|
||||||
|
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
|
||||||
|
<div class="hidden sm:flex flex-col py-4">
|
||||||
|
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
|
||||||
|
variant="ghost" class="rounded-none px-6"
|
||||||
|
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
|
||||||
|
truncate @click="selectRange(range.duration)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DatePicker v-model="selected" @close="close" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPopover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="w-[80%]">
|
||||||
|
<div v-if="pendingMetrics"> Loading... </div>
|
||||||
|
<div class="flex gap-10 flex-wrap" v-if="!pendingMetrics && metrics">
|
||||||
|
<div> Projects: {{ metrics.totalProjects }} ({{ metrics.premiumProjects }} premium) </div>
|
||||||
|
<div>
|
||||||
|
Total visits: {{ formatNumberK(metrics.totalVisits) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Active: {{ metrics.totalProjects - metrics.deadProjects }} |
|
||||||
|
Dead: {{ metrics.deadProjects }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Total events: {{ formatNumberK(metrics.totalEvents) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||||
|
|
||||||
|
<AdminOverviewProjectCard v-if="!pendingProjects" :key="project._id.toString()" :project="project"
|
||||||
|
class="w-[26rem]" v-for="project of projectsInfo?.projects" />
|
||||||
|
|
||||||
|
<div v-if="pendingProjects"> Loading...</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
151
dashboard/components/admin/Users.vue
Normal file
151
dashboard/components/admin/Users.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
|
||||||
|
import type { TAdminUser } from '~/server/api/admin/users';
|
||||||
|
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const filterText = ref<string>('');
|
||||||
|
|
||||||
|
watch(filterText, () => {
|
||||||
|
page.value = 1;
|
||||||
|
})
|
||||||
|
|
||||||
|
function isRangeSelected(duration: Duration) {
|
||||||
|
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRange(duration: Duration) {
|
||||||
|
selected.value = { start: sub(new Date(), duration), end: new Date() }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranges = [
|
||||||
|
{ label: 'Last 7 days', duration: { days: 7 } },
|
||||||
|
{ label: 'Last 14 days', duration: { days: 14 } },
|
||||||
|
{ label: 'Last 30 days', duration: { days: 30 } },
|
||||||
|
{ label: 'Last 3 months', duration: { months: 3 } },
|
||||||
|
{ label: 'Last 6 months', duration: { months: 6 } },
|
||||||
|
{ label: 'Last year', duration: { years: 1 } }
|
||||||
|
]
|
||||||
|
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
|
||||||
|
|
||||||
|
const filter = computed(() => {
|
||||||
|
return JSON.stringify({
|
||||||
|
$or: [
|
||||||
|
{ given_name: { $regex: `.*${filterText.value}.*`, $options: "i" } },
|
||||||
|
{ email: { $regex: `.*${filterText.value}.*`, $options: "i" } }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const page = ref<number>(1);
|
||||||
|
|
||||||
|
const ordersList = [
|
||||||
|
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
|
||||||
|
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const order = ref<string>('{ "created_at": -1 }');
|
||||||
|
|
||||||
|
|
||||||
|
const limitList = [
|
||||||
|
{ label: '10', id: 10 },
|
||||||
|
{ label: '20', id: 20 },
|
||||||
|
{ label: '50', id: 50 },
|
||||||
|
{ label: '100', id: 100 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const limit = ref<number>(20);
|
||||||
|
|
||||||
|
const { data: usersInfo, pending: pendingUsers } = await useFetch<{ count: number, users: TAdminUser[] }>(
|
||||||
|
() => `/api/admin/users?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
|
||||||
|
signHeaders()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { uiMenu } = useSelectMenuStyle();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-6">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-10 px-10">
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div>Order:</div>
|
||||||
|
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
|
||||||
|
value-attribute="id" option-attribute="label" v-model="order">
|
||||||
|
</USelectMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div>Limit:</div>
|
||||||
|
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
|
||||||
|
value-attribute="id" option-attribute="label" v-model="limit">
|
||||||
|
</USelectMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<LyxUiInput placeholder="Search user" class="px-2 py-1" v-model="filterText"></LyxUiInput>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-centet gap-10">
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div>Page {{ page }} </div>
|
||||||
|
<div>
|
||||||
|
{{ Math.min(limit, usersInfo?.count || 0) }}
|
||||||
|
of
|
||||||
|
{{ usersInfo?.count || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<UPagination v-model="page" :page-count="limit" :total="usersInfo?.count || 0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UPopover class="w-[20rem]" :popper="{ placement: 'bottom' }">
|
||||||
|
<UButton class="w-full" color="primary" variant="solid">
|
||||||
|
<div class="flex items-center justify-center w-full gap-2">
|
||||||
|
<i class="i-heroicons-calendar-days-20-solid"></i>
|
||||||
|
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
|
||||||
|
</div>
|
||||||
|
</UButton>
|
||||||
|
<template #panel="{ close }">
|
||||||
|
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
|
||||||
|
<div class="hidden sm:flex flex-col py-4">
|
||||||
|
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
|
||||||
|
variant="ghost" class="rounded-none px-6"
|
||||||
|
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
|
||||||
|
truncate @click="selectRange(range.duration)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DatePicker v-model="selected" @close="close" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPopover>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||||
|
|
||||||
|
<AdminUsersUserCard v-if="!pendingUsers" :key="user._id.toString()" :user="user" class="w-[26rem]"
|
||||||
|
v-for="user of usersInfo?.users" />
|
||||||
|
|
||||||
|
<div v-if="pendingUsers"> Loading...</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
132
dashboard/components/admin/backend/LineChart.vue
Normal file
132
dashboard/components/admin/backend/LineChart.vue
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js';
|
||||||
|
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||||
|
import * as datefns from 'date-fns';
|
||||||
|
|
||||||
|
const errored = ref<boolean>(false);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
labels: string[],
|
||||||
|
title: string,
|
||||||
|
datasets: {
|
||||||
|
points: number[],
|
||||||
|
color: string,
|
||||||
|
name: string
|
||||||
|
}[]
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartOptions = ref<ChartOptions<'line'>>({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: 'x',
|
||||||
|
includeInvisible: true
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
// borderDash: [5, 10]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true },
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: props.title
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
titleFont: { size: 16, weight: 'bold' },
|
||||||
|
bodyFont: { size: 14 },
|
||||||
|
padding: 10,
|
||||||
|
cornerRadius: 4,
|
||||||
|
boxPadding: 10,
|
||||||
|
caretPadding: 20,
|
||||||
|
yAlign: 'bottom',
|
||||||
|
xAlign: 'center',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = ref<ChartData<'line'>>({
|
||||||
|
labels: props.labels.map(e => {
|
||||||
|
try {
|
||||||
|
return datefns.format(new Date(e), 'dd/MM');
|
||||||
|
} catch (ex) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
datasets: props.datasets.map(e => ({
|
||||||
|
data: e.points,
|
||||||
|
label: e.name,
|
||||||
|
backgroundColor: [e.color + '00'],
|
||||||
|
borderColor: e.color,
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.45,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 10,
|
||||||
|
type: 'line'
|
||||||
|
} as any))
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
|
||||||
|
|
||||||
|
function createGradient(startColor: string) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
const ctx = c.getContext("2d");
|
||||||
|
let gradient: any = `${startColor}22`;
|
||||||
|
if (ctx) {
|
||||||
|
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||||
|
gradient.addColorStop(0, `${startColor}99`);
|
||||||
|
gradient.addColorStop(0.35, `${startColor}66`);
|
||||||
|
gradient.addColorStop(1, `${startColor}22`);
|
||||||
|
} else {
|
||||||
|
console.warn('Cannot get context for gradient');
|
||||||
|
}
|
||||||
|
|
||||||
|
return gradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// chartData.value.datasets.forEach(dataset => {
|
||||||
|
// if (dataset.borderColor && dataset.borderColor.toString().startsWith('#')) {
|
||||||
|
// dataset.backgroundColor = [createGradient(dataset.borderColor as string)]
|
||||||
|
// } else {
|
||||||
|
// dataset.backgroundColor = [createGradient('#3d59a4')]
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
} catch (ex) {
|
||||||
|
errored.value = true;
|
||||||
|
console.error(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="errored"> ERROR CREATING CHART </div>
|
||||||
|
<LineChart v-if="!errored" ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
48
dashboard/components/admin/dialog/ProjectDetails.vue
Normal file
48
dashboard/components/admin/dialog/ProjectDetails.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||||
|
|
||||||
|
const props = defineProps<{ pid: string }>();
|
||||||
|
|
||||||
|
const { data: projectInfo, refresh, pending } = useFetch<{ domains: { _id: string }[], project: TAdminProject }>(
|
||||||
|
() => `/api/admin/project_info?pid=${props.pid}`,
|
||||||
|
signHeaders(),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div class="mt-6 h-full flex flex-col gap-10 w-full" v-if="!pending">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<LyxUiButton type="secondary" @click="refresh"> Refresh </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center gap-10" v-if="projectInfo">
|
||||||
|
|
||||||
|
<AdminOverviewProjectCard :project="projectInfo.project" class="w-[30rem] shrink-0" />
|
||||||
|
|
||||||
|
<AdminMiniChart class="max-w-[40rem]" :pid="pid"></AdminMiniChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="projectInfo" class="flex flex-col">
|
||||||
|
|
||||||
|
<div>Domains:</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-8 mt-8">
|
||||||
|
|
||||||
|
<div v-for="domain of projectInfo.domains">
|
||||||
|
{{ domain._id }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
62
dashboard/components/admin/onboarding/PieChart.vue
Normal file
62
dashboard/components/admin/onboarding/PieChart.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import { type ChartData, type ChartOptions } from 'chart.js';
|
||||||
|
import { PieChart, usePieChart } from 'vue-chart-3';
|
||||||
|
|
||||||
|
const props = defineProps<{ data: { _id: string, count: number }[], title:string }>();
|
||||||
|
|
||||||
|
const eventsTimelineOptions = ref<ChartOptions<'pie'>>({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: props.title,
|
||||||
|
color: '#EEECF6',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventsTimelineData = computed<ChartData<'pie'>>(() => ({
|
||||||
|
labels: props.data.map(e => e._id),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: props.data.map(e => e.count),
|
||||||
|
backgroundColor: [
|
||||||
|
"#295270",
|
||||||
|
"#304F71",
|
||||||
|
"#374C72",
|
||||||
|
"#3E4A73",
|
||||||
|
"#444773",
|
||||||
|
"#4B4474",
|
||||||
|
"#524175",
|
||||||
|
],
|
||||||
|
borderColor: '#222222'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
const { pieChartProps } = usePieChart({ chartData: eventsTimelineData, options: eventsTimelineOptions });
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="graph">
|
||||||
|
<PieChart v-bind="pieChartProps">
|
||||||
|
</PieChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
134
dashboard/components/admin/overview/ProjectCard.vue
Normal file
134
dashboard/components/admin/overview/ProjectCard.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||||
|
import { getPlanFromId } from '~/shared/data/PREMIUM';
|
||||||
|
|
||||||
|
|
||||||
|
import { AdminDialogProjectDetails } from '#components';
|
||||||
|
|
||||||
|
const { openDialogEx } = useCustomDialog();
|
||||||
|
|
||||||
|
function showProjectDetails(pid: string) {
|
||||||
|
openDialogEx(AdminDialogProjectDetails, {
|
||||||
|
params: { pid }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ project: TAdminProject }>();
|
||||||
|
|
||||||
|
|
||||||
|
const logBg = computed(() => {
|
||||||
|
|
||||||
|
const day = 1000 * 60 * 60 * 24;
|
||||||
|
const week = 1000 * 60 * 60 * 24 * 7;
|
||||||
|
|
||||||
|
const lastLoggedAtDate = new Date(props.project.last_log_at || 0);
|
||||||
|
|
||||||
|
if (lastLoggedAtDate.getTime() > Date.now() - day) {
|
||||||
|
return 'bg-green-500'
|
||||||
|
} else if (lastLoggedAtDate.getTime() > Date.now() - week) {
|
||||||
|
return 'bg-yellow-500'
|
||||||
|
} else {
|
||||||
|
return 'bg-red-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const dateDiffDays = computed(() => {
|
||||||
|
const res = (Date.now() - new Date(props.project.last_log_at || 0).getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
if (res > -1 && res < 1) return 0;
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageLabel = computed(() => {
|
||||||
|
return formatNumberK(props.project.limit_total) + ' / ' + formatNumberK(props.project.limit_max)
|
||||||
|
});
|
||||||
|
|
||||||
|
const usagePercentLabel = computed(() => {
|
||||||
|
const percent = 100 / props.project.limit_max * props.project.limit_total;
|
||||||
|
return `~ ${percent.toFixed(1)}%`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageAiLabel = computed(() => {
|
||||||
|
return formatNumberK(props.project.limit_ai_messages) + ' / ' + formatNumberK(props.project.limit_ai_max);
|
||||||
|
}
|
||||||
|
|
||||||
|
); const usageAiPercentLabel = computed(() => {
|
||||||
|
const percent = 100 / props.project.limit_ai_max * props.project.limit_ai_messages;
|
||||||
|
return `~ ${percent.toFixed(1)}%`
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative h-fit">
|
||||||
|
|
||||||
|
<div class="absolute top-1 left-2 text-[.8rem] text-lyx-text-dark flex items-center gap-2">
|
||||||
|
<div :class="logBg" class="h-3 w-3 rounded-full"> </div>
|
||||||
|
<div class="mt-1"> {{ dateDiffDays.toFixed(0) }} days </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4 justify-center text-[.9rem]">
|
||||||
|
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
|
||||||
|
<div class="font-medium text-lyx-text-dark">
|
||||||
|
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
|
||||||
|
</div>
|
||||||
|
</UTooltip>
|
||||||
|
<div class="text-lyx-text-darker">
|
||||||
|
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-5 justify-center">
|
||||||
|
<div @click="showProjectDetails(project._id.toString())" class="font-medium hover:text-lyx-primary cursor-pointer">
|
||||||
|
{{ project.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center mt-2">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="text-right"> Visits:</div>
|
||||||
|
<div>{{ formatNumberK(project.visits || 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="text-right"> Events:</div>
|
||||||
|
<div>{{ formatNumberK(project.events || 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="text-right"> Sessions:</div>
|
||||||
|
<div>{{ formatNumberK(project.sessions || 0) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LyxUiSeparator class="my-2" />
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<UProgress :value="project.limit_visits + project.limit_events" :max="project.limit_max"></UProgress>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-6 justify-around">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<div>
|
||||||
|
{{ usageLabel }}
|
||||||
|
</div>
|
||||||
|
<div class="text-lyx-text-dark">
|
||||||
|
{{ usagePercentLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div>
|
||||||
|
{{ usageAiLabel }}
|
||||||
|
</div>
|
||||||
|
<div class="text-lyx-text-dark">
|
||||||
|
{{ usageAiPercentLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
135
dashboard/components/admin/users/UserCard.vue
Normal file
135
dashboard/components/admin/users/UserCard.vue
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||||
|
import type { TAdminUser } from '~/server/api/admin/users';
|
||||||
|
import { getPlanFromId } from '~/shared/data/PREMIUM';
|
||||||
|
|
||||||
|
import { AdminDialogProjectDetails } from '#components';
|
||||||
|
|
||||||
|
const { openDialogEx } = useCustomDialog();
|
||||||
|
|
||||||
|
function showProjectDetails(pid: string) {
|
||||||
|
openDialogEx(AdminDialogProjectDetails, {
|
||||||
|
params: { pid }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ user: TAdminUser }>();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative max-h-[15rem]">
|
||||||
|
<div class="flex gap-4 justify-center text-[.9rem]">
|
||||||
|
<div class="font-medium text-lyx-text-dark">
|
||||||
|
{{ user.name ?? user.given_name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-lyx-text-darker">
|
||||||
|
{{ new Date(user.created_at).toLocaleDateString('it-IT') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-5 justify-center">
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ user.email }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LyxUiSeparator class="my-2" />
|
||||||
|
|
||||||
|
<div class="flex flex-col text-[.9rem]">
|
||||||
|
<div class="flex gap-2" v-for="project of user.projects">
|
||||||
|
<div class="text-lyx-text-darker">
|
||||||
|
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||||
|
</div>
|
||||||
|
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
|
||||||
|
<div class="font-medium text-lyx-text-dark">
|
||||||
|
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
|
||||||
|
</div>
|
||||||
|
</UTooltip>
|
||||||
|
|
||||||
|
<div @click="showProjectDetails(project._id.toString())"
|
||||||
|
class="ml-1 hover:text-lyx-primary cursor-pointer">
|
||||||
|
{{ project.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative">
|
||||||
|
|
||||||
|
<div class="absolute top-1 left-2 text-[.8rem] text-lyx-text-dark flex items-center gap-2">
|
||||||
|
<div :class="logBg" class="h-3 w-3 rounded-full"> </div>
|
||||||
|
<div class="mt-1"> {{ dateDiffDays.toFixed(0) }} days </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4 justify-center text-[.9rem]">
|
||||||
|
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
|
||||||
|
<div class="font-medium text-lyx-text-dark">
|
||||||
|
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
|
||||||
|
</div>
|
||||||
|
</UTooltip>
|
||||||
|
<div class="text-lyx-text-darker">
|
||||||
|
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-5 justify-center">
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ project.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center mt-2">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="text-right"> Visits:</div>
|
||||||
|
<div>{{ formatNumberK(project.visits || 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="text-right"> Events:</div>
|
||||||
|
<div>{{ formatNumberK(project.events || 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="text-right"> Sessions:</div>
|
||||||
|
<div>{{ formatNumberK(project.sessions || 0) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LyxUiSeparator class="my-2" />
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<UProgress :value="project.limit_visits + project.limit_events" :max="project.limit_max"></UProgress>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-6 justify-around">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<div>
|
||||||
|
{{ usageLabel }}
|
||||||
|
</div>
|
||||||
|
<div class="text-lyx-text-dark">
|
||||||
|
{{ usagePercentLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div>
|
||||||
|
{{ usageAiLabel }}
|
||||||
|
</div>
|
||||||
|
<div class="text-lyx-text-dark">
|
||||||
|
{{ usageAiPercentLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -5,10 +5,10 @@ const limitsInfo = await useFetch("/api/project/limits_info", {
|
|||||||
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
|
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
|
||||||
});
|
});
|
||||||
|
|
||||||
const pricingDrawer = usePricingDrawer();
|
const { showDrawer } = useDrawer();
|
||||||
|
|
||||||
function goToUpgrade() {
|
function goToUpgrade() {
|
||||||
pricingDrawer.visible.value = true;
|
showDrawer('PRICING');
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
const pricingDrawer = usePricingDrawer();
|
const { showDrawer } = useDrawer();
|
||||||
|
|
||||||
function goToUpgrade() {
|
function goToUpgrade() {
|
||||||
pricingDrawer.visible.value = true;
|
showDrawer('PRICING');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { project } = useProject()
|
const { project } = useProject()
|
||||||
@@ -20,15 +20,17 @@ const isPremium = computed(() => {
|
|||||||
<div v-if="!isPremium" class="w-full bg-[#5680f822] p-4 rounded-lg text-[.9rem] flex items-center">
|
<div v-if="!isPremium" class="w-full bg-[#5680f822] p-4 rounded-lg text-[.9rem] flex items-center">
|
||||||
<div class="flex flex-col grow">
|
<div class="flex flex-col grow">
|
||||||
<div class="poppins font-semibold text-lyx-primary">
|
<div class="poppins font-semibold text-lyx-primary">
|
||||||
Launch offer: 25% off
|
Launch offer: 25% off forever with code <span class="text-white font-bold text-[1rem]">LIT25</span> at
|
||||||
|
checkout
|
||||||
|
from Acceleration Plan and beyond.
|
||||||
</div>
|
</div>
|
||||||
<div class="poppins text-lyx-primary">
|
<!-- <div class="poppins text-lyx-primary">
|
||||||
We're offering an exclusive 25% discount forever on all plans starting from the Acceleration
|
We're offering an exclusive 25% discount forever on all plans starting from the Acceleration
|
||||||
Plan for our first 100 users who believe in our project.
|
Plan for our first 100 users who believe in our project.
|
||||||
<br>
|
<br>
|
||||||
Redeem Code: <span class="text-white font-bold text-[1rem]">LIT25</span> at checkout to
|
Redeem Code: <span class="text-white font-bold text-[1rem]">LIT25</span> at checkout to
|
||||||
claim your discount.
|
claim your discount.
|
||||||
</div>
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>
|
<LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted } from 'vue';
|
|
||||||
import DateService, { type Slice } from '@services/DateService';
|
import DateService, { type Slice } from '@services/DateService';
|
||||||
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
|
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
|
||||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||||
|
|
||||||
const errorData = ref<{ errored: boolean, text: string }>({ errored: false, text: '' })
|
const errorData = ref<{ errored: boolean, text: string }>({ errored: false, text: '' })
|
||||||
|
|
||||||
|
|
||||||
|
function createGradient(startColor: string) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
const ctx = c.getContext("2d");
|
||||||
|
let gradient: any = `${startColor}22`;
|
||||||
|
if (ctx) {
|
||||||
|
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||||
|
gradient.addColorStop(0, `${startColor}99`);
|
||||||
|
gradient.addColorStop(0.35, `${startColor}66`);
|
||||||
|
gradient.addColorStop(1, `${startColor}22`);
|
||||||
|
} else {
|
||||||
|
console.warn('Cannot get context for gradient');
|
||||||
|
}
|
||||||
|
|
||||||
|
return gradient;
|
||||||
|
}
|
||||||
|
|
||||||
const chartOptions = ref<ChartOptions<'line'>>({
|
const chartOptions = ref<ChartOptions<'line'>>({
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@@ -24,9 +40,12 @@ const chartOptions = ref<ChartOptions<'line'>>({
|
|||||||
color: '#CCCCCC22',
|
color: '#CCCCCC22',
|
||||||
// borderDash: [5, 10]
|
// borderDash: [5, 10]
|
||||||
},
|
},
|
||||||
|
beginAtZero: true,
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
ticks: { display: true },
|
ticks: { display: true },
|
||||||
|
stacked: false,
|
||||||
|
offset: false,
|
||||||
grid: {
|
grid: {
|
||||||
display: true,
|
display: true,
|
||||||
drawBorder: false,
|
drawBorder: false,
|
||||||
@@ -65,15 +84,35 @@ const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
|
|||||||
borderColor: '#5655d7',
|
borderColor: '#5655d7',
|
||||||
borderWidth: 4,
|
borderWidth: 4,
|
||||||
fill: true,
|
fill: true,
|
||||||
tension: 0.45,
|
tension: 0.35,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 10,
|
pointHoverRadius: 10,
|
||||||
hoverBackgroundColor: '#5655d7',
|
hoverBackgroundColor: '#5655d7',
|
||||||
hoverBorderColor: 'white',
|
hoverBorderColor: 'white',
|
||||||
hoverBorderWidth: 2,
|
hoverBorderWidth: 2,
|
||||||
|
segment: {
|
||||||
|
borderColor(ctx, options) {
|
||||||
|
const todayIndex = visitsData.data.value?.todayIndex;
|
||||||
|
if (!todayIndex || todayIndex == -1) return '#5655d7';
|
||||||
|
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||||
|
return '#5655d7'
|
||||||
|
},
|
||||||
|
borderDash(ctx, options) {
|
||||||
|
const todayIndex = visitsData.data.value?.todayIndex;
|
||||||
|
if (!todayIndex || todayIndex == -1) return undefined;
|
||||||
|
if (ctx.p1DataIndex == todayIndex - 1) return [3, 5];
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
backgroundColor(ctx, options) {
|
||||||
|
const todayIndex = visitsData.data.value?.todayIndex;
|
||||||
|
if (!todayIndex || todayIndex == -1) return createGradient('#5655d7');
|
||||||
|
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||||
|
return createGradient('#5655d7');
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Unique sessions',
|
label: 'Unique visitors',
|
||||||
data: [],
|
data: [],
|
||||||
backgroundColor: ['#4abde8'],
|
backgroundColor: ['#4abde8'],
|
||||||
borderColor: '#4abde8',
|
borderColor: '#4abde8',
|
||||||
@@ -81,20 +120,22 @@ const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
|
|||||||
hoverBackgroundColor: '#4abde8',
|
hoverBackgroundColor: '#4abde8',
|
||||||
hoverBorderColor: '#4abde8',
|
hoverBorderColor: '#4abde8',
|
||||||
hoverBorderWidth: 2,
|
hoverBorderWidth: 2,
|
||||||
type: 'bar'
|
type: 'bar',
|
||||||
|
// barThickness: 20,
|
||||||
|
borderSkipped: ['bottom']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Events',
|
label: 'Events',
|
||||||
data: [],
|
data: [],
|
||||||
backgroundColor: ['#fbbf24'],
|
backgroundColor: ['#fbbf24'],
|
||||||
borderColor: '#fbbf24',
|
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
hoverBackgroundColor: '#fbbf24',
|
hoverBackgroundColor: '#fbbf24',
|
||||||
hoverBorderColor: '#fbbf24',
|
hoverBorderColor: '#fbbf24',
|
||||||
hoverBorderWidth: 2,
|
hoverBorderWidth: 2,
|
||||||
type: 'bubble',
|
type: 'bubble',
|
||||||
stack: 'combined'
|
stack: 'combined',
|
||||||
},
|
borderColor: ["#fbbf24"]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,6 +147,17 @@ function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'li
|
|||||||
const { chart, tooltip } = context;
|
const { chart, tooltip } = context;
|
||||||
const tooltipEl = externalTooltipElement.value;
|
const tooltipEl = externalTooltipElement.value;
|
||||||
|
|
||||||
|
const currentIndex = tooltip.dataPoints[0].parsed.x;
|
||||||
|
|
||||||
|
const todayIndex = visitsData.data.value?.todayIndex;
|
||||||
|
if (todayIndex && todayIndex >= 0) {
|
||||||
|
if (currentIndex > todayIndex - 1) {
|
||||||
|
if (!tooltipEl) return;
|
||||||
|
return tooltipEl.style.opacity = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
currentTooltipData.value.visits = (tooltip.dataPoints.find(e => e.datasetIndex == 0)?.raw) as number;
|
currentTooltipData.value.visits = (tooltip.dataPoints.find(e => e.datasetIndex == 0)?.raw) as number;
|
||||||
currentTooltipData.value.sessions = (tooltip.dataPoints.find(e => e.datasetIndex == 1)?.raw) as number;
|
currentTooltipData.value.sessions = (tooltip.dataPoints.find(e => e.datasetIndex == 1)?.raw) as number;
|
||||||
currentTooltipData.value.events = ((tooltip.dataPoints.find(e => e.datasetIndex == 2)?.raw) as any)?.r2 as number;
|
currentTooltipData.value.events = ((tooltip.dataPoints.find(e => e.datasetIndex == 2)?.raw) as any)?.r2 as number;
|
||||||
@@ -130,13 +182,34 @@ function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'li
|
|||||||
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px';
|
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px';
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { snapshotDuration } = useSnapshot();
|
||||||
|
|
||||||
const selectLabels: { label: string, value: Slice }[] = [
|
const selectLabels: { label: string, value: Slice }[] = [
|
||||||
{ label: 'Hour', value: 'hour' },
|
{ label: 'Hour', value: 'hour' },
|
||||||
{ label: 'Day', value: 'day' },
|
{ label: 'Day', value: 'day' },
|
||||||
{ label: 'Month', value: 'month' },
|
{ label: 'Month', value: 'month' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const selectedSlice = computed(() => selectLabels[selectedLabelIndex.value].value);
|
const selectLabelsAvailable = computed<{ label: string, value: Slice, disabled: boolean }[]>(() => {
|
||||||
|
return selectLabels.map(e => {
|
||||||
|
return { ...e, disabled: !DateService.canUseSliceFromDays(snapshotDuration.value, e.value)[0] }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedSlice = computed<Slice>(() => {
|
||||||
|
console.log({ available: selectLabelsAvailable.value })
|
||||||
|
const targetValue = selectLabelsAvailable.value[selectedLabelIndex.value];
|
||||||
|
if (!targetValue) return 'day';
|
||||||
|
if (targetValue.disabled) {
|
||||||
|
selectedLabelIndex.value = selectLabelsAvailable.value.findIndex(e => !e.disabled);
|
||||||
|
}
|
||||||
|
if (selectedLabelIndex.value === -1) {
|
||||||
|
console.error('ERROR CANNOT FIND CORRECT SLICE')
|
||||||
|
return 'day';
|
||||||
|
}
|
||||||
|
return selectLabelsAvailable.value[selectedLabelIndex.value].value
|
||||||
|
});
|
||||||
|
|
||||||
const selectedLabelIndex = ref<number>(1);
|
const selectedLabelIndex = ref<number>(1);
|
||||||
const allDatesFull = ref<string[]>([]);
|
const allDatesFull = ref<string[]>([]);
|
||||||
@@ -144,13 +217,23 @@ const allDatesFull = ref<string[]>([]);
|
|||||||
|
|
||||||
function transformResponse(input: { _id: string, count: number }[]) {
|
function transformResponse(input: { _id: string, count: number }[]) {
|
||||||
const data = input.map(e => e.count);
|
const data = input.map(e => e.count);
|
||||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, selectLabels[selectedLabelIndex.value].value));
|
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice.value));
|
||||||
allDatesFull.value = input.map(e => e._id.toString());
|
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
|
||||||
return { data, labels }
|
|
||||||
|
const current = (Date.now());
|
||||||
|
//console.log(input.map(e => e._id));
|
||||||
|
//console.log(new Date(current));
|
||||||
|
|
||||||
|
const todayIndex = input.findIndex(e => new Date(e._id).getTime() >= current);
|
||||||
|
|
||||||
|
//console.log({ todayIndex })
|
||||||
|
return { data, labels, todayIndex: todayIndex + 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
function onResponseError(e: any) {
|
function onResponseError(e: any) {
|
||||||
errorData.value = { errored: true, text: e.response._data.message ?? 'Generic error' }
|
let message = e.response._data.message ?? 'Generic error';
|
||||||
|
if (message == 'internal server error') message = 'Please change slice';
|
||||||
|
errorData.value = { errored: true, text: message }
|
||||||
}
|
}
|
||||||
|
|
||||||
function onResponse(e: any) {
|
function onResponse(e: any) {
|
||||||
@@ -180,22 +263,6 @@ watch(readyToDisplay, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
function createGradient(startColor: string) {
|
|
||||||
const c = document.createElement('canvas');
|
|
||||||
const ctx = c.getContext("2d");
|
|
||||||
let gradient: any = `${startColor}22`;
|
|
||||||
if (ctx) {
|
|
||||||
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
|
||||||
gradient.addColorStop(0, `${startColor}99`);
|
|
||||||
gradient.addColorStop(0.35, `${startColor}66`);
|
|
||||||
gradient.addColorStop(1, `${startColor}22`);
|
|
||||||
} else {
|
|
||||||
console.warn('Cannot get context for gradient');
|
|
||||||
}
|
|
||||||
|
|
||||||
return gradient;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDataReady() {
|
function onDataReady() {
|
||||||
if (!visitsData.data.value) return;
|
if (!visitsData.data.value) return;
|
||||||
if (!eventsData.data.value) return;
|
if (!eventsData.data.value) return;
|
||||||
@@ -208,15 +275,32 @@ function onDataReady() {
|
|||||||
|
|
||||||
chartData.value.datasets[0].data = visitsData.data.value.data;
|
chartData.value.datasets[0].data = visitsData.data.value.data;
|
||||||
chartData.value.datasets[1].data = sessionsData.data.value.data;
|
chartData.value.datasets[1].data = sessionsData.data.value.data;
|
||||||
|
|
||||||
chartData.value.datasets[2].data = eventsData.data.value.data.map(e => {
|
chartData.value.datasets[2].data = eventsData.data.value.data.map(e => {
|
||||||
const rValue = 25 / maxEventSize * e;
|
const rValue = 20 / maxEventSize * e;
|
||||||
return { x: 0, y: maxChartY + 70, r: isNaN(rValue) ? 0 : rValue, r2: e }
|
return { x: 0, y: maxChartY + 20, r: isNaN(rValue) ? 0 : rValue, r2: e }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
chartData.value.datasets[0].backgroundColor = [createGradient('#5655d7')];
|
chartData.value.datasets[0].backgroundColor = [createGradient('#5655d7')];
|
||||||
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
|
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
|
||||||
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
|
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
|
||||||
|
|
||||||
|
(chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => {
|
||||||
|
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||||
|
if (i == todayIndex - 1) return true;
|
||||||
|
return 'bottom';
|
||||||
|
});
|
||||||
|
|
||||||
|
chartData.value.datasets[2].borderColor = eventsData.data.value.data.map((e, i) => {
|
||||||
|
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||||
|
if (i == todayIndex - 1) return '#fbbf2400';
|
||||||
|
return '#fbbf24';
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
updateChart();
|
updateChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +314,8 @@ const currentTooltipData = ref<{ visits: number, events: number, sessions: numbe
|
|||||||
const tooltipNameIndex = ['visits', 'sessions', 'events'];
|
const tooltipNameIndex = ['visits', 'sessions', 'events'];
|
||||||
|
|
||||||
function onLegendChange(dataset: any, index: number, checked: any) {
|
function onLegendChange(dataset: any, index: number, checked: any) {
|
||||||
dataset.hidden = !checked;
|
const newValue = !checked;
|
||||||
|
dataset.hidden = newValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const legendColors = ref<string[]>(['#5655d7', '#4abde8', '#fbbf24'])
|
const legendColors = ref<string[]>(['#5655d7', '#4abde8', '#fbbf24'])
|
||||||
@@ -247,15 +332,15 @@ const legendClasses = ref<string[]>([
|
|||||||
<CardTitled title="Trend chart" sub="Easily match Visits, Unique sessions and Events trends." class="w-full">
|
<CardTitled title="Trend chart" sub="Easily match Visits, Unique sessions and Events trends." class="w-full">
|
||||||
<template #header>
|
<template #header>
|
||||||
<SelectButton class="w-fit" @changeIndex="selectedLabelIndex = $event" :currentIndex="selectedLabelIndex"
|
<SelectButton class="w-fit" @changeIndex="selectedLabelIndex = $event" :currentIndex="selectedLabelIndex"
|
||||||
:options="selectLabels">
|
:options="selectLabelsAvailable">
|
||||||
</SelectButton>
|
</SelectButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="flex gap-6 w-full justify-between">
|
<div class="flex gap-6 w-full justify-between lg:flex-row flex-col">
|
||||||
<LyxUiButton type="secondary" :to="isLiveDemo ? '#' : '/analyst'" :disabled="isLiveDemo">
|
<LyxUiButton type="secondary" :to="isLiveDemo ? '#' : '/analyst'" :disabled="isLiveDemo">
|
||||||
<div class="flex items-center gap-2 px-10">
|
<div class="flex items-center gap-2 px-10">
|
||||||
<i class="far fa-sparkles text-yellow-400"></i>
|
<i class="far fa-sparkles text-yellow-600 dark:text-yellow-400"></i>
|
||||||
<div class="poppins text-lyx-text"> Ask AI </div>
|
<div class="poppins text-lyx-lightmode-text dark:text-lyx-text"> Ask AI </div>
|
||||||
</div>
|
</div>
|
||||||
</LyxUiButton>
|
</LyxUiButton>
|
||||||
<div class="flex gap-6">
|
<div class="flex gap-6">
|
||||||
@@ -273,7 +358,7 @@ const legendClasses = ref<string[]>([
|
|||||||
|
|
||||||
|
|
||||||
<div id='external-tooltip' ref="externalTooltipElement" class="z-[400]">
|
<div id='external-tooltip' ref="externalTooltipElement" class="z-[400]">
|
||||||
<LyxUiCard>
|
<LyxUiCard class="text-lyx-lightmode-text dark:text-lyx-text">
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<div> Date: </div>
|
<div> Date: </div>
|
||||||
<div v-if="currentTooltipData"> {{ currentTooltipData.date }}</div>
|
<div v-if="currentTooltipData"> {{ currentTooltipData.date }}</div>
|
||||||
@@ -286,6 +371,11 @@ const legendClasses = ref<string[]>([
|
|||||||
{{ (currentTooltipData as any)[tooltipNameIndex[index]] }}
|
{{ (currentTooltipData as any)[tooltipNameIndex[index]] }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-3 font-normal flex flex-col text-[.9rem] dark:text-lyx-text-dark text-lyx-lightmode-text-dark"
|
||||||
|
v-if="(currentTooltipData as any).sessions > (currentTooltipData as any).visits">
|
||||||
|
<div> Unique visitors is greater than visits. </div>
|
||||||
|
<div> This can indicate bot traffic. </div>
|
||||||
|
</div>
|
||||||
<!-- <div class="bg-lyx-background-lighter h-[2px] w-full my-2"> </div> -->
|
<!-- <div class="bg-lyx-background-lighter h-[2px] w-full my-2"> </div> -->
|
||||||
</LyxUiCard>
|
</LyxUiCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,23 +5,18 @@ const props = defineProps<{
|
|||||||
value: string,
|
value: string,
|
||||||
text: string,
|
text: string,
|
||||||
avg?: string,
|
avg?: string,
|
||||||
trend?: number,
|
|
||||||
color: string,
|
color: string,
|
||||||
data?: number[],
|
data?: number[],
|
||||||
labels?: string[],
|
labels?: string[],
|
||||||
ready?: boolean,
|
ready?: boolean,
|
||||||
slow?: boolean
|
slow?: boolean,
|
||||||
|
todayIndex: number,
|
||||||
|
tooltipText: string
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { snapshotDuration } = useSnapshot()
|
const { snapshotDuration } = useSnapshot()
|
||||||
|
|
||||||
const uTooltipText = computed(() => {
|
const { showDrawer } = useDrawer();
|
||||||
const duration = snapshotDuration.value;
|
|
||||||
if (!duration) return '';
|
|
||||||
if (duration > 25) return 'Monthly trend';
|
|
||||||
if (duration > 7) return 'Weekly trend';
|
|
||||||
return 'Daily trend';
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -34,37 +29,30 @@ const uTooltipText = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col grow">
|
<div class="flex flex-col grow">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="brockmann text-text-dirty text-[1.2rem] 2xl:text-[1.4rem]">
|
<div class="brockmann text-lyx-lightmode-text-dark dark:text-text-dirty text-[1.2rem] 2xl:text-[1.4rem]">
|
||||||
{{ value }}
|
{{ value }}
|
||||||
</div>
|
|
||||||
<div class="poppins text-text-sub text-[.65rem] 2xl:text-[.8rem]"> {{ avg }} </div>
|
|
||||||
</div>
|
|
||||||
<div class="poppins text-text-sub text-[.9rem] 2xl:text-[1rem]"> {{ text }} </div>
|
|
||||||
</div>
|
|
||||||
<div v-if="trend" class="flex flex-col items-center gap-1">
|
|
||||||
<UTooltip :text="uTooltipText">
|
|
||||||
<div class="flex items-center gap-3 rounded-md px-2 py-1"
|
|
||||||
:style="`background-color: ${props.color}33`">
|
|
||||||
<i :class="trend > 0 ? 'fa-arrow-trend-up' : 'fa-arrow-trend-down'"
|
|
||||||
class="far text-[.9rem] 2xl:text-[1rem]" :style="`color: ${props.color}`"></i>
|
|
||||||
<div :style="`color: ${props.color}`" class="font-semibold text-[.75rem] 2xl:text-[.875rem]">
|
|
||||||
{{ trend.toFixed(0) }} %
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="poppins text-lyx-lightmode-darker dark:text-text-sub text-[.65rem] 2xl:text-[.8rem]"> {{ avg }} </div>
|
||||||
|
</div>
|
||||||
|
<div class="poppins text-lyx-lightmode-darker dark:text-text-sub text-[.9rem] 2xl:text-[1rem]"> {{ text }} </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
<UTooltip :text="props.tooltipText">
|
||||||
|
<i class="far fa-info-circle text-lyx-text-darker text-[1rem]"></i>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
<!-- <div class="poppins text-text-sub text-[.7rem]"> Trend </div> -->
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end"
|
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end"
|
||||||
v-if="((props.data?.length || 0) > 0) && ready">
|
v-if="((props.data?.length || 0) > 0) && ready">
|
||||||
<DashboardEmbedChartCard v-if="ready" :data="props.data || []" :labels="props.labels || []"
|
<DashboardEmbedChartCard v-if="ready" :todayIndex="todayIndex" :data="props.data || []"
|
||||||
:color="props.color">
|
:labels="props.labels || []" :color="props.color">
|
||||||
</DashboardEmbedChartCard>
|
</DashboardEmbedChartCard>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!ready" class="flex justify-center items-center w-full h-full flex-col gap-2">
|
<div v-if="!ready" class="flex justify-center items-center w-full h-full flex-col gap-2">
|
||||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||||
<div v-if="props.slow"> Can be very slow on large snapshots </div>
|
<!-- <div v-if="props.slow"> Can be very slow on large timeframes </div> -->
|
||||||
</div>
|
</div>
|
||||||
</LyxUiCard>
|
</LyxUiCard>
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
icon: string,
|
|
||||||
title: string,
|
|
||||||
text: string,
|
|
||||||
sub: string,
|
|
||||||
color: string
|
|
||||||
}>();
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
|
|
||||||
<div class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full lg:w-[20rem] relative pb-2 lg:pb-4">
|
|
||||||
|
|
||||||
<!-- <div class="absolute flex items-center justify-center right-4 top-4 cursor-pointer hover:text-blue-400">
|
|
||||||
<i class="fal fa-info-circle text-[.9rem] lg:text-[1.4rem]"></i>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<div class="gap-4 flex flex-row items-center lg:items-start lg:gap-2 lg:flex-col">
|
|
||||||
<div class="w-[2.5rem] h-[2.5rem] lg:w-[3.5rem] lg:h-[3.5rem] flex items-center justify-center rounded-lg"
|
|
||||||
:style="`background: ${props.color}`">
|
|
||||||
<i :class="icon" class="text-[1rem] lg:text-[1.5rem]"></i>
|
|
||||||
</div>
|
|
||||||
<div class="text-[1rem] lg:text-[1.3rem] text-text-sub/90 poppins">
|
|
||||||
{{ title }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 items-center lg:items-end">
|
|
||||||
<div class="brockmann text-text text-[2rem] lg:text-[2.8rem] grow">
|
|
||||||
{{ text }}
|
|
||||||
</div>
|
|
||||||
<div class="poppins text-text-sub/90 text-[.9rem] lg:text-[1rem]"> {{ sub }} </div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</template>
|
|
||||||
@@ -13,8 +13,8 @@ const columns = [
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div class="w-full h-full bg-bg rounded-xl p-8">
|
<div class="w-full h-full bg-lyx-lightmode-background dark:bg-lyx-background-light rounded-xl p-8">
|
||||||
<div class="full h-full overflow-y-auto">
|
<div class="full h-full overflow-y-auto text-lyx-lightmode-text dark:text-lyx-text">
|
||||||
<UTable :columns="columns" :rows="dialogBarData" :loading="isDataLoading" v-if="dialogBarData">
|
<UTable :columns="columns" :rows="dialogBarData" :loading="isDataLoading" v-if="dialogBarData">
|
||||||
<template #count-data="{ row }">
|
<template #count-data="{ row }">
|
||||||
<div class="font-bold"> {{ formatNumberK(row.count) }} </div>
|
<div class="font-bold"> {{ formatNumberK(row.count) }} </div>
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ const props = defineProps<{
|
|||||||
data: any[],
|
data: any[],
|
||||||
labels: string[]
|
labels: string[]
|
||||||
color: string,
|
color: string,
|
||||||
|
todayIndex: number
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|
||||||
const chartOptions = ref<ChartOptions<'line'>>({
|
const chartOptions = ref<ChartOptions<'line'>>({
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@@ -48,10 +50,22 @@ const chartData = ref<ChartData<'line'>>({
|
|||||||
data: props.data,
|
data: props.data,
|
||||||
backgroundColor: [props.color + '77'],
|
backgroundColor: [props.color + '77'],
|
||||||
borderColor: props.color,
|
borderColor: props.color,
|
||||||
borderWidth: 4,
|
borderWidth: 2,
|
||||||
fill: true,
|
fill: false,
|
||||||
tension: 0.45,
|
tension: 0.35,
|
||||||
pointRadius: 0
|
pointRadius: 0,
|
||||||
|
segment: {
|
||||||
|
borderColor(ctx, options) {
|
||||||
|
if (!props.todayIndex || props.todayIndex == -1) return props.color;
|
||||||
|
if (ctx.p1DataIndex >= props.todayIndex) return props.color + '00';
|
||||||
|
return props.color;
|
||||||
|
},
|
||||||
|
borderDash(ctx, options) {
|
||||||
|
if (!props.todayIndex || props.todayIndex == -1) return undefined;
|
||||||
|
if (ctx.p1DataIndex == props.todayIndex -1) return [2, 4];
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ const chartOptions = ref<ChartOptions<'doughnut'>>({
|
|||||||
position: 'top',
|
position: 'top',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
labels: {
|
labels: {
|
||||||
color: 'white',
|
|
||||||
font: {
|
font: {
|
||||||
family: 'Poppins',
|
family: 'Poppins',
|
||||||
size: 16
|
size: 16
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const { safeSnapshotDates } = useSnapshot()
|
|||||||
|
|
||||||
function transformResponse(input: { _id: string, count: number }[]) {
|
function transformResponse(input: { _id: string, count: number }[]) {
|
||||||
const data = input.map(e => e.count);
|
const data = input.map(e => e.count);
|
||||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), props.slice));
|
||||||
return { data, labels }
|
return { data, labels }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +1,59 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
import DateService from '@services/DateService';
|
import DateService, { type Slice } from '../../shared/services/DateService';
|
||||||
import type { Slice } from '@services/DateService';
|
|
||||||
|
|
||||||
const { snapshot, safeSnapshotDates } = useSnapshot()
|
|
||||||
|
|
||||||
const snapshotDays = computed(() => {
|
const { snapshot, safeSnapshotDates, snapshotDuration } = useSnapshot()
|
||||||
const to = new Date(safeSnapshotDates.value.to).getTime();
|
|
||||||
const from = new Date(safeSnapshotDates.value.from).getTime();
|
|
||||||
return (to - from) / 1000 / 60 / 60 / 24;
|
|
||||||
});
|
|
||||||
|
|
||||||
const chartSlice = computed(() => {
|
const chartSlice = computed(() => {
|
||||||
const snapshotSizeMs = new Date(snapshot.value.to).getTime() - new Date(snapshot.value.from).getTime();
|
if (snapshotDuration.value <= 3) return 'hour' as Slice;
|
||||||
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 6) return 'hour' as Slice;
|
if (snapshotDuration.value <= 31 * 3) return 'day' 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;
|
return 'month' as Slice;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function findFirstZeroOrNullIndex(arr: (number | null)[]) {
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
if (arr.slice(i).every(val => val === 0 || val === null)) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
function transformResponse(input: { _id: string, count: number }[]) {
|
function transformResponse(input: { _id: string, count: number }[]) {
|
||||||
|
|
||||||
const data = input.map(e => e.count || 0);
|
const data = input.map(e => e.count || 0);
|
||||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, chartSlice.value));
|
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), chartSlice.value));
|
||||||
const pool = [...input.map(e => e.count || 0)];
|
|
||||||
pool.pop();
|
return { data, labels, input }
|
||||||
const avg = pool.reduce((a, e) => a + e, 0) / pool.length;
|
|
||||||
const diffPercent: number = (100 / avg * (input.at(-1)?.count || 0)) - 100;
|
|
||||||
const trend = Math.max(Math.min(diffPercent, 99), -99);
|
|
||||||
return { data, labels, trend }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const visitsData = useFetch('/api/timeline/visits', {
|
const visitsData = useFetch('/api/timeline/visits', {
|
||||||
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
|
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionsData = useFetch('/api/timeline/sessions', {
|
const sessionsData = useFetch('/api/timeline/sessions', {
|
||||||
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
|
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||||
});
|
});
|
||||||
const sessionsDurationData = useFetch('/api/timeline/sessions_duration', {
|
const sessionsDurationData = useFetch('/api/timeline/sessions_duration', {
|
||||||
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
|
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||||
});
|
});
|
||||||
const bouncingRateData = useFetch('/api/timeline/bouncing_rate', {
|
const bouncingRateData = useFetch('/api/timeline/bouncing_rate', {
|
||||||
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
|
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||||
});
|
});
|
||||||
|
|
||||||
const avgVisitDay = computed(() => {
|
const avgVisitDay = computed(() => {
|
||||||
if (!visitsData.data.value) return '0.00';
|
if (!visitsData.data.value) return '0.00';
|
||||||
const counts = visitsData.data.value.data.reduce((a, e) => e + a, 0);
|
const counts = visitsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||||
const avg = counts / Math.max(snapshotDays.value, 1);
|
const avg = counts / Math.max(snapshotDuration.value, 1);
|
||||||
return avg.toFixed(2);
|
return avg.toFixed(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
const avgSessionsDay = computed(() => {
|
const avgSessionsDay = computed(() => {
|
||||||
if (!sessionsData.data.value) return '0.00';
|
if (!sessionsData.data.value) return '0.00';
|
||||||
const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
|
const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||||
const avg = counts / Math.max(snapshotDays.value, 1);
|
const avg = counts / Math.max(snapshotDuration.value, 1);
|
||||||
return avg.toFixed(2);
|
return avg.toFixed(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,7 +75,7 @@ const avgSessionDuration = computed(() => {
|
|||||||
.filter(e => e > 0)
|
.filter(e => e > 0)
|
||||||
.reduce((a, e) => e + a, 0);
|
.reduce((a, e) => e + a, 0);
|
||||||
|
|
||||||
const avg = counts / Math.max(sessionsDurationData.data.value.data.filter(e => e > 0).length, 1);
|
const avg = counts / (Math.max(sessionsDurationData.data.value.data.filter(e => e > 0).length, 1)) / 5;
|
||||||
|
|
||||||
let hours = 0;
|
let hours = 0;
|
||||||
let minutes = 0;
|
let minutes = 0;
|
||||||
@@ -87,35 +86,43 @@ const avgSessionDuration = computed(() => {
|
|||||||
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
|
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const todayIndex = computed(() => {
|
||||||
|
if (!visitsData.data.value) return -1;
|
||||||
|
return visitsData.data.value.input.findIndex(e => new Date(e._id).getTime() > (Date.now()));
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4">
|
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 m-cards-wrap:grid-cols-4">
|
||||||
|
|
||||||
<DashboardCountCard :ready="!visitsData.pending.value" icon="far fa-earth" text="Total page visits"
|
<DashboardCountCard :todayIndex="todayIndex" :ready="!visitsData.pending.value" icon="far fa-earth"
|
||||||
:value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
text="Total visits" :value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||||
:avg="formatNumberK(avgVisitDay) + '/day'" :trend="visitsData.data.value?.trend"
|
:avg="formatNumberK(avgVisitDay) + '/day'" :data="visitsData.data.value?.data"
|
||||||
:data="visitsData.data.value?.data" :labels="visitsData.data.value?.labels" color="#5655d7">
|
tooltipText="Sum of all page views on your website." :labels="visitsData.data.value?.labels"
|
||||||
|
color="#5655d7">
|
||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|
||||||
<DashboardCountCard :ready="!bouncingRateData.pending.value" icon="far fa-chart-user" text="Bouncing rate"
|
<DashboardCountCard :todayIndex="todayIndex" :ready="!bouncingRateData.pending.value" icon="far fa-chart-user"
|
||||||
:value="avgBouncingRate" :trend="bouncingRateData.data.value?.trend" :slow="true"
|
text="Bouncing rate" :value="avgBouncingRate" :slow="true" :data="bouncingRateData.data.value?.data"
|
||||||
:data="bouncingRateData.data.value?.data" :labels="bouncingRateData.data.value?.labels" color="#1e9b86">
|
tooltipText="Percentage of users who leave quickly (lower is better)."
|
||||||
|
:labels="bouncingRateData.data.value?.labels" color="#1e9b86">
|
||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|
||||||
|
|
||||||
<DashboardCountCard :ready="!sessionsData.pending.value" icon="far fa-user" text="Unique visits sessions"
|
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsData.pending.value" icon="far fa-user"
|
||||||
|
text="Unique visitors"
|
||||||
:value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
:value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||||
:avg="formatNumberK(avgSessionsDay) + '/day'" :trend="sessionsData.data.value?.trend"
|
tooltipText="Count of distinct users visiting your website." :avg="formatNumberK(avgSessionsDay) + '/day'"
|
||||||
:data="sessionsData.data.value?.data" :labels="sessionsData.data.value?.labels" color="#4abde8">
|
:data="sessionsData.data.value?.data" :labels="sessionsData.data.value?.labels" color="#4abde8">
|
||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|
||||||
|
|
||||||
<DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer"
|
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsDurationData.pending.value" icon="far fa-timer"
|
||||||
text="Total avg session time" :value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
|
text="Visit duration" :value="avgSessionDuration" :data="sessionsDurationData.data.value?.data"
|
||||||
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
|
tooltipText="Average time users spend on your website." :labels="sessionsDurationData.data.value?.labels"
|
||||||
color="#f56523">
|
color="#f56523">
|
||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { onlineUsers, stopWatching, startWatching } = useOnlineUsers();
|
|||||||
onMounted(() => startWatching());
|
onMounted(() => startWatching());
|
||||||
onUnmounted(() => stopWatching());
|
onUnmounted(() => stopWatching());
|
||||||
|
|
||||||
|
const selfhosted = useSelfhosted();
|
||||||
|
|
||||||
const { createAlert } = useAlert();
|
const { createAlert } = useAlert();
|
||||||
|
|
||||||
@@ -17,27 +18,15 @@ function copyProjectId() {
|
|||||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function showAnomalyInfoAlert() {
|
|
||||||
createAlert('AI Anomaly Detector info',
|
|
||||||
`Anomaly detector is running. It helps you detect a spike in visits or events, it could mean an
|
|
||||||
attack or simply higher traffic due to good performance. Additionally, it can detect if someone is
|
|
||||||
stealing parts of your website and hosting a duplicate version—an unfortunately common practice.
|
|
||||||
Litlyx will notify you via email with actionable advices`,
|
|
||||||
'far fa-shield',
|
|
||||||
10000
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="w-full px-6 pb-2 lg:pb-6 font-bold flex flex-col xl:flex-row text-lg gap-2 xl:gap-12 lg:text-2xl">
|
||||||
class="w-full px-6 pb-2 lg:pb-6 font-bold text-text-sub/40 flex flex-col xl:flex-row text-lg gap-2 xl:gap-12 lg:text-2xl">
|
|
||||||
|
|
||||||
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
|
<div
|
||||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
class="flex gap-2 items-center text-lyx-lightmode-text/90 dark:text-lyx-text/90 justify-center md:justify-start">
|
||||||
|
<div class="animate-pulse w-[.8rem] h-[.8rem] bg-green-400 rounded-full"> </div>
|
||||||
<div class="poppins font-medium text-[.9rem]"> {{ onlineUsers.data }} Online users</div>
|
<div class="poppins font-medium text-[.9rem]"> {{ onlineUsers.data }} Online users</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,15 +50,16 @@ function showAnomalyInfoAlert() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div> -->
|
</div> -->
|
||||||
|
<!--
|
||||||
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
|
<div v-if="!selfhosted"
|
||||||
|
class="flex gap-2 items-center text-lyx-lightmode-text/90 dark:text-lyx-text/90 justify-center md:justify-start">
|
||||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||||
<div class="poppins font-regular text-[.9rem]"> AI Anomaly Detector </div>
|
<div class="poppins font-regular text-[.9rem]"> AI Anomaly Detector </div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<i class="far fa-info-circle text-[.9rem] hover:text-lyx-primary cursor-pointer"
|
<i class="far fa-info-circle text-[.9rem] hover:text-lyx-primary cursor-pointer"
|
||||||
@click="showAnomalyInfoAlert"></i>
|
@click="showAnomalyInfoAlert"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -10,7 +10,7 @@ const { safeSnapshotDates } = useSnapshot()
|
|||||||
|
|
||||||
function transformResponse(input: { _id: string, count: number }[]) {
|
function transformResponse(input: { _id: string, count: number }[]) {
|
||||||
const data = input.map(e => e.count);
|
const data = input.map(e => e.count);
|
||||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), props.slice));
|
||||||
return { data, labels }
|
return { data, labels }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
dashboard/components/dialog/ConfirmLogout.vue
Normal file
38
dashboard/components/dialog/ConfirmLogout.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const emit = defineEmits(['success', 'cancel'])
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal :ui="{
|
||||||
|
strategy: 'override',
|
||||||
|
overlay: {
|
||||||
|
background: 'bg-lyx-background/85'
|
||||||
|
},
|
||||||
|
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
|
||||||
|
ring: 'border-solid border-[1px] border-[#262626]'
|
||||||
|
}">
|
||||||
|
<div class="h-full flex flex-col gap-2 p-4">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
|
||||||
|
<div class="font-medium">
|
||||||
|
Are you sure to logout ?
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<LyxUiButton type="secondary" @click="emit('cancel')">
|
||||||
|
Cancel
|
||||||
|
</LyxUiButton>
|
||||||
|
<LyxUiButton @click="emit('success')" type="danger">
|
||||||
|
Confirm
|
||||||
|
</LyxUiButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</UModal>
|
||||||
|
|
||||||
|
</template>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
const { closeDialog } = useCustomDialog();
|
const { closeDialog } = useCustomDialog();
|
||||||
|
|
||||||
import { sub, format, isSameDay, type Duration } from 'date-fns'
|
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||||
|
|
||||||
const ranges = [
|
const ranges = [
|
||||||
{ label: 'Last 7 days', duration: { days: 7 } },
|
{ label: 'Last 7 days', duration: { days: 7 } },
|
||||||
@@ -36,24 +36,27 @@ function onColorChange() {
|
|||||||
|
|
||||||
const snapshotName = ref<string>("");
|
const snapshotName = ref<string>("");
|
||||||
|
|
||||||
const { updateSnapshots } = useSnapshot();
|
const { updateSnapshots, snapshot, snapshots } = useSnapshot();
|
||||||
const { createAlert } = useAlert()
|
const { createAlert } = useAlert()
|
||||||
|
|
||||||
async function confirmSnapshot() {
|
async function confirmSnapshot() {
|
||||||
await $fetch("/api/snapshot/create", {
|
await $fetch("/api/snapshot/create", {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
headers: useComputedHeaders({ useSnapshotDates: false }).value,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: snapshotName.value,
|
name: snapshotName.value,
|
||||||
color: currentColor.value,
|
color: currentColor.value,
|
||||||
from: selected.value.start.toISOString(),
|
from: startOfDay(selected.value.start),
|
||||||
to: selected.value.end.toISOString()
|
to: endOfDay(selected.value.end)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateSnapshots();
|
await updateSnapshots();
|
||||||
closeDialog();
|
closeDialog();
|
||||||
createAlert('Snapshot created','Snapshot created successfully', 'far fa-circle-check', 5000);
|
createAlert('Timeframe created', 'Timeframe created successfully', 'far fa-circle-check', 5000);
|
||||||
|
const newSnapshot = snapshots.value.at(-1);
|
||||||
|
if (newSnapshot) snapshot.value = newSnapshot;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -61,8 +64,8 @@ async function confirmSnapshot() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full flex flex-col">
|
<div class="w-full h-full flex flex-col">
|
||||||
|
|
||||||
<div class="poppins text-center">
|
<div class="poppins text-center text-lyx-lightmode-text dark:text-lyx-text">
|
||||||
Create a snapshot
|
Create a timeframe
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-10 flex items-center gap-2">
|
<div class="mt-10 flex items-center gap-2">
|
||||||
@@ -71,7 +74,7 @@ async function confirmSnapshot() {
|
|||||||
<input @input="onColorChange" ref="colorpicker" class="relative w-0 h-0 z-[-100]" type="color">
|
<input @input="onColorChange" ref="colorpicker" class="relative w-0 h-0 z-[-100]" type="color">
|
||||||
</div>
|
</div>
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
<LyxUiInput placeholder="Snapshot name" v-model="snapshotName" class="px-4 py-1 w-full"></LyxUiInput>
|
<LyxUiInput placeholder="Timeframe name" v-model="snapshotName" class="px-4 py-1 w-full"></LyxUiInput>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,7 +107,7 @@ async function confirmSnapshot() {
|
|||||||
Cancel
|
Cancel
|
||||||
</LyxUiButton>
|
</LyxUiButton>
|
||||||
<LyxUiButton @click="confirmSnapshot()" type="primary" class="w-full text-center"
|
<LyxUiButton @click="confirmSnapshot()" type="primary" class="w-full text-center"
|
||||||
:disabled="snapshotName.length == 0">
|
:disabled="snapshotName.trim().length == 0">
|
||||||
Confirm
|
Confirm
|
||||||
</LyxUiButton>
|
</LyxUiButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
84
dashboard/components/dialog/DeleteDomainData.vue
Normal file
84
dashboard/components/dialog/DeleteDomainData.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ButtonType } from '../LyxUi/Button.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['success', 'cancel'])
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
buttonType: ButtonType,
|
||||||
|
message: string,
|
||||||
|
deleteData: { isAll: boolean, visits: boolean, sessions: boolean, events: boolean, domain: string }
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isDone = ref<boolean>(false);
|
||||||
|
const canDelete = ref<boolean>(false);
|
||||||
|
|
||||||
|
async function deleteData() {
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (props.deleteData.isAll) {
|
||||||
|
await $fetch('/api/settings/delete_all', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: useComputedHeaders({ useSnapshotDates: false }).value,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await $fetch('/api/settings/delete_domain', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: useComputedHeaders({ useSnapshotDates: false, custom: { 'Content-Type': 'application/json' } }).value,
|
||||||
|
body: JSON.stringify({
|
||||||
|
domain: props.deleteData.domain,
|
||||||
|
visits: props.deleteData.visits,
|
||||||
|
sessions: props.deleteData.sessions,
|
||||||
|
events: props.deleteData.events,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
alert('Something went wrong');
|
||||||
|
console.error(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
isDone.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal :ui="{
|
||||||
|
strategy: 'override',
|
||||||
|
overlay: {
|
||||||
|
background: 'bg-lyx-background/85'
|
||||||
|
},
|
||||||
|
background: 'bg-lyx-lightmode-widget dark:bg-lyx-widget',
|
||||||
|
ring: 'border-solid border-[1px] border-[#262626]'
|
||||||
|
}">
|
||||||
|
<div class="h-full flex flex-col gap-2 p-4">
|
||||||
|
|
||||||
|
<div class="font-semibold text-[1.2rem]"> {{ isDone ? "Data Deletion Scheduled" : "Are you sure ?" }}</div>
|
||||||
|
|
||||||
|
<div v-if="!isDone">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isDone">
|
||||||
|
Your data deletion request is being processed and will be reflected in your project dashboard within a
|
||||||
|
few minutes.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grow"></div>
|
||||||
|
|
||||||
|
<div v-if="!isDone">
|
||||||
|
<UCheckbox v-model="canDelete" label="Confirm data delete"></UCheckbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isDone" class="flex justify-end gap-2">
|
||||||
|
<LyxUiButton type="secondary" @click="emit('cancel')"> Cancel </LyxUiButton>
|
||||||
|
<LyxUiButton :disabled="!canDelete" @click="canDelete ? deleteData() : () => { }" :type="buttonType">
|
||||||
|
Confirm </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isDone" class="flex justify-end w-full">
|
||||||
|
<LyxUiButton type="secondary" @click="emit('success')"> Dismiss </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
56
dashboard/components/dialog/Feedback.vue
Normal file
56
dashboard/components/dialog/Feedback.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { createAlert } = useAlert();
|
||||||
|
const { close } = useModal()
|
||||||
|
|
||||||
|
const text = ref<string>("");
|
||||||
|
|
||||||
|
async function sendFeedback() {
|
||||||
|
if (text.value.length < 5) return;
|
||||||
|
try {
|
||||||
|
|
||||||
|
const res = await $fetch('/api/feedback/add', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
useSnapshotDates: false,
|
||||||
|
custom: { 'Content-Type': 'application/json' }
|
||||||
|
}).value,
|
||||||
|
method:'POST',
|
||||||
|
body: JSON.stringify({ text: text.value })
|
||||||
|
});
|
||||||
|
|
||||||
|
createAlert('Success', 'Feedback sent successfully.', 'far fa-circle-check', 5000);
|
||||||
|
|
||||||
|
close();
|
||||||
|
} catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
createAlert('Error', 'Error sending feedback. Please try again later', 'far fa-triangle-exclamation', 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal :ui="{
|
||||||
|
strategy: 'override',
|
||||||
|
overlay: {
|
||||||
|
background: 'bg-lyx-background/85'
|
||||||
|
},
|
||||||
|
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
|
||||||
|
ring: 'border-solid border-[1px] border-[#262626]'
|
||||||
|
}">
|
||||||
|
<div class="h-full flex flex-col gap-2 p-4">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div> Share everything with us. </div>
|
||||||
|
<textarea v-model="text" placeholder="Leave your feedback"
|
||||||
|
class="p-2 w-full h-[8rem] dark:bg-lyx-widget bg-lyx-lightmode-widget-light resize-none rounded-md outline outline-[2px] outline-[#3a3f47]"></textarea>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>Need help ? Check the docs <a href="https://docs.litlyx.com" target="_blank"
|
||||||
|
class="text-blue-500">here</a> </div>
|
||||||
|
<LyxUiButton :disabled="text.length < 5" @click="sendFeedback()" type="primary"> Send </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UModal>
|
||||||
|
|
||||||
|
</template>
|
||||||
58
dashboard/components/dialog/Help.vue
Normal file
58
dashboard/components/dialog/Help.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { createAlert } = useAlert();
|
||||||
|
const { close } = useModal()
|
||||||
|
|
||||||
|
function copyEmail() {
|
||||||
|
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||||
|
navigator.clipboard.writeText('help@litlyx.com');
|
||||||
|
createAlert('Success', 'Email copied successfully.', 'far fa-circle-check', 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal :ui="{
|
||||||
|
strategy: 'override',
|
||||||
|
overlay: {
|
||||||
|
background: 'bg-lyx-background/85'
|
||||||
|
},
|
||||||
|
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
|
||||||
|
ring: 'border-solid border-[1px] border-[#262626]'
|
||||||
|
}">
|
||||||
|
<div class="h-full flex flex-col gap-2 p-4">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
|
||||||
|
<div class="font-medium">
|
||||||
|
Contact Support
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dark:text-lyx-text-dark">
|
||||||
|
Contact Support for any questions or issues you have.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dark:bg-lyx-widget-lighter bg-lyx-lightmode-widget h-[1px]"></div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="p-2 bg-lyx-lightmode-widget dark:bg-[#1c1b1b] rounded-md w-full">
|
||||||
|
<div class="w-full text-[.9rem] dark:text-[#acacac]"> help@litlyx.com </div>
|
||||||
|
</div>
|
||||||
|
<LyxUiButton type="secondary" @click="copyEmail()"> Copy </LyxUiButton>
|
||||||
|
<LyxUiButton type="secondary" to="mailto:help@litlyx.com"> Send </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dark:text-lyx-text-dark mt-2">
|
||||||
|
or text us on Discord, we will reply to you personally.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LyxUiButton to="https://discord.gg/9cQykjsmWX" target="_blank" type="secondary">
|
||||||
|
Discord Support
|
||||||
|
</LyxUiButton>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</UModal>
|
||||||
|
|
||||||
|
</template>
|
||||||
86
dashboard/components/dialog/InviteManager.vue
Normal file
86
dashboard/components/dialog/InviteManager.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import Accept_invite from '~/pages/accept_invite.vue';
|
||||||
|
|
||||||
|
|
||||||
|
const { createAlert } = useAlert();
|
||||||
|
const { close } = useModal()
|
||||||
|
|
||||||
|
const emit = defineEmits(['success', 'cancel'])
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
invites: {
|
||||||
|
project_name: string, project_id: string
|
||||||
|
}[]
|
||||||
|
}>();
|
||||||
|
|
||||||
|
async function acceptInvite(project_id: string) {
|
||||||
|
try {
|
||||||
|
await $fetch('/api/project/members/accept', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ project_id }),
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
custom: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}).value
|
||||||
|
});
|
||||||
|
emit('success');
|
||||||
|
} catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
alert('Error accepting invite');
|
||||||
|
emit('cancel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function declineInvite(project_id: string) {
|
||||||
|
try {
|
||||||
|
await $fetch('/api/project/members/decline', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ project_id }),
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
custom: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}).value
|
||||||
|
});
|
||||||
|
emit('success');
|
||||||
|
} catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
alert('Error accepting invite');
|
||||||
|
emit('cancel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal :ui="{
|
||||||
|
strategy: 'override',
|
||||||
|
overlay: {
|
||||||
|
background: 'bg-lyx-background/85'
|
||||||
|
},
|
||||||
|
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
|
||||||
|
ring: 'border-solid border-[1px] border-[#262626]'
|
||||||
|
}">
|
||||||
|
<div class="h-full flex flex-col gap-8 p-6">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6" v-for="invite of invites">
|
||||||
|
|
||||||
|
<div class="dark:text-lyx-text text-lyx-lightmode-text">
|
||||||
|
You are invited to join
|
||||||
|
<span class="font-semibold">{{ invite.project_name }}</span>.
|
||||||
|
Do you accept?
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4 w-full justify-end">
|
||||||
|
<LyxUiButton @click="declineInvite(invite.project_id)" type="secondary"> Decline </LyxUiButton>
|
||||||
|
<LyxUiButton @click="acceptInvite(invite.project_id)" type="primary"> Accept </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
118
dashboard/components/dialog/PermissionManager.vue
Normal file
118
dashboard/components/dialog/PermissionManager.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
|
||||||
|
import type { TTeamMember } from '~/shared/schema/TeamMemberSchema';
|
||||||
|
|
||||||
|
const emit = defineEmits(['success', 'cancel'])
|
||||||
|
|
||||||
|
const props = defineProps<{ member_id: string }>();
|
||||||
|
|
||||||
|
const { domainList, domain, setActiveDomain, refreshDomains, refreshingDomains } = useDomain();
|
||||||
|
|
||||||
|
const { data: member } = useFetch<TTeamMember>(`/api/project/members/get?member_id=${props.member_id}`, {
|
||||||
|
headers: useComputedHeaders({})
|
||||||
|
})
|
||||||
|
|
||||||
|
const { createAlert } = useAlert()
|
||||||
|
|
||||||
|
async function save(member_id: string) {
|
||||||
|
if (!member.value) return;
|
||||||
|
const res = await $fetch('/api/project/members/edit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: useComputedHeaders({ custom: { 'Content-Type': 'application/json' } }).value,
|
||||||
|
body: JSON.stringify({
|
||||||
|
member_id,
|
||||||
|
webAnalytics: member.value.permission.webAnalytics,
|
||||||
|
events: member.value.permission.events,
|
||||||
|
ai: member.value.permission.ai,
|
||||||
|
domains: member.value.permission.domains
|
||||||
|
})
|
||||||
|
});
|
||||||
|
createAlert('Saved', 'Permission saved successfully', 'fas fa-check', 2500);
|
||||||
|
emit('success')
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal :ui="{
|
||||||
|
strategy: 'override',
|
||||||
|
overlay: {
|
||||||
|
background: 'bg-lyx-background/85'
|
||||||
|
},
|
||||||
|
background: 'bg-lyx-lightmode-widget dark:bg-lyx-widget',
|
||||||
|
ring: 'border-solid border-[1px] border-[#262626]'
|
||||||
|
}">
|
||||||
|
<div class="p-8">
|
||||||
|
<div v-if="member" class="manage flex flex-col gap-4">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="poppins text-[1.1rem]"> Manage permissions </div>
|
||||||
|
<div class="poppins text-[.9rem] dark:text-lyx-text-dark"> Choose what this member can do on this project. </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LyxUiSeparator></LyxUiSeparator>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1"> Select what domain is allowed to see: </div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<USelectMenu v-model="member.permission.domains" :options="domainList" multiple
|
||||||
|
value-attribute="_id">
|
||||||
|
<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._id }} </div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #label="e">
|
||||||
|
<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>
|
||||||
|
{{
|
||||||
|
member.permission.domains.length > 2 ?
|
||||||
|
`${member.permission.domains.length} domains` :
|
||||||
|
(member.permission.domains.map(e => e).join(' & ') || 'No domains')
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</USelectMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UCheckbox v-model="member.permission.webAnalytics"></UCheckbox>
|
||||||
|
<div> Allow web analytics page </div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UCheckbox v-model="member.permission.events"></UCheckbox>
|
||||||
|
<div> Allow events page </div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UCheckbox v-model="member.permission.ai"></UCheckbox>
|
||||||
|
<div> Allow to use AI data analyst </div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex gap-2 justify-end mt-8">
|
||||||
|
<LyxUiButton class="!w-[6rem] text-center" type="secondary" @click="emit('cancel')"> Cancel </LyxUiButton>
|
||||||
|
<LyxUiButton class="!w-[6rem] text-center" v-if="member?.permission" @click="save(member._id.toString())" type="primary">
|
||||||
|
Save
|
||||||
|
</LyxUiButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</UModal>
|
||||||
|
|
||||||
|
</template>
|
||||||
13
dashboard/components/drawer/Docs.vue
Normal file
13
dashboard/components/drawer/Docs.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(evt: 'onCloseClick'): void
|
||||||
|
}>();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<iframe class="w-full h-full" src="https://docs.litlyx.com/introduction" frameborder="0"></iframe>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
20
dashboard/components/drawer/Generic.vue
Normal file
20
dashboard/components/drawer/Generic.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const emits = defineEmits<{ (evt: 'onCloseClick'): void }>();
|
||||||
|
|
||||||
|
const { drawerComponent } = useDrawer();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-8 overflow-y-auto">
|
||||||
|
|
||||||
|
<div @click="$emit('onCloseClick')"
|
||||||
|
class="cursor-pointer fixed top-4 right-4 rounded-full dark:bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
|
||||||
|
<i class="fas fa-close text-[1.6rem]"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Component v-if="drawerComponent" :is="drawerComponent"></Component>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { PricingCardProp } from './PricingCardGeneric.vue';
|
import type { PricingCardProp } from '../pricing/PricingCardGeneric.vue';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { data: planData, refresh: refreshPlanData } = useFetch('/api/project/plan', {
|
const { data: planData, refresh: refreshPlanData } = useFetch('/api/project/plan', {
|
||||||
@@ -182,35 +181,11 @@ function getPricingsData() {
|
|||||||
return { freePricing, customPricing, slidePricings }
|
return { freePricing, customPricing, slidePricings }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { projectId } = useProject();
|
|
||||||
|
|
||||||
const emits = defineEmits<{
|
|
||||||
(evt: 'onCloseClick'): void
|
|
||||||
}>();
|
|
||||||
|
|
||||||
async function onLifetimeUpgradeClick() {
|
|
||||||
const res = await $fetch<string>(`/api/pay/create-onetime`, {
|
|
||||||
...signHeaders({
|
|
||||||
'content-type': 'application/json',
|
|
||||||
'x-pid': projectId.value ?? ''
|
|
||||||
}),
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ planId: 2001 })
|
|
||||||
})
|
|
||||||
if (!res) alert('Something went wrong');
|
|
||||||
window.open(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-8 overflow-y-auto">
|
<div class="p-8 overflow-y-auto">
|
||||||
|
|
||||||
<div @click="$emit('onCloseClick')"
|
|
||||||
class="cursor-pointer fixed top-4 right-4 rounded-full bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
|
|
||||||
<i class="fas fa-close text-[1.6rem]"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-8 mt-10 h-max xl:flex-row flex-col">
|
<div class="flex gap-8 mt-10 h-max xl:flex-row flex-col">
|
||||||
<PricingCardGeneric class="flex-1" :datas="getPricingsData().freePricing"></PricingCardGeneric>
|
<PricingCardGeneric class="flex-1" :datas="getPricingsData().freePricing"></PricingCardGeneric>
|
||||||
<PricingCardGeneric class="flex-1" :datas="getPricingsData().slidePricings" :default-index="2">
|
<PricingCardGeneric class="flex-1" :datas="getPricingsData().slidePricings" :default-index="2">
|
||||||
@@ -218,71 +193,31 @@ async function onLifetimeUpgradeClick() {
|
|||||||
<PricingCardGeneric class="flex-1" :datas="getPricingsData().customPricing"></PricingCardGeneric>
|
<PricingCardGeneric class="flex-1" :datas="getPricingsData().customPricing"></PricingCardGeneric>
|
||||||
</div>
|
</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 justify-between items-center mt-10 flex-col xl:flex-row">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="poppins text-[1.1rem] text-lyx-lightmode-text dark:text-yellow-400 mb-2">
|
||||||
|
*Plan upgrades are applicable exclusively to this project(workspace).
|
||||||
|
</div>
|
||||||
<div class="poppins text-[2rem] font-semibold">
|
<div class="poppins text-[2rem] font-semibold">
|
||||||
Do you need help ?
|
Do you need help ?
|
||||||
</div>
|
</div>
|
||||||
<div class="poppins text-[1.2rem] text-text/90">
|
<div class="poppins text-[1.2rem]">
|
||||||
We respond in max. 1-2 days
|
We respond in max. 1-2 days
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="rounded-lg px-10 py-3 bg-[#151515]">
|
<LyxUiButton type="secondary">
|
||||||
<a href="mailto:help@litlyx.com" class="poppins text-[1.3rem]">
|
<a href="mailto:help@litlyx.com" class="poppins text-[1.1rem]">
|
||||||
help@litlyx.com
|
help@litlyx.com
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</LyxUiButton>
|
||||||
|
<LyxUiButton type="secondary">
|
||||||
|
<a href="https://discord.com/invite/9cQykjsmWX" class="poppins text-[1.1rem]">
|
||||||
|
Discord support
|
||||||
|
</a>
|
||||||
|
</LyxUiButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -111,7 +111,7 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const eventsData = useFetch(`/api/data/events`, {
|
const eventsData = useFetch(`/api/data/events`, {
|
||||||
headers: useComputedHeaders(), lazy: true, immediate: false
|
headers: useComputedHeaders(), lazy: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const enabledEvents = ref<string[]>([]);
|
const enabledEvents = ref<string[]>([]);
|
||||||
@@ -140,7 +140,7 @@ async function onEventCheck(eventName: string) {
|
|||||||
<template>
|
<template>
|
||||||
<CardTitled title="Funnel"
|
<CardTitled title="Funnel"
|
||||||
sub="Monitor and analyze the actions your users are performing on your platform to gain insights into their behavior and optimize the user experience">
|
sub="Monitor and analyze the actions your users are performing on your platform to gain insights into their behavior and optimize the user experience">
|
||||||
<div class="flex gap-2 justify-between">
|
<div class="flex gap-2 justify-between lg:flex-row flex-col">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div class="min-w-[20rem] text-lyx-text-darker">
|
<div class="min-w-[20rem] text-lyx-text-darker">
|
||||||
Select two or more events
|
Select two or more events
|
||||||
|
|||||||
@@ -81,11 +81,11 @@ const canSearch = computed(() => {
|
|||||||
<div class="flex-[2]">
|
<div class="flex-[2]">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<USelectMenu :uiMenu="{
|
<USelectMenu :uiMenu="{
|
||||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
|
||||||
base: '!bg-lyx-widget',
|
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
|
||||||
option: {
|
option: {
|
||||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||||
active: '!bg-lyx-widget-lighter'
|
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
|
||||||
}
|
}
|
||||||
}" searchable searchable-placeholder="Search an event..." class="w-full"
|
}" searchable searchable-placeholder="Search an event..." class="w-full"
|
||||||
placeholder="Select an event" :options="eventNames.data.value || []"
|
placeholder="Select an event" :options="eventNames.data.value || []"
|
||||||
@@ -93,24 +93,24 @@ const canSearch = computed(() => {
|
|||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
|
|
||||||
<USelectMenu :uiMenu="{
|
<USelectMenu :uiMenu="{
|
||||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
|
||||||
base: '!bg-lyx-widget',
|
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
|
||||||
option: {
|
option: {
|
||||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||||
active: '!bg-lyx-widget-lighter'
|
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
|
||||||
}
|
}
|
||||||
}" searchable searchable-placeholder="Search a field..." class="w-full"
|
}" searchable searchable-placeholder="Search a field..." class="w-full"
|
||||||
placeholder="Select a field" :options="metadataFields" v-model="selectedMetadataField">
|
placeholder="Select a field" :options="metadataFields" v-model="selectedMetadataField">
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-lyx-text-darker poppins mt-4 flex items-center gap-4">
|
<div class="text-lyx-text-darker poppins mt-4 flex items-center gap-4 lg:flex-row flex-col">
|
||||||
<div class="w-[10rem]">
|
<div class="w-[10rem]">
|
||||||
Search results: {{ metadataFieldGroupedFiltered.length }}
|
Search results: {{ metadataFieldGroupedFiltered.length }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="canSearch" class="h-full flex items-center text-[1.2rem]">
|
<div v-if="canSearch" class="h-full flex items-center text-[1.2rem]">
|
||||||
|
|
||||||
<div class="bg-lyx-widget-light flex items-center rounded-md pl-4">
|
<div class="bg-lyx-lightmode-widget dark:bg-lyx-widget-light flex items-center rounded-md pl-4">
|
||||||
<div><i class="far fa-search"></i></div>
|
<div><i class="far fa-search"></i></div>
|
||||||
<input class="bg-transparent px-4 py-2 text-[1rem] outline-none" type="text"
|
<input class="bg-transparent px-4 py-2 text-[1rem] outline-none" type="text"
|
||||||
placeholder="Filter by metadata name" v-model="currentSearchText">
|
placeholder="Filter by metadata name" v-model="currentSearchText">
|
||||||
@@ -119,13 +119,13 @@ const canSearch = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 mt-4">
|
<div class="flex flex-wrap gap-2 lg:mt-4 mt-10">
|
||||||
|
|
||||||
<div class="bg-lyx-widget-light text-lyx-text-dark px-3 py-2 rounded-md w-fit"
|
<div class="bg-lyx-lightmode-widget dark:bg-lyx-widget-light text-lyx-lightmode-text dark:text-lyx-text-dark px-3 py-2 rounded-md w-fit"
|
||||||
v-for="item of metadataFieldGroupedFiltered">
|
v-for="item of metadataFieldGroupedFiltered">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2 items-center">
|
||||||
<div> {{ item._id || 'OLD_EVENTS' }} </div>
|
<div> {{ item._id || 'OLD_EVENTS' }} </div>
|
||||||
<div> {{ item.count }} </div>
|
<div class="px-1"> {{ item.count }} </div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
|
|
||||||
import DateService, { type Slice } from '@services/DateService';
|
import { type Slice } from '@services/DateService';
|
||||||
|
|
||||||
const props = defineProps<{ slice: Slice }>();
|
const props = defineProps<{ slice: Slice }>();
|
||||||
const slice = computed(() => props.slice);
|
const slice = computed(() => props.slice);
|
||||||
@@ -10,46 +10,23 @@ const { safeSnapshotDates } = useSnapshot()
|
|||||||
|
|
||||||
function transformResponse(input: { _id: string, name: string, count: number }[]) {
|
function transformResponse(input: { _id: string, name: string, count: number }[]) {
|
||||||
|
|
||||||
const fixed = fixMetrics({
|
const fixed = fixMetrics(
|
||||||
data: input,
|
{ data: input, from: input[0]._id, to: safeSnapshotDates.value.to },
|
||||||
from: input[0]._id,
|
slice.value,
|
||||||
to: safeSnapshotDates.value.to
|
{ advanced: true, advancedGroupKey: 'name' }
|
||||||
}, slice.value, {
|
);
|
||||||
advanced: true,
|
|
||||||
advancedGroupKey: 'name'
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsedDatasets: any[] = [];
|
const parsedDatasets: any[] = [];
|
||||||
|
|
||||||
const colors = [
|
const colors = [
|
||||||
"#5655d0",
|
"#5655d0", "#6bbbe3", "#a6d5cb", "#fae0b9", "#f28e8e",
|
||||||
"#6bbbe3",
|
"#e3a7e4", "#c4a8e1", "#8cc1d8", "#f9c2cd", "#b4e3b2",
|
||||||
"#a6d5cb",
|
"#ffdfba", "#e9c3b5", "#d5b8d6", "#add7f6", "#ffd1dc",
|
||||||
"#fae0b9",
|
"#ffe7a1", "#a8e6cf", "#d4a5a5", "#f3d6e4", "#c3aed6"
|
||||||
"#f28e8e",
|
|
||||||
"#e3a7e4",
|
|
||||||
"#c4a8e1",
|
|
||||||
"#8cc1d8",
|
|
||||||
"#f9c2cd",
|
|
||||||
"#b4e3b2",
|
|
||||||
"#ffdfba",
|
|
||||||
"#e9c3b5",
|
|
||||||
"#d5b8d6",
|
|
||||||
"#add7f6",
|
|
||||||
"#ffd1dc",
|
|
||||||
"#ffe7a1",
|
|
||||||
"#a8e6cf",
|
|
||||||
"#d4a5a5",
|
|
||||||
"#f3d6e4",
|
|
||||||
"#c3aed6"
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (let i = 0; i < fixed.allKeys.length; i++) {
|
for (let i = 0; i < fixed.allKeys.length; i++) {
|
||||||
const line: any = {
|
const line: any = { data: [], color: colors[i] || '#FF0000', label: fixed.allKeys[i] };
|
||||||
data: [],
|
|
||||||
color: colors[i] || '#FF0000',
|
|
||||||
label: fixed.allKeys[i]
|
|
||||||
};
|
|
||||||
parsedDatasets.push(line)
|
parsedDatasets.push(line)
|
||||||
fixed.data.forEach((e: { key: string, value: number }[]) => {
|
fixed.data.forEach((e: { key: string, value: number }[]) => {
|
||||||
const target = e.find(e => e.key == fixed.allKeys[i]);
|
const target = e.find(e => e.key == fixed.allKeys[i]);
|
||||||
@@ -57,11 +34,7 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
|
|||||||
line.data.push(target.value);
|
line.data.push(target.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return { datasets: parsedDatasets, labels: fixed.labels }
|
||||||
return {
|
|
||||||
datasets: parsedDatasets,
|
|
||||||
labels: fixed.labels
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorData = ref<{ errored: boolean, text: string }>({
|
const errorData = ref<{ errored: boolean, text: string }>({
|
||||||
@@ -83,12 +56,11 @@ function onResponse(e: any) {
|
|||||||
const eventsStackedData = useFetch(`/api/timeline/events_stacked`, {
|
const eventsStackedData = useFetch(`/api/timeline/events_stacked`, {
|
||||||
lazy: true, immediate: false,
|
lazy: true, immediate: false,
|
||||||
transform: transformResponse,
|
transform: transformResponse,
|
||||||
headers: useComputedHeaders({slice}),
|
headers: useComputedHeaders({ slice }),
|
||||||
onResponseError,
|
onResponseError,
|
||||||
onResponse
|
onResponse
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
eventsStackedData.execute();
|
eventsStackedData.execute();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,12 +46,12 @@ async function analyzeEvent() {
|
|||||||
|
|
||||||
<div class="py-2 flex items-center gap-3">
|
<div class="py-2 flex items-center gap-3">
|
||||||
<USelectMenu :uiMenu="{
|
<USelectMenu :uiMenu="{
|
||||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
|
||||||
base: '!bg-lyx-widget',
|
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
|
||||||
option: {
|
option: {
|
||||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||||
active: '!bg-lyx-widget-lighter'
|
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
|
||||||
}
|
}
|
||||||
}" searchable searchable-placeholder="Search an event..." class="w-full" placeholder="Select an event"
|
}" searchable searchable-placeholder="Search an event..." class="w-full" placeholder="Select an event"
|
||||||
:options="eventNames.data.value || []" v-model="selectedEventName">
|
:options="eventNames.data.value || []" v-model="selectedEventName">
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
@@ -62,10 +62,16 @@ async function analyzeEvent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="analyzing"> Analyzing... </div>
|
<div v-if="analyzing">
|
||||||
|
<div
|
||||||
|
class="backdrop-blur-[1px] z-[20] w-full h-full flex items-center justify-center font-bold rockmann">
|
||||||
|
<i
|
||||||
|
class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2" v-if="userFlowData">
|
<div class="flex flex-col gap-2" v-if="userFlowData">
|
||||||
<div class="flex gap-4 items-center bg-bg py-2 px-2 bg-lyx-widget-light rounded-lg"
|
<div class="flex gap-4 items-center bg-bg py-2 px-2 bg-lyx-lightmode-widget dark:bg-lyx-widget-light rounded-lg"
|
||||||
v-for="(count, referrer) in userFlowData">
|
v-for="(count, referrer) in userFlowData">
|
||||||
<div class="w-5 h-5 flex items-center justify-center">
|
<div class="w-5 h-5 flex items-center justify-center">
|
||||||
<img :src="`https://s2.googleusercontent.com/s2/favicons?domain=${referrer}&sz=64`"
|
<img :src="`https://s2.googleusercontent.com/s2/favicons?domain=${referrer}&sz=64`"
|
||||||
|
|||||||
@@ -22,86 +22,27 @@ const widthHeight = computed(() => {
|
|||||||
return 9 + props.size * props.spacing;
|
return 9 + props.size * props.spacing;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const colorMode = useColorMode();
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-fit h-fit">
|
<div class="w-fit h-fit">
|
||||||
|
|
||||||
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" :width="widthHeight" :height="widthHeight" :style="`opacity: ${props.opacity};`"
|
<svg xmlns="http://www.w3.org/2000/svg" :width="widthHeight" :height="widthHeight" :style="`opacity: ${props.opacity};`"
|
||||||
fill="none">
|
fill="none">
|
||||||
|
|
||||||
<template v-for="(p, x) of sizeArr">
|
<template v-for="(p, x) of sizeArr">
|
||||||
<template v-for="(p, y) of sizeArr">
|
<template v-for="(p, y) of sizeArr">
|
||||||
<circle :cx="9 + (spacing * x)" :cy="9 + (spacing * y)" r="1" fill="#fff"
|
<circle :cx="9 + (spacing * x)" :cy="9 + (spacing * y)" r="1" :fill="colorMode.value === 'light' ? '#000' : '#FFF'"
|
||||||
:fill-opacity="calculateOpacity(x, y)" />
|
:fill-opacity="calculateOpacity(x, y)" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- <circle cx="27" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="45" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="63" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="81" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="99" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="117" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="135" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="9" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="27" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="45" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="63" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="81" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="99" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="117" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="135" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="9" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="27" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="45" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="63" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="81" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="99" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="117" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="135" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="9" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="27" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="45" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="63" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="81" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="99" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="117" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="135" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="9" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="27" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="45" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="63" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="81" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="99" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="117" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="135" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="9" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="27" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="45" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="63" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="81" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="99" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="117" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="135" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="9" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="27" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="45" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="63" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="81" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="99" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="117" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="135" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="9" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="27" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="45" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="63" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="81" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="99" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="117" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
<circle cx="135" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
|
||||||
-->
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
import { sub, isSameDay, type Duration } from 'date-fns'
|
|
||||||
|
|
||||||
type ChartType = 'bar' | 'line';
|
|
||||||
const chartTypeOptions: { value: ChartType, label: string }[] = [
|
|
||||||
{ value: 'bar', label: 'Bar chart' },
|
|
||||||
{ value: 'line', label: 'Line chart' },
|
|
||||||
]
|
|
||||||
|
|
||||||
type yAxisMode = 'count';
|
|
||||||
const yAxisModeOptions: { value: yAxisMode, label: string }[] = [
|
|
||||||
{ value: 'count', label: 'Count fields' },
|
|
||||||
]
|
|
||||||
|
|
||||||
type Slice = 'day' | 'month';
|
|
||||||
const sliceOptions: Slice[] = ['day', 'month'];
|
|
||||||
|
|
||||||
const chartType = ref<ChartType>('line');
|
|
||||||
const tableName = ref<string>('');
|
|
||||||
const xAxis = ref<string>('');
|
|
||||||
const yAxisMode = ref<yAxisMode>('count');
|
|
||||||
const slice = ref<Slice>('day');
|
|
||||||
const visualizationName = ref<string>('');
|
|
||||||
|
|
||||||
|
|
||||||
const ranges = [
|
|
||||||
{ label: 'Last 7 days', duration: { days: 7 } },
|
|
||||||
{ label: 'Last 14 days', duration: { days: 14 } },
|
|
||||||
{ label: 'Last 30 days', duration: { days: 30 } },
|
|
||||||
{ label: 'Last 3 months', duration: { months: 3 } },
|
|
||||||
{ label: 'Last 6 months', duration: { months: 6 } },
|
|
||||||
{ label: 'Last year', duration: { years: 1 } }
|
|
||||||
]
|
|
||||||
const timeframe = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
|
|
||||||
|
|
||||||
function isRangeSelected(duration: Duration) {
|
|
||||||
return isSameDay(timeframe.value.start, sub(new Date(), duration)) && isSameDay(timeframe.value.end, new Date())
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectRange(duration: Duration) {
|
|
||||||
timeframe.value = { start: sub(new Date(), duration), end: new Date() }
|
|
||||||
}
|
|
||||||
|
|
||||||
const { createAlert } = useAlert();
|
|
||||||
const { closeDialog } = useCustomDialog();
|
|
||||||
const activeProjectId = useActiveProjectId();
|
|
||||||
|
|
||||||
const { integrationsCredentials,testConnection } = useSupabase();
|
|
||||||
|
|
||||||
async function generate() {
|
|
||||||
const credentials = integrationsCredentials.data.value;
|
|
||||||
if (!credentials?.supabase) return createAlert('Credentials not found', 'Please add supabase credentials on the integration page', 'far fa-error', 5000);
|
|
||||||
const connectionStatus = await testConnection();
|
|
||||||
if (!connectionStatus) return createAlert('Invalid supabase credentials', 'Please check your supabase credentials on the integration page', 'far fa-error', 5000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const creation = await $fetch('/api/integrations/supabase/add', {
|
|
||||||
...signHeaders({
|
|
||||||
'x-pid': activeProjectId.data.value || '',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}),
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: visualizationName.value,
|
|
||||||
chart_type: chartType.value,
|
|
||||||
table_name: tableName.value,
|
|
||||||
xField: xAxis.value,
|
|
||||||
yMode: yAxisMode.value,
|
|
||||||
from: timeframe.value.start,
|
|
||||||
to: timeframe.value.end,
|
|
||||||
slice: slice.value
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
createAlert('Integration generated', 'Integration generated successfully', 'far fa-check-circle', 5000);
|
|
||||||
closeDialog();
|
|
||||||
} catch (ex: any) {
|
|
||||||
createAlert('Error generating integrations', ex.response._data.message.toString(), 'far fa-error', 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div>
|
|
||||||
<div> Visualization name </div>
|
|
||||||
<div>
|
|
||||||
<LyxUiInput class="w-full px-2 py-1" v-model="visualizationName"></LyxUiInput>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div> Chart type </div>
|
|
||||||
<USelect v-model="chartType" :options="chartTypeOptions" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div> Table name </div>
|
|
||||||
<div>
|
|
||||||
<LyxUiInput class="w-full px-2 py-1" v-model="tableName"></LyxUiInput>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div> X axis field </div>
|
|
||||||
<div>
|
|
||||||
<LyxUiInput class="w-full px-2 py-1" v-model="xAxis"></LyxUiInput>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div> Y axis mode </div>
|
|
||||||
<div>
|
|
||||||
<USelect v-model="yAxisMode" :options="yAxisModeOptions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div> Timeframe </div>
|
|
||||||
<div>
|
|
||||||
<UPopover class="w-full" :popper="{ placement: 'bottom' }">
|
|
||||||
<UButton class="w-full" color="primary" variant="solid">
|
|
||||||
<div class="flex items-center justify-center w-full gap-2">
|
|
||||||
<i class="i-heroicons-calendar-days-20-solid"></i>
|
|
||||||
{{ timeframe.start.toLocaleDateString() }} - {{ timeframe.end.toLocaleDateString() }}
|
|
||||||
</div>
|
|
||||||
</UButton>
|
|
||||||
<template #panel="{ close }">
|
|
||||||
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
|
|
||||||
<div class="hidden sm:flex flex-col py-4">
|
|
||||||
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
|
|
||||||
variant="ghost" class="rounded-none px-6"
|
|
||||||
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
|
|
||||||
truncate @click="selectRange(range.duration)" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DatePicker v-model="timeframe" @close="close" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UPopover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div> View mode </div>
|
|
||||||
<div>
|
|
||||||
<USelect v-model="slice" :options="sliceOptions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LyxUiButton type="primary" @click="generate()">
|
|
||||||
Generate
|
|
||||||
</LyxUiButton>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { TSupabaseIntegration } from '@schema/integrations/SupabaseIntegrationSchema';
|
|
||||||
import type { ChartData, ChartOptions } from 'chart.js';
|
|
||||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
|
||||||
|
|
||||||
const props = defineProps<{ integration_id: string }>();
|
|
||||||
|
|
||||||
const activeProjectId = useActiveProjectId();
|
|
||||||
|
|
||||||
const supabaseData = ref<{ labels: string[], data: number[] }>();
|
|
||||||
const supabaseError = ref<string | undefined>(undefined);
|
|
||||||
const supabaseFetching = ref<boolean>(false);
|
|
||||||
|
|
||||||
const { getRemoteData } = useSupabase();
|
|
||||||
|
|
||||||
function createGradient() {
|
|
||||||
|
|
||||||
const c = document.createElement('canvas');
|
|
||||||
const ctx = c.getContext("2d");
|
|
||||||
let gradient: any = `#34B67C22`;
|
|
||||||
if (ctx) {
|
|
||||||
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
|
||||||
gradient.addColorStop(0, `#34B67C99`);
|
|
||||||
gradient.addColorStop(0.35, `#34B67C66`);
|
|
||||||
gradient.addColorStop(1, `#34B67C22`);
|
|
||||||
} else {
|
|
||||||
console.warn('Cannot get context for gradient');
|
|
||||||
}
|
|
||||||
|
|
||||||
chartData.value.datasets[0].backgroundColor = [gradient];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const chartOptions = ref<ChartOptions<'line'>>({
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
interaction: {
|
|
||||||
intersect: false,
|
|
||||||
mode: 'nearest',
|
|
||||||
axis: 'x',
|
|
||||||
includeInvisible: true
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
ticks: { display: true },
|
|
||||||
grid: {
|
|
||||||
display: true,
|
|
||||||
drawBorder: false,
|
|
||||||
color: '#CCCCCC22',
|
|
||||||
// borderDash: [5, 10]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
ticks: { display: true },
|
|
||||||
grid: {
|
|
||||||
display: true,
|
|
||||||
drawBorder: false,
|
|
||||||
color: '#CCCCCC22',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: { display: false },
|
|
||||||
title: { display: false },
|
|
||||||
tooltip: {
|
|
||||||
enabled: true,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
||||||
titleFont: { size: 16, weight: 'bold' },
|
|
||||||
bodyFont: { size: 14 },
|
|
||||||
padding: 10,
|
|
||||||
cornerRadius: 4,
|
|
||||||
boxPadding: 10,
|
|
||||||
caretPadding: 20,
|
|
||||||
yAlign: 'bottom',
|
|
||||||
xAlign: 'center',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const chartData = ref<ChartData<'line'>>({
|
|
||||||
labels: [],
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
data: [],
|
|
||||||
backgroundColor: ['#34B67C' + '77'],
|
|
||||||
borderColor: '#34B67C',
|
|
||||||
borderWidth: 4,
|
|
||||||
fill: true,
|
|
||||||
tension: 0.45,
|
|
||||||
pointRadius: 0,
|
|
||||||
pointHoverRadius: 10,
|
|
||||||
hoverBackgroundColor: '#34B67C',
|
|
||||||
hoverBorderColor: 'white',
|
|
||||||
hoverBorderWidth: 2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
|
|
||||||
supabaseFetching.value = true;
|
|
||||||
supabaseError.value = undefined;
|
|
||||||
|
|
||||||
const integrationData = await $fetch<TSupabaseIntegration>('/api/integrations/supabase/get', {
|
|
||||||
...signHeaders({
|
|
||||||
'x-pid': activeProjectId.data.value || '',
|
|
||||||
'x-integration': props.integration_id
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!integrationData) {
|
|
||||||
supabaseError.value = 'Cannot get integration data';
|
|
||||||
supabaseFetching.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await getRemoteData(
|
|
||||||
integrationData.table_name,
|
|
||||||
integrationData.xField,
|
|
||||||
integrationData.yMode,
|
|
||||||
integrationData.from.toString(),
|
|
||||||
integrationData.to.toString(),
|
|
||||||
integrationData.slice,
|
|
||||||
);
|
|
||||||
if (data.error) {
|
|
||||||
supabaseError.value = data.error;
|
|
||||||
supabaseFetching.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
supabaseFetching.value = false;
|
|
||||||
supabaseData.value = data.result;
|
|
||||||
|
|
||||||
chartData.value.labels = data.result?.labels || [];
|
|
||||||
chartData.value.datasets[0].data = data.result?.data || [];
|
|
||||||
|
|
||||||
console.log(data.result);
|
|
||||||
createGradient();
|
|
||||||
} catch (ex: any) {
|
|
||||||
if (!ex.response._data) {
|
|
||||||
supabaseError.value = ex.message.toString();
|
|
||||||
supabaseFetching.value = false;
|
|
||||||
} else {
|
|
||||||
supabaseError.value = ex.response._data.message.toString();
|
|
||||||
supabaseFetching.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="!supabaseFetching">
|
|
||||||
<div v-if="!supabaseError">
|
|
||||||
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
|
||||||
</div>
|
|
||||||
<div v-if="supabaseError"> {{ supabaseError }} </div>
|
|
||||||
</div>
|
|
||||||
<div v-if="supabaseFetching">
|
|
||||||
Getting remote data...
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
69
dashboard/components/layout/TopNavigation.vue
Normal file
69
dashboard/components/layout/TopNavigation.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import { DialogFeedback, DialogHelp } from '#components';
|
||||||
|
|
||||||
|
const modal = useModal();
|
||||||
|
const selfhosted = useSelfhosted();
|
||||||
|
|
||||||
|
const { domain } = useDomain();
|
||||||
|
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
const isDark = computed({
|
||||||
|
get() {
|
||||||
|
return colorMode.value === 'dark'
|
||||||
|
},
|
||||||
|
set() {
|
||||||
|
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { safeSnapshotDates } = useSnapshot();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="w-full hide-scrollbars relative h-[4rem] border-solid border-[#D9D9E0] dark:border-[#202020] border-b-[1px] bg-lyx-lightmode-background dark:bg-lyx-background dark:shadow-[1px_0_10px_#000000]">
|
||||||
|
|
||||||
|
<div class="absolute flex h-full w-full">
|
||||||
|
<div class="flex items-center px-6">
|
||||||
|
<SelectorDomainSelector></SelectorDomainSelector>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hidden lg:flex items-center popping text-[.9rem] dark:text-lyx-text-dark">
|
||||||
|
Timeframe:
|
||||||
|
{{ new Date(safeSnapshotDates.from).toLocaleDateString() }}
|
||||||
|
to
|
||||||
|
{{ new Date(safeSnapshotDates.to).toLocaleDateString() }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="grow"></div>
|
||||||
|
<div class="flex items-center gap-6 mr-10">
|
||||||
|
|
||||||
|
<div v-if="!selfhosted" @click="modal.open(DialogFeedback, {});"
|
||||||
|
class="flex gap-2 items-center cursor-pointer outline-[1px] outline-lyx-widget-lighter p-1 px-3 rounded-md outline">
|
||||||
|
<i class="far fa-message"></i>
|
||||||
|
Feedback
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div @click="modal.open(DialogHelp, {});" class="cursor-pointer"> Help </div>
|
||||||
|
<NuxtLink to="https://docs.litlyx.com" target="_blank" class="cursor-pointer">
|
||||||
|
Docs
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<UTooltip :text="isDark ? 'Toggle light mode' : 'Toggle dark mode'">
|
||||||
|
<i @click="isDark = !isDark"
|
||||||
|
class="cursor-pointer hover:text-lyx-lightmode-text text-lyx-lightmode-text-dark dark:hover:text-lyx-text dark:text-lyx-text-dark"
|
||||||
|
:class="isDark ? 'far fa-moon' : 'far fa-sun'"></i>
|
||||||
|
</UTooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
import CreateSnapshot from './dialog/CreateSnapshot.vue';
|
import { DialogConfirmLogout, DialogInviteManager } from '#components';
|
||||||
|
import CreateSnapshot from '../dialog/CreateSnapshot.vue';
|
||||||
|
|
||||||
export type Entry = {
|
export type Entry = {
|
||||||
label: string,
|
label: string,
|
||||||
@@ -23,9 +24,14 @@ type Props = {
|
|||||||
sections: Section[]
|
sections: Section[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const { data: pendingInvites, refresh: refreshInvites } = useFetch('/api/project/members/pending', {
|
||||||
|
headers: useComputedHeaders({})
|
||||||
|
});
|
||||||
|
|
||||||
const { userRoles, setLoggedUser } = useLoggedUser();
|
const { userRoles, setLoggedUser } = useLoggedUser();
|
||||||
const { projectList } = useProject();
|
const { projectList } = useProject();
|
||||||
|
|
||||||
@@ -56,7 +62,7 @@ const { createAlert } = useAlert()
|
|||||||
async function deleteSnapshot(close: () => any) {
|
async function deleteSnapshot(close: () => any) {
|
||||||
await $fetch("/api/snapshot/delete", {
|
await $fetch("/api/snapshot/delete", {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
headers: useComputedHeaders({ useSnapshotDates: false }).value,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: snapshot.value._id.toString(),
|
id: snapshot.value._id.toString(),
|
||||||
})
|
})
|
||||||
@@ -71,11 +77,7 @@ async function generatePDF() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<Blob>('/api/project/generate_pdf', {
|
const res = await $fetch<Blob>('/api/project/generate_pdf', {
|
||||||
...signHeaders({
|
headers: useComputedHeaders({ useSnapshotDates: false, custom: { 'x-snapshot-name': snapshot.value.name } }).value,
|
||||||
'x-snapshot-name': snapshot.value.name,
|
|
||||||
'x-from': snapshot.value.from.toISOString(),
|
|
||||||
'x-to': snapshot.value.to.toISOString(),
|
|
||||||
}),
|
|
||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,12 +94,25 @@ async function generatePDF() {
|
|||||||
|
|
||||||
const { setToken } = useAccessToken();
|
const { setToken } = useAccessToken();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { actions } = useProject();
|
||||||
|
|
||||||
|
|
||||||
|
const modal = useModal();
|
||||||
|
|
||||||
|
|
||||||
function onLogout() {
|
function onLogout() {
|
||||||
console.log('LOGOUT')
|
modal.open(DialogConfirmLogout, {
|
||||||
setToken('');
|
onSuccess() {
|
||||||
setLoggedUser(undefined);
|
modal.close();
|
||||||
router.push('/login');
|
console.log('LOGOUT');
|
||||||
|
setToken('');
|
||||||
|
setLoggedUser(undefined);
|
||||||
|
router.push('/login');
|
||||||
|
},
|
||||||
|
onCancel() {
|
||||||
|
modal.close();
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||||
@@ -109,12 +124,32 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const pricingDrawer = usePricingDrawer();
|
function openPendingInvites() {
|
||||||
|
if (!pendingInvites.value) return;
|
||||||
|
if (pendingInvites.value.length == 0) return;
|
||||||
|
|
||||||
|
console.log(pendingInvites);
|
||||||
|
modal.open(DialogInviteManager, {
|
||||||
|
invites: pendingInvites.value.map(e => {
|
||||||
|
return { project_id: e.project_id, project_name: e.project_name }
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
modal.close();
|
||||||
|
actions.refreshProjectsList();
|
||||||
|
refreshInvites();
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
modal.close();
|
||||||
|
actions.refreshProjectsList();
|
||||||
|
refreshInvites();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="CVerticalNavigation border-solid border-[#202020] border-r-[1px] h-full w-[20rem] bg-lyx-background flex shadow-[1px_0_10px_#000000] rounded-r-lg"
|
<div class="CVerticalNavigation border-solid border-[#D9D9E0] dark:border-[#202020] border-r-[1px] h-full w-[20rem] bg-lyx-lightmode-background dark:bg-lyx-background flex shadow-[1px_0_10px_#000000]"
|
||||||
:class="{
|
:class="{
|
||||||
'absolute top-0 w-full md:w-[20rem] z-[45] open': isOpen,
|
'absolute top-0 w-full md:w-[20rem] z-[45] open': isOpen,
|
||||||
'hidden lg:flex': !isOpen
|
'hidden lg:flex': !isOpen
|
||||||
@@ -132,7 +167,7 @@ const pricingDrawer = usePricingDrawer();
|
|||||||
|
|
||||||
<div class="flex items-center gap-2 w-full">
|
<div class="flex items-center gap-2 w-full">
|
||||||
|
|
||||||
<ProjectSelector></ProjectSelector>
|
<SelectorProjectSelector></SelectorProjectSelector>
|
||||||
|
|
||||||
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
|
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
|
||||||
<i @click="close()" class="fas fa-close"></i>
|
<i @click="close()" class="fas fa-close"></i>
|
||||||
@@ -144,30 +179,39 @@ const pricingDrawer = usePricingDrawer();
|
|||||||
<LyxUiButton to="/project_creation" v-if="projectList && (projectList.length < (maxProjects || 1))"
|
<LyxUiButton to="/project_creation" v-if="projectList && (projectList.length < (maxProjects || 1))"
|
||||||
type="outlined" class="w-full py-1 mt-2 text-[.8rem]">
|
type="outlined" class="w-full py-1 mt-2 text-[.8rem]">
|
||||||
<div class="flex items-center gap-2 justify-center">
|
<div class="flex items-center gap-2 justify-center">
|
||||||
<div><i class="fas fa-plus"></i></div>
|
<div><i class="fas fa-plus text-[.7rem]"></i></div>
|
||||||
<div> Create new project </div>
|
<div class="poppins"> New Project </div>
|
||||||
</div>
|
</div>
|
||||||
</LyxUiButton>
|
</LyxUiButton>
|
||||||
|
|
||||||
|
<LyxUiButton v-if="projectList && (projectList.length >= (maxProjects || 1))" type="outlined"
|
||||||
|
class="w-full py-1 mt-2 text-[.7rem]">
|
||||||
|
<div class="flex items-center gap-2 justify-center">
|
||||||
|
<div><i class="text-lyx-text-darker far fa-lock"></i></div>
|
||||||
|
<div class="text-lyx-text-darker"> Projects limit reached </div>
|
||||||
|
</div>
|
||||||
|
</LyxUiButton>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="w-full flex-col px-2">
|
<div class="w-full flex-col px-2">
|
||||||
|
|
||||||
<div class="flex mb-2 items-center justify-between">
|
<div class="flex mb-2 items-center justify-between text-lyx-lightmode-text dark:text-lyx-text">
|
||||||
<div class="poppins text-[.8rem]">
|
<div class="poppins text-[.8rem]">
|
||||||
Snapshots
|
Timeframes
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<UTooltip text="Download report">
|
<!-- <UTooltip text="Download report">
|
||||||
<LyxUiButton @click="generatePDF()" type="outlined" class="!px-3 !py-1">
|
<LyxUiButton @click="generatePDF()" type="outlined" class="!px-3 !py-1">
|
||||||
<div><i class="far fa-download text-[.8rem]"></i></div>
|
<div><i class="far fa-download text-[.8rem]"></i></div>
|
||||||
</LyxUiButton>
|
</LyxUiButton>
|
||||||
</UTooltip>
|
</UTooltip> -->
|
||||||
<UTooltip text="Create new snapshot">
|
<UTooltip text="Create new timeframe">
|
||||||
<LyxUiButton @click="openSnapshotDialog()" type="outlined" class="!px-3 !py-1">
|
<LyxUiButton @click="openSnapshotDialog()" type="outlined" class="!px-3 !py-1">
|
||||||
<div><i class="fas fa-plus text-[.9rem]"></i></div>
|
<div><i class="fas fa-plus text-[.8rem]"></i></div>
|
||||||
</LyxUiButton>
|
</LyxUiButton>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,11 +220,11 @@ const pricingDrawer = usePricingDrawer();
|
|||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<USelectMenu :uiMenu="{
|
<USelectMenu :uiMenu="{
|
||||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
|
||||||
base: '!bg-lyx-widget',
|
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
|
||||||
option: {
|
option: {
|
||||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||||
active: '!bg-lyx-widget-lighter'
|
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
|
||||||
}
|
}
|
||||||
}" class="w-full" v-model="snapshot" :options="snapshotsItems">
|
}" class="w-full" v-model="snapshot" :options="snapshotsItems">
|
||||||
<template #label>
|
<template #label>
|
||||||
@@ -201,18 +245,18 @@ const pricingDrawer = usePricingDrawer();
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="snapshot" class="flex flex-col text-[.7rem] mt-2">
|
<div v-if="snapshot" class="flex flex-col text-[.7rem] mt-2">
|
||||||
<div class="flex gap-1 items-center justify-center text-lyx-text-dark">
|
<div
|
||||||
|
class="flex gap-1 items-center justify-center text-lyx-lightmode-text-dark dark:text-lyx-text-dark">
|
||||||
<div class="poppins">
|
<div class="poppins">
|
||||||
{{ new Date(snapshot.from).toLocaleString('it-IT').split(',')[0].trim().replace(/\//g, '-')
|
{{ new Date(snapshot.from).toLocaleString().split(',')[0].trim() }}
|
||||||
}}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="poppins"> to </div>
|
<div class="poppins"> to </div>
|
||||||
<div class="poppins">
|
<div class="poppins">
|
||||||
{{ new Date(snapshot.to).toLocaleString('it-IT').split(',')[0].trim().replace(/\//g, '-') }}
|
{{ new Date(snapshot.to).toLocaleString().split(',')[0].trim() }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2" v-if="snapshot._id.toString().startsWith('default') === false">
|
<div class="mt-2" v-if="('default' in snapshot == false)">
|
||||||
<UPopover placement="bottom">
|
<UPopover placement="bottom">
|
||||||
<LyxUiButton type="danger" class="w-full text-center">
|
<LyxUiButton type="danger" class="w-full text-center">
|
||||||
Delete current snapshot
|
Delete current snapshot
|
||||||
@@ -233,22 +277,28 @@ const pricingDrawer = usePricingDrawer();
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full flex mt-4">
|
||||||
|
<LyxUiButton @click="generatePDF()" type="outline" class="w-full text-center text-[.8rem]">
|
||||||
|
Export report
|
||||||
|
</LyxUiButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-[#202020] h-[1px] w-full"></div>
|
<div class="bg-lyx-lightmode-widget dark:bg-[#202020] h-[1px] w-full"></div>
|
||||||
|
|
||||||
<div class="flex flex-col h-full">
|
<div 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="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-for="entry of section.entries" :class="{ 'grow flex items-end': entry.grow }">
|
||||||
|
|
||||||
<div v-if="(!entry.adminOnly || (userRoles.isAdmin && !isAdminHidden))"
|
<div v-if="(!entry.adminOnly || (userRoles.isAdmin.value && !isAdminHidden))"
|
||||||
class="bg-lyx-background w-full cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
|
class="bg-lyx-lightmode-background text-lyx-lightmode-text-dark dark:bg-lyx-background dark:text-lyx-text-dark w-full cursor-pointer py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
|
||||||
:class="{
|
:class="{
|
||||||
'!text-lyx-text-darker pointer-events-none': entry.disabled,
|
'!text-lyx-text-darker pointer-events-none': entry.disabled,
|
||||||
'bg-lyx-background-lighter !text-lyx-text/90': route.path == (entry.to || '#'),
|
'bg-lyx-lightmode-background-light !text-lyx-lightmode-text dark:bg-lyx-background-lighter dark:!text-lyx-text': route.path == (entry.to || '#'),
|
||||||
'hover:bg-lyx-background-light hover:!text-lyx-text/90': route.path != (entry.to || '#'),
|
'hover:bg-lyx-lightmode-background-light hover:!text-lyx-lightmode-text dark:hover:bg-lyx-background-light dark:hover:!text-lyx-text': route.path != (entry.to || '#'),
|
||||||
}">
|
}">
|
||||||
|
|
||||||
<NuxtLink @click="close() && entry.action?.()" :target="entry.external ? '_blank' : ''"
|
<NuxtLink @click="close() && entry.action?.()" :target="entry.external ? '_blank' : ''"
|
||||||
@@ -259,7 +309,7 @@ const pricingDrawer = usePricingDrawer();
|
|||||||
<div class="manrope grow">
|
<div class="manrope grow">
|
||||||
{{ entry.label }}
|
{{ entry.label }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="entry.premiumOnly && !userRoles.isPremium" class="flex items-center">
|
<div v-if="entry.premiumOnly && !userRoles.isPremium.value" class="flex items-center">
|
||||||
<i class="fal fa-lock"></i>
|
<i class="fal fa-lock"></i>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
@@ -272,35 +322,33 @@ const pricingDrawer = usePricingDrawer();
|
|||||||
|
|
||||||
<div class="grow"></div>
|
<div class="grow"></div>
|
||||||
|
|
||||||
<div class="bg-[#202020] h-[1px] w-full px-4 mb-3"></div>
|
<div v-if="pendingInvites && pendingInvites.length > 0" @click="openPendingInvites()"
|
||||||
|
class="w-full bg-[#fbbf2422] p-4 rounded-lg text-[.9rem] flex flex-col justify-center cursor-pointer">
|
||||||
|
<div class="poppins font-medium dark:text-lyx-text text-lyx-lightmode-text">
|
||||||
|
Pending invitation
|
||||||
|
</div>
|
||||||
|
<div class="poppins dark:text-lyx-text-dark text-lyx-lightmode-text-dark">
|
||||||
|
You have {{ pendingInvites.length }}
|
||||||
|
pending invitation{{ pendingInvites.length != 1 ? 's' : '' }}
|
||||||
|
awaiting your response
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-lyx-lightmode-widget dark:bg-[#202020] h-[1px] w-full px-4 mb-3"></div>
|
||||||
|
|
||||||
<div class="flex justify-end px-2">
|
<div class="flex justify-end px-2">
|
||||||
|
|
||||||
<div class="grow flex gap-3">
|
<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">
|
<NuxtLink to="/admin" v-if="userRoles.isAdmin.value"
|
||||||
<i class="fab fa-github"></i>
|
class="cursor-pointer hover:text-lyx-lightmode-text text-lyx-lightmode-text-dark dark:hover:text-lyx-text dark:text-lyx-text-dark">
|
||||||
</NuxtLink> -->
|
<i class="far fa-cat"></i>
|
||||||
<!-- <NuxtLink to="https://discord.gg/9cQykjsmWX" target="_blank"
|
|
||||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
|
||||||
<i class="fab fa-discord"></i>
|
|
||||||
</NuxtLink> -->
|
|
||||||
<NuxtLink to="https://x.com/litlyx" target="_blank"
|
|
||||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
|
||||||
<i class="fab fa-x-twitter"></i>
|
|
||||||
</NuxtLink>
|
|
||||||
<!-- <NuxtLink to="https://dev.to/litlyx-org" target="_blank"
|
|
||||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
|
||||||
<i class="fab fa-dev"></i>
|
|
||||||
</NuxtLink> -->
|
|
||||||
<NuxtLink to="/admin" v-if="userRoles.isAdmin"
|
|
||||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
|
||||||
<i class="fas fa-cat"></i>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UTooltip text="Logout" :popper="{ arrow: true, placement: 'top' }">
|
<UTooltip text="Logout" :popper="{ arrow: true, placement: 'top' }">
|
||||||
<div @click="onLogout()" class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
<div @click="onLogout()"
|
||||||
|
class="cursor-pointer hover:text-lyx-lightmode-text text-lyx-lightmode-text-dark dark:hover:text-lyx-text dark:text-lyx-text-dark">
|
||||||
<i class="far fa-arrow-right-from-bracket scale-x-[-100%]"></i>
|
<i class="far fa-arrow-right-from-bracket scale-x-[-100%]"></i>
|
||||||
</div>
|
</div>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
@@ -15,8 +15,6 @@ export type PricingCardProp = {
|
|||||||
|
|
||||||
const props = defineProps<{ datas: PricingCardProp[], defaultIndex?: number }>();
|
const props = defineProps<{ datas: PricingCardProp[], defaultIndex?: number }>();
|
||||||
|
|
||||||
const { project } = useProject();
|
|
||||||
|
|
||||||
const currentIndex = ref<number>(props.defaultIndex || 0);
|
const currentIndex = ref<number>(props.defaultIndex || 0);
|
||||||
|
|
||||||
const data = computed(() => {
|
const data = computed(() => {
|
||||||
@@ -43,11 +41,11 @@ async function onUpgradeClick() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="relative bg-[#151515] outline outline-[1px] outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]">
|
class="relative bg-lyx-lightmode-widget-light dark:bg-[#151515] outline outline-[1px] outline-lyx-lightmode-widget dark:outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]">
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 text-center pt-3">
|
<div class="flex flex-col gap-3 text-center pt-3">
|
||||||
<div v-if="data.active"
|
<div v-if="data.active"
|
||||||
class="absolute right-6 top-3 poppins text-[.75rem] bg-[#222A42] outline outline-[1px] outline-[#5680F8] px-3 py-[.1rem] rounded-sm">
|
class="absolute right-6 top-3 poppins text-[.75rem] bg-transparent border-[#262626] border-solid border-[1px] px-3 py-[.1rem] rounded-sm">
|
||||||
Active
|
Active
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!data.active && data.title === 'Growth'"
|
<div v-if="!data.active && data.title === 'Growth'"
|
||||||
@@ -58,7 +56,7 @@ async function onUpgradeClick() {
|
|||||||
<div class="poppins text-4xl font-medium"> {{ data.price }} </div>
|
<div class="poppins text-4xl font-medium"> {{ data.price }} </div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sep bg-[#262626] h-[1px] my-8"></div>
|
<div class="sep bg-lyx-lightmode-widget dark:bg-[#262626] h-[1px] my-8"></div>
|
||||||
|
|
||||||
<div class="flex flex-col text-center h-[6rem] justify-center gap-2">
|
<div class="flex flex-col text-center h-[6rem] justify-center gap-2">
|
||||||
<div v-if="datas.length > 1">
|
<div v-if="datas.length > 1">
|
||||||
@@ -78,7 +76,7 @@ async function onUpgradeClick() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sep bg-[#262626] h-[1px] my-8"></div>
|
<div class="sep bg-lyx-lightmode-widget dark:bg-[#262626] h-[1px] my-8"></div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="flex gap-2" v-for="feature of data.features">
|
<div class="flex gap-2" v-for="feature of data.features">
|
||||||
|
|||||||
51
dashboard/components/selector/DomainSelector.vue
Normal file
51
dashboard/components/selector/DomainSelector.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { domainList, domain, setActiveDomain, refreshDomains, refreshingDomains } = useDomain();
|
||||||
|
|
||||||
|
function onChange(e: string) {
|
||||||
|
setActiveDomain(e);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<USelectMenu :uiMenu="{
|
||||||
|
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
|
||||||
|
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget w-max',
|
||||||
|
option: {
|
||||||
|
base: 'z-[990] hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||||
|
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
|
||||||
|
},
|
||||||
|
input: 'z-[999] !bg-lyx-lightmode-widget dark:!bg-lyx-widget-light'
|
||||||
|
}" class="w-full" searchable searchable-placeholder="Search domain..." v-if="domainList" @change="onChange"
|
||||||
|
:value="domain" :loading="refreshingDomains" value-attribute="_id" :options="domainList">
|
||||||
|
|
||||||
|
<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._id }} </div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #label="e">
|
||||||
|
<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>
|
||||||
|
{{ refreshingDomains ? 'Loading...' : (domain || '-') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</USelectMenu>
|
||||||
|
|
||||||
|
<UTooltip text="Manage domains">
|
||||||
|
<NuxtLink to="/settings?tab=domains"
|
||||||
|
class="flex items-center hover:rotate-[60deg] transition-all duration-200 ease-in-out cursor-pointer">
|
||||||
|
<i class="far fa-gear"></i>
|
||||||
|
</NuxtLink>
|
||||||
|
</UTooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
import type { TProject } from '@schema/ProjectSchema';
|
import type { TProject } from '@schema/project/ProjectSchema';
|
||||||
|
|
||||||
const { user } = useLoggedUser()
|
const { user } = useLoggedUser()
|
||||||
|
|
||||||
const { projectList, guestProjectList,allProjectList, actions, project } = useProject();
|
const { projectList, guestProjectList, allProjectList, actions, project } = useProject();
|
||||||
|
const { setActiveDomain } = useDomain();
|
||||||
|
|
||||||
function isProjectMine(owner?: string) {
|
function isProjectMine(owner?: string) {
|
||||||
if (!owner) return false;
|
if (!owner) return false;
|
||||||
@@ -22,11 +22,11 @@ function onChange(e: TProject) {
|
|||||||
<template>
|
<template>
|
||||||
|
|
||||||
<USelectMenu :uiMenu="{
|
<USelectMenu :uiMenu="{
|
||||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
|
||||||
base: '!bg-lyx-widget',
|
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
|
||||||
option: {
|
option: {
|
||||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||||
active: '!bg-lyx-widget-lighter'
|
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
|
||||||
}
|
}
|
||||||
}" class="w-full" v-if="allProjectList" @change="onChange" :value="project" :options="allProjectList">
|
}" class="w-full" v-if="allProjectList" @change="onChange" :value="project" :options="allProjectList">
|
||||||
|
|
||||||
@@ -2,12 +2,18 @@
|
|||||||
import type { SettingsTemplateEntry } from './Template.vue';
|
import type { SettingsTemplateEntry } from './Template.vue';
|
||||||
|
|
||||||
const entries: SettingsTemplateEntry[] = [
|
const entries: SettingsTemplateEntry[] = [
|
||||||
|
{ id: 'change_pass', title: 'Change password', text: 'Change your password' },
|
||||||
{ id: 'delete', title: 'Delete account', text: 'Delete your account' },
|
{ id: 'delete', title: 'Delete account', text: 'Delete your account' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
const { user } = useLoggedUser();
|
||||||
const { setToken } = useAccessToken();
|
const { setToken } = useAccessToken();
|
||||||
|
|
||||||
|
const canChangePassword = useFetch('/api/user/password/can_change', {
|
||||||
|
headers: useComputedHeaders({ useSnapshotDates: false })
|
||||||
|
});
|
||||||
|
|
||||||
async function deleteAccount() {
|
async function deleteAccount() {
|
||||||
const sure = confirm("Are you sure you want to delete this account ?");
|
const sure = confirm("Are you sure you want to delete this account ?");
|
||||||
if (!sure) return;
|
if (!sure) return;
|
||||||
@@ -20,17 +26,63 @@ async function deleteAccount() {
|
|||||||
location.href = "/login"
|
location.href = "/login"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const old_password = ref<string>("");
|
||||||
|
const new_password = ref<string>("");
|
||||||
|
|
||||||
|
const { createAlert } = useAlert()
|
||||||
|
|
||||||
|
async function changePassword() {
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await $fetch("/api/user/password/change", {
|
||||||
|
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ old_password: old_password.value, new_password: new_password.value })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res) throw Error('No response');
|
||||||
|
|
||||||
|
if (res.error) return createAlert('Error', res.message, 'far fa-triangle-exclamation', 5000);
|
||||||
|
|
||||||
|
|
||||||
|
old_password.value = '';
|
||||||
|
new_password.value = '';
|
||||||
|
|
||||||
|
return createAlert('Success', 'Password changed successfully', 'far fa-circle-check', 5000);
|
||||||
|
|
||||||
|
} catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
createAlert('Error', 'Internal error', 'far fa-triangle-exclamation', 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SettingsTemplate :entries="entries">
|
<SettingsTemplate :entries="entries">
|
||||||
|
<template #change_pass>
|
||||||
|
<div v-if="canChangePassword.data.value?.can_change">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<LyxUiInput type="password" class="py-1 px-2" v-model="old_password" placeholder="Current password"></LyxUiInput>
|
||||||
|
<LyxUiInput type="password" class="py-1 px-2" v-model="new_password" placeholder="New password"></LyxUiInput>
|
||||||
|
<LyxUiButton type="primary" @click="changePassword()"> Change password </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!canChangePassword.data.value?.can_change">
|
||||||
|
You cannot change the password for accounts created using social login options.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template #delete>
|
<template #delete>
|
||||||
<div
|
<div
|
||||||
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-[#1e1412]">
|
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-lyx-lightmode-widget-light dark:bg-[#1e1412]">
|
||||||
<div class="poppins font-semibold"> Deleting this account will also remove its projects </div>
|
<div class="poppins font-semibold"> Deleting this account will also remove its projects </div>
|
||||||
<div @click="deleteAccount()"
|
<div @click="deleteAccount()"
|
||||||
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-[#291415]">
|
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-lyx-lightmode-widget-light dark:bg-[#291415]">
|
||||||
Delete account
|
Delete account
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
66
dashboard/components/settings/Codes.vue
Normal file
66
dashboard/components/settings/Codes.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TApiSettings } from '@schema/ApiSettingsSchema';
|
||||||
|
import type { SettingsTemplateEntry } from './Template.vue';
|
||||||
|
|
||||||
|
const { project, isGuest } = useProject();
|
||||||
|
|
||||||
|
const entries: SettingsTemplateEntry[] = [
|
||||||
|
{ id: 'acodes', title: 'Appsumo codes', text: 'Redeem appsumo codes' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { createAlert } = useAlert()
|
||||||
|
|
||||||
|
const currentCode = ref<string>("");
|
||||||
|
const redeeming = ref<boolean>(false);
|
||||||
|
|
||||||
|
const valid_codes = useFetch('/api/pay/valid_codes', signHeaders({ 'x-pid': project.value?._id.toString() ?? '' }));
|
||||||
|
|
||||||
|
async function redeemCode() {
|
||||||
|
redeeming.value = true;
|
||||||
|
try {
|
||||||
|
const res = await $fetch<TApiSettings>('/api/pay/redeem_appsumo_code', {
|
||||||
|
method: 'POST', ...signHeaders({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-pid': project.value?._id.toString() ?? ''
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({ code: currentCode.value })
|
||||||
|
});
|
||||||
|
createAlert('Success', 'Code redeem success.', 'far fa-check', 5000);
|
||||||
|
valid_codes.refresh();
|
||||||
|
} catch (ex: any) {
|
||||||
|
createAlert('Error', ex?.response?.statusText || 'Unexpected error. Contact support.', 'far fa-error', 5000);
|
||||||
|
} finally {
|
||||||
|
currentCode.value = '';
|
||||||
|
}
|
||||||
|
redeeming.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SettingsTemplate v-if="!isGuest" :entries="entries" :key="project?.name || 'NONE'">
|
||||||
|
<template #acodes>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<LyxUiInput class="w-full px-4 py-2" placeholder="Appsumo code" v-model="currentCode"></LyxUiInput>
|
||||||
|
<LyxUiButton v-if="!redeeming" :disabled="currentCode.length == 0" @click="redeemCode()" type="primary">
|
||||||
|
Redeem
|
||||||
|
</LyxUiButton>
|
||||||
|
<div v-if="redeeming">
|
||||||
|
Redeeming...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-lyx-text-darker mt-1 text-[.9rem] poppins">
|
||||||
|
Redeemed codes: {{ valid_codes.data.value?.count || '0' }}
|
||||||
|
</div>
|
||||||
|
<div class="poppins text-[1.1rem] text-lyx-lightmode-text dark:text-yellow-400 mb-2">
|
||||||
|
*Plan upgrades are applicable exclusively to this project(workspace).
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SettingsTemplate>
|
||||||
|
|
||||||
|
<div v-if="isGuest" class="text-lyx-text-darker flex w-full h-full justify-center mt-20">
|
||||||
|
Guests cannot view billing
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
162
dashboard/components/settings/Data.vue
Normal file
162
dashboard/components/settings/Data.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import DeleteDomainData from '../dialog/DeleteDomainData.vue';
|
||||||
|
import type { SettingsTemplateEntry } from './Template.vue';
|
||||||
|
|
||||||
|
|
||||||
|
const { isGuest } = useProject();
|
||||||
|
|
||||||
|
const entries: SettingsTemplateEntry[] = [
|
||||||
|
{ id: 'delete_dns', title: 'Delete domain data', text: 'Delete data of a specific domain from this project' },
|
||||||
|
{ id: 'delete_data', title: 'Delete project data', text: 'Delete all data from this project' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const domains = useFetch('/api/settings/domains', {
|
||||||
|
headers: useComputedHeaders({ useSnapshotDates: false }),
|
||||||
|
transform: (e) => {
|
||||||
|
if (!e) return [];
|
||||||
|
return e.sort((a, b) => {
|
||||||
|
return a.count - b.count;
|
||||||
|
}).map(e => {
|
||||||
|
return { id: e._id, label: `${e._id} - ${e.count} visits` }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedDomain = ref<{ id: string, label: string }>();
|
||||||
|
const selectedVisits = ref<boolean>(true);
|
||||||
|
const selectedSessions = ref<boolean>(true);
|
||||||
|
const selectedEvents = ref<boolean>(true);
|
||||||
|
|
||||||
|
|
||||||
|
const domainCounts = useFetch(() => `/api/settings/domain_counts?domain=${selectedDomain.value?.id}`, {
|
||||||
|
headers: useComputedHeaders({ useSnapshotDates: false }),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const { setToken } = useAccessToken();
|
||||||
|
|
||||||
|
|
||||||
|
const modal = useModal();
|
||||||
|
|
||||||
|
function openDeleteDomainDataDialog() {
|
||||||
|
modal.open(DeleteDomainData, {
|
||||||
|
preventClose: true,
|
||||||
|
deleteData: {
|
||||||
|
isAll: false,
|
||||||
|
domain: selectedDomain.value?.id as string,
|
||||||
|
visits: selectedVisits.value,
|
||||||
|
sessions: selectedSessions.value,
|
||||||
|
events: selectedEvents.value,
|
||||||
|
},
|
||||||
|
buttonType: 'primary',
|
||||||
|
message: 'This action is irreversable and will wipe all the data from the selected domain.',
|
||||||
|
onSuccess: () => {
|
||||||
|
modal.close()
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
modal.close()
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteAllDomainDataDialog() {
|
||||||
|
modal.open(DeleteDomainData, {
|
||||||
|
preventClose: true,
|
||||||
|
deleteData: {
|
||||||
|
isAll: true,
|
||||||
|
domain: '',
|
||||||
|
visits: false,
|
||||||
|
sessions: false,
|
||||||
|
events: false,
|
||||||
|
},
|
||||||
|
buttonType: 'danger',
|
||||||
|
message: 'This action is irreversable and will wipe all the data from the entire project.',
|
||||||
|
onSuccess: () => {
|
||||||
|
modal.close()
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
modal.close()
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const visitsLabel = computed(() => {
|
||||||
|
if (domainCounts.pending.value === true) return 'Visits loading...';
|
||||||
|
if (domainCounts.data.value?.error === true) return 'Visits (too many to compute)';
|
||||||
|
return 'Visits ' + (domainCounts.data.value?.visits ?? '');
|
||||||
|
})
|
||||||
|
|
||||||
|
const eventsLabel = computed(() => {
|
||||||
|
if (domainCounts.pending.value === true) return 'Events loading...';
|
||||||
|
if (domainCounts.data.value?.error === true) return 'Events (too many to compute)';
|
||||||
|
return 'Events ' + (domainCounts.data.value?.events ?? '');
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessionsLabel = computed(() => {
|
||||||
|
if (domainCounts.pending.value === true) return 'Sessions loading...';
|
||||||
|
if (domainCounts.data.value?.error === true) return 'Sessions (too many to compute)';
|
||||||
|
return 'Sessions ' + (domainCounts.data.value?.sessions ?? '');
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SettingsTemplate :entries="entries">
|
||||||
|
<template #delete_dns>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
|
||||||
|
<!-- <div class="text-[.9rem] text-lyx-text-darker"> Select a domain </div> -->
|
||||||
|
<USelectMenu v-if="!isGuest" placeholder="Select a domain" :uiMenu="{
|
||||||
|
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
|
||||||
|
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
|
||||||
|
option: {
|
||||||
|
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||||
|
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
|
||||||
|
}
|
||||||
|
}" :options="domains.data.value ?? []" v-model="selectedDomain"></USelectMenu>
|
||||||
|
|
||||||
|
<div v-if="isGuest" class="text-lyx-text-darker"> Guests cannot delete data</div>
|
||||||
|
|
||||||
|
<div v-if="selectedDomain" class="flex flex-col gap-2 mt-4">
|
||||||
|
<div class="text-[.9rem] text-lyx-text-dark"> Select data to delete </div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
|
||||||
|
|
||||||
|
<UCheckbox :ui="{ color: 'actionable-visits-color-checkbox' }" v-model="selectedVisits"
|
||||||
|
:label="visitsLabel" />
|
||||||
|
<UCheckbox :ui="{ color: 'actionable-sessions-color-checkbox' }" v-model="selectedSessions"
|
||||||
|
:label="sessionsLabel" />
|
||||||
|
<UCheckbox :ui="{ color: 'actionable-events-color-checkbox' }" v-model="selectedEvents"
|
||||||
|
:label="eventsLabel" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LyxUiButton class="mt-2" v-if="selectedVisits || selectedSessions || selectedEvents"
|
||||||
|
@click="openDeleteDomainDataDialog()" type="outline">
|
||||||
|
Delete data
|
||||||
|
</LyxUiButton>
|
||||||
|
<div class="text-lyx-text-dark">
|
||||||
|
This action will delete all data from the project creation date.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #delete_data>
|
||||||
|
|
||||||
|
<div v-if="!isGuest"
|
||||||
|
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-lyx-lightmode-widget-light dark:bg-[#1e1412]">
|
||||||
|
<div class="poppins font-semibold"> This operation will reset this project to it's initial state (0
|
||||||
|
visits 0 events 0 sessions) </div>
|
||||||
|
<div @click="openDeleteAllDomainDataDialog()"
|
||||||
|
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-lyx-lightmode-widget-light dark:bg-[#291415]">
|
||||||
|
Delete all data
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isGuest" class="text-lyx-text-darker"> Guests cannot delete data</div>
|
||||||
|
</template>
|
||||||
|
</SettingsTemplate>
|
||||||
|
</template>
|
||||||
@@ -4,6 +4,8 @@ import type { SettingsTemplateEntry } from './Template.vue';
|
|||||||
|
|
||||||
const { project, actions, projectList, isGuest, projectId } = useProject();
|
const { project, actions, projectList, isGuest, projectId } = useProject();
|
||||||
|
|
||||||
|
const { createErrorAlert, createAlert } = useAlert();
|
||||||
|
|
||||||
const entries: SettingsTemplateEntry[] = [
|
const entries: SettingsTemplateEntry[] = [
|
||||||
{ id: 'pname', title: 'Name', text: 'Project name' },
|
{ id: 'pname', title: 'Name', text: 'Project name' },
|
||||||
{ id: 'api', title: 'ApiKeys', text: 'Manage your authorization token' },
|
{ id: 'api', title: 'ApiKeys', text: 'Manage your authorization token' },
|
||||||
@@ -37,7 +39,7 @@ async function createApiKey() {
|
|||||||
apiKeys.value.push(res);
|
apiKeys.value.push(res);
|
||||||
newApiKeyName.value = '';
|
newApiKeyName.value = '';
|
||||||
} catch (ex: any) {
|
} catch (ex: any) {
|
||||||
alert(ex.message);
|
createErrorAlert('Error', ex.message, 10000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ async function deleteApiKey(api_id: string) {
|
|||||||
newApiKeyName.value = '';
|
newApiKeyName.value = '';
|
||||||
await updateApiKeys();
|
await updateApiKeys();
|
||||||
} catch (ex: any) {
|
} catch (ex: any) {
|
||||||
alert(ex.message);
|
createErrorAlert('Error', ex.message, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -116,14 +118,12 @@ async function deleteProject() {
|
|||||||
|
|
||||||
|
|
||||||
} catch (ex: any) {
|
} catch (ex: any) {
|
||||||
alert(ex.message);
|
createErrorAlert('Error', ex.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { createAlert } = useAlert()
|
|
||||||
|
|
||||||
function copyScript() {
|
function copyScript() {
|
||||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||||
|
|
||||||
@@ -156,20 +156,28 @@ function copyProjectId() {
|
|||||||
<template>
|
<template>
|
||||||
<SettingsTemplate :entries="entries" :key="project?.name || 'NONE'">
|
<SettingsTemplate :entries="entries" :key="project?.name || 'NONE'">
|
||||||
<template #pname>
|
<template #pname>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex flex-col gap-2">
|
||||||
<LyxUiInput class="w-full px-4 py-2" :disabled="isGuest" v-model="projectNameInputVal"></LyxUiInput>
|
<div class="flex items-center gap-4">
|
||||||
<LyxUiButton v-if="!isGuest" @click="changeProjectName()" :disabled="!canChange" type="primary"> Change
|
<LyxUiInput class="w-full px-4 py-2" :disabled="isGuest" v-model="projectNameInputVal"></LyxUiInput>
|
||||||
</LyxUiButton>
|
<LyxUiButton v-if="!isGuest" @click="changeProjectName()" :disabled="!canChange" type="primary">
|
||||||
|
Change
|
||||||
|
</LyxUiButton>
|
||||||
|
</div>
|
||||||
|
<div v-if="isGuest" class="text-lyx-text-darker"> *Guests cannot change project name </div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #api>
|
<template #api>
|
||||||
<div class="flex items-center gap-4" v-if="apiKeys && apiKeys.length < 5">
|
<div class="flex flex-col gap-2" v-if="apiKeys && apiKeys.length < 5">
|
||||||
<LyxUiInput class="grow px-4 py-2" :disabled="isGuest" placeholder="ApiKeyName" v-model="newApiKeyName">
|
<div class="flex items-center gap-4">
|
||||||
</LyxUiInput>
|
<LyxUiInput class="grow px-4 py-2" :disabled="isGuest" placeholder="ApiKeyName"
|
||||||
<LyxUiButton v-if="!isGuest" @click="createApiKey()" :disabled="newApiKeyName.length < 3"
|
v-model="newApiKeyName">
|
||||||
type="primary">
|
</LyxUiInput>
|
||||||
<i class="far fa-plus"></i>
|
<LyxUiButton v-if="!isGuest" @click="createApiKey()" :disabled="newApiKeyName.trim().length < 3"
|
||||||
</LyxUiButton>
|
type="primary">
|
||||||
|
<i class="far fa-plus"></i>
|
||||||
|
</LyxUiButton>
|
||||||
|
</div>
|
||||||
|
<div v-if="isGuest" class="text-lyx-text-darker"> *Guests cannot manage api keys </div>
|
||||||
</div>
|
</div>
|
||||||
<LyxUiCard v-if="apiKeys && apiKeys.length > 0" class="w-full flex flex-col gap-4 items-center mt-4">
|
<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 v-for="apiKey of apiKeys" class="flex flex-col w-full">
|
||||||
@@ -198,15 +206,21 @@ function copyProjectId() {
|
|||||||
<script defer data-project="${project?._id}"
|
<script defer data-project="${project?._id}"
|
||||||
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }}
|
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }}
|
||||||
</div>
|
</div>
|
||||||
<div><i class="far fa-copy" @click="copyScript()"></i></div>
|
<div class="hidden lg:flex"><i class="far fa-copy" @click="copyScript()"></i></div>
|
||||||
</LyxUiCard>
|
</LyxUiCard>
|
||||||
|
<div class="flex justify-end w-full">
|
||||||
|
<LyxUiButton type="outline" class="flex lg:hidden mt-4">
|
||||||
|
Copy script
|
||||||
|
</LyxUiButton>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #pdelete>
|
<template #pdelete>
|
||||||
<div class="flex justify-end" v-if="!isGuest">
|
<div class="flex lg:justify-end" v-if="!isGuest">
|
||||||
<LyxUiButton type="danger" @click="deleteProject()">
|
<LyxUiButton type="danger" @click="deleteProject()">
|
||||||
Delete project
|
Delete project
|
||||||
</LyxUiButton>
|
</LyxUiButton>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isGuest"> *Guests cannot delete project </div>
|
||||||
</template>
|
</template>
|
||||||
</SettingsTemplate>
|
</SettingsTemplate>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -16,22 +16,22 @@ const props = defineProps<SettingsTemplateProp>();
|
|||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mt-10 px-4">
|
<div class="mt-10 px-4 xl:pb-0 pb-[10rem]">
|
||||||
<div v-for="(entry, index) of props.entries" class="flex flex-col">
|
<div v-for="(entry, index) of props.entries" class="flex flex-col">
|
||||||
<div class="flex">
|
<div class="flex xl:flex-row flex-col gap-4 xl:gap-0">
|
||||||
<div class="flex-[2]">
|
<div class="xl:flex-[2]">
|
||||||
<div class="poppins font-medium text-lyx-text">
|
<div class="poppins font-medium text-lyx-lightmode-text dark:text-lyx-text">
|
||||||
{{ entry.title }}
|
{{ entry.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="poppins font-regular text-lyx-text-dark whitespace-pre-wrap">
|
<div class="poppins font-regular text-lyx-lightmode-text-dark dark:text-lyx-text-dark whitespace-pre-wrap">
|
||||||
{{ entry.text }}
|
{{ entry.text }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-[3]">
|
<div class="xl:flex-[3]">
|
||||||
<slot :name="entry.id"></slot>
|
<slot :name="entry.id"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="index < props.entries.length - 1" class="h-[2px] bg-lyx-widget-lighter w-full my-10"></div>
|
<div v-if="index < props.entries.length - 1" class="h-[2px] bg-lyx-lightmode-widget dark:bg-lyx-widget-lighter w-full my-10"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function getPremiumPrice(type: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entries: SettingsTemplateEntry[] = [
|
const entries: SettingsTemplateEntry[] = [
|
||||||
{ id: 'plan', title: 'Current plan', text: 'Manage current plat for this project' },
|
{ id: 'plan', title: 'Current plan', text: 'Manage current plan for this project' },
|
||||||
{ id: 'usage', title: 'Usage', text: 'Show usage of current project' },
|
{ id: 'usage', title: 'Usage', text: 'Show usage of current project' },
|
||||||
{ id: 'info', title: 'Billing address', text: 'This will be reflected in every upcoming invoice,\npast invoices are not affected' },
|
{ id: 'info', title: 'Billing address', text: 'This will be reflected in every upcoming invoice,\npast invoices are not affected' },
|
||||||
{ id: 'invoices', title: 'Invoices', text: 'Manage invoices of current project' },
|
{ id: 'invoices', title: 'Invoices', text: 'Manage invoices of current project' },
|
||||||
@@ -111,39 +111,42 @@ async function saveBillingInfo() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { showDrawer } = useDrawer();
|
||||||
const { visible } = usePricingDrawer();
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative pb-[6rem]">
|
||||||
|
|
||||||
<div v-if="invoicesPending || planPending"
|
<div v-if="invoicesPending || planPending"
|
||||||
class="backdrop-blur-[1px] z-[20] mt-20 w-full h-full flex items-center justify-center font-bold">
|
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>
|
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsTemplate v-if="!invoicesPending && !planPending" :entries="entries">
|
<SettingsTemplate v-if="!invoicesPending && !planPending && !isGuest" :entries="entries">
|
||||||
<template #info>
|
<template #info>
|
||||||
<div v-if="!isGuest">
|
<div v-if="!isGuest">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<LyxUiInput class="px-2 py-2 !bg-[#161616]" placeholder="Address line 1" v-model="currentBillingInfo.line1">
|
<LyxUiInput class="px-2 py-2 dark:!bg-[#161616]" placeholder="Address line 1"
|
||||||
|
v-model="currentBillingInfo.line1">
|
||||||
</LyxUiInput>
|
</LyxUiInput>
|
||||||
<LyxUiInput class="px-2 py-2 !bg-[#161616]" placeholder="Address line 2" v-model="currentBillingInfo.line2">
|
<LyxUiInput class="px-2 py-2 dark:!bg-[#161616]" placeholder="Address line 2"
|
||||||
|
v-model="currentBillingInfo.line2">
|
||||||
</LyxUiInput>
|
</LyxUiInput>
|
||||||
<div class="flex gap-4 w-full">
|
<div class="flex gap-4 w-full">
|
||||||
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="Country"
|
<LyxUiInput class="px-2 py-2 w-full dark:!bg-[#161616]" placeholder="Country"
|
||||||
v-model="currentBillingInfo.country">
|
v-model="currentBillingInfo.country">
|
||||||
</LyxUiInput>
|
</LyxUiInput>
|
||||||
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="Postal code"
|
<LyxUiInput class="px-2 py-2 w-full dark:!bg-[#161616]" placeholder="Postal code"
|
||||||
v-model="currentBillingInfo.postal_code">
|
v-model="currentBillingInfo.postal_code">
|
||||||
</LyxUiInput>
|
</LyxUiInput>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-4 w-full">
|
<div class="flex gap-4 w-full">
|
||||||
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="City" v-model="currentBillingInfo.city">
|
<LyxUiInput class="px-2 py-2 w-full dark:!bg-[#161616]" placeholder="City"
|
||||||
|
v-model="currentBillingInfo.city">
|
||||||
</LyxUiInput>
|
</LyxUiInput>
|
||||||
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="State" v-model="currentBillingInfo.state">
|
<LyxUiInput class="px-2 py-2 w-full dark:!bg-[#161616]" placeholder="State"
|
||||||
|
v-model="currentBillingInfo.state">
|
||||||
</LyxUiInput>
|
</LyxUiInput>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,7 +167,7 @@ const { visible } = usePricingDrawer();
|
|||||||
{{ planData.premium ? 'Premium plan' : 'Basic plan' }}
|
{{ planData.premium ? 'Premium plan' : 'Basic plan' }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex lato text-[.7rem] bg-accent/25 border-accent/40 border-[1px] px-[.6rem] rounded-sm">
|
class="flex lato text-[.7rem] bg-transparent border-[#262626] border-[1px] px-[.6rem] rounded-sm">
|
||||||
{{ planData.premium ? getPremiumName(planData.premium_type) : 'FREE' }}
|
{{ planData.premium ? getPremiumName(planData.premium_type) : 'FREE' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,7 +175,8 @@ const { visible } = usePricingDrawer();
|
|||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class="poppins font-semibold text-[2rem]"> €
|
<div class="poppins font-semibold text-[2rem]"> €
|
||||||
{{ getPremiumPrice(planData.premium_type) }} </div>
|
{{ getPremiumPrice(planData.premium_type) }} </div>
|
||||||
<div class="poppins text-text-sub mt-2"> per month </div>
|
<div class="poppins text-lyx-lightmode-text-dark dark:text-text-sub mt-2"> per month
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
@@ -190,12 +194,12 @@ const { visible } = usePricingDrawer();
|
|||||||
</div>
|
</div>
|
||||||
<div class="my-4 w-full bg-gray-400/30 h-[1px]">
|
<div class="my-4 w-full bg-gray-400/30 h-[1px]">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between px-8 flex-col sm:flex-row">
|
<div class="flex justify-between px-8 flex-col lg:flex-row gap-2 lg:gap-0 items-center">
|
||||||
<div class="flex gap-2 text-text-sub text-[.9rem]">
|
<div class="flex gap-2 text-lyx-lightmode-text-dark dark:text-text-sub text-[.9rem]">
|
||||||
<div class="poppins"> Expire date:</div>
|
<div class="poppins"> Expire date:</div>
|
||||||
<div> {{ prettyExpireDate }}</div>
|
<div> {{ prettyExpireDate }}</div>
|
||||||
</div>
|
</div>
|
||||||
<LyxUiButton v-if="!isGuest" @click="visible = true" type="primary">
|
<LyxUiButton v-if="!isGuest" @click="showDrawer('PRICING')" type="primary">
|
||||||
Upgrade plan
|
Upgrade plan
|
||||||
</LyxUiButton>
|
</LyxUiButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,7 +213,7 @@ const { visible } = usePricingDrawer();
|
|||||||
<div class="poppins font-semibold text-[1.1rem]">
|
<div class="poppins font-semibold text-[1.1rem]">
|
||||||
Usage
|
Usage
|
||||||
</div>
|
</div>
|
||||||
<div class="poppins text-text-sub text-[.9rem]">
|
<div class="poppins text-lyx-lightmode-text-dark dark:text-text-sub text-[.9rem]">
|
||||||
Check the usage limits of your project.
|
Check the usage limits of your project.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,7 +241,7 @@ const { visible } = usePricingDrawer();
|
|||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|
||||||
<div class="flex justify-between items-center bg-[#161616] p-4 rounded-lg"
|
<div class="flex justify-between items-center outline-[1px] outline outline-lyx-lightmode-widget dark:outline-none bg-lyx-lightmode-widget-light dark:bg-[#161616] p-4 rounded-lg"
|
||||||
v-for="invoice of invoices">
|
v-for="invoice of invoices">
|
||||||
|
|
||||||
<div> <i class="fal fa-file-invoice"></i> </div>
|
<div> <i class="fal fa-file-invoice"></i> </div>
|
||||||
@@ -263,6 +267,10 @@ const { visible } = usePricingDrawer();
|
|||||||
</CardTitled>
|
</CardTitled>
|
||||||
</template>
|
</template>
|
||||||
</SettingsTemplate>
|
</SettingsTemplate>
|
||||||
|
|
||||||
|
<div v-if="isGuest" class="text-lyx-text-darker flex w-full h-full justify-center mt-20">
|
||||||
|
Guests cannot view billing
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { SettingsTemplateEntry } from './Template.vue';
|
|
||||||
|
|
||||||
const { projectId, isGuest } = useProject();
|
|
||||||
|
|
||||||
definePageMeta({ layout: 'dashboard' });
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ key: 'me', label: '' },
|
|
||||||
{ key: 'email', label: 'Email' },
|
|
||||||
{ key: 'name', label: 'Name' },
|
|
||||||
{ key: 'role', label: 'Role' },
|
|
||||||
{ key: 'action', label: 'Actions' },
|
|
||||||
// { key: 'pending', label: 'Pending' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const { data: members, refresh: refreshMembers } = useFetch('/api/project/members/list', {
|
|
||||||
headers: useComputedHeaders({ useSnapshotDates: false })
|
|
||||||
});
|
|
||||||
|
|
||||||
const showAddMember = ref<boolean>(false);
|
|
||||||
|
|
||||||
const addMemberEmail = ref<string>("");
|
|
||||||
|
|
||||||
async function kickMember(email: string) {
|
|
||||||
|
|
||||||
const sure = confirm('Are you sure to kick ' + email + ' ?');
|
|
||||||
if (!sure) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
await $fetch('/api/project/members/kick', {
|
|
||||||
method: 'POST',
|
|
||||||
...signHeaders({
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-pid': projectId.value ?? ''
|
|
||||||
}),
|
|
||||||
body: JSON.stringify({ email }),
|
|
||||||
onResponseError({ request, response, options }) {
|
|
||||||
alert(response.statusText);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
refreshMembers();
|
|
||||||
|
|
||||||
} catch (ex: any) { }
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addMember() {
|
|
||||||
|
|
||||||
if (addMemberEmail.value.length === 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
showAddMember.value = false;
|
|
||||||
|
|
||||||
await $fetch('/api/project/members/add', {
|
|
||||||
method: 'POST',
|
|
||||||
...signHeaders({
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-pid': projectId.value ?? ''
|
|
||||||
}),
|
|
||||||
body: JSON.stringify({ email: addMemberEmail.value }),
|
|
||||||
onResponseError({ request, response, options }) {
|
|
||||||
alert(response.statusText);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
addMemberEmail.value = '';
|
|
||||||
|
|
||||||
refreshMembers();
|
|
||||||
|
|
||||||
} catch (ex: any) { }
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries: SettingsTemplateEntry[] = [
|
|
||||||
{ id: 'add', title: 'Add member', text: 'Add new member to project' },
|
|
||||||
{ id: 'members', title: 'Members', text: 'Manage members of current project' },
|
|
||||||
]
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<i v-if="e.row.me" class="far fa-user"></i>
|
|
||||||
<i v-if="!e.row.me"></i>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #action-data="e" v-if="!isGuest">
|
|
||||||
<div @click="kickMember(e.row.email)" v-if="e.row.role != 'OWNER'"
|
|
||||||
class="text-red-500 hover:bg-black/20 cursor-pointer outline outline-[1px] outline-red-500 px-3 py-1 rounded-lg text-center">
|
|
||||||
Kick
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
</UTable>
|
|
||||||
</template>
|
|
||||||
</SettingsTemplate>
|
|
||||||
|
|
||||||
</template>
|
|
||||||
127
dashboard/composables/snapshots/BaseSnapshots.ts
Normal file
127
dashboard/composables/snapshots/BaseSnapshots.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
|
||||||
|
import type { TProjectSnapshot } from "@schema/project/ProjectSnapshot";
|
||||||
|
|
||||||
|
import * as fns from 'date-fns';
|
||||||
|
|
||||||
|
export type DefaultSnapshot = TProjectSnapshot & { default: true }
|
||||||
|
export type GenericSnapshot = TProjectSnapshot | DefaultSnapshot;
|
||||||
|
|
||||||
|
export function getDefaultSnapshots(project_id: TProjectSnapshot['project_id'], project_created_at: Date | string) {
|
||||||
|
|
||||||
|
const today: DefaultSnapshot = {
|
||||||
|
project_id,
|
||||||
|
_id: '___today' as any,
|
||||||
|
name: 'Today',
|
||||||
|
from: fns.startOfDay(Date.now()),
|
||||||
|
to: fns.endOfDay(Date.now()),
|
||||||
|
color: '#FFA600',
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastDay: DefaultSnapshot = {
|
||||||
|
project_id,
|
||||||
|
_id: '___lastDay' as any,
|
||||||
|
name: 'Yesterday',
|
||||||
|
from: fns.startOfDay(fns.subDays(Date.now(), 1)),
|
||||||
|
to: fns.endOfDay(fns.subDays(Date.now(), 1)),
|
||||||
|
color: '#FF8531',
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const lastMonth: DefaultSnapshot = {
|
||||||
|
project_id,
|
||||||
|
_id: '___lastMonth' as any,
|
||||||
|
name: 'Last Month',
|
||||||
|
from: fns.startOfMonth(fns.subMonths(Date.now(), 1)),
|
||||||
|
to: fns.endOfMonth(fns.subMonths(Date.now(), 1)),
|
||||||
|
color: '#BC5090',
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
const currentMonth: DefaultSnapshot = {
|
||||||
|
project_id,
|
||||||
|
_id: '___currentMonth' as any,
|
||||||
|
name: 'Current Month',
|
||||||
|
from: fns.startOfMonth(Date.now()),
|
||||||
|
to: fns.endOfMonth(Date.now()),
|
||||||
|
color: '#58508D',
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const lastWeek: DefaultSnapshot = {
|
||||||
|
project_id,
|
||||||
|
_id: '___lastWeek' as any,
|
||||||
|
name: 'Last Week',
|
||||||
|
from: fns.startOfWeek(fns.subWeeks(Date.now(), 1)),
|
||||||
|
to: fns.endOfWeek(fns.subWeeks(Date.now(), 1)),
|
||||||
|
color: '#3E909D',
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const currentWeek: DefaultSnapshot = {
|
||||||
|
project_id,
|
||||||
|
_id: '___currentWeek' as any,
|
||||||
|
name: 'Current Week',
|
||||||
|
from: fns.startOfWeek(Date.now()),
|
||||||
|
to: fns.endOfWeek(Date.now()),
|
||||||
|
color: '#007896',
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const allTime: DefaultSnapshot = {
|
||||||
|
project_id,
|
||||||
|
_id: '___allTime' as any,
|
||||||
|
name: 'All Time',
|
||||||
|
from: fns.addMinutes(fns.startOfMonth(new Date(project_created_at.toString())), -new Date().getTimezoneOffset()),
|
||||||
|
to: fns.addMilliseconds(fns.endOfDay(Date.now()), 1),
|
||||||
|
color: '#9362FF',
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const last30Days: DefaultSnapshot = {
|
||||||
|
project_id,
|
||||||
|
_id: '___last30days' as any,
|
||||||
|
name: 'Last 30 days',
|
||||||
|
from: fns.startOfDay(fns.subDays(Date.now(), 30)),
|
||||||
|
to: fns.endOfDay(fns.subDays(Date.now(), 0)),
|
||||||
|
color: '#606c38',
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const last60Days: DefaultSnapshot = {
|
||||||
|
project_id,
|
||||||
|
_id: '___last60days' as any,
|
||||||
|
name: 'Last 60 days',
|
||||||
|
from: fns.startOfDay(fns.subDays(Date.now(), 60)),
|
||||||
|
to: fns.endOfDay(fns.subDays(Date.now(), 0)),
|
||||||
|
color: '#bc6c25',
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const last90Days: DefaultSnapshot = {
|
||||||
|
project_id,
|
||||||
|
_id: '___last90days' as any,
|
||||||
|
name: 'Last 90 days',
|
||||||
|
from: fns.startOfDay(fns.subDays(Date.now(), 90)),
|
||||||
|
to: fns.endOfDay(fns.subDays(Date.now(), 0)),
|
||||||
|
color: '#fefae0',
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const snapshotList = [
|
||||||
|
allTime,
|
||||||
|
lastDay, today,
|
||||||
|
lastWeek, currentWeek,
|
||||||
|
lastMonth, currentMonth,
|
||||||
|
last30Days,
|
||||||
|
last60Days, last90Days,
|
||||||
|
]
|
||||||
|
|
||||||
|
return snapshotList;
|
||||||
|
|
||||||
|
}
|
||||||
13
dashboard/composables/ui/useSelectMenuStyle.ts
Normal file
13
dashboard/composables/ui/useSelectMenuStyle.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
export function useSelectMenuStyle() {
|
||||||
|
return {
|
||||||
|
uiMenu: {
|
||||||
|
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
|
||||||
|
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
|
||||||
|
option: {
|
||||||
|
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||||
|
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,10 +34,19 @@ function createAlert(title: string, text: string, icon: string, ms: number) {
|
|||||||
}, 250)
|
}, 250)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSuccessAlert(title: string, text: string, ms?: number) {
|
||||||
|
return createAlert(title, text, 'far fa-circle-check', ms ?? 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createErrorAlert(title: string, text: string, ms?: number) {
|
||||||
|
return createAlert(title, text, 'far fa-triangle-exclamation', ms ?? 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function closeAlert(id: number) {
|
function closeAlert(id: number) {
|
||||||
alerts.value = alerts.value.filter(e => e.id != id);
|
alerts.value = alerts.value.filter(e => e.id != id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAlert() {
|
export function useAlert() {
|
||||||
return { alerts, createAlert, closeAlert }
|
return { alerts, createAlert, closeAlert, createSuccessAlert, createErrorAlert }
|
||||||
}
|
}
|
||||||
42
dashboard/composables/useCountryName.ts
Normal file
42
dashboard/composables/useCountryName.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const countryMap: Record<string, string> = {
|
||||||
|
RW: "Rwanda", SO: "Somalia", YE: "Yemen", IQ: "Iraq", SA: "Saudi Arabia", IR: "Iran", CY: "Cyprus", TZ: "Tanzania",
|
||||||
|
SY: "Syria", AM: "Armenia", KE: "Kenya", CD: "Congo", DJ: "Djibouti", UG: "Uganda", CF: "Central African Republic",
|
||||||
|
SC: "Seychelles", JO: "Jordan", LB: "Lebanon", KW: "Kuwait", OM: "Oman", QA: "Qatar", BH: "Bahrain", AE: "United Arab Emirates",
|
||||||
|
IL: "Israel", TR: "Türkiye", ET: "Ethiopia", ER: "Eritrea", EG: "Egypt", SD: "Sudan", GR: "Greece", BI: "Burundi",
|
||||||
|
EE: "Estonia", LV: "Latvia", AZ: "Azerbaijan", LT: "Lithuania", SJ: "Svalbard and Jan Mayen", GE: "Georgia", MD: "Moldova",
|
||||||
|
BY: "Belarus", FI: "Finland", AX: "Åland Islands", UA: "Ukraine", MK: "North Macedonia", HU: "Hungary", BG: "Bulgaria",
|
||||||
|
AL: "Albania", PL: "Poland", RO: "Romania", XK: "Kosovo", ZW: "Zimbabwe", ZM: "Zambia", KM: "Comoros", MW: "Malawi",
|
||||||
|
LS: "Lesotho", BW: "Botswana", MU: "Mauritius", SZ: "Eswatini", RE: "Réunion", ZA: "South Africa", YT: "Mayotte",
|
||||||
|
MZ: "Mozambique", MG: "Madagascar", AF: "Afghanistan", PK: "Pakistan", BD: "Bangladesh", TM: "Turkmenistan", TJ: "Tajikistan",
|
||||||
|
LK: "Sri Lanka", BT: "Bhutan", IN: "India", MV: "Maldives", IO: "British Indian Ocean Territory", NP: "Nepal", MM: "Myanmar",
|
||||||
|
UZ: "Uzbekistan", KZ: "Kazakhstan", KG: "Kyrgyzstan", TF: "French Southern Territories", HM: "Heard and McDonald Islands",
|
||||||
|
CC: "Cocos (Keeling) Islands", PW: "Palau", VN: "Vietnam", TH: "Thailand", ID: "Indonesia", LA: "Laos", TW: "Taiwan",
|
||||||
|
PH: "Philippines", MY: "Malaysia", CN: "China", HK: "Hong Kong", BN: "Brunei", MO: "Macao", KH: "Cambodia", KR: "South Korea",
|
||||||
|
JP: "Japan", KP: "North Korea", SG: "Singapore", CK: "Cook Islands", TL: "Timor-Leste", RU: "Russia", MN: "Mongolia",
|
||||||
|
AU: "Australia", CX: "Christmas Island", MH: "Marshall Islands", FM: "Federated States of Micronesia", PG: "Papua New Guinea",
|
||||||
|
SB: "Solomon Islands", TV: "Tuvalu", NR: "Nauru", VU: "Vanuatu", NC: "New Caledonia", NF: "Norfolk Island", NZ: "New Zealand",
|
||||||
|
FJ: "Fiji", LY: "Libya", CM: "Cameroon", SN: "Senegal", CG: "Congo Republic", PT: "Portugal", LR: "Liberia", CI: "Ivory Coast", GH: "Ghana",
|
||||||
|
GQ: "Equatorial Guinea", NG: "Nigeria", BF: "Burkina Faso", TG: "Togo", GW: "Guinea-Bissau", MR: "Mauritania", BJ: "Benin", GA: "Gabon",
|
||||||
|
SL: "Sierra Leone", ST: "São Tomé and Príncipe", GI: "Gibraltar", GM: "Gambia", GN: "Guinea", TD: "Chad", NE: "Niger", ML: "Mali",
|
||||||
|
EH: "Western Sahara", TN: "Tunisia", ES: "Spain", MA: "Morocco", MT: "Malta", DZ: "Algeria", FO: "Faroe Islands", DK: "Denmark",
|
||||||
|
IS: "Iceland", GB: "United Kingdom", CH: "Switzerland", SE: "Sweden", NL: "The Netherlands", AT: "Austria", BE: "Belgium",
|
||||||
|
DE: "Germany", LU: "Luxembourg", IE: "Ireland", MC: "Monaco", FR: "France", AD: "Andorra", LI: "Liechtenstein", JE: "Jersey",
|
||||||
|
IM: "Isle of Man", GG: "Guernsey", SK: "Slovakia", CZ: "Czechia", NO: "Norway", VA: "Vatican City", SM: "San Marino",
|
||||||
|
IT: "Italy", SI: "Slovenia", ME: "Montenegro", HR: "Croatia", BA: "Bosnia and Herzegovina", AO: "Angola", NA: "Namibia",
|
||||||
|
SH: "Saint Helena", BV: "Bouvet Island", BB: "Barbados", CV: "Cabo Verde", GY: "Guyana", GF: "French Guiana", SR: "Suriname",
|
||||||
|
PM: "Saint Pierre and Miquelon", GL: "Greenland", PY: "Paraguay", UY: "Uruguay", BR: "Brazil", FK: "Falkland Islands",
|
||||||
|
GS: "South Georgia and the South Sandwich Islands", JM: "Jamaica", DO: "Dominican Republic", CU: "Cuba", MQ: "Martinique",
|
||||||
|
BS: "Bahamas", BM: "Bermuda", AI: "Anguilla", TT: "Trinidad and Tobago", KN: "St Kitts and Nevis", DM: "Dominica",
|
||||||
|
AG: "Antigua and Barbuda", LC: "Saint Lucia", TC: "Turks and Caicos Islands", AW: "Aruba", VG: "British Virgin Islands",
|
||||||
|
VC: "St Vincent and Grenadines", MS: "Montserrat", MF: "Saint Martin", BL: "Saint Barthélemy", GP: "Guadeloupe",
|
||||||
|
GD: "Grenada", KY: "Cayman Islands", BZ: "Belize", SV: "El Salvador", GT: "Guatemala", HN: "Honduras", NI: "Nicaragua",
|
||||||
|
CR: "Costa Rica", VE: "Venezuela", EC: "Ecuador", CO: "Colombia", PA: "Panama", HT: "Haiti", AR: "Argentina", CL: "Chile",
|
||||||
|
BO: "Bolivia", PE: "Peru", MX: "Mexico", PF: "French Polynesia", PN: "Pitcairn Islands", KI: "Kiribati", TK: "Tokelau",
|
||||||
|
TO: "Tonga", WF: "Wallis and Futuna", WS: "Samoa", NU: "Niue", MP: "Northern Mariana Islands", GU: "Guam", PR: "Puerto Rico",
|
||||||
|
VI: "U.S. Virgin Islands", UM: "U.S. Outlying Islands", AS: "American Samoa", CA: "Canada", US: "United States",
|
||||||
|
PS: "Palestine", RS: "Serbia", AQ: "Antarctica", SX: "Sint Maarten", CW: "Curaçao", BQ: "Bonaire", SS: "South Sudan"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCountryName(iso: string) {
|
||||||
|
return countryMap[iso] as string | undefined;
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export type CustomDialogOptions = {
|
|||||||
params?: any,
|
params?: any,
|
||||||
width?: string,
|
width?: string,
|
||||||
height?: string,
|
height?: string,
|
||||||
closable?: boolean
|
closable?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDialogEx(component: Component, options?: CustomDialogOptions) {
|
function openDialogEx(component: Component, options?: CustomDialogOptions) {
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import type { StringExpressionOperator } from "mongoose";
|
|
||||||
|
|
||||||
type RefOrPrimitive<T> = T | Ref<T> | ComputedRef<T>
|
type RefOrPrimitive<T> = T | Ref<T> | ComputedRef<T>
|
||||||
|
|
||||||
export type CustomOptions = {
|
export type CustomOptions = {
|
||||||
useSnapshotDates?: boolean,
|
useSnapshotDates?: boolean,
|
||||||
|
useActiveDomain?: boolean,
|
||||||
useActivePid?: boolean,
|
useActivePid?: boolean,
|
||||||
|
useTimeOffset?: boolean,
|
||||||
slice?: RefOrPrimitive<string>,
|
slice?: RefOrPrimitive<string>,
|
||||||
limit?: RefOrPrimitive<number | string>,
|
limit?: RefOrPrimitive<number | string>,
|
||||||
custom?: Record<string, RefOrPrimitive<string>>
|
custom?: Record<string, RefOrPrimitive<string>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const { token } = useAccessToken();
|
const { token } = useAccessToken();
|
||||||
const { projectId } = useProject();
|
const { projectId } = useProject();
|
||||||
const { safeSnapshotDates } = useSnapshot()
|
const { safeSnapshotDates } = useSnapshot()
|
||||||
|
const { domain } = useDomain();
|
||||||
|
|
||||||
function getValueFromRefOrPrimitive<T>(data?: T | Ref<T> | ComputedRef<T>) {
|
function getValueFromRefOrPrimitive<T>(data?: T | Ref<T> | ComputedRef<T>) {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
@@ -23,9 +25,11 @@ function getValueFromRefOrPrimitive<T>(data?: T | Ref<T> | ComputedRef<T>) {
|
|||||||
export function useComputedHeaders(customOptions?: CustomOptions) {
|
export function useComputedHeaders(customOptions?: CustomOptions) {
|
||||||
const useSnapshotDates = customOptions?.useSnapshotDates || true;
|
const useSnapshotDates = customOptions?.useSnapshotDates || true;
|
||||||
const useActivePid = customOptions?.useActivePid || true;
|
const useActivePid = customOptions?.useActivePid || true;
|
||||||
|
const useTimeOffset = customOptions?.useTimeOffset || true;
|
||||||
|
const useActiveDomain = customOptions?.useActiveDomain || true;
|
||||||
|
|
||||||
const headers = computed<Record<string, string>>(() => {
|
const headers = computed<Record<string, string>>(() => {
|
||||||
|
// console.trace('Computed recalculated');
|
||||||
const parsedCustom: Record<string, string> = {}
|
const parsedCustom: Record<string, string> = {}
|
||||||
const customKeys = Object.keys(customOptions?.custom || {});
|
const customKeys = Object.keys(customOptions?.custom || {});
|
||||||
for (const key of customKeys) {
|
for (const key of customKeys) {
|
||||||
@@ -37,11 +41,15 @@ export function useComputedHeaders(customOptions?: CustomOptions) {
|
|||||||
'x-pid': useActivePid ? (projectId.value ?? '') : '',
|
'x-pid': useActivePid ? (projectId.value ?? '') : '',
|
||||||
'x-from': useSnapshotDates ? (safeSnapshotDates.value.from ?? '') : '',
|
'x-from': useSnapshotDates ? (safeSnapshotDates.value.from ?? '') : '',
|
||||||
'x-to': useSnapshotDates ? (safeSnapshotDates.value.to ?? '') : '',
|
'x-to': useSnapshotDates ? (safeSnapshotDates.value.to ?? '') : '',
|
||||||
|
'x-time-offset': useTimeOffset ? (new Date().getTimezoneOffset().toString()) : '',
|
||||||
'x-slice': getValueFromRefOrPrimitive(customOptions?.slice) ?? '',
|
'x-slice': getValueFromRefOrPrimitive(customOptions?.slice) ?? '',
|
||||||
'x-limit': getValueFromRefOrPrimitive(customOptions?.limit)?.toString() ?? '',
|
'x-limit': getValueFromRefOrPrimitive(customOptions?.limit)?.toString() ?? '',
|
||||||
|
'x-domain': useActiveDomain ? (domain.value ?? '') : '',
|
||||||
...parsedCustom
|
...parsedCustom
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user