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;
|
@@ -375,3 +375,13 @@ model DailyEmailUsage {
|
|||||||
|
|
||||||
@@id([teamId, domainId, date, type])
|
@@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";
|
"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() {
|
export default function Dashboard() {
|
||||||
|
const [days, setDays] = useUrlState("days", "7");
|
||||||
|
const [domain, setDomain] = useUrlState("domain");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="w-full ">
|
<div className="w-full">
|
||||||
<DashboardChart />
|
<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>
|
||||||
</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;
|
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 { templateRouter } from "./routers/template";
|
||||||
import { billingRouter } from "./routers/billing";
|
import { billingRouter } from "./routers/billing";
|
||||||
import { invitationRouter } from "./routers/invitiation";
|
import { invitationRouter } from "./routers/invitiation";
|
||||||
|
import { dashboardRouter } from "./routers/dashboard";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
@@ -26,6 +27,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
template: templateRouter,
|
template: templateRouter,
|
||||||
billing: billingRouter,
|
billing: billingRouter,
|
||||||
invitation: invitationRouter,
|
invitation: invitationRouter,
|
||||||
|
dashboard: dashboardRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// 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 };
|
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 }) => {
|
getEmail: emailProcedure.query(async ({ input }) => {
|
||||||
const email = await db.email.findUnique({
|
const email = await db.email.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
@@ -66,8 +66,6 @@ export async function parseSesHook(data: SesEvent) {
|
|||||||
mailStatus === EmailStatus.BOUNCED &&
|
mailStatus === EmailStatus.BOUNCED &&
|
||||||
(mailData as SesBounce).bounceType === "Permanent";
|
(mailData as SesBounce).bounceType === "Permanent";
|
||||||
|
|
||||||
console.log("mailStatus", mailStatus, "isHardBounced", isHardBounced);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
"DELIVERED",
|
"DELIVERED",
|
||||||
@@ -109,6 +107,31 @@ export async function parseSesHook(data: SesEvent) {
|
|||||||
...(isHardBounced ? { hardBounced: { increment: 1 } } : {}),
|
...(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) {
|
if (email.campaignId) {
|
||||||
|
@@ -61,6 +61,10 @@ const config = {
|
|||||||
DEFAULT: "hsl(var(--success))",
|
DEFAULT: "hsl(var(--success))",
|
||||||
foreground: "hsl(var(--success-foreground))",
|
foreground: "hsl(var(--success-foreground))",
|
||||||
},
|
},
|
||||||
|
warning: {
|
||||||
|
DEFAULT: "hsl(var(--warning))",
|
||||||
|
foreground: "hsl(var(--warning-foreground))",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
|
@@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
|
|||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
@@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
@@ -29,6 +29,9 @@
|
|||||||
--destructive: 347 62% 55%;
|
--destructive: 347 62% 55%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--warning: 35 77% 49%;
|
||||||
|
--warning-foreground: 210 40% 98%;
|
||||||
|
|
||||||
--success: 142 49% 44%;
|
--success: 142 49% 44%;
|
||||||
--success-foreground: 210 40% 98%;
|
--success-foreground: 210 40% 98%;
|
||||||
|
|
||||||
@@ -55,7 +58,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 240 21% 15%;
|
--background: 240 21% 12%;
|
||||||
--foreground: 226 64% 88%;
|
--foreground: 226 64% 88%;
|
||||||
|
|
||||||
--card: 222.2 84% 4.9%;
|
--card: 222.2 84% 4.9%;
|
||||||
@@ -82,6 +85,9 @@
|
|||||||
--success: 115 54% 76%;
|
--success: 115 54% 76%;
|
||||||
--success-foreground: 210 40% 98%;
|
--success-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--warning: 41 86% 83%;
|
||||||
|
--warning-foreground: 210 40% 98%;
|
||||||
|
|
||||||
--border: 237 16% 23%;
|
--border: 237 16% 23%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 217.2 32.6% 17.5%;
|
||||||
--ring: 217.2 32.6% 17.5%;
|
--ring: 217.2 32.6% 17.5%;
|
||||||
@@ -92,7 +98,7 @@
|
|||||||
--chart-4: 280 65% 60%;
|
--chart-4: 280 65% 60%;
|
||||||
--chart-5: 340 75% 55%;
|
--chart-5: 340 75% 55%;
|
||||||
|
|
||||||
--sidebar-background: 240 21% 12%;
|
--sidebar-background: 240 23% 9%;
|
||||||
--sidebar-foreground: 226 64% 88%;
|
--sidebar-foreground: 226 64% 88%;
|
||||||
--sidebar-primary: 224.3 76.3% 48%;
|
--sidebar-primary: 224.3 76.3% 48%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
Reference in New Issue
Block a user