add analytics (#162)
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "CumulatedMetrics" (
|
||||
"teamId" INTEGER NOT NULL,
|
||||
"domainId" INTEGER NOT NULL,
|
||||
"delivered" BIGINT NOT NULL DEFAULT 0,
|
||||
"hardBounced" BIGINT NOT NULL DEFAULT 0,
|
||||
"complained" BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "CumulatedMetrics_pkey" PRIMARY KEY ("teamId","domainId")
|
||||
);
|
@@ -0,0 +1,27 @@
|
||||
BEGIN;
|
||||
|
||||
-- 1) Populate or update cumulated totals
|
||||
INSERT INTO "CumulatedMetrics" (
|
||||
"teamId",
|
||||
"domainId",
|
||||
"delivered",
|
||||
"hardBounced",
|
||||
"complained"
|
||||
)
|
||||
SELECT
|
||||
du."teamId",
|
||||
du."domainId",
|
||||
SUM(du.delivered)::BIGINT AS delivered,
|
||||
SUM(du."hardBounced")::BIGINT AS hardBounced,
|
||||
SUM(du.complained)::BIGINT AS complained
|
||||
FROM public."DailyEmailUsage" du
|
||||
GROUP BY
|
||||
du."teamId",
|
||||
du."domainId"
|
||||
ON CONFLICT ("teamId","domainId") DO UPDATE
|
||||
SET
|
||||
"delivered" = EXCLUDED."delivered",
|
||||
"hardBounced" = EXCLUDED."hardBounced",
|
||||
"complained" = EXCLUDED."complained";
|
||||
|
||||
COMMIT;
|
@@ -1,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
provider = "postgresql"
|
||||
|
@@ -375,3 +375,13 @@ model DailyEmailUsage {
|
||||
|
||||
@@id([teamId, domainId, date, type])
|
||||
}
|
||||
|
||||
model CumulatedMetrics {
|
||||
teamId Int
|
||||
domainId Int
|
||||
delivered BigInt @default(0)
|
||||
hardBounced BigInt @default(0)
|
||||
complained BigInt @default(0)
|
||||
|
||||
@@id([teamId, domainId])
|
||||
}
|
||||
|
@@ -1,315 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import { EmailStatusIcon } from "../emails/email-status-badge";
|
||||
import { EmailStatus } from "@prisma/client";
|
||||
import { api } from "~/trpc/react";
|
||||
import Spinner from "@unsend/ui/src/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@unsend/ui/src/tabs";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
import { useTheme } from "@unsend/ui";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@unsend/ui/src/select";
|
||||
|
||||
export default function DashboardChart() {
|
||||
const [days, setDays] = useUrlState("days", "7");
|
||||
const [domain, setDomain] = useUrlState("domain");
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const domainId = domain ? Number(domain) : undefined;
|
||||
const statusQuery = api.email.dashboard.useQuery({
|
||||
days: Number(days),
|
||||
domain: domainId,
|
||||
});
|
||||
const { data: domainsQuery } = api.domain.domains.useQuery();
|
||||
|
||||
const handleDomain = (val: string) => {
|
||||
setDomain(val === "All Domain" ? null : val);
|
||||
};
|
||||
|
||||
const lightColors = {
|
||||
delivered: "#40a02bcc",
|
||||
bounced: "#d20f39cc",
|
||||
complained: "#df8e1dcc",
|
||||
opened: "#8839efcc",
|
||||
clicked: "#04a5e5cc",
|
||||
};
|
||||
|
||||
const darkColors = {
|
||||
delivered: "#a6e3a1",
|
||||
bounced: "#f38ba8",
|
||||
complained: "#F9E2AF",
|
||||
opened: "#cba6f7",
|
||||
clicked: "#93c5fd",
|
||||
};
|
||||
|
||||
const currentColors = resolvedTheme === "dark" ? darkColors : lightColors;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="font-semibold text-xl">Dashboard</h1>
|
||||
<div className="flex gap-3">
|
||||
<Select
|
||||
value={domain ?? "All Domain"}
|
||||
onValueChange={(val) => handleDomain(val)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
{domain
|
||||
? domainsQuery?.find((d) => d.id === Number(domain))?.name
|
||||
: "All Domain"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All Domain" className="capitalize">
|
||||
All Domain
|
||||
</SelectItem>
|
||||
{domainsQuery &&
|
||||
domainsQuery.map((domain) => (
|
||||
<SelectItem key={domain.id} value={domain.id.toString()}>
|
||||
{domain.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Tabs value={days || "7"} onValueChange={(value) => setDays(value)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="7">7 Days</TabsTrigger>
|
||||
<TabsTrigger value="30">30 Days</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-16 mt-10">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!statusQuery.isLoading && statusQuery.data ? (
|
||||
<>
|
||||
<DashboardItemCard
|
||||
status={"total"}
|
||||
count={statusQuery.data.totalCounts.sent}
|
||||
percentage={100}
|
||||
/>
|
||||
<DashboardItemCard
|
||||
status={EmailStatus.DELIVERED}
|
||||
count={statusQuery.data.totalCounts.delivered}
|
||||
percentage={
|
||||
statusQuery.data.totalCounts.delivered /
|
||||
statusQuery.data.totalCounts.sent
|
||||
}
|
||||
/>
|
||||
<DashboardItemCard
|
||||
status={EmailStatus.BOUNCED}
|
||||
count={statusQuery.data.totalCounts.bounced}
|
||||
percentage={
|
||||
statusQuery.data.totalCounts.bounced /
|
||||
statusQuery.data.totalCounts.sent
|
||||
}
|
||||
/>
|
||||
<DashboardItemCard
|
||||
status={EmailStatus.COMPLAINED}
|
||||
count={statusQuery.data.totalCounts.complained}
|
||||
percentage={
|
||||
statusQuery.data.totalCounts.complained /
|
||||
statusQuery.data.totalCounts.sent
|
||||
}
|
||||
/>
|
||||
<DashboardItemCard
|
||||
status={EmailStatus.CLICKED}
|
||||
count={statusQuery.data.totalCounts.clicked}
|
||||
percentage={
|
||||
statusQuery.data.totalCounts.clicked /
|
||||
statusQuery.data.totalCounts.sent
|
||||
}
|
||||
/>
|
||||
<DashboardItemCard
|
||||
status={EmailStatus.OPENED}
|
||||
count={statusQuery.data.totalCounts.opened}
|
||||
percentage={
|
||||
statusQuery.data.totalCounts.opened /
|
||||
statusQuery.data.totalCounts.sent
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-[100px] w-[1/5] min-w-[200px] bg-secondary/10 border rounded-lg p-4 flex justify-center items-center gap-3 ">
|
||||
<Spinner className="w-4 h-4" innerSvgClass="stroke-primary" />
|
||||
</div>
|
||||
<div className="h-[100px] w-[1/5] min-w-[200px] bg-secondary/10 border rounded-lg p-4 flex justify-center items-center gap-3 ">
|
||||
<Spinner className="w-4 h-4" innerSvgClass="stroke-primary" />
|
||||
</div>
|
||||
<div className="h-[100px] w-[1/5] min-w-[200px] bg-secondary/10 border rounded-lg p-4 flex justify-center items-center gap-3 ">
|
||||
<Spinner className="w-4 h-4" innerSvgClass="stroke-primary" />
|
||||
</div>
|
||||
<div className="h-[100px] w-[1/5] min-w-[200px] bg-secondary/10 border rounded-lg p-4 flex justify-center items-center gap-3 ">
|
||||
<Spinner className="w-4 h-4" innerSvgClass="stroke-primary" />
|
||||
</div>
|
||||
<div className="h-[100px] w-[1/5] min-w-[200px] bg-secondary/10 border rounded-lg p-4 flex justify-center items-center gap-3 ">
|
||||
<Spinner className="w-4 h-4" innerSvgClass="stroke-primary" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!statusQuery.isLoading && statusQuery.data ? (
|
||||
<div className="w-full h-[400px] border shadow rounded-lg p-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
width={900}
|
||||
height={300}
|
||||
data={statusQuery.data.result}
|
||||
margin={{
|
||||
top: 20,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" fontSize={12} />
|
||||
<YAxis fontSize={12} />
|
||||
<Tooltip
|
||||
content={({ payload }) => {
|
||||
const data = payload?.[0]?.payload as Record<
|
||||
| "sent"
|
||||
| "delivered"
|
||||
| "opened"
|
||||
| "clicked"
|
||||
| "bounced"
|
||||
| "complained",
|
||||
number
|
||||
> & { date: string };
|
||||
|
||||
if (!data || data.sent === 0) return null;
|
||||
|
||||
return (
|
||||
<div className=" bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.date}
|
||||
</p>
|
||||
{data.delivered ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-2.5 h-2.5 bg-[#40a02bcc] dark:bg-[#a6e3a1] rounded-[2px]"></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
Delivered
|
||||
</p>
|
||||
<p className="text-xs font-mono">
|
||||
{data.delivered}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data.bounced ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-2.5 h-2.5 bg-[#d20f39cc] dark:bg-[#f38ba8] rounded-[2px]"></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
Bounced
|
||||
</p>
|
||||
<p className="text-xs font-mono">{data.bounced}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data.complained ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-2.5 h-2.5 bg-[#df8e1dcc] dark:bg-[#F9E2AF] rounded-[2px]"></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
Complained
|
||||
</p>
|
||||
<p className="text-xs font-mono">
|
||||
{data.complained}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data.opened ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-2.5 h-2.5 bg-[#8839efcc] dark:bg-[#cba6f7] rounded-[2px]"></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
Opened
|
||||
</p>
|
||||
<p className="text-xs font-mono">{data.opened}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data.clicked ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-2.5 h-2.5 bg-[#04a5e5cc] dark:bg-[#93c5fd] rounded-[2px]"></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
Clicked
|
||||
</p>
|
||||
<p className="text-xs font-mono">{data.clicked}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
cursor={false}
|
||||
/>
|
||||
{/* <Legend /> */}
|
||||
<Bar
|
||||
barSize={8}
|
||||
dataKey="delivered"
|
||||
stackId="a"
|
||||
fill={currentColors.delivered}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="bounced"
|
||||
stackId="a"
|
||||
fill={currentColors.bounced}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="complained"
|
||||
stackId="a"
|
||||
fill={currentColors.complained}
|
||||
/>
|
||||
<Bar dataKey="opened" stackId="a" fill={currentColors.opened} />
|
||||
<Bar
|
||||
dataKey="clicked"
|
||||
stackId="a"
|
||||
fill={currentColors.clicked}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DashboardItemCardProps = {
|
||||
status: EmailStatus | "total";
|
||||
count: number;
|
||||
percentage: number;
|
||||
};
|
||||
|
||||
const DashboardItemCard: React.FC<DashboardItemCardProps> = ({
|
||||
status,
|
||||
count,
|
||||
percentage,
|
||||
}) => {
|
||||
return (
|
||||
<div className="h-[100px] w-[16%] min-w-[170px] bg-secondary/10 border shadow rounded-lg p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{status !== "total" ? <EmailStatusIcon status={status} /> : null}
|
||||
<div className=" capitalize">{status.toLowerCase()}</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="text-foreground font-light text-2xl font-mono">
|
||||
{count}
|
||||
</div>
|
||||
{status !== "total" ? (
|
||||
<div className="text-sm pb-1">
|
||||
{count > 0 ? (percentage * 100).toFixed(0) : 0}%
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
62
apps/web/src/app/(dashboard)/dashboard/dashboard-filters.tsx
Normal file
62
apps/web/src/app/(dashboard)/dashboard/dashboard-filters.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from "react";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@unsend/ui/src/tabs";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@unsend/ui/src/select";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface DashboardFiltersProps {
|
||||
days: string;
|
||||
setDays: (days: string) => void;
|
||||
domain: string | null;
|
||||
setDomain: (domain: string | null) => void;
|
||||
}
|
||||
|
||||
export default function DashboardFilters({
|
||||
days,
|
||||
setDays,
|
||||
domain,
|
||||
setDomain,
|
||||
}: DashboardFiltersProps) {
|
||||
const { data: domainsQuery } = api.domain.domains.useQuery();
|
||||
|
||||
const handleDomain = (val: string) => {
|
||||
setDomain(val === "All Domain" ? null : val);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<Select
|
||||
value={domain ?? "All Domain"}
|
||||
onValueChange={(val) => handleDomain(val)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
{domain
|
||||
? domainsQuery?.find((d) => d.id === Number(domain))?.name
|
||||
: "All Domain"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All Domain" className="capitalize">
|
||||
All Domain
|
||||
</SelectItem>
|
||||
{domainsQuery &&
|
||||
domainsQuery.map((domain) => (
|
||||
<SelectItem key={domain.id} value={domain.id.toString()}>
|
||||
{domain.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Tabs value={days || "7"} onValueChange={(value) => setDays(value)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="7">7 Days</TabsTrigger>
|
||||
<TabsTrigger value="30">30 Days</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
265
apps/web/src/app/(dashboard)/dashboard/email-chart.tsx
Normal file
265
apps/web/src/app/(dashboard)/dashboard/email-chart.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import React from "react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
AreaChart,
|
||||
Area,
|
||||
} from "recharts";
|
||||
import { EmailStatusIcon } from "../emails/email-status-badge";
|
||||
import { EmailStatus } from "@prisma/client";
|
||||
import { api } from "~/trpc/react";
|
||||
import Spinner from "@unsend/ui/src/spinner";
|
||||
import { useTheme } from "@unsend/ui";
|
||||
import { EMAIL_COLORS } from "~/lib/constants/colors";
|
||||
import { useColors } from "./hooks/useColors";
|
||||
|
||||
interface EmailChartProps {
|
||||
days: number;
|
||||
domain: string | null;
|
||||
}
|
||||
|
||||
export default function EmailChart({ days, domain }: EmailChartProps) {
|
||||
const domainId = domain ? Number(domain) : undefined;
|
||||
const statusQuery = api.dashboard.emailTimeSeries.useQuery({
|
||||
days: days,
|
||||
domain: domainId,
|
||||
});
|
||||
|
||||
const currentColors = useColors();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-16">
|
||||
{!statusQuery.isLoading && statusQuery.data ? (
|
||||
<div className="w-full h-[450px] border shadow rounded-xl p-4">
|
||||
<div>
|
||||
{/* <div className="mb-4 text-sm">Emails</div> */}
|
||||
|
||||
<div className="flex gap-10">
|
||||
<EmailChartItem
|
||||
status={"total"}
|
||||
count={statusQuery.data.totalCounts.sent}
|
||||
percentage={100}
|
||||
/>
|
||||
<EmailChartItem
|
||||
status={EmailStatus.DELIVERED}
|
||||
count={statusQuery.data.totalCounts.delivered}
|
||||
percentage={
|
||||
statusQuery.data.totalCounts.delivered /
|
||||
statusQuery.data.totalCounts.sent
|
||||
}
|
||||
/>
|
||||
<EmailChartItem
|
||||
status={EmailStatus.BOUNCED}
|
||||
count={statusQuery.data.totalCounts.bounced}
|
||||
percentage={
|
||||
statusQuery.data.totalCounts.bounced /
|
||||
statusQuery.data.totalCounts.sent
|
||||
}
|
||||
/>
|
||||
<EmailChartItem
|
||||
status={EmailStatus.COMPLAINED}
|
||||
count={statusQuery.data.totalCounts.complained}
|
||||
percentage={
|
||||
statusQuery.data.totalCounts.complained /
|
||||
statusQuery.data.totalCounts.sent
|
||||
}
|
||||
/>
|
||||
<EmailChartItem
|
||||
status={EmailStatus.CLICKED}
|
||||
count={statusQuery.data.totalCounts.clicked}
|
||||
percentage={
|
||||
statusQuery.data.totalCounts.clicked /
|
||||
statusQuery.data.totalCounts.sent
|
||||
}
|
||||
/>
|
||||
<EmailChartItem
|
||||
status={EmailStatus.OPENED}
|
||||
count={statusQuery.data.totalCounts.opened}
|
||||
percentage={
|
||||
statusQuery.data.totalCounts.opened /
|
||||
statusQuery.data.totalCounts.sent
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height="80%">
|
||||
<BarChart
|
||||
width={900}
|
||||
height={200}
|
||||
data={statusQuery.data.result}
|
||||
margin={{
|
||||
top: 20,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
fontSize={12}
|
||||
className="font-mono"
|
||||
stroke={currentColors.xaxis}
|
||||
/>
|
||||
{/* <YAxis fontSize={12} className="font-mono" /> */}
|
||||
<Tooltip
|
||||
content={({ payload }) => {
|
||||
const data = payload?.[0]?.payload as Record<
|
||||
| "sent"
|
||||
| "delivered"
|
||||
| "opened"
|
||||
| "clicked"
|
||||
| "bounced"
|
||||
| "complained",
|
||||
number
|
||||
> & { date: string };
|
||||
|
||||
if (!data || data.sent === 0) return null;
|
||||
|
||||
return (
|
||||
<div className=" bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.date}
|
||||
</p>
|
||||
{data.delivered ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-2.5 h-2.5 bg-[#40a02bcc] dark:bg-[#a6e3a1] rounded-[2px]"></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
Delivered
|
||||
</p>
|
||||
<p className="text-xs font-mono">{data.delivered}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data.bounced ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-2.5 h-2.5 bg-[#d20f39cc] dark:bg-[#f38ba8] rounded-[2px]"></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
Bounced
|
||||
</p>
|
||||
<p className="text-xs font-mono">{data.bounced}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data.complained ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-2.5 h-2.5 bg-[#df8e1dcc] dark:bg-[#F9E2AF] rounded-[2px]"></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
Complained
|
||||
</p>
|
||||
<p className="text-xs font-mono">{data.complained}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data.opened ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-2.5 h-2.5 bg-[#8839efcc] dark:bg-[#cba6f7] rounded-[2px]"></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
Opened
|
||||
</p>
|
||||
<p className="text-xs font-mono">{data.opened}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data.clicked ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-2.5 h-2.5 bg-[#04a5e5cc] dark:bg-[#93c5fd] rounded-[2px]"></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
Clicked
|
||||
</p>
|
||||
<p className="text-xs font-mono">{data.clicked}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
cursor={false}
|
||||
/>
|
||||
{/* <Legend /> */}
|
||||
<Bar
|
||||
barSize={8}
|
||||
dataKey="delivered"
|
||||
stackId="a"
|
||||
fill={currentColors.delivered}
|
||||
/>
|
||||
<Bar dataKey="bounced" stackId="a" fill={currentColors.bounced} />
|
||||
<Bar
|
||||
dataKey="complained"
|
||||
stackId="a"
|
||||
fill={currentColors.complained}
|
||||
/>
|
||||
<Bar dataKey="opened" stackId="a" fill={currentColors.opened} />
|
||||
<Bar dataKey="clicked" stackId="a" fill={currentColors.clicked} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[450px]"> </div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DashboardItemCardProps = {
|
||||
status: EmailStatus | "total";
|
||||
count: number;
|
||||
percentage: number;
|
||||
};
|
||||
|
||||
const DashboardItemCard: React.FC<DashboardItemCardProps> = ({
|
||||
status,
|
||||
count,
|
||||
percentage,
|
||||
}) => {
|
||||
return (
|
||||
<div className="h-[100px] w-[16%] min-w-[170px] bg-secondary/10 border shadow rounded-xl p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{status !== "total" ? <EmailStatusIcon status={status} /> : null}
|
||||
<div className=" capitalize">{status.toLowerCase()}</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="text-foreground font-light text-2xl font-mono">
|
||||
{count}
|
||||
</div>
|
||||
{status !== "total" ? (
|
||||
<div className="text-sm pb-1">
|
||||
{count > 0 ? (percentage * 100).toFixed(0) : 0}%
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EmailChartItem: React.FC<DashboardItemCardProps> = ({
|
||||
status,
|
||||
count,
|
||||
percentage,
|
||||
}) => {
|
||||
const color = EMAIL_COLORS[status];
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 items-stretch font-mono">
|
||||
{/* <div className={`${color} w-0.5 rounded-full`}></div> */}
|
||||
|
||||
<div>
|
||||
<div className=" flex items-center gap-2">
|
||||
<div className={`${color} w-2.5 h-2.5 rounded-[3px]`}></div>
|
||||
{/* <div className={`${color} w-0.5 rounded-full`}></div> */}
|
||||
|
||||
<div className="text-xs uppercase text-muted-foreground ">
|
||||
{status.toLowerCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 -ml-0.5 ">
|
||||
<span className="text-xl font-mono">{count}</span>
|
||||
<span className="text-xs ml-2 font-mono">
|
||||
{status !== "total"
|
||||
? `(${count > 0 ? (percentage * 100).toFixed(0) : 0}%)`
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
27
apps/web/src/app/(dashboard)/dashboard/hooks/useColors.ts
Normal file
27
apps/web/src/app/(dashboard)/dashboard/hooks/useColors.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useTheme } from "@unsend/ui";
|
||||
|
||||
export function useColors() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const lightColors = {
|
||||
delivered: "#40a02b",
|
||||
bounced: "#d20f39",
|
||||
complained: "#df8e1d",
|
||||
opened: "#8839ef",
|
||||
clicked: "#04a5e5",
|
||||
xaxis: "#6D6F84",
|
||||
};
|
||||
|
||||
const darkColors = {
|
||||
delivered: "#a6e3a1",
|
||||
bounced: "#f38ba8",
|
||||
complained: "#F9E2AF",
|
||||
opened: "#cba6f7",
|
||||
clicked: "#93c5fd",
|
||||
xaxis: "#AAB1CD",
|
||||
};
|
||||
|
||||
const currentColors = resolvedTheme === "dark" ? darkColors : lightColors;
|
||||
|
||||
return currentColors;
|
||||
}
|
@@ -1,12 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import DashboardChart from "./dashboard-chart";
|
||||
import EmailChart from "./email-chart";
|
||||
import DashboardFilters from "./dashboard-filters";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
import { ReputationMetrics } from "./reputation-metrics";
|
||||
|
||||
export default function Dashboard() {
|
||||
const [days, setDays] = useUrlState("days", "7");
|
||||
const [domain, setDomain] = useUrlState("domain");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full ">
|
||||
<DashboardChart />
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="font-semibold text-xl">Analytics</h1>
|
||||
<DashboardFilters
|
||||
days={days ?? "7"}
|
||||
setDays={setDays}
|
||||
domain={domain}
|
||||
setDomain={setDomain}
|
||||
/>
|
||||
</div>
|
||||
<div className=" space-y-12">
|
||||
<EmailChart days={Number(days ?? "7")} domain={domain} />
|
||||
|
||||
<ReputationMetrics days={Number(days ?? "7")} domain={domain} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
402
apps/web/src/app/(dashboard)/dashboard/reputation-metrics.tsx
Normal file
402
apps/web/src/app/(dashboard)/dashboard/reputation-metrics.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@unsend/ui/src/tooltip";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CheckCircle2Icon,
|
||||
InfoIcon,
|
||||
OctagonAlertIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip as RechartsTooltip,
|
||||
CartesianGrid,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import {
|
||||
HARD_BOUNCE_RISK_RATE,
|
||||
HARD_BOUNCE_WARNING_RATE,
|
||||
COMPLAINED_WARNING_RATE,
|
||||
COMPLAINED_RISK_RATE,
|
||||
} from "~/lib/constants";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useColors } from "./hooks/useColors";
|
||||
|
||||
interface ReputationMetricsProps {
|
||||
days: number;
|
||||
domain: string | null;
|
||||
}
|
||||
|
||||
enum ACCOUNT_STATUS {
|
||||
HEALTHY = "HEALTHY",
|
||||
WARNING = "WARNING",
|
||||
RISK = "RISK",
|
||||
}
|
||||
|
||||
const CustomLabel = ({ value, stroke }: { value: string; stroke: string }) => {
|
||||
return (
|
||||
<text x={0} y={-5} fill={stroke} fontSize={12} textAnchor="start">
|
||||
{value}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
|
||||
const { data: metrics, isLoading } =
|
||||
api.dashboard.reputationMetricsData.useQuery({
|
||||
domain: domain ? Number(domain) : undefined,
|
||||
});
|
||||
|
||||
const colors = useColors();
|
||||
|
||||
const bouncedMetric = metrics
|
||||
? [
|
||||
{
|
||||
name: "Bounce Rate",
|
||||
value: metrics.bounceRate,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const complaintMetric = metrics
|
||||
? [
|
||||
{
|
||||
name: "Complaint Rate",
|
||||
value: metrics.complaintRate,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const bounceStatus =
|
||||
(metrics?.bounceRate ?? 0) > HARD_BOUNCE_RISK_RATE
|
||||
? ACCOUNT_STATUS.RISK
|
||||
: (metrics?.bounceRate ?? 0) > HARD_BOUNCE_WARNING_RATE
|
||||
? ACCOUNT_STATUS.WARNING
|
||||
: ACCOUNT_STATUS.HEALTHY;
|
||||
|
||||
const complaintStatus =
|
||||
(metrics?.complaintRate ?? 0) > COMPLAINED_RISK_RATE
|
||||
? ACCOUNT_STATUS.RISK
|
||||
: (metrics?.complaintRate ?? 0) > COMPLAINED_WARNING_RATE
|
||||
? ACCOUNT_STATUS.WARNING
|
||||
: ACCOUNT_STATUS.HEALTHY;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex gap-10 w-full">
|
||||
<div className="w-1/2 border rounded-xl shadow p-4">
|
||||
<div className="flex justify-between">
|
||||
<div className=" flex items-center gap-2">
|
||||
<div className="text-muted-foreground">Bounce Rate</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className=" h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="w-[300px]">
|
||||
The percentage of emails sent from your account that resulted
|
||||
in a hard bounce.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-4">
|
||||
<div className="text-2xl mt-2">{metrics?.bounceRate}%</div>
|
||||
<StatusBadge status={bounceStatus} />
|
||||
</div>
|
||||
{/* <div className="flex">
|
||||
<StatusBadge status={ACCOUNT_STATUS.HEALTHY} />
|
||||
</div> */}
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart
|
||||
// width={350}
|
||||
// height={100}
|
||||
data={bouncedMetric}
|
||||
margin={{
|
||||
top: 20,
|
||||
// right: 30,
|
||||
left: -30,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<YAxis
|
||||
domain={[0, 15]}
|
||||
ticks={[0, 5, 10, 15]}
|
||||
fontSize={12}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
hide={false}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<CartesianGrid
|
||||
vertical={false}
|
||||
strokeDasharray="3 3"
|
||||
stroke={`${colors.xaxis}50`}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={HARD_BOUNCE_WARNING_RATE}
|
||||
stroke={`${colors.complained}A0`}
|
||||
label={{
|
||||
value: "",
|
||||
position: "insideBottomLeft",
|
||||
fill: colors.complained,
|
||||
fontSize: 12,
|
||||
}}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
|
||||
{/* <CartesianGrid vertical={false} strokeDasharray="3 3" /> */}
|
||||
{/* <YAxis fontSize={12} /> */}
|
||||
|
||||
{/* <ReferenceLine
|
||||
y={0}
|
||||
stroke={colors.xaxis}
|
||||
strokeDasharray="3 3"
|
||||
/> */}
|
||||
|
||||
<ReferenceLine
|
||||
y={HARD_BOUNCE_RISK_RATE}
|
||||
stroke={`${colors.bounced}A0`}
|
||||
label={{
|
||||
value: ``,
|
||||
position: "insideBottomLeft",
|
||||
fill: colors.bounced,
|
||||
fontSize: 12,
|
||||
}}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<RechartsTooltip
|
||||
content={({ payload }) => {
|
||||
const data = payload?.[0]?.payload as {
|
||||
name: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.name}
|
||||
</p>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
style={{ background: colors.clicked }}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
Current
|
||||
</p>
|
||||
<p className="text-xs font-mono">{data.value}%</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
style={{ background: colors.complained }}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
Warning at
|
||||
</p>
|
||||
<p className="text-xs font-mono">
|
||||
{HARD_BOUNCE_WARNING_RATE}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
style={{ background: colors.bounced }}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
Risk at
|
||||
</p>
|
||||
<p className="text-xs font-mono">
|
||||
{HARD_BOUNCE_RISK_RATE}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
cursor={false}
|
||||
/>
|
||||
<Bar
|
||||
barSize={150}
|
||||
dataKey="value"
|
||||
stackId="a"
|
||||
fill={colors.clicked}
|
||||
radius={[8, 8, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="w-1/2 border rounded-xl shadow p-4">
|
||||
<div className=" flex items-center gap-2">
|
||||
<div className=" text-muted-foreground">Complaint Rate</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className=" h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="w-[300px]">
|
||||
The percentage of emails sent from your account that resulted in
|
||||
recipients reporting them as spam.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-4">
|
||||
<div className="text-2xl mt-2">{metrics?.complaintRate}%</div>
|
||||
<StatusBadge status={complaintStatus} />
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart
|
||||
data={complaintMetric}
|
||||
margin={{
|
||||
top: 20,
|
||||
left: -30,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<YAxis
|
||||
domain={[0, 0.8]}
|
||||
ticks={[0, 0.2, 0.4, 0.6, 0.8]}
|
||||
fontSize={12}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
hide={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<CartesianGrid
|
||||
vertical={false}
|
||||
strokeDasharray="3 3"
|
||||
stroke={`${colors.xaxis}50`}
|
||||
/>
|
||||
|
||||
<ReferenceLine
|
||||
y={COMPLAINED_WARNING_RATE}
|
||||
stroke={`${colors.complained}A0`}
|
||||
label={{
|
||||
value: "",
|
||||
position: "insideBottomLeft",
|
||||
fill: colors.complained,
|
||||
fontSize: 12,
|
||||
}}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
|
||||
{/* <ReferenceLine
|
||||
y={0}
|
||||
stroke={colors.xaxis}
|
||||
strokeDasharray="3 3"
|
||||
/> */}
|
||||
|
||||
<ReferenceLine
|
||||
y={COMPLAINED_RISK_RATE}
|
||||
stroke={`${colors.bounced}A0`}
|
||||
label={{
|
||||
value: ``,
|
||||
position: "insideBottomLeft",
|
||||
fill: colors.bounced,
|
||||
fontSize: 12,
|
||||
}}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<RechartsTooltip
|
||||
content={({ payload }) => {
|
||||
const data = payload?.[0]?.payload as {
|
||||
name: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.name}
|
||||
</p>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
style={{ background: colors.clicked }}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
Current
|
||||
</p>
|
||||
<p className="text-xs font-mono">{data.value}%</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
style={{ background: colors.complained }}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
Warning at
|
||||
</p>
|
||||
<p className="text-xs font-mono">
|
||||
{COMPLAINED_WARNING_RATE}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
style={{ background: colors.bounced }}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
Risk at
|
||||
</p>
|
||||
<p className="text-xs font-mono">
|
||||
{COMPLAINED_RISK_RATE}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
cursor={false}
|
||||
/>
|
||||
<Bar
|
||||
barSize={150}
|
||||
dataKey="value"
|
||||
stackId="a"
|
||||
fill={colors.clicked}
|
||||
radius={[8, 8, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const StatusBadge: React.FC<{ status: ACCOUNT_STATUS }> = ({
|
||||
status,
|
||||
}) => {
|
||||
const className =
|
||||
status === "HEALTHY"
|
||||
? " text-success border-success"
|
||||
: status === "WARNING"
|
||||
? " text-warning border-warning"
|
||||
: " text-destructive border-destructive";
|
||||
|
||||
const StatusIcon =
|
||||
status === "HEALTHY"
|
||||
? CheckCircle2Icon
|
||||
: status === "WARNING"
|
||||
? TriangleAlertIcon
|
||||
: OctagonAlertIcon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={` capitalize text-xs ${className} flex gap-1 items-center rounded-lg`}
|
||||
>
|
||||
<StatusIcon className="h-3.5 w-3.5" />
|
||||
{status.toLowerCase()}
|
||||
</div>
|
||||
);
|
||||
};
|
18
apps/web/src/lib/constants/colors.ts
Normal file
18
apps/web/src/lib/constants/colors.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { EmailStatus } from "@prisma/client";
|
||||
|
||||
export const EMAIL_COLORS: Record<EmailStatus | "total", string> = {
|
||||
total: "bg-gray-400 dark:bg-gray-400",
|
||||
DELIVERED: "bg-[#40a02b] dark:bg-[#a6e3a1]",
|
||||
BOUNCED: "bg-[#d20f39] dark:bg-[#f38ba8]",
|
||||
FAILED: "bg-[#d20f39] dark:bg-[#f38ba8]",
|
||||
CLICKED: "bg-[#04a5e5] dark:bg-[#93c5fd]",
|
||||
OPENED: "bg-[#8839ef] dark:bg-[#cba6f7]",
|
||||
COMPLAINED: "bg-[#df8e1d] dark:bg-[#F9E2AF]",
|
||||
DELIVERY_DELAYED: "bg-[#df8e1d] dark:bg-[#F9E2AF]",
|
||||
SENT: "bg-gray-200 dark:bg-gray-400",
|
||||
SCHEDULED: "bg-gray-200 dark:bg-gray-400",
|
||||
QUEUED: "bg-gray-200 dark:bg-gray-400",
|
||||
REJECTED: "bg-[#d20f39] dark:bg-[#f38ba8]",
|
||||
RENDERING_FAILURE: "bg-[#d20f39] dark:bg-[#f38ba8]",
|
||||
CANCELLED: "bg-gray-200 dark:bg-gray-400",
|
||||
};
|
@@ -1 +1,7 @@
|
||||
export const DEFAULT_QUERY_LIMIT = 30;
|
||||
|
||||
/* Reputation constants */
|
||||
export const HARD_BOUNCE_WARNING_RATE = 5;
|
||||
export const HARD_BOUNCE_RISK_RATE = 10;
|
||||
export const COMPLAINED_WARNING_RATE = 0.1;
|
||||
export const COMPLAINED_RISK_RATE = 0.5;
|
||||
|
@@ -9,6 +9,7 @@ import { campaignRouter } from "./routers/campaign";
|
||||
import { templateRouter } from "./routers/template";
|
||||
import { billingRouter } from "./routers/billing";
|
||||
import { invitationRouter } from "./routers/invitiation";
|
||||
import { dashboardRouter } from "./routers/dashboard";
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
@@ -26,6 +27,7 @@ export const appRouter = createTRPCRouter({
|
||||
template: templateRouter,
|
||||
billing: billingRouter,
|
||||
invitation: invitationRouter,
|
||||
dashboard: dashboardRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
140
apps/web/src/server/api/routers/dashboard.ts
Normal file
140
apps/web/src/server/api/routers/dashboard.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { format, subDays } from "date-fns";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
|
||||
import { db } from "~/server/db";
|
||||
|
||||
export const dashboardRouter = createTRPCRouter({
|
||||
emailTimeSeries: teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
days: z.number().optional(),
|
||||
domain: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { team } = ctx;
|
||||
const days = input.days !== 7 ? 30 : 7;
|
||||
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
const isoStartDate = startDate.toISOString().split("T")[0];
|
||||
|
||||
type DailyEmailUsage = {
|
||||
date: string;
|
||||
sent: number;
|
||||
delivered: number;
|
||||
opened: number;
|
||||
clicked: number;
|
||||
bounced: number;
|
||||
complained: number;
|
||||
};
|
||||
|
||||
const result = await db.$queryRaw<Array<DailyEmailUsage>>`
|
||||
SELECT
|
||||
date,
|
||||
SUM(sent)::integer AS sent,
|
||||
SUM(delivered)::integer AS delivered,
|
||||
SUM(opened)::integer AS opened,
|
||||
SUM(clicked)::integer AS clicked,
|
||||
SUM(bounced)::integer AS bounced,
|
||||
SUM(complained)::integer AS complained
|
||||
FROM "DailyEmailUsage"
|
||||
WHERE "teamId" = ${team.id}
|
||||
AND "date" >= ${isoStartDate}
|
||||
${input.domain ? Prisma.sql`AND "domainId" = ${input.domain}` : Prisma.sql``}
|
||||
GROUP BY "date"
|
||||
ORDER BY "date" ASC
|
||||
`;
|
||||
|
||||
// Fill in any missing dates with 0 values
|
||||
const filledResult: DailyEmailUsage[] = [];
|
||||
const endDateObj = new Date();
|
||||
|
||||
for (let i = days; i > -1; i--) {
|
||||
const dateStr = subDays(endDateObj, i)
|
||||
.toISOString()
|
||||
.split("T")[0] as string;
|
||||
const existingData = result.find((r) => r.date === dateStr);
|
||||
|
||||
if (existingData) {
|
||||
filledResult.push({
|
||||
...existingData,
|
||||
date: format(dateStr, "MMM dd"),
|
||||
});
|
||||
} else {
|
||||
filledResult.push({
|
||||
date: format(dateStr, "MMM dd"),
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
complained: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalCounts = result.reduce(
|
||||
(acc, curr) => {
|
||||
acc.sent += curr.sent;
|
||||
acc.delivered += curr.delivered;
|
||||
acc.opened += curr.opened;
|
||||
acc.clicked += curr.clicked;
|
||||
acc.bounced += curr.bounced;
|
||||
acc.complained += curr.complained;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
complained: 0,
|
||||
}
|
||||
);
|
||||
|
||||
return { result: filledResult, totalCounts };
|
||||
}),
|
||||
|
||||
reputationMetricsData: teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
domain: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { team } = ctx;
|
||||
|
||||
const reputations = await db.cumulatedMetrics.findMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
...(input.domain ? { domainId: input.domain } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const results = reputations.reduce(
|
||||
(acc, curr) => {
|
||||
acc.delivered += Number(curr.delivered);
|
||||
acc.hardBounced += Number(curr.hardBounced);
|
||||
acc.complained += Number(curr.complained);
|
||||
return acc;
|
||||
},
|
||||
{ delivered: 0, hardBounced: 0, complained: 0 }
|
||||
);
|
||||
|
||||
const resultWithRates = {
|
||||
...results,
|
||||
bounceRate: results.delivered
|
||||
? (results.hardBounced / results.delivered) * 100
|
||||
: 0,
|
||||
complaintRate: results.delivered
|
||||
? (results.complained / results.delivered) * 100
|
||||
: 0,
|
||||
};
|
||||
|
||||
return resultWithRates;
|
||||
}),
|
||||
});
|
@@ -61,99 +61,6 @@ export const emailRouter = createTRPCRouter({
|
||||
return { emails };
|
||||
}),
|
||||
|
||||
dashboard: teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
days: z.number().optional(),
|
||||
domain: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { team } = ctx;
|
||||
const days = input.days !== 7 ? 30 : 7;
|
||||
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
const isoStartDate = startDate.toISOString().split("T")[0];
|
||||
|
||||
type DailyEmailUsage = {
|
||||
date: string;
|
||||
sent: number;
|
||||
delivered: number;
|
||||
opened: number;
|
||||
clicked: number;
|
||||
bounced: number;
|
||||
complained: number;
|
||||
};
|
||||
|
||||
const result = await db.$queryRaw<Array<DailyEmailUsage>>`
|
||||
SELECT
|
||||
date,
|
||||
SUM(sent)::integer AS sent,
|
||||
SUM(delivered)::integer AS delivered,
|
||||
SUM(opened)::integer AS opened,
|
||||
SUM(clicked)::integer AS clicked,
|
||||
SUM(bounced)::integer AS bounced,
|
||||
SUM(complained)::integer AS complained
|
||||
FROM "DailyEmailUsage"
|
||||
WHERE "teamId" = ${team.id}
|
||||
AND "date" >= ${isoStartDate}
|
||||
${input.domain ? Prisma.sql`AND "domainId" = ${input.domain}` : Prisma.sql``}
|
||||
GROUP BY "date"
|
||||
ORDER BY "date" ASC
|
||||
`;
|
||||
|
||||
// Fill in any missing dates with 0 values
|
||||
const filledResult: DailyEmailUsage[] = [];
|
||||
const endDateObj = new Date();
|
||||
|
||||
for (let i = days; i > -1; i--) {
|
||||
const dateStr = subDays(endDateObj, i)
|
||||
.toISOString()
|
||||
.split("T")[0] as string;
|
||||
const existingData = result.find((r) => r.date === dateStr);
|
||||
|
||||
if (existingData) {
|
||||
filledResult.push({
|
||||
...existingData,
|
||||
date: format(dateStr, "MMM dd"),
|
||||
});
|
||||
} else {
|
||||
filledResult.push({
|
||||
date: format(dateStr, "MMM dd"),
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
complained: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalCounts = result.reduce(
|
||||
(acc, curr) => {
|
||||
acc.sent += curr.sent;
|
||||
acc.delivered += curr.delivered;
|
||||
acc.opened += curr.opened;
|
||||
acc.clicked += curr.clicked;
|
||||
acc.bounced += curr.bounced;
|
||||
acc.complained += curr.complained;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
complained: 0,
|
||||
}
|
||||
);
|
||||
|
||||
return { result: filledResult, totalCounts };
|
||||
}),
|
||||
|
||||
getEmail: emailProcedure.query(async ({ input }) => {
|
||||
const email = await db.email.findUnique({
|
||||
where: {
|
||||
|
@@ -66,8 +66,6 @@ export async function parseSesHook(data: SesEvent) {
|
||||
mailStatus === EmailStatus.BOUNCED &&
|
||||
(mailData as SesBounce).bounceType === "Permanent";
|
||||
|
||||
console.log("mailStatus", mailStatus, "isHardBounced", isHardBounced);
|
||||
|
||||
if (
|
||||
[
|
||||
"DELIVERED",
|
||||
@@ -109,6 +107,31 @@ export async function parseSesHook(data: SesEvent) {
|
||||
...(isHardBounced ? { hardBounced: { increment: 1 } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
isHardBounced ||
|
||||
updateField === "complained" ||
|
||||
updateField === "delivered"
|
||||
) {
|
||||
await db.cumulatedMetrics.upsert({
|
||||
where: {
|
||||
teamId_domainId: {
|
||||
teamId: email.teamId,
|
||||
domainId: email.domainId ?? 0,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
[updateField]: {
|
||||
increment: BigInt(1),
|
||||
},
|
||||
},
|
||||
create: {
|
||||
teamId: email.teamId,
|
||||
domainId: email.domainId ?? 0,
|
||||
[updateField]: BigInt(1),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (email.campaignId) {
|
||||
|
@@ -61,6 +61,10 @@ const config = {
|
||||
DEFAULT: "hsl(var(--success))",
|
||||
foreground: "hsl(var(--success-foreground))",
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: "hsl(var(--warning))",
|
||||
foreground: "hsl(var(--warning-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
|
@@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
"inline-flex h-9 items-center justify-center rounded-md bg-muted p-0.5 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
@@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 overflow-hidden rounded-xl border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
@@ -29,6 +29,9 @@
|
||||
--destructive: 347 62% 55%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--warning: 35 77% 49%;
|
||||
--warning-foreground: 210 40% 98%;
|
||||
|
||||
--success: 142 49% 44%;
|
||||
--success-foreground: 210 40% 98%;
|
||||
|
||||
@@ -55,7 +58,7 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 21% 15%;
|
||||
--background: 240 21% 12%;
|
||||
--foreground: 226 64% 88%;
|
||||
|
||||
--card: 222.2 84% 4.9%;
|
||||
@@ -82,6 +85,9 @@
|
||||
--success: 115 54% 76%;
|
||||
--success-foreground: 210 40% 98%;
|
||||
|
||||
--warning: 41 86% 83%;
|
||||
--warning-foreground: 210 40% 98%;
|
||||
|
||||
--border: 237 16% 23%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 217.2 32.6% 17.5%;
|
||||
@@ -92,7 +98,7 @@
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
|
||||
--sidebar-background: 240 21% 12%;
|
||||
--sidebar-background: 240 23% 9%;
|
||||
--sidebar-foreground: 226 64% 88%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
|
Reference in New Issue
Block a user