400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
import { ProjectLink } from '@/components/links';
|
|
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
|
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
|
import { DropdownMenuShortcut } from '@/components/ui/dropdown-menu';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { useTRPC } from '@/integrations/trpc/react';
|
|
import type { IChartData } from '@/trpc/client';
|
|
import { cn } from '@/utils/cn';
|
|
import { getProfileName } from '@/utils/getters';
|
|
import type { IChartInput } from '@openpanel/validation';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { popModal } from '.';
|
|
import { ModalHeader } from './Modal/Container';
|
|
import { ScrollableModal, useScrollableModal } from './Modal/scrollable-modal';
|
|
|
|
const ProfileItem = ({ profile }: { profile: any }) => {
|
|
console.log('ProfileItem', profile.id);
|
|
return (
|
|
<ProjectLink
|
|
preload={false}
|
|
href={`/profiles/${profile.id}`}
|
|
title={getProfileName(profile, false)}
|
|
className="col gap-2 rounded-lg border p-2 bg-card"
|
|
onClick={(e) => {
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
|
return;
|
|
}
|
|
popModal();
|
|
}}
|
|
>
|
|
<div className="row gap-2 items-center">
|
|
<ProfileAvatar {...profile} />
|
|
<div className="flex-1">
|
|
<div className="font-medium">{getProfileName(profile)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="row gap-4 text-sm overflow-hidden">
|
|
{profile.properties.country && (
|
|
<div className="row gap-2 items-center">
|
|
<SerieIcon name={profile.properties.country} />
|
|
<span>
|
|
{profile.properties.country}
|
|
{profile.properties.city && ` / ${profile.properties.city}`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{profile.properties.os && (
|
|
<div className="row gap-2 items-center">
|
|
<SerieIcon name={profile.properties.os} />
|
|
<span>{profile.properties.os}</span>
|
|
</div>
|
|
)}
|
|
{profile.properties.browser && (
|
|
<div className="row gap-2 items-center">
|
|
<SerieIcon name={profile.properties.browser} />
|
|
<span>{profile.properties.browser}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ProjectLink>
|
|
);
|
|
};
|
|
// Shared profile list component
|
|
function ProfileList({ profiles }: { profiles: any[] }) {
|
|
const ITEM_HEIGHT = 74;
|
|
const CONTAINER_PADDING = 20;
|
|
const ITEM_GAP = 5;
|
|
const { scrollAreaRef } = useScrollableModal();
|
|
const [isScrollReady, setIsScrollReady] = useState(false);
|
|
|
|
// Check if scroll container is ready
|
|
useEffect(() => {
|
|
if (scrollAreaRef.current) {
|
|
setIsScrollReady(true);
|
|
} else {
|
|
setIsScrollReady(false);
|
|
}
|
|
}, [scrollAreaRef]);
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: profiles.length,
|
|
getScrollElement: () => scrollAreaRef.current,
|
|
estimateSize: () => ITEM_HEIGHT + ITEM_GAP,
|
|
overscan: 5,
|
|
paddingStart: CONTAINER_PADDING,
|
|
paddingEnd: CONTAINER_PADDING,
|
|
});
|
|
|
|
// Re-measure when scroll container becomes available or profiles change
|
|
useEffect(() => {
|
|
if (isScrollReady && scrollAreaRef.current) {
|
|
// Small delay to ensure DOM is ready
|
|
const timeoutId = setTimeout(() => {
|
|
virtualizer.measure();
|
|
}, 0);
|
|
return () => clearTimeout(timeoutId);
|
|
}
|
|
}, [isScrollReady, profiles.length, virtualizer]);
|
|
|
|
if (profiles.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="text-muted-foreground">No users found</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const virtualItems = virtualizer.getVirtualItems();
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
height: `${virtualizer.getTotalSize()}px`,
|
|
width: '100%',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{/* Only the visible items in the virtualizer, manually positioned to be in view */}
|
|
{virtualItems.map((virtualItem) => {
|
|
const profile = profiles[virtualItem.index];
|
|
return (
|
|
<div
|
|
key={profile.id}
|
|
data-index={virtualItem.index}
|
|
ref={virtualizer.measureElement}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: `${virtualItem.size}px`,
|
|
transform: `translateY(${virtualItem.start}px)`,
|
|
padding: `0px ${CONTAINER_PADDING}px ${ITEM_GAP}px`,
|
|
}}
|
|
>
|
|
<ProfileItem profile={profile} />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Chart-specific props and component
|
|
interface ChartUsersViewProps {
|
|
chartData: IChartData;
|
|
report: IChartInput;
|
|
date: string;
|
|
}
|
|
|
|
function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
|
|
const trpc = useTRPC();
|
|
const [selectedSerieId, setSelectedSerieId] = useState<string | null>(
|
|
report.series[0]?.id || null,
|
|
);
|
|
const [selectedBreakdownId, setSelectedBreakdownId] = useState<string | null>(
|
|
null,
|
|
);
|
|
|
|
const selectedReportSerie = useMemo(
|
|
() => report.series.find((s) => s.id === selectedSerieId),
|
|
[report.series, selectedSerieId],
|
|
);
|
|
|
|
// Get all chart series that match the selected report serie
|
|
const matchingChartSeries = useMemo(() => {
|
|
if (!selectedSerieId || !chartData) return [];
|
|
return chartData.series.filter((s) => s.event.id === selectedSerieId);
|
|
}, [chartData?.series, selectedSerieId]);
|
|
|
|
const selectedBreakdown = useMemo(() => {
|
|
if (!selectedBreakdownId) return null;
|
|
return matchingChartSeries.find((s) => s.id === selectedBreakdownId);
|
|
}, [matchingChartSeries, selectedBreakdownId]);
|
|
|
|
// Reset breakdown selection when serie changes
|
|
const handleSerieChange = (value: string) => {
|
|
setSelectedSerieId(value);
|
|
setSelectedBreakdownId(null);
|
|
};
|
|
|
|
const profilesQuery = useQuery(
|
|
trpc.chart.getProfiles.queryOptions(
|
|
{
|
|
projectId: report.projectId,
|
|
date: date,
|
|
series:
|
|
selectedReportSerie && selectedReportSerie.type === 'event'
|
|
? [selectedReportSerie]
|
|
: [],
|
|
breakdowns: selectedBreakdown?.event.breakdowns,
|
|
interval: report.interval,
|
|
},
|
|
{
|
|
enabled: !!selectedReportSerie && selectedReportSerie.type === 'event',
|
|
},
|
|
),
|
|
);
|
|
|
|
const profiles = profilesQuery.data ?? [];
|
|
|
|
return (
|
|
<ScrollableModal
|
|
header={
|
|
<div>
|
|
<ModalHeader
|
|
title="View Users"
|
|
text={`Users who performed actions on ${new Date(date).toLocaleDateString()}`}
|
|
/>
|
|
{report.series.length > 0 && (
|
|
<div className="col md:row gap-2">
|
|
<Select
|
|
value={selectedSerieId || ''}
|
|
onValueChange={handleSerieChange}
|
|
>
|
|
<SelectTrigger className="flex-1">
|
|
<SelectValue placeholder="Select Serie" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{report.series.map((serie) => (
|
|
<SelectItem key={serie.id} value={serie.id || ''}>
|
|
{serie.type === 'event'
|
|
? serie.displayName || serie.name
|
|
: serie.displayName || 'Formula'}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{matchingChartSeries.length > 1 && (
|
|
<Select
|
|
value={selectedBreakdownId || ''}
|
|
onValueChange={(value) => setSelectedBreakdownId(value)}
|
|
>
|
|
<SelectTrigger className="flex-1">
|
|
<SelectValue placeholder="Select Breakdown" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{matchingChartSeries
|
|
.sort((a, b) => b.metrics.sum - a.metrics.sum)
|
|
.map((serie) => (
|
|
<SelectItem key={serie.id} value={serie.id}>
|
|
{Object.values(serie.event.breakdowns ?? {}).join(
|
|
', ',
|
|
)}
|
|
<DropdownMenuShortcut className="ml-auto">
|
|
({serie.data.find((d) => d.date === date)?.count})
|
|
</DropdownMenuShortcut>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
}
|
|
>
|
|
<div className="col">
|
|
{profilesQuery.isLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="text-muted-foreground">Loading users...</div>
|
|
</div>
|
|
) : (
|
|
<ProfileList profiles={profiles} />
|
|
)}
|
|
</div>
|
|
</ScrollableModal>
|
|
);
|
|
}
|
|
|
|
// Funnel-specific props and component
|
|
interface FunnelUsersViewProps {
|
|
report: IChartInput;
|
|
stepIndex: number;
|
|
}
|
|
|
|
function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
|
|
const trpc = useTRPC();
|
|
const [showDropoffs, setShowDropoffs] = useState(false);
|
|
|
|
const profilesQuery = useQuery(
|
|
trpc.chart.getFunnelProfiles.queryOptions(
|
|
{
|
|
projectId: report.projectId,
|
|
startDate: report.startDate!,
|
|
endDate: report.endDate!,
|
|
range: report.range,
|
|
series: report.series,
|
|
stepIndex: stepIndex,
|
|
showDropoffs: showDropoffs,
|
|
funnelWindow: report.funnelWindow,
|
|
funnelGroup: report.funnelGroup,
|
|
breakdowns: report.breakdowns,
|
|
},
|
|
{
|
|
enabled: stepIndex !== undefined,
|
|
},
|
|
),
|
|
);
|
|
|
|
const profiles = profilesQuery.data ?? [];
|
|
const isLastStep = stepIndex === report.series.length - 1;
|
|
|
|
return (
|
|
<ScrollableModal
|
|
header={
|
|
<div className="flex flex-col gap-2">
|
|
<ModalHeader
|
|
title="View Users"
|
|
text={
|
|
showDropoffs
|
|
? `Users who dropped off after step ${stepIndex + 1} of ${report.series.length}`
|
|
: `Users who completed step ${stepIndex + 1} of ${report.series.length} in the funnel`
|
|
}
|
|
/>
|
|
{!isLastStep && (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowDropoffs(false)}
|
|
className={cn(
|
|
'px-3 py-1.5 text-sm rounded-md transition-colors',
|
|
!showDropoffs
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-muted text-muted-foreground hover:bg-muted/80',
|
|
)}
|
|
>
|
|
Completed
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowDropoffs(true)}
|
|
className={cn(
|
|
'px-3 py-1.5 text-sm rounded-md transition-colors',
|
|
showDropoffs
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-muted text-muted-foreground hover:bg-muted/80',
|
|
)}
|
|
>
|
|
Dropped Off
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
{profilesQuery.isLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="text-muted-foreground">Loading users...</div>
|
|
</div>
|
|
) : (
|
|
<ProfileList profiles={profiles} />
|
|
)}
|
|
</div>
|
|
</ScrollableModal>
|
|
);
|
|
}
|
|
|
|
// Union type for props
|
|
type ViewChartUsersProps =
|
|
| {
|
|
type: 'chart';
|
|
chartData: IChartData;
|
|
report: IChartInput;
|
|
date: string;
|
|
}
|
|
| {
|
|
type: 'funnel';
|
|
report: IChartInput;
|
|
stepIndex: number;
|
|
};
|
|
|
|
// Main component that routes to the appropriate view
|
|
export default function ViewChartUsers(props: ViewChartUsersProps) {
|
|
if (props.type === 'funnel') {
|
|
return (
|
|
<FunnelUsersView report={props.report} stepIndex={props.stepIndex} />
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ChartUsersView
|
|
chartData={props.chartData}
|
|
report={props.report}
|
|
date={props.date}
|
|
/>
|
|
);
|
|
}
|