Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/components/analytics/AnalyticsOverview.tsx
T

330 lines
13 KiB
TypeScript

import { Link } from "react-router";
import { t as i18nT } from "i18next";
import clsx from "clsx";
import { lazy, Suspense, useEffect, useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import UrlUtils from "~/utils/app/UrlUtils";
import { useRootData } from "~/utils/data/useRootData";
import type { AnalyticsOverviewDto } from "~/utils/helpers/.server/AnalyticsService";
import { useLocaleFormatters } from "~/locale/format";
import { Card } from "../ui/card";
import { Label } from "../ui/label";
const AnalyticsOverviewChart = lazy(() => import("./AnalyticsOverviewChart"));
function joinRootUrl(rootUrl: string | undefined, path: string) {
if (!rootUrl) {
return path;
}
return `${rootUrl.replace(/\/+$/g, "")}/${path.replace(/^\/+/g, "")}`;
}
const MetricCard = ({ label, value }: { label: string; value: string | number }) => {
const formatters = useLocaleFormatters();
return (
<Card className="group-hover:bg-secondary mx-auto h-24 gap-3 p-4">
<Label>{label}</Label>
<div className="text-2xl font-semibold tracking-normal">{formatters.number(Number(value), { maximumFractionDigits: 0 })}</div>
</Card>
);
};
const Metrics = ({ authenticated, overview, rootUrl }: { authenticated: Boolean; overview: AnalyticsOverviewDto; rootUrl?: string }) => {
const { t } = useTranslation();
return (
<dl className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{authenticated ? (
<>
<Link to={joinRootUrl(rootUrl, "visitors")} className="group">
<MetricCard label={t("analytics.uniqueVisitors")} value={overview.uniqueVisitors} />
</Link>
<Link to={joinRootUrl(rootUrl, "page-views")} className="group">
<MetricCard label={t("analytics.pageViews")} value={overview.pageViews} />
</Link>
<Link to={joinRootUrl(rootUrl, "events")} className="group">
<MetricCard label={t("analytics.events")} value={overview.events} />
</Link>
</>
) : (
<>
<MetricCard label={t("analytics.uniqueVisitors")} value={overview.uniqueVisitors} />
<MetricCard label={t("analytics.pageViews")} value={overview.pageViews} />
<MetricCard label={t("analytics.events")} value={overview.events} />
</>
)}
<div>
<MetricCard label={t("analytics.liveVisitors")} value={overview.liveVisitors} />
</div>
</dl>
);
};
interface TopItemDto {
title: string;
items: { name: string | null; count: number; image?: any }[];
viewMoreRoute?: string;
fallbackName?: string;
bgClassName?: string;
tabTitle?: string;
}
function TopItemsData({ title, items, viewMoreRoute, fallbackName, bgClassName }: TopItemDto) {
const { authenticated } = useRootData();
const formatters = useLocaleFormatters();
const maxCount = useMemo(() => Math.max(0, ...items.map((item) => item.count)), [items]);
function getWidthPercentageCss(current: { count: number }) {
const percentage = maxCount > 0 ? (current.count / maxCount) * 100 : 0;
if (percentage >= 95) {
return "w-[95%]";
} else if (percentage >= 90) {
return "w-[90%]";
} else if (percentage >= 80) {
return "w-[80%]";
} else if (percentage >= 70) {
return "w-[70%]";
} else if (percentage >= 60) {
return "w-[60%]";
} else if (percentage >= 50) {
return "w-[50%]";
} else if (percentage >= 40) {
return "w-[40%]";
} else if (percentage >= 30) {
return "w-[30%]";
} else if (percentage >= 20) {
return "w-[20%]";
} else if (percentage >= 10) {
return "w-[10%]";
} else if (percentage >= 3) {
return "w-[3%]";
}
return "w-[3%]";
}
const renderTopItems = () => {
return items.map((item, idx) => (
<div key={idx} className="flex justify-between space-x-2">
<div className="w-full truncate">
<div className="flex items-center space-x-1">
<div
className={clsx("flex items-center space-x-2 overflow-visible px-2 py-0.5 text-sm", getWidthPercentageCss(item), bgClassName ?? "bg-orange-50")}
>
{item.image !== undefined && <div className="flex-shrink-0">{item.image}</div>}
{fallbackName ? <span>{!item.name ? fallbackName : item.name}</span> : <div>{item.name}</div>}
</div>
</div>
</div>
<div className="px-2 py-0.5 text-right text-sm font-extrabold">{formatters.number(item.count, { maximumFractionDigits: 0 })}</div>
</div>
));
};
return (
<>
<div className="h-48 space-y-1 overflow-y-auto">{renderTopItems()}</div>
{viewMoreRoute && authenticated && (
<Link to={viewMoreRoute} className="text-muted-foreground hover:text-foreground/80 flex justify-center p-1 text-xs font-medium underline">
{i18nT("analytics.viewMore")}
</Link>
)}
</>
);
}
function TopItems({ tabs }: { tabs: TopItemDto[] }) {
const [selectedTab, setSelectedTab] = useState(0);
const tab = tabs[selectedTab] ?? tabs[0];
const baseId = useId();
useEffect(() => {
if (selectedTab >= tabs.length) {
setSelectedTab(0);
}
}, [selectedTab, tabs.length]);
function selectAdjacentTab(offset: number) {
setSelectedTab((current) => (current + offset + tabs.length) % tabs.length);
}
return (
<div className="border-border bg-background min-w-0 space-y-1 rounded-md border px-4 py-2 shadow-sm">
<div className="flex min-w-0 items-center justify-between gap-2">
<h4 id={`${baseId}-heading`} className="min-w-0 truncate text-sm font-bold">
{tab.title}
</h4>
{tabs.length > 1 && (
<div role="tablist" aria-labelledby={`${baseId}-heading`} className="flex min-w-0 items-center gap-1 overflow-x-auto">
{tabs.map((item, idx) => {
const selected = selectedTab === idx;
return (
<button
key={idx}
id={`${baseId}-tab-${idx}`}
type="button"
role="tab"
aria-selected={selected}
aria-controls={`${baseId}-panel`}
tabIndex={selected ? 0 : -1}
onClick={() => setSelectedTab(idx)}
onKeyDown={(event) => {
if (event.key === "ArrowRight") {
event.preventDefault();
selectAdjacentTab(1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
selectAdjacentTab(-1);
} else if (event.key === "Home") {
event.preventDefault();
setSelectedTab(0);
} else if (event.key === "End") {
event.preventDefault();
setSelectedTab(tabs.length - 1);
}
}}
className={clsx(
"min-h-10 shrink-0 rounded-md px-2 py-1 text-xs font-medium whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
selected ? "text-theme-500 hover:text-theme-600 underline" : "text-muted-foreground hover:text-foreground"
)}
>
{item.tabTitle}
</button>
);
})}
</div>
)}
</div>
<div id={`${baseId}-panel`} role={tabs.length > 1 ? "tabpanel" : undefined} aria-labelledby={tabs.length > 1 ? `${baseId}-tab-${selectedTab}` : undefined}>
<TopItemsData {...tab} />
</div>
</div>
);
}
export default function AnalyticsOverview({ overview, withUsers, rootUrl }: { overview: AnalyticsOverviewDto; withUsers: boolean; rootUrl?: string }) {
const { authenticated } = useRootData();
const { t } = useTranslation();
return (
<div className="space-y-2">
<div className="w-64"></div>
<Metrics authenticated={authenticated} overview={overview} rootUrl={rootUrl} />
{overview.charts && (
<Suspense fallback={<Card className="h-56 animate-pulse p-4" />}>
<AnalyticsOverviewChart chartData={overview.charts.pageViewsAndUniqueVisitors} pageViewsLabel={t("analytics.pageViews")} uniqueVisitorsLabel={t("analytics.uniqueVisitors")} />
</Suspense>
)}
<div className={clsx("grid gap-2", withUsers && "md:grid-cols-2 lg:grid-cols-4", !withUsers && "md:grid-cols-2 lg:grid-cols-3")}>
<TopItems
tabs={[
{
title: t("analytics.topHttpReferrers"),
items: overview.top.httpReferrers.map((item) => {
return {
image:
!item.name || item.name === "Direct" ? (
<svg
className="text-muted-foreground h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75l3 3m0 0l3-3m-3 3v-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : item.name.startsWith("localhost") ? (
<svg
className="text-muted-foreground h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
) : (
<img className="h-4 w-4" src={`http://www.google.com/s2/favicons?domain=${item.name}&sz=256`} alt={item.name} />
),
name: UrlUtils.stripTrailingSlash(item.name?.replace("https://", "").replace("http://", "").replace("www.", "") ?? ""),
count: item.count,
};
}),
fallbackName: t("analytics.direct"),
bgClassName: "bg-orange-50 dark:bg-orange-900 dark:text-white",
},
]}
/>
<TopItems
tabs={[
{
title: t("analytics.topPages"),
items: overview.top.urls,
fallbackName: t("shared.notSet"),
bgClassName: "bg-emerald-50 dark:bg-emerald-900 dark:text-white",
tabTitle: t("analytics.pages"),
},
{
title: t("analytics.topRoutes"),
items: overview.top.routes,
fallbackName: t("shared.notSet"),
bgClassName: "bg-emerald-50 dark:bg-emerald-900 dark:text-white",
tabTitle: t("analytics.routes"),
},
]}
/>
<TopItems
tabs={[
{
tabTitle: t("analytics.source"),
title: t("analytics.topSources"),
items: overview.top.sources,
fallbackName: t("analytics.fallbacks.noReferrer"),
bgClassName: "bg-indigo-50 dark:bg-indigo-900 dark:text-white",
},
{
tabTitle: t("analytics.referrers"),
title: t("analytics.topReferrers"),
items: overview.top.via,
fallbackName: t("shared.notSet"),
bgClassName: "bg-teal-50 dark:bg-teal-900 dark:text-white",
},
{
tabTitle: t("analytics.medium"),
title: t("analytics.medium"),
items: overview.top.medium,
fallbackName: t("shared.notSet"),
bgClassName: "bg-indigo-50 dark:bg-indigo-900 dark:text-white",
},
{
tabTitle: t("analytics.campaigns"),
title: t("analytics.campaigns"),
items: overview.top.campaign,
fallbackName: t("shared.notSet"),
bgClassName: "bg-indigo-50 dark:bg-indigo-900 dark:text-white",
},
]}
/>
{withUsers && (
<TopItems
tabs={[
{
tabTitle: t("models.user.plural"),
title: t("models.user.plural"),
items: overview.top.user,
fallbackName: t("shared.notSet"),
bgClassName: "bg-indigo-50 dark:bg-indigo-900 dark:text-white",
},
]}
/>
)}
</div>
</div>
);
}