29 Commits

Author SHA1 Message Date
Emily
4134d33dc4 fix pdf + admin panel 2024-09-10 16:59:34 +02:00
Emily
5172ad4f4d add api support 2024-09-09 14:43:27 +02:00
Emily
be45448288 add api keys 2024-09-08 15:51:03 +02:00
Emily
73739dde9d fix pricing + limits email + redis 2024-09-07 15:47:13 +02:00
Emily
30b3ed80e2 fix pricing + stripe payments 2024-09-05 16:56:21 +02:00
antonio
8e56069b1a fix on readme curl example 2024-09-05 13:14:23 +02:00
Antonio Verdiglione
3ecdec9ca9 Merge pull request #16 from art-santos/issue-15
fix: css bug in header
2024-09-05 11:54:14 +02:00
Arthur Santos
7b41a3ed0d fix: css bug in header 2024-09-04 09:28:56 -03:00
Emily
5804d7a73b fix support type from Discord to Slack 2024-09-04 14:01:06 +02:00
Emily
8b026099de fix dashboard + live demo 2024-09-04 14:00:03 +02:00
Emily
d7e18d570f fix userAgent device type 2024-09-04 13:59:53 +02:00
Emily
023f2b5f4a aggregation optimization 2024-09-02 18:37:02 +02:00
Emily
c003b655ec aggregation optimization 2024-09-02 18:36:52 +02:00
Emily
d499aa2f39 remove project_max text 2024-09-02 18:14:25 +02:00
Emily
944996eb15 add proper limit + csv lock 2024-09-02 15:24:29 +02:00
antonio
87b1f9caf9 improvements on readme 2024-09-01 14:49:44 +02:00
Emily
748894b946 . 2024-08-30 17:32:36 +02:00
Emily
01e8a9ab1d . 2024-08-30 17:30:18 +02:00
Emily
a2034551ec add log to stream loop 2024-08-30 17:27:34 +02:00
Emily
6d26c3c8af add brevo email 2024-08-30 16:26:53 +02:00
Emily
518b4ce6c1 add blog-post + links 2024-08-30 14:59:17 +02:00
Emily
71bd4d0e58 remove beta text 2024-08-30 14:16:07 +02:00
Emily
0563a833eb . 2024-08-29 16:34:26 +02:00
Emily
ab07ffb108 fix limits 2024-08-29 16:31:50 +02:00
Emily
79309cc537 add tests infrastructure 2024-08-29 16:31:44 +02:00
Emily
9b9ed3e9ad update mailsave to https 2024-08-29 15:09:43 +02:00
Emily
1cb6b92d5c add mail + github stars 2024-08-29 14:55:42 +02:00
Antonio Verdiglione
c1bdc30933 Merge pull request #12 from bradenhirschi/readme
Updated readme for better English
2024-08-19 15:59:36 +02:00
Braden Hirschi
887ed45b4d Updated readme for better English 2024-08-09 12:15:08 -07:00
88 changed files with 7070 additions and 953 deletions

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/claim.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
assets/dashboard-clip.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

BIN
assets/tech.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

3
broker/.gitignore vendored
View File

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

View File

@@ -7,9 +7,7 @@ module.exports = {
script: './dist/producer/src/index.js', script: './dist/producer/src/index.js',
env: { env: {
EMAIL_SERVICE: "", EMAIL_SERVICE: "",
EMAIL_HOST: "", BREVO_API_KEY: "",
EMAIL_USER: "",
EMAIL_PASS: "",
PORT: "", PORT: "",
MONGO_CONNECTION_STRING: "", MONGO_CONNECTION_STRING: "",
REDIS_URL: "", REDIS_URL: "",

13
broker/jest.config.js Normal file
View File

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

View File

@@ -1,5 +1,6 @@
{ {
"dependencies": { "dependencies": {
"@getbrevo/brevo": "^2.2.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"mongoose": "^8.3.2", "mongoose": "^8.3.2",
@@ -8,13 +9,17 @@
"ua-parser-js": "^1.0.37" "ua-parser-js": "^1.0.37"
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "^29.7.0",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.12.13", "@types/node": "^20.12.13",
"@types/nodemailer": "^6.4.15", "@types/nodemailer": "^6.4.15",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"glob": "^10.4.1", "glob": "^10.4.1",
"jest": "^29.7.0",
"node-ssh": "^13.2.0", "node-ssh": "^13.2.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.4.5" "typescript": "^5.4.5"
}, },
@@ -28,10 +33,11 @@
"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", "build_all": "npm run compile && npm run build && npm run create_db",
"docker-build": "docker build -t litlyx-broker -f Dockerfile ../", "docker-build": "docker build -t litlyx-broker -f Dockerfile ../",
"docker-inspect": "docker run -it litlyx-broker sh" "docker-inspect": "docker run -it litlyx-broker sh",
"test": "jest"
}, },
"keywords": [], "keywords": [],
"author": "Emily", "author": "Emily",
"license": "MIT", "license": "MIT",
"description": "Queue broker for Litlyx - Saves events to database." "description": "Queue broker for Litlyx - Saves events to database."
} }

3218
broker/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,25 +6,68 @@ import { requireEnv } from "../../shared/utilts/requireEnv";
import { TProjectLimit } from "@schema/ProjectsLimits"; import { TProjectLimit } from "@schema/ProjectsLimits";
if (process.env.EMAIL_SERVICE) { if (process.env.EMAIL_SERVICE) {
EmailService.createTransport( EmailService.init(requireEnv('BREVO_API_KEY'));
requireEnv('EMAIL_SERVICE'),
requireEnv('EMAIL_HOST'),
requireEnv('EMAIL_USER'),
requireEnv('EMAIL_PASS'),
);
} }
export async function checkLimitsForEmail(projectCounts: TProjectLimit) { export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit / 2)) { console.log('CHECK LIMIT EMAIL');
const notify = await LimitNotifyModel.findOne({ project_id: projectCounts._id });
if (notify && notify.limit1 === true) return; const project_id = projectCounts.project_id;
const project = await ProjectModel.findById(projectCounts.project_id); const hasNotifyEntry = await LimitNotifyModel.findOne({ project_id });
if (!project) return; if (!hasNotifyEntry) {
const owner = await UserModel.findById(project.owner); await LimitNotifyModel.create({ project_id, limit1: false, limit2: false, limit3: false })
if (!owner) return;
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail50(owner.email);
await LimitNotifyModel.updateOne({ project_id: projectCounts._id }, { limit1: true, limit2: false, limit3: false }, { upsert: true });
} }
} if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit)) {
console.log('LIMIT 3');
const notify = await LimitNotifyModel.findOne({ project_id });
if (notify && notify.limit3 === true) return;
const project = await ProjectModel.findById(project_id);
if (!project) return;
const owner = await UserModel.findById(project.owner);
if (!owner) return;
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmailMax(owner.email, project.name);
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: true });
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.9)) {
console.log('LIMIT 2');
const notify = await LimitNotifyModel.findOne({ project_id });
if (notify && notify.limit2 === true) return;
const project = await ProjectModel.findById(project_id);
if (!project) return;
const owner = await UserModel.findById(project.owner);
if (!owner) return;
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail90(owner.email, project.name);
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: false });
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.5)) {
console.log('LIMIT 1');
const notify = await LimitNotifyModel.findOne({ project_id });
if (notify && notify.limit1 === true) return;
const project = await ProjectModel.findById(project_id);
if (!project) return;
const owner = await UserModel.findById(project.owner);
if (!owner) return;
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail50(owner.email, project.name);
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: false, limit3: false });
}
}

View File

@@ -19,27 +19,24 @@ export async function startStreamLoop() {
await RedisStreamService.startReadingLoop({ await RedisStreamService.startReadingLoop({
streamName: requireEnv('STREAM_NAME'), streamName: requireEnv('STREAM_NAME'),
delay: { base: 100, empty: 5000 }, delay: { base: 10, empty: 5000 },
readBlock: 2500 readBlock: 2000
}, processStreamEvent); }, processStreamEvent);
} }
export async function processStreamEvent(data: Record<string, string>) {
async function processStreamEvent(data: Record<string, string>) {
try { try {
const eventType = data._type; const eventType = data._type;
if (!eventType) return; if (!eventType) return;
const { pid, sessionHash } = data; const { pid, sessionHash } = data;
const project = await ProjectModel.exists({ _id: pid }); const project = await ProjectModel.exists({ _id: pid });
if (!project) return; if (!project) return;
if (eventType === 'event') return await process_event(data, sessionHash); if (eventType === 'event') return await process_event(data, sessionHash);
if (eventType === 'keep_alive') return await process_keep_alive(data, sessionHash); if (eventType === 'keep_alive') return await process_keep_alive(data, sessionHash);
if (eventType === 'visit') return await process_visit(data, sessionHash); if (eventType === 'visit') return await process_visit(data, sessionHash);
@@ -50,17 +47,23 @@ async function processStreamEvent(data: Record<string, string>) {
} }
async function checkLimits(project_id: string) {
const projectLimits = await ProjectLimitModel.findOne({ project_id });
if (!projectLimits) return false;
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
const COUNT_LIMIT = projectLimits.limit;
if ((TOTAL_COUNT) > COUNT_LIMIT * EVENT_LOG_LIMIT_PERCENT) return false;
await checkLimitsForEmail(projectLimits);
return true;
}
async function process_visit(data: Record<string, string>, sessionHash: string) { 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 } = data;
const projectLimits = await ProjectLimitModel.findOne({ project_id: pid }); const canLog = await checkLimits(pid);
if (!projectLimits) return; if (!canLog) return;
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
const COUNT_LIMIT = projectLimits.limit;
if ((TOTAL_COUNT) > COUNT_LIMIT * EVENT_LOG_LIMIT_PERCENT) return;
await checkLimitsForEmail(projectLimits);
let referrerParsed; let referrerParsed;
try { try {
@@ -73,11 +76,13 @@ async function process_visit(data: Record<string, string>, sessionHash: string)
const userAgentParsed = UAParser(userAgent); const userAgentParsed = UAParser(userAgent);
const device = userAgentParsed.device.type;
const visit = new VisitModel({ const visit = new VisitModel({
project_id: pid, website, page, referrer: referrerParsed.hostname, project_id: pid, website, page, referrer: referrerParsed.hostname,
browser: userAgentParsed.browser.name || 'NO_BROWSER', browser: userAgentParsed.browser.name || 'NO_BROWSER',
os: userAgentParsed.os.name || 'NO_OS', os: userAgentParsed.os.name || 'NO_OS',
device: userAgentParsed.device.type, device: device ? device : (userAgentParsed.browser.name ? 'desktop' : undefined),
session: sessionHash, session: sessionHash,
flowHash, flowHash,
continent: geoLocation[0], continent: geoLocation[0],
@@ -97,7 +102,10 @@ async function process_keep_alive(data: Record<string, string>, sessionHash: str
const { pid, instant, flowHash } = data; const { pid, instant, flowHash } = data;
const existingSession = await SessionModel.findOne({ project_id: pid }, { _id: 1 }); const canLog = await checkLimits(pid);
if (!canLog) return;
const existingSession = await SessionModel.findOne({ project_id: pid, session: sessionHash }, { _id: 1 });
if (!existingSession) { if (!existingSession) {
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'sessions': 1 } }, { upsert: true }); await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'sessions': 1 } }, { upsert: true });
} }
@@ -123,6 +131,9 @@ async function process_event(data: Record<string, string>, sessionHash: string)
const { name, metadata, pid, flowHash } = data; const { name, metadata, pid, flowHash } = data;
const canLog = await checkLimits(pid);
if (!canLog) return;
let metadataObject; let metadataObject;
try { try {
if (metadata) metadataObject = JSON.parse(metadata); if (metadata) metadataObject = JSON.parse(metadata);

View File

@@ -1,9 +1,14 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": ".",
"module": "NodeNext", "module": "NodeNext",
"target": "ESNext", "target": "ESNext",
"esModuleInterop": true, "esModuleInterop": true,
"outDir": "dist", "outDir": "dist",
"types": [
"node",
"jest"
],
"paths": { "paths": {
"@schema/*": [ "@schema/*": [
"../shared/schema/*" "../shared/schema/*"
@@ -21,7 +26,9 @@
}, },
"include": [ "include": [
"src/**/*.ts", "src/**/*.ts",
"scripts/**/*.ts" "scripts/**/*.ts",
"tests/**/*.test.ts",
"tests/utils.ts"
], ],
"exclude": [ "exclude": [
"node_modules" "node_modules"

View File

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

View File

@@ -31,4 +31,8 @@ logs
out.pdf out.pdf
# TESTS - TO REMOVE # TESTS - TO REMOVE
tests tests
# EXPLAINS MONGODB
explains

View File

@@ -9,12 +9,28 @@ const debugMode = process.dev;
const { alerts, closeAlert } = useAlert(); const { alerts, closeAlert } = useAlert();
const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dialogClosable } = useCustomDialog(); const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dialogClosable } = useCustomDialog();
const { visible } = usePricingDrawer();
const { data: planData } = useFetch('/api/project/plan', {
...signHeaders(),
lazy: true
});
</script> </script>
<template> <template>
<div class="w-dvw h-dvh bg-lyx-background-light relative"> <div class="w-dvw h-dvh bg-lyx-background-light relative">
<Transition name="pdrawer">
<LazyPricingDrawer @onCloseClick="visible = false" :currentSub="planData?.premium_type || 0"
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if=visible>
</LazyPricingDrawer>
</Transition>
<div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0"> <div 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-[#151515] overflow-hidden border-solid border-[2px] border-[#262626] rounded-lg p-6 drop-shadow-lg">
@@ -64,3 +80,19 @@ const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dia
</template> </template>
<style scoped lang="scss">
.pdrawer-enter-active,
.pdrawer-leave-active {
transition: all .5s ease-in-out;
}
.pdrawer-enter-from,
.pdrawer-leave-to {
transform: translateX(100%)
}
.pdrawer-enter-to,
.pdrawer-leave-from {
transform: translateX(0)
}
</style>

View File

@@ -10,6 +10,7 @@ export type Entry = {
icon?: string, icon?: string,
action?: () => any, action?: () => any,
adminOnly?: boolean, adminOnly?: boolean,
premiumOnly?:boolean,
external?: boolean, external?: boolean,
grow?: boolean grow?: boolean
} }
@@ -70,7 +71,11 @@ async function generatePDF() {
try { try {
const res = await $fetch<Blob>('/api/project/generate_pdf', { const res = await $fetch<Blob>('/api/project/generate_pdf', {
...signHeaders(), ...signHeaders({
'x-snapshot-name': snapshot.value.name,
'x-from': snapshot.value.from.toISOString(),
'x-to': snapshot.value.to.toISOString(),
}),
responseType: 'blob' responseType: 'blob'
}); });
@@ -112,6 +117,10 @@ watch(selected, () => {
setActiveProject(selected.value._id.toString()) setActiveProject(selected.value._id.toString())
}) })
const isPremium = computed(()=>{
return activeProject.value?.premium;
})
</script> </script>
<template> <template>
@@ -138,7 +147,7 @@ watch(selected, () => {
<template #option="{ option, active, selected }"> <template #option="{ option, active, selected }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div> <div>
<img class="h-5 bg-black rounded-full" :src="'logo_32.png'" alt="Litlyx logo"> <img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
</div> </div>
<div> {{ option.name }} </div> <div> {{ option.name }} </div>
</div> </div>
@@ -147,7 +156,7 @@ watch(selected, () => {
<template #label> <template #label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div> <div>
<img class="h-5 bg-black rounded-full" :src="'logo_32.png'" alt="Litlyx logo"> <img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
</div> </div>
<div> {{ activeProject?.name || '???' }} </div> <div> {{ activeProject?.name || '???' }} </div>
</div> </div>
@@ -249,9 +258,9 @@ watch(selected, () => {
<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"> <div v-for="section of sections" class="flex flex-col gap-1 h-full pb-6">
<div v-for="entry of section.entries"> <div v-for="entry of section.entries" :class="{ 'grow flex items-end': entry.grow }">
<div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))" <div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))"
class="bg-lyx-background cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center" class="bg-lyx-background cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
@@ -266,9 +275,12 @@ watch(selected, () => {
<div class="flex items-center w-[1.4rem] mr-2 text-[1.1rem] justify-center"> <div class="flex items-center w-[1.4rem] mr-2 text-[1.1rem] justify-center">
<i :class="entry.icon"></i> <i :class="entry.icon"></i>
</div> </div>
<div class="manrope"> <div class="manrope grow">
{{ entry.label }} {{ entry.label }}
</div> </div>
<div v-if="entry.premiumOnly && !isPremium" class="flex items-center">
<i class="fal fa-lock"></i>
</div>
</NuxtLink> </NuxtLink>
</div> </div>
@@ -278,9 +290,6 @@ watch(selected, () => {
</div> </div>
<div class="grow"></div> <div class="grow"></div>
<div class="text-lyx-text-dark poppins text-[.8rem] px-4 pb-3">
Litlyx is in Beta version.
</div>
<div class="bg-lyx-widget-lighter h-[2px] px-4 w-full mb-3"></div> <div class="bg-lyx-widget-lighter h-[2px] px-4 w-full mb-3"></div>
<div class="flex justify-end px-2"> <div class="flex justify-end px-2">

View File

@@ -36,7 +36,7 @@ const props = defineProps<{
{{ trend.toFixed(0) }} % {{ trend.toFixed(0) }} %
</div> </div>
</div> </div>
<div class="poppins text-text-sub text-[.7rem]"> Daily variation </div> <div class="poppins text-text-sub text-[.7rem]"> Trend </div>
</div> </div>
</div> </div>

View File

@@ -134,7 +134,7 @@ onMounted(async () => {
</DashboardCountCard> </DashboardCountCard>
<DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer" text="Avg session time" <DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer" text="Total avg session time"
:value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend" :value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels" :data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
color="#f56523"> color="#f56523">

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Slice } from '@services/DateService';
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import DateService, { 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);
@@ -22,7 +22,7 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
const fixed = fixMetrics({ const fixed = fixMetrics({
data: input, data: input,
from: safeSnapshotDates.value.from, from: input[0]._id,
to: safeSnapshotDates.value.to to: safeSnapshotDates.value.to
}, slice.value, { }, slice.value, {
advanced: true, advanced: true,
@@ -68,7 +68,8 @@ onMounted(async () => {
<div v-if="eventsStackedData.pending.value" class="flex justify-center py-40"> <div v-if="eventsStackedData.pending.value" class="flex justify-center py-40">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i> <i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div> </div>
<AdvancedStackedBarChart v-if="!eventsStackedData.pending.value" :datasets="eventsStackedData.data.value?.datasets || []" <AdvancedStackedBarChart v-if="!eventsStackedData.pending.value"
:datasets="eventsStackedData.data.value?.datasets || []"
:labels="eventsStackedData.data.value?.labels || []"> :labels="eventsStackedData.data.value?.labels || []">
</AdvancedStackedBarChart> </AdvancedStackedBarChart>
</div> </div>

View File

@@ -13,11 +13,11 @@ export type PricingCardProp = {
planId: number planId: number
} }
const props = defineProps<{ datas: PricingCardProp[] }>(); const props = defineProps<{ datas: PricingCardProp[], defaultIndex?: number }>();
const activeProject = useActiveProject(); const activeProject = useActiveProject();
const currentIndex = ref<number>(0); const currentIndex = ref<number>(props.defaultIndex || 0);
const data = computed(() => { const data = computed(() => {
return props.datas[currentIndex.value]; return props.datas[currentIndex.value];
@@ -37,13 +37,19 @@ async function onUpgradeClick() {
<template> <template>
<div class="relative bg-[#151515] outline outline-[1px] outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]"> <div
class="relative bg-[#151515] outline outline-[1px] outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]">
<div class="flex flex-col gap-3 text-center"> <div class="flex flex-col gap-3 text-center pt-3">
<div class="poppins text-xl font-light"> {{ data.title }} </div> <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-xl"> class="absolute right-6 top-3 poppins text-[.75rem] bg-[#222A42] outline outline-[1px] outline-[#5680F8] px-3 py-[.1rem] rounded-sm">
Active Active
</div> </div>
<div v-if="!data.active && data.title === 'Growth'"
class="absolute right-6 top-3 poppins text-[.75rem] bg-[#fbbe244f] outline outline-[1px] outline-[#fbbf24] px-3 py-[.1rem] rounded-sm">
Most popular
</div>
<div class="poppins text-xl font-light"> {{ data.title }} </div>
<div class="poppins text-4xl font-medium"> {{ data.price }} </div> <div class="poppins text-4xl font-medium"> {{ data.price }} </div>
</div> </div>
@@ -69,7 +75,7 @@ async function onUpgradeClick() {
<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">
<div class="h-6 w-6"> <div class="h-6 w-6">
<img class="w-full h-full" :src="'check.png'" alt="Check"> <img class="w-full h-full" :src="'/check.png'" alt="Check">
</div> </div>
<div>{{ feature }}</div> <div>{{ feature }}</div>
</div> </div>

View File

@@ -1,9 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { PricingCardProp } from './PricingCardGeneric.vue'; import type { PricingCardProp } from './PricingCardGeneric.vue';
const activeProject = useActiveProject();
const props = defineProps<{ currentSub: number }>(); const props = defineProps<{ currentSub: number }>();
const freePricing: PricingCardProp[] = [ const freePricing: PricingCardProp[] = [
@@ -20,7 +17,6 @@ const freePricing: PricingCardProp[] = [
'Unlimited reports', 'Unlimited reports',
'AI Tokens: 10', 'AI Tokens: 10',
'Server type: SHARED', 'Server type: SHARED',
'Projects: max 2',
'Data retention: 2 Months' 'Data retention: 2 Months'
], ],
cta: 'Start For Free now!', cta: 'Start For Free now!',
@@ -44,7 +40,6 @@ const customPricing: PricingCardProp[] = [
'DB instance: DEDICATED', 'DB instance: DEDICATED',
'Dedicated operator', 'Dedicated operator',
'White label', 'White label',
'Custom Charts',
'Custom Data Aggregation' 'Custom Data Aggregation'
], ],
cta: 'Let\'s Talk!', cta: 'Let\'s Talk!',
@@ -64,12 +59,11 @@ const slidePricings: PricingCardProp[] = [
'CPM 0,10€ per visit/event' 'CPM 0,10€ per visit/event'
], ],
features: [ features: [
'Discord support', 'Slack support',
'Unlimited domains', 'Unlimited domains',
'Unlimited reports', 'Unlimited reports',
'AI Tokens: 30', 'AI Tokens: 30',
'Server type: SHARED', 'Server type: SHARED',
'Projects: max 3',
'Data retention: 6 Months' 'Data retention: 6 Months'
], ],
cta: 'Go to Cloud Dashboard', cta: 'Go to Cloud Dashboard',
@@ -85,12 +79,11 @@ const slidePricings: PricingCardProp[] = [
'CPM 0,06€ per visit/event' 'CPM 0,06€ per visit/event'
], ],
features: [ features: [
'Discord support', 'Slack support',
'Unlimited domains', 'Unlimited domains',
'Unlimited reports', 'Unlimited reports',
'AI Tokens: 100', 'AI Tokens: 100',
'Server type: SHARED', 'Server type: SHARED',
'Projects: max 3',
'Data retention: 9 Months' 'Data retention: 9 Months'
], ],
cta: 'Go to Cloud Dashboard', cta: 'Go to Cloud Dashboard',
@@ -106,12 +99,11 @@ const slidePricings: PricingCardProp[] = [
'CPM 0,059€ per visit/event' 'CPM 0,059€ per visit/event'
], ],
features: [ features: [
'Discord support', 'Slack support',
'Unlimited domains', 'Unlimited domains',
'Unlimited reports', 'Unlimited reports',
'AI Tokens: 3.000', 'AI Tokens: 3.000',
'Server type: SHARED', 'Server type: SHARED',
'Projects: max 3',
'Data retention: 1 Year' 'Data retention: 1 Year'
], ],
cta: 'Go to Cloud Dashboard', cta: 'Go to Cloud Dashboard',
@@ -127,12 +119,11 @@ const slidePricings: PricingCardProp[] = [
'CPM 0,059€ per visit/event' 'CPM 0,059€ per visit/event'
], ],
features: [ features: [
'Discord support', 'Slack support',
'Unlimited domains', 'Unlimited domains',
'Unlimited reports', 'Unlimited reports',
'AI Tokens: 5.000', 'AI Tokens: 5.000',
'Server type: SHARED', 'Server type: SHARED',
'Projects: max 3',
'Data retention: 1 Year' 'Data retention: 1 Year'
], ],
cta: 'Go to Cloud Dashboard', cta: 'Go to Cloud Dashboard',
@@ -148,12 +139,11 @@ const slidePricings: PricingCardProp[] = [
'CPM 0,039€ per visit/event' 'CPM 0,039€ per visit/event'
], ],
features: [ features: [
'Discord support', 'Slack support',
'Unlimited domains', 'Unlimited domains',
'Unlimited reports', 'Unlimited reports',
'AI Tokens: 10.000', 'AI Tokens: 10.000',
'Server type: DEDICATED', 'Server type: DEDICATED',
'Projects: max 3',
'Data retention: 2 Years' 'Data retention: 2 Years'
], ],
cta: 'Go to Cloud Dashboard', cta: 'Go to Cloud Dashboard',
@@ -169,12 +159,11 @@ const slidePricings: PricingCardProp[] = [
'CPM 0,029€ per visit/event' 'CPM 0,029€ per visit/event'
], ],
features: [ features: [
'Discord support', 'Slack support',
'Unlimited domains', 'Unlimited domains',
'Unlimited reports', 'Unlimited reports',
'AI Tokens: 20.000', 'AI Tokens: 20.000',
'Server type: DEDICATED', 'Server type: DEDICATED',
'Projects: max 3',
'Data retention: 3 Years' 'Data retention: 3 Years'
], ],
cta: 'Go to Cloud Dashboard', cta: 'Go to Cloud Dashboard',
@@ -189,10 +178,22 @@ const emits = defineEmits<{
(evt: 'onCloseClick'): void (evt: 'onCloseClick'): void
}>(); }>();
const activeProject = useActiveProject()
async function onLifetimeUpgradeClick() {
const res = await $fetch<string>(`/api/pay/${activeProject.value?._id.toString()}/create-onetime`, {
...signHeaders({ 'content-type': 'application/json' }),
method: 'POST',
body: JSON.stringify({ planId: 2001 })
})
if (!res) alert('Something went wrong');
window.open(res);
}
</script> </script>
<template> <template>
<div class="p-8 overflow-y-auto xl:overflow-y-hidden"> <div class="p-8 overflow-y-auto">
<div @click="$emit('onCloseClick')" <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"> 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">
@@ -201,10 +202,56 @@ const emits = defineEmits<{
<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="freePricing"></PricingCardGeneric> <PricingCardGeneric class="flex-1" :datas="freePricing"></PricingCardGeneric>
<PricingCardGeneric class="flex-1" :datas="slidePricings"></PricingCardGeneric> <PricingCardGeneric class="flex-1" :datas="slidePricings" :default-index="2"></PricingCardGeneric>
<PricingCardGeneric class="flex-1" :datas="customPricing"></PricingCardGeneric> <PricingCardGeneric class="flex-1" :datas="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-[2rem] font-semibold"> <div class="poppins text-[2rem] font-semibold">
@@ -222,5 +269,8 @@ const emits = defineEmits<{
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,9 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { TApiSettings } from '@schema/ApiSettingsSchema';
import type { SettingsTemplateEntry } from './Template.vue'; import type { SettingsTemplateEntry } from './Template.vue';
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: 'pid', title: 'Id', text: 'Project id' }, { id: 'pid', title: 'Id', text: 'Project id' },
{ id: 'pscript', title: 'Script', text: 'Universal javascript integration' }, { id: 'pscript', title: 'Script', text: 'Universal javascript integration' },
{ id: 'pdelete', title: 'Delete', text: 'Delete current project' }, { id: 'pdelete', title: 'Delete', text: 'Delete current project' },
@@ -12,8 +14,54 @@ const entries: SettingsTemplateEntry[] = [
const activeProject = useActiveProject(); const activeProject = useActiveProject();
const projectNameInputVal = ref<string>(activeProject.value?.name || ''); const projectNameInputVal = ref<string>(activeProject.value?.name || '');
const apiKeys = ref<TApiSettings[]>([]);
const newApiKeyName = ref<string>('');
async function updateApiKeys() {
newApiKeyName.value = '';
apiKeys.value = await $fetch<TApiSettings[]>('/api/keys/get_all', signHeaders());
}
async function createApiKey() {
try {
const res = await $fetch<TApiSettings>('/api/keys/create', {
method: 'POST', ...signHeaders({
'Content-Type': 'application/json'
}),
body: JSON.stringify({ name: newApiKeyName.value })
});
apiKeys.value.push(res);
newApiKeyName.value = '';
} catch (ex: any) {
alert(ex.message);
}
}
async function deleteApiKey(api_id: string) {
try {
const res = await $fetch<TApiSettings>('/api/keys/delete', {
method: 'DELETE', ...signHeaders({
'Content-Type': 'application/json'
}),
body: JSON.stringify({ api_id })
});
newApiKeyName.value = '';
await updateApiKeys();
} catch (ex: any) {
alert(ex.message);
}
}
onMounted(() => {
updateApiKeys();
})
watch(activeProject, () => { watch(activeProject, () => {
projectNameInputVal.value = activeProject.value?.name || ""; projectNameInputVal.value = activeProject.value?.name || "";
updateApiKeys();
}) })
const canChange = computed(() => { const canChange = computed(() => {
@@ -47,7 +95,7 @@ async function deleteProject() {
const projectsList = useProjectsList() const projectsList = useProjectsList()
await projectsList.refresh(); await projectsList.refresh();
const firstProjectId = projectsList.data.value?.[0]?._id.toString(); const firstProjectId = projectsList.data.value?.[0]?._id.toString();
if (firstProjectId) { if (firstProjectId) {
await setActiveProject(firstProjectId); await setActiveProject(firstProjectId);
@@ -61,6 +109,32 @@ async function deleteProject() {
} }
const { createAlert } = useAlert()
function copyScript() {
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
const createScriptText = () => {
return [
'<script defer ',
`data-project="${activeProject.value?._id}" `,
'src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></',
'script>'
].join('')
}
navigator.clipboard.writeText(createScriptText());
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
}
function copyProjectId() {
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
navigator.clipboard.writeText(activeProject.value?._id?.toString() || '');
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
}
</script> </script>
@@ -74,10 +148,31 @@ async function deleteProject() {
<LyxUiButton @click="changeProjectName()" :disabled="!canChange" type="primary"> Change </LyxUiButton> <LyxUiButton @click="changeProjectName()" :disabled="!canChange" type="primary"> Change </LyxUiButton>
</div> </div>
</template> </template>
<template #api>
<div class="flex items-center gap-4" v-if="apiKeys && apiKeys.length < 5">
<LyxUiInput class="grow px-4 py-2" placeholder="ApiKeyName" v-model="newApiKeyName"></LyxUiInput>
<LyxUiButton @click="createApiKey()" :disabled="newApiKeyName.length < 3" type="primary">
<i class="far fa-plus"></i>
</LyxUiButton>
</div>
<LyxUiCard v-if="apiKeys && apiKeys.length > 0" class="w-full flex flex-col gap-4 items-center mt-4">
<div v-for="apiKey of apiKeys" class="flex flex-col w-full">
<div class="flex gap-8 items-center">
<div class="grow">Name: {{ apiKey.apiName }}</div>
<div>{{ apiKey.apiKey }}</div>
<div class="flex justify-end">
<i class="far fa-trash cursor-pointer" @click="deleteApiKey(apiKey._id.toString())"></i>
</div>
</div>
</div>
</LyxUiCard>
</template>
<template #pid> <template #pid>
<LyxUiCard class="w-full flex items-center"> <LyxUiCard class="w-full flex items-center">
<div class="grow">{{ activeProject?._id.toString() }}</div> <div class="grow">{{ activeProject?._id.toString() }}</div>
<div><i class="far fa-copy"></i></div> <div><i class="far fa-copy" @click="copyProjectId()"></i></div>
</LyxUiCard> </LyxUiCard>
</template> </template>
<template #pscript> <template #pscript>
@@ -87,7 +182,7 @@ async function deleteProject() {
<script defer data-project="${activeProject?._id}" <script defer data-project="${activeProject?._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"></i></div> <div><i class="far fa-copy" @click="copyScript()"></i></div>
</LyxUiCard> </LyxUiCard>
</template> </template>
<template #pdelete> <template #pdelete>

View File

@@ -46,11 +46,6 @@ const { data: invoices, refresh: invoicesRefresh, pending: invoicesPending } = u
lazy: true lazy: true
}) })
const showPricingDrawer = ref<boolean>(false);
function onPlanUpgradeClick() {
showPricingDrawer.value = true;
}
function openInvoice(link: string) { function openInvoice(link: string) {
window.open(link, '_blank'); window.open(link, '_blank');
} }
@@ -77,18 +72,13 @@ const entries: SettingsTemplateEntry[] = [
] ]
const { visible } = usePricingDrawer();
</script> </script>
<template> <template>
<div class="relative"> <div class="relative">
<Transition name="pdrawer">
<PricingDrawer @onCloseClick="showPricingDrawer = false" :currentSub="planData?.premium_type || 0"
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]"
v-if=showPricingDrawer>
</PricingDrawer>
</Transition>
<div 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>
@@ -138,7 +128,7 @@ const entries: SettingsTemplateEntry[] = [
<div class="poppins"> Expire date:</div> <div class="poppins"> Expire date:</div>
<div> {{ prettyExpireDate }}</div> <div> {{ prettyExpireDate }}</div>
</div> </div>
<div v-if="!isGuest" @click="onPlanUpgradeClick()" <div v-if="!isGuest" @click="visible = true"
class="cursor-pointer flex items-center gap-2 text-[.9rem] text-white font-semibold bg-accent px-4 py-1 rounded-lg drop-shadow-[0_0_8px_#000000]"> class="cursor-pointer flex items-center gap-2 text-[.9rem] text-white font-semibold bg-accent px-4 py-1 rounded-lg drop-shadow-[0_0_8px_#000000]">
<div class="poppins"> Upgrade plan </div> <div class="poppins"> Upgrade plan </div>
<i class="fas fa-arrow-up-right"></i> <i class="fas fa-arrow-up-right"></i>

View File

@@ -0,0 +1,9 @@
const pricingDrawerVisible = ref<boolean>(false);
export function usePricingDrawer() {
return { visible: pricingDrawerVisible };
}

View File

@@ -4,23 +4,45 @@ import type { Section } from '~/components/CVerticalNavigation.vue';
import { Lit } from 'litlyx-js'; import { Lit } from 'litlyx-js';
const activeProject = useActiveProject();
const isPremium = computed(() => {
return activeProject.value?.premium;
});
const pricingDrawer = usePricingDrawer();
const sections: Section[] = [ const sections: Section[] = [
{ {
title: 'Project', title: '',
entries: [ entries: [
{ label: 'Dashboard', to: '/', icon: 'fal fa-table-layout' }, { label: 'Dashboard', to: '/', icon: 'fal fa-table-layout' },
{ label: 'Events', to: '/events', icon: 'fal fa-square-bolt' }, { label: 'Events', to: '/events', icon: 'fal fa-square-bolt' },
{ label: 'Analyst', to: '/analyst', icon: 'fal fa-microchip-ai' }, { label: 'Analyst', to: '/analyst', icon: 'fal fa-microchip-ai' },
{ label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true }, { label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true },
{ label: 'Links (soon)', to: '#', icon: 'fal fa-globe-pointer', disabled: true },
{ label: 'Integrations (soon)', to: '#', icon: 'fal fa-cube', disabled: true },
{ label: 'Settings', to: '/settings', icon: 'fal fa-gear' },
{ {
label: 'Docs', to: 'https://docs.litlyx.com', icon: 'fal fa-book', external: true, grow: true,
label: 'Documentation', to: 'https://docs.litlyx.com', icon: 'fal fa-book', external: true,
action() { Lit.event('docs_clicked') }, action() { Lit.event('docs_clicked') },
}, },
{ label: 'Settings', to: '/settings', icon: 'fal fa-gear' }, {
label: 'Slack support', icon: 'fab fa-slack',
premiumOnly: true,
action() {
if (isPremium.value === true) {
window.open('https://join.slack.com/t/litlyx/shared_invite/zt-2q3oawn29-hZlu_fBUBlc4052Ooe3FZg', '_blank');
} else {
pricingDrawer.visible.value = true;
}
},
},
] ]
} }
]; ];
const { showDialog, closeDialog } = useBarCardDialog(); const { showDialog, closeDialog } = useBarCardDialog();
const { isOpen, close, open } = useMenu(); const { isOpen, close, open } = useMenu();

View File

@@ -39,9 +39,7 @@ export default defineNuxtConfig({
AI_PROJECT: process.env.AI_PROJECT, AI_PROJECT: process.env.AI_PROJECT,
AI_KEY: process.env.AI_KEY, AI_KEY: process.env.AI_KEY,
EMAIL_SERVICE: process.env.EMAIL_SERVICE, EMAIL_SERVICE: process.env.EMAIL_SERVICE,
EMAIL_HOST: process.env.EMAIL_HOST, BREVO_API_KEY: process.env.BREVO_API_KEY,
EMAIL_USER: process.env.EMAIL_USER,
EMAIL_PASS: process.env.EMAIL_PASS,
AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET, AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET,
GOOGLE_AUTH_CLIENT_ID: process.env.GOOGLE_AUTH_CLIENT_ID, GOOGLE_AUTH_CLIENT_ID: process.env.GOOGLE_AUTH_CLIENT_ID,
GOOGLE_AUTH_CLIENT_SECRET: process.env.GOOGLE_AUTH_CLIENT_SECRET, GOOGLE_AUTH_CLIENT_SECRET: process.env.GOOGLE_AUTH_CLIENT_SECRET,

View File

@@ -13,6 +13,7 @@
"docker-inspect": "docker run -it litlyx-dashboard sh" "docker-inspect": "docker run -it litlyx-dashboard sh"
}, },
"dependencies": { "dependencies": {
"@getbrevo/brevo": "^2.2.0",
"@nuxtjs/tailwindcss": "^6.12.0", "@nuxtjs/tailwindcss": "^6.12.0",
"chart.js": "^3.9.1", "chart.js": "^3.9.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",

View File

@@ -5,6 +5,8 @@ import type { AdminProjectsList } from '~/server/api/admin/projects';
definePageMeta({ layout: 'dashboard' }); definePageMeta({ layout: 'dashboard' });
const { data: projects } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders()); const { data: projects } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
const { data: counts } = await useFetch('/api/admin/counts', signHeaders());
type TProjectsGrouped = { type TProjectsGrouped = {
user: { user: {
@@ -88,11 +90,6 @@ function onHideClicked() {
isAdminHidden.value = true; isAdminHidden.value = true;
} }
const projectsCount = computed(() => {
return projects.value?.length || 0;
});
const premiumCount = computed(() => { const premiumCount = computed(() => {
let premiums = 0; let premiums = 0;
projects.value?.forEach(e => { projects.value?.forEach(e => {
@@ -102,12 +99,6 @@ const premiumCount = computed(() => {
}) })
const usersCount = computed(() => {
const uniqueUsers = new Set<string>();
projects.value?.forEach(e => uniqueUsers.add(e.user.email));
return uniqueUsers.size;
});
const totalVisits = computed(() => { const totalVisits = computed(() => {
return projects.value?.reduce((a, e) => a + e.total_visits, 0) || 0; return projects.value?.reduce((a, e) => a + e.total_visits, 0) || 0;
@@ -155,10 +146,10 @@ async function resetCount(project_id: string) {
<div class="grid grid-cols-2"> <div class="grid grid-cols-2">
<div> <div>
Users: {{ usersCount }} Users: {{ counts?.users }}
</div> </div>
<div> <div>
Projects: {{ projectsCount }} ( {{ premiumCount }} premium ) Projects: {{ counts?.projects }} ( {{ premiumCount }} premium )
</div> </div>
<div> <div>
Total visits: {{ formatNumberK(totalVisits) }} Total visits: {{ formatNumberK(totalVisits) }}

View File

@@ -6,6 +6,8 @@ definePageMeta({ layout: 'dashboard' });
const activeProject = useActiveProject(); const activeProject = useActiveProject();
const isPremium = computed(() => (activeProject.value?.premium_type || 0) > 0);
const metricsInfo = ref<number>(0); const metricsInfo = ref<number>(0);
const columns = [ const columns = [
@@ -36,7 +38,36 @@ onMounted(async () => {
metricsInfo.value = counts.eventsCount; metricsInfo.value = counts.eventsCount;
}); });
const creatingCsv = ref<boolean>(false);
async function downloadCSV() {
creatingCsv.value = true;
const result = await $fetch(`/api/project/generate_csv?mode=events&slice=${options.indexOf(selectedTimeFrom.value)}`, signHeaders());
const blob = new Blob([result], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'ReportVisits.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
creatingCsv.value = false;
}
const options = ['Last day', 'Last week', 'Last month', 'Total']
const selectedTimeFrom = ref<string>(options[0]);
const showWarning = computed(() => {
return options.indexOf(selectedTimeFrom.value) > 1
})
const pricingDrawer = usePricingDrawer();
function goToUpgrade() {
pricingDrawer.visible.value = true;
}
</script> </script>
@@ -47,14 +78,38 @@ onMounted(async () => {
<div class="w-full h-dvh flex flex-col"> <div class="w-full h-dvh flex flex-col">
<div v-if="creatingCsv"
class="fixed z-[100] flex items-center justify-center left-0 top-0 w-full h-full bg-black/60 backdrop-blur-[4px]">
<div class="poppins text-[2rem]">
Creating csv...
</div>
</div>
<div class="flex justify-end px-12 py-3">
<div <div class="flex justify-end px-12 py-3 items-center gap-2">
<div v-if="showWarning" class="text-orange-400 flex gap-2 items-center">
<i class="far fa-warning "></i>
<div> It can take a few minutes </div>
</div>
<div class="w-[15rem] flex flex-col gap-0">
<USelectMenu v-model="selectedTimeFrom" :options="options"></USelectMenu>
</div>
<div v-if="isPremium" @click="downloadCSV()"
class="bg-[#57c78fc0] hover:bg-[#57c78fab] cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg"> class="bg-[#57c78fc0] hover:bg-[#57c78fab] cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
Download CSV Download CSV
</div> </div>
<div v-if="!isPremium" @click="goToUpgrade()"
class="bg-[#57c78f46] hover:bg-[#57c78f42] flex gap-4 items-center cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
<i class="far fa-lock"></i>
Upgrade plan for CSV
</div>
</div> </div>
<UTable v-if="tableData" class="utable px-8" :ui="{ <UTable v-if="tableData" class="utable px-8" :ui="{
wrapper: 'overflow-auto w-full h-full', wrapper: 'overflow-auto w-full h-full',
thead: 'sticky top-0 bg-menu', thead: 'sticky top-0 bg-menu',

View File

@@ -6,6 +6,8 @@ definePageMeta({ layout: 'dashboard' });
const activeProject = useActiveProject(); const activeProject = useActiveProject();
const isPremium = computed(() => (activeProject.value?.premium_type || 0) > 0);
const metricsInfo = ref<number>(0); const metricsInfo = ref<number>(0);
const columns = [ const columns = [
@@ -68,6 +70,12 @@ const showWarning = computed(() => {
return options.indexOf(selectedTimeFrom.value) > 1 return options.indexOf(selectedTimeFrom.value) > 1
}) })
const pricingDrawer = usePricingDrawer();
function goToUpgrade() {
pricingDrawer.visible.value = true;
}
</script> </script>
@@ -83,7 +91,9 @@ const showWarning = computed(() => {
</div> </div>
</div> </div>
<div class="flex justify-end px-12 py-3 items-center gap-2"> <div class="flex justify-end px-12 py-3 items-center gap-2">
<div v-if="showWarning" class="text-orange-400 flex gap-2 items-center"> <div v-if="showWarning" class="text-orange-400 flex gap-2 items-center">
<i class="far fa-warning "></i> <i class="far fa-warning "></i>
<div> It can take a few minutes </div> <div> It can take a few minutes </div>
@@ -91,12 +101,21 @@ const showWarning = computed(() => {
<div class="w-[15rem] flex flex-col gap-0"> <div class="w-[15rem] flex flex-col gap-0">
<USelectMenu v-model="selectedTimeFrom" :options="options"></USelectMenu> <USelectMenu v-model="selectedTimeFrom" :options="options"></USelectMenu>
</div> </div>
<div @click="downloadCSV()"
<div v-if="isPremium" @click="downloadCSV()"
class="bg-[#57c78fc0] hover:bg-[#57c78fab] cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg"> class="bg-[#57c78fc0] hover:bg-[#57c78fab] cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
Download CSV Download CSV
</div> </div>
<div v-if="!isPremium" @click="goToUpgrade()"
class="bg-[#57c78f46] hover:bg-[#57c78f42] flex gap-4 items-center cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
<i class="far fa-lock"></i>
Upgrade plan for CSV
</div>
</div> </div>
<UTable v-if="tableData" class="utable px-8" :ui="{ <UTable v-if="tableData" class="utable px-8" :ui="{
wrapper: 'overflow-auto w-full h-full', wrapper: 'overflow-auto w-full h-full',
thead: 'sticky top-0 bg-menu', thead: 'sticky top-0 bg-menu',

View File

@@ -24,6 +24,9 @@ const limitsInfo = ref<{
onMounted(async () => { onMounted(async () => {
if (route.query.just_logged) return location.href = '/'; if (route.query.just_logged) return location.href = '/';
limitsInfo.value = await $fetch<any>("/api/project/limits_info", signHeaders()); limitsInfo.value = await $fetch<any>("/api/project/limits_info", signHeaders());
watch(activeProject, async () => {
limitsInfo.value = await $fetch<any>("/api/project/limits_info", signHeaders());
});
}); });
@@ -75,6 +78,12 @@ const { snapshot } = useSnapshot();
const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProject.value?._id.toString()}`); const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProject.value?._id.toString()}`);
const pricingDrawer = usePricingDrawer();
function goToUpgrade() {
pricingDrawer.visible.value = true;
}
</script> </script>
@@ -94,18 +103,19 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
Limit reached Limit reached
</div> </div>
<div class="poppins text-[#fbbf24]"> <div class="poppins text-[#fbbf24]">
Litlyx has stopped to collect yur data. Please upgrade the plan for a minimal data loss. Litlyx cannot receive new data as you reached your plan's limit. Resume all the great
features and collect even more data with a higher plan.
</div> </div>
</div> </div>
<div> <div>
<LyxUiButton type="outline"> Upgrade </LyxUiButton> <LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>
</div> </div>
</div> </div>
</div> </div>
<DashboardTopSection></DashboardTopSection> <DashboardTopSection></DashboardTopSection>
<DashboardTopCards :key="refreshKey"></DashboardTopCards> <DashboardTopCards :key="refreshKey"></DashboardTopCards>
@@ -124,7 +134,7 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
</div> </div>
</CardTitled> </CardTitled>
<CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Sessions" <!-- <CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Sessions"
sub="Shows trends in sessions."> sub="Shows trends in sessions.">
<template #header> <template #header>
<SelectButton @changeIndex="sessionsChartSelectIndex = $event" <SelectButton @changeIndex="sessionsChartSelectIndex = $event"
@@ -135,9 +145,9 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
<DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)"> <DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)">
</DashboardSessionsLineChart> </DashboardSessionsLineChart>
</div> </div>
</CardTitled> </CardTitled> -->
</div> </div>
<div class="flex w-full justify-center mt-6 px-6"> <div class="flex w-full justify-center mt-6 px-6">
<div class="flex w-full gap-6 flex-col xl:flex-row"> <div class="flex w-full gap-6 flex-col xl:flex-row">
@@ -145,13 +155,13 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
<DashboardWebsitesBarCard :key="refreshKey"></DashboardWebsitesBarCard> <DashboardWebsitesBarCard :key="refreshKey"></DashboardWebsitesBarCard>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<DashboardEventsBarCard :key="refreshKey"></DashboardEventsBarCard> <DashboardEventsBarCard :key="refreshKey"></DashboardEventsBarCard>
</div> </div>
</div> </div>
</div> </div>
<div class="flex w-full justify-center mt-6 px-6"> <div class="flex w-full justify-center mt-6 px-6">
<div class="flex w-full gap-6 flex-col xl:flex-row"> <div class="flex w-full gap-6 flex-col xl:flex-row">
<div class="flex-1"> <div class="flex-1">
<DashboardReferrersBarCard :key="refreshKey"></DashboardReferrersBarCard> <DashboardReferrersBarCard :key="refreshKey"></DashboardReferrersBarCard>
@@ -181,7 +191,7 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
<div class="flex-1"> <div class="flex-1">
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@
definePageMeta({ layout: 'none' }); definePageMeta({ layout: 'none' });
const { snapshot, snapshots } = useSnapshot();
const { data: project } = useLiveDemo(); const { data: project } = useLiveDemo();
@@ -9,7 +10,7 @@ let interval: any;
onMounted(async () => { onMounted(async () => {
await getOnlineUsers(); await getOnlineUsers();
snapshot.value = snapshots.value[0];
interval = setInterval(async () => { interval = setInterval(async () => {
await getOnlineUsers(); await getOnlineUsers();
}, 5000); }, 5000);
@@ -46,7 +47,7 @@ const selectLabelsEvents = [
{ label: 'Month', value: 'month' }, { label: 'Month', value: 'month' },
]; ];
const { snapshot } = useSnapshot();
</script> </script>
@@ -71,14 +72,10 @@ const { snapshot } = useSnapshot();
</div> </div>
<div class="grow"></div> <div class="grow"></div>
<div class="flex gap-2 md:pt-0 pt-4"> <div class="flex gap-2 md:pt-0 pt-4">
<NuxtLink target="_blank" to="https://cal.com/litlyx/30min" <LyxUiButton link="/" type="primary"
class="bg-white hover:bg-white/90 px-4 py-3 text-black poppins font-semibold text-[.9rem] lg:text-[1.2rem] rounded-lg"> class="poppins font-semibold text-[.9rem] lg:text-[1.2rem] flex items-center !px-14 py-4">
Book a demo Get started for free
</NuxtLink> </LyxUiButton>
<NuxtLink to="/"
class="bg-accent hover:bg-accent/90 px-4 py-3 poppins font-semibold text-[.9rem] lg:text-[1.2rem] rounded-lg">
Go to dashboard
</NuxtLink>
</div> </div>
</div> </div>
</div> </div>
@@ -90,7 +87,7 @@ const { snapshot } = useSnapshot();
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row"> <div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row">
<CardTitled class="p-4 flex-1" title="Visits trends" sub="Shows trends in page visits."> <CardTitled class="p-4 flex-1 w-full" title="Visits trends" sub="Shows trends in page visits.">
<template #header> <template #header>
<SelectButton @changeIndex="mainChartSelectIndex = $event" :currentIndex="mainChartSelectIndex" <SelectButton @changeIndex="mainChartSelectIndex = $event" :currentIndex="mainChartSelectIndex"
:options="selectLabels"> :options="selectLabels">
@@ -102,7 +99,7 @@ const { snapshot } = useSnapshot();
</div> </div>
</CardTitled> </CardTitled>
<CardTitled class="p-4 flex-1" title="Sessions" sub="Shows trends in sessions."> <!-- <CardTitled class="p-4 flex-1" title="Sessions" sub="Shows trends in sessions.">
<template #header> <template #header>
<SelectButton @changeIndex="sessionsChartSelectIndex = $event" <SelectButton @changeIndex="sessionsChartSelectIndex = $event"
:currentIndex="sessionsChartSelectIndex" :options="selectLabels"> :currentIndex="sessionsChartSelectIndex" :options="selectLabels">
@@ -112,13 +109,14 @@ const { snapshot } = useSnapshot();
<DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)"> <DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)">
</DashboardSessionsLineChart> </DashboardSessionsLineChart>
</div> </div>
</CardTitled> </CardTitled> -->
</div> </div>
<div class="flex gap-6 flex-col xl:flex-row p-6"> <div class="flex gap-6 flex-col xl:flex-row p-6">
<!-- <CardTitled class="p-4 flex-[4]" title="Events" sub="Events stacked bar chart.">
<CardTitled class="p-4 flex-[4] w-full h-full" title="Events" sub="Events stacked bar chart.">
<template #header> <template #header>
<SelectButton @changeIndex="eventsStackedSelectIndex = $event" <SelectButton @changeIndex="eventsStackedSelectIndex = $event"
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents"> :currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
@@ -128,25 +126,17 @@ const { snapshot } = useSnapshot();
<EventsStackedBarChart :slice="(selectLabelsEvents[eventsStackedSelectIndex].value as any)"> <EventsStackedBarChart :slice="(selectLabelsEvents[eventsStackedSelectIndex].value as any)">
</EventsStackedBarChart> </EventsStackedBarChart>
</div> </div>
</CardTitled> --> </CardTitled>
<div class="bg-menu p-4 rounded-xl flex-[2] flex flex-col gap-10 h-full"> <CardTitled title="Top events" sub=" Displays key events." class="p-4 flex-[2] w-full h-full">
<div class="flex flex-col gap-1"> <div>
<div class="poppins font-semibold text-[1.4rem] text-text"> <DashboardEventsChart class="w-full"> </DashboardEventsChart>
Top events
</div>
<div class="poppins text-[1rem] text-text-sub/90">
Displays key events.
</div>
</div> </div>
</CardTitled>
<DashboardEventsChart class="w-full"> </DashboardEventsChart>
</div>
</div> </div>
<div class="flex w-full justify-center mt-6 px-6"> <div class="flex w-full justify-center px-6">
<div class="flex w-full gap-6 flex-col lg:flex-row"> <div class="flex w-full gap-6 flex-col lg:flex-row">
<div class="flex-1"> <div class="flex-1">
<DashboardWebsitesBarCard></DashboardWebsitesBarCard> <DashboardWebsitesBarCard></DashboardWebsitesBarCard>
@@ -203,15 +193,15 @@ const { snapshot } = useSnapshot();
Do you want this KPIs for your website ? Do you want this KPIs for your website ?
</div> </div>
<div class="poppins font-semibold text-text-sub"> <div class="poppins font-semibold text-text-sub">
Start now ! It's free. Start now! It's free.
</div> </div>
</div> </div>
<div class="flex gap-2 flex-col md:flex-row"> <div class="flex gap-2 flex-col md:flex-row">
<NuxtLink to="/" <LyxUiButton link="/" type="primary"
class="bg-accent hover:bg-accent/90 px-14 py-4 poppins font-semibold text-[1.1rem] lg:text-[1.6rem] rounded-lg"> class="poppins font-semibold text-[1.1rem] lg:text-[1.6rem] flex items-center !px-14">
Get started Get started
</NuxtLink> </LyxUiButton>
<NuxtLink target="_blank" to="https://cal.com/litlyx/30min" <NuxtLink target="_blank" to="https://cal.com/litlyx/30min"
class="bg-white hover:bg-white/90 text-black px-14 py-4 poppins font-semibold text-[1.1rem] lg:text-[1.6rem] rounded-lg"> class="bg-white hover:bg-white/90 text-black px-14 py-4 poppins font-semibold text-[1.1rem] lg:text-[1.6rem] rounded-lg">
Book a demo Book a demo

View File

@@ -7,7 +7,7 @@ definePageMeta({ layout: 'header' });
<template> <template>
<div class="home h-full overflow-y-auto relative"> <!-- <div class="home h-full overflow-y-auto relative">
<div class="absolute top-0 left-0 w-full h-full flex flex-col items-center z-0 overflow-hidden"> <div class="absolute top-0 left-0 w-full h-full flex flex-col items-center z-0 overflow-hidden">
<HomeBgGrid :size="50" :spacing="18" opacity="0.3" class="w-fit h-fit"></HomeBgGrid> <HomeBgGrid :size="50" :spacing="18" opacity="0.3" class="w-fit h-fit"></HomeBgGrid>
@@ -96,6 +96,6 @@ definePageMeta({ layout: 'header' });
</div> </div>
</div> </div>
</div> </div>
</div> </div> -->
</template> </template>

Binary file not shown.

710
dashboard/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
import { ProjectModel } from "@schema/ProjectSchema";
import { UserModel } from "@schema/UserSchema";
export default defineEventHandler(async event => {
const userData = getRequestUser(event);
if (!userData?.logged) return;
if (!userData.user.roles.includes('ADMIN')) return;
const projectsCount = await ProjectModel.countDocuments({});
const usersCount = await UserModel.countDocuments({});
return { users: usersCount, projects: projectsCount }
});

View File

@@ -51,6 +51,7 @@ export default defineEventHandler(async event => {
const savedUser = await newUser.save(); const savedUser = await newUser.save();
setImmediate(() => { setImmediate(() => {
console.log('SENDING WELCOME EMAIL TO', payload.email);
if (payload.email) EmailService.sendWelcomeEmail(payload.email); if (payload.email) EmailService.sendWelcomeEmail(payload.email);
}); });

View File

@@ -0,0 +1,47 @@
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import { ApiSettingsModel, TApiSettings } from "@schema/ApiSettingsSchema";
import { UserSettingsModel } from "@schema/UserSettings";
import { ProjectModel } from "@schema/ProjectSchema";
import crypto from 'crypto';
function generateApiKey() {
return 'lit_' + crypto.randomBytes(6).toString('hex');
}
export default defineEventHandler(async event => {
const body = await readBody(event);
if (body.name.length == 0) return setResponseStatus(event, 400, 'name is required');
if (body.name.length < 3) return setResponseStatus(event, 400, 'name too short');
if (body.name.length > 32) return setResponseStatus(event, 400, 'name too long');
const userData = getRequestUser(event);
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
const project_id = currentActiveProject.active_project_id;
const project = await ProjectModel.findById(project_id);
if (!project) return setResponseStatus(event, 400, 'Project not found');
if (project.owner.toString() != userData.id) {
return setResponseStatus(event, 400, 'You are not the owner');
}
const key = generateApiKey();
const keyNumbers = await ApiSettingsModel.countDocuments({ project_id });
if (keyNumbers >= 5) return setResponseStatus(event, 400, 'Api key limit reached');
const newApiSettings = await ApiSettingsModel.create({ project_id, apiKey: key, apiName: body.name, created_at: Date.now(), usage: 0 });
return newApiSettings.toJSON();
});

View File

@@ -0,0 +1,28 @@
import { ApiSettingsModel } from "@schema/ApiSettingsSchema";
import { UserSettingsModel } from "@schema/UserSettings";
import { ProjectModel } from "@schema/ProjectSchema";
export default defineEventHandler(async event => {
const body = await readBody(event);
const userData = getRequestUser(event);
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
const project_id = currentActiveProject.active_project_id;
const project = await ProjectModel.findById(project_id);
if (!project) return setResponseStatus(event, 400, 'Project not found');
if (project.owner.toString() != userData.id) {
return setResponseStatus(event, 400, 'You are not the owner');
}
const deletation = await ApiSettingsModel.deleteOne({ _id: body.api_id });
return { ok: deletation.acknowledged };
});

View File

@@ -0,0 +1,33 @@
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import { ApiSettingsModel, TApiSettings } from "@schema/ApiSettingsSchema";
import { UserSettingsModel } from "@schema/UserSettings";
import { ProjectModel } from "@schema/ProjectSchema";
function cryptApiKeyName(apiSettings: TApiSettings): TApiSettings {
return { ...apiSettings, apiKey: apiSettings.apiKey.substring(0, 6) + '******' }
}
export default defineEventHandler(async event => {
const userData = getRequestUser(event);
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
const project_id = currentActiveProject.active_project_id;
const project = await ProjectModel.findById(project_id);
if (!project) return setResponseStatus(event, 400, 'Project not found');
if (project.owner.toString() != userData.id) {
return setResponseStatus(event, 400, 'You are not the owner');
}
const apiKeys = await ApiSettingsModel.find({ project_id }, { project_id: 0 })
return apiKeys.map(e => cryptApiKeyName(e.toJSON())) as TApiSettings[];
});

View File

@@ -4,7 +4,6 @@ import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import DateService from "@services/DateService"; import DateService from "@services/DateService";
import { executeTimelineAggregation, fillAndMergeTimelineAggregation } from "~/server/services/TimelineService"; import { executeTimelineAggregation, fillAndMergeTimelineAggregation } from "~/server/services/TimelineService";
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
const project_id = getRequestProjectId(event); const project_id = getRequestProjectId(event);
if (!project_id) return; if (!project_id) return;
@@ -22,12 +21,12 @@ export default defineEventHandler(async event => {
return await Redis.useCache({ return await Redis.useCache({
key: `timeline:visits:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`, key: `timeline:visits:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,
exp: TIMELINE_EXPIRE_TIME exp: TIMELINE_EXPIRE_TIME,
}, async () => { }, async () => {
const timelineData = await executeTimelineAggregation({ const timelineData = await executeTimelineAggregation({
projectId: project._id, projectId: project._id,
model: VisitModel, model: VisitModel,
from, to, slice from, to, slice,
}); });
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice); const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice);
return timelineFilledMerged; return timelineFilledMerged;

View File

@@ -0,0 +1,40 @@
import { getPlanFromId } from "@data/PREMIUM";
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import StripeService from '~/server/services/StripeService';
export default defineEventHandler(async event => {
const project_id = getRequestProjectId(event);
if (!project_id) return;
const user = getRequestUser(event);
const project = await getUserProjectFromId(project_id, user);
if (!project) return;
const body = await readBody(event);
const { planId } = body;
const PLAN = getPlanFromId(planId);
if (!PLAN) {
console.error('PLAN', planId, 'NOT EXIST');
return setResponseStatus(event, 400, 'Plan not exist');
}
const intent = await StripeService.createOnetimePayment(
StripeService.testMode ? PLAN.PRICE_TEST : PLAN.PRICE,
'https://dashboard.litlyx.com/payment_ok',
project_id,
project.customer_id
)
if (!intent) {
console.error('Cannot create Intent', { plan: PLAN });
return setResponseStatus(event, 400, 'Cannot create intent');
}
return intent.url;
});

View File

@@ -63,6 +63,36 @@ async function onPaymentFailed(event: Event.InvoicePaymentFailedEvent) {
} }
async function onPaymentOnetimeSuccess(event: Event.PaymentIntentSucceededEvent) {
const customer_id = event.data.object.customer as string;
const project = await ProjectModel.findOne({ customer_id });
if (!project) return { error: 'CUSTOMER NOT EXIST' }
if (event.data.object.status === 'succeeded') {
const PLAN = getPlanFromPrice(event.data.object.metadata.price, StripeService.testMode || false);
if (!PLAN) return { error: 'Plan not found' }
const dummyPlan = PLAN.ID + 3000;
const subscription = await StripeService.createOneTimeSubscriptionDummy(customer_id, dummyPlan);
if (!subscription) return { error: 'Error creating subscription' }
const allSubscriptions = await StripeService.getAllSubscriptions(customer_id);
if (!allSubscriptions) return;
for (const subscription of allSubscriptions.data) {
if (subscription.id === subscription.id) continue;
await StripeService.deleteSubscription(subscription.id);
}
await addSubscriptionToProject(project._id.toString(), PLAN, subscription.id, subscription.current_period_start, subscription.current_period_end)
return { ok: true };
}
return { received: true, warn: 'object status not succeeded' }
}
async function onPaymentSuccess(event: Event.InvoicePaidEvent) { async function onPaymentSuccess(event: Event.InvoicePaidEvent) {
const customer_id = event.data.object.customer as string; const customer_id = event.data.object.customer as string;
@@ -76,7 +106,7 @@ async function onPaymentSuccess(event: Event.InvoicePaidEvent) {
const allSubscriptions = await StripeService.getAllSubscriptions(customer_id); const allSubscriptions = await StripeService.getAllSubscriptions(customer_id);
if (!allSubscriptions) return; if (!allSubscriptions) return;
const currentSubscription = allSubscriptions.data.find(e => e.id === subscription_id); const currentSubscription = allSubscriptions.data.find(e => e.id === subscription_id);
if (!currentSubscription) return { error: 'SUBSCRIPTION NOT EXIST' } if (!currentSubscription) return { error: 'SUBSCRIPTION NOT EXIST' }
@@ -201,7 +231,11 @@ export default defineEventHandler(async event => {
const eventData = StripeService.parseWebhook(body, signature); const eventData = StripeService.parseWebhook(body, signature);
if (!eventData) return; if (!eventData) return;
// console.log('WEBHOOK FIRED', eventData.type);
if (eventData.type === 'invoice.paid') return await onPaymentSuccess(eventData); if (eventData.type === 'invoice.paid') return await onPaymentSuccess(eventData);
if (eventData.type === 'payment_intent.succeeded') return await onPaymentOnetimeSuccess(eventData);
if (eventData.type === 'invoice.payment_failed') return await onPaymentFailed(eventData); if (eventData.type === 'invoice.payment_failed') return await onPaymentFailed(eventData);
if (eventData.type === 'customer.subscription.deleted') return await onSubscriptionDeleted(eventData); if (eventData.type === 'customer.subscription.deleted') return await onSubscriptionDeleted(eventData);
if (eventData.type === 'customer.subscription.created') return await onSubscriptionCreated(eventData); if (eventData.type === 'customer.subscription.created') return await onSubscriptionCreated(eventData);

View File

@@ -21,6 +21,8 @@ export default defineEventHandler(async event => {
} }
const { name } = await readBody(event); const { name } = await readBody(event);
if (name.length == 0) return setResponseStatus(event, 400, 'name is required');
project.name = name; project.name = name;
await project.save(); await project.save();

View File

@@ -18,6 +18,10 @@ export default defineEventHandler(async event => {
const project = await ProjectModel.findById(project_id); const project = await ProjectModel.findById(project_id);
if (!project) return setResponseStatus(event, 400, 'Project not found'); if (!project) return setResponseStatus(event, 400, 'Project not found');
const PREMIUM_TYPE = project.premium_type;
if (PREMIUM_TYPE === 0) return setResponseStatus(event, 400, 'Project not premium');
const { mode, slice } = getQuery(event); const { mode, slice } = getQuery(event);
let timeSub = 1000 * 60 * 60 * 24; let timeSub = 1000 * 60 * 60 * 24;

View File

@@ -2,18 +2,25 @@
import pdfkit from 'pdfkit'; import pdfkit from 'pdfkit';
import { PassThrough } from 'node:stream'; import { PassThrough } from 'node:stream';
import fs from 'fs';
import { ProjectModel, TProject } from "@schema/ProjectSchema"; import { ProjectModel } from "@schema/ProjectSchema";
import { UserSettingsModel } from "@schema/UserSettings"; import { UserSettingsModel } from "@schema/UserSettings";
import { VisitModel } from '@schema/metrics/VisitSchema'; import { VisitModel } from '@schema/metrics/VisitSchema';
import { EventModel } from '@schema/metrics/EventSchema'; import { EventModel } from '@schema/metrics/EventSchema';
type PDF_Data = { type PDFGenerationData = {
pageVisits: number, customEvents: number, projectName: string,
visitsDay: number, eventsDay: number, visitsSessions: number, snapshotName: string,
visitsSessionsDay: number totalVisits: string,
avgVisitsDay: string,
totalEvents: string,
topDomain: string,
topDevice: string,
topCountries: string[],
topReferrers: string[],
avgGrowthText: string,
} }
function formatNumberK(value: string | number, decimals: number = 1) { function formatNumberK(value: string | number, decimals: number = 1) {
@@ -25,82 +32,54 @@ function formatNumberK(value: string | number, decimals: number = 1) {
} }
function createPdf(projectName: string, data: PDF_Data) { const LINE_SPACING = 0.5;
const pdf = new pdfkit({
size: 'A4', function createPdf(data: PDFGenerationData) {
margins: { top: 50, bottom: 50, left: 50, right: 50 },
const pdf = new pdfkit({ size: 'A4', margins: { top: 50, bottom: 50, left: 50, right: 50 }, });
pdf.fillColor('#ffffff').rect(0, 0, pdf.page.width, pdf.page.height).fill('#000000');
pdf.font('pdf_fonts/Poppins-Bold.ttf').fontSize(16).fillColor('#ffffff');
pdf.text(`Project name: ${data.projectName}`, { align: 'left' }).moveDown(LINE_SPACING);
pdf.text(`Snapshot name: ${data.snapshotName}`, { align: 'left' }).moveDown(LINE_SPACING);
pdf.font('pdf_fonts/Poppins-Regular.ttf').fontSize(12).fillColor('#ffffff')
pdf.text(`Total visits: ${data.totalVisits}`, { align: 'left' }).moveDown(LINE_SPACING);
pdf.text(`Average visits per day: ${data.avgVisitsDay}`, { align: 'left' }).moveDown(LINE_SPACING);
pdf.text(`Total events: ${data.totalEvents}`, { align: 'left' }).moveDown(LINE_SPACING);
pdf.text(`Top domain: ${data.topDomain}`, { align: 'left' }).moveDown(LINE_SPACING);
pdf.text(`Top device: ${data.topDevice}`, { align: 'left' }).moveDown(LINE_SPACING);
pdf.text('Top 3 countries:', { align: 'left' }).moveDown(LINE_SPACING);
data.topCountries.forEach((country: any) => {
pdf.text(`${country}`, { align: 'left' }).moveDown(LINE_SPACING);
}); });
pdf.pipe(fs.createWriteStream('out.pdf')); pdf.text('Top 3 best acquisition channels (referrers):', { align: 'left' }).moveDown(LINE_SPACING);
data.topReferrers.forEach((channel: any) => {
pdf.text(`${channel}`, { align: 'left' }).moveDown(LINE_SPACING);
});
// Set up fonts and colors pdf.text('Average growth:', { align: 'left' }).moveDown(LINE_SPACING);
pdf pdf.text(`${data.avgGrowthText}`, { align: 'left' }).moveDown(LINE_SPACING);
pdf.font('pdf_fonts/Poppins-Italic.ttf')
.text('This gives you an idea of the average growth your website is experiencing over time.', { align: 'left' })
.moveDown(LINE_SPACING);
pdf.font('pdf_fonts/Poppins-Regular.ttf')
.fontSize(10)
.fillColor('#ffffff') .fillColor('#ffffff')
.rect(0, 0, pdf.page.width, pdf.page.height) .text('Created with Litlyx.com', 50, 760, { align: 'center' });
.fill('#000000');
// Title pdf.image('pdf_images/logo.png', 460, 700, { width: 100 });
pdf
.font('pdf_fonts/Poppins-Bold.ttf')
.fontSize(26)
.fillColor('#ffffff')
.text(`Report of: ${projectName}`, 50, 50);
// Section 1
pdf
.font('pdf_fonts/Poppins-SemiBold.ttf')
.fontSize(20)
.fillColor('#ffffff')
.text('-> This month has seen a lot of visits!', 50, 120);
pdf
.image('pdf_images/d.png', 50, 160, { width: 300 })
.font('pdf_fonts/Poppins-Bold.ttf')
.fontSize(28)
.fillColor('#ffffff')
.text(`${formatNumberK(data.pageVisits, 2)}`, 400, 180)
.text('WOW!', 400, 210);
// Section 2
pdf
.font('pdf_fonts/Poppins-SemiBold.ttf')
.fontSize(20)
.fillColor('#ffffff')
.text('-> There are also many recorded events!', 50, 350);
pdf
.image('pdf_images/c.png', 50, 390, { width: 300 })
.font('pdf_fonts/Poppins-Bold.ttf')
.fontSize(28)
.fillColor('#ffffff')
.text(`${formatNumberK(data.customEvents, 2)}`, 400, 420)
.text('Let\'s go!', 400, 450);
// Final section
pdf
.font('pdf_fonts/Poppins-SemiBold.ttf')
.fontSize(20)
.fillColor('#ffffff')
.text('This report is not final, it only serves to demonstrate the potential of this tool. LitLyx will improve soon! Stay tuned!', 50, 600);
pdf
.font('pdf_fonts/Poppins-Regular.ttf')
.fontSize(14)
.fillColor('#ffffff')
.text('Generated on litlyx.com', 50, 760);
pdf
.image('pdf_images/logo.png', 460, 700, { width: 100 }) // replace with the correct path to your Unsplash image
// End PDF creation and save to file
pdf.end(); pdf.end();
return pdf; return pdf;
} }
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
const userData = getRequestUser(event); const userData = getRequestUser(event);
@@ -114,51 +93,73 @@ export default defineEventHandler(async event => {
const project = await ProjectModel.findById(project_id); const project = await ProjectModel.findById(project_id);
if (!project) return setResponseStatus(event, 400, 'Project not found'); if (!project) return setResponseStatus(event, 400, 'Project not found');
const snapshotHeader = getHeader(event, 'x-snapshot-name');
const fromHeader = getHeader(event, 'x-from');
const toHeader = getHeader(event, 'x-to');
const from = fromHeader ? new Date(fromHeader) : new Date(2020, 0);
const to = toHeader ? new Date(toHeader) : new Date(3001, 0);
const eventsCount = await EventModel.countDocuments({
project_id: project._id,
created_at: { $gte: from, $lte: to }
});
const eventsCount = await EventModel.countDocuments({ project_id: project._id }); const visitsCount = await VisitModel.countDocuments({
const visitsCount = await VisitModel.countDocuments({ project_id: project._id }); project_id: project._id,
created_at: { $gte: from, $lte: to }
const sessionsVisitsCount: any[] = await VisitModel.aggregate([ });
{ $match: { project_id: project._id } },
{ $group: { _id: "$session" } },
{ $count: "count" }
]);
const firstEventDate = await EventModel.findOne({ project_id: project._id }, { created_at: 1 }, { sort: { created_at: 1 } });
const firstViewDate = await VisitModel.findOne({ project_id: project._id }, { created_at: 1 }, { sort: { created_at: 1 } });
if (!firstEventDate || !firstViewDate) {
return setResponseStatus(event, 400, 'Not enough data to generate report');
}
const avgEventsDay = () => {
const days = (Date.now() - (firstEventDate?.created_at.getTime() || 0)) / 1000 / 60 / 60 / 24;
const avg = eventsCount / Math.max(days, 1);
return avg;
};
const avgVisitDay = () => { const avgVisitDay = () => {
const days = (Date.now() - (firstViewDate?.created_at.getTime() || 0)) / 1000 / 60 / 60 / 24; const days = (Date.now() - (from.getTime())) / 1000 / 60 / 60 / 24;
const avg = visitsCount / Math.max(days, 1); const avg = visitsCount / Math.max(days, 1);
return avg; return avg;
}; };
const avgVisitsSessionsDay = () => { const topDevices = await VisitModel.aggregate([
const days = (Date.now() - (firstViewDate?.created_at.getTime() || 0)) / 1000 / 60 / 60 / 24; { $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
const avg = sessionsVisitsCount[0].count / Math.max(days, 1); { $group: { _id: "$device", count: { $sum: 1 } } },
return avg; { $match: { _id: { $ne: null } } },
}; { $sort: { count: -1 } },
{ $limit: 1 }
]);
const pdf = createPdf( const topDevice = topDevices?.[0]?._id || 'Not enough data';
project.name, {
customEvents: eventsCount, const topDomains = await VisitModel.aggregate([
eventsDay: avgEventsDay(), { $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
pageVisits: visitsCount, { $group: { _id: "$website", count: { $sum: 1 } } },
visitsDay: avgVisitDay(), { $sort: { count: -1 } },
visitsSessions: sessionsVisitsCount[0].count, { $limit: 1 }
visitsSessionsDay: avgVisitsSessionsDay() ]);
const topDomain = topDomains?.[0]?._id || 'Not enough data';
const topCountries = await VisitModel.aggregate([
{ $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
{ $group: { _id: "$country", count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 3 }
]);
const topReferrers = await VisitModel.aggregate([
{ $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
{ $group: { _id: "$referrer", count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 3 }
]);
const pdf = createPdf({
projectName: project.name,
snapshotName: snapshotHeader || 'NO_NAME',
totalVisits: formatNumberK(visitsCount),
avgVisitsDay: formatNumberK(avgVisitDay()) + '/day',
totalEvents: formatNumberK(eventsCount),
avgGrowthText: 'Insufficient Data (Requires at least 2 months of tracking)',
topDevice: topDevice,
topDomain: topDomain,
topCountries: topCountries.map(e => e._id),
topReferrers: topReferrers.map(e => e._id)
}); });
const passThrough = new PassThrough(); const passThrough = new PassThrough();

View File

@@ -16,6 +16,25 @@ export default defineEventHandler(async event => {
const project = await ProjectModel.findById(project_id); const project = await ProjectModel.findById(project_id);
if (!project) return setResponseStatus(event, 400, 'Project not found'); if (!project) return setResponseStatus(event, 400, 'Project not found');
if (project.subscription_id === 'onetime') {
const projectLimits = await ProjectLimitModel.findOne({ project_id });
if (!projectLimits) return setResponseStatus(event, 400, 'Project limits not found');
const result = {
premium: project.premium,
premium_type: project.premium_type,
billing_start_at: projectLimits.billing_start_at,
billing_expire_at: projectLimits.billing_expire_at,
limit: projectLimits.limit,
count: projectLimits.events + projectLimits.visits,
subscription_status: StripeService.isDisabled() ? 'Disabled mode' : ('One time payment')
}
return result;
}
const subscription = await StripeService.getSubscription(project.subscription_id); const subscription = await StripeService.getSubscription(project.subscription_id);
const projectLimits = await ProjectLimitModel.findOne({ project_id }); const projectLimits = await ProjectLimitModel.findOne({ project_id });

View File

@@ -0,0 +1,27 @@
import { checkApiKey, checkAuthorization, eventsListApi } from '~/server/services/ApiService';
export default defineEventHandler(async event => {
const { rows, from, to, limit } = await readBody(event);
const token = checkAuthorization(event);
if (!token) return;
const apiKeyResult = await checkApiKey(token);
if (!apiKeyResult.ok) return setResponseStatus(event, 401, 'ApiKey not valid');
if (!rows) return setResponseStatus(event, 400, 'rows is required');
if (!Array.isArray(rows)) return setResponseStatus(event, 400, 'rows must be an array');
if (rows.length == 0) return setResponseStatus(event, 400, 'rows cannot be empty');
if (Array.isArray(from)) return setResponseStatus(event, 400, 'Only one "from" is allowed');
if (Array.isArray(to)) return setResponseStatus(event, 400, 'Only one "to" is allowed');
const result = await eventsListApi(apiKeyResult.data.apiKey, apiKeyResult.data.project_id.toString(), rows, limit as string, from as string, to as string);
if (result.ok) return result;
return setResponseStatus(event, result.code, result.error);
});

View File

@@ -0,0 +1,25 @@
import { checkApiKey, checkAuthorization, eventsListApi, } from '~/server/services/ApiService';
export default defineEventHandler(async event => {
const { row, from, to, limit } = getQuery(event);
const token = checkAuthorization(event);
if (!token) return;
const apiKeyResult = await checkApiKey(token);
if (!apiKeyResult.ok) return setResponseStatus(event, 401, 'ApiKey not valid');
if (Array.isArray(from)) return setResponseStatus(event, 400, 'Only one "from" is allowed');
if (Array.isArray(to)) return setResponseStatus(event, 400, 'Only one "to" is allowed');
const rows: string[] = Array.isArray(row) ? row as string[] : [row as string];
const result = await eventsListApi(apiKeyResult.data.apiKey, apiKeyResult.data.project_id.toString(), rows, limit as string, from as string, to as string);
if (result.ok) return result;
return setResponseStatus(event, result.code, result.error);
});

View File

@@ -0,0 +1,28 @@
import { checkApiKey, checkAuthorization } from '~/server/services/ApiService';
import { visitsListApi } from '../../services/ApiService';
export default defineEventHandler(async event => {
const { rows, from, to, limit } = await readBody(event);
const token = checkAuthorization(event);
if (!token) return;
const apiKeyResult = await checkApiKey(token);
if (!apiKeyResult.ok) return setResponseStatus(event, 401, 'ApiKey not valid');
if (!rows) return setResponseStatus(event, 400, 'rows is required');
if (!Array.isArray(rows)) return setResponseStatus(event, 400, 'rows must be an array');
if (rows.length == 0) return setResponseStatus(event, 400, 'rows cannot be empty');
if (Array.isArray(from)) return setResponseStatus(event, 400, 'Only one "from" is allowed');
if (Array.isArray(to)) return setResponseStatus(event, 400, 'Only one "to" is allowed');
const result = await visitsListApi(apiKeyResult.data.apiKey, apiKeyResult.data.project_id.toString(), rows, limit as string, from as string, to as string);
if (result.ok) return result;
return setResponseStatus(event, result.code, result.error);
});

View File

@@ -0,0 +1,27 @@
import { ApiSettingsModel } from '@schema/ApiSettingsSchema';
import { VisitModel } from '@schema/metrics/VisitSchema';
import { checkApiKey, checkAuthorization, visitsListApi } from '~/server/services/ApiService';
export default defineEventHandler(async event => {
const { row, from, to, limit } = getQuery(event);
const token = checkAuthorization(event);
if (!token) return;
const apiKeyResult = await checkApiKey(token);
if (!apiKeyResult.ok) return setResponseStatus(event, 401, 'ApiKey not valid');
if (Array.isArray(from)) return setResponseStatus(event, 400, 'Only one "from" is allowed');
if (Array.isArray(to)) return setResponseStatus(event, 400, 'Only one "to" is allowed');
const rows: string[] = Array.isArray(row) ? row as string[] : [row as string];
const result = await visitsListApi(apiKeyResult.data.apiKey, apiKeyResult.data.project_id.toString(), rows, limit as string, from as string, to as string);
if (result.ok) return result;
return setResponseStatus(event, result.code, result.error);
});

View File

@@ -11,7 +11,7 @@ export default async () => {
console.log('[SERVER] Initializing'); console.log('[SERVER] Initializing');
if (config.EMAIL_SERVICE) { if (config.EMAIL_SERVICE) {
EmailService.createTransport(config.EMAIL_SERVICE, config.EMAIL_HOST, config.EMAIL_USER, config.EMAIL_PASS); EmailService.init(config.BREVO_API_KEY);
console.log('[EMAIL] Initialized') console.log('[EMAIL] Initialized')
} }

View File

@@ -127,7 +127,7 @@ export async function sendMessageOnChat(text: string, pid: string, initial_chat_
messages.push({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) }); messages.push({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) });
await addMessageToChat({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) }, chat_id); await addMessageToChat({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) }, chat_id);
} }
response = await openai.chat.completions.create({ model: 'gpt-3.5-turbo', messages, n: 1, tools }); response = await openai.chat.completions.create({ model: 'gpt-4o', messages, n: 1, tools });
responseMessage = response.choices[0].message; responseMessage = response.choices[0].message;
toolCalls = responseMessage.tool_calls; toolCalls = responseMessage.tool_calls;

View File

@@ -0,0 +1,86 @@
import { ApiSettingsModel, TApiSettings } from '@schema/ApiSettingsSchema';
import { EventModel } from '@schema/metrics/EventSchema';
import { VisitModel } from '@schema/metrics/VisitSchema';
import type { H3Event, EventHandlerRequest } from 'h3'
export function checkAuthorization(event: H3Event<EventHandlerRequest>) {
const authorization = getHeader(event, 'Authorization');
if (!authorization) return setResponseStatus(event, 403, 'Authorization is required');
const [type, token] = authorization.split(' ');
if (type != 'Bearer') return setResponseStatus(event, 401, 'Malformed authorization');
return token;
}
export type CheckApiKeyResult = { ok: false } | { ok: true, data: TApiSettings };
export async function checkApiKey(apiKey: string): Promise<CheckApiKeyResult> {
const apiSettings = await ApiSettingsModel.findOne({ apiKey });
if (!apiSettings) return { ok: false }
return { ok: true, data: apiSettings }
}
async function incrementApiUsage(apiKey: string, value: number) {
await ApiSettingsModel.updateOne({ apiKey }, { $inc: { usage: value } });
}
async function checkApiUsage(apiKey: string) {
const data = await ApiSettingsModel.findOne({ apiKey }, { usage: 1 });
if (!data) return false;
if (data.usage > 100000) return false;
return true;
}
export type ApiResult = { ok: true, data: any } | { ok: false, code: number, error: string }
export async function eventsListApi(apiKey: string, project_id: string, rows: string[], limit?: number | string, from?: string, to?: string): Promise<ApiResult> {
const canMakeRequest = await checkApiUsage(apiKey);
if (!canMakeRequest) return { ok: false, code: 429, error: 'Api limit reached (100.000)' }
const projection = Object.fromEntries(rows.map(e => [e, 1]));
const limitNumber = parseInt((limit?.toString() as string));
const limitValue = isNaN(limitNumber) ? 100 : limitNumber;
const events = await EventModel.find({
project_id,
created_at: {
$gte: from || new Date(2023, 0),
$lte: to || new Date(3000, 0)
}
}, { _id: 0, ...projection }, { limit: limitValue });
await incrementApiUsage(apiKey, events.length);
return { ok: true, data: events.map(e => e.toJSON()) }
}
export async function visitsListApi(apiKey: string, project_id: string, rows: string[], limit?: number | string, from?: string, to?: string): Promise<ApiResult> {
const canMakeRequest = await checkApiUsage(apiKey);
if (!canMakeRequest) return { ok: false, code: 429, error: 'Api limit reached (100.000)' }
const projection = Object.fromEntries(rows.map(e => [e, 1]));
const limitNumber = parseInt((limit?.toString() as string));
const limitValue = isNaN(limitNumber) ? 100 : limitNumber;
const visits = await VisitModel.find({
project_id,
created_at: {
$gte: from || new Date(2023, 0),
$lte: to || new Date(3000, 0)
}
}, { _id: 0, ...projection }, { limit: limitValue });
await incrementApiUsage(apiKey, visits.length);
return { ok: true, data: visits.map(e => e.toJSON()) };
}

View File

@@ -1,4 +1,4 @@
import { getPlanFromTag } from '@data/PREMIUM'; import { getPlanFromId, getPlanFromTag } from '@data/PREMIUM';
import Stripe from 'stripe'; import Stripe from 'stripe';
class StripeService { class StripeService {
@@ -29,6 +29,33 @@ class StripeService {
return this.stripe.webhooks.constructEvent(body, sig, this.webhookSecret); return this.stripe.webhooks.constructEvent(body, sig, this.webhookSecret);
} }
async createOnetimePayment(price: string, success_url: string, pid: string, customer?: string) {
if (this.disabledMode) return;
if (!this.stripe) throw Error('Stripe not initialized');
const checkout = await this.stripe.checkout.sessions.create({
allow_promotion_codes: true,
payment_method_types: ['card'],
invoice_creation: {
enabled: true,
},
line_items: [
{ price, quantity: 1 }
],
payment_intent_data: {
metadata: {
pid, price
}
},
customer,
success_url,
mode: 'payment'
});
return checkout;
}
async cretePayment(price: string, success_url: string, pid: string, customer?: string) { async cretePayment(price: string, success_url: string, pid: string, customer?: string) {
if (this.disabledMode) return; if (this.disabledMode) return;
if (!this.stripe) throw Error('Stripe not initialized'); if (!this.stripe) throw Error('Stripe not initialized');
@@ -50,6 +77,13 @@ class StripeService {
return checkout; return checkout;
} }
async getPriceData(priceId: string) {
if (this.disabledMode) return;
if (!this.stripe) throw Error('Stripe not initialized');
const priceData = await this.stripe.prices.retrieve(priceId);
return priceData;
}
async deleteSubscription(subscriptionId: string) { async deleteSubscription(subscriptionId: string) {
if (this.disabledMode) return; if (this.disabledMode) return;
if (!this.stripe) throw Error('Stripe not initialized'); if (!this.stripe) throw Error('Stripe not initialized');
@@ -78,7 +112,6 @@ class StripeService {
return invoices; return invoices;
} }
async getCustomer(customer_id: string) { async getCustomer(customer_id: string) {
if (this.disabledMode) return; if (this.disabledMode) return;
if (!this.stripe) throw Error('Stripe not initialized'); if (!this.stripe) throw Error('Stripe not initialized');
@@ -100,8 +133,27 @@ class StripeService {
return deleted; return deleted;
} }
async createOneTimeCoupon() {
if (this.disabledMode) return;
if (!this.stripe) throw Error('Stripe not initialized');
}
async createOneTimeSubscriptionDummy(customer_id: string, planId: number) {
if (this.disabledMode) return;
if (!this.stripe) throw Error('Stripe not initialized');
const PLAN = getPlanFromId(planId);
if (!PLAN) throw Error('Plan not found');
const subscription = await this.stripe.subscriptions.create({
customer: customer_id,
items: [
{ price: this.testMode ? PLAN.PRICE_TEST : PLAN.PRICE, quantity: 1 }
],
});
return subscription;
}
async createFreeSubscription(customer_id: string) { async createFreeSubscription(customer_id: string) {
if (this.disabledMode) return; if (this.disabledMode) return;

View File

@@ -9,6 +9,7 @@ export function formatNumberK(value: string | number, decimals: number = 1) {
if (num > 1_000_000) return (num / 1_000_000).toFixed(decimals) + ' M'; if (num > 1_000_000) return (num / 1_000_000).toFixed(decimals) + ' M';
if (num > 1_000) return (num / 1_000).toFixed(decimals) + ' K'; if (num > 1_000) return (num / 1_000).toFixed(decimals) + ' K';
return num.toFixed();
return isNaN(num) ? '0' : num.toFixed();
} }

View File

@@ -40,10 +40,8 @@ services:
# Optional - Used to send welcome and quota emails # Optional - Used to send welcome and quota emails
# EMAIL_SERVICE: "" # NUXT_EMAIL_SERVICE: "Brevo"
# EMAIL_HOST: "" # NUXT_BREVO_API_KEY: ""
# EMAIL_USER: ""
# EMAIL_PASS: ""
PORT: "3999" PORT: "3999"
MONGO_CONNECTION_STRING: "mongodb://litlyx:litlyx@mongo:27017/SimpleMetrics?readPreference=primaryPreferred&authSource=admin" MONGO_CONNECTION_STRING: "mongodb://litlyx:litlyx@mongo:27017/SimpleMetrics?readPreference=primaryPreferred&authSource=admin"
@@ -77,10 +75,8 @@ services:
# Optional - Used to send welcome and quota emails # Optional - Used to send welcome and quota emails
# NUXT_EMAIL_SERVICE: "" # NUXT_EMAIL_SERVICE: "Brevo"
# NUXT_EMAIL_HOST: "" # NUXT_BREVO_API_KEY: ""
# NUXT_EMAIL_USER: ""
# NUXT_EMAIL_PASS: ""
NUXT_AUTH_JWT_SECRET: "litlyx_jwt_secret" NUXT_AUTH_JWT_SECRET: "litlyx_jwt_secret"

View File

@@ -9,7 +9,7 @@ export type BlogPost = {
} }
export const homePostsIndexes = ref<number[]>([ export const homePostsIndexes = ref<number[]>([
0 1, 0
]) ])
export const blogPosts = ref<BlogPost[]>([ export const blogPosts = ref<BlogPost[]>([
@@ -21,9 +21,19 @@ export const blogPosts = ref<BlogPost[]>([
title: 'Presenting Litlyx', title: 'Presenting Litlyx',
subtitle: 'Our Why. Our Vision. Our Manifestation of Intent', subtitle: 'Our Why. Our Vision. Our Manifestation of Intent',
id: 'presenting-litlyx' id: 'presenting-litlyx'
},
{
author: 'Antonio, CEO at Litlyx',
authorImage: 'AntonioVerdiglione.jpg',
image: 'posts/why-choose-litlyx.jpg',
created_at: "Sep 1, 2024",
title: 'Why choose Litlyx',
subtitle: 'Litlyx vs Plausible vs Google Analitycs',
id: 'why-choose-litlyx'
} }
]); ]);
export const homePosts = computed(() => { export const homePosts = computed(() => {
return blogPosts.value.filter((e, i) => homePostsIndexes.value.includes(i)); return homePostsIndexes.value.map(e => blogPosts.value[e]);
// return blogPosts.value.filter((e, i) => homePostsIndexes.value.includes(i));
}) })

View File

@@ -11,6 +11,20 @@ nuxtApp.hook("page:finish", () => {
scroller.value?.scrollTo(0, 0); scroller.value?.scrollTo(0, 0);
}) })
const gitstars = ref<string>('Loading...')
async function getGithubStars() {
const res = await fetch('https://api.github.com/repos/litlyx/litlyx');
if (!res.ok) return gitstars.value = '340+'
const data = await res.json();
return gitstars.value = data.stargazers_count.toString() + '+';
}
onMounted(() => {
getGithubStars();
})
</script> </script>
@@ -37,7 +51,7 @@ nuxtApp.hook("page:finish", () => {
Blog Blog
</NuxtLink> </NuxtLink>
<NuxtLink target="_blank" to="https://dashboard.litlyx.com/live_demo" <NuxtLink target="_blank" to="https://dashboard.litlyx.com/live_demo"
class="poppins hover:text-text-sub/90"> class="poppins hover:text-text-sub/90 whitespace-nowrap">
Live demo Live demo
</NuxtLink> </NuxtLink>
<NuxtLink target="_blank" to="https://docs.litlyx.com" class="poppins hover:text-text-sub/90"> <NuxtLink target="_blank" to="https://docs.litlyx.com" class="poppins hover:text-text-sub/90">
@@ -63,19 +77,17 @@ nuxtApp.hook("page:finish", () => {
</svg> </svg>
</div> </div>
<div class="text-[1rem]"> <div class="text-[1rem]">
210+ {{ gitstars }}
</div> </div>
</NuxtLink> </NuxtLink>
</div> </div>
<div class="px-10 pt-6 lg:pt-0"> <div class="px-10 pt-6 lg:pt-0">
<MainButton link="https://dashboard.litlyx.com" class="!whitespace-nowrap">
<MainButton link="https://dashboard.litlyx.com">
Get started Get started
</MainButton> </MainButton>
</div> </div>
</div> </div>
@@ -123,7 +135,8 @@ nuxtApp.hook("page:finish", () => {
<div class="divider border-b border-gray-500/40"></div> <div class="divider border-b border-gray-500/40"></div>
<NuxtLink @click="isMenuOpen = false" to="/why-choose-litlyx" class="flex justify-between items-center mr-2"> <NuxtLink @click="isMenuOpen = false" to="/why-choose-litlyx"
class="flex justify-between items-center mr-2">
<div class="hover:text-text-sub/90 py-3"> <div class="hover:text-text-sub/90 py-3">
Why choose Litlyx Why choose Litlyx
</div> </div>
@@ -148,7 +161,7 @@ nuxtApp.hook("page:finish", () => {
</div> </div>
<div> <i class="fas fa-chevron-right"></i> </div> <div> <i class="fas fa-chevron-right"></i> </div>
</NuxtLink> </NuxtLink>
<div class="divider border-b border-gray-500/40"></div> <div class="divider border-b border-gray-500/40"></div>
@@ -233,7 +246,8 @@ nuxtApp.hook("page:finish", () => {
<NuxtLink target="_blank" to="https://github.com/Litlyx/litlyx" <NuxtLink target="_blank" to="https://github.com/Litlyx/litlyx"
class="hover:text-accent cursor-pointer"> Github </NuxtLink> class="hover:text-accent cursor-pointer"> Github </NuxtLink>
<NuxtLink to="/pricing" class="hover:text-accent cursor-pointer"> Pricing </NuxtLink> <NuxtLink to="/pricing" class="hover:text-accent cursor-pointer"> Pricing </NuxtLink>
<NuxtLink to="/why-choose-litlyx" class="hover:text-accent cursor-pointer"> Why choose Litlyx </NuxtLink> <NuxtLink to="/why-choose-litlyx" class="hover:text-accent cursor-pointer"> Why choose Litlyx
</NuxtLink>
</div> </div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="text-text-sub/60 font-semibold text-[1.3rem]"> Company </div> <div class="text-text-sub/60 font-semibold text-[1.3rem]"> Company </div>

View File

@@ -1,34 +1,35 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
colorMode: { preference: 'dark', }, colorMode: { preference: 'dark', },
devtools: { enabled: false }, devtools: { enabled: false },
app: { app: {
head: { head: {
script: [ script: [
{ {
src: 'https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js', src: 'https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js',
'data-project': '6643cd08a1854e3b81722ab5', 'data-project': '6643cd08a1854e3b81722ab5',
defer: true defer: true
} }
] ]
} }
},
pages: true,
ssr: true,
routeRules: {
'/': {
prerender: true
}, },
'/**': { pages: true,
prerender: true ssr: true,
routeRules: {
'/': {
prerender: true
},
'/**': {
prerender: true
},
}, },
}, css: ['~/assets/scss/main.scss'],
css: ['~/assets/scss/main.scss'], modules: ['@nuxt/ui'],
modules: ['@nuxt/ui'], devServer: {
devServer: { host: '0.0.0.0',
host: '0.0.0.0', },
}, components: true,
components: true, extends: ['../lyx-ui']
extends: ['../lyx-ui'] })
})

View File

@@ -0,0 +1,455 @@
<script lang="ts" setup>
definePageMeta({ layout: 'header' });
import { blogPosts } from '~/blog/Blog';
definePageMeta({ layout: 'header' });
const route = useRoute();
const id = route.path.replace('/blog/', '');
const blogPost = blogPosts.value.find(e => e.id == id);
useSeoMeta({
title: () => `Litlyx Blog - ${blogPost?.title}`,
ogTitle: () => `Litlyx Blog - ${blogPost?.title}`,
description: () => blogPost?.subtitle,
ogDescription: () => blogPost?.subtitle,
keywords: 'Litlyx blog, analytics insights, real-time analytics, AI-powered analytics, data visualization, performance metrics, KPI tracking, custom events, open-source analytics, business intelligence, data trends, developer insights, analytics updates, data-driven decisions, blog updates, tech news, Litlyx updates, analytics best practices',
author: () => blogPost?.author,
ogImage: () => 'https://litlyx.com/blog/' + blogPost?.image,
ogType: 'website',
ogUrl: 'https://litlyx.com/blog',
twitterCard: 'summary_large_image',
twitterTitle: () => `Litlyx Blog - ${blogPost?.title}`,
twitterDescription: () => blogPost?.subtitle,
twitterImage: () => 'https://litlyx.com/blog/' + blogPost?.image,
ogSiteName: 'Litlyx',
ogLocale: 'en_US',
ogImageWidth: '1200',
ogImageHeight: '630'
})
const featureStyle = 'font-bold poppins text-lyx-text';
const cellStyle = 'poppins text-lyx-text'
const activeStyle = 'poppins !text-lyx-text border-solid border-[1px] border-green-400 bg-green-400/5'
const columns = [{
key: 'feature',
label: 'Feature'
}, {
key: 'litlyx',
label: 'Litlyx'
}, {
key: 'plausible',
label: 'Plausible'
}, {
key: 'google',
label: 'Google Analytics'
}]
const comparisons = ref<any[]>([
{
feature: { text: 'Setup Time', class: featureStyle },
litlyx: { text: '30 seconds', class: activeStyle },
plausible: { text: 'A few Minutes', class: cellStyle },
google: { text: 'Hours', class: cellStyle }
},
{
feature: { text: 'Real-Time Collection', class: featureStyle },
litlyx: { text: 'Instant', class: activeStyle },
plausible: { text: 'each 5 minutes', class: cellStyle },
google: { text: 'from few minutes, to hours', class: cellStyle }
},
{
feature: { text: 'Documentation', class: featureStyle },
litlyx: { text: 'Small, Easy & Modern', class: activeStyle },
plausible: { text: 'Longer than necessary', class: cellStyle },
google: { text: 'Mstodon & Complex', class: cellStyle }
},
{
feature: { text: 'Modern UI', class: featureStyle },
litlyx: { text: 'Yes', class: activeStyle },
plausible: { text: 'Yes', class: activeStyle },
google: { text: 'Yes', class: activeStyle }
},
{
feature: { text: 'Developers Centric Approach', class: featureStyle },
litlyx: { text: 'Yes', class: activeStyle },
plausible: { text: 'Partially', class: cellStyle },
google: { text: 'No', class: cellStyle }
},
{
feature: { text: 'Website Loading Time', class: featureStyle },
litlyx: { text: '3 ms', class: activeStyle },
plausible: { text: '3 ms', class: activeStyle },
google: { text: '5-15 ms', class: cellStyle }
},
{
feature: { text: 'Library Size', class: featureStyle },
litlyx: { text: '3 kb', class: '' },
plausible: { text: '2 kb', class: activeStyle },
google: { text: 'avg. 50 kb', class: cellStyle }
},
{
feature: { text: 'Custom Events Support', class: featureStyle },
litlyx: { text: 'Yes', class: activeStyle },
plausible: { text: 'Yes', class: activeStyle },
google: { text: 'Yes', class: activeStyle }
},
{
feature: { text: 'Native Js Runtimes', class: featureStyle },
litlyx: { text: 'Yes', class: activeStyle },
plausible: { text: 'No', class: cellStyle },
google: { text: 'No', class: cellStyle }
},
{
feature: { text: 'Custom Events metadata Support', class: featureStyle },
litlyx: { text: 'Yes', class: activeStyle },
plausible: { text: 'No', class: cellStyle },
google: { text: 'Limited', class: cellStyle }
},
{
feature: { text: 'Raw Data Export', class: featureStyle },
litlyx: { text: 'Yes', class: activeStyle },
plausible: { text: 'No', class: cellStyle },
google: { text: 'No', class: cellStyle }
},
{
feature: { text: 'Data Ownership', class: featureStyle },
litlyx: { text: 'Yes', class: activeStyle },
plausible: { text: 'Yes', class: activeStyle },
google: { text: 'No', class: cellStyle }
},
{
feature: { text: 'Open-Source', class: featureStyle },
litlyx: { text: 'Self-Hostable', class: activeStyle },
plausible: { text: 'Self-Hostable', class: activeStyle },
google: { text: 'No', class: cellStyle }
},
{
feature: { text: 'Privacy-focused', class: featureStyle },
litlyx: { text: 'Yes', class: activeStyle },
plausible: { text: 'Yes', class: activeStyle },
google: { text: 'No', class: cellStyle }
},
{
feature: { text: 'Cookies Stored', class: featureStyle },
litlyx: { text: '0 Cookies', class: activeStyle },
plausible: { text: '0 Cookies', class: activeStyle },
google: { text: 'Yes, many', class: cellStyle }
},
{
feature: { text: 'EU standard', class: featureStyle },
litlyx: { text: 'Yes', class: activeStyle },
plausible: { text: 'Yes', class: activeStyle },
google: { text: 'Limited', class: cellStyle }
},
{
feature: { text: 'Ad Blockers', class: featureStyle },
litlyx: { text: 'Impossible to block', class: activeStyle },
plausible: { text: 'Possible, but hard!', class: cellStyle },
google: { text: 'Possible', class: cellStyle }
},
{
feature: { text: 'AI Data Analyst Assistant', class: featureStyle },
litlyx: { text: 'Yes', class: activeStyle },
plausible: { text: 'No', class: cellStyle },
google: { text: 'No', class: cellStyle }
},
{
feature: { text: 'Report Generation', class: featureStyle },
litlyx: { text: 'Detailed reports in seconds', class: activeStyle },
plausible: { text: 'Basic Reports', class: cellStyle },
google: { text: 'Extensive, but complex to do', class: cellStyle }
},
{
feature: { text: 'User-Friendly interface', class: featureStyle },
litlyx: { text: 'Yes', class: activeStyle },
plausible: { text: 'Yes', class: activeStyle },
google: { text: 'Moderate Complexity', class: cellStyle }
},
{
feature: { text: 'Agglomeration from third parties DBs', class: featureStyle },
litlyx: { text: 'Yes', class: activeStyle },
plausible: { text: 'No', class: cellStyle },
google: { text: 'No', class: cellStyle }
},
{
feature: { text: 'Free Options (Cloud)', class: featureStyle },
litlyx: { text: 'Free-Forever plan to start.', class: activeStyle },
plausible: { text: '30 days free trial.', class: cellStyle },
google: { text: 'Free forever. No Ownership.', class: activeStyle }
},
{
feature: { text: 'Cost Effective (Cloud)', class: featureStyle },
litlyx: { text: 'Basic plan: €4,99/mo for 50k visits/events', class: activeStyle },
plausible: { text: 'Basic plan: €9,00/mo for 10k page visits', class: cellStyle },
google: { text: 'Offers Enterprise only Premium prices (really expansive)', class: cellStyle }
}
]);
</script>
<template>
<BlogArticleWrapper>
<div class="px-0">
<div class="section">
<div class="title">
Why Choose Litlyx
</div>
<div class="paragraph">
Our product is simple and focuses on the developer's experience. Developers love free stuff, so
Litlyx
offers a generous Free-Forever plan to get started. Developers don't want to waste time setting up
complex analytics to track key metrics for their websites. So, we asked ourselves, 'What would
developers find easy and cool?' As a team of developers, we created the simplest, most optimized,
and
developer-friendly way to do analytics.
</div>
</div>
<div class="section">
<div class="title">
Litlyx vs Plausible vs Google Analytics Comparison Table
</div>
<div class="paragraph">
Comparisons between the most common softwares used for web analytics collection and Litlyx.
</div>
</div>
<div class="section">
<div class="hidden lg:flex lg:justify-center w-full lg:flex-nowrap">
<UTable :rows="comparisons" :columns="columns" :ui="{
td: {
base: 'border-x-[1px] border-solid border-lyx-widget-lighter'
},
th: {
base: 'border-[1px] border-solid border-lyx-widget-lighter bg-lyx-widget'
}
}">
<template #litlyx-data="{ row }">
{{ row.litlyx.text }}
</template>
<template #feature-data="{ row }">
{{ row.feature.text }}
</template>
<template #plausible-data="{ row }">
{{ row.plausible.text }}
</template>
<template #google-data="{ row }">
{{ row.google.text }}
</template>
</UTable>
</div>
<div class="lg:hidden text-lyx-text-dark">
[Table available only on desktop]
</div>
</div>
<div class="section">
<div class="title">
Google Analytics vs. Litlyx
</div>
<ul class="list-disc text-lyx-text-dark mt-4">
<li>
Ease of Use: Google Analytics can be complex, with many features that may be overwhelming when
reading its documentation. Litlyx is simple and easy to use.
</li>
<li>
Setup: Google Analytics requires more time to set up, whereas Litlyx is quick and easy to set up
(just 30 seconds).
</li>
<li>
Free Plan: Google Analytics offers a free plan, but you do not own your data. Litlyx offers a
Free-Forever plan with generous limits, and you can handle raw data.
</li>
<li>
Developer Focus: Google Analytics is for general users, with libraries written by third parties.
Litlyx is built with developers in mind, supporting all tech stacks natively.
</li>
</ul>
</div>
<div class="section">
<div class="title">
Plausible vs. Litlyx <span class="text-[.9rem]">(we Plausible)</span>
</div>
<ul class="list-disc text-lyx-text-dark mt-4">
<li>
Ease of Use: Plausible is simple and user-friendly. Litlyx is also simple and easy to use. Both
Plausible and Litlyx are awesome!
</li>
<li>
Setup: Plausible setup is quick and easy. Litlyx setup is equally quick and straightforward.
</li>
<li>
Free Plan: Plausible does not offer a free plan, giving you a 30-day free trial before you need
to
pay. Litlyx offers a generous Free-Forever plan.
</li>
<li>
Developer Focus: Plausible is designed for general users with a focus on privacy, whereas Litlyx
is
specifically designed for developers.
</li>
</ul>
</div>
<div class="section">
<div class="title">
Who can use Litlyx?
</div>
<div class="paragraph">
We built Litlyx with developers in mind. If you want really good metrics, you should go through your
development team. We created the simplest setup a developer can ask for, supporting all JavaScript
and
TypeScript runtimes. Litlyx can be used even by startup founders or business owners who have access
to
their landing page. We offer extensive support to guide you through the implementation. You can
contact
us anytime for information at help@litlyx.com. With Litlyx, you will not be alone.
</div>
</div>
<div class="section">
<div class="title">
We are far from perfection...
</div>
<div class="paragraph">
We are far from perfect. We are human, and we embrace that. Our imperfections drive us to always
improve
and innovate. We learn from our mistakes and use those lessons to create better solutions. By
acknowledging our humanity, we connect more deeply with our users, understanding their needs and
challenges. Our commitment to growth and improvement ensures that we constantly strive to deliver
the
best possible experience for developers.
</div>
</div>
<div class="section">
<div class="title">
Is Impossible to Beat a giant, but...
</div>
<div class="paragraph">
We know we cant compete with Google directlyits like trying to challenge a giant. But even giants
have weaknesses. Startups with bold ideas can find ways to disrupt the status quo and we see this
many
times in history. Were here to support all innovators by providing a first-class developer
experience
with our product. Our goal is to empower every size companies to make a significant impact in the
industry taking matrics driven decisions with semplicity.
</div>
</div>
<div class="section">
<div class="title">
Help us improve!
</div>
<div class="paragraph">
If you choose us today, you can help us improve and become first-class in analytics. We cant do
this
alone; we need you! Your feedback and suggestions are very valuable to us. By working together, we
can
refine our product and create an exceptional experience for developers. Join us on this journey, and
let's build something amazing together. Your input will help us reach new heights and set the
standard
of excellence next-generation developers deserve.
</div>
</div>
<div class="section">
<div class="title text-center">
Ready to start with Litlyx?
</div>
<div class="button-container flex-col items-center gap-6">
<LyxUiButton link="https://dashboard.litlyx.com/live_demo" target="_blank" class="button"
type="outline">
Go to Live Demo
</LyxUiButton>
<LyxUiButton link="https://dashboard.litlyx.com" target="_blank" class="button" type="primary">
Start for free now
</LyxUiButton>
</div>
</div>
</div>
</BlogArticleWrapper>
</template>
<style scoped lang="scss">
.article * {
font-family: 'Poppins', sans-serif;
}
article {
margin: 2rem;
}
header,
section,
footer {
margin-bottom: 2rem;
}
section h2 {
margin-bottom: 2rem;
color: white;
}
p {
margin-bottom: 1rem;
}
.title {
font-family: 'Poppins' !important;
@apply font-semibold text-[1.8rem]
}
.subtitle {
font-family: 'Poppins' !important;
@apply font-medium text-[1.4rem]
}
.paragraph {
font-family: 'Poppins' !important;
@apply text-lyx-text-dark mt-4 text-[1.1rem]
}
.section {
@apply mt-20
}
.button {
@apply font-medium text-[1rem] px-6 py-2
}
.button-container {
@apply flex justify-center mt-8
}
</style>

View File

@@ -51,6 +51,15 @@ const scriptDeferTokens = ref<string[]>([
const snippetIndex = ref<number>(0); const snippetIndex = ref<number>(0);
async function saveEmail() {
await fetch('https://savemail.litlyx.com/email/' + encodeURIComponent(email.value), {
mode: 'no-cors'
});
email.value = '';
alert('We will keep you updated');
}
</script> </script>
@@ -74,10 +83,14 @@ const snippetIndex = ref<number>(0);
All Your Analytics in a Single AI Powered Dashboard. All Your Analytics in a Single AI Powered Dashboard.
</div> </div>
<div class="button-container"> <div class="button-container gap-3 flex-col lg:flex-row items-center">
<LyxUiButton link="https://dashboard.litlyx.com" target="_blank" class="button" type="primary"> <LyxUiButton link="https://dashboard.litlyx.com" target="_blank" class="button" type="primary">
Start for free Start for free
</LyxUiButton> </LyxUiButton>
<LyxUiButton link="https://dashboard.litlyx.com/live_demo" target="_blank" class="button"
type="outline">
Go to live demo
</LyxUiButton>
</div> </div>
</div> </div>
@@ -192,6 +205,24 @@ const snippetIndex = ref<number>(0);
</div> </div>
</div> </div>
<LyxUiCard class="section w-full p-8">
<div class="subtitle">
Why choose Litlyx
</div>
<div class="paragraph">
Litlyx vs Plausible vs Google Analytics
</div>
<div class="button-container">
<LyxUiButton link="/why-choose-litlyx" target="_blank" class="button" type="outline">
Read more
</LyxUiButton>
</div>
</LyxUiCard>
<div class="section"> <div class="section">
@@ -300,22 +331,6 @@ const snippetIndex = ref<number>(0);
</div> </div>
<div class="section">
<div class="subtitle">
Why choose Litlyx
</div>
<div class="paragraph">
Litlyx vs Plausible vs Google Analytics
</div>
<div class="button-container">
<LyxUiButton link="/why-choose-litlyx" target="_blank" class="button" type="outline">
Read more
</LyxUiButton>
</div>
</div>
<div class="section"> <div class="section">
<div class="subtitle"> <div class="subtitle">
Update me! Update me!
@@ -331,7 +346,7 @@ const snippetIndex = ref<number>(0);
</div> </div>
<div class="button-container"> <div class="button-container">
<LyxUiButton class="button" type="primary"> <LyxUiButton class="button" type="primary" @click="saveEmail()">
Keep me updated Keep me updated
</LyxUiButton> </LyxUiButton>
</div> </div>

View File

@@ -20,7 +20,6 @@ const freePricing: PricingCardProp[] = [
'Unlimited reports', 'Unlimited reports',
'AI Tokens: 10', 'AI Tokens: 10',
'Server type: SHARED', 'Server type: SHARED',
'Projects: max 2',
'Data retention: 2 Months' 'Data retention: 2 Months'
], ],
cta: 'Start For Free now!' cta: 'Start For Free now!'
@@ -41,7 +40,6 @@ const customPricing: PricingCardProp[] = [
'DB instance: DEDICATED', 'DB instance: DEDICATED',
'Dedicated operator', 'Dedicated operator',
'White label', 'White label',
'Custom Charts',
'Custom Data Aggregation' 'Custom Data Aggregation'
], ],
cta: 'Let\'s Talk!', cta: 'Let\'s Talk!',
@@ -58,12 +56,11 @@ const slidePricings: PricingCardProp[] = [
'CPM 0,10€ per visit/event' 'CPM 0,10€ per visit/event'
], ],
features: [ features: [
'Discord support', 'Slack support',
'Unlimited domains', 'Unlimited domains',
'Unlimited reports', 'Unlimited reports',
'AI Tokens: 30', 'AI Tokens: 30',
'Server type: SHARED', 'Server type: SHARED',
'Projects: max 3',
'Data retention: 6 Months' 'Data retention: 6 Months'
], ],
cta: 'Go to Cloud Dashboard' cta: 'Go to Cloud Dashboard'
@@ -76,12 +73,11 @@ const slidePricings: PricingCardProp[] = [
'CPM 0,06€ per visit/event' 'CPM 0,06€ per visit/event'
], ],
features: [ features: [
'Discord support', 'Slack support',
'Unlimited domains', 'Unlimited domains',
'Unlimited reports', 'Unlimited reports',
'AI Tokens: 100', 'AI Tokens: 100',
'Server type: SHARED', 'Server type: SHARED',
'Projects: max 3',
'Data retention: 9 Months' 'Data retention: 9 Months'
], ],
cta: 'Go to Cloud Dashboard' cta: 'Go to Cloud Dashboard'
@@ -94,12 +90,11 @@ const slidePricings: PricingCardProp[] = [
'CPM 0,059€ per visit/event' 'CPM 0,059€ per visit/event'
], ],
features: [ features: [
'Discord support', 'Slack support',
'Unlimited domains', 'Unlimited domains',
'Unlimited reports', 'Unlimited reports',
'AI Tokens: 3.000', 'AI Tokens: 3.000',
'Server type: SHARED', 'Server type: SHARED',
'Projects: max 3',
'Data retention: 1 Year' 'Data retention: 1 Year'
], ],
cta: 'Go to Cloud Dashboard' cta: 'Go to Cloud Dashboard'
@@ -112,12 +107,11 @@ const slidePricings: PricingCardProp[] = [
'CPM 0,059€ per visit/event' 'CPM 0,059€ per visit/event'
], ],
features: [ features: [
'Discord support', 'Slack support',
'Unlimited domains', 'Unlimited domains',
'Unlimited reports', 'Unlimited reports',
'AI Tokens: 5.000', 'AI Tokens: 5.000',
'Server type: SHARED', 'Server type: SHARED',
'Projects: max 3',
'Data retention: 1 Year' 'Data retention: 1 Year'
], ],
cta: 'Go to Cloud Dashboard' cta: 'Go to Cloud Dashboard'
@@ -130,12 +124,11 @@ const slidePricings: PricingCardProp[] = [
'CPM 0,039€ per visit/event' 'CPM 0,039€ per visit/event'
], ],
features: [ features: [
'Discord support', 'Slack support',
'Unlimited domains', 'Unlimited domains',
'Unlimited reports', 'Unlimited reports',
'AI Tokens: 10.000', 'AI Tokens: 10.000',
'Server type: DEDICATED', 'Server type: DEDICATED',
'Projects: max 3',
'Data retention: 2 Years' 'Data retention: 2 Years'
], ],
cta: 'Go to Cloud Dashboard' cta: 'Go to Cloud Dashboard'
@@ -148,12 +141,11 @@ const slidePricings: PricingCardProp[] = [
'CPM 0,029€ per visit/event' 'CPM 0,029€ per visit/event'
], ],
features: [ features: [
'Discord support', 'Slack support',
'Unlimited domains', 'Unlimited domains',
'Unlimited reports', 'Unlimited reports',
'AI Tokens: 20.000', 'AI Tokens: 20.000',
'Server type: DEDICATED', 'Server type: DEDICATED',
'Projects: max 3',
'Data retention: 3 Years' 'Data retention: 3 Years'
], ],
cta: 'Go to Cloud Dashboard' cta: 'Go to Cloud Dashboard'
@@ -189,6 +181,54 @@ const slidePricings: PricingCardProp[] = [
</PricingCardGeneric> </PricingCardGeneric>
</div> </div>
<div class="flex justify-center">
<LyxUiCard class="w-full mt-6 max-w-[96rem]">
<div class="flex flex-col lg:flex-row">
<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" link="https://dashboard.litlyx.com"> Start for free now </LyxUiButton>
</div>
<div class="flex justify-evenly grow flex-col gap-2 lg:gap-0 lg:flex-row mt-4 lg:mt-0">
<div class="flex flex-col justify-evenly gap-2 lg:gap-0">
<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 gap-2 lg:gap-0">
<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>
<!-- <div class="flex gap-8 h-max flex-col lg:flex-row"> <!-- <div class="flex gap-8 h-max flex-col lg:flex-row">
<PricingCard class="flex-1" :data="starterTierCardData"></PricingCard> <PricingCard class="flex-1" :data="starterTierCardData"></PricingCard>
<PricingCard class="flex-1" :data="accelerationTierCardData"></PricingCard> <PricingCard class="flex-1" :data="accelerationTierCardData"></PricingCard>
@@ -240,7 +280,7 @@ const slidePricings: PricingCardProp[] = [
<UAccordion :ui="{ <UAccordion :ui="{
wrapper: 'w-full', wrapper: 'w-full',
item: { item: {
padding: 'pl-8' padding: 'pl-8',
} }
}" color="white" variant="ghost" size="xl" :items="[ }" color="white" variant="ghost" size="xl" :items="[
{ {

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -12,7 +12,9 @@ export const PREMIUM_TAGS = [
'GROWTH', 'GROWTH',
'EXPANSION', 'EXPANSION',
'SCALING', 'SCALING',
'UNICORN' 'UNICORN',
'LIFETIME_GROWTH_ONETIME',
'GROWTH_DUMMY'
] as const; ] as const;
@@ -95,6 +97,20 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
PRICE: 'price_1Pdt2LB2lPUiVs9VGBFAIG9G', PRICE: 'price_1Pdt2LB2lPUiVs9VGBFAIG9G',
PRICE_TEST: '' PRICE_TEST: ''
}, },
LIFETIME_GROWTH_ONETIME: {
ID: 2001,
COUNT_LIMIT: 500_000,
AI_MESSAGE_LIMIT: 3_000,
PRICE: 'price_1PvewGB2lPUiVs9VLheJC8s1',
PRICE_TEST: 'price_1Pvf7LB2lPUiVs9VMFNyzpim'
},
GROWTH_DUMMY: {
ID: 5001,
COUNT_LIMIT: 500_000,
AI_MESSAGE_LIMIT: 3_000,
PRICE: 'price_1PvgoRB2lPUiVs9VC51YBT7J',
PRICE_TEST: 'price_1PvgRTB2lPUiVs9V3kFSNC3G'
}
} }
CustomPremiumPriceModel.find({}).then(custom_prices => { CustomPremiumPriceModel.find({}).then(custom_prices => {

View File

@@ -1,5 +1,5 @@
// Default: 1.1 // Default: 1.01
// ((events + visits) * VALUE) > limit // ((events + visits) * VALUE) > limit
export const EVENT_LOG_LIMIT_PERCENT = 1.1; export const EVENT_LOG_LIMIT_PERCENT = 1.01;

View File

@@ -1,12 +1,11 @@
{ {
"dependencies": { "dependencies": {
"@getbrevo/brevo": "^2.2.0",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"mongoose": "^8.4.0", "mongoose": "^8.4.0",
"nodemailer": "^6.9.13",
"redis": "^4.6.14" "redis": "^4.6.14"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.12.13", "@types/node": "^20.12.13"
"@types/nodemailer": "^6.4.15"
} }
} }

1069
shared/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
import { model, Schema, Types } from 'mongoose';
export type TApiSettings = {
_id: Schema.Types.ObjectId,
project_id: Schema.Types.ObjectId,
apiKey: string,
apiName: string,
usage: number,
created_at: Date
}
const ApiSettingsSchema = new Schema<TApiSettings>({
project_id: { type: Types.ObjectId, index: 1 },
apiKey: { type: String, required: true },
apiName: { type: String, required: true },
usage: { type: Number, default: 0, required: true, },
created_at: { type: Date, default: () => Date.now() },
});
export const ApiSettingsModel = model<TApiSettings>('api_settings', ApiSettingsSchema);

View File

@@ -15,7 +15,7 @@ const EventSchema = new Schema<TEvent>({
metadata: Schema.Types.Mixed, metadata: Schema.Types.Mixed,
session: { type: String }, session: { type: String },
flowHash: { type: String }, flowHash: { type: String },
created_at: { type: Date, default: () => Date.now() }, created_at: { type: Date, default: () => Date.now(), index: true },
}) })
export const EventModel = model<TEvent>('events', EventSchema); export const EventModel = model<TEvent>('events', EventSchema);

View File

@@ -16,7 +16,7 @@ const SessionSchema = new Schema<TSession>({
flowHash: { type: String }, flowHash: { type: String },
duration: { type: Number, required: true, default: 0 }, duration: { type: Number, required: true, default: 0 },
updated_at: { type: Date, default: () => Date.now() }, updated_at: { type: Date, default: () => Date.now() },
created_at: { type: Date, default: () => Date.now() }, created_at: { type: Date, default: () => Date.now(), index: true },
}) })
export const SessionModel = model<TSession>('sessions', SessionSchema); export const SessionModel = model<TSession>('sessions', SessionSchema);

View File

@@ -36,8 +36,10 @@ const VisitSchema = new Schema<TVisit>({
website: { type: String, required: true }, website: { type: String, required: true },
page: { type: String, required: true }, page: { type: String, required: true },
referrer: { type: String, required: true }, referrer: { type: String, required: true },
created_at: { type: Date, default: () => Date.now(), index: true }, created_at: { type: Date, default: () => Date.now() },
}) })
VisitSchema.index({ project_id: 1, created_at: -1 });
export const VisitModel = model<TVisit>('visits', VisitSchema); export const VisitModel = model<TVisit>('visits', VisitSchema);

View File

@@ -3,4 +3,8 @@ import mongoose from "mongoose";
export async function connectDatabase(connectionString: string) { export async function connectDatabase(connectionString: string) {
await mongoose.connect(connectionString); await mongoose.connect(connectionString);
} }
export async function disconnectDatabase() {
await mongoose.disconnect();
}

View File

@@ -1,137 +1,64 @@
import nodemailer from 'nodemailer'; import { TransactionalEmailsApi, SendSmtpEmail } from '@getbrevo/brevo';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import { WELCOME_EMAIL } from './email_templates/WelcomeEmail'; import { WELCOME_EMAIL } from './email_templates/WelcomeEmail';
import { LIMIT_50_EMAIL } from './email_templates/Limit50Email';
import { LIMIT_90_EMAIL } from './email_templates/Limit90Email';
import { LIMIT_MAX_EMAIL } from './email_templates/LimitMaxEmail';
const TemplateEmail50 = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LitLyx Limit Reached Email</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600;700&display=swap');
body {
font-family: 'Poppins', sans-serif;
background-color: #0a0a0a;
color: #ffffff;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
text-align: center;
padding: 20px 0;
}
.header h1 {
font-size: 36px;
font-weight: 700;
margin: 0;
}
.step {
margin: 20px 0;
text-align: center;
}
.step h2 {
font-size: 24px;
font-weight: 600;
margin: 0 0 10px 0;
}
.step p {
font-size: 16px;
font-weight: 300;
margin: 0 0 20px 0;
}
.button {
display: inline-block;
padding: 10px 20px;
font-size: 16px;
font-weight: 600;
color: #ffffff;
background-color: #1a73e8;
text-decoration: none;
border-radius: 5px;
}
.footer {
text-align: center;
padding: 20px 0;
font-size: 14px;
font-weight: 300;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>⚠ Limit for project ⚠</h1>
</div>
<div class="header">
<p>Hey there! We found that one of your projects is at 50% of the <strong>limit of the plan.</strong> In order to continue to log visits & events, you should upgrade the plan of your project!</p>
</div>
<div class="step" style="margin-top: 4rem;">
<h2>How can I upgrade the plan?</h2>
<p>We offer different plans, each of them follows the stage of your project, so based on the reach, you should upgrade to the most appropriate one for your web platform.<strong> It takes 1 minute to upgrade the plan!</strong> You can find everything in the "Billing" section in the left menu of your dashboard.</p>
<a href="https://dashboard.litlyx.com" class="button">Visit your dashboard</a>
</div>
<div class="step" style="margin-top: 4rem;">
<h2>We are in early phases!</h2>
<p>Want to become an early adopter? Book a demo with me! I'm Antonio & I'll guide you through all the features and benefits of LitLyx.<strong> A big discount is waiting for you❗</strong></p>
<a href="https://cal.com/litlyx/30min" class="button">Book a Demo with Me!</a>
</div>
<div class="footer">
<p>Thank you for choosing LitLyx each day to keep track of your business KPIs!</p>
<p>Made with ❤️ in Italy</p>
</div>
</div>
</body>
</html>
`
class EmailService { class EmailService {
private transport: nodemailer.Transporter<SMTPTransport.SentMessageInfo>; private apiInstance = new TransactionalEmailsApi();
createTransport(service: string, host: string, user: string, pass: string) { init(apiKey: string) {
this.transport = nodemailer.createTransport({ this.apiInstance.setApiKey(0, apiKey);
host,
secure: true,
auth: { user, pass },
tls: {
minVersion: 'TLSv1',
ciphers: 'HIGH:MEDIUM:!aNULL:!eNULL:@STRENGTH:!DH:!kEDH'
}
});
} }
async sendLimitEmail50(target: string) { async sendLimitEmail50(target: string, projectName: string) {
try { try {
if (!this.transport) return console.error('Transport not created'); const sendSmtpEmail = new SendSmtpEmail();
await this.transport.sendMail({ sendSmtpEmail.subject = "You've reached 50% limit on Litlyx";
from: 'helplitlyx@gmail.com', sendSmtpEmail.sender = { "name": "Litlyx", "email": "no-reply@litlyx.com" };
to: target, sendSmtpEmail.to = [{ "email": target }];
subject: 'Project limit 50%',
html: TemplateEmail50 sendSmtpEmail.htmlContent = LIMIT_50_EMAIL
}); .replace(/\[Project Name\]/, projectName)
.toString();
await this.apiInstance.sendTransacEmail(sendSmtpEmail);
return true;
} catch (ex) {
console.error('ERROR SENDING EMAIL', ex);
return false;
}
}
async sendLimitEmail90(target: string, projectName: string) {
try {
const sendSmtpEmail = new SendSmtpEmail();
sendSmtpEmail.subject = "You've reached 90% limit on Litlyx";
sendSmtpEmail.sender = { "name": "Litlyx", "email": "no-reply@litlyx.com" };
sendSmtpEmail.to = [{ "email": target }];
sendSmtpEmail.htmlContent = LIMIT_90_EMAIL
.replace(/\[Project Name\]/, projectName)
.toString();
await this.apiInstance.sendTransacEmail(sendSmtpEmail);
return true;
} catch (ex) {
console.error('ERROR SENDING EMAIL', ex);
return false;
}
}
async sendLimitEmailMax(target: string, projectName: string) {
try {
const sendSmtpEmail = new SendSmtpEmail();
sendSmtpEmail.subject = "You've reached your limit on Litlyx!";
sendSmtpEmail.sender = { "name": "Litlyx", "email": "no-reply@litlyx.com" };
sendSmtpEmail.to = [{ "email": target }];
sendSmtpEmail.htmlContent = LIMIT_MAX_EMAIL
.replace(/\[Project Name\]/, projectName)
.toString();
await this.apiInstance.sendTransacEmail(sendSmtpEmail);
return true; return true;
} catch (ex) { } catch (ex) {
console.error('ERROR SENDING EMAIL', ex); console.error('ERROR SENDING EMAIL', ex);
@@ -141,13 +68,12 @@ class EmailService {
async sendWelcomeEmail(target: string) { async sendWelcomeEmail(target: string) {
try { try {
if (!this.transport) return console.error('Transport not created'); const sendSmtpEmail = new SendSmtpEmail();
await this.transport.sendMail({ sendSmtpEmail.subject = "Welcome to Litlyx!";
from: 'helplitlyx@gmail.com', sendSmtpEmail.sender = { "name": "Litlyx", "email": "no-reply@litlyx.com" };
to: target, sendSmtpEmail.to = [{ "email": target }];
subject: 'Welcome to Litlyx', sendSmtpEmail.htmlContent = WELCOME_EMAIL;
html: WELCOME_EMAIL await this.apiInstance.sendTransacEmail(sendSmtpEmail);
});
return true; return true;
} catch (ex) { } catch (ex) {
console.error('ERROR SENDING EMAIL', ex); console.error('ERROR SENDING EMAIL', ex);

View File

@@ -13,14 +13,20 @@ export type ReadingLoopOptions = {
export class RedisStreamService { export class RedisStreamService {
private static processed = 0;
private static client = createClient({ private static client = createClient({
url: requireEnv("REDIS_URL"), url: requireEnv("REDIS_URL"),
username: requireEnv("REDIS_USERNAME"), username: requireEnv("REDIS_USERNAME"),
password: requireEnv("REDIS_PASSWORD"), password: requireEnv("REDIS_PASSWORD"),
database: process.env.DEV_MODE === 'true' ? 1 : 0
}); });
static async connect() { static async connect() {
console.log('RedisStreamService DEV_MODE=', process.env.DEV_MODE === 'true');
await this.client.connect(); await this.client.connect();
} }
private static async readingLoop(options: ReadingLoopOptions, processFunction: (content: Record<string, string>) => Promise<any>) { private static async readingLoop(options: ReadingLoopOptions, processFunction: (content: Record<string, string>) => Promise<any>) {
@@ -31,6 +37,7 @@ export class RedisStreamService {
return; return;
} }
await processFunction(result); await processFunction(result);
RedisStreamService.processed++;
await new Promise(r => setTimeout(r, options.delay?.base || 100)); await new Promise(r => setTimeout(r, options.delay?.base || 100));
setTimeout(() => this.readingLoop(options, processFunction), 1); setTimeout(() => this.readingLoop(options, processFunction), 1);
return; return;
@@ -38,10 +45,17 @@ export class RedisStreamService {
static async startReadingLoop(options: ReadingLoopOptions, processFunction: (content: Record<string, string>) => Promise<any>) { static async startReadingLoop(options: ReadingLoopOptions, processFunction: (content: Record<string, string>) => Promise<any>) {
setInterval(() => {
console.log('Processed:', (RedisStreamService.processed / 30).toFixed(), '/s');
RedisStreamService.processed = 0;
}, 30_000)
try { try {
console.log('Start reading loop');
await this.client.xGroupCreate(options.streamName, 'broker', '0', { MKSTREAM: true, }); await this.client.xGroupCreate(options.streamName, 'broker', '0', { MKSTREAM: true, });
console.log('Reading loop started');
} catch (ex) { } catch (ex) {
console.error(ex);
} }
this.readingLoop(options, processFunction) this.readingLoop(options, processFunction)

View File

@@ -0,0 +1,31 @@
export const LIMIT_50_EMAIL = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Youve reached 50% limit on Litlyx</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<p>Dear User,</p>
<p>We wanted to let you know that <strong>[Project Name]</strong> on <strong>Litlyx</strong> has reached 50% of its data collection limit for this month.</p>
<p>To avoid losing precious data, please remember to monitor your usage on the <strong>Litlyx Dashboard</strong>. You can find your current usage details under <strong>Settings > Billing Tab</strong>.</p>
<p>If you need more data collection storage, you may consider upgrading your plan to get additional benefits and ensure uninterrupted data collection.</p>
<p>Feel free to reply to this email or contact us at <a href="mailto:help@litlyx.com" style="color: #FF5733; text-decoration: none;">help@litlyx.com</a> if you have any questions or need assistance.</p>
<p>Thank you for choosing Litlyx every day as your analytics tool.</p>
<p>Have a nice day!</p>
<p>Antonio,</p>
<p>CEO | Litlyx</p>
</body>
</html>
`

View File

@@ -0,0 +1,31 @@
export const LIMIT_90_EMAIL = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Youve reached 90% limit on Litlyx</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<p>Dear User,</p>
<p>We wanted to let you know that <strong>[Project Name]</strong> on <strong>Litlyx</strong> has reached 90% of its data collection limit for this month.</p>
<p>To avoid losing precious data, please remember to monitor your usage on the <strong>Litlyx Dashboard</strong>. You can find your current usage details under <strong>Settings > Billing Tab</strong>.</p>
<p>If you need more data collection storage, you may consider upgrading your plan to get additional benefits and ensure uninterrupted data collection.</p>
<p>Feel free to reply to this email or contact us at <a href="mailto:help@litlyx.com" style="color: #FF0000; text-decoration: none;">help@litlyx.com</a> if you have any questions or need assistance.</p>
<p>Thank you for choosing Litlyx every day as your analytics tool.</p>
<p>Have a nice day!</p>
<p>Antonio,</p>
<p>CEO | Litlyx</p>
</body>
</html>
`

View File

@@ -0,0 +1,32 @@
export const LIMIT_MAX_EMAIL = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>❗️ Youve reached your limit on Litlyx!</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<p>Dear User,</p>
<p>We wanted to let you know that <strong>[Project Name]</strong> on <strong>Litlyx</strong> has reached the limit of your current plan.</p>
<p>To avoid losing precious data, please remember to monitor your usage on the <strong>Litlyx Dashboard</strong>. You can find your current usage details under <strong>Settings > Billing Tab</strong>.</p>
<p>If you need more data collection storage, you may consider upgrading your plan to get additional benefits and ensure uninterrupted data collection.</p>
<p>Feel free to reply to this email or contact us at <a href="mailto:help@litlyx.com" style="color: #D32F2F; text-decoration: none;">help@litlyx.com</a> if you have any questions or need assistance.</p>
<p>Thank you for choosing Litlyx every day as your analytics tool.</p>
<p>Have a nice day!</p>
<p>Antonio,</p>
<p>CEO | Litlyx</p>
</body>
</html>
`

View File

@@ -1,378 +1,39 @@
export const WELCOME_EMAIL = ` export const WELCOME_EMAIL = `
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title>Welcome to Litlyx!</title>
<title>Welcome Email Litlyx</title> </head>
<style media="all" type="text/css"> <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
/* -------------------------------------
GLOBAL RESETS
------------------------------------- */
body { <p>Were happy to have you onboard,</p>
font-family: Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 16px;
line-height: 1.3;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
table { <p>At Litlyx, were committed to creating the best analytics collection experience for everybody, starting from developers.</p>
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%;
}
table td { <p>Here are a few things you can do to get started tracking analytics today:</p>
font-family: Helvetica, sans-serif;
font-size: 16px;
vertical-align: top;
}
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
body { <ol>
/* background-color: #f4f5f6; */ <li><strong><a href="https://dashboard.litlyx.com" style="color: #007BFF; text-decoration: none;">Create a new project</a></strong> by just naming it</li>
background-color: #181818; <li><strong><a style="color: #0a0a0a; text-decoration: none;">Copy the universal Script</a></strong> we provide you the snippets to copy in your index.html file and start instantly to track metrics on your website or web app.</li>
margin: 0; <li><strong><a style="color: #0a0a0a; text-decoration: none;">Third Step</a></strong> Encourage engagement or interaction.</li>
padding: 0; </ol>
}
.body { <p>If you have any questions or need support, visit <a href="http://docs.litlyx.com" style="color: #007BFF;">docs.litlyx.com</a>.</p>
/* background-color: #f4f5f6; */
background-color: #181818;
width: 100%;
}
.container { <p>Feel free to reply to this email or reach out to our team at <a href="mailto:help@litlyx.com" style="color: #007BFF;">help@litlyx.com</a>. Were here to help!</p>
margin: 0 auto !important;
max-width: 600px;
padding: 0;
padding-top: 24px;
width: 600px;
}
.content { <p>Link to Discord for developer support: <a href="https://discord.com/invite/9cQykjsmWX" style="color: #007BFF;">https://discord.com/invite/9cQykjsmWX</a></p>
box-sizing: border-box;
display: block;
margin: 0 auto;
max-width: 600px;
padding: 0;
}
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.main { <p>Thank you for joining us, and we look forward to seeing you around.</p>
background: #f7f7f7;
border: 1px solid #eaebed;
border-radius: 16px;
width: 100%;
}
.wrapper { <p>We want to make analytics the freshest thing on the web.</p>
box-sizing: border-box;
padding: 24px;
}
.footer { <p>Antonio,</p>
clear: both; <p>CEO | Litlyx</p>
padding-top: 24px;
text-align: center;
width: 100%;
}
.footer td, </body>
.footer p,
.footer span,
.footer a {
color: #9a9ea6;
font-size: 16px;
text-align: center;
}
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
p {
font-family: Helvetica, sans-serif;
font-size: 16px;
font-weight: normal;
margin: 0;
margin-bottom: 16px;
}
a {
color: #0867ec;
text-decoration: underline;
}
/* -------------------------------------
BUTTONS
------------------------------------- */
.btn {
box-sizing: border-box;
min-width: 100% !important;
width: 100%;
}
.btn > tbody > tr > td {
padding-bottom: 16px;
}
.btn table {
width: auto;
}
.btn table td {
background-color: #ffffff;
border-radius: 4px;
text-align: center;
}
.btn a {
background-color: #ffffff;
border: solid 2px #0867ec;
border-radius: 4px;
box-sizing: border-box;
color: #0867ec;
cursor: pointer;
display: inline-block;
font-size: 16px;
font-weight: bold;
margin: 0;
padding: 12px 24px;
text-decoration: none;
text-transform: capitalize;
}
.btn-primary table td {
background-color: #0867ec;
}
.btn-primary a {
background-color: #0867ec;
border-color: #0867ec;
color: #ffffff;
}
@media all {
.btn-primary table td:hover {
background-color: #006aff !important;
}
.btn-primary a:hover {
background-color: #006aff !important;
border-color: #006aff !important;
}
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.text-link {
color: #0867ec !important;
text-decoration: underline !important;
}
.clear {
clear: both;
}
.mt0 {
margin-top: 0;
}
.mb0 {
margin-bottom: 0;
}
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0;
}
.powered-by a {
text-decoration: none;
}
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 640px) {
.main p,
.main td,
.main span {
font-size: 16px !important;
}
.wrapper {
padding: 8px !important;
}
.content {
padding: 0 !important;
}
.container {
padding: 0 !important;
padding-top: 8px !important;
width: 100% !important;
}
.main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
.btn table {
max-width: 100% !important;
width: 100% !important;
}
.btn a {
font-size: 16px !important;
max-width: 100% !important;
width: 100% !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
}
</style>
</head>
<body>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
<tr>
<td>&nbsp;</td>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<span class="preheader">This is preheader text. Some clients will show this text as a preview.</span>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper">
<p>Hi 👋 Welcome to <strong>Litlyx!</strong></p>
<p>We value your time so we will keep this simple.
We are happy to have you onboard! 🎊</p>
<p>We leave you a super fast video tutorial, just 1 minute where our CEO, Antonio, explain the very basics of Litlyx and will guide you to start logging analytics today for your website or web app!</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td> <a href="https://youtube.com" target="_blank">Super Fast Video Tutorial</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>Good luck! Enjoy your KPIs in real-time. Start now visiting the dashboard!</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td> <a href="https://dashboard.litlyx.com" target="_blank">Go to Litlyx Dashboard</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>If you have any question, you can find us at: help@litlyx.com, we will respond in less than a work day.</p>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
<div class="footer">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block">
<span class="apple-link">&copy; 2024 Litlyx All right reserved. Made with ❤ in Italy </span>
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER --></div>
</td>
<td>&nbsp;</td>
</tr>
</table>
</body>
</html> </html>
` `

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Youve reached 50% limit on Litlyx</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<!-- Email Content -->
<h2 style="color: #FF5733;">Youve reached 50% limit on Litlyx</h2>
<p>Dear User,</p>
<p>We wanted to let you know that <strong>[Project Name]</strong> on <strong>Litlyx</strong> has reached 50% of its data collection limit for this month.</p>
<p>To avoid losing precious data, please remember to monitor your usage on the <strong>Litlyx Dashboard</strong>. You can find your current usage details under <strong>Settings > Billing Tab</strong>.</p>
<p>If you need more data collection storage, you may consider upgrading your plan to get additional benefits and ensure uninterrupted data collection.</p>
<p>Feel free to reply to this email or contact us at <a href="mailto:help@litlyx.com" style="color: #FF5733; text-decoration: none;">help@litlyx.com</a> if you have any questions or need assistance.</p>
<p>Thank you for choosing Litlyx every day as your analytics tool.</p>
<p>Have a nice day!</p>
<p>Antonio,</p>
<p>CEO | Litlyx</p>
</body>
</html>

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Youve reached 90% limit on Litlyx</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<!-- Email Content -->
<h2 style="color: #FF0000;">Youve reached 90% limit on Litlyx</h2>
<p>Dear User,</p>
<p>We wanted to let you know that <strong>[Project Name]</strong> on <strong>Litlyx</strong> has reached 90% of its data collection limit for this month.</p>
<p>To avoid losing precious data, please remember to monitor your usage on the <strong>Litlyx Dashboard</strong>. You can find your current usage details under <strong>Settings > Billing Tab</strong>.</p>
<p>If you need more data collection storage, you may consider upgrading your plan to get additional benefits and ensure uninterrupted data collection.</p>
<p>Feel free to reply to this email or contact us at <a href="mailto:help@litlyx.com" style="color: #FF0000; text-decoration: none;">help@litlyx.com</a> if you have any questions or need assistance.</p>
<p>Thank you for choosing Litlyx every day as your analytics tool.</p>
<p>Have a nice day!</p>
<p>Antonio,</p>
<p>CEO | Litlyx</p>
</body>
</html>

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>❗️ Youve reached your limit on Litlyx!</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<!-- Email Content -->
<h2 style="color: #D32F2F;">❗️ Youve reached your limit on Litlyx!</h2>
<p>Dear User,</p>
<p>We wanted to let you know that <strong>[Project Name]</strong> on <strong>Litlyx</strong> has reached the limit of your current plan.</p>
<p>To avoid losing precious data, please remember to monitor your usage on the <strong>Litlyx Dashboard</strong>. You can find your current usage details under <strong>Settings > Billing Tab</strong>.</p>
<p>If you need more data collection storage, you may consider upgrading your plan to get additional benefits and ensure uninterrupted data collection.</p>
<p>Feel free to reply to this email or contact us at <a href="mailto:help@litlyx.com" style="color: #D32F2F; text-decoration: none;">help@litlyx.com</a> if you have any questions or need assistance.</p>
<p>Thank you for choosing Litlyx every day as your analytics tool.</p>
<p>Have a nice day!</p>
<p>Antonio,</p>
<p>CEO | Litlyx</p>
</body>
</html>

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to Litlyx!</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<!-- Subject -->
<h2 style="color: #007BFF;">Welcome to Litlyx!</h2>
<p>Were happy to have you onboard,</p>
<p>At Litlyx, were committed to creating the best analytics collection experience for everybody, starting from developers.</p>
<p>Here are a few things you can do to get started tracking analytics today:</p>
<ol>
<li><strong><a href="https://dashboard.litlyx.com" style="color: #007BFF; text-decoration: none;">Create a new project</a></strong> by just naming it</li>
<li><strong><a style="color: #0a0a0a; text-decoration: none;">Copy the universal Script</a></strong> we provide you the snippets to copy in your index.html file and start instantly to track metrics on your website or web app.</li>
<li><strong><a style="color: #0a0a0a; text-decoration: none;">Third Step</a></strong> Encourage engagement or interaction.</li>
</ol>
<p>If you have any questions or need support, visit <a href="http://docs.litlyx.com" style="color: #007BFF;">docs.litlyx.com</a>.</p>
<p>Feel free to reply to this email or reach out to our team at <a href="mailto:help@litlyx.com" style="color: #007BFF;">help@litlyx.com</a>. Were here to help!</p>
<p>Link to Discord for developer support: <a href="https://discord.com/invite/9cQykjsmWX" style="color: #007BFF;">https://discord.com/invite/9cQykjsmWX</a></p>
<p>Thank you for joining us, and we look forward to seeing you around.</p>
<p>We want to make analytics the freshest thing on the web.</p>
<p>Antonio,</p>
<p>CEO | Litlyx</p>
</body>
</html>