referrer refactor and add sources stats

This commit is contained in:
vincanger 2023-11-27 17:26:24 +01:00
parent eb940e71b8
commit c894ed102c
29 changed files with 329 additions and 372 deletions

View File

@ -7,6 +7,10 @@ export default defineConfig({
integrations: [
starlightBlog({
title: 'The Best Blog Ever',
sidebar: [
{
}
],
authors: {
vince: {
name: 'Vince',

View File

@ -30,9 +30,7 @@ entity User {=psl
credits Int @default(3)
relatedObject RelatedObject[]
externalAuthAssociations SocialLogin[]
contactFormMessages ContactFormMessage[]
referrer Referrer? @relation(fields: [referrerId], references: [id])
referrerId Int?
contactFormMessages ContactFormMessage[]
psl=}
```

View File

@ -102,9 +102,7 @@ entity User {=psl
credits Int @default(3)
relatedObject RelatedObject[]
externalAuthAssociations SocialLogin[]
contactFormMessages ContactFormMessage[]
referrer Referrer? @relation(fields: [referrerId], references: [id])
referrerId Int?
contactFormMessages ContactFormMessage[]
psl=}
entity SocialLogin {=psl
@ -138,21 +136,26 @@ entity ContactFormMessage {=psl
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 Float @default(0)
totalProfit Float @default(0)
id Int @id @default(autoincrement())
date DateTime @default(now()) @unique
totalViews Int @default(0)
prevDayViewsChangePercent String @default("0")
userCount Int @default(0)
paidUserCount Int @default(0)
userDelta Int @default(0)
paidUserDelta Int @default(0)
totalRevenue Float @default(0)
totalProfit Float @default(0)
sources PageViewSource[]
psl=}
entity Referrer {=psl
id Int @id @default(autoincrement())
name String @default("unknown") @unique
count Int @default(0)
users User[]
entity PageViewSource {=psl
date DateTime @default(now())
name String @unique
visitors Int
dailyStats DailyStats? @relation(fields: [dailyStatsId], references: [id])
dailyStatsId Int?
@@id([date, name])
psl=}
entity Logs {=psl
@ -298,16 +301,6 @@ action updateUserById {
entities: [User]
}
action saveReferrer {
fn: import { saveReferrer } from "@server/actions.js",
entities: [Referrer]
}
action UpdateUserReferrer {
fn: import { updateUserReferrer } from "@server/actions.js",
entities: [User, Referrer]
}
// 📚 Queries
query getRelatedObjects {
@ -320,21 +313,11 @@ query getDailyStats {
entities: [User, DailyStats]
}
query getReferrerStats {
fn: import { getReferrerStats } from "@server/queries.js",
entities: [User, Referrer]
}
query getPaginatedUsers {
fn: import { getPaginatedUsers } from "@server/queries.js",
entities: [User]
}
query getPlausibleStats {
fn: import { getPlausibleStats } from "@server/queries.js",
entities: [User, Logs]
}
/*
* 📡 These are custom Wasp API Endpoints. Use them for callbacks, webhooks, etc.
* https://wasp-lang.dev/docs/advanced/apis
@ -369,8 +352,8 @@ job dailyStats {
},
schedule: {
// every hour
cron: "0 * * * *"
// cron: "* * * * *"
// cron: "0 * * * *"
cron: "* * * * *"
},
entities: [User, DailyStats, Logs]
entities: [User, DailyStats, Logs, PageViewSource]
}

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "DailyStats" ADD COLUMN "dailyPageViewsDelta" DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN "totalPageViews" INTEGER NOT NULL DEFAULT 0;

View File

@ -0,0 +1,9 @@
/*
Warnings:
- You are about to alter the column `dailyPageViewsDelta` on the `DailyStats` table. The data in that column could be lost. The data in that column will be cast from `DoublePrecision` to `Integer`.
*/
-- AlterTable
ALTER TABLE "DailyStats" ALTER COLUMN "dailyPageViewsDelta" SET DEFAULT 0,
ALTER COLUMN "dailyPageViewsDelta" SET DATA TYPE INTEGER;

View File

@ -0,0 +1,12 @@
/*
Warnings:
- You are about to drop the column `dailyPageViewsDelta` on the `DailyStats` table. All the data in the column will be lost.
- You are about to drop the column `totalPageViews` on the `DailyStats` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "DailyStats" DROP COLUMN "dailyPageViewsDelta",
DROP COLUMN "totalPageViews",
ADD COLUMN "prevDayViewsChangePercent" TEXT NOT NULL DEFAULT '0',
ADD COLUMN "totalViews" INTEGER NOT NULL DEFAULT 0;

View File

@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "PageViewSource" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"visitors" INTEGER NOT NULL,
"dailyStatsId" INTEGER,
CONSTRAINT "PageViewSource_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PageViewSource_name_key" ON "PageViewSource"("name");
-- AddForeignKey
ALTER TABLE "PageViewSource" ADD CONSTRAINT "PageViewSource_dailyStatsId_fkey" FOREIGN KEY ("dailyStatsId") REFERENCES "DailyStats"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "PageViewSource" ADD COLUMN "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `date` on the `PageViewSource` table. All the data in the column will be lost.
- Added the required column `dateUTC` to the `PageViewSource` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "PageViewSource" DROP COLUMN "date",
ADD COLUMN "dateUTC" TEXT NOT NULL;

View File

@ -0,0 +1,9 @@
/*
Warnings:
- You are about to drop the column `dateUTC` on the `PageViewSource` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "PageViewSource" DROP COLUMN "dateUTC",
ADD COLUMN "dat" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `dat` on the `PageViewSource` table. All the data in the column will be lost.
- A unique constraint covering the columns `[date]` on the table `PageViewSource` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "PageViewSource" DROP COLUMN "dat",
ADD COLUMN "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- CreateIndex
CREATE UNIQUE INDEX "PageViewSource_date_key" ON "PageViewSource"("date");

View File

@ -0,0 +1,11 @@
/*
Warnings:
- The primary key for the `PageViewSource` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `id` on the `PageViewSource` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "PageViewSource" DROP CONSTRAINT "PageViewSource_pkey",
DROP COLUMN "id",
ADD CONSTRAINT "PageViewSource_pkey" PRIMARY KEY ("date", "name");

View File

@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "PageViewSource_date_key";

View File

@ -0,0 +1,15 @@
/*
Warnings:
- You are about to drop the column `referrerId` on the `User` table. All the data in the column will be lost.
- You are about to drop the `Referrer` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "User" DROP CONSTRAINT "User_referrerId_fkey";
-- AlterTable
ALTER TABLE "User" DROP COLUMN "referrerId";
-- DropTable
DROP TABLE "Referrer";

View File

@ -2,11 +2,8 @@ import './Main.css';
import AppNavBar from './components/AppNavBar';
import { useMemo, useEffect, ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
import { useReferrer, UNKOWN_REFERRER } from './hooks/useReferrer';
import useAuth from '@wasp/auth/useAuth';
import updateCurrentUser from '@wasp/actions/updateCurrentUser'; // TODO fix
import updateUserReferrer from '@wasp/actions/UpdateUserReferrer';
import saveReferrer from '@wasp/actions/saveReferrer';
/**
* use this component to wrap all child components
@ -15,7 +12,6 @@ import saveReferrer from '@wasp/actions/saveReferrer';
export default function App({ children }: { children: ReactNode }) {
const location = useLocation();
const { data: user } = useAuth();
const [referrer, setReferrer] = useReferrer();
const shouldDisplayAppNavBar = useMemo(() => {
return location.pathname !== '/' && location.pathname !== '/login' && location.pathname !== '/signup';
@ -34,26 +30,6 @@ export default function App({ children }: { children: ReactNode }) {
}
}, [user]);
useEffect(() => {
if (referrer && referrer.ref !== UNKOWN_REFERRER && !referrer.isSavedInDB) {
saveReferrer({ name: referrer.ref });
setReferrer({
...referrer,
isSavedInDB: true,
});
}
}, [referrer]);
useEffect(() => {
if (user && referrer && !referrer.isSavedToUser && referrer.ref !== UNKOWN_REFERRER) {
updateUserReferrer({ name: referrer.ref });
setReferrer({
...referrer,
isSavedToUser: true,
});
}
}, [user, referrer]);
useEffect(() => {
if (location.hash) {
const id = location.hash.replace('#', '');

View File

@ -1,57 +0,0 @@
import { useQuery } from '@wasp/queries';
import getReferrerStats from '@wasp/queries/getReferrerStats';
// We're using a simple, in-house analytics system that tracks referrers and page views.
// You could instead set up Google Analytics or Plausible and use their API for more detailed stats.
const ReferrerTable = () => {
const { data: referrers, isLoading: isReferrersLoading, error: referrersError } = useQuery(getReferrerStats);
return (
<div className='rounded-sm border border-stroke bg-white px-5 pt-6 pb-2.5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1'>
<h4 className='mb-6 text-xl font-semibold text-black dark:text-white'>Top Referrers</h4>
<div className='flex flex-col'>
<div className='grid grid-cols-3 rounded-sm bg-gray-2 dark:bg-meta-4 sm:grid-cols-4'>
<div className='p-2.5 xl:p-5'>
<h5 className='text-sm font-medium uppercase xsm:text-base'>Source</h5>
</div>
<div className='p-2.5 text-center xl:p-5'>
<h5 className='text-sm font-medium uppercase xsm:text-base'>Visitors</h5>
</div>
<div className='p-2.5 text-center xl:p-5'>
<h5 className='text-sm font-medium uppercase xsm:text-base'>Conversion</h5>
<span className='text-xs font-normal text-gray-600 whitespace-nowrap'>% of visitors that register</span>
</div>
<div className='hidden p-2.5 text-center sm:block xl:p-5'>
<h5 className='text-sm font-medium uppercase xsm:text-base'>Sales</h5>
</div>
</div>
{referrers &&
referrers.length > 0 &&
referrers.map((ref) => (
<div className='grid grid-cols-3 border-b border-stroke dark:border-strokedark sm:grid-cols-4'>
<div className='flex items-center gap-3 p-2.5 xl:p-5'>
<p className='text-black dark:text-white'>{ref.name}</p>
</div>
<div className='flex items-center justify-center p-2.5 xl:p-5'>
<p className='text-black dark:text-white'>{ref.count}</p>
</div>
<div className='flex items-center justify-center p-2.5 xl:p-5'>
<p className='text-meta-3'>{ref.users.length > 0 ? Math.round((ref.users.length / ref.count)*100) : '0'}%</p>
</div>
<div className='hidden items-center justify-center p-2.5 sm:flex xl:p-5'>
<p className='text-black dark:text-white'>--</p>
</div>
</div>
))}
</div>
</div>
);
};
export default ReferrerTable;

View File

@ -11,7 +11,7 @@ const options: ApexOptions = {
},
colors: ['#3C50E0', '#80CAEE'],
chart: {
fontFamily: 'Satoshi, sans-serif',
fontFamily: 'Satoshi, sans-serif',
height: 335,
type: 'area',
dropShadow: {
@ -175,18 +175,22 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
}, [dailyRevenueArray]);
useEffect(() => {
console.log('ooptions categories: ', options?.xaxis?.categories);
console.log('days of week arr: ', daysOfWeekArr);
if (!!daysOfWeekArr && daysOfWeekArr?.length > 0) {
if (!!daysOfWeekArr && daysOfWeekArr?.length > 0 && !!dailyRevenueArray && dailyRevenueArray?.length > 0) {
setChartOptions({
...options,
xaxis: {
...options.xaxis,
categories: daysOfWeekArr,
},
yaxis: {
...options.yaxis,
// get the min & max values to the neareast hundred
max: Math.ceil(Math.max(...dailyRevenueArray) / 100) * 100,
min: Math.floor(Math.min(...dailyRevenueArray) / 100) * 100,
},
});
}
}, [daysOfWeekArr]);
}, [daysOfWeekArr, dailyRevenueArray]);
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'>

View File

@ -0,0 +1,49 @@
import { PageViewSource } from "@wasp/entities";
const SourcesTable = ({ sources } : { sources: PageViewSource[] | undefined }) => {
return (
<div className='rounded-sm border border-stroke bg-white px-5 pt-6 pb-2.5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1'>
<h4 className='mb-6 text-xl font-semibold text-black dark:text-white'>Top Sources</h4>
<div className='flex flex-col'>
<div className='grid grid-cols-3 rounded-sm bg-gray-2 dark:bg-meta-4 '>
<div className='p-2.5 xl:p-5'>
<h5 className='text-sm font-medium uppercase xsm:text-base'>Source</h5>
</div>
<div className='p-2.5 text-center xl:p-5'>
<h5 className='text-sm font-medium uppercase xsm:text-base'>Visitors</h5>
</div>
<div className='hidden p-2.5 text-center sm:block xl:p-5'>
<h5 className='text-sm font-medium uppercase xsm:text-base'>Sales</h5>
</div>
</div>
{sources &&
sources.length > 0 ?
sources.map((source) => (
<div className='grid grid-cols-3 border-b border-stroke dark:border-strokedark'>
<div className='flex items-center gap-3 p-2.5 xl:p-5'>
<p className='text-black dark:text-white'>{source.name}</p>
</div>
<div className='flex items-center justify-center p-2.5 xl:p-5'>
<p className='text-black dark:text-white'>{source.visitors}</p>
</div>
<div className='hidden items-center justify-center p-2.5 sm:flex xl:p-5'>
<p className='text-black dark:text-white'>--</p>
</div>
</div>
)) : (
<div className='flex items-center justify-center p-2.5 xl:p-5'>
<p className='text-black dark:text-white'>No data to display</p>
</div>
)}
</div>
</div>
);
};
export default SourcesTable;

View File

@ -19,7 +19,7 @@ const SwitcherOne = ({ user, updateUserById}: { user?: Partial<User>, updateUser
/>
<div className='reblock h-8 w-14 rounded-full bg-meta-9 dark:bg-[#5A616B]'></div>
<div
className={`absolute left-1 top-1 h-6 w-6 rounded-full bg-white transition ${
className={`absolute left-1 top-1 h-6 w-6 rounded-full bg-white dark:bg-gray-400 transition ${
enabled && '!right-1 !translate-x-full !bg-primary dark:!bg-white'
}`}
></div>

View File

@ -1,13 +1,13 @@
import { UpArrow, DownArrow } from "../images/icon/icons-arrows";
type PageViewsStats = {
totalPageViews: string | undefined;
dailyChangePercentage: string | undefined;
totalPageViews: number | undefined;
prevDayViewsChangePercent: string | undefined;
}
const TotalPageViewsCard = ({ totalPageViews, dailyChangePercentage } : PageViewsStats ) => {
const TotalPageViewsCard = ({ totalPageViews, prevDayViewsChangePercent } : PageViewsStats ) => {
const isDeltaPositive = parseInt(dailyChangePercentage || '') > 0;
const isDeltaPositive = parseInt(prevDayViewsChangePercent || '') > 0;
return (
<div className='rounded-sm border border-stroke bg-white py-6 px-7.5 shadow-default dark:border-strokedark dark:bg-boxdark'>
@ -37,11 +37,11 @@ const TotalPageViewsCard = ({ totalPageViews, dailyChangePercentage } : PageView
<span className='text-sm font-medium'>Total page views</span>
</div>
{dailyChangePercentage && parseInt(dailyChangePercentage) !== 0 && (
{prevDayViewsChangePercent && parseInt(prevDayViewsChangePercent) !== 0 && (
<span
className={`flex items-center gap-1 text-sm font-medium ${isDeltaPositive ? 'text-meta-3' : 'text-meta-5'}`}
>
{dailyChangePercentage}%{parseInt(dailyChangePercentage) > 0 ? <UpArrow /> : <DownArrow />}
{prevDayViewsChangePercent}%{parseInt(prevDayViewsChangePercent) > 0 ? <UpArrow /> : <DownArrow />}
</span>
)}
</div>

View File

@ -9,18 +9,15 @@ const TotalRevenueCard = ({dailyStats, weeklyStats, isLoading}: DailyStatsProps)
}, [weeklyStats]);
const deltaPercentage = useMemo(() => {
if ( !weeklyStats || isLoading) return;
if ( !weeklyStats || weeklyStats.length < 2 || isLoading) return;
if ( weeklyStats[1]?.totalRevenue === 0 || weeklyStats[0]?.totalRevenue === 0 ) return 0;
weeklyStats.sort((a, b) => b.id - a.id);
console.log('weeklyStats[1]?.totalRevenue; ', !!weeklyStats && weeklyStats)
const percentage = ((weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) / weeklyStats[1]?.totalRevenue) * 100;
return Math.floor(percentage);
}, [weeklyStats]);
useEffect(() => {
console.log('deltaPercentage; ', deltaPercentage)
console.log('weeklyStats; ', weeklyStats)
}, [deltaPercentage])
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'>

View File

@ -32,8 +32,8 @@ const UsersTable = () => {
return (
<div className='flex flex-col gap-4'>
<div className='rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark'>
<div className='flex-col flex items-start justify-between p-6 gap-3 w-full '>
<span className='text-sm font-semibold text-gray-700'>Filters:</span>
<div className='flex-col flex items-start justify-between p-6 gap-3 w-full bg-gray-100/40 dark:bg-gray-700/50'>
<span className='text-sm font-medium'>Filters:</span>
<div className='flex items-center justify-between gap-3 w-full px-2'>
<div className='relative flex items-center gap-3 '>
<label htmlFor='email-filter' className='block text-sm text-gray-700 dark:text-white'>
@ -46,18 +46,18 @@ const UsersTable = () => {
onChange={(e) => {
setEmail(e.currentTarget.value);
}}
className='rounded border border-stroke bg-transparent py-2 px-5 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
className='rounded border border-stroke py-2 px-5 bg-white outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
/>
<label htmlFor='status-filter' className='block text-sm ml-2 text-gray-700 dark:text-white'>
status:
</label>
<div className='flex-grow relative z-20 rounded border border-stroke pr-8 outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'>
<div className='flex-grow relative z-20 rounded border border-stroke pr-8 outline-none bg-white transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'>
<div className='flex items-center'>
{!!statusOptions && statusOptions.length > 0 ? (
statusOptions.map((opt, idx) => (
<span
key={opt}
className='z-30 flex items-center bg-transparent my-1 mx-2 py-1 px-2 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
className='z-30 flex items-center my-1 mx-2 py-1 px-2 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
>
{opt}
<span
@ -87,7 +87,7 @@ const UsersTable = () => {
</span>
))
) : (
<span className='bg-transparent text-gray-500 py-2 px-5 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'>
<span className='bg-white text-gray-500 py-2 px-5 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'>
Select Status Filters
</span>
)}
@ -106,7 +106,7 @@ const UsersTable = () => {
}}
name='status-filter'
id='status-filter'
className='absolute top-0 left-0 z-20 h-full w-full bg-transparent opacity-0'
className='absolute top-0 left-0 z-20 h-full w-full bg-white opacity-0'
>
<option value=''>Select filters</option>
{['past_due', 'canceled', 'active'].map((status) => {
@ -141,7 +141,7 @@ const UsersTable = () => {
setHasPaidFilter(e.target.value === 'true');
}
}}
className='relative z-20 w-full appearance-none rounded border border-stroke bg-transparent p-2 pl-4 pr-8 outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'
className='relative z-20 w-full appearance-none rounded border border-stroke bg-white p-2 pl-4 pr-8 outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'
>
<option value='both'>both</option>
<option value='true'>true</option>
@ -162,6 +162,7 @@ const UsersTable = () => {
}}
className='rounded-md border-1 border-stroke bg-transparent px-4 font-medium outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
/>
<span className='text-md text-black dark:text-white'> / {data?.totalPages} </span>
</div>
)}
</div>

View File

@ -3,11 +3,10 @@ import TotalPageViewsCard from '../../components/TotalPaidViewsCard';
import TotalPayingUsersCard from '../../components/TotalPayingUsersCard';
import TotalRevenueCard from '../../components/TotalRevenueCard';
import RevenueAndProfitChart from '../../components/RevenueAndProfitChart';
import ReferrerTable from '../../components/ReferrerTable';
import SourcesTable from '../../components/SourcesTable';
import DefaultLayout from '../../layout/DefaultLayout';
import { useQuery } from '@wasp/queries';
import getDailyStats from '@wasp/queries/getDailyStats';
import getPlausibleStats from '@wasp/queries/getPlausibleStats';
import { useHistory } from 'react-router-dom';
import type { User } from '@wasp/entities';
@ -18,12 +17,14 @@ const ECommerce = ({ user} : { user: User }) => {
}
const { data: stats, isLoading, error } = useQuery(getDailyStats);
const { data: plausibleStats, isLoading: isPlausibleLoading, error: plausibleError } = useQuery(getPlausibleStats);
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'>
<TotalPageViewsCard totalPageViews={plausibleStats?.totalPageViews} dailyChangePercentage={plausibleStats?.dailyChangePercentage} />
<TotalPageViewsCard
totalPageViews={stats?.dailyStats.totalViews}
prevDayViewsChangePercent={stats?.dailyStats.prevDayViewsChangePercent}
/>
<TotalRevenueCard dailyStats={stats?.dailyStats} weeklyStats={stats?.weeklyStats} isLoading={isLoading} />
<TotalPayingUsersCard dailyStats={stats?.dailyStats} isLoading={isLoading} />
<TotalSignupsCard dailyStats={stats?.dailyStats} isLoading={isLoading} />
@ -33,7 +34,7 @@ const ECommerce = ({ user} : { user: User }) => {
<RevenueAndProfitChart weeklyStats={stats?.weeklyStats} isLoading={isLoading} />
<div className='col-span-12 xl:col-span-8'>
<ReferrerTable />
<SourcesTable sources={stats?.dailyStats?.sources} />
</div>
</div>
</DefaultLayout>

View File

@ -1,32 +0,0 @@
import { useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import useLocalStorage from './useLocalStorage';
const REFERRER_KEY = 'ref';
export const UNKOWN_REFERRER = 'unknown';
export function useReferrer() {
const history = useHistory();
const urlParams = new URLSearchParams(window.location.search);
const refValue = urlParams.get(REFERRER_KEY);
const values = {
[REFERRER_KEY]: refValue || UNKOWN_REFERRER,
isSavedInDB: false,
isSavedToUser: false,
}
const [referrer, setReferrer] = useLocalStorage(REFERRER_KEY, values);
useEffect(() => {
console.log('referrer', referrer);
if (!!refValue && refValue !== UNKOWN_REFERRER) {
setReferrer(values);
history.replace({
search: '',
});
}
}, [referrer]);
return [referrer, setReferrer] as const;
}

View File

@ -4,7 +4,7 @@ import HttpError from '@wasp/core/HttpError.js';
import type { RelatedObject, User } from '@wasp/entities';
import type { GenerateGptResponse, StripePayment } from '@wasp/actions/types';
import type { StripePaymentResult, OpenAIResponse } from './types';
import { UpdateCurrentUser, SaveReferrer, UpdateUserReferrer, UpdateUserById } from '@wasp/actions/types';
import { UpdateCurrentUser, UpdateUserById } from '@wasp/actions/types';
import { fetchStripeCustomer, createStripeCheckoutSession } from './stripeUtils.js';
import { TierIds } from '@wasp/shared/const.js';
@ -167,38 +167,3 @@ export const updateCurrentUser: UpdateCurrentUser<Partial<User>, User> = async (
data: user,
});
};
export const saveReferrer: SaveReferrer<{ name: string }, void> = async ({ name }, context) => {
await context.entities.Referrer.upsert({
where: {
name,
},
create: {
name,
count: 1,
},
update: {
count: {
increment: 1,
},
},
});
};
export const updateUserReferrer: UpdateUserReferrer<{ name: string }, void> = async ({ name }, context) => {
if (!context.user) {
throw new HttpError(401);
}
await context.entities.User.update({
where: {
id: context.user.id,
},
data: {
referrer: {
connect: {
name,
},
},
},
});
};

View File

@ -1,17 +1,18 @@
import HttpError from '@wasp/core/HttpError.js';
import { getTotalPageViews, calculateDailyChangePercentage } from './analyticsUtils.js';
import type { DailyStats, RelatedObject, Referrer, User } from '@wasp/entities';
import type { DailyStats, RelatedObject, User, PageViewSource } from '@wasp/entities';
import type {
GetRelatedObjects,
GetDailyStats,
GetReferrerStats,
GetPaginatedUsers,
GetPlausibleStats,
} from '@wasp/queries/types';
type DailyStatsWithSources = DailyStats & {
sources: PageViewSource[];
};
type DailyStatsValues = {
dailyStats: DailyStats;
weeklyStats: DailyStats[];
dailyStats: DailyStatsWithSources;
weeklyStats: DailyStatsWithSources[];
};
export const getRelatedObjects: GetRelatedObjects<void, RelatedObject[]> = async (args, context) => {
@ -35,38 +36,24 @@ export const getDailyStats: GetDailyStats<void, DailyStatsValues> = async (_args
orderBy: {
date: 'desc',
},
include: {
sources: true,
},
});
console.log('dailyStats: ', dailyStats)
const weeklyStats = await context.entities.DailyStats.findMany({
orderBy: {
date: 'desc',
},
take: 7,
});
return { dailyStats, weeklyStats };
};
type ReferrerWithSanitizedUsers = Referrer & {
users: Pick<User, 'id' | 'email' | 'hasPaid' | 'subscriptionStatus'>[];
};
export const getReferrerStats: GetReferrerStats<void, ReferrerWithSanitizedUsers[]> = async (args, context) => {
const referrers = await context.entities.Referrer.findMany({
include: {
users: true,
sources: true,
},
});
return referrers.map((referrer) => ({
...referrer,
users: referrer.users.map((user) => ({
id: user.id,
email: user.email,
hasPaid: user.hasPaid,
subscriptionStatus: user.subscriptionStatus,
})),
}));
return { dailyStats, weeklyStats };
};
type GetPaginatedUsersInput = {
@ -131,22 +118,4 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
users: queryResults,
totalPages,
};
};
// TODO: move this and analyticsUtils to Cron Job
export const getPlausibleStats: GetPlausibleStats<
void,
{ totalPageViews: string | undefined; dailyChangePercentage: string | undefined }
> = async (_args, context) => {
if (!context.user?.isAdmin) {
throw new HttpError(401);
}
const totalPageViews = (await getTotalPageViews()).toString();
const dailyChangePercentage = await calculateDailyChangePercentage();
return {
totalPageViews,
dailyChangePercentage,
};
};
};

View File

@ -1,27 +1,9 @@
import { faker } from '@faker-js/faker';
import type { PrismaClient } from '@prisma/client';
import type { User, Referrer } from '@wasp/entities';
import type { User } from '@wasp/entities';
// in a terminal window run `wasp db seed` to seed your dev database with this data
const referrerArr: Referrer[] = [
{
id: 1,
name: 'product-hunt',
count: 27,
},
{
id: 2,
name: 'twitter',
count: 26,
},
{
id: 3,
name: 'linkedin',
count: 25,
},
];
let prevUserId = 0;
export function createRandomUser(): Partial<User> {
const user: Partial<User> = {
@ -41,7 +23,6 @@ export function createRandomUser(): Partial<User> {
subscriptionStatus: faker.helpers.arrayElement(['active', 'canceled', 'past_due']),
datePaid: faker.date.recent(),
credits: faker.number.int({ min: 0, max: 3 }),
referrerId: faker.number.int({ min: 1, max: 3 }),
};
return user;
}
@ -52,13 +33,6 @@ const USERS: Partial<User>[] = faker.helpers.multiple(createRandomUser, {
export async function devSeedUsers(prismaClient: PrismaClient) {
try {
await Promise.all(
referrerArr.map(async (referrer) => {
await prismaClient.referrer.create({
data: referrer,
});
})
);
await Promise.all(
USERS.map(async (user) => {
await prismaClient.user.create({

View File

@ -15,6 +15,13 @@ type PageViewsResult = {
};
};
type PageViewSourcesResult = {
results: [{
source: string;
visitors: number;
}];
};
export async function getTotalPageViews() {
const response = await fetch(
`${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&metrics=pageviews`,
@ -26,12 +33,12 @@ export async function getTotalPageViews() {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const json = await response.json() as PageViewsResult;
const json = (await response.json()) as PageViewsResult;
return json.results.pageviews.value;
}
export async function calculateDailyChangePercentage() {
export async function getPrevDayViewsChangePercent() {
// Calculate today, yesterday, and the day before yesterday's dates
const today = new Date();
const yesterday = new Date(today.setDate(today.getDate() - 1)).toISOString().split('T')[0];
@ -42,7 +49,12 @@ export async function calculateDailyChangePercentage() {
const pageViewsYesterday = await getPageviewsForDate(yesterday);
const pageViewsDayBeforeYesterday = await getPageviewsForDate(dayBeforeYesterday);
console.table({ pageViewsYesterday, pageViewsDayBeforeYesterday, typeY: typeof pageViewsYesterday, typeDBY: typeof pageViewsDayBeforeYesterday })
console.table({
pageViewsYesterday,
pageViewsDayBeforeYesterday,
typeY: typeof pageViewsYesterday,
typeDBY: typeof pageViewsDayBeforeYesterday,
});
let change = 0;
if (pageViewsYesterday === 0 || pageViewsDayBeforeYesterday === 0) {
@ -52,7 +64,7 @@ export async function calculateDailyChangePercentage() {
}
console.log(`Daily change in page views percentage: ${change.toFixed(2)}%`);
return change.toFixed(2); // Limit the number to two decimal places
return change.toFixed(2);
} catch (error) {
console.error('Error calculating daily change percentage:', error);
}
@ -67,6 +79,19 @@ async function getPageviewsForDate(date: string) {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json() as PageViewsResult;
const data = (await response.json()) as PageViewsResult;
return data.results.pageviews.value;
}
export async function getSources() {
const url = `${PLAUSIBLE_BASE_URL}/v1/stats/breakdown?site_id=${PLAUSIBLE_SITE_ID}&property=visit:source&metrics=visitors`;
const response = await fetch(url, {
method: 'GET',
headers: headers,
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json() as PageViewSourcesResult;
return data.results;
}

View File

@ -1,5 +1,6 @@
import type { DailyStats } from '@wasp/jobs/dailyStats';
import Stripe from 'stripe';
import { getTotalPageViews, getPrevDayViewsChangePercent, getSources } from './analyticsUtils.js';
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15', // TODO find out where this is in the Stripe dashboard and document
@ -40,8 +41,9 @@ export const calculateDailyStats: DailyStats<never, void> = async (_args, contex
userDelta -= yesterdaysStats.userCount;
paidUserDelta -= yesterdaysStats.paidUserCount;
}
const newRunningTotal = await calculateTotalRevenue(context);
const totalRevenue = await fetchTotalStripeRevenue();
const { totalViews, prevDayViewsChangePercent } = await getDailyPageviews();
const newDailyStat = await context.entities.DailyStats.upsert({
where: {
@ -49,21 +51,47 @@ export const calculateDailyStats: DailyStats<never, void> = async (_args, contex
},
create: {
date: nowUTC,
totalViews,
prevDayViewsChangePercent: prevDayViewsChangePercent || '0',
userCount,
paidUserCount,
userDelta,
paidUserDelta,
totalRevenue: newRunningTotal,
totalRevenue,
},
update: {
totalViews,
prevDayViewsChangePercent: prevDayViewsChangePercent || '0' ,
userCount,
paidUserCount,
userDelta,
paidUserDelta,
totalRevenue: newRunningTotal,
totalRevenue,
},
});
const sources = await getSources();
for (const source of sources) {
await context.entities.PageViewSource.upsert({
where: {
date_name: {
date: nowUTC,
name: source.source,
},
},
create: {
date: nowUTC,
name: source.source,
visitors: source.visitors,
dailyStatsId: newDailyStat.id,
},
update: {
visitors: source.visitors,
},
});
}
console.table({ newDailyStat })
} catch (error: any) {
@ -77,75 +105,46 @@ export const calculateDailyStats: DailyStats<never, void> = async (_args, contex
}
};
async function fetchDailyStripeRevenue() {
const startOfDayUTC = new Date(Date.now());
startOfDayUTC.setHours(0, 0, 0, 0); // Sets to beginning of day
const startOfDayTimestamp = Math.floor(startOfDayUTC.getTime() / 1000); // Convert to Unix timestamp in seconds
async function fetchTotalStripeRevenue() {
let totalRevenue = 0;
let params: Stripe.BalanceTransactionListParams = {
limit: 100,
// created: {
// gte: startTimestamp,
// lt: endTimestamp
// },
type: 'charge',
};
const endOfDayUTC = new Date();
endOfDayUTC.setHours(23, 59, 59, 999); // Sets to end of day
const endOfDayTimestamp = Math.floor(endOfDayUTC.getTime() / 1000); // Convert to Unix timestamp in seconds
let hasMore = true;
while (hasMore) {
const balanceTransactions = await stripe.balanceTransactions.list(params);
let nextPageCursor = undefined;
const allPayments = [] as Stripe.Invoice[];
for (const transaction of balanceTransactions.data) {
if (transaction.type === 'charge') {
totalRevenue += transaction.amount;
}
}
while (true) {
// Stripe allows searching for invoices by date range via their Query Language
// If there are more than 100 invoices in a day, we need to paginate through them
const params = {
query: `created>=${startOfDayTimestamp} AND created<=${endOfDayTimestamp} AND status:"paid"`,
limit: 100,
page: nextPageCursor,
if (balanceTransactions.has_more) {
// Set the starting point for the next iteration to the last object fetched
params.starting_after = balanceTransactions.data[balanceTransactions.data.length - 1].id;
} else {
hasMore = false;
}
}
// Revenue is in cents so we convert to dollars (or your main currency unit)
const formattedRevenue = (totalRevenue / 100)
return formattedRevenue;
}
async function getDailyPageviews() {
const totalViews = await getTotalPageViews()
const prevDayViewsChangePercent = await getPrevDayViewsChangePercent();
return {
totalViews,
prevDayViewsChangePercent,
};
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;
// we use UTC time to avoid issues with local timezones
const nowUTC = new Date(Date.now());
// Set the time component to midnight in UTC
// This way we can pass the Date object directly to Prisma
// without having to convert it to a string
nowUTC.setUTCHours(0, 0, 0, 0);
// Get yesterday's date by subtracting one day
const yesterdayUTC = new Date(nowUTC);
yesterdayUTC.setUTCDate(yesterdayUTC.getUTCDate() - 1);
const lastTotalEntry = await context.entities.DailyStats.findUnique({
where: {
date: yesterdayUTC, // Pass the Date object directly, not as a string
},
});
let newRunningTotal = revenueInDollars;
if (lastTotalEntry) {
newRunningTotal += lastTotalEntry.totalRevenue;
}
return newRunningTotal;
}
}