mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 07:48:37 +01:00
change reports + chage pricing
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||
|
||||
import { PREMIUM_PLAN, getPlanFromId } from '@data/PREMIUM'
|
||||
import { PREMIUM_PLAN, getPlanFromId } from '@data/PLANS'
|
||||
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
|
||||
|
||||
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||
import { getPlanFromId } from '~/shared/data/PREMIUM';
|
||||
import { getPlanFromId } from '~/shared/data/PLANS';
|
||||
|
||||
|
||||
import { AdminDialogProjectDetails } from '#components';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||
import type { TAdminUser } from '~/server/api/admin/users';
|
||||
import { getPlanFromId } from '~/shared/data/PREMIUM';
|
||||
import { getPlanFromId } from '~/shared/data/PLANS';
|
||||
|
||||
import { AdminDialogProjectDetails } from '#components';
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ function getPricingsData() {
|
||||
<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-[1.1rem] text-lyx-lightmode-text dark:text-yellow-400 mb-2">
|
||||
*Plan upgrades are applicable exclusively to this project(workspace).
|
||||
*Plan upgrades are applied to account level.
|
||||
</div>
|
||||
<div class="poppins text-[2rem] font-semibold">
|
||||
Do you need help ?
|
||||
|
||||
@@ -73,25 +73,6 @@ async function deleteSnapshot(close: () => any) {
|
||||
close();
|
||||
}
|
||||
|
||||
async function generatePDF() {
|
||||
|
||||
try {
|
||||
const res = await $fetch<Blob>('/api/project/generate_pdf', {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false, custom: { 'x-snapshot-name': snapshot.value.name } }).value,
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(res);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `Report.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
const { actions } = useProject();
|
||||
|
||||
const { showDrawer } = useDrawer();
|
||||
@@ -256,12 +237,6 @@ function openPendingInvites() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex mt-4">
|
||||
<LyxUiButton @click="generatePDF()" type="outline" class="w-full text-center text-[.8rem]">
|
||||
Export report
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="bg-lyx-lightmode-widget dark:bg-[#202020] h-[1px] w-full"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import dayjs from 'dayjs';
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
import { getPlanFromId, PREMIUM_PLAN, type PLAN_TAG } from '@data/PREMIUM';
|
||||
import { getPlanFromId, PREMIUM_PLAN, type PLAN_TAG } from '@data/PLANS';
|
||||
|
||||
const { projectId, isGuest } = useProject();
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ definePageMeta({ layout: 'dashboard' });
|
||||
const customization = ref<any>();
|
||||
|
||||
const { snapshot } = useSnapshot();
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
const { isPremium } = useLoggedUser()
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await $fetch('/api/report/customization', {
|
||||
@@ -26,7 +29,11 @@ async function updateCustomization() {
|
||||
})
|
||||
}
|
||||
|
||||
const generating = ref<boolean>(false);
|
||||
|
||||
async function generateReport(type: number) {
|
||||
if (generating.value === true) return;
|
||||
generating.value = true;
|
||||
try {
|
||||
const res = await $fetch<Blob>(`/api/project/generate_pdf?type=${type}`, {
|
||||
headers: useComputedHeaders({
|
||||
@@ -46,6 +53,8 @@ async function generateReport(type: number) {
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
}
|
||||
|
||||
generating.value = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,22 +75,32 @@ function onFileSelected(e: string) {
|
||||
<div class="p-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
<CardTitled class="w-full h-full" title="Choose a report" sub="Select a report type">
|
||||
<div style="height: 18rem;" class="w-full flex gap-4">
|
||||
<LyxUiCard>
|
||||
<div @click="generateReport(1)" class="cursor-pointer hover:text-lyx-text-darker">
|
||||
Easy report
|
||||
<div class="w-full flex gap-4 h-[18rem]">
|
||||
<LyxUiCard class="flex-1 h-full">
|
||||
<div @click="generateReport(1)"
|
||||
:class="{ 'cursor-pointer hover:text-lyx-text-darker': !generating }"
|
||||
class="flex justify-center items-center text-[1.2rem] h-full">
|
||||
<div v-if="!generating"> Easy report </div>
|
||||
<div v-if="generating" class="flex justify-center pb-8 text-[1.2rem]">
|
||||
<i class="fas fa-loader animate-spin"></i>
|
||||
</div>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
<LyxUiCard>
|
||||
<div @click="generateReport(1)" class="cursor-pointer hover:text-lyx-text-darker">
|
||||
Product report
|
||||
<LyxUiCard class="flex-1 h-full">
|
||||
<div class="flex justify-center items-center text-[1.2rem] h-full">
|
||||
<div class="text-gray-400">(coming soon)</div>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
</div>
|
||||
</CardTitled>
|
||||
<div class="flex gap-4">
|
||||
<CardTitled class="w-full h-full" title="Customize theme" sub="Choose the report colors">
|
||||
<div v-if="customization" style="height: 18rem;" class="w-full flex gap-2">
|
||||
<CardTitled class="w-full h-full relative" title="Customize theme" sub="Choose the report colors">
|
||||
<div v-if="!isPremium" @click="showDrawer('PRICING')"
|
||||
class="absolute w-full h-full top-0 left-0 bg-black/80 rounded-lg flex items-center justify-center gap-1">
|
||||
<div class="text-amber-300"> <i class="far fa-lock"></i> </div>
|
||||
<div class="text-amber-300"> Premium only </div>
|
||||
</div>
|
||||
<div v-if="customization" class="w-full flex gap-2 h-[18rem]">
|
||||
<div @click="selectColor('white')"
|
||||
class="flex items-center justify-center rounded-lg bg-white border-solid border-[1px] border-gray-200 cursor-pointer w-[4rem] h-[2rem]">
|
||||
<i v-if="customization.bg == 'white'" class="fas fa-check text-blue-600"></i>
|
||||
@@ -92,7 +111,12 @@ function onFileSelected(e: string) {
|
||||
</div>
|
||||
</div>
|
||||
</CardTitled>
|
||||
<CardTitled class="w-full h-full" title="Customize logo" sub="Upload your logo">
|
||||
<CardTitled class="w-full h-full relative" title="Customize logo" sub="Upload your logo">
|
||||
<div v-if="!isPremium" @click="showDrawer('PRICING')"
|
||||
class="absolute w-full h-full top-0 left-0 bg-black/80 rounded-lg flex items-center justify-center gap-1">
|
||||
<div class="text-amber-300"> <i class="far fa-lock"></i> </div>
|
||||
<div class="text-amber-300"> Premium only </div>
|
||||
</div>
|
||||
<div v-if="customization" style="height: 18rem;" class="w-full flex gap-4">
|
||||
<img v-if="customization.logo" :src="customization.logo" class="w-[256px] h-[256px]">
|
||||
<div class="flex h-[10rem]">
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 20 KiB |
@@ -1,4 +1,4 @@
|
||||
import { getPlanFromId } from "@data/PREMIUM";
|
||||
import { getPlanFromId } from "@data/PLANS";
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
// import StripeService from '~/server/services/StripeService';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getPlanFromId } from "@data/PREMIUM";
|
||||
import { getPlanFromId } from "@data/PLANS";
|
||||
// import StripeService from '~/server/services/StripeService';
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getPlanFromId, PREMIUM_PLAN } from "@data/PREMIUM";
|
||||
import { getPlanFromId, PREMIUM_PLAN } from "@data/PLANS";
|
||||
import { canTryAppsumoCode, checkAppsumoCode, useAppsumoCode, useTryAppsumoCode } from "~/server/services/AppsumoService";
|
||||
import { PaymentServiceHelper } from "~/server/services/PaymentServiceHelper";
|
||||
import { PremiumModel } from "~/shared/schema/PremiumSchema";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// import StripeService from '~/server/services/StripeService';
|
||||
// import type Event from 'stripe';
|
||||
// import { ProjectModel } from '@schema/project/ProjectSchema';
|
||||
// import { PREMIUM_DATA, PREMIUM_PLAN, getPlanFromId, getPlanFromPrice, getPlanFromTag } from '@data/PREMIUM';
|
||||
// import { PREMIUM_DATA, PREMIUM_PLAN, getPlanFromId, getPlanFromPrice, getPlanFromTag } from '@data/PLANS';
|
||||
// import { ProjectLimitModel } from '@schema/project/ProjectsLimits';
|
||||
// import { EmailService } from '@services/EmailService'
|
||||
// import { UserModel } from '@schema/UserSchema';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
|
||||
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||
import { EventModel } from "~/shared/schema/metrics/EventSchema";
|
||||
|
||||
|
||||
@@ -7,6 +7,16 @@ import { VisitModel } from '@schema/metrics/VisitSchema';
|
||||
import { EventModel } from '@schema/metrics/EventSchema';
|
||||
import { ReportCustomizationModel, TReportCustomization } from '~/shared/schema/report/ReportCustomizationSchema';
|
||||
|
||||
import { getInstance } from '~/server/services/AiService';
|
||||
|
||||
import { zodResponseFormat } from 'openai/helpers/zod';
|
||||
import z from 'zod';
|
||||
|
||||
|
||||
const ZPromptResponse = z.object({
|
||||
report: z.string({ description: 'Short, user-facing report summarizing website analytics data. Should be professional but slightly discursive, not just a list of stats, feel like a human summary — similar to an executive update. Highlight key numbers and insights (like visits, top countries, referrers). Use just text, no markdown. Max 620 chars.' }),
|
||||
insights: z.string({ description: 'Growth hacker, product expert and marketing expert. Simple and effective actionable insights. Max 3. Short.' }).array()
|
||||
})
|
||||
|
||||
type PDFGenerationData = {
|
||||
projectName: string,
|
||||
@@ -18,8 +28,9 @@ type PDFGenerationData = {
|
||||
topDevice: string,
|
||||
topCountries: string[],
|
||||
topReferrers: string[],
|
||||
avgGrowthText: string,
|
||||
customization?: TReportCustomization
|
||||
customization?: any,
|
||||
naturalText: string,
|
||||
insights: string[]
|
||||
}
|
||||
|
||||
function formatNumberK(value: string | number, decimals: number = 1) {
|
||||
@@ -37,10 +48,15 @@ const resourcePath = process.env.MODE === 'TEST' ? './public/pdf/' : './.output/
|
||||
|
||||
function createPdf(data: PDFGenerationData) {
|
||||
|
||||
const pdf = new pdfkit({ size: 'A4', margins: { top: 50, bottom: 50, left: 50, right: 50 }, });
|
||||
const pdf = new pdfkit({
|
||||
size: 'A4',
|
||||
margins: {
|
||||
top: 30, bottom: 30, left: 50, right: 50
|
||||
},
|
||||
});
|
||||
|
||||
let bgColor = '#0A0A0A';
|
||||
let textColor = 'FFFFFF';
|
||||
let textColor = '#FFFFFF';
|
||||
let logo = data.customization?.logo ?? resourcePath + 'pdf_images/logo.png'
|
||||
|
||||
if (data.customization?.bg) {
|
||||
@@ -54,49 +70,58 @@ function createPdf(data: PDFGenerationData) {
|
||||
|
||||
pdf.font(resourcePath + 'pdf_fonts/Poppins-Bold.ttf').fontSize(16).fillColor(textColor);
|
||||
|
||||
pdf.text(`${data.projectName}`, { align: 'center' }).moveDown(LINE_SPACING);
|
||||
pdf.text(`Report for: ${data.projectName} project`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||
|
||||
pdf.moveDown(LINE_SPACING)
|
||||
pdf.font(resourcePath + 'pdf_fonts/Poppins-Bold.ttf').fontSize(13).fillColor(textColor);
|
||||
pdf.text(`Timeframe name: ${data.snapshotName}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||
|
||||
pdf.font(resourcePath + 'pdf_fonts/Poppins-Regular.ttf').fontSize(12).fillColor(textColor)
|
||||
|
||||
pdf.text(`${data.naturalText}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||
|
||||
pdf.moveDown(LINE_SPACING)
|
||||
pdf.font(resourcePath + 'pdf_fonts/Poppins-Bold.ttf').fontSize(13).fillColor(textColor);
|
||||
pdf.text(`Plain metrics:`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||
pdf.font(resourcePath + 'pdf_fonts/Poppins-Regular.ttf').fontSize(12).fillColor(textColor)
|
||||
|
||||
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.text(`Top 3 countries: ${data.topCountries.join(', ')}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||
|
||||
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);
|
||||
});
|
||||
pdf.text(`Top 3 best acquisition channels (referrers): ${data.topReferrers.join(', ')}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||
|
||||
pdf.text('Average growth:', { align: 'left' }).moveDown(LINE_SPACING);
|
||||
pdf.text(`${data.avgGrowthText}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||
pdf.moveDown(LINE_SPACING)
|
||||
pdf.font(resourcePath + 'pdf_fonts/Poppins-Bold.ttf').fontSize(13).fillColor(textColor);
|
||||
pdf.text(`Actionable insights:`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||
pdf.font(resourcePath + 'pdf_fonts/Poppins-Regular.ttf').fontSize(12).fillColor(textColor)
|
||||
|
||||
pdf.font(resourcePath + '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);
|
||||
|
||||
for (let i = 0; i < data.insights.length; i++) {
|
||||
pdf.text(`• ${data.insights[i]}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||
}
|
||||
|
||||
pdf.font(resourcePath + 'pdf_fonts/Poppins-Regular.ttf')
|
||||
.fontSize(10)
|
||||
.fillColor(textColor)
|
||||
.text('Created with Litlyx.com', 50, 760, { align: 'center' });
|
||||
.text(`Created with Litlyx.com, ${new Date().toLocaleDateString('en-US')}`, 50, 780, { align: 'left' });
|
||||
|
||||
|
||||
pdf.image(logo, 460, 700, { width: 100 });
|
||||
pdf.image(logo, 465, 695, { width: 85 });
|
||||
|
||||
|
||||
pdf.end();
|
||||
return pdf;
|
||||
}
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestDataOld(event, { requireSchema: false, allowGuests: true, requireRange: false });
|
||||
const data = await getRequestData(event, [], []);
|
||||
if (!data) return;
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
@@ -163,19 +188,36 @@ export default defineEventHandler(async event => {
|
||||
|
||||
const customization = await ReportCustomizationModel.findOne({ project_id: project._id });
|
||||
|
||||
const pdf = createPdf({
|
||||
|
||||
const textData: Omit<PDFGenerationData, 'naturalText' | 'insights' | 'customization'> = {
|
||||
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),
|
||||
customization: customization?.toJSON() as TReportCustomization
|
||||
});
|
||||
}
|
||||
|
||||
const openai = getInstance();
|
||||
|
||||
const res = await openai.chat.completions.create({
|
||||
messages: [
|
||||
{
|
||||
role: 'user', content: `${JSON.stringify(textData)}`,
|
||||
}
|
||||
],
|
||||
response_format: zodResponseFormat(ZPromptResponse, 'response'),
|
||||
model: 'gpt-4o-mini'
|
||||
})
|
||||
|
||||
|
||||
const resObject = JSON.parse(res.choices[0].message.content ?? '{}');
|
||||
|
||||
|
||||
const pdf = createPdf({ ...textData, naturalText: resObject.report, insights: resObject.insights, customization: customization?.toJSON() as TReportCustomization, });
|
||||
|
||||
const passThrough = new PassThrough();
|
||||
pdf.pipe(passThrough);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
import z from 'zod';
|
||||
import { PremiumModel } from '~/shared/schema/PremiumSchema';
|
||||
import { ReportCustomizationModel } from "~/shared/schema/report/ReportCustomizationSchema";
|
||||
|
||||
const ZUpdateCustomizationBody = z.object({
|
||||
@@ -11,6 +12,11 @@ export default defineEventHandler(async event => {
|
||||
const data = await getRequestData(event, []);
|
||||
if (!data) return;
|
||||
|
||||
|
||||
const premium = await PremiumModel.findOne({ user_id: data.user.id });
|
||||
if (!premium) return createError({ status: 400, message: 'Not premium' });
|
||||
if (premium.premium_type == 0) return createError({ status: 400, message: 'Not premium' });
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
const bodyData = ZUpdateCustomizationBody.parse(body);
|
||||
|
||||
@@ -91,6 +91,10 @@ type ElaborateResponseCallbacks = {
|
||||
onChatId?: (chat_id: string) => any
|
||||
}
|
||||
|
||||
export function getInstance() {
|
||||
return openai;
|
||||
}
|
||||
|
||||
async function elaborateResponse(messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[], pid: string, time_offset: number, chat_id: string, callbacks?: ElaborateResponseCallbacks) {
|
||||
|
||||
console.log('[ELABORATING RESPONSE]');
|
||||
|
||||
@@ -11,7 +11,7 @@ helper.copy('services/EmailService.ts');
|
||||
|
||||
|
||||
helper.create('data');
|
||||
helper.copy('data/PREMIUM.ts');
|
||||
helper.copy('data/PLANS.ts');
|
||||
helper.copy('data/ADMINS.ts');
|
||||
|
||||
helper.create('data/broker');
|
||||
|
||||
@@ -34,8 +34,8 @@ export type PLAN_DATA = {
|
||||
export const PREMIUM_PLAN: Record<PLAN_TAG, PLAN_DATA> = {
|
||||
FREE: {
|
||||
ID: 0,
|
||||
COUNT_LIMIT: 5_000,
|
||||
AI_MESSAGE_LIMIT: 10_000,
|
||||
COUNT_LIMIT: 10_000,
|
||||
AI_MESSAGE_LIMIT: 10,
|
||||
PRICE: 'price_1POKCMB2lPUiVs9VLe3QjIHl',
|
||||
PRICE_TEST: 'price_1PNbHYB2lPUiVs9VZP32xglF',
|
||||
COST: 0,
|
||||
Reference in New Issue
Block a user