create job to calc stripe and user stats

This commit is contained in:
vincanger 2023-11-14 16:54:46 +01:00
parent 5a96ae8fd7
commit 1a999bb257
20 changed files with 525 additions and 201 deletions

View File

@ -78,7 +78,9 @@ entity User {=psl
id Int @id @default(autoincrement())
email String? @unique
password String?
createdAt DateTime @default(now())
lastActiveTimestamp DateTime @default(now())
isAdmin Boolean @default(false)
isEmailVerified Boolean @default(false)
emailVerificationSentAt DateTime?
passwordResetSentAt DateTime?
@ -125,6 +127,17 @@ entity ContactFormMessage {=psl
repliedAt DateTime?
psl=}
entity DailyStats {=psl
id Int @id @default(autoincrement())
date DateTime @default(now()) @unique
userCount Int @default(0)
paidUserCount Int @default(0)
userDelta Int @default(0)
paidUserDelta Int @default(0)
totalRevenue Int @default(0)
totalProfit Int @default(0)
psl=}
/* 📡 These are the Wasp Routes (You can protect them easily w/ 'authRequired: true');
* https://wasp-lang.dev/docs/tutorial/pages
*/
@ -273,6 +286,11 @@ query getRelatedObjects {
entities: [User, RelatedObject]
}
query getDailyStats {
fn: import { getDailyStats } from "@server/queries.js",
entities: [User, DailyStats]
}
/*
* 📡 These are custom Wasp API Endpoints. Use them for callbacks, webhooks, etc.
* https://wasp-lang.dev/docs/advanced/apis
@ -298,3 +316,14 @@ job emailChecker {
},
entities: [User]
}
job dailyStats {
executor: PgBoss,
perform: {
fn: import { calculateDailyStats } from "@server/workers/calculateDailyStats.js"
},
schedule: {
cron: "0 * * * *" // every hour
},
entities: [User, DailyStats]
}

View File

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "isAdmin" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "lastActiveTimestamp" DROP DEFAULT;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "lastActiveTimestamp" SET DEFAULT CURRENT_TIMESTAMP;

View File

@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "DailyStats" (
"id" SERIAL NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"newUsers" INTEGER NOT NULL DEFAULT 0,
"newPaidUsers" INTEGER NOT NULL DEFAULT 0,
"newUsersDelta" INTEGER NOT NULL DEFAULT 0,
"newPaidUsersDelta" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "DailyStats_pkey" PRIMARY KEY ("id")
);

View File

@ -0,0 +1,18 @@
/*
Warnings:
- You are about to drop the column `newPaidUsers` on the `DailyStats` table. All the data in the column will be lost.
- You are about to drop the column `newPaidUsersDelta` on the `DailyStats` table. All the data in the column will be lost.
- You are about to drop the column `newUsers` on the `DailyStats` table. All the data in the column will be lost.
- You are about to drop the column `newUsersDelta` on the `DailyStats` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "DailyStats" DROP COLUMN "newPaidUsers",
DROP COLUMN "newPaidUsersDelta",
DROP COLUMN "newUsers",
DROP COLUMN "newUsersDelta",
ADD COLUMN "paidUserCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "paidUserDelta" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "userCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "userDelta" INTEGER NOT NULL DEFAULT 0;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[date]` on the table `DailyStats` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "DailyStats_date_key" ON "DailyStats"("date");

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "DailyStats" ADD COLUMN "totalProfit" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "totalRevenue" INTEGER NOT NULL DEFAULT 0;

View File

@ -0,0 +1,35 @@
export function UpArrow() {
return (
<svg
className='fill-meta-3'
width='10'
height='11'
viewBox='0 0 10 11'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M4.35716 2.47737L0.908974 5.82987L5.0443e-07 4.94612L5 0.0848689L10 4.94612L9.09103 5.82987L5.64284 2.47737L5.64284 10.0849L4.35716 10.0849L4.35716 2.47737Z'
fill=''
/>
</svg>
);
}
export function DownArrow() {
return (
<svg
className='fill-meta-5'
width='10'
height='11'
viewBox='0 0 10 11'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M5.64284 7.69237L9.09102 4.33987L10 5.22362L5 10.0849L-8.98488e-07 5.22362L0.908973 4.33987L4.35716 7.69237L4.35716 0.0848701L5.64284 0.0848704L5.64284 7.69237Z'
fill=''
/>
</svg>
);
}

View File

@ -0,0 +1,3 @@
import { DailyStats } from "@wasp/entities";
export type DailyStatsProps = { dailyStats?: DailyStats; weeklyStats?:DailyStats[], isLoading?: Boolean }

View File

@ -1,6 +1,7 @@
import { ApexOptions } from 'apexcharts';
import React, { useState } from 'react';
import React, { useState, useMemo, useEffect } from 'react';
import ReactApexChart from 'react-apexcharts';
import { DailyStatsProps } from '../common/types';
const options: ApexOptions = {
legend: {
@ -10,7 +11,7 @@ const options: ApexOptions = {
},
colors: ['#3C50E0', '#80CAEE'],
chart: {
fontFamily: 'Satoshi, sans-serif',
fontFamily: 'Satoshi, sans-serif',
height: 335,
type: 'area',
dropShadow: {
@ -83,20 +84,6 @@ const options: ApexOptions = {
},
xaxis: {
type: 'category',
categories: [
'Sep',
'Oct',
'Nov',
'Dec',
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
],
axisBorder: {
show: false,
},
@ -122,53 +109,117 @@ interface ChartOneState {
}[];
}
const DailyActiveUsersChart: React.FC = () => {
const DailyActiveUsersChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
const dailyRevenueArray = useMemo(() => {
if (!!weeklyStats && weeklyStats?.length > 0) {
const sortedWeeks = weeklyStats?.sort((a, b) => {
return new Date(a.date).getTime() - new Date(b.date).getTime();
});
return sortedWeeks.map((stat) => stat.totalRevenue);
}
}, [weeklyStats]);
const daysOfWeekArr = useMemo(() => {
if (!!weeklyStats && weeklyStats?.length > 0) {
const datesArr = weeklyStats?.map((stat) => {
// get day of week, month, and day of month
const dateArr = stat.date.toString().split(' ');
return dateArr.slice(0, 3).join(' ');
});
return datesArr;
}
}, [weeklyStats]);
const [state, setState] = useState<ChartOneState>({
series: [
{
name: 'Product One',
data: [23, 0],
},
{
name: 'Product Two',
data: [30, 25, 36, 30, 45, 35, 64, 52, 59, 36, 39, 51],
name: 'Profit',
data: [4, 7, 10, 11, 13, 14, 17],
},
],
});
const [chartOptions, setChartOptions] = useState<ApexOptions>(options);
useEffect(() => {
if (dailyRevenueArray && dailyRevenueArray.length > 0) {
setState((prevState) => {
// Check if a "Revenue" series already exists
const existingSeriesIndex = prevState.series.findIndex((series) => series.name === 'Revenue');
if (existingSeriesIndex >= 0) {
// Update existing "Revenue" series data
return {
...prevState,
series: prevState.series.map((serie, index) => {
if (index === existingSeriesIndex) {
return { ...serie, data: dailyRevenueArray };
}
return serie;
}),
};
} else {
// Add "Revenue" series as it does not exist yet
return {
...prevState,
series: [
...prevState.series,
{
name: 'Revenue',
data: dailyRevenueArray,
},
],
};
}
});
}
}, [dailyRevenueArray]);
useEffect(() => {
console.log('ooptions categories: ', options?.xaxis?.categories);
console.log('days of week arr: ', daysOfWeekArr);
if (!!daysOfWeekArr && daysOfWeekArr?.length > 0) {
setChartOptions({
...options,
xaxis: {
...options.xaxis,
categories: daysOfWeekArr,
},
});
}
}, [daysOfWeekArr]);
return (
<div className="col-span-12 rounded-sm border border-stroke bg-white px-5 pt-7.5 pb-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:col-span-8">
<div className="flex flex-wrap items-start justify-between gap-3 sm:flex-nowrap">
<div className="flex w-full flex-wrap gap-3 sm:gap-5">
<div className="flex min-w-47.5">
<span className="mt-1 mr-2 flex h-4 w-full max-w-4 items-center justify-center rounded-full border border-primary">
<span className="block h-2.5 w-full max-w-2.5 rounded-full bg-primary"></span>
<div className='col-span-12 rounded-sm border border-stroke bg-white px-5 pt-7.5 pb-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:col-span-8'>
<div className='flex flex-wrap items-start justify-between gap-3 sm:flex-nowrap'>
<div className='flex w-full flex-wrap gap-3 sm:gap-5'>
<div className='flex min-w-47.5'>
<span className='mt-1 mr-2 flex h-4 w-full max-w-4 items-center justify-center rounded-full border border-primary'>
<span className='block h-2.5 w-full max-w-2.5 rounded-full bg-primary'></span>
</span>
<div className="w-full">
<p className="font-semibold text-primary">Total Revenue</p>
<p className="text-sm font-medium">12.04.2022 - 12.05.2022</p>
<div className='w-full'>
<p className='font-semibold text-primary'>Total Profit</p>
<p className='text-sm font-medium'>Last 7 Days</p>
</div>
</div>
<div className="flex min-w-47.5">
<span className="mt-1 mr-2 flex h-4 w-full max-w-4 items-center justify-center rounded-full border border-secondary">
<span className="block h-2.5 w-full max-w-2.5 rounded-full bg-secondary"></span>
<div className='flex min-w-47.5'>
<span className='mt-1 mr-2 flex h-4 w-full max-w-4 items-center justify-center rounded-full border border-secondary'>
<span className='block h-2.5 w-full max-w-2.5 rounded-full bg-secondary'></span>
</span>
<div className="w-full">
<p className="font-semibold text-secondary">Total Sales</p>
<p className="text-sm font-medium">12.04.2022 - 12.05.2022</p>
<div className='w-full'>
<p className='font-semibold text-secondary'>Total Revenue</p>
<p className='text-sm font-medium'>Last 7 Days</p>
</div>
</div>
</div>
<div className="flex w-full max-w-45 justify-end">
<div className="inline-flex items-center rounded-md bg-whiter p-1.5 dark:bg-meta-4">
<button className="rounded bg-white py-1 px-3 text-xs font-medium text-black shadow-card hover:bg-white hover:shadow-card dark:bg-boxdark dark:text-white dark:hover:bg-boxdark">
<div className='flex w-full max-w-45 justify-end'>
<div className='inline-flex items-center rounded-md bg-whiter p-1.5 dark:bg-meta-4'>
<button className='rounded bg-white py-1 px-3 text-xs font-medium text-black shadow-card hover:bg-white hover:shadow-card dark:bg-boxdark dark:text-white dark:hover:bg-boxdark'>
Day
</button>
<button className="rounded py-1 px-3 text-xs font-medium text-black hover:bg-white hover:shadow-card dark:text-white dark:hover:bg-boxdark">
<button className='rounded py-1 px-3 text-xs font-medium text-black hover:bg-white hover:shadow-card dark:text-white dark:hover:bg-boxdark'>
Week
</button>
<button className="rounded py-1 px-3 text-xs font-medium text-black hover:bg-white hover:shadow-card dark:text-white dark:hover:bg-boxdark">
<button className='rounded py-1 px-3 text-xs font-medium text-black hover:bg-white hover:shadow-card dark:text-white dark:hover:bg-boxdark'>
Month
</button>
</div>
@ -176,13 +227,8 @@ const DailyActiveUsersChart: React.FC = () => {
</div>
<div>
<div id="chartOne" className="-ml-5">
<ReactApexChart
options={options}
series={state.series}
type="area"
height={350}
/>
<div id='chartOne' className='-ml-5'>
<ReactApexChart options={chartOptions} series={state.series} type='area' height={350} />
</div>
</div>
</div>

View File

@ -24,9 +24,9 @@ const TotalPageViewsCard = () => {
<div className="mt-4 flex items-end justify-between">
<div>
<h4 className="text-title-md font-bold text-black dark:text-white">
$3.456K
3.456K
</h4>
<span className="text-sm font-medium">Total views</span>
<span className="text-sm font-medium">Total page views</span>
</div>
<span className="flex items-center gap-1 text-sm font-medium text-meta-3">

View File

@ -1,49 +1,45 @@
const TotalPayingUsersCard = () => {
import { useMemo } from 'react';
import { UpArrow, DownArrow } from '../common/icons';
import type { DailyStatsProps } from '../common/types';
const TotalPayingUsersCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
const isDeltaPositive = useMemo(() => {
return !!dailyStats?.paidUserDelta && dailyStats?.paidUserDelta > 0;
}, [dailyStats]);
return (
<div className="rounded-sm border border-stroke bg-white py-6 px-7.5 shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="flex h-11.5 w-11.5 items-center justify-center rounded-full bg-meta-2 dark:bg-meta-4">
<div className='rounded-sm border border-stroke bg-white py-6 px-7.5 shadow-default dark:border-strokedark dark:bg-boxdark'>
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-meta-2 dark:bg-meta-4'>
<svg
className="fill-primary dark:fill-white"
width="22"
height="22"
viewBox="0 0 22 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className='fill-primary dark:fill-white'
width='22'
height='22'
viewBox='0 0 22 22'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d="M21.1063 18.0469L19.3875 3.23126C19.2157 1.71876 17.9438 0.584381 16.3969 0.584381H5.56878C4.05628 0.584381 2.78441 1.71876 2.57816 3.23126L0.859406 18.0469C0.756281 18.9063 1.03128 19.7313 1.61566 20.3844C2.20003 21.0375 2.99066 21.3813 3.85003 21.3813H18.1157C18.975 21.3813 19.8 21.0031 20.35 20.3844C20.9 19.7656 21.2094 18.9063 21.1063 18.0469ZM19.2157 19.3531C18.9407 19.6625 18.5625 19.8344 18.15 19.8344H3.85003C3.43753 19.8344 3.05941 19.6625 2.78441 19.3531C2.50941 19.0438 2.37191 18.6313 2.44066 18.2188L4.12503 3.43751C4.19378 2.71563 4.81253 2.16563 5.56878 2.16563H16.4313C17.1532 2.16563 17.7719 2.71563 17.875 3.43751L19.5938 18.2531C19.6282 18.6656 19.4907 19.0438 19.2157 19.3531Z"
fill=""
d='M21.1063 18.0469L19.3875 3.23126C19.2157 1.71876 17.9438 0.584381 16.3969 0.584381H5.56878C4.05628 0.584381 2.78441 1.71876 2.57816 3.23126L0.859406 18.0469C0.756281 18.9063 1.03128 19.7313 1.61566 20.3844C2.20003 21.0375 2.99066 21.3813 3.85003 21.3813H18.1157C18.975 21.3813 19.8 21.0031 20.35 20.3844C20.9 19.7656 21.2094 18.9063 21.1063 18.0469ZM19.2157 19.3531C18.9407 19.6625 18.5625 19.8344 18.15 19.8344H3.85003C3.43753 19.8344 3.05941 19.6625 2.78441 19.3531C2.50941 19.0438 2.37191 18.6313 2.44066 18.2188L4.12503 3.43751C4.19378 2.71563 4.81253 2.16563 5.56878 2.16563H16.4313C17.1532 2.16563 17.7719 2.71563 17.875 3.43751L19.5938 18.2531C19.6282 18.6656 19.4907 19.0438 19.2157 19.3531Z'
fill=''
/>
<path
d="M14.3345 5.29375C13.922 5.39688 13.647 5.80938 13.7501 6.22188C13.7845 6.42813 13.8189 6.63438 13.8189 6.80625C13.8189 8.35313 12.547 9.625 11.0001 9.625C9.45327 9.625 8.1814 8.35313 8.1814 6.80625C8.1814 6.6 8.21577 6.42813 8.25015 6.22188C8.35327 5.80938 8.07827 5.39688 7.66577 5.29375C7.25327 5.19063 6.84077 5.46563 6.73765 5.87813C6.6689 6.1875 6.63452 6.49688 6.63452 6.80625C6.63452 9.2125 8.5939 11.1719 11.0001 11.1719C13.4064 11.1719 15.3658 9.2125 15.3658 6.80625C15.3658 6.49688 15.3314 6.1875 15.2626 5.87813C15.1595 5.46563 14.747 5.225 14.3345 5.29375Z"
fill=""
d='M14.3345 5.29375C13.922 5.39688 13.647 5.80938 13.7501 6.22188C13.7845 6.42813 13.8189 6.63438 13.8189 6.80625C13.8189 8.35313 12.547 9.625 11.0001 9.625C9.45327 9.625 8.1814 8.35313 8.1814 6.80625C8.1814 6.6 8.21577 6.42813 8.25015 6.22188C8.35327 5.80938 8.07827 5.39688 7.66577 5.29375C7.25327 5.19063 6.84077 5.46563 6.73765 5.87813C6.6689 6.1875 6.63452 6.49688 6.63452 6.80625C6.63452 9.2125 8.5939 11.1719 11.0001 11.1719C13.4064 11.1719 15.3658 9.2125 15.3658 6.80625C15.3658 6.49688 15.3314 6.1875 15.2626 5.87813C15.1595 5.46563 14.747 5.225 14.3345 5.29375Z'
fill=''
/>
</svg>
</div>
<div className="mt-4 flex items-end justify-between">
<div className='mt-4 flex items-end justify-between'>
<div>
<h4 className="text-title-md font-bold text-black dark:text-white">
2.450
</h4>
<span className="text-sm font-medium">Total Product</span>
<h4 className='text-title-md font-bold text-black dark:text-white'>{dailyStats?.paidUserCount}</h4>
<span className='text-sm font-medium'>Total Paying Users</span>
</div>
<span className="flex items-center gap-1 text-sm font-medium text-meta-3">
2.59%
<svg
className="fill-meta-3"
width="10"
height="11"
viewBox="0 0 10 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.35716 2.47737L0.908974 5.82987L5.0443e-07 4.94612L5 0.0848689L10 4.94612L9.09103 5.82987L5.64284 2.47737L5.64284 10.0849L4.35716 10.0849L4.35716 2.47737Z"
fill=""
/>
</svg>
<span
className={`flex items-center gap-1 text-sm font-medium ${isDeltaPositive ? 'text-meta-3' : 'text-meta-5'}`}
>
{isLoading ? '...' : dailyStats?.paidUserDelta !== 0 ? dailyStats?.paidUserDelta : '-'}
{dailyStats?.paidUserDelta !== 0 ? isDeltaPositive ? <UpArrow /> : <DownArrow /> : null}
</span>
</div>
</div>

View File

@ -1,57 +0,0 @@
const TotalProfitCard = () => {
return (
<div className="rounded-sm border border-stroke bg-white py-6 px-7.5 shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="flex h-11.5 w-11.5 items-center justify-center rounded-full bg-meta-2 dark:bg-meta-4">
<svg
className="fill-primary dark:fill-white"
width="20"
height="22"
viewBox="0 0 20 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.7531 16.4312C10.3781 16.4312 9.27808 17.5312 9.27808 18.9062C9.27808 20.2812 10.3781 21.3812 11.7531 21.3812C13.1281 21.3812 14.2281 20.2812 14.2281 18.9062C14.2281 17.5656 13.0937 16.4312 11.7531 16.4312ZM11.7531 19.8687C11.2375 19.8687 10.825 19.4562 10.825 18.9406C10.825 18.425 11.2375 18.0125 11.7531 18.0125C12.2687 18.0125 12.6812 18.425 12.6812 18.9406C12.6812 19.4219 12.2343 19.8687 11.7531 19.8687Z"
fill=""
/>
<path
d="M5.22183 16.4312C3.84683 16.4312 2.74683 17.5312 2.74683 18.9062C2.74683 20.2812 3.84683 21.3812 5.22183 21.3812C6.59683 21.3812 7.69683 20.2812 7.69683 18.9062C7.69683 17.5656 6.56245 16.4312 5.22183 16.4312ZM5.22183 19.8687C4.7062 19.8687 4.2937 19.4562 4.2937 18.9406C4.2937 18.425 4.7062 18.0125 5.22183 18.0125C5.73745 18.0125 6.14995 18.425 6.14995 18.9406C6.14995 19.4219 5.73745 19.8687 5.22183 19.8687Z"
fill=""
/>
<path
d="M19.0062 0.618744H17.15C16.325 0.618744 15.6031 1.23749 15.5 2.06249L14.95 6.01562H1.37185C1.0281 6.01562 0.684353 6.18749 0.443728 6.46249C0.237478 6.73749 0.134353 7.11562 0.237478 7.45937C0.237478 7.49374 0.237478 7.49374 0.237478 7.52812L2.36873 13.9562C2.50623 14.4375 2.9531 14.7812 3.46873 14.7812H12.9562C14.2281 14.7812 15.3281 13.8187 15.5 12.5469L16.9437 2.26874C16.9437 2.19999 17.0125 2.16562 17.0812 2.16562H18.9375C19.35 2.16562 19.7281 1.82187 19.7281 1.37499C19.7281 0.928119 19.4187 0.618744 19.0062 0.618744ZM14.0219 12.3062C13.9531 12.8219 13.5062 13.2 12.9906 13.2H3.7781L1.92185 7.56249H14.7094L14.0219 12.3062Z"
fill=""
/>
</svg>
</div>
<div className="mt-4 flex items-end justify-between">
<div>
<h4 className="text-title-md font-bold text-black dark:text-white">
$45,2K
</h4>
<span className="text-sm font-medium">Total Profit</span>
</div>
<span className="flex items-center gap-1 text-sm font-medium text-meta-3">
4.35%
<svg
className="fill-meta-3"
width="10"
height="11"
viewBox="0 0 10 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.35716 2.47737L0.908974 5.82987L5.0443e-07 4.94612L5 0.0848689L10 4.94612L9.09103 5.82987L5.64284 2.47737L5.64284 10.0849L4.35716 10.0849L4.35716 2.47737Z"
fill=""
/>
</svg>
</span>
</div>
</div>
);
};
export default TotalProfitCard;

View File

@ -0,0 +1,62 @@
import { useMemo, useEffect } from 'react';
import { UpArrow, DownArrow } from '../common/icons';
import type { DailyStatsProps } from '../common/types';
const TotalRevenueCard = ({dailyStats, weeklyStats, isLoading}: DailyStatsProps) => {
const isDeltaPositive = useMemo(() => {
if (!weeklyStats) return false;
return (weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) > 0;
}, [weeklyStats]);
const delta = useMemo(() => {
if (!weeklyStats) return;
return weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue;
}, [weeklyStats]);
const deltaPercentage = useMemo(() => {
if (!weeklyStats || !weeklyStats[1]?.totalRevenue) return;
return ((weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) / weeklyStats[1]?.totalRevenue) * 100;
}, [weeklyStats]);
return (
<div className='rounded-sm border border-stroke bg-white py-6 px-7.5 shadow-default dark:border-strokedark dark:bg-boxdark'>
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-meta-2 dark:bg-meta-4'>
<svg
className='fill-primary dark:fill-white'
width='20'
height='22'
viewBox='0 0 20 22'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M11.7531 16.4312C10.3781 16.4312 9.27808 17.5312 9.27808 18.9062C9.27808 20.2812 10.3781 21.3812 11.7531 21.3812C13.1281 21.3812 14.2281 20.2812 14.2281 18.9062C14.2281 17.5656 13.0937 16.4312 11.7531 16.4312ZM11.7531 19.8687C11.2375 19.8687 10.825 19.4562 10.825 18.9406C10.825 18.425 11.2375 18.0125 11.7531 18.0125C12.2687 18.0125 12.6812 18.425 12.6812 18.9406C12.6812 19.4219 12.2343 19.8687 11.7531 19.8687Z'
fill=''
/>
<path
d='M5.22183 16.4312C3.84683 16.4312 2.74683 17.5312 2.74683 18.9062C2.74683 20.2812 3.84683 21.3812 5.22183 21.3812C6.59683 21.3812 7.69683 20.2812 7.69683 18.9062C7.69683 17.5656 6.56245 16.4312 5.22183 16.4312ZM5.22183 19.8687C4.7062 19.8687 4.2937 19.4562 4.2937 18.9406C4.2937 18.425 4.7062 18.0125 5.22183 18.0125C5.73745 18.0125 6.14995 18.425 6.14995 18.9406C6.14995 19.4219 5.73745 19.8687 5.22183 19.8687Z'
fill=''
/>
<path
d='M19.0062 0.618744H17.15C16.325 0.618744 15.6031 1.23749 15.5 2.06249L14.95 6.01562H1.37185C1.0281 6.01562 0.684353 6.18749 0.443728 6.46249C0.237478 6.73749 0.134353 7.11562 0.237478 7.45937C0.237478 7.49374 0.237478 7.49374 0.237478 7.52812L2.36873 13.9562C2.50623 14.4375 2.9531 14.7812 3.46873 14.7812H12.9562C14.2281 14.7812 15.3281 13.8187 15.5 12.5469L16.9437 2.26874C16.9437 2.19999 17.0125 2.16562 17.0812 2.16562H18.9375C19.35 2.16562 19.7281 1.82187 19.7281 1.37499C19.7281 0.928119 19.4187 0.618744 19.0062 0.618744ZM14.0219 12.3062C13.9531 12.8219 13.5062 13.2 12.9906 13.2H3.7781L1.92185 7.56249H14.7094L14.0219 12.3062Z'
fill=''
/>
</svg>
</div>
<div className='mt-4 flex items-end justify-between'>
<div>
<h4 className='text-title-md font-bold text-black dark:text-white'>${dailyStats?.totalRevenue}</h4>
<span className='text-sm font-medium'>Total Revenue</span>
</div>
<span className='flex items-center gap-1 text-sm font-medium text-meta-3'>
{isLoading ? '...' : !!deltaPercentage ? deltaPercentage + '%' : '-'}
{!!deltaPercentage ? isDeltaPositive ? <UpArrow /> : <DownArrow /> : null}
</span>
</div>
</div>
);
};
export default TotalRevenueCard;

View File

@ -1,53 +1,49 @@
const TotalSignupsCard = () => {
import { useMemo } from 'react';
import { UpArrow } from '../common/icons';
import type { DailyStatsProps } from '../common/types';
const TotalSignupsCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
const isDeltaPositive = useMemo(() => {
return !!dailyStats?.userDelta && dailyStats.userDelta > 0;
}, [dailyStats]);
return (
<div className="rounded-sm border border-stroke bg-white py-6 px-7.5 shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="flex h-11.5 w-11.5 items-center justify-center rounded-full bg-meta-2 dark:bg-meta-4">
<div className='rounded-sm border border-stroke bg-white py-6 px-7.5 shadow-default dark:border-strokedark dark:bg-boxdark'>
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-meta-2 dark:bg-meta-4'>
<svg
className="fill-primary dark:fill-white"
width="22"
height="18"
viewBox="0 0 22 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className='fill-primary dark:fill-white'
width='22'
height='18'
viewBox='0 0 22 18'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d="M7.18418 8.03751C9.31543 8.03751 11.0686 6.35313 11.0686 4.25626C11.0686 2.15938 9.31543 0.475006 7.18418 0.475006C5.05293 0.475006 3.2998 2.15938 3.2998 4.25626C3.2998 6.35313 5.05293 8.03751 7.18418 8.03751ZM7.18418 2.05626C8.45605 2.05626 9.52168 3.05313 9.52168 4.29063C9.52168 5.52813 8.49043 6.52501 7.18418 6.52501C5.87793 6.52501 4.84668 5.52813 4.84668 4.29063C4.84668 3.05313 5.9123 2.05626 7.18418 2.05626Z"
fill=""
d='M7.18418 8.03751C9.31543 8.03751 11.0686 6.35313 11.0686 4.25626C11.0686 2.15938 9.31543 0.475006 7.18418 0.475006C5.05293 0.475006 3.2998 2.15938 3.2998 4.25626C3.2998 6.35313 5.05293 8.03751 7.18418 8.03751ZM7.18418 2.05626C8.45605 2.05626 9.52168 3.05313 9.52168 4.29063C9.52168 5.52813 8.49043 6.52501 7.18418 6.52501C5.87793 6.52501 4.84668 5.52813 4.84668 4.29063C4.84668 3.05313 5.9123 2.05626 7.18418 2.05626Z'
fill=''
/>
<path
d="M15.8124 9.6875C17.6687 9.6875 19.1468 8.24375 19.1468 6.42188C19.1468 4.6 17.6343 3.15625 15.8124 3.15625C13.9905 3.15625 12.478 4.6 12.478 6.42188C12.478 8.24375 13.9905 9.6875 15.8124 9.6875ZM15.8124 4.7375C16.8093 4.7375 17.5999 5.49375 17.5999 6.45625C17.5999 7.41875 16.8093 8.175 15.8124 8.175C14.8155 8.175 14.0249 7.41875 14.0249 6.45625C14.0249 5.49375 14.8155 4.7375 15.8124 4.7375Z"
fill=""
d='M15.8124 9.6875C17.6687 9.6875 19.1468 8.24375 19.1468 6.42188C19.1468 4.6 17.6343 3.15625 15.8124 3.15625C13.9905 3.15625 12.478 4.6 12.478 6.42188C12.478 8.24375 13.9905 9.6875 15.8124 9.6875ZM15.8124 4.7375C16.8093 4.7375 17.5999 5.49375 17.5999 6.45625C17.5999 7.41875 16.8093 8.175 15.8124 8.175C14.8155 8.175 14.0249 7.41875 14.0249 6.45625C14.0249 5.49375 14.8155 4.7375 15.8124 4.7375Z'
fill=''
/>
<path
d="M15.9843 10.0313H15.6749C14.6437 10.0313 13.6468 10.3406 12.7874 10.8563C11.8593 9.61876 10.3812 8.79376 8.73115 8.79376H5.67178C2.85303 8.82814 0.618652 11.0625 0.618652 13.8469V16.3219C0.618652 16.975 1.13428 17.4906 1.7874 17.4906H20.2468C20.8999 17.4906 21.4499 16.9406 21.4499 16.2875V15.4625C21.4155 12.4719 18.9749 10.0313 15.9843 10.0313ZM2.16553 15.9438V13.8469C2.16553 11.9219 3.74678 10.3406 5.67178 10.3406H8.73115C10.6562 10.3406 12.2374 11.9219 12.2374 13.8469V15.9438H2.16553V15.9438ZM19.8687 15.9438H13.7499V13.8469C13.7499 13.2969 13.6468 12.7469 13.4749 12.2313C14.0937 11.7844 14.8499 11.5781 15.6405 11.5781H15.9499C18.0812 11.5781 19.8343 13.3313 19.8343 15.4625V15.9438H19.8687Z"
fill=""
d='M15.9843 10.0313H15.6749C14.6437 10.0313 13.6468 10.3406 12.7874 10.8563C11.8593 9.61876 10.3812 8.79376 8.73115 8.79376H5.67178C2.85303 8.82814 0.618652 11.0625 0.618652 13.8469V16.3219C0.618652 16.975 1.13428 17.4906 1.7874 17.4906H20.2468C20.8999 17.4906 21.4499 16.9406 21.4499 16.2875V15.4625C21.4155 12.4719 18.9749 10.0313 15.9843 10.0313ZM2.16553 15.9438V13.8469C2.16553 11.9219 3.74678 10.3406 5.67178 10.3406H8.73115C10.6562 10.3406 12.2374 11.9219 12.2374 13.8469V15.9438H2.16553V15.9438ZM19.8687 15.9438H13.7499V13.8469C13.7499 13.2969 13.6468 12.7469 13.4749 12.2313C14.0937 11.7844 14.8499 11.5781 15.6405 11.5781H15.9499C18.0812 11.5781 19.8343 13.3313 19.8343 15.4625V15.9438H19.8687Z'
fill=''
/>
</svg>
</div>
<div className="mt-4 flex items-end justify-between">
<div className='mt-4 flex items-end justify-between'>
<div>
<h4 className="text-title-md font-bold text-black dark:text-white">
3.456
</h4>
<span className="text-sm font-medium">Total Users</span>
<h4 className='text-title-md font-bold text-black dark:text-white'>{dailyStats?.userCount}</h4>
<span className='text-sm font-medium'>Total Signups</span>
</div>
<span className="flex items-center gap-1 text-sm font-medium text-meta-5">
0.95%
<svg
className="fill-meta-5"
width="10"
height="11"
viewBox="0 0 10 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.64284 7.69237L9.09102 4.33987L10 5.22362L5 10.0849L-8.98488e-07 5.22362L0.908973 4.33987L4.35716 7.69237L4.35716 0.0848701L5.64284 0.0848704L5.64284 7.69237Z"
fill=""
/>
</svg>
<span
className={`flex items-center gap-1 text-sm font-medium ${isDeltaPositive ? 'text-meta-3' : 'text-meta-5'}`}
>
{isLoading ? '...' : isDeltaPositive ? dailyStats?.userDelta : '-'}
{!!dailyStats && isDeltaPositive && <UpArrow />}
</span>
</div>
</div>

View File

@ -1,26 +1,29 @@
import TotalSignupsCard from '../../components/TotalSignupsCard';
import TotalPageViewsCard from '../../components/TotalPaidViewsCard';
import TotalPayingUsersCard from '../../components/TotalPayingUsersCard';
import TotalProfitCard from '../../components/TotalProfitCard';
import TotalRevenueCard from '../../components/TotalRevenueCard';
import DailyActiveUsersChart from '../../components/DailyActiveUsersChart';
import ReferrerTable from '../../components/ReferrerTable';
import DefaultLayout from '../../layout/DefaultLayout';
import { useQuery } from '@wasp/queries';
import getDailyStats from '@wasp/queries/getDailyStats';
const ECommerce = () => {
const { data: stats, isLoading, error } = useQuery(getDailyStats);
return (
<DefaultLayout>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 xl:grid-cols-4 2xl:gap-7.5">
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 xl:grid-cols-4 2xl:gap-7.5'>
<TotalPageViewsCard />
<TotalProfitCard />
<TotalPayingUsersCard />
<TotalSignupsCard />
<TotalRevenueCard dailyStats={stats?.dailyStats} weeklyStats={stats?.weeklyStats} isLoading={isLoading}/>
<TotalPayingUsersCard dailyStats={stats?.dailyStats} isLoading={isLoading} />
<TotalSignupsCard dailyStats={stats?.dailyStats} isLoading={isLoading} />
</div>
<div className="mt-4 grid grid-cols-12 gap-4 md:mt-6 md:gap-6 2xl:mt-7.5 2xl:gap-7.5">
<DailyActiveUsersChart />
{/* <ChartThree /> */}
<div className='mt-4 grid grid-cols-12 gap-4 md:mt-6 md:gap-6 2xl:mt-7.5 2xl:gap-7.5'>
<DailyActiveUsersChart weeklyStats={stats?.weeklyStats} isLoading={isLoading} />
<div className="col-span-12 xl:col-span-8">
<div className='col-span-12 xl:col-span-8'>
<ReferrerTable />
</div>
</div>

View File

@ -161,3 +161,4 @@ export const updateUser: UpdateUser<Partial<User>, User> = async (user, context)
data: user
});
}

View File

@ -1,6 +1,11 @@
import HttpError from '@wasp/core/HttpError.js';
import type { RelatedObject } from '@wasp/entities';
import type { GetRelatedObjects } from '@wasp/queries/types';
import type { DailyStats, RelatedObject } from '@wasp/entities';
import type { GetRelatedObjects, GetDailyStats } from '@wasp/queries/types';
type DailyStatsValues = {
dailyStats: DailyStats;
weeklyStats: DailyStats[];
};
export const getRelatedObjects: GetRelatedObjects<void, RelatedObject[]> = async (args, context) => {
if (!context.user) {
@ -9,8 +14,28 @@ export const getRelatedObjects: GetRelatedObjects<void, RelatedObject[]> = async
return context.entities.RelatedObject.findMany({
where: {
user: {
id: context.user.id
}
id: context.user.id,
},
},
})
});
};
export const getDailyStats: GetDailyStats<void, DailyStatsValues> = async (_args, context) => {
if (!context.user?.isAdmin) {
throw new HttpError(401);
}
const dailyStats = await context.entities.DailyStats.findFirstOrThrow({
orderBy: {
date: 'desc',
},
});
const weeklyStats = await context.entities.DailyStats.findMany({
orderBy: {
date: 'desc',
},
take: 7,
});
return {dailyStats, weeklyStats};
}

View File

@ -19,8 +19,9 @@ export const STRIPE_WEBHOOK_IPS = [
'54.187.216.72',
];
// make sure the api version matches the version in the Stripe dashboard
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15',
apiVersion: '2022-11-15', // TODO find out where this is in the Stripe dashboard and document
});
export const stripeWebhook: StripeWebhook = async (request, response, context) => {
@ -132,25 +133,37 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
if (subscription.cancel_at_period_end) {
console.log('Subscription canceled at period end');
const customer = await context.entities.User.findFirst({
let customer = await context.entities.User.findFirst({
where: {
stripeId: userStripeId,
},
select: {
id: true,
email: true,
},
});
if (customer?.email) {
await emailSender.send({
to: customer.email,
subject: 'We hate to see you go :(',
text: 'We hate to see you go. Here is a sweet offer...',
html: 'We hate to see you go. Here is a sweet offer...',
if (customer) {
await context.entities.User.update({
where: {
id: customer.id,
},
data: {
subscriptionStatus: 'canceled',
},
});
if (customer.email) {
await emailSender.send({
to: customer.email,
subject: 'We hate to see you go :(',
text: 'We hate to see you go. Here is a sweet offer...',
html: 'We hate to see you go. Here is a sweet offer...',
});
}
}
}
} else if (event.type === 'customer.subscription.deleted' || event.type === 'customer.subscription.canceled') {
} else if (event.type === 'customer.subscription.deleted') {
const subscription = event.data.object as Stripe.Subscription;
userStripeId = subscription.customer as string;
@ -165,6 +178,7 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
},
data: {
hasPaid: false,
subscriptionStatus: 'deleted',
},
});
} else {

View File

@ -0,0 +1,125 @@
import type { DailyStats } from '@wasp/jobs/dailyStats';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15', // TODO find out where this is in the Stripe dashboard and document
});
export const calculateDailyStats: DailyStats<never, void> = async (_args, context) => {
const currentDate = new Date();
const yesterdaysDate = new Date(new Date().setDate(currentDate.getDate() - 1));
try {
const yesterdaysStats = await context.entities.DailyStats.findFirst({
where: {
date: {
equals: yesterdaysDate,
},
},
});
const userCount = await context.entities.User.count({});
// users can have paid but canceled subscriptions which terminate at the end of the period
// we don't want to count those users as current paying users
const paidUserCount = await context.entities.User.count({
where: {
hasPaid: true,
subscriptionStatus: 'active',
},
});
let userDelta = userCount;
let paidUserDelta = paidUserCount;
if (yesterdaysStats) {
userDelta -= yesterdaysStats.userCount;
paidUserDelta -= yesterdaysStats.paidUserCount;
}
const newRunningTotal = await calculateTotalRevenue(context);
await context.entities.DailyStats.upsert({
where: {
date: currentDate,
},
create: {
date: currentDate,
userCount,
paidUserCount,
userDelta,
paidUserDelta,
totalRevenue: newRunningTotal,
},
update: {
userCount,
paidUserCount,
userDelta,
paidUserDelta,
totalRevenue: newRunningTotal,
},
});
} catch (error) {
console.error('Error calculating daily stats: ', error);
}
};
async function fetchDailyStripeRevenue() {
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0); // Sets to beginning of day
const startOfDayTimestamp = Math.floor(startOfDay.getTime() / 1000); // Convert to Unix timestamp in seconds
const endOfDay = new Date();
endOfDay.setHours(23, 59, 59, 999); // Sets to end of day
const endOfDayTimestamp = Math.floor(endOfDay.getTime() / 1000); // Convert to Unix timestamp in seconds
let nextPageCursor = undefined;
const allPayments = [] as Stripe.Invoice[];
while (true) {
const params = {
query: `created>=${startOfDayTimestamp} AND created<=${endOfDayTimestamp} AND status:"paid"`,
limit: 100,
page: nextPageCursor,
};
const payments = await stripe.invoices.search(params);
if (payments.next_page) {
nextPageCursor = payments.next_page;
}
console.log('\n\nstripe invoice payments: ', payments, '\n\n');
payments.data.forEach((invoice) => allPayments.push(invoice));
if (!payments.has_more) {
break;
}
}
const dailyTotalInCents = allPayments.reduce((total, invoice) => {
return total + invoice.amount_paid;
}, 0);
return dailyTotalInCents;
}
async function calculateTotalRevenue(context: any) {
const revenueInCents = await fetchDailyStripeRevenue();
const revenueInDollars = revenueInCents / 100;
const lastTotalEntry = await context.entities.DailyStats.find({
where: {
// date is yesterday
date: {
equals: new Date(new Date().setDate(new Date().getDate() - 1)),
},
},
});
let newRunningTotal = revenueInDollars;
if (lastTotalEntry) {
newRunningTotal += lastTotalEntry.totalRevenue;
}
return newRunningTotal;
}