29 Commits

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

View File

@@ -1,18 +1,16 @@
<p align="center">
<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>
#
![GitHub Repo stars](https://img.shields.io/github/stars/Litlyx/litlyx)
![NPM Version](https://img.shields.io/npm/v/litlyx?logo=npm&color=orange)
![npm bundle size](https://img.shields.io/bundlephobia/min/litlyx)
## Pre-Requisites 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">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/claim.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
assets/dashboard-clip.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

BIN
assets/tech.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

3
broker/.gitignore vendored
View File

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

View File

@@ -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
View File

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

View File

@@ -1,5 +1,6 @@
{
"dependencies": {
"@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

File diff suppressed because it is too large Load Diff

View File

@@ -6,25 +6,68 @@ import { requireEnv } from "../../shared/utilts/requireEnv";
import { TProjectLimit } from "@schema/ProjectsLimits";
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 });
}
}

View File

@@ -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);

View File

@@ -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"

View File

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

View File

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

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -4,23 +4,45 @@ import type { Section } from '~/components/CVerticalNavigation.vue';
import { Lit } from 'litlyx-js';
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();

View File

@@ -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,

View File

@@ -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",

View File

@@ -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) }}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

Binary file not shown.

710
dashboard/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import DateService from "@services/DateService";
import { 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;

View File

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

View File

@@ -63,6 +63,36 @@ async function onPaymentFailed(event: Event.InvoicePaymentFailedEvent) {
}
async function onPaymentOnetimeSuccess(event: Event.PaymentIntentSucceededEvent) {
const customer_id = event.data.object.customer as string;
const project = await ProjectModel.findOne({ customer_id });
if (!project) return { error: 'CUSTOMER NOT EXIST' }
if (event.data.object.status === 'succeeded') {
const PLAN = getPlanFromPrice(event.data.object.metadata.price, StripeService.testMode || false);
if (!PLAN) return { error: 'Plan not found' }
const dummyPlan = PLAN.ID + 3000;
const subscription = await StripeService.createOneTimeSubscriptionDummy(customer_id, dummyPlan);
if (!subscription) return { error: 'Error creating subscription' }
const allSubscriptions = await StripeService.getAllSubscriptions(customer_id);
if (!allSubscriptions) return;
for (const subscription of allSubscriptions.data) {
if (subscription.id === subscription.id) continue;
await StripeService.deleteSubscription(subscription.id);
}
await addSubscriptionToProject(project._id.toString(), PLAN, subscription.id, subscription.current_period_start, subscription.current_period_end)
return { ok: true };
}
return { received: true, warn: 'object status not succeeded' }
}
async function onPaymentSuccess(event: Event.InvoicePaidEvent) {
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);

View File

@@ -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();

View File

@@ -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;

View File

@@ -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();

View File

@@ -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 });

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ export default async () => {
console.log('[SERVER] Initializing');
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')
}

View File

@@ -127,7 +127,7 @@ export async function sendMessageOnChat(text: string, pid: string, initial_chat_
messages.push({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) });
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;

View File

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

View File

@@ -1,4 +1,4 @@
import { getPlanFromTag } from '@data/PREMIUM';
import { getPlanFromId, getPlanFromTag } from '@data/PREMIUM';
import Stripe from 'stripe';
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;

View File

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

View File

@@ -40,10 +40,8 @@ services:
# Optional - Used to send welcome and quota emails
# 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"

View File

@@ -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));
})

View File

@@ -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>

View File

@@ -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']
})

View File

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

View File

@@ -51,6 +51,15 @@ const scriptDeferTokens = ref<string[]>([
const snippetIndex = ref<number>(0);
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>

View File

@@ -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="[
{

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -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 => {

View File

@@ -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;

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -15,7 +15,7 @@ const EventSchema = new Schema<TEvent>({
metadata: Schema.Types.Mixed,
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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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)

View File

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

View File

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

View File

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

View File

@@ -1,378 +1,39 @@
export const WELCOME_EMAIL = `
<!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>Were happy to have you onboard,</p>
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%;
}
<p>At Litlyx, were 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>. Were 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>&nbsp;</td>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<span class="preheader">This is preheader text. Some clients will show this text as a preview.</span>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper">
<p>Hi 👋 Welcome to <strong>Litlyx!</strong></p>
<p>We value your time so we will keep this simple.
We are happy to have you onboard! 🎊</p>
<p>We leave you a super fast video tutorial, just 1 minute where our CEO, Antonio, explain the very basics of Litlyx and will guide you to start logging analytics today for your website or web app!</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td> <a href="https://youtube.com" target="_blank">Super Fast Video Tutorial</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>Good luck! Enjoy your KPIs in real-time. Start now visiting the dashboard!</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td> <a href="https://dashboard.litlyx.com" target="_blank">Go to Litlyx Dashboard</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>If you have any question, you can find us at: help@litlyx.com, we will respond in less than a work day.</p>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
<div class="footer">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block">
<span class="apple-link">&copy; 2024 Litlyx All right reserved. Made with ❤ in Italy </span>
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER --></div>
</td>
<td>&nbsp;</td>
</tr>
</table>
</body>
</body>
</html>
`

View File

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

View File

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

View File

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

View File

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