Files
stats/apps/start/src/components/overview/overview-list-modal.tsx
2026-01-09 14:42:11 +01:00

265 lines
8.8 KiB
TypeScript

import { useNumber } from '@/hooks/use-numer-formatter';
import { ModalContent } from '@/modals/Modal/Container';
import { cn } from '@/utils/cn';
import { DialogTitle } from '@radix-ui/react-dialog';
import { useVirtualizer } from '@tanstack/react-virtual';
import { SearchIcon } from 'lucide-react';
import React, { useMemo, useRef, useState } from 'react';
import { Input } from '../ui/input';
const ROW_HEIGHT = 36;
// Revenue pie chart component
function RevenuePieChart({ percentage }: { percentage: number }) {
const size = 16;
const strokeWidth = 2;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - percentage * circumference;
return (
<svg width={size} height={size} className="flex-shrink-0">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-def-200"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#3ba974"
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
// Base data type that all items must conform to
export interface OverviewListItem {
sessions: number;
pageviews: number;
revenue?: number;
}
interface OverviewListModalProps<T extends OverviewListItem> {
/** Modal title */
title: string;
/** Search placeholder text */
searchPlaceholder?: string;
/** The data to display */
data: T[];
/** Extract a unique key for each item */
keyExtractor: (item: T) => string;
/** Filter function for search - receives item and lowercase search query */
searchFilter: (item: T, query: string) => boolean;
/** Render the main content cell (first column) */
renderItem: (item: T) => React.ReactNode;
/** Optional footer content */
footer?: React.ReactNode;
/** Optional header content (appears below title/search) */
headerContent?: React.ReactNode;
/** Column name for the first column */
columnName?: string;
/** Whether to show pageviews column */
showPageviews?: boolean;
/** Whether to show sessions column */
showSessions?: boolean;
}
export function OverviewListModal<T extends OverviewListItem>({
title,
searchPlaceholder = 'Search...',
data,
keyExtractor,
searchFilter,
renderItem,
footer,
headerContent,
columnName = 'Name',
showPageviews = true,
showSessions = true,
}: OverviewListModalProps<T>) {
const [searchQuery, setSearchQuery] = useState('');
const scrollAreaRef = useRef<HTMLDivElement>(null);
const number = useNumber();
// Filter data based on search query
const filteredData = useMemo(() => {
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter((item) => searchFilter(item, queryLower));
}, [data, searchQuery, searchFilter]);
// Calculate totals and check for revenue
const { maxSessions, totalRevenue, hasRevenue, hasPageviews } =
useMemo(() => {
const maxSessions = Math.max(...filteredData.map((item) => item.sessions));
const totalRevenue = filteredData.reduce(
(sum, item) => sum + (item.revenue ?? 0),
0,
);
const hasRevenue = filteredData.some((item) => (item.revenue ?? 0) > 0);
const hasPageviews =
showPageviews && filteredData.some((item) => item.pageviews > 0);
return { maxSessions, totalRevenue, hasRevenue, hasPageviews };
}, [filteredData, showPageviews]);
// Virtual list setup
const virtualizer = useVirtualizer({
count: filteredData.length,
getScrollElement: () => scrollAreaRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 10,
});
const virtualItems = virtualizer.getVirtualItems();
return (
<ModalContent className="flex !max-h-[90vh] flex-col p-0 gap-0 sm:max-w-2xl">
{/* Sticky Header */}
<div className="flex-shrink-0 border-b border-border">
<div className="p-6 pb-4">
<DialogTitle className="text-lg font-semibold mb-4">
{title}
</DialogTitle>
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder={searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{headerContent}
</div>
{/* Column Headers */}
<div
className="grid px-4 py-2 text-sm font-medium text-muted-foreground bg-def-100"
style={{
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
}}
>
<div className="text-left truncate">{columnName}</div>
{hasRevenue && <div className="text-right">Revenue</div>}
{hasPageviews && <div className="text-right">Views</div>}
{showSessions && <div className="text-right">Sessions</div>}
</div>
</div>
{/* Virtualized Scrollable Body */}
<div
ref={scrollAreaRef}
className="flex-1 min-h-0 overflow-y-auto"
style={{ maxHeight: '60vh' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualRow) => {
const item = filteredData[virtualRow.index];
if (!item) return null;
const percentage = item.sessions / maxSessions;
const revenuePercentage =
totalRevenue > 0 ? (item.revenue ?? 0) / totalRevenue : 0;
return (
<div
key={keyExtractor(item)}
className="absolute top-0 left-0 w-full group/row"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{/* Background bar */}
<div className="absolute inset-0 overflow-hidden">
<div
className="h-full bg-def-200 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors"
style={{ width: `${percentage * 100}%` }}
/>
</div>
{/* Row content */}
<div
className="relative grid h-full items-center px-4 border-b border-border"
style={{
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
}}
>
{/* Main content cell */}
<div className="min-w-0 truncate pr-2">{renderItem(item)}</div>
{/* Revenue cell */}
{hasRevenue && (
<div className="flex items-center justify-end gap-2">
<span
className="font-semibold font-mono text-sm"
style={{ color: '#3ba974' }}
>
{(item.revenue ?? 0) > 0
? number.currency((item.revenue ?? 0) / 100, {
short: true,
})
: '-'}
</span>
<RevenuePieChart percentage={revenuePercentage} />
</div>
)}
{/* Pageviews cell */}
{hasPageviews && (
<div className="text-right font-semibold font-mono text-sm">
{number.short(item.pageviews)}
</div>
)}
{/* Sessions cell */}
{showSessions && (
<div className="text-right font-semibold font-mono text-sm">
{number.short(item.sessions)}
</div>
)}
</div>
</div>
);
})}
</div>
{/* Empty state */}
{filteredData.length === 0 && (
<div className="flex items-center justify-center h-32 text-muted-foreground">
{searchQuery ? 'No results found' : 'No data available'}
</div>
)}
</div>
{/* Fixed Footer */}
{footer && (
<div className="flex-shrink-0 border-t border-border p-4">{footer}</div>
)}
</ModalContent>
);
}