Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4134d33dc4 | ||
|
|
5172ad4f4d | ||
|
|
be45448288 | ||
|
|
73739dde9d | ||
|
|
30b3ed80e2 | ||
|
|
8e56069b1a | ||
|
|
3ecdec9ca9 | ||
|
|
7b41a3ed0d | ||
|
|
5804d7a73b | ||
|
|
8b026099de | ||
|
|
d7e18d570f | ||
|
|
023f2b5f4a | ||
|
|
c003b655ec | ||
|
|
d499aa2f39 | ||
|
|
944996eb15 | ||
|
|
87b1f9caf9 | ||
|
|
748894b946 | ||
|
|
01e8a9ab1d | ||
|
|
a2034551ec | ||
|
|
6d26c3c8af | ||
|
|
518b4ce6c1 | ||
|
|
71bd4d0e58 | ||
|
|
0563a833eb | ||
|
|
ab07ffb108 | ||
|
|
79309cc537 | ||
|
|
9b9ed3e9ad | ||
|
|
1cb6b92d5c | ||
|
|
c1bdc30933 | ||
|
|
887ed45b4d |
63
README.md
@@ -1,18 +1,16 @@
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/claim-t.png"/>
|
||||
<img src="assets/claim.png"/>
|
||||
</p>
|
||||
|
||||
<h4 align="center">
|
||||
🌐 <a href="https://litlyx.com">Website</a> 📚 <a href="https://docs.litlyx.com">Docs</a> 🔥 <a href="https://dashboard.litlyx.com">Start for Free!</a>
|
||||
🌐 <a href="https://litlyx.com">Website</a> 📚 <a href="https://docs.litlyx.com">Docs</a> 👾 <a href="https://discord.gg/9cQykjsmWX">Join Discord</a> 🔥 <a href="https://dashboard.litlyx.com">Start for free!</a>
|
||||
</h4>
|
||||
|
||||
<br />
|
||||
|
||||
#
|
||||
|
||||
<p align="center">
|
||||
The easiest Dev-Centric Analytics tool.<br>Litlyx is , Open-Source, Plug-In everywhere Javascript is Supported. Setup in less then 30 seconds, with just One-Line of code.
|
||||
The easiest, developer-centric analytics tool.<br>
|
||||
Litlyxis an open-source, self-hostable analytics solution for modern framework. Setup takes less than 30 seconds!
|
||||
</p>
|
||||
|
||||
#
|
||||
@@ -20,18 +18,14 @@
|
||||
<br />
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/screen.png"/>
|
||||
<img src="assets/dashboard-clip.png"/>
|
||||
</p>
|
||||
|
||||
#
|
||||
|
||||

|
||||

|
||||

|
||||
## Pre-Requisites on Cloud Version
|
||||
|
||||
## Pre-Requisites
|
||||
|
||||
Sign-up on [Litlyx cloud](https://dashboard.litlyx.com) using OAuth & name your project to get your project_id to connect Litlyx to your website OR Self-Host Litlyx with Docker.
|
||||
Sign-up on [Litlyx.com](https://dashboard.litlyx.com) and create a project. Then simply use your project_id to connect Litlyx to your website OR Self-Host Litlyx with Docker.
|
||||
|
||||
## Universal Installation
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -49,10 +43,10 @@ You can install Litlyx using `npm`, `yarn`, or `pnpm`:
|
||||
npm i litlyx-js
|
||||
```
|
||||
|
||||
Litlyx natively supports all JS/TS frameworks. You can use Litlyx in all WordPress Websites by injecting JS code using a plug-in. Litlyx work in serverless enviroments with Cloud (or Edge) Functions.
|
||||
Litlyx natively works with all JavaScript / TypeScript frameworks. You can use Litlyx in all WordPress Websites by injecting JS code using a plug-in. Litlyx also works in serverless enviroments with Cloud (or Edge) Functions.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/techs.png" />
|
||||
<img src="assets/tech.png" />
|
||||
</p>
|
||||
|
||||
# Import
|
||||
@@ -69,17 +63,17 @@ Once imported, you need to initialize Litlyx:
|
||||
Lit.init('your_project_id');
|
||||
```
|
||||
|
||||
After initialization, Litlyx will automatically track Analytics such as `Page visits`, `Browsers`, `Devices`, `OS`, `Real-Time Online Users`, `Unique Session`, `Countries`, `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
|
||||
|
||||
With Litlyx, you can create your own events to track in your project.
|
||||
You aren't just limited to the built-in KPIs. With Litlyx, you can create your own events to track in your project.
|
||||
|
||||
```js
|
||||
Lit.event('click_on_buy_item');
|
||||
```
|
||||
|
||||
If you want more dept tracking, you can use the `metadata` field, like this:
|
||||
If you want more specific tracking, you can use the `metadata` field, like this:
|
||||
|
||||
```js
|
||||
Lit.event('click_on_buy_item', {
|
||||
@@ -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">
|
||||
<img src="assets/agent.png" width="180px"/>
|
||||
</p>
|
||||
# Fire Your First Event with cURL
|
||||
|
||||
Lit can compare data, query specific metadata, visualize charts, and much more just by having a simple `conversation` with him.
|
||||
Want to quickly see how Litlyx works with events? Use the cURL command below to send a test event. Just replace the `project_id` with your actual project ID in your terminal.
|
||||
|
||||
```bash
|
||||
curl -X POST "https://broker.litlyx.com/event" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"pid": "project_id",
|
||||
"name": "testEvent1",
|
||||
"metadata": "{\"test\": \"something\"}",
|
||||
"website": "something",
|
||||
"userAgent": "something"
|
||||
}'
|
||||
```
|
||||
|
||||
# Self-Hosting with Docker
|
||||
|
||||
First thing first **Fork** this repository.
|
||||
To self-host the Litlyx dashboard, first **fork** this repository.
|
||||
|
||||
Then run the following command:
|
||||
|
||||
```bash
|
||||
docker-compose build
|
||||
```
|
||||
|
||||
then, after the build finish, run:
|
||||
after the build finishes, run:
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
on your localhost you will see your own instance of the Litlyx Dashboard.
|
||||
at localhost:3000 you will see your own instance of the Litlyx Dashboard.
|
||||
|
||||
# Official Docs
|
||||
|
||||
@@ -124,11 +125,11 @@ For more info read our [documentation](https://docs.litlyx.com). (will be improv
|
||||
|
||||
# Join Discord
|
||||
|
||||
If you need more information, help, or want to provide general feedback, feel free to join us on[Discord](https://discord.gg/9cQykjsmWX)
|
||||
If you need more information, interact with us or the community, help, or want to provide feedbacks, feel free to join us on the Litlyx [Discord](https://discord.gg/9cQykjsmWX)
|
||||
|
||||
# Contributors
|
||||
|
||||
Every kind of contribution is accepted in this stage of the project. In the future we will onboard you better.
|
||||
Every kind of contribution is accepted in this stage of the project. In the future we will improve the contributor onboarding process.
|
||||
|
||||
### Thank you!
|
||||
<a href="https://github.com/litlyx/litlyx/graphs/contributors">
|
||||
|
||||
BIN
assets/bg.png
|
Before Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 12 KiB |
BIN
assets/claim.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/dashboard-clip.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 144 KiB |
BIN
assets/tech.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/techs.png
|
Before Width: | Height: | Size: 29 KiB |
3
broker/.gitignore
vendored
@@ -4,4 +4,5 @@ ecosystem.config.cjs
|
||||
dist
|
||||
scripts/start_dev.js
|
||||
package-lock.json
|
||||
build_all.bat
|
||||
build_all.bat
|
||||
tests
|
||||
@@ -7,9 +7,7 @@ module.exports = {
|
||||
script: './dist/producer/src/index.js',
|
||||
env: {
|
||||
EMAIL_SERVICE: "",
|
||||
EMAIL_HOST: "",
|
||||
EMAIL_USER: "",
|
||||
EMAIL_PASS: "",
|
||||
BREVO_API_KEY: "",
|
||||
PORT: "",
|
||||
MONGO_CONNECTION_STRING: "",
|
||||
REDIS_URL: "",
|
||||
|
||||
13
broker/jest.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} **/
|
||||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+.tsx?$": ["ts-jest",{}],
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'@services/(.*)': '<rootDir>/../shared/services/$1',
|
||||
'@data/(.*)': '<rootDir>/../shared/data/$1',
|
||||
'@functions/(.*)': '<rootDir>/../shared/functions/$1',
|
||||
'@schema/(.*)': '<rootDir>/../shared/schema/$1',
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@getbrevo/brevo": "^2.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"mongoose": "^8.3.2",
|
||||
@@ -8,13 +9,17 @@
|
||||
"ua-parser-js": "^1.0.37"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.12.13",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"glob": "^10.4.1",
|
||||
"jest": "^29.7.0",
|
||||
"node-ssh": "^13.2.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
@@ -28,10 +33,11 @@
|
||||
"create_db": "cd scripts && ts-node create_database.ts",
|
||||
"build_all": "npm run compile && npm run build && npm run create_db",
|
||||
"docker-build": "docker build -t litlyx-broker -f Dockerfile ../",
|
||||
"docker-inspect": "docker run -it litlyx-broker sh"
|
||||
"docker-inspect": "docker run -it litlyx-broker sh",
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Emily",
|
||||
"license": "MIT",
|
||||
"description": "Queue broker for Litlyx - Saves events to database."
|
||||
}
|
||||
}
|
||||
|
||||
3218
broker/pnpm-lock.yaml
generated
@@ -6,25 +6,68 @@ import { requireEnv } from "../../shared/utilts/requireEnv";
|
||||
import { TProjectLimit } from "@schema/ProjectsLimits";
|
||||
|
||||
if (process.env.EMAIL_SERVICE) {
|
||||
EmailService.createTransport(
|
||||
requireEnv('EMAIL_SERVICE'),
|
||||
requireEnv('EMAIL_HOST'),
|
||||
requireEnv('EMAIL_USER'),
|
||||
requireEnv('EMAIL_PASS'),
|
||||
);
|
||||
EmailService.init(requireEnv('BREVO_API_KEY'));
|
||||
}
|
||||
|
||||
export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
||||
|
||||
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit / 2)) {
|
||||
const notify = await LimitNotifyModel.findOne({ project_id: projectCounts._id });
|
||||
if (notify && notify.limit1 === true) return;
|
||||
const project = await ProjectModel.findById(projectCounts.project_id);
|
||||
if (!project) return;
|
||||
const owner = await UserModel.findById(project.owner);
|
||||
if (!owner) return;
|
||||
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail50(owner.email);
|
||||
await LimitNotifyModel.updateOne({ project_id: projectCounts._id }, { limit1: true, limit2: false, limit3: false }, { upsert: true });
|
||||
console.log('CHECK LIMIT EMAIL');
|
||||
|
||||
const project_id = projectCounts.project_id;
|
||||
const hasNotifyEntry = await LimitNotifyModel.findOne({ project_id });
|
||||
if (!hasNotifyEntry) {
|
||||
await LimitNotifyModel.create({ project_id, limit1: false, limit2: false, limit3: false })
|
||||
}
|
||||
|
||||
}
|
||||
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit)) {
|
||||
console.log('LIMIT 3');
|
||||
|
||||
const notify = await LimitNotifyModel.findOne({ project_id });
|
||||
if (notify && notify.limit3 === true) return;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return;
|
||||
|
||||
const owner = await UserModel.findById(project.owner);
|
||||
if (!owner) return;
|
||||
|
||||
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmailMax(owner.email, project.name);
|
||||
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: true });
|
||||
|
||||
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.9)) {
|
||||
console.log('LIMIT 2');
|
||||
|
||||
const notify = await LimitNotifyModel.findOne({ project_id });
|
||||
if (notify && notify.limit2 === true) return;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return;
|
||||
|
||||
const owner = await UserModel.findById(project.owner);
|
||||
if (!owner) return;
|
||||
|
||||
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail90(owner.email, project.name);
|
||||
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: false });
|
||||
|
||||
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.5)) {
|
||||
|
||||
console.log('LIMIT 1');
|
||||
|
||||
const notify = await LimitNotifyModel.findOne({ project_id });
|
||||
if (notify && notify.limit1 === true) return;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return;
|
||||
|
||||
const owner = await UserModel.findById(project.owner);
|
||||
if (!owner) return;
|
||||
|
||||
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail50(owner.email, project.name);
|
||||
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: false, limit3: false });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -19,27 +19,24 @@ export async function startStreamLoop() {
|
||||
|
||||
await RedisStreamService.startReadingLoop({
|
||||
streamName: requireEnv('STREAM_NAME'),
|
||||
delay: { base: 100, empty: 5000 },
|
||||
readBlock: 2500
|
||||
delay: { base: 10, empty: 5000 },
|
||||
readBlock: 2000
|
||||
}, processStreamEvent);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
async function processStreamEvent(data: Record<string, string>) {
|
||||
export async function processStreamEvent(data: Record<string, string>) {
|
||||
try {
|
||||
const eventType = data._type;
|
||||
if (!eventType) return;
|
||||
|
||||
|
||||
|
||||
const { pid, sessionHash } = data;
|
||||
|
||||
const project = await ProjectModel.exists({ _id: pid });
|
||||
if (!project) return;
|
||||
|
||||
|
||||
if (eventType === 'event') return await process_event(data, sessionHash);
|
||||
if (eventType === 'keep_alive') return await process_keep_alive(data, sessionHash);
|
||||
if (eventType === 'visit') return await process_visit(data, sessionHash);
|
||||
@@ -50,17 +47,23 @@ async function processStreamEvent(data: Record<string, string>) {
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function checkLimits(project_id: string) {
|
||||
const projectLimits = await ProjectLimitModel.findOne({ project_id });
|
||||
if (!projectLimits) return false;
|
||||
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
|
||||
const COUNT_LIMIT = projectLimits.limit;
|
||||
if ((TOTAL_COUNT) > COUNT_LIMIT * EVENT_LOG_LIMIT_PERCENT) return false;
|
||||
await checkLimitsForEmail(projectLimits);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function process_visit(data: Record<string, string>, sessionHash: string) {
|
||||
|
||||
const { pid, ip, website, page, referrer, userAgent, flowHash } = data;
|
||||
|
||||
const projectLimits = await ProjectLimitModel.findOne({ project_id: pid });
|
||||
if (!projectLimits) return;
|
||||
|
||||
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
|
||||
const COUNT_LIMIT = projectLimits.limit;
|
||||
if ((TOTAL_COUNT) > COUNT_LIMIT * EVENT_LOG_LIMIT_PERCENT) return;
|
||||
await checkLimitsForEmail(projectLimits);
|
||||
const canLog = await checkLimits(pid);
|
||||
if (!canLog) return;
|
||||
|
||||
let referrerParsed;
|
||||
try {
|
||||
@@ -73,11 +76,13 @@ async function process_visit(data: Record<string, string>, sessionHash: string)
|
||||
|
||||
const userAgentParsed = UAParser(userAgent);
|
||||
|
||||
const device = userAgentParsed.device.type;
|
||||
|
||||
const visit = new VisitModel({
|
||||
project_id: pid, website, page, referrer: referrerParsed.hostname,
|
||||
browser: userAgentParsed.browser.name || 'NO_BROWSER',
|
||||
os: userAgentParsed.os.name || 'NO_OS',
|
||||
device: userAgentParsed.device.type,
|
||||
device: device ? device : (userAgentParsed.browser.name ? 'desktop' : undefined),
|
||||
session: sessionHash,
|
||||
flowHash,
|
||||
continent: geoLocation[0],
|
||||
@@ -97,7 +102,10 @@ async function process_keep_alive(data: Record<string, string>, sessionHash: str
|
||||
|
||||
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) {
|
||||
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 canLog = await checkLimits(pid);
|
||||
if (!canLog) return;
|
||||
|
||||
let metadataObject;
|
||||
try {
|
||||
if (metadata) metadataObject = JSON.parse(metadata);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"module": "NodeNext",
|
||||
"target": "ESNext",
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"types": [
|
||||
"node",
|
||||
"jest"
|
||||
],
|
||||
"paths": {
|
||||
"@schema/*": [
|
||||
"../shared/schema/*"
|
||||
@@ -21,7 +26,9 @@
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"scripts/**/*.ts"
|
||||
"scripts/**/*.ts",
|
||||
"tests/**/*.test.ts",
|
||||
"tests/utils.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
@@ -10,9 +10,7 @@ AI_PROJECT=
|
||||
AI_KEY=
|
||||
|
||||
EMAIL_SERVICE=
|
||||
EMAIL_HOST=
|
||||
EMAIL_USER=
|
||||
EMAIL_PASS=
|
||||
BREVO_API_KEY=
|
||||
|
||||
AUTH_JWT_SECRET=
|
||||
|
||||
|
||||
6
dashboard/.gitignore
vendored
@@ -31,4 +31,8 @@ logs
|
||||
out.pdf
|
||||
|
||||
# TESTS - TO REMOVE
|
||||
tests
|
||||
tests
|
||||
|
||||
# EXPLAINS MONGODB
|
||||
|
||||
explains
|
||||
@@ -9,12 +9,28 @@ const debugMode = process.dev;
|
||||
const { alerts, closeAlert } = useAlert();
|
||||
|
||||
const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dialogClosable } = useCustomDialog();
|
||||
|
||||
const { visible } = usePricingDrawer();
|
||||
|
||||
const { data: planData } = useFetch('/api/project/plan', {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="w-dvw h-dvh bg-lyx-background-light relative">
|
||||
|
||||
<Transition name="pdrawer">
|
||||
<LazyPricingDrawer @onCloseClick="visible = false" :currentSub="planData?.premium_type || 0"
|
||||
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if=visible>
|
||||
</LazyPricingDrawer>
|
||||
</Transition>
|
||||
|
||||
|
||||
<div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0">
|
||||
<div v-for="alert of alerts"
|
||||
class="w-[30vw] min-w-[20rem] relative bg-[#151515] overflow-hidden border-solid border-[2px] border-[#262626] rounded-lg p-6 drop-shadow-lg">
|
||||
@@ -64,3 +80,19 @@ const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dia
|
||||
|
||||
</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>
|
||||
|
||||
@@ -10,6 +10,7 @@ export type Entry = {
|
||||
icon?: string,
|
||||
action?: () => any,
|
||||
adminOnly?: boolean,
|
||||
premiumOnly?:boolean,
|
||||
external?: boolean,
|
||||
grow?: boolean
|
||||
}
|
||||
@@ -70,7 +71,11 @@ async function generatePDF() {
|
||||
|
||||
try {
|
||||
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'
|
||||
});
|
||||
|
||||
@@ -112,6 +117,10 @@ watch(selected, () => {
|
||||
setActiveProject(selected.value._id.toString())
|
||||
})
|
||||
|
||||
const isPremium = computed(()=>{
|
||||
return activeProject.value?.premium;
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -138,7 +147,7 @@ watch(selected, () => {
|
||||
<template #option="{ option, active, selected }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<img class="h-5 bg-black rounded-full" :src="'logo_32.png'" alt="Litlyx logo">
|
||||
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
|
||||
</div>
|
||||
<div> {{ option.name }} </div>
|
||||
</div>
|
||||
@@ -147,7 +156,7 @@ watch(selected, () => {
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<img class="h-5 bg-black rounded-full" :src="'logo_32.png'" alt="Litlyx logo">
|
||||
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
|
||||
</div>
|
||||
<div> {{ activeProject?.name || '???' }} </div>
|
||||
</div>
|
||||
@@ -249,9 +258,9 @@ watch(selected, () => {
|
||||
|
||||
<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))"
|
||||
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">
|
||||
<i :class="entry.icon"></i>
|
||||
</div>
|
||||
<div class="manrope">
|
||||
<div class="manrope grow">
|
||||
{{ entry.label }}
|
||||
</div>
|
||||
<div v-if="entry.premiumOnly && !isPremium" class="flex items-center">
|
||||
<i class="fal fa-lock"></i>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
</div>
|
||||
@@ -278,9 +290,6 @@ watch(selected, () => {
|
||||
</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="flex justify-end px-2">
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ const props = defineProps<{
|
||||
{{ trend.toFixed(0) }} %
|
||||
</div>
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.7rem]"> Daily variation </div>
|
||||
<div class="poppins text-text-sub text-[.7rem]"> Trend </div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -134,7 +134,7 @@ onMounted(async () => {
|
||||
</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"
|
||||
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
|
||||
color="#f56523">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Slice } from '@services/DateService';
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
|
||||
const props = defineProps<{ slice: Slice }>();
|
||||
const slice = computed(() => props.slice);
|
||||
@@ -22,7 +22,7 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
|
||||
|
||||
const fixed = fixMetrics({
|
||||
data: input,
|
||||
from: safeSnapshotDates.value.from,
|
||||
from: input[0]._id,
|
||||
to: safeSnapshotDates.value.to
|
||||
}, slice.value, {
|
||||
advanced: true,
|
||||
@@ -68,7 +68,8 @@ onMounted(async () => {
|
||||
<div v-if="eventsStackedData.pending.value" class="flex justify-center py-40">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
<AdvancedStackedBarChart v-if="!eventsStackedData.pending.value" :datasets="eventsStackedData.data.value?.datasets || []"
|
||||
<AdvancedStackedBarChart v-if="!eventsStackedData.pending.value"
|
||||
:datasets="eventsStackedData.data.value?.datasets || []"
|
||||
:labels="eventsStackedData.data.value?.labels || []">
|
||||
</AdvancedStackedBarChart>
|
||||
</div>
|
||||
|
||||
@@ -13,11 +13,11 @@ export type PricingCardProp = {
|
||||
planId: number
|
||||
}
|
||||
|
||||
const props = defineProps<{ datas: PricingCardProp[] }>();
|
||||
const props = defineProps<{ datas: PricingCardProp[], defaultIndex?: number }>();
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const currentIndex = ref<number>(0);
|
||||
const currentIndex = ref<number>(props.defaultIndex || 0);
|
||||
|
||||
const data = computed(() => {
|
||||
return props.datas[currentIndex.value];
|
||||
@@ -37,13 +37,19 @@ async function onUpgradeClick() {
|
||||
|
||||
|
||||
<template>
|
||||
<div class="relative bg-[#151515] outline outline-[1px] outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]">
|
||||
<div
|
||||
class="relative bg-[#151515] outline outline-[1px] outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]">
|
||||
|
||||
<div class="flex flex-col gap-3 text-center">
|
||||
<div class="poppins text-xl font-light"> {{ data.title }} </div>
|
||||
<div v-if="data.active" class="absolute right-6 top-3 poppins text-[.75rem] bg-[#222A42] outline outline-[1px] outline-[#5680F8] px-3 py-[.1rem] rounded-xl">
|
||||
<div class="flex flex-col gap-3 text-center pt-3">
|
||||
<div v-if="data.active"
|
||||
class="absolute right-6 top-3 poppins text-[.75rem] bg-[#222A42] outline outline-[1px] outline-[#5680F8] px-3 py-[.1rem] rounded-sm">
|
||||
Active
|
||||
</div>
|
||||
<div v-if="!data.active && data.title === 'Growth'"
|
||||
class="absolute right-6 top-3 poppins text-[.75rem] bg-[#fbbe244f] outline outline-[1px] outline-[#fbbf24] px-3 py-[.1rem] rounded-sm">
|
||||
Most popular
|
||||
</div>
|
||||
<div class="poppins text-xl font-light"> {{ data.title }} </div>
|
||||
<div class="poppins text-4xl font-medium"> {{ data.price }} </div>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +75,7 @@ async function onUpgradeClick() {
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2" v-for="feature of data.features">
|
||||
<div class="h-6 w-6">
|
||||
<img class="w-full h-full" :src="'check.png'" alt="Check">
|
||||
<img class="w-full h-full" :src="'/check.png'" alt="Check">
|
||||
</div>
|
||||
<div>{{ feature }}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PricingCardProp } from './PricingCardGeneric.vue';
|
||||
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const props = defineProps<{ currentSub: number }>();
|
||||
|
||||
const freePricing: PricingCardProp[] = [
|
||||
@@ -20,7 +17,6 @@ const freePricing: PricingCardProp[] = [
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 10',
|
||||
'Server type: SHARED',
|
||||
'Projects: max 2',
|
||||
'Data retention: 2 Months'
|
||||
],
|
||||
cta: 'Start For Free now!',
|
||||
@@ -44,7 +40,6 @@ const customPricing: PricingCardProp[] = [
|
||||
'DB instance: DEDICATED',
|
||||
'Dedicated operator',
|
||||
'White label',
|
||||
'Custom Charts',
|
||||
'Custom Data Aggregation'
|
||||
],
|
||||
cta: 'Let\'s Talk!',
|
||||
@@ -64,12 +59,11 @@ const slidePricings: PricingCardProp[] = [
|
||||
'CPM 0,10€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Discord support',
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 30',
|
||||
'Server type: SHARED',
|
||||
'Projects: max 3',
|
||||
'Data retention: 6 Months'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
@@ -85,12 +79,11 @@ const slidePricings: PricingCardProp[] = [
|
||||
'CPM 0,06€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Discord support',
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 100',
|
||||
'Server type: SHARED',
|
||||
'Projects: max 3',
|
||||
'Data retention: 9 Months'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
@@ -106,12 +99,11 @@ const slidePricings: PricingCardProp[] = [
|
||||
'CPM 0,059€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Discord support',
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 3.000',
|
||||
'Server type: SHARED',
|
||||
'Projects: max 3',
|
||||
'Data retention: 1 Year'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
@@ -127,12 +119,11 @@ const slidePricings: PricingCardProp[] = [
|
||||
'CPM 0,059€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Discord support',
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 5.000',
|
||||
'Server type: SHARED',
|
||||
'Projects: max 3',
|
||||
'Data retention: 1 Year'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
@@ -148,12 +139,11 @@ const slidePricings: PricingCardProp[] = [
|
||||
'CPM 0,039€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Discord support',
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 10.000',
|
||||
'Server type: DEDICATED',
|
||||
'Projects: max 3',
|
||||
'Data retention: 2 Years'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
@@ -169,12 +159,11 @@ const slidePricings: PricingCardProp[] = [
|
||||
'CPM 0,029€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Discord support',
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 20.000',
|
||||
'Server type: DEDICATED',
|
||||
'Projects: max 3',
|
||||
'Data retention: 3 Years'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard',
|
||||
@@ -189,10 +178,22 @@ const emits = defineEmits<{
|
||||
(evt: 'onCloseClick'): void
|
||||
}>();
|
||||
|
||||
const activeProject = useActiveProject()
|
||||
|
||||
async function onLifetimeUpgradeClick() {
|
||||
const res = await $fetch<string>(`/api/pay/${activeProject.value?._id.toString()}/create-onetime`, {
|
||||
...signHeaders({ 'content-type': 'application/json' }),
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ planId: 2001 })
|
||||
})
|
||||
if (!res) alert('Something went wrong');
|
||||
window.open(res);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 overflow-y-auto xl:overflow-y-hidden">
|
||||
<div class="p-8 overflow-y-auto">
|
||||
|
||||
<div @click="$emit('onCloseClick')"
|
||||
class="cursor-pointer fixed top-4 right-4 rounded-full bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
|
||||
@@ -201,10 +202,56 @@ const emits = defineEmits<{
|
||||
|
||||
<div class="flex gap-8 mt-10 h-max xl:flex-row flex-col">
|
||||
<PricingCardGeneric class="flex-1" :datas="freePricing"></PricingCardGeneric>
|
||||
<PricingCardGeneric class="flex-1" :datas="slidePricings"></PricingCardGeneric>
|
||||
<PricingCardGeneric class="flex-1" :datas="slidePricings" :default-index="2"></PricingCardGeneric>
|
||||
<PricingCardGeneric class="flex-1" :datas="customPricing"></PricingCardGeneric>
|
||||
</div>
|
||||
|
||||
<LyxUiCard class="w-full mt-6">
|
||||
<div class="flex">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<span class="text-lyx-primary font-semibold text-[1.4rem]">
|
||||
LIFETIME DEAL
|
||||
</span>
|
||||
<span class="text-lyx-text-dark text-[.8rem]"> (Growth plan) </span>
|
||||
</div>
|
||||
<div class="text-[2rem]"> € 2.399,00 </div>
|
||||
<div> Up to 500.000 visits/events per month </div>
|
||||
<LyxUiButton type="primary" @click="onLifetimeUpgradeClick()"> Purchase </LyxUiButton>
|
||||
</div>
|
||||
<div class="flex justify-evenly grow">
|
||||
<div class="flex flex-col justify-evenly">
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Slack support </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Unlimited domanis </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Unlimited reports </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-evenly">
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> AI Tokens: 3.000 / month </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Server type: SHARED </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Data retention: 5 Years </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
|
||||
<div class="flex justify-between items-center mt-10 flex-col xl:flex-row">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="poppins text-[2rem] font-semibold">
|
||||
@@ -222,5 +269,8 @@ const emits = defineEmits<{
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TApiSettings } from '@schema/ApiSettingsSchema';
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
{ id: 'pname', title: 'Name', text: 'Project name' },
|
||||
{ id: 'api', title: 'ApiKeys', text: 'Manage your authorization token' },
|
||||
{ id: 'pid', title: 'Id', text: 'Project id' },
|
||||
{ id: 'pscript', title: 'Script', text: 'Universal javascript integration' },
|
||||
{ id: 'pdelete', title: 'Delete', text: 'Delete current project' },
|
||||
@@ -12,8 +14,54 @@ const entries: SettingsTemplateEntry[] = [
|
||||
const activeProject = useActiveProject();
|
||||
const projectNameInputVal = ref<string>(activeProject.value?.name || '');
|
||||
|
||||
|
||||
const apiKeys = ref<TApiSettings[]>([]);
|
||||
|
||||
const newApiKeyName = ref<string>('');
|
||||
|
||||
async function updateApiKeys() {
|
||||
newApiKeyName.value = '';
|
||||
apiKeys.value = await $fetch<TApiSettings[]>('/api/keys/get_all', signHeaders());
|
||||
}
|
||||
|
||||
async function createApiKey() {
|
||||
try {
|
||||
const res = await $fetch<TApiSettings>('/api/keys/create', {
|
||||
method: 'POST', ...signHeaders({
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
body: JSON.stringify({ name: newApiKeyName.value })
|
||||
});
|
||||
apiKeys.value.push(res);
|
||||
newApiKeyName.value = '';
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteApiKey(api_id: string) {
|
||||
try {
|
||||
const res = await $fetch<TApiSettings>('/api/keys/delete', {
|
||||
method: 'DELETE', ...signHeaders({
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
body: JSON.stringify({ api_id })
|
||||
});
|
||||
newApiKeyName.value = '';
|
||||
await updateApiKeys();
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateApiKeys();
|
||||
})
|
||||
|
||||
watch(activeProject, () => {
|
||||
projectNameInputVal.value = activeProject.value?.name || "";
|
||||
updateApiKeys();
|
||||
})
|
||||
|
||||
const canChange = computed(() => {
|
||||
@@ -47,7 +95,7 @@ async function deleteProject() {
|
||||
|
||||
const projectsList = useProjectsList()
|
||||
await projectsList.refresh();
|
||||
|
||||
|
||||
const firstProjectId = projectsList.data.value?.[0]?._id.toString();
|
||||
if (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>
|
||||
@@ -74,10 +148,31 @@ async function deleteProject() {
|
||||
<LyxUiButton @click="changeProjectName()" :disabled="!canChange" type="primary"> Change </LyxUiButton>
|
||||
</div>
|
||||
</template>
|
||||
<template #api>
|
||||
<div class="flex items-center gap-4" v-if="apiKeys && apiKeys.length < 5">
|
||||
<LyxUiInput class="grow px-4 py-2" placeholder="ApiKeyName" v-model="newApiKeyName"></LyxUiInput>
|
||||
<LyxUiButton @click="createApiKey()" :disabled="newApiKeyName.length < 3" type="primary">
|
||||
<i class="far fa-plus"></i>
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
<LyxUiCard v-if="apiKeys && apiKeys.length > 0" class="w-full flex flex-col gap-4 items-center mt-4">
|
||||
<div v-for="apiKey of apiKeys" class="flex flex-col w-full">
|
||||
|
||||
<div class="flex gap-8 items-center">
|
||||
<div class="grow">Name: {{ apiKey.apiName }}</div>
|
||||
<div>{{ apiKey.apiKey }}</div>
|
||||
<div class="flex justify-end">
|
||||
<i class="far fa-trash cursor-pointer" @click="deleteApiKey(apiKey._id.toString())"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
<template #pid>
|
||||
<LyxUiCard class="w-full flex items-center">
|
||||
<div class="grow">{{ activeProject?._id.toString() }}</div>
|
||||
<div><i class="far fa-copy"></i></div>
|
||||
<div><i class="far fa-copy" @click="copyProjectId()"></i></div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
<template #pscript>
|
||||
@@ -87,7 +182,7 @@ async function deleteProject() {
|
||||
<script defer data-project="${activeProject?._id}"
|
||||
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }}
|
||||
</div>
|
||||
<div><i class="far fa-copy"></i></div>
|
||||
<div><i class="far fa-copy" @click="copyScript()"></i></div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
<template #pdelete>
|
||||
|
||||
@@ -46,11 +46,6 @@ const { data: invoices, refresh: invoicesRefresh, pending: invoicesPending } = u
|
||||
lazy: true
|
||||
})
|
||||
|
||||
const showPricingDrawer = ref<boolean>(false);
|
||||
function onPlanUpgradeClick() {
|
||||
showPricingDrawer.value = true;
|
||||
}
|
||||
|
||||
function openInvoice(link: string) {
|
||||
window.open(link, '_blank');
|
||||
}
|
||||
@@ -77,18 +72,13 @@ const entries: SettingsTemplateEntry[] = [
|
||||
]
|
||||
|
||||
|
||||
const { visible } = usePricingDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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"
|
||||
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>
|
||||
@@ -138,7 +128,7 @@ const entries: SettingsTemplateEntry[] = [
|
||||
<div class="poppins"> Expire date:</div>
|
||||
<div> {{ prettyExpireDate }}</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]">
|
||||
<div class="poppins"> Upgrade plan </div>
|
||||
<i class="fas fa-arrow-up-right"></i>
|
||||
|
||||
9
dashboard/composables/usePricingDrawer.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
|
||||
|
||||
const pricingDrawerVisible = ref<boolean>(false);
|
||||
|
||||
|
||||
export function usePricingDrawer() {
|
||||
return { visible: pricingDrawerVisible };
|
||||
}
|
||||
@@ -4,23 +4,45 @@ import type { Section } from '~/components/CVerticalNavigation.vue';
|
||||
|
||||
import { Lit } from 'litlyx-js';
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const isPremium = computed(() => {
|
||||
return activeProject.value?.premium;
|
||||
});
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
title: 'Project',
|
||||
title: '',
|
||||
entries: [
|
||||
{ label: 'Dashboard', to: '/', icon: 'fal fa-table-layout' },
|
||||
{ label: 'Events', to: '/events', icon: 'fal fa-square-bolt' },
|
||||
{ label: 'Analyst', to: '/analyst', icon: 'fal fa-microchip-ai' },
|
||||
{ label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true },
|
||||
{ label: 'Links (soon)', to: '#', icon: 'fal fa-globe-pointer', disabled: true },
|
||||
{ label: 'Integrations (soon)', to: '#', icon: 'fal fa-cube', disabled: true },
|
||||
{ label: 'Settings', to: '/settings', icon: 'fal fa-gear' },
|
||||
{
|
||||
label: 'Docs', to: 'https://docs.litlyx.com', icon: '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') },
|
||||
},
|
||||
{ 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 { isOpen, close, open } = useMenu();
|
||||
|
||||
@@ -39,9 +39,7 @@ export default defineNuxtConfig({
|
||||
AI_PROJECT: process.env.AI_PROJECT,
|
||||
AI_KEY: process.env.AI_KEY,
|
||||
EMAIL_SERVICE: process.env.EMAIL_SERVICE,
|
||||
EMAIL_HOST: process.env.EMAIL_HOST,
|
||||
EMAIL_USER: process.env.EMAIL_USER,
|
||||
EMAIL_PASS: process.env.EMAIL_PASS,
|
||||
BREVO_API_KEY: process.env.BREVO_API_KEY,
|
||||
AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET,
|
||||
GOOGLE_AUTH_CLIENT_ID: process.env.GOOGLE_AUTH_CLIENT_ID,
|
||||
GOOGLE_AUTH_CLIENT_SECRET: process.env.GOOGLE_AUTH_CLIENT_SECRET,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"docker-inspect": "docker run -it litlyx-dashboard sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getbrevo/brevo": "^2.2.0",
|
||||
"@nuxtjs/tailwindcss": "^6.12.0",
|
||||
"chart.js": "^3.9.1",
|
||||
"date-fns": "^3.6.0",
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { AdminProjectsList } from '~/server/api/admin/projects';
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const { data: projects } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
|
||||
const { data: counts } = await useFetch('/api/admin/counts', signHeaders());
|
||||
|
||||
|
||||
type TProjectsGrouped = {
|
||||
user: {
|
||||
@@ -88,11 +90,6 @@ function onHideClicked() {
|
||||
isAdminHidden.value = true;
|
||||
}
|
||||
|
||||
|
||||
const projectsCount = computed(() => {
|
||||
return projects.value?.length || 0;
|
||||
});
|
||||
|
||||
const premiumCount = computed(() => {
|
||||
let premiums = 0;
|
||||
projects.value?.forEach(e => {
|
||||
@@ -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(() => {
|
||||
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>
|
||||
Users: {{ usersCount }}
|
||||
Users: {{ counts?.users }}
|
||||
</div>
|
||||
<div>
|
||||
Projects: {{ projectsCount }} ( {{ premiumCount }} premium )
|
||||
Projects: {{ counts?.projects }} ( {{ premiumCount }} premium )
|
||||
</div>
|
||||
<div>
|
||||
Total visits: {{ formatNumberK(totalVisits) }}
|
||||
|
||||
@@ -6,6 +6,8 @@ definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const isPremium = computed(() => (activeProject.value?.premium_type || 0) > 0);
|
||||
|
||||
const metricsInfo = ref<number>(0);
|
||||
|
||||
const columns = [
|
||||
@@ -36,7 +38,36 @@ onMounted(async () => {
|
||||
metricsInfo.value = counts.eventsCount;
|
||||
});
|
||||
|
||||
const creatingCsv = ref<boolean>(false);
|
||||
|
||||
async function downloadCSV() {
|
||||
creatingCsv.value = true;
|
||||
const result = await $fetch(`/api/project/generate_csv?mode=events&slice=${options.indexOf(selectedTimeFrom.value)}`, signHeaders());
|
||||
const blob = new Blob([result], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'ReportVisits.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
creatingCsv.value = false;
|
||||
}
|
||||
|
||||
const options = ['Last day', 'Last week', 'Last month', 'Total']
|
||||
const selectedTimeFrom = ref<string>(options[0]);
|
||||
|
||||
const showWarning = computed(() => {
|
||||
return options.indexOf(selectedTimeFrom.value) > 1
|
||||
})
|
||||
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
|
||||
function goToUpgrade() {
|
||||
pricingDrawer.visible.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -47,14 +78,38 @@ onMounted(async () => {
|
||||
|
||||
<div class="w-full h-dvh flex flex-col">
|
||||
|
||||
<div v-if="creatingCsv"
|
||||
class="fixed z-[100] flex items-center justify-center left-0 top-0 w-full h-full bg-black/60 backdrop-blur-[4px]">
|
||||
<div class="poppins text-[2rem]">
|
||||
Creating csv...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end px-12 py-3">
|
||||
<div
|
||||
|
||||
<div class="flex justify-end px-12 py-3 items-center gap-2">
|
||||
|
||||
<div v-if="showWarning" class="text-orange-400 flex gap-2 items-center">
|
||||
<i class="far fa-warning "></i>
|
||||
<div> It can take a few minutes </div>
|
||||
</div>
|
||||
<div class="w-[15rem] flex flex-col gap-0">
|
||||
<USelectMenu v-model="selectedTimeFrom" :options="options"></USelectMenu>
|
||||
</div>
|
||||
|
||||
<div v-if="isPremium" @click="downloadCSV()"
|
||||
class="bg-[#57c78fc0] hover:bg-[#57c78fab] cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
||||
Download CSV
|
||||
</div>
|
||||
|
||||
<div v-if="!isPremium" @click="goToUpgrade()"
|
||||
class="bg-[#57c78f46] hover:bg-[#57c78f42] flex gap-4 items-center cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
||||
<i class="far fa-lock"></i>
|
||||
Upgrade plan for CSV
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<UTable v-if="tableData" class="utable px-8" :ui="{
|
||||
wrapper: 'overflow-auto w-full h-full',
|
||||
thead: 'sticky top-0 bg-menu',
|
||||
|
||||
@@ -6,6 +6,8 @@ definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const isPremium = computed(() => (activeProject.value?.premium_type || 0) > 0);
|
||||
|
||||
const metricsInfo = ref<number>(0);
|
||||
|
||||
const columns = [
|
||||
@@ -68,6 +70,12 @@ const showWarning = computed(() => {
|
||||
return options.indexOf(selectedTimeFrom.value) > 1
|
||||
})
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
|
||||
function goToUpgrade() {
|
||||
pricingDrawer.visible.value = true;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -83,7 +91,9 @@ const showWarning = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex justify-end px-12 py-3 items-center gap-2">
|
||||
|
||||
<div v-if="showWarning" class="text-orange-400 flex gap-2 items-center">
|
||||
<i class="far fa-warning "></i>
|
||||
<div> It can take a few minutes </div>
|
||||
@@ -91,12 +101,21 @@ const showWarning = computed(() => {
|
||||
<div class="w-[15rem] flex flex-col gap-0">
|
||||
<USelectMenu v-model="selectedTimeFrom" :options="options"></USelectMenu>
|
||||
</div>
|
||||
<div @click="downloadCSV()"
|
||||
|
||||
<div v-if="isPremium" @click="downloadCSV()"
|
||||
class="bg-[#57c78fc0] hover:bg-[#57c78fab] cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
||||
Download CSV
|
||||
</div>
|
||||
|
||||
<div v-if="!isPremium" @click="goToUpgrade()"
|
||||
class="bg-[#57c78f46] hover:bg-[#57c78f42] flex gap-4 items-center cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
||||
<i class="far fa-lock"></i>
|
||||
Upgrade plan for CSV
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<UTable v-if="tableData" class="utable px-8" :ui="{
|
||||
wrapper: 'overflow-auto w-full h-full',
|
||||
thead: 'sticky top-0 bg-menu',
|
||||
|
||||
@@ -24,6 +24,9 @@ const limitsInfo = ref<{
|
||||
onMounted(async () => {
|
||||
if (route.query.just_logged) return location.href = '/';
|
||||
limitsInfo.value = await $fetch<any>("/api/project/limits_info", signHeaders());
|
||||
watch(activeProject, async () => {
|
||||
limitsInfo.value = await $fetch<any>("/api/project/limits_info", signHeaders());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -75,6 +78,12 @@ const { snapshot } = useSnapshot();
|
||||
|
||||
const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProject.value?._id.toString()}`);
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
|
||||
function goToUpgrade() {
|
||||
pricingDrawer.visible.value = true;
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -94,18 +103,19 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
|
||||
Limit reached
|
||||
</div>
|
||||
<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>
|
||||
<LyxUiButton type="outline"> Upgrade </LyxUiButton>
|
||||
<LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<DashboardTopSection></DashboardTopSection>
|
||||
<DashboardTopSection></DashboardTopSection>
|
||||
<DashboardTopCards :key="refreshKey"></DashboardTopCards>
|
||||
|
||||
|
||||
@@ -124,7 +134,7 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
|
||||
</div>
|
||||
</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.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="sessionsChartSelectIndex = $event"
|
||||
@@ -135,9 +145,9 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
|
||||
<DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)">
|
||||
</DashboardSessionsLineChart>
|
||||
</div>
|
||||
</CardTitled>
|
||||
</CardTitled> -->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full justify-center mt-6 px-6">
|
||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||
@@ -145,13 +155,13 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
|
||||
<DashboardWebsitesBarCard :key="refreshKey"></DashboardWebsitesBarCard>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<DashboardEventsBarCard :key="refreshKey"></DashboardEventsBarCard>
|
||||
<DashboardEventsBarCard :key="refreshKey"></DashboardEventsBarCard>
|
||||
</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-1">
|
||||
<DashboardReferrersBarCard :key="refreshKey"></DashboardReferrersBarCard>
|
||||
@@ -181,7 +191,7 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
|
||||
<div class="flex-1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
definePageMeta({ layout: 'none' });
|
||||
|
||||
const { snapshot, snapshots } = useSnapshot();
|
||||
|
||||
const { data: project } = useLiveDemo();
|
||||
|
||||
@@ -9,7 +10,7 @@ let interval: any;
|
||||
|
||||
onMounted(async () => {
|
||||
await getOnlineUsers();
|
||||
|
||||
snapshot.value = snapshots.value[0];
|
||||
interval = setInterval(async () => {
|
||||
await getOnlineUsers();
|
||||
}, 5000);
|
||||
@@ -46,7 +47,7 @@ const selectLabelsEvents = [
|
||||
{ label: 'Month', value: 'month' },
|
||||
];
|
||||
|
||||
const { snapshot } = useSnapshot();
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -71,14 +72,10 @@ const { snapshot } = useSnapshot();
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
<div class="flex gap-2 md:pt-0 pt-4">
|
||||
<NuxtLink target="_blank" to="https://cal.com/litlyx/30min"
|
||||
class="bg-white hover:bg-white/90 px-4 py-3 text-black poppins font-semibold text-[.9rem] lg:text-[1.2rem] rounded-lg">
|
||||
Book a demo
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/"
|
||||
class="bg-accent hover:bg-accent/90 px-4 py-3 poppins font-semibold text-[.9rem] lg:text-[1.2rem] rounded-lg">
|
||||
Go to dashboard
|
||||
</NuxtLink>
|
||||
<LyxUiButton link="/" type="primary"
|
||||
class="poppins font-semibold text-[.9rem] lg:text-[1.2rem] flex items-center !px-14 py-4">
|
||||
Get started for free
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,7 +87,7 @@ const { snapshot } = useSnapshot();
|
||||
|
||||
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row">
|
||||
|
||||
<CardTitled class="p-4 flex-1" title="Visits trends" sub="Shows trends in page visits.">
|
||||
<CardTitled class="p-4 flex-1 w-full" title="Visits trends" sub="Shows trends in page visits.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="mainChartSelectIndex = $event" :currentIndex="mainChartSelectIndex"
|
||||
:options="selectLabels">
|
||||
@@ -102,7 +99,7 @@ const { snapshot } = useSnapshot();
|
||||
</div>
|
||||
</CardTitled>
|
||||
|
||||
<CardTitled class="p-4 flex-1" title="Sessions" sub="Shows trends in sessions.">
|
||||
<!-- <CardTitled class="p-4 flex-1" title="Sessions" sub="Shows trends in sessions.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="sessionsChartSelectIndex = $event"
|
||||
:currentIndex="sessionsChartSelectIndex" :options="selectLabels">
|
||||
@@ -112,13 +109,14 @@ const { snapshot } = useSnapshot();
|
||||
<DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)">
|
||||
</DashboardSessionsLineChart>
|
||||
</div>
|
||||
</CardTitled>
|
||||
</CardTitled> -->
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex gap-6 flex-col xl:flex-row p-6">
|
||||
<!-- <CardTitled class="p-4 flex-[4]" title="Events" sub="Events stacked bar chart.">
|
||||
|
||||
<CardTitled class="p-4 flex-[4] w-full h-full" title="Events" sub="Events stacked bar chart.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="eventsStackedSelectIndex = $event"
|
||||
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
|
||||
@@ -128,25 +126,17 @@ const { snapshot } = useSnapshot();
|
||||
<EventsStackedBarChart :slice="(selectLabelsEvents[eventsStackedSelectIndex].value as any)">
|
||||
</EventsStackedBarChart>
|
||||
</div>
|
||||
</CardTitled> -->
|
||||
</CardTitled>
|
||||
|
||||
<div class="bg-menu p-4 rounded-xl flex-[2] flex flex-col gap-10 h-full">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="poppins font-semibold text-[1.4rem] text-text">
|
||||
Top events
|
||||
</div>
|
||||
<div class="poppins text-[1rem] text-text-sub/90">
|
||||
Displays key events.
|
||||
</div>
|
||||
<CardTitled title="Top events" sub=" Displays key events." class="p-4 flex-[2] w-full h-full">
|
||||
<div>
|
||||
<DashboardEventsChart class="w-full"> </DashboardEventsChart>
|
||||
</div>
|
||||
|
||||
<DashboardEventsChart class="w-full"> </DashboardEventsChart>
|
||||
|
||||
</div>
|
||||
</CardTitled>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex w-full justify-center mt-6 px-6">
|
||||
<div class="flex w-full justify-center px-6">
|
||||
<div class="flex w-full gap-6 flex-col lg:flex-row">
|
||||
<div class="flex-1">
|
||||
<DashboardWebsitesBarCard></DashboardWebsitesBarCard>
|
||||
@@ -203,15 +193,15 @@ const { snapshot } = useSnapshot();
|
||||
Do you want this KPIs for your website ?
|
||||
</div>
|
||||
<div class="poppins font-semibold text-text-sub">
|
||||
Start now ! It's free.
|
||||
Start now! It's free.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 flex-col md:flex-row">
|
||||
<NuxtLink to="/"
|
||||
class="bg-accent hover:bg-accent/90 px-14 py-4 poppins font-semibold text-[1.1rem] lg:text-[1.6rem] rounded-lg">
|
||||
<LyxUiButton link="/" type="primary"
|
||||
class="poppins font-semibold text-[1.1rem] lg:text-[1.6rem] flex items-center !px-14">
|
||||
Get started
|
||||
</NuxtLink>
|
||||
</LyxUiButton>
|
||||
<NuxtLink target="_blank" to="https://cal.com/litlyx/30min"
|
||||
class="bg-white hover:bg-white/90 text-black px-14 py-4 poppins font-semibold text-[1.1rem] lg:text-[1.6rem] rounded-lg">
|
||||
Book a demo
|
||||
|
||||
@@ -7,7 +7,7 @@ definePageMeta({ layout: 'header' });
|
||||
|
||||
<template>
|
||||
|
||||
<div class="home h-full overflow-y-auto relative">
|
||||
<!-- <div class="home h-full overflow-y-auto relative">
|
||||
|
||||
<div class="absolute top-0 left-0 w-full h-full flex flex-col items-center z-0 overflow-hidden">
|
||||
<HomeBgGrid :size="50" :spacing="18" opacity="0.3" class="w-fit h-fit"></HomeBgGrid>
|
||||
@@ -96,6 +96,6 @@ definePageMeta({ layout: 'header' });
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
</template>
|
||||
|
||||
BIN
dashboard/pdf_fonts/Poppins-Italic.ttf
Normal file
710
dashboard/pnpm-lock.yaml
generated
17
dashboard/server/api/admin/counts.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
if (!userData?.logged) return;
|
||||
if (!userData.user.roles.includes('ADMIN')) return;
|
||||
|
||||
|
||||
const projectsCount = await ProjectModel.countDocuments({});
|
||||
const usersCount = await UserModel.countDocuments({});
|
||||
|
||||
return { users: usersCount, projects: projectsCount }
|
||||
|
||||
});
|
||||
@@ -51,6 +51,7 @@ export default defineEventHandler(async event => {
|
||||
const savedUser = await newUser.save();
|
||||
|
||||
setImmediate(() => {
|
||||
console.log('SENDING WELCOME EMAIL TO', payload.email);
|
||||
if (payload.email) EmailService.sendWelcomeEmail(payload.email);
|
||||
});
|
||||
|
||||
|
||||
47
dashboard/server/api/keys/create.post.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { ApiSettingsModel, TApiSettings } from "@schema/ApiSettingsSchema";
|
||||
import { UserSettingsModel } from "@schema/UserSettings";
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
function generateApiKey() {
|
||||
return 'lit_' + crypto.randomBytes(6).toString('hex');
|
||||
}
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
if (body.name.length == 0) return setResponseStatus(event, 400, 'name is required');
|
||||
|
||||
if (body.name.length < 3) return setResponseStatus(event, 400, 'name too short');
|
||||
if (body.name.length > 32) return setResponseStatus(event, 400, 'name too long');
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||
|
||||
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
|
||||
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
|
||||
|
||||
const project_id = currentActiveProject.active_project_id;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||
|
||||
if (project.owner.toString() != userData.id) {
|
||||
return setResponseStatus(event, 400, 'You are not the owner');
|
||||
}
|
||||
|
||||
const key = generateApiKey();
|
||||
|
||||
const keyNumbers = await ApiSettingsModel.countDocuments({ project_id });
|
||||
|
||||
if (keyNumbers >= 5) return setResponseStatus(event, 400, 'Api key limit reached');
|
||||
|
||||
const newApiSettings = await ApiSettingsModel.create({ project_id, apiKey: key, apiName: body.name, created_at: Date.now(), usage: 0 });
|
||||
|
||||
return newApiSettings.toJSON();
|
||||
|
||||
});
|
||||
28
dashboard/server/api/keys/delete.delete.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
import { ApiSettingsModel } from "@schema/ApiSettingsSchema";
|
||||
import { UserSettingsModel } from "@schema/UserSettings";
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||
|
||||
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
|
||||
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
|
||||
|
||||
const project_id = currentActiveProject.active_project_id;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||
|
||||
if (project.owner.toString() != userData.id) {
|
||||
return setResponseStatus(event, 400, 'You are not the owner');
|
||||
}
|
||||
|
||||
const deletation = await ApiSettingsModel.deleteOne({ _id: body.api_id });
|
||||
return { ok: deletation.acknowledged };
|
||||
|
||||
});
|
||||
33
dashboard/server/api/keys/get_all.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { ApiSettingsModel, TApiSettings } from "@schema/ApiSettingsSchema";
|
||||
import { UserSettingsModel } from "@schema/UserSettings";
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
|
||||
|
||||
function cryptApiKeyName(apiSettings: TApiSettings): TApiSettings {
|
||||
return { ...apiSettings, apiKey: apiSettings.apiKey.substring(0, 6) + '******' }
|
||||
}
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||
|
||||
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
|
||||
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
|
||||
|
||||
const project_id = currentActiveProject.active_project_id;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||
|
||||
if (project.owner.toString() != userData.id) {
|
||||
return setResponseStatus(event, 400, 'You are not the owner');
|
||||
}
|
||||
|
||||
const apiKeys = await ApiSettingsModel.find({ project_id }, { project_id: 0 })
|
||||
|
||||
return apiKeys.map(e => cryptApiKeyName(e.toJSON())) as TApiSettings[];
|
||||
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import DateService from "@services/DateService";
|
||||
import { executeTimelineAggregation, fillAndMergeTimelineAggregation } from "~/server/services/TimelineService";
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
const project_id = getRequestProjectId(event);
|
||||
if (!project_id) return;
|
||||
@@ -22,12 +21,12 @@ export default defineEventHandler(async event => {
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `timeline:visits:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,
|
||||
exp: TIMELINE_EXPIRE_TIME
|
||||
exp: TIMELINE_EXPIRE_TIME,
|
||||
}, async () => {
|
||||
const timelineData = await executeTimelineAggregation({
|
||||
projectId: project._id,
|
||||
model: VisitModel,
|
||||
from, to, slice
|
||||
from, to, slice,
|
||||
});
|
||||
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice);
|
||||
return timelineFilledMerged;
|
||||
|
||||
40
dashboard/server/api/pay/[project_id]/create-onetime.post.ts
Normal 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;
|
||||
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
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);
|
||||
if (!allSubscriptions) return;
|
||||
|
||||
|
||||
const currentSubscription = allSubscriptions.data.find(e => e.id === subscription_id);
|
||||
if (!currentSubscription) return { error: 'SUBSCRIPTION NOT EXIST' }
|
||||
|
||||
@@ -201,7 +231,11 @@ export default defineEventHandler(async event => {
|
||||
|
||||
const eventData = StripeService.parseWebhook(body, signature);
|
||||
if (!eventData) return;
|
||||
|
||||
// console.log('WEBHOOK FIRED', eventData.type);
|
||||
|
||||
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 === 'customer.subscription.deleted') return await onSubscriptionDeleted(eventData);
|
||||
if (eventData.type === 'customer.subscription.created') return await onSubscriptionCreated(eventData);
|
||||
|
||||
@@ -21,6 +21,8 @@ export default defineEventHandler(async event => {
|
||||
}
|
||||
|
||||
const { name } = await readBody(event);
|
||||
|
||||
if (name.length == 0) return setResponseStatus(event, 400, 'name is required');
|
||||
|
||||
project.name = name;
|
||||
await project.save();
|
||||
|
||||
@@ -18,6 +18,10 @@ export default defineEventHandler(async event => {
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
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);
|
||||
|
||||
let timeSub = 1000 * 60 * 60 * 24;
|
||||
|
||||
@@ -2,18 +2,25 @@
|
||||
import pdfkit from 'pdfkit';
|
||||
|
||||
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 { VisitModel } from '@schema/metrics/VisitSchema';
|
||||
import { EventModel } from '@schema/metrics/EventSchema';
|
||||
|
||||
|
||||
type PDF_Data = {
|
||||
pageVisits: number, customEvents: number,
|
||||
visitsDay: number, eventsDay: number, visitsSessions: number,
|
||||
visitsSessionsDay: number
|
||||
type PDFGenerationData = {
|
||||
projectName: string,
|
||||
snapshotName: string,
|
||||
totalVisits: string,
|
||||
avgVisitsDay: string,
|
||||
totalEvents: string,
|
||||
topDomain: string,
|
||||
topDevice: string,
|
||||
topCountries: string[],
|
||||
topReferrers: string[],
|
||||
avgGrowthText: string,
|
||||
|
||||
}
|
||||
|
||||
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 pdf = new pdfkit({
|
||||
size: 'A4',
|
||||
margins: { top: 50, bottom: 50, left: 50, right: 50 },
|
||||
const LINE_SPACING = 0.5;
|
||||
|
||||
function createPdf(data: PDFGenerationData) {
|
||||
|
||||
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
|
||||
pdf.text('Average growth:', { align: 'left' }).moveDown(LINE_SPACING);
|
||||
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')
|
||||
.rect(0, 0, pdf.page.width, pdf.page.height)
|
||||
.fill('#000000');
|
||||
.text('Created with Litlyx.com', 50, 760, { align: 'center' });
|
||||
|
||||
// Title
|
||||
pdf
|
||||
.font('pdf_fonts/Poppins-Bold.ttf')
|
||||
.fontSize(26)
|
||||
.fillColor('#ffffff')
|
||||
.text(`Report of: ${projectName}`, 50, 50);
|
||||
pdf.image('pdf_images/logo.png', 460, 700, { width: 100 });
|
||||
|
||||
// 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();
|
||||
return pdf;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
@@ -114,51 +93,73 @@ export default defineEventHandler(async event => {
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
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({ project_id: project._id });
|
||||
|
||||
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 visitsCount = await VisitModel.countDocuments({
|
||||
project_id: project._id,
|
||||
created_at: { $gte: from, $lte: to }
|
||||
});
|
||||
|
||||
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);
|
||||
return avg;
|
||||
};
|
||||
|
||||
const avgVisitsSessionsDay = () => {
|
||||
const days = (Date.now() - (firstViewDate?.created_at.getTime() || 0)) / 1000 / 60 / 60 / 24;
|
||||
const avg = sessionsVisitsCount[0].count / Math.max(days, 1);
|
||||
return avg;
|
||||
};
|
||||
const topDevices = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
|
||||
{ $group: { _id: "$device", count: { $sum: 1 } } },
|
||||
{ $match: { _id: { $ne: null } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 1 }
|
||||
]);
|
||||
|
||||
const pdf = createPdf(
|
||||
project.name, {
|
||||
customEvents: eventsCount,
|
||||
eventsDay: avgEventsDay(),
|
||||
pageVisits: visitsCount,
|
||||
visitsDay: avgVisitDay(),
|
||||
visitsSessions: sessionsVisitsCount[0].count,
|
||||
visitsSessionsDay: avgVisitsSessionsDay()
|
||||
const topDevice = topDevices?.[0]?._id || 'Not enough data';
|
||||
|
||||
const topDomains = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
|
||||
{ $group: { _id: "$website", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 1 }
|
||||
]);
|
||||
|
||||
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();
|
||||
|
||||
@@ -16,6 +16,25 @@ export default defineEventHandler(async event => {
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
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 projectLimits = await ProjectLimitModel.findOne({ project_id });
|
||||
|
||||
27
dashboard/server/api/v1/events.post.ts
Normal 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);
|
||||
|
||||
});
|
||||
25
dashboard/server/api/v1/events.ts
Normal 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);
|
||||
|
||||
});
|
||||
28
dashboard/server/api/v1/visits.post.ts
Normal 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);
|
||||
|
||||
});
|
||||
27
dashboard/server/api/v1/visits.ts
Normal 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);
|
||||
|
||||
|
||||
});
|
||||
@@ -11,7 +11,7 @@ export default async () => {
|
||||
console.log('[SERVER] Initializing');
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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) });
|
||||
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;
|
||||
toolCalls = responseMessage.tool_calls;
|
||||
|
||||
|
||||
86
dashboard/server/services/ApiService.ts
Normal 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()) };
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getPlanFromTag } from '@data/PREMIUM';
|
||||
import { getPlanFromId, getPlanFromTag } from '@data/PREMIUM';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
class StripeService {
|
||||
@@ -29,6 +29,33 @@ class StripeService {
|
||||
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) {
|
||||
if (this.disabledMode) return;
|
||||
if (!this.stripe) throw Error('Stripe not initialized');
|
||||
@@ -50,6 +77,13 @@ class StripeService {
|
||||
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) {
|
||||
if (this.disabledMode) return;
|
||||
if (!this.stripe) throw Error('Stripe not initialized');
|
||||
@@ -78,7 +112,6 @@ class StripeService {
|
||||
return invoices;
|
||||
}
|
||||
|
||||
|
||||
async getCustomer(customer_id: string) {
|
||||
if (this.disabledMode) return;
|
||||
if (!this.stripe) throw Error('Stripe not initialized');
|
||||
@@ -100,8 +133,27 @@ class StripeService {
|
||||
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) {
|
||||
if (this.disabledMode) return;
|
||||
|
||||
@@ -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) return (num / 1_000).toFixed(decimals) + ' K';
|
||||
return num.toFixed();
|
||||
|
||||
return isNaN(num) ? '0' : num.toFixed();
|
||||
|
||||
}
|
||||
@@ -40,10 +40,8 @@ services:
|
||||
|
||||
# Optional - Used to send welcome and quota emails
|
||||
|
||||
# EMAIL_SERVICE: ""
|
||||
# EMAIL_HOST: ""
|
||||
# EMAIL_USER: ""
|
||||
# EMAIL_PASS: ""
|
||||
# NUXT_EMAIL_SERVICE: "Brevo"
|
||||
# NUXT_BREVO_API_KEY: ""
|
||||
|
||||
PORT: "3999"
|
||||
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
|
||||
|
||||
# NUXT_EMAIL_SERVICE: ""
|
||||
# NUXT_EMAIL_HOST: ""
|
||||
# NUXT_EMAIL_USER: ""
|
||||
# NUXT_EMAIL_PASS: ""
|
||||
# NUXT_EMAIL_SERVICE: "Brevo"
|
||||
# NUXT_BREVO_API_KEY: ""
|
||||
|
||||
NUXT_AUTH_JWT_SECRET: "litlyx_jwt_secret"
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export type BlogPost = {
|
||||
}
|
||||
|
||||
export const homePostsIndexes = ref<number[]>([
|
||||
0
|
||||
1, 0
|
||||
])
|
||||
|
||||
export const blogPosts = ref<BlogPost[]>([
|
||||
@@ -21,9 +21,19 @@ export const blogPosts = ref<BlogPost[]>([
|
||||
title: 'Presenting Litlyx',
|
||||
subtitle: 'Our Why. Our Vision. Our Manifestation of Intent',
|
||||
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(() => {
|
||||
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));
|
||||
})
|
||||
@@ -11,6 +11,20 @@ nuxtApp.hook("page:finish", () => {
|
||||
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>
|
||||
|
||||
|
||||
@@ -37,7 +51,7 @@ nuxtApp.hook("page:finish", () => {
|
||||
Blog
|
||||
</NuxtLink>
|
||||
<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
|
||||
</NuxtLink>
|
||||
<NuxtLink target="_blank" to="https://docs.litlyx.com" class="poppins hover:text-text-sub/90">
|
||||
@@ -63,19 +77,17 @@ nuxtApp.hook("page:finish", () => {
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-[1rem]">
|
||||
210+
|
||||
{{ gitstars }}
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
</div>
|
||||
<div class="px-10 pt-6 lg:pt-0">
|
||||
|
||||
|
||||
<MainButton link="https://dashboard.litlyx.com">
|
||||
<MainButton link="https://dashboard.litlyx.com" class="!whitespace-nowrap">
|
||||
Get started
|
||||
</MainButton>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +135,8 @@ nuxtApp.hook("page:finish", () => {
|
||||
|
||||
<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">
|
||||
Why choose Litlyx
|
||||
</div>
|
||||
@@ -148,7 +161,7 @@ nuxtApp.hook("page:finish", () => {
|
||||
</div>
|
||||
<div> <i class="fas fa-chevron-right"></i> </div>
|
||||
</NuxtLink>
|
||||
|
||||
|
||||
|
||||
|
||||
<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"
|
||||
class="hover:text-accent cursor-pointer"> Github </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 class="flex flex-col gap-4">
|
||||
<div class="text-text-sub/60 font-semibold text-[1.3rem]"> Company </div>
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
|
||||
export default defineNuxtConfig({
|
||||
colorMode: { preference: 'dark', },
|
||||
devtools: { enabled: false },
|
||||
app: {
|
||||
head: {
|
||||
script: [
|
||||
{
|
||||
src: 'https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js',
|
||||
'data-project': '6643cd08a1854e3b81722ab5',
|
||||
defer: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
pages: true,
|
||||
ssr: true,
|
||||
routeRules: {
|
||||
'/': {
|
||||
prerender: true
|
||||
colorMode: { preference: 'dark', },
|
||||
devtools: { enabled: false },
|
||||
app: {
|
||||
head: {
|
||||
script: [
|
||||
{
|
||||
src: 'https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js',
|
||||
'data-project': '6643cd08a1854e3b81722ab5',
|
||||
defer: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
'/**': {
|
||||
prerender: true
|
||||
pages: true,
|
||||
ssr: true,
|
||||
routeRules: {
|
||||
'/': {
|
||||
prerender: true
|
||||
},
|
||||
'/**': {
|
||||
prerender: true
|
||||
},
|
||||
},
|
||||
},
|
||||
css: ['~/assets/scss/main.scss'],
|
||||
modules: ['@nuxt/ui'],
|
||||
devServer: {
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
components: true,
|
||||
extends: ['../lyx-ui']
|
||||
})
|
||||
css: ['~/assets/scss/main.scss'],
|
||||
modules: ['@nuxt/ui'],
|
||||
devServer: {
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
components: true,
|
||||
extends: ['../lyx-ui']
|
||||
})
|
||||
|
||||
455
landing/pages/blog/why-choose-litlyx.vue
Normal 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 DB’s', 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 can’t compete with Google directly—it’s 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. We’re 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 can’t 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>
|
||||
@@ -51,6 +51,15 @@ const scriptDeferTokens = ref<string[]>([
|
||||
|
||||
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>
|
||||
|
||||
|
||||
@@ -74,10 +83,14 @@ const snippetIndex = ref<number>(0);
|
||||
All Your Analytics in a Single AI Powered Dashboard.
|
||||
</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">
|
||||
Start for free
|
||||
</LyxUiButton>
|
||||
<LyxUiButton link="https://dashboard.litlyx.com/live_demo" target="_blank" class="button"
|
||||
type="outline">
|
||||
Go to live demo
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -192,6 +205,24 @@ const snippetIndex = ref<number>(0);
|
||||
</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">
|
||||
|
||||
@@ -300,22 +331,6 @@ const snippetIndex = ref<number>(0);
|
||||
</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="subtitle">
|
||||
Update me!
|
||||
@@ -331,7 +346,7 @@ const snippetIndex = ref<number>(0);
|
||||
</div>
|
||||
|
||||
<div class="button-container">
|
||||
<LyxUiButton class="button" type="primary">
|
||||
<LyxUiButton class="button" type="primary" @click="saveEmail()">
|
||||
Keep me updated
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,6 @@ const freePricing: PricingCardProp[] = [
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 10',
|
||||
'Server type: SHARED',
|
||||
'Projects: max 2',
|
||||
'Data retention: 2 Months'
|
||||
],
|
||||
cta: 'Start For Free now!'
|
||||
@@ -41,7 +40,6 @@ const customPricing: PricingCardProp[] = [
|
||||
'DB instance: DEDICATED',
|
||||
'Dedicated operator',
|
||||
'White label',
|
||||
'Custom Charts',
|
||||
'Custom Data Aggregation'
|
||||
],
|
||||
cta: 'Let\'s Talk!',
|
||||
@@ -58,12 +56,11 @@ const slidePricings: PricingCardProp[] = [
|
||||
'CPM 0,10€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Discord support',
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 30',
|
||||
'Server type: SHARED',
|
||||
'Projects: max 3',
|
||||
'Data retention: 6 Months'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard'
|
||||
@@ -76,12 +73,11 @@ const slidePricings: PricingCardProp[] = [
|
||||
'CPM 0,06€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Discord support',
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 100',
|
||||
'Server type: SHARED',
|
||||
'Projects: max 3',
|
||||
'Data retention: 9 Months'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard'
|
||||
@@ -94,12 +90,11 @@ const slidePricings: PricingCardProp[] = [
|
||||
'CPM 0,059€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Discord support',
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 3.000',
|
||||
'Server type: SHARED',
|
||||
'Projects: max 3',
|
||||
'Data retention: 1 Year'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard'
|
||||
@@ -112,12 +107,11 @@ const slidePricings: PricingCardProp[] = [
|
||||
'CPM 0,059€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Discord support',
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 5.000',
|
||||
'Server type: SHARED',
|
||||
'Projects: max 3',
|
||||
'Data retention: 1 Year'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard'
|
||||
@@ -130,12 +124,11 @@ const slidePricings: PricingCardProp[] = [
|
||||
'CPM 0,039€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Discord support',
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 10.000',
|
||||
'Server type: DEDICATED',
|
||||
'Projects: max 3',
|
||||
'Data retention: 2 Years'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard'
|
||||
@@ -148,12 +141,11 @@ const slidePricings: PricingCardProp[] = [
|
||||
'CPM 0,029€ per visit/event'
|
||||
],
|
||||
features: [
|
||||
'Discord support',
|
||||
'Slack support',
|
||||
'Unlimited domains',
|
||||
'Unlimited reports',
|
||||
'AI Tokens: 20.000',
|
||||
'Server type: DEDICATED',
|
||||
'Projects: max 3',
|
||||
'Data retention: 3 Years'
|
||||
],
|
||||
cta: 'Go to Cloud Dashboard'
|
||||
@@ -189,6 +181,54 @@ const slidePricings: PricingCardProp[] = [
|
||||
</PricingCardGeneric>
|
||||
</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">
|
||||
<PricingCard class="flex-1" :data="starterTierCardData"></PricingCard>
|
||||
<PricingCard class="flex-1" :data="accelerationTierCardData"></PricingCard>
|
||||
@@ -240,7 +280,7 @@ const slidePricings: PricingCardProp[] = [
|
||||
<UAccordion :ui="{
|
||||
wrapper: 'w-full',
|
||||
item: {
|
||||
padding: 'pl-8'
|
||||
padding: 'pl-8',
|
||||
}
|
||||
}" color="white" variant="ghost" size="xl" :items="[
|
||||
{
|
||||
|
||||
BIN
landing/public/blog/posts/why-choose-litlyx.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
@@ -12,7 +12,9 @@ export const PREMIUM_TAGS = [
|
||||
'GROWTH',
|
||||
'EXPANSION',
|
||||
'SCALING',
|
||||
'UNICORN'
|
||||
'UNICORN',
|
||||
'LIFETIME_GROWTH_ONETIME',
|
||||
'GROWTH_DUMMY'
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -95,6 +97,20 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
|
||||
PRICE: 'price_1Pdt2LB2lPUiVs9VGBFAIG9G',
|
||||
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 => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
|
||||
// Default: 1.1
|
||||
// Default: 1.01
|
||||
// ((events + visits) * VALUE) > limit
|
||||
export const EVENT_LOG_LIMIT_PERCENT = 1.1;
|
||||
export const EVENT_LOG_LIMIT_PERCENT = 1.01;
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@getbrevo/brevo": "^2.2.0",
|
||||
"dayjs": "^1.11.11",
|
||||
"mongoose": "^8.4.0",
|
||||
"nodemailer": "^6.9.13",
|
||||
"redis": "^4.6.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.13",
|
||||
"@types/nodemailer": "^6.4.15"
|
||||
"@types/node": "^20.12.13"
|
||||
}
|
||||
}
|
||||
|
||||
1069
shared/pnpm-lock.yaml
generated
20
shared/schema/ApiSettingsSchema.ts
Normal 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);
|
||||
@@ -15,7 +15,7 @@ const EventSchema = new Schema<TEvent>({
|
||||
metadata: Schema.Types.Mixed,
|
||||
session: { 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);
|
||||
|
||||
@@ -16,7 +16,7 @@ const SessionSchema = new Schema<TSession>({
|
||||
flowHash: { type: String },
|
||||
duration: { type: Number, required: true, default: 0 },
|
||||
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);
|
||||
|
||||
@@ -36,8 +36,10 @@ const VisitSchema = new Schema<TVisit>({
|
||||
website: { type: String, required: true },
|
||||
page: { 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);
|
||||
|
||||
|
||||
@@ -3,4 +3,8 @@ import mongoose from "mongoose";
|
||||
|
||||
export async function connectDatabase(connectionString: string) {
|
||||
await mongoose.connect(connectionString);
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectDatabase() {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
|
||||
@@ -1,137 +1,64 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
import { TransactionalEmailsApi, SendSmtpEmail } from '@getbrevo/brevo';
|
||||
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 {
|
||||
|
||||
private transport: nodemailer.Transporter<SMTPTransport.SentMessageInfo>;
|
||||
private apiInstance = new TransactionalEmailsApi();
|
||||
|
||||
createTransport(service: string, host: string, user: string, pass: string) {
|
||||
this.transport = nodemailer.createTransport({
|
||||
host,
|
||||
secure: true,
|
||||
auth: { user, pass },
|
||||
tls: {
|
||||
minVersion: 'TLSv1',
|
||||
ciphers: 'HIGH:MEDIUM:!aNULL:!eNULL:@STRENGTH:!DH:!kEDH'
|
||||
}
|
||||
});
|
||||
init(apiKey: string) {
|
||||
this.apiInstance.setApiKey(0, apiKey);
|
||||
}
|
||||
|
||||
async sendLimitEmail50(target: string) {
|
||||
async sendLimitEmail50(target: string, projectName: string) {
|
||||
try {
|
||||
if (!this.transport) return console.error('Transport not created');
|
||||
await this.transport.sendMail({
|
||||
from: 'helplitlyx@gmail.com',
|
||||
to: target,
|
||||
subject: 'Project limit 50%',
|
||||
html: TemplateEmail50
|
||||
});
|
||||
const sendSmtpEmail = new SendSmtpEmail();
|
||||
sendSmtpEmail.subject = "You've reached 50% limit on Litlyx";
|
||||
sendSmtpEmail.sender = { "name": "Litlyx", "email": "no-reply@litlyx.com" };
|
||||
sendSmtpEmail.to = [{ "email": target }];
|
||||
|
||||
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;
|
||||
} catch (ex) {
|
||||
console.error('ERROR SENDING EMAIL', ex);
|
||||
@@ -141,13 +68,12 @@ class EmailService {
|
||||
|
||||
async sendWelcomeEmail(target: string) {
|
||||
try {
|
||||
if (!this.transport) return console.error('Transport not created');
|
||||
await this.transport.sendMail({
|
||||
from: 'helplitlyx@gmail.com',
|
||||
to: target,
|
||||
subject: 'Welcome to Litlyx',
|
||||
html: WELCOME_EMAIL
|
||||
});
|
||||
const sendSmtpEmail = new SendSmtpEmail();
|
||||
sendSmtpEmail.subject = "Welcome to Litlyx!";
|
||||
sendSmtpEmail.sender = { "name": "Litlyx", "email": "no-reply@litlyx.com" };
|
||||
sendSmtpEmail.to = [{ "email": target }];
|
||||
sendSmtpEmail.htmlContent = WELCOME_EMAIL;
|
||||
await this.apiInstance.sendTransacEmail(sendSmtpEmail);
|
||||
return true;
|
||||
} catch (ex) {
|
||||
console.error('ERROR SENDING EMAIL', ex);
|
||||
|
||||
@@ -13,14 +13,20 @@ export type ReadingLoopOptions = {
|
||||
|
||||
export class RedisStreamService {
|
||||
|
||||
private static processed = 0;
|
||||
|
||||
private static client = createClient({
|
||||
url: requireEnv("REDIS_URL"),
|
||||
username: requireEnv("REDIS_USERNAME"),
|
||||
password: requireEnv("REDIS_PASSWORD"),
|
||||
database: process.env.DEV_MODE === 'true' ? 1 : 0
|
||||
});
|
||||
|
||||
static async connect() {
|
||||
console.log('RedisStreamService DEV_MODE=', process.env.DEV_MODE === 'true');
|
||||
await this.client.connect();
|
||||
|
||||
|
||||
}
|
||||
|
||||
private static async readingLoop(options: ReadingLoopOptions, processFunction: (content: Record<string, string>) => Promise<any>) {
|
||||
@@ -31,6 +37,7 @@ export class RedisStreamService {
|
||||
return;
|
||||
}
|
||||
await processFunction(result);
|
||||
RedisStreamService.processed++;
|
||||
await new Promise(r => setTimeout(r, options.delay?.base || 100));
|
||||
setTimeout(() => this.readingLoop(options, processFunction), 1);
|
||||
return;
|
||||
@@ -38,10 +45,17 @@ export class RedisStreamService {
|
||||
|
||||
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 {
|
||||
console.log('Start reading loop');
|
||||
await this.client.xGroupCreate(options.streamName, 'broker', '0', { MKSTREAM: true, });
|
||||
console.log('Reading loop started');
|
||||
} catch (ex) {
|
||||
|
||||
console.error(ex);
|
||||
}
|
||||
|
||||
this.readingLoop(options, processFunction)
|
||||
|
||||
31
shared/services/email_templates/Limit50Email.ts
Normal 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>You’ve 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>
|
||||
|
||||
`
|
||||
31
shared/services/email_templates/Limit90Email.ts
Normal 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>You’ve 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>
|
||||
|
||||
`
|
||||
32
shared/services/email_templates/LimitMaxEmail.ts
Normal 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>❗️ You’ve 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>
|
||||
|
||||
|
||||
`
|
||||
@@ -1,378 +1,39 @@
|
||||
export const WELCOME_EMAIL = `
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<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 http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<title>Welcome Email Litlyx</title>
|
||||
<style media="all" type="text/css">
|
||||
/* -------------------------------------
|
||||
GLOBAL RESETS
|
||||
------------------------------------- */
|
||||
<title>Welcome to Litlyx!</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
|
||||
body {
|
||||
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%;
|
||||
}
|
||||
<p>We’re happy to have you onboard,</p>
|
||||
|
||||
table {
|
||||
border-collapse: separate;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
width: 100%;
|
||||
}
|
||||
<p>At Litlyx, we’re committed to creating the best analytics collection experience for everybody, starting from developers.</p>
|
||||
|
||||
table td {
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 16px;
|
||||
vertical-align: top;
|
||||
}
|
||||
/* -------------------------------------
|
||||
BODY & CONTAINER
|
||||
------------------------------------- */
|
||||
<p>Here are a few things you can do to get started tracking analytics today:</p>
|
||||
|
||||
body {
|
||||
/* background-color: #f4f5f6; */
|
||||
background-color: #181818;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
<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>
|
||||
|
||||
.body {
|
||||
/* background-color: #f4f5f6; */
|
||||
background-color: #181818;
|
||||
width: 100%;
|
||||
}
|
||||
<p>If you have any questions or need support, visit <a href="http://docs.litlyx.com" style="color: #007BFF;">docs.litlyx.com</a>.</p>
|
||||
|
||||
.container {
|
||||
margin: 0 auto !important;
|
||||
max-width: 600px;
|
||||
padding: 0;
|
||||
padding-top: 24px;
|
||||
width: 600px;
|
||||
}
|
||||
<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>. We’re here to help!</p>
|
||||
|
||||
.content {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 600px;
|
||||
padding: 0;
|
||||
}
|
||||
/* -------------------------------------
|
||||
HEADER, FOOTER, MAIN
|
||||
------------------------------------- */
|
||||
<p>Link to Discord for developer support: <a href="https://discord.com/invite/9cQykjsmWX" style="color: #007BFF;">https://discord.com/invite/9cQykjsmWX</a></p>
|
||||
|
||||
.main {
|
||||
background: #f7f7f7;
|
||||
border: 1px solid #eaebed;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
<p>Thank you for joining us, and we look forward to seeing you around.</p>
|
||||
|
||||
.wrapper {
|
||||
box-sizing: border-box;
|
||||
padding: 24px;
|
||||
}
|
||||
<p>We want to make analytics the freshest thing on the web.</p>
|
||||
|
||||
.footer {
|
||||
clear: both;
|
||||
padding-top: 24px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
<p>Antonio,</p>
|
||||
<p>CEO | Litlyx</p>
|
||||
|
||||
.footer td,
|
||||
.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> </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">© 2024 Litlyx All right reserved. Made with ❤ in Italy </span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- END FOOTER -->
|
||||
|
||||
<!-- END CENTERED WHITE CONTAINER --></div>
|
||||
</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
32
shared/services/email_templates/limit_50.html
Normal 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>You’ve 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;">You’ve 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>
|
||||
32
shared/services/email_templates/limit_90.html
Normal 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>You’ve 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;">You’ve 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>
|
||||
32
shared/services/email_templates/limit_max.html
Normal 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>❗️ You’ve 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;">❗️ You’ve 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>
|
||||
40
shared/services/email_templates/welcome.html
Normal 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>We’re happy to have you onboard,</p>
|
||||
|
||||
<p>At Litlyx, we’re 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>. We’re 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>
|
||||