mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-06-11 01:10:56 +02:00
referrer refactor and add sources stats
This commit is contained in:
parent
eb940e71b8
commit
c894ed102c
@ -7,6 +7,10 @@ export default defineConfig({
|
|||||||
integrations: [
|
integrations: [
|
||||||
starlightBlog({
|
starlightBlog({
|
||||||
title: 'The Best Blog Ever',
|
title: 'The Best Blog Ever',
|
||||||
|
sidebar: [
|
||||||
|
{
|
||||||
|
}
|
||||||
|
],
|
||||||
authors: {
|
authors: {
|
||||||
vince: {
|
vince: {
|
||||||
name: 'Vince',
|
name: 'Vince',
|
||||||
|
@ -31,8 +31,6 @@ entity User {=psl
|
|||||||
relatedObject RelatedObject[]
|
relatedObject RelatedObject[]
|
||||||
externalAuthAssociations SocialLogin[]
|
externalAuthAssociations SocialLogin[]
|
||||||
contactFormMessages ContactFormMessage[]
|
contactFormMessages ContactFormMessage[]
|
||||||
referrer Referrer? @relation(fields: [referrerId], references: [id])
|
|
||||||
referrerId Int?
|
|
||||||
psl=}
|
psl=}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
59
main.wasp
59
main.wasp
@ -103,8 +103,6 @@ entity User {=psl
|
|||||||
relatedObject RelatedObject[]
|
relatedObject RelatedObject[]
|
||||||
externalAuthAssociations SocialLogin[]
|
externalAuthAssociations SocialLogin[]
|
||||||
contactFormMessages ContactFormMessage[]
|
contactFormMessages ContactFormMessage[]
|
||||||
referrer Referrer? @relation(fields: [referrerId], references: [id])
|
|
||||||
referrerId Int?
|
|
||||||
psl=}
|
psl=}
|
||||||
|
|
||||||
entity SocialLogin {=psl
|
entity SocialLogin {=psl
|
||||||
@ -138,21 +136,26 @@ entity ContactFormMessage {=psl
|
|||||||
psl=}
|
psl=}
|
||||||
|
|
||||||
entity DailyStats {=psl
|
entity DailyStats {=psl
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
date DateTime @default(now()) @unique
|
date DateTime @default(now()) @unique
|
||||||
userCount Int @default(0)
|
totalViews Int @default(0)
|
||||||
paidUserCount Int @default(0)
|
prevDayViewsChangePercent String @default("0")
|
||||||
userDelta Int @default(0)
|
userCount Int @default(0)
|
||||||
paidUserDelta Int @default(0)
|
paidUserCount Int @default(0)
|
||||||
totalRevenue Float @default(0)
|
userDelta Int @default(0)
|
||||||
totalProfit Float @default(0)
|
paidUserDelta Int @default(0)
|
||||||
|
totalRevenue Float @default(0)
|
||||||
|
totalProfit Float @default(0)
|
||||||
|
sources PageViewSource[]
|
||||||
psl=}
|
psl=}
|
||||||
|
|
||||||
entity Referrer {=psl
|
entity PageViewSource {=psl
|
||||||
id Int @id @default(autoincrement())
|
date DateTime @default(now())
|
||||||
name String @default("unknown") @unique
|
name String @unique
|
||||||
count Int @default(0)
|
visitors Int
|
||||||
users User[]
|
dailyStats DailyStats? @relation(fields: [dailyStatsId], references: [id])
|
||||||
|
dailyStatsId Int?
|
||||||
|
@@id([date, name])
|
||||||
psl=}
|
psl=}
|
||||||
|
|
||||||
entity Logs {=psl
|
entity Logs {=psl
|
||||||
@ -298,16 +301,6 @@ action updateUserById {
|
|||||||
entities: [User]
|
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
|
// 📚 Queries
|
||||||
|
|
||||||
query getRelatedObjects {
|
query getRelatedObjects {
|
||||||
@ -320,21 +313,11 @@ query getDailyStats {
|
|||||||
entities: [User, DailyStats]
|
entities: [User, DailyStats]
|
||||||
}
|
}
|
||||||
|
|
||||||
query getReferrerStats {
|
|
||||||
fn: import { getReferrerStats } from "@server/queries.js",
|
|
||||||
entities: [User, Referrer]
|
|
||||||
}
|
|
||||||
|
|
||||||
query getPaginatedUsers {
|
query getPaginatedUsers {
|
||||||
fn: import { getPaginatedUsers } from "@server/queries.js",
|
fn: import { getPaginatedUsers } from "@server/queries.js",
|
||||||
entities: [User]
|
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.
|
* 📡 These are custom Wasp API Endpoints. Use them for callbacks, webhooks, etc.
|
||||||
* https://wasp-lang.dev/docs/advanced/apis
|
* https://wasp-lang.dev/docs/advanced/apis
|
||||||
@ -369,8 +352,8 @@ job dailyStats {
|
|||||||
},
|
},
|
||||||
schedule: {
|
schedule: {
|
||||||
// every hour
|
// every hour
|
||||||
cron: "0 * * * *"
|
// cron: "0 * * * *"
|
||||||
// cron: "* * * * *"
|
cron: "* * * * *"
|
||||||
},
|
},
|
||||||
entities: [User, DailyStats, Logs]
|
entities: [User, DailyStats, Logs, PageViewSource]
|
||||||
}
|
}
|
||||||
|
3
migrations/20231127110538_daily_page_views/migration.sql
Normal file
3
migrations/20231127110538_daily_page_views/migration.sql
Normal 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;
|
@ -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;
|
@ -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;
|
15
migrations/20231127143949_page_view_source/migration.sql
Normal file
15
migrations/20231127143949_page_view_source/migration.sql
Normal 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;
|
2
migrations/20231127144953_page_view_source/migration.sql
Normal file
2
migrations/20231127144953_page_view_source/migration.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PageViewSource" ADD COLUMN "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
10
migrations/20231127145135_date_utc/migration.sql
Normal file
10
migrations/20231127145135_date_utc/migration.sql
Normal 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;
|
9
migrations/20231127145351_date/migration.sql
Normal file
9
migrations/20231127145351_date/migration.sql
Normal 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;
|
13
migrations/20231127145440_unique_date/migration.sql
Normal file
13
migrations/20231127145440_unique_date/migration.sql
Normal 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");
|
11
migrations/20231127151321_composite_key/migration.sql
Normal file
11
migrations/20231127151321_composite_key/migration.sql
Normal 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");
|
2
migrations/20231127151444_f/migration.sql
Normal file
2
migrations/20231127151444_f/migration.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "PageViewSource_date_key";
|
15
migrations/20231127161704_/migration.sql
Normal file
15
migrations/20231127161704_/migration.sql
Normal 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";
|
@ -2,11 +2,8 @@ import './Main.css';
|
|||||||
import AppNavBar from './components/AppNavBar';
|
import AppNavBar from './components/AppNavBar';
|
||||||
import { useMemo, useEffect, ReactNode } from 'react';
|
import { useMemo, useEffect, ReactNode } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useReferrer, UNKOWN_REFERRER } from './hooks/useReferrer';
|
|
||||||
import useAuth from '@wasp/auth/useAuth';
|
import useAuth from '@wasp/auth/useAuth';
|
||||||
import updateCurrentUser from '@wasp/actions/updateCurrentUser'; // TODO fix
|
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
|
* 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 }) {
|
export default function App({ children }: { children: ReactNode }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { data: user } = useAuth();
|
const { data: user } = useAuth();
|
||||||
const [referrer, setReferrer] = useReferrer();
|
|
||||||
|
|
||||||
const shouldDisplayAppNavBar = useMemo(() => {
|
const shouldDisplayAppNavBar = useMemo(() => {
|
||||||
return location.pathname !== '/' && location.pathname !== '/login' && location.pathname !== '/signup';
|
return location.pathname !== '/' && location.pathname !== '/login' && location.pathname !== '/signup';
|
||||||
@ -34,26 +30,6 @@ export default function App({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [user]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (location.hash) {
|
if (location.hash) {
|
||||||
const id = location.hash.replace('#', '');
|
const id = location.hash.replace('#', '');
|
||||||
|
@ -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;
|
|
@ -175,18 +175,22 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
|||||||
}, [dailyRevenueArray]);
|
}, [dailyRevenueArray]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('ooptions categories: ', options?.xaxis?.categories);
|
if (!!daysOfWeekArr && daysOfWeekArr?.length > 0 && !!dailyRevenueArray && dailyRevenueArray?.length > 0) {
|
||||||
console.log('days of week arr: ', daysOfWeekArr);
|
|
||||||
if (!!daysOfWeekArr && daysOfWeekArr?.length > 0) {
|
|
||||||
setChartOptions({
|
setChartOptions({
|
||||||
...options,
|
...options,
|
||||||
xaxis: {
|
xaxis: {
|
||||||
...options.xaxis,
|
...options.xaxis,
|
||||||
categories: daysOfWeekArr,
|
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 (
|
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='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'>
|
||||||
|
49
src/client/admin/components/SourcesTable.tsx
Normal file
49
src/client/admin/components/SourcesTable.tsx
Normal 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;
|
@ -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='reblock h-8 w-14 rounded-full bg-meta-9 dark:bg-[#5A616B]'></div>
|
||||||
<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'
|
enabled && '!right-1 !translate-x-full !bg-primary dark:!bg-white'
|
||||||
}`}
|
}`}
|
||||||
></div>
|
></div>
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { UpArrow, DownArrow } from "../images/icon/icons-arrows";
|
import { UpArrow, DownArrow } from "../images/icon/icons-arrows";
|
||||||
|
|
||||||
type PageViewsStats = {
|
type PageViewsStats = {
|
||||||
totalPageViews: string | undefined;
|
totalPageViews: number | undefined;
|
||||||
dailyChangePercentage: string | 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 (
|
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='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>
|
<span className='text-sm font-medium'>Total page views</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dailyChangePercentage && parseInt(dailyChangePercentage) !== 0 && (
|
{prevDayViewsChangePercent && parseInt(prevDayViewsChangePercent) !== 0 && (
|
||||||
<span
|
<span
|
||||||
className={`flex items-center gap-1 text-sm font-medium ${isDeltaPositive ? 'text-meta-3' : 'text-meta-5'}`}
|
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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -9,18 +9,15 @@ const TotalRevenueCard = ({dailyStats, weeklyStats, isLoading}: DailyStatsProps)
|
|||||||
}, [weeklyStats]);
|
}, [weeklyStats]);
|
||||||
|
|
||||||
const deltaPercentage = useMemo(() => {
|
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);
|
weeklyStats.sort((a, b) => b.id - a.id);
|
||||||
console.log('weeklyStats[1]?.totalRevenue; ', !!weeklyStats && weeklyStats)
|
console.log('weeklyStats[1]?.totalRevenue; ', !!weeklyStats && weeklyStats)
|
||||||
const percentage = ((weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) / weeklyStats[1]?.totalRevenue) * 100;
|
const percentage = ((weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) / weeklyStats[1]?.totalRevenue) * 100;
|
||||||
return Math.floor(percentage);
|
return Math.floor(percentage);
|
||||||
}, [weeklyStats]);
|
}, [weeklyStats]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('deltaPercentage; ', deltaPercentage)
|
|
||||||
console.log('weeklyStats; ', weeklyStats)
|
|
||||||
}, [deltaPercentage])
|
|
||||||
|
|
||||||
return (
|
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='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='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-meta-2 dark:bg-meta-4'>
|
||||||
|
@ -32,8 +32,8 @@ const UsersTable = () => {
|
|||||||
return (
|
return (
|
||||||
<div className='flex flex-col gap-4'>
|
<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='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 '>
|
<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-semibold text-gray-700'>Filters:</span>
|
<span className='text-sm font-medium'>Filters:</span>
|
||||||
<div className='flex items-center justify-between gap-3 w-full px-2'>
|
<div className='flex items-center justify-between gap-3 w-full px-2'>
|
||||||
<div className='relative flex items-center gap-3 '>
|
<div className='relative flex items-center gap-3 '>
|
||||||
<label htmlFor='email-filter' className='block text-sm text-gray-700 dark:text-white'>
|
<label htmlFor='email-filter' className='block text-sm text-gray-700 dark:text-white'>
|
||||||
@ -46,18 +46,18 @@ const UsersTable = () => {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setEmail(e.currentTarget.value);
|
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'>
|
<label htmlFor='status-filter' className='block text-sm ml-2 text-gray-700 dark:text-white'>
|
||||||
status:
|
status:
|
||||||
</label>
|
</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'>
|
<div className='flex items-center'>
|
||||||
{!!statusOptions && statusOptions.length > 0 ? (
|
{!!statusOptions && statusOptions.length > 0 ? (
|
||||||
statusOptions.map((opt, idx) => (
|
statusOptions.map((opt, idx) => (
|
||||||
<span
|
<span
|
||||||
key={opt}
|
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}
|
{opt}
|
||||||
<span
|
<span
|
||||||
@ -87,7 +87,7 @@ const UsersTable = () => {
|
|||||||
</span>
|
</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
|
Select Status Filters
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -106,7 +106,7 @@ const UsersTable = () => {
|
|||||||
}}
|
}}
|
||||||
name='status-filter'
|
name='status-filter'
|
||||||
id='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>
|
<option value=''>Select filters</option>
|
||||||
{['past_due', 'canceled', 'active'].map((status) => {
|
{['past_due', 'canceled', 'active'].map((status) => {
|
||||||
@ -141,7 +141,7 @@ const UsersTable = () => {
|
|||||||
setHasPaidFilter(e.target.value === 'true');
|
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='both'>both</option>
|
||||||
<option value='true'>true</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'
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,11 +3,10 @@ import TotalPageViewsCard from '../../components/TotalPaidViewsCard';
|
|||||||
import TotalPayingUsersCard from '../../components/TotalPayingUsersCard';
|
import TotalPayingUsersCard from '../../components/TotalPayingUsersCard';
|
||||||
import TotalRevenueCard from '../../components/TotalRevenueCard';
|
import TotalRevenueCard from '../../components/TotalRevenueCard';
|
||||||
import RevenueAndProfitChart from '../../components/RevenueAndProfitChart';
|
import RevenueAndProfitChart from '../../components/RevenueAndProfitChart';
|
||||||
import ReferrerTable from '../../components/ReferrerTable';
|
import SourcesTable from '../../components/SourcesTable';
|
||||||
import DefaultLayout from '../../layout/DefaultLayout';
|
import DefaultLayout from '../../layout/DefaultLayout';
|
||||||
import { useQuery } from '@wasp/queries';
|
import { useQuery } from '@wasp/queries';
|
||||||
import getDailyStats from '@wasp/queries/getDailyStats';
|
import getDailyStats from '@wasp/queries/getDailyStats';
|
||||||
import getPlausibleStats from '@wasp/queries/getPlausibleStats';
|
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import type { User } from '@wasp/entities';
|
import type { User } from '@wasp/entities';
|
||||||
|
|
||||||
@ -18,12 +17,14 @@ const ECommerce = ({ user} : { user: User }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { data: stats, isLoading, error } = useQuery(getDailyStats);
|
const { data: stats, isLoading, error } = useQuery(getDailyStats);
|
||||||
const { data: plausibleStats, isLoading: isPlausibleLoading, error: plausibleError } = useQuery(getPlausibleStats);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DefaultLayout>
|
<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 totalPageViews={plausibleStats?.totalPageViews} dailyChangePercentage={plausibleStats?.dailyChangePercentage} />
|
<TotalPageViewsCard
|
||||||
|
totalPageViews={stats?.dailyStats.totalViews}
|
||||||
|
prevDayViewsChangePercent={stats?.dailyStats.prevDayViewsChangePercent}
|
||||||
|
/>
|
||||||
<TotalRevenueCard dailyStats={stats?.dailyStats} weeklyStats={stats?.weeklyStats} isLoading={isLoading} />
|
<TotalRevenueCard dailyStats={stats?.dailyStats} weeklyStats={stats?.weeklyStats} isLoading={isLoading} />
|
||||||
<TotalPayingUsersCard dailyStats={stats?.dailyStats} isLoading={isLoading} />
|
<TotalPayingUsersCard dailyStats={stats?.dailyStats} isLoading={isLoading} />
|
||||||
<TotalSignupsCard 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} />
|
<RevenueAndProfitChart weeklyStats={stats?.weeklyStats} isLoading={isLoading} />
|
||||||
|
|
||||||
<div className='col-span-12 xl:col-span-8'>
|
<div className='col-span-12 xl:col-span-8'>
|
||||||
<ReferrerTable />
|
<SourcesTable sources={stats?.dailyStats?.sources} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -4,7 +4,7 @@ import HttpError from '@wasp/core/HttpError.js';
|
|||||||
import type { RelatedObject, User } from '@wasp/entities';
|
import type { RelatedObject, User } from '@wasp/entities';
|
||||||
import type { GenerateGptResponse, StripePayment } from '@wasp/actions/types';
|
import type { GenerateGptResponse, StripePayment } from '@wasp/actions/types';
|
||||||
import type { StripePaymentResult, OpenAIResponse } from './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 { fetchStripeCustomer, createStripeCheckoutSession } from './stripeUtils.js';
|
||||||
import { TierIds } from '@wasp/shared/const.js';
|
import { TierIds } from '@wasp/shared/const.js';
|
||||||
|
|
||||||
@ -167,38 +167,3 @@ export const updateCurrentUser: UpdateCurrentUser<Partial<User>, User> = async (
|
|||||||
data: user,
|
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
@ -1,17 +1,18 @@
|
|||||||
import HttpError from '@wasp/core/HttpError.js';
|
import HttpError from '@wasp/core/HttpError.js';
|
||||||
import { getTotalPageViews, calculateDailyChangePercentage } from './analyticsUtils.js';
|
import type { DailyStats, RelatedObject, User, PageViewSource } from '@wasp/entities';
|
||||||
import type { DailyStats, RelatedObject, Referrer, User } from '@wasp/entities';
|
|
||||||
import type {
|
import type {
|
||||||
GetRelatedObjects,
|
GetRelatedObjects,
|
||||||
GetDailyStats,
|
GetDailyStats,
|
||||||
GetReferrerStats,
|
|
||||||
GetPaginatedUsers,
|
GetPaginatedUsers,
|
||||||
GetPlausibleStats,
|
|
||||||
} from '@wasp/queries/types';
|
} from '@wasp/queries/types';
|
||||||
|
|
||||||
|
type DailyStatsWithSources = DailyStats & {
|
||||||
|
sources: PageViewSource[];
|
||||||
|
};
|
||||||
|
|
||||||
type DailyStatsValues = {
|
type DailyStatsValues = {
|
||||||
dailyStats: DailyStats;
|
dailyStats: DailyStatsWithSources;
|
||||||
weeklyStats: DailyStats[];
|
weeklyStats: DailyStatsWithSources[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getRelatedObjects: GetRelatedObjects<void, RelatedObject[]> = async (args, context) => {
|
export const getRelatedObjects: GetRelatedObjects<void, RelatedObject[]> = async (args, context) => {
|
||||||
@ -35,38 +36,24 @@ export const getDailyStats: GetDailyStats<void, DailyStatsValues> = async (_args
|
|||||||
orderBy: {
|
orderBy: {
|
||||||
date: 'desc',
|
date: 'desc',
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
sources: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('dailyStats: ', dailyStats)
|
||||||
|
|
||||||
const weeklyStats = await context.entities.DailyStats.findMany({
|
const weeklyStats = await context.entities.DailyStats.findMany({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
date: 'desc',
|
date: 'desc',
|
||||||
},
|
},
|
||||||
take: 7,
|
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: {
|
include: {
|
||||||
users: true,
|
sources: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return referrers.map((referrer) => ({
|
return { dailyStats, weeklyStats };
|
||||||
...referrer,
|
|
||||||
users: referrer.users.map((user) => ({
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
hasPaid: user.hasPaid,
|
|
||||||
subscriptionStatus: user.subscriptionStatus,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetPaginatedUsersInput = {
|
type GetPaginatedUsersInput = {
|
||||||
@ -132,21 +119,3 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
|
|||||||
totalPages,
|
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
@ -1,27 +1,9 @@
|
|||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import type { PrismaClient } from '@prisma/client';
|
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
|
// 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;
|
let prevUserId = 0;
|
||||||
export function createRandomUser(): Partial<User> {
|
export function createRandomUser(): Partial<User> {
|
||||||
const user: Partial<User> = {
|
const user: Partial<User> = {
|
||||||
@ -41,7 +23,6 @@ export function createRandomUser(): Partial<User> {
|
|||||||
subscriptionStatus: faker.helpers.arrayElement(['active', 'canceled', 'past_due']),
|
subscriptionStatus: faker.helpers.arrayElement(['active', 'canceled', 'past_due']),
|
||||||
datePaid: faker.date.recent(),
|
datePaid: faker.date.recent(),
|
||||||
credits: faker.number.int({ min: 0, max: 3 }),
|
credits: faker.number.int({ min: 0, max: 3 }),
|
||||||
referrerId: faker.number.int({ min: 1, max: 3 }),
|
|
||||||
};
|
};
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@ -52,13 +33,6 @@ const USERS: Partial<User>[] = faker.helpers.multiple(createRandomUser, {
|
|||||||
|
|
||||||
export async function devSeedUsers(prismaClient: PrismaClient) {
|
export async function devSeedUsers(prismaClient: PrismaClient) {
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
|
||||||
referrerArr.map(async (referrer) => {
|
|
||||||
await prismaClient.referrer.create({
|
|
||||||
data: referrer,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
USERS.map(async (user) => {
|
USERS.map(async (user) => {
|
||||||
await prismaClient.user.create({
|
await prismaClient.user.create({
|
||||||
|
@ -15,6 +15,13 @@ type PageViewsResult = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PageViewSourcesResult = {
|
||||||
|
results: [{
|
||||||
|
source: string;
|
||||||
|
visitors: number;
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
|
||||||
export async function getTotalPageViews() {
|
export async function getTotalPageViews() {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&metrics=pageviews`,
|
`${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&metrics=pageviews`,
|
||||||
@ -26,12 +33,12 @@ export async function getTotalPageViews() {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
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;
|
return json.results.pageviews.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function calculateDailyChangePercentage() {
|
export async function getPrevDayViewsChangePercent() {
|
||||||
// Calculate today, yesterday, and the day before yesterday's dates
|
// Calculate today, yesterday, and the day before yesterday's dates
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const yesterday = new Date(today.setDate(today.getDate() - 1)).toISOString().split('T')[0];
|
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 pageViewsYesterday = await getPageviewsForDate(yesterday);
|
||||||
const pageViewsDayBeforeYesterday = await getPageviewsForDate(dayBeforeYesterday);
|
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;
|
let change = 0;
|
||||||
if (pageViewsYesterday === 0 || pageViewsDayBeforeYesterday === 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)}%`);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error calculating daily change percentage:', error);
|
console.error('Error calculating daily change percentage:', error);
|
||||||
}
|
}
|
||||||
@ -67,6 +79,19 @@ async function getPageviewsForDate(date: string) {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
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;
|
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;
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import type { DailyStats } from '@wasp/jobs/dailyStats';
|
import type { DailyStats } from '@wasp/jobs/dailyStats';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
import { getTotalPageViews, getPrevDayViewsChangePercent, getSources } from './analyticsUtils.js';
|
||||||
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_KEY!, {
|
const stripe = new Stripe(process.env.STRIPE_KEY!, {
|
||||||
apiVersion: '2022-11-15', // TODO find out where this is in the Stripe dashboard and document
|
apiVersion: '2022-11-15', // TODO find out where this is in the Stripe dashboard and document
|
||||||
@ -41,7 +42,8 @@ export const calculateDailyStats: DailyStats<never, void> = async (_args, contex
|
|||||||
paidUserDelta -= yesterdaysStats.paidUserCount;
|
paidUserDelta -= yesterdaysStats.paidUserCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newRunningTotal = await calculateTotalRevenue(context);
|
const totalRevenue = await fetchTotalStripeRevenue();
|
||||||
|
const { totalViews, prevDayViewsChangePercent } = await getDailyPageviews();
|
||||||
|
|
||||||
const newDailyStat = await context.entities.DailyStats.upsert({
|
const newDailyStat = await context.entities.DailyStats.upsert({
|
||||||
where: {
|
where: {
|
||||||
@ -49,21 +51,47 @@ export const calculateDailyStats: DailyStats<never, void> = async (_args, contex
|
|||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
date: nowUTC,
|
date: nowUTC,
|
||||||
|
totalViews,
|
||||||
|
prevDayViewsChangePercent: prevDayViewsChangePercent || '0',
|
||||||
userCount,
|
userCount,
|
||||||
paidUserCount,
|
paidUserCount,
|
||||||
userDelta,
|
userDelta,
|
||||||
paidUserDelta,
|
paidUserDelta,
|
||||||
totalRevenue: newRunningTotal,
|
totalRevenue,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
|
totalViews,
|
||||||
|
prevDayViewsChangePercent: prevDayViewsChangePercent || '0' ,
|
||||||
userCount,
|
userCount,
|
||||||
paidUserCount,
|
paidUserCount,
|
||||||
userDelta,
|
userDelta,
|
||||||
paidUserDelta,
|
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 })
|
console.table({ newDailyStat })
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -77,75 +105,46 @@ export const calculateDailyStats: DailyStats<never, void> = async (_args, contex
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchDailyStripeRevenue() {
|
async function fetchTotalStripeRevenue() {
|
||||||
const startOfDayUTC = new Date(Date.now());
|
let totalRevenue = 0;
|
||||||
startOfDayUTC.setHours(0, 0, 0, 0); // Sets to beginning of day
|
let params: Stripe.BalanceTransactionListParams = {
|
||||||
const startOfDayTimestamp = Math.floor(startOfDayUTC.getTime() / 1000); // Convert to Unix timestamp in seconds
|
limit: 100,
|
||||||
|
// created: {
|
||||||
|
// gte: startTimestamp,
|
||||||
|
// lt: endTimestamp
|
||||||
|
// },
|
||||||
|
type: 'charge',
|
||||||
|
};
|
||||||
|
|
||||||
const endOfDayUTC = new Date();
|
let hasMore = true;
|
||||||
endOfDayUTC.setHours(23, 59, 59, 999); // Sets to end of day
|
while (hasMore) {
|
||||||
const endOfDayTimestamp = Math.floor(endOfDayUTC.getTime() / 1000); // Convert to Unix timestamp in seconds
|
const balanceTransactions = await stripe.balanceTransactions.list(params);
|
||||||
|
|
||||||
let nextPageCursor = undefined;
|
for (const transaction of balanceTransactions.data) {
|
||||||
const allPayments = [] as Stripe.Invoice[];
|
if (transaction.type === 'charge') {
|
||||||
|
totalRevenue += transaction.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
while (true) {
|
if (balanceTransactions.has_more) {
|
||||||
// Stripe allows searching for invoices by date range via their Query Language
|
// Set the starting point for the next iteration to the last object fetched
|
||||||
// If there are more than 100 invoices in a day, we need to paginate through them
|
params.starting_after = balanceTransactions.data[balanceTransactions.data.length - 1].id;
|
||||||
const params = {
|
} else {
|
||||||
query: `created>=${startOfDayTimestamp} AND created<=${endOfDayTimestamp} AND status:"paid"`,
|
hasMore = false;
|
||||||
limit: 100,
|
}
|
||||||
page: nextPageCursor,
|
}
|
||||||
|
|
||||||
|
// 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;
|
|
||||||
}
|
}
|
Loading…
x
Reference in New Issue
Block a user