feat: share dashboard & reports, sankey report, new widgets
* fix: prompt card shadows on light mode * fix: handle past_due and unpaid from polar * wip * wip * wip 1 * fix: improve types for chart/reports * wip share
This commit is contained in:
committed by
GitHub
parent
39251c8598
commit
ed1c57dbb8
@@ -1,20 +1,6 @@
|
||||
import type { NumberFlowProps } from '@number-flow/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ReactAnimatedNumber from '@number-flow/react';
|
||||
|
||||
// NumberFlow is breaking ssr and forces loaders to fetch twice
|
||||
export function AnimatedNumber(props: NumberFlowProps) {
|
||||
const [Component, setComponent] =
|
||||
useState<React.ComponentType<NumberFlowProps> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
import('@number-flow/react').then(({ default: NumberFlow }) => {
|
||||
setComponent(NumberFlow);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!Component) {
|
||||
return <>{props.value}</>;
|
||||
}
|
||||
|
||||
return <Component {...props} />;
|
||||
return <ReactAnimatedNumber {...props} />;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,13 @@ import { LogoSquare } from '../logo';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
export function ShareEnterPassword({ shareId }: { shareId: string }) {
|
||||
export function ShareEnterPassword({
|
||||
shareId,
|
||||
shareType = 'overview',
|
||||
}: {
|
||||
shareId: string;
|
||||
shareType?: 'overview' | 'dashboard' | 'report';
|
||||
}) {
|
||||
const trpc = useTRPC();
|
||||
const mutation = useMutation(
|
||||
trpc.auth.signInShare.mutationOptions({
|
||||
@@ -25,6 +31,7 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
|
||||
defaultValues: {
|
||||
password: '',
|
||||
shareId,
|
||||
shareType,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -32,6 +39,7 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
|
||||
mutation.mutate({
|
||||
password: data.password,
|
||||
shareId,
|
||||
shareType,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,9 +48,20 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
|
||||
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
|
||||
<div className="col mt-1 flex-1 gap-2">
|
||||
<LogoSquare className="size-12 mb-4" />
|
||||
<div className="text-xl font-semibold">Overview is locked</div>
|
||||
<div className="text-xl font-semibold">
|
||||
{shareType === 'dashboard'
|
||||
? 'Dashboard is locked'
|
||||
: shareType === 'report'
|
||||
? 'Report is locked'
|
||||
: 'Overview is locked'}
|
||||
</div>
|
||||
<div className="text-lg text-muted-foreground leading-normal">
|
||||
Please enter correct password to access this overview
|
||||
Please enter correct password to access this{' '}
|
||||
{shareType === 'dashboard'
|
||||
? 'dashboard'
|
||||
: shareType === 'report'
|
||||
? 'report'
|
||||
: 'overview'}
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="col gap-4 mt-6">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Markdown } from '@/components/markdown';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { zChartInputAI } from '@openpanel/validation';
|
||||
import { zReport } from '@openpanel/validation';
|
||||
import { z } from 'zod';
|
||||
import type { UIMessage } from 'ai';
|
||||
import { Loader2Icon, UserIcon } from 'lucide-react';
|
||||
import { Fragment, memo } from 'react';
|
||||
@@ -77,7 +78,10 @@ export const ChatMessage = memo(
|
||||
const { result } = p.toolInvocation;
|
||||
|
||||
if (result.type === 'report') {
|
||||
const report = zChartInputAI.safeParse(result.report);
|
||||
const report = zReport.extend({
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
}).safeParse(result.report);
|
||||
if (report.success) {
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pushModal } from '@/modals';
|
||||
import type {
|
||||
IChartInputAi,
|
||||
IReport,
|
||||
IChartRange,
|
||||
IChartType,
|
||||
IInterval,
|
||||
@@ -16,7 +16,7 @@ import { Button } from '../ui/button';
|
||||
export function ChatReport({
|
||||
lazy,
|
||||
...props
|
||||
}: { report: IChartInputAi; lazy: boolean }) {
|
||||
}: { report: IReport & { startDate: string; endDate: string }; lazy: boolean }) {
|
||||
const [chartType, setChartType] = useState<IChartType>(
|
||||
props.report.chartType,
|
||||
);
|
||||
|
||||
95
apps/start/src/components/grafana-grid.tsx
Normal file
95
apps/start/src/components/grafana-grid.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { IServiceReport } from '@openpanel/db';
|
||||
import { useMemo } from 'react';
|
||||
import { Responsive, WidthProvider } from 'react-grid-layout';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(Responsive);
|
||||
|
||||
export type Layout = ReactGridLayout.Layout;
|
||||
|
||||
export const useReportLayouts = (
|
||||
reports: NonNullable<IServiceReport>[],
|
||||
): ReactGridLayout.Layouts => {
|
||||
return useMemo(() => {
|
||||
const baseLayout = reports.map((report, index) => ({
|
||||
i: report.id,
|
||||
x: report.layout?.x ?? (index % 2) * 6,
|
||||
y: report.layout?.y ?? Math.floor(index / 2) * 4,
|
||||
w: report.layout?.w ?? 6,
|
||||
h: report.layout?.h ?? 4,
|
||||
minW: 3,
|
||||
minH: 3,
|
||||
}));
|
||||
|
||||
return {
|
||||
lg: baseLayout,
|
||||
md: baseLayout,
|
||||
sm: baseLayout.map((item) => ({ ...item, w: Math.min(item.w, 6) })),
|
||||
xs: baseLayout.map((item) => ({ ...item, w: 4, x: 0 })),
|
||||
xxs: baseLayout.map((item) => ({ ...item, w: 2, x: 0 })),
|
||||
};
|
||||
}, [reports]);
|
||||
};
|
||||
|
||||
export function GrafanaGrid({
|
||||
layouts,
|
||||
children,
|
||||
transitions,
|
||||
onLayoutChange,
|
||||
onDragStop,
|
||||
onResizeStop,
|
||||
isDraggable,
|
||||
isResizable,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
transitions?: boolean;
|
||||
} & Pick<
|
||||
ReactGridLayout.ResponsiveProps,
|
||||
| 'layouts'
|
||||
| 'onLayoutChange'
|
||||
| 'onDragStop'
|
||||
| 'onResizeStop'
|
||||
| 'isDraggable'
|
||||
| 'isResizable'
|
||||
>) {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.react-grid-item {
|
||||
transition: ${transitions ? 'transform 200ms ease, width 200ms ease, height 200ms ease' : 'none'} !important;
|
||||
}
|
||||
.react-grid-item.react-grid-placeholder {
|
||||
background: none !important;
|
||||
opacity: 0.5;
|
||||
transition-duration: 100ms;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px dashed var(--primary);
|
||||
}
|
||||
.react-grid-item.resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
`}</style>
|
||||
<div className="-m-4">
|
||||
<ResponsiveGridLayout
|
||||
className="layout"
|
||||
layouts={layouts}
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
|
||||
rowHeight={100}
|
||||
draggableHandle=".drag-handle"
|
||||
compactType="vertical"
|
||||
preventCollision={false}
|
||||
margin={[16, 16]}
|
||||
transformScale={1}
|
||||
useCSSTransforms={true}
|
||||
onLayoutChange={onLayoutChange}
|
||||
onDragStop={onDragStop}
|
||||
onResizeStop={onResizeStop}
|
||||
isDraggable={isDraggable}
|
||||
isResizable={isResizable}
|
||||
>
|
||||
{children}
|
||||
</ResponsiveGridLayout>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function LoginNavbar({ className }: { className?: string }) {
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://openpanel.dev/compare/mixpanel-alternative">
|
||||
<a href="https://openpanel.dev/compare/posthog-alternative">
|
||||
Posthog alternative
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function PromptCard({
|
||||
}}
|
||||
className="fixed bottom-0 right-0 z-50 p-4 max-w-sm"
|
||||
>
|
||||
<div className="bg-card border rounded-lg shadow-[0_0_100px_50px_rgba(20,20,20,1)] col gap-6 py-6 overflow-hidden">
|
||||
<div className="bg-card border rounded-lg shadow-[0_0_100px_50px_var(--color-background)] col gap-6 py-6 overflow-hidden">
|
||||
<div className="relative px-6 col gap-1">
|
||||
<div
|
||||
className="absolute -bottom-10 -right-10 h-64 w-64 rounded-full opacity-30 blur-3xl pointer-events-none"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { IChartInput } from '@openpanel/validation';
|
||||
import type { IReportInput } from '@openpanel/validation';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -74,7 +74,7 @@ export default function OverviewTopEvents({
|
||||
},
|
||||
});
|
||||
|
||||
const report: IChartInput = useMemo(
|
||||
const report: IReportInput = useMemo(
|
||||
() => ({
|
||||
limit: 1000,
|
||||
projectId,
|
||||
@@ -96,9 +96,7 @@ export default function OverviewTopEvents({
|
||||
},
|
||||
],
|
||||
chartType: 'bar' as const,
|
||||
lineType: 'monotone' as const,
|
||||
interval,
|
||||
name: widget.title,
|
||||
range,
|
||||
previous,
|
||||
metric: 'sum' as const,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import { ReportChart } from '../report-chart';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { ReportChartShortcut } from '../report-chart/shortcut';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
@@ -210,9 +211,8 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
<div className="title">Map</div>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<ReportChart
|
||||
options={{ hideID: true }}
|
||||
report={{
|
||||
<ReportChartShortcut
|
||||
{...{
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
@@ -232,12 +232,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
},
|
||||
],
|
||||
chartType: 'map',
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
}}
|
||||
/>
|
||||
</WidgetBody>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ReportChart } from '@/components/report-chart';
|
||||
import { Widget, WidgetBody } from '@/components/widget';
|
||||
import { memo } from 'react';
|
||||
|
||||
import type { IChartProps } from '@openpanel/validation';
|
||||
import type { IReport } from '@openpanel/validation';
|
||||
import { WidgetHead } from '../overview/overview-widget';
|
||||
|
||||
type Props = {
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
|
||||
export const ProfileCharts = memo(
|
||||
({ profileId, projectId }: Props) => {
|
||||
const pageViewsChart: IChartProps = {
|
||||
const pageViewsChart: IReport = {
|
||||
projectId,
|
||||
chartType: 'linear',
|
||||
series: [
|
||||
@@ -46,7 +46,7 @@ export const ProfileCharts = memo(
|
||||
metric: 'sum',
|
||||
};
|
||||
|
||||
const eventsChart: IChartProps = {
|
||||
const eventsChart: IReport = {
|
||||
projectId,
|
||||
chartType: 'linear',
|
||||
series: [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportAreaChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
trpc.chart.chart.queryOptions(
|
||||
{
|
||||
...report,
|
||||
shareId,
|
||||
reportId: 'id' in report ? report.id : undefined,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? report.startDate,
|
||||
endDate: endDate ?? report.endDate,
|
||||
interval: interval ?? report.interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportBarChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.aggregate.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
trpc.chart.aggregate.queryOptions(
|
||||
{
|
||||
...report,
|
||||
shareId,
|
||||
reportId: 'id' in report ? report.id : undefined,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? report.startDate,
|
||||
endDate: endDate ?? report.endDate,
|
||||
interval: interval ?? report.interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -42,10 +42,10 @@ export function PreviousDiffIndicator({
|
||||
className,
|
||||
}: PreviousDiffIndicatorProps) {
|
||||
const {
|
||||
report: { previousIndicatorInverted, previous },
|
||||
report: { previous },
|
||||
} = useReportChartContext();
|
||||
const variant = getDiffIndicator(
|
||||
inverted ?? previousIndicatorInverted,
|
||||
inverted,
|
||||
state,
|
||||
'bg-emerald-300',
|
||||
'bg-rose-300',
|
||||
|
||||
@@ -2,16 +2,11 @@ import isEqual from 'lodash.isequal';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import type {
|
||||
IChartInput,
|
||||
IChartProps,
|
||||
IChartSerie,
|
||||
} from '@openpanel/validation';
|
||||
import type { IChartSerie, IReportInput } from '@openpanel/validation';
|
||||
|
||||
export type ReportChartContextType = {
|
||||
options: Partial<{
|
||||
columns: React.ReactNode[];
|
||||
hideID: boolean;
|
||||
hideLegend: boolean;
|
||||
hideXAxis: boolean;
|
||||
hideYAxis: boolean;
|
||||
@@ -28,9 +23,11 @@ export type ReportChartContextType = {
|
||||
onClick: () => void;
|
||||
}[];
|
||||
}>;
|
||||
report: IChartProps;
|
||||
report: IReportInput & { id?: string };
|
||||
isLazyLoading: boolean;
|
||||
isEditMode: boolean;
|
||||
shareId?: string;
|
||||
reportId?: string;
|
||||
};
|
||||
|
||||
type ReportChartContextProviderProps = ReportChartContextType & {
|
||||
@@ -38,7 +35,7 @@ type ReportChartContextProviderProps = ReportChartContextType & {
|
||||
};
|
||||
|
||||
export type ReportChartProps = Partial<ReportChartContextType> & {
|
||||
report: IChartInput;
|
||||
report: IReportInput & { id?: string };
|
||||
lazy?: boolean;
|
||||
};
|
||||
|
||||
@@ -54,20 +51,6 @@ export const useReportChartContext = () => {
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const useSelectReportChartContext = <T,>(
|
||||
selector: (ctx: ReportChartContextType) => T,
|
||||
) => {
|
||||
const ctx = useReportChartContext();
|
||||
const [state, setState] = useState(selector(ctx));
|
||||
useEffect(() => {
|
||||
const newState = selector(ctx);
|
||||
if (!isEqual(newState, state)) {
|
||||
setState(newState);
|
||||
}
|
||||
}, [ctx]);
|
||||
return state;
|
||||
};
|
||||
|
||||
export const ReportChartProvider = ({
|
||||
children,
|
||||
...propsToContext
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
@@ -11,15 +12,27 @@ import { Chart } from './chart';
|
||||
import { Summary } from './summary';
|
||||
|
||||
export function ReportConversionChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
console.log(report.limit);
|
||||
const res = useQuery(
|
||||
trpc.chart.conversion.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
trpc.chart.conversion.queryOptions(
|
||||
{
|
||||
...report,
|
||||
shareId,
|
||||
reportId: 'id' in report ? report.id : undefined,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? report.startDate,
|
||||
endDate: endDate ?? report.endDate,
|
||||
interval: interval ?? report.interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -131,34 +131,36 @@ export function Tables({
|
||||
series: reportSeries,
|
||||
breakdowns: reportBreakdowns,
|
||||
previous,
|
||||
funnelWindow,
|
||||
funnelGroup,
|
||||
options,
|
||||
},
|
||||
} = useReportChartContext();
|
||||
|
||||
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||
const funnelWindow = funnelOptions?.funnelWindow;
|
||||
const funnelGroup = funnelOptions?.funnelGroup;
|
||||
|
||||
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
|
||||
if (!projectId || !step.event.id) return;
|
||||
|
||||
// For funnels, we need to pass the step index so the modal can query
|
||||
// users who completed at least that step in the funnel sequence
|
||||
pushModal('ViewChartUsers', {
|
||||
type: 'funnel',
|
||||
report: {
|
||||
projectId,
|
||||
series: reportSeries,
|
||||
breakdowns: reportBreakdowns || [],
|
||||
interval: interval || 'day',
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
chartType: 'funnel',
|
||||
metric: 'sum',
|
||||
funnelWindow,
|
||||
funnelGroup,
|
||||
},
|
||||
stepIndex, // Pass the step index for funnel queries
|
||||
});
|
||||
pushModal('ViewChartUsers', {
|
||||
type: 'funnel',
|
||||
report: {
|
||||
projectId,
|
||||
series: reportSeries,
|
||||
breakdowns: reportBreakdowns || [],
|
||||
interval: interval || 'day',
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
chartType: 'funnel',
|
||||
metric: 'sum',
|
||||
options: funnelOptions,
|
||||
},
|
||||
stepIndex, // Pass the step index for funnel queries
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className={cn('col @container divide-y divide-border card')}>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useTRPC } from '@/integrations/trpc/react';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { IChartInput } from '@openpanel/validation';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import type { IReportInput } from '@openpanel/validation';
|
||||
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
@@ -14,35 +15,39 @@ import { Chart, Summary, Tables } from './chart';
|
||||
export function ReportFunnelChart() {
|
||||
const {
|
||||
report: {
|
||||
id,
|
||||
series,
|
||||
range,
|
||||
projectId,
|
||||
funnelWindow,
|
||||
funnelGroup,
|
||||
options,
|
||||
startDate,
|
||||
endDate,
|
||||
previous,
|
||||
breakdowns,
|
||||
interval,
|
||||
},
|
||||
isLazyLoading,
|
||||
shareId,
|
||||
} = useReportChartContext();
|
||||
const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions();
|
||||
|
||||
const input: IChartInput = {
|
||||
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||
|
||||
const trpc = useTRPC();
|
||||
const input: IReportInput = {
|
||||
series,
|
||||
range,
|
||||
range: overviewRange ?? range,
|
||||
projectId,
|
||||
interval: 'day',
|
||||
interval: overviewInterval ?? interval ?? 'day',
|
||||
chartType: 'funnel',
|
||||
breakdowns,
|
||||
funnelWindow,
|
||||
funnelGroup,
|
||||
previous,
|
||||
metric: 'sum',
|
||||
startDate,
|
||||
endDate,
|
||||
startDate: overviewStartDate ?? startDate,
|
||||
endDate: overviewEndDate ?? endDate,
|
||||
limit: 20,
|
||||
options: funnelOptions,
|
||||
};
|
||||
const trpc = useTRPC();
|
||||
const res = useQuery(
|
||||
trpc.chart.funnel.queryOptions(input, {
|
||||
enabled: !isLazyLoading && input.series.length > 0,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportHistogramChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
trpc.chart.chart.queryOptions(
|
||||
{
|
||||
...report,
|
||||
shareId,
|
||||
reportId: 'id' in report ? report.id : undefined,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? report.startDate,
|
||||
endDate: endDate ?? report.endDate,
|
||||
interval: interval ?? report.interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ReportMapChart } from './map';
|
||||
import { ReportMetricChart } from './metric';
|
||||
import { ReportPieChart } from './pie';
|
||||
import { ReportRetentionChart } from './retention';
|
||||
import { ReportSankeyChart } from './sankey';
|
||||
|
||||
export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -57,6 +58,8 @@ export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
|
||||
return <ReportRetentionChart />;
|
||||
case 'conversion':
|
||||
return <ReportConversionChart />;
|
||||
case 'sankey':
|
||||
return <ReportSankeyChart />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
@@ -10,15 +11,27 @@ import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportLineChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
trpc.chart.chart.queryOptions(
|
||||
{
|
||||
...report,
|
||||
shareId,
|
||||
reportId: 'id' in report ? report.id : undefined,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? report.startDate,
|
||||
endDate: endDate ?? report.endDate,
|
||||
interval: interval ?? report.interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportMapChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
trpc.chart.chart.queryOptions(
|
||||
{
|
||||
...report,
|
||||
shareId,
|
||||
reportId: 'id' in report ? report.id : undefined,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? report.startDate,
|
||||
endDate: endDate ?? report.endDate,
|
||||
interval: interval ?? report.interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
@@ -8,15 +9,27 @@ import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportMetricChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
trpc.chart.chart.queryOptions(
|
||||
{
|
||||
...report,
|
||||
shareId,
|
||||
reportId: 'id' in report ? report.id : undefined,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? report.startDate,
|
||||
endDate: endDate ?? report.endDate,
|
||||
interval: interval ?? report.interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -54,10 +54,7 @@ export function MetricCard({
|
||||
metric,
|
||||
unit,
|
||||
}: MetricCardProps) {
|
||||
const {
|
||||
report: { previousIndicatorInverted },
|
||||
isEditMode,
|
||||
} = useReportChartContext();
|
||||
const { isEditMode } = useReportChartContext();
|
||||
const number = useNumber();
|
||||
|
||||
const renderValue = (value: number | undefined, unitClassName?: string) => {
|
||||
@@ -80,7 +77,7 @@ export function MetricCard({
|
||||
const previous = serie.metrics.previous?.[metric];
|
||||
|
||||
const graphColors = getDiffIndicator(
|
||||
previousIndicatorInverted,
|
||||
false,
|
||||
previous?.state,
|
||||
'#6ee7b7', // green
|
||||
'#fda4af', // red
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportPieChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.aggregate.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
trpc.chart.aggregate.queryOptions(
|
||||
{
|
||||
...report,
|
||||
shareId,
|
||||
reportId: 'id' in report ? report.id : undefined,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? report.startDate,
|
||||
endDate: endDate ?? report.endDate,
|
||||
interval: interval ?? report.interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
changeStartDate,
|
||||
ready,
|
||||
reset,
|
||||
setName,
|
||||
setReport,
|
||||
} from '@/components/report/reportSlice';
|
||||
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
|
||||
@@ -19,9 +18,10 @@ import { TimeWindowPicker } from '@/components/time-window-picker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { pushModal } from '@/modals';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import { GanttChartSquareIcon } from 'lucide-react';
|
||||
import { GanttChartSquareIcon, ShareIcon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { IServiceReport } from '@openpanel/db';
|
||||
@@ -54,8 +54,19 @@ export default function ReportEditor({
|
||||
return (
|
||||
<Sheet>
|
||||
<div>
|
||||
<div className="p-4">
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<EditReportName />
|
||||
{initialReport?.id && (
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={ShareIcon}
|
||||
onClick={() =>
|
||||
pushModal('ShareReportModal', { reportId: initialReport.id })
|
||||
}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6">
|
||||
<SheetTrigger asChild>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
@@ -12,21 +13,33 @@ import CohortTable from './table';
|
||||
export function ReportRetentionChart() {
|
||||
const {
|
||||
report: {
|
||||
id,
|
||||
series,
|
||||
range,
|
||||
projectId,
|
||||
options,
|
||||
startDate,
|
||||
endDate,
|
||||
criteria,
|
||||
interval,
|
||||
},
|
||||
isLazyLoading,
|
||||
shareId,
|
||||
} = useReportChartContext();
|
||||
const {
|
||||
range: overviewRange,
|
||||
startDate: overviewStartDate,
|
||||
endDate: overviewEndDate,
|
||||
interval: overviewInterval,
|
||||
} = useOverviewOptions();
|
||||
const eventSeries = series.filter((item) => item.type === 'event');
|
||||
const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String);
|
||||
const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String);
|
||||
const isEnabled =
|
||||
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
|
||||
|
||||
const retentionOptions = options?.type === 'retention' ? options : undefined;
|
||||
const criteria = retentionOptions?.criteria ?? 'on_or_after';
|
||||
|
||||
const trpc = useTRPC();
|
||||
const res = useQuery(
|
||||
trpc.chart.cohort.queryOptions(
|
||||
@@ -34,11 +47,13 @@ export function ReportRetentionChart() {
|
||||
firstEvent,
|
||||
secondEvent,
|
||||
projectId,
|
||||
range,
|
||||
startDate,
|
||||
endDate,
|
||||
range: overviewRange ?? range,
|
||||
startDate: overviewStartDate ?? startDate,
|
||||
endDate: overviewEndDate ?? endDate,
|
||||
criteria,
|
||||
interval,
|
||||
interval: overviewInterval ?? interval,
|
||||
shareId,
|
||||
reportId: id,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
|
||||
302
apps/start/src/components/report-chart/sankey/chart.tsx
Normal file
302
apps/start/src/components/report-chart/sankey/chart.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
ChartTooltipContainer,
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { round } from '@/utils/math';
|
||||
import { ResponsiveSankey } from '@nivo/sankey';
|
||||
import {
|
||||
type ReactNode,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
import { truncate } from '@/utils/truncate';
|
||||
import { ArrowRightIcon } from 'lucide-react';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
|
||||
type PortalTooltipPosition = { left: number; top: number; ready: boolean };
|
||||
|
||||
function SankeyPortalTooltip({
|
||||
children,
|
||||
offset = 12,
|
||||
padding = 8,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
offset?: number;
|
||||
padding?: number;
|
||||
}) {
|
||||
const anchorRef = useRef<HTMLSpanElement | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
||||
const [pos, setPos] = useState<PortalTooltipPosition>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
ready: false,
|
||||
});
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = anchorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const wrapper = el.parentElement;
|
||||
if (!wrapper) return;
|
||||
|
||||
const update = () => {
|
||||
setAnchorRect(wrapper.getBoundingClientRect());
|
||||
};
|
||||
|
||||
update();
|
||||
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(wrapper);
|
||||
|
||||
window.addEventListener('scroll', update, true);
|
||||
window.addEventListener('resize', update);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('scroll', update, true);
|
||||
window.removeEventListener('resize', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (!anchorRect) return;
|
||||
const tooltipEl = tooltipRef.current;
|
||||
if (!tooltipEl) return;
|
||||
|
||||
const rect = tooltipEl.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let left = anchorRect.left + offset;
|
||||
let top = anchorRect.top + offset;
|
||||
|
||||
left = Math.min(
|
||||
Math.max(padding, left),
|
||||
Math.max(padding, vw - rect.width - padding),
|
||||
);
|
||||
top = Math.min(
|
||||
Math.max(padding, top),
|
||||
Math.max(padding, vh - rect.height - padding),
|
||||
);
|
||||
|
||||
setPos({ left, top, ready: true });
|
||||
}, [mounted, anchorRect, children, offset, padding]);
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={anchorRef} className="sr-only" />
|
||||
{mounted &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className="pointer-events-none fixed z-[9999]"
|
||||
style={{
|
||||
left: pos.left,
|
||||
top: pos.top,
|
||||
visibility: pos.ready ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SankeyData = {
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
nodeColor: string;
|
||||
percentage?: number;
|
||||
value?: number;
|
||||
step?: number;
|
||||
}>;
|
||||
links: Array<{ source: string; target: string; value: number }>;
|
||||
};
|
||||
|
||||
export function Chart({ data }: { data: SankeyData }) {
|
||||
const number = useNumber();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { appTheme } = useTheme();
|
||||
|
||||
// Process data for Sankey
|
||||
const sankeyData = useMemo(() => {
|
||||
if (!data) return { nodes: [], links: [] };
|
||||
|
||||
return {
|
||||
nodes: data.nodes.map((node) => ({
|
||||
...node,
|
||||
label: node.label || node.id,
|
||||
data: {
|
||||
percentage: node.percentage,
|
||||
value: node.value,
|
||||
step: node.step,
|
||||
label: node.label || node.id,
|
||||
},
|
||||
})),
|
||||
links: data.links,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const totalSessions = useMemo(() => {
|
||||
if (!sankeyData.nodes || sankeyData.nodes.length === 0) return 0;
|
||||
const step1 = sankeyData.nodes.filter((n: any) => n.data?.step === 1);
|
||||
const base = step1.length > 0 ? step1 : sankeyData.nodes;
|
||||
return base.reduce((sum: number, n: any) => sum + (n.data?.value ?? 0), 0);
|
||||
}, [sankeyData.nodes]);
|
||||
|
||||
return (
|
||||
<AspectContainer>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full relative aspect-square md:aspect-[2]"
|
||||
>
|
||||
<ResponsiveSankey
|
||||
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
|
||||
data={sankeyData}
|
||||
colors={(node: any) => node.nodeColor}
|
||||
nodeBorderRadius={2}
|
||||
animate={false}
|
||||
nodeBorderWidth={0}
|
||||
nodeOpacity={0.8}
|
||||
linkContract={1}
|
||||
linkOpacity={0.3}
|
||||
linkBlendMode={'normal'}
|
||||
nodeTooltip={({ node }: any) => {
|
||||
const label = node?.data?.label ?? node?.label ?? node?.id;
|
||||
const value = node?.data?.value ?? node?.value ?? 0;
|
||||
const step = node?.data?.step;
|
||||
const pct =
|
||||
typeof node?.data?.percentage === 'number'
|
||||
? node.data.percentage
|
||||
: totalSessions > 0
|
||||
? (value / totalSessions) * 100
|
||||
: 0;
|
||||
const color =
|
||||
node?.color ??
|
||||
node?.data?.nodeColor ??
|
||||
node?.data?.color ??
|
||||
node?.nodeColor ??
|
||||
'#64748b';
|
||||
|
||||
return (
|
||||
<SankeyPortalTooltip>
|
||||
<ChartTooltipContainer className="min-w-[250px]">
|
||||
<ChartTooltipHeader>
|
||||
<div className="min-w-0 flex-1 font-medium break-words">
|
||||
{label}
|
||||
</div>
|
||||
{typeof step === 'number' && (
|
||||
<div className="shrink-0 text-muted-foreground">
|
||||
Step {step}
|
||||
</div>
|
||||
)}
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color={color} innerClassName="gap-2">
|
||||
<div className="flex items-center justify-between gap-8 font-mono font-medium">
|
||||
<div className="text-muted-foreground">Sessions</div>
|
||||
<div>{number.format(value)}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-8 font-mono font-medium">
|
||||
<div className="text-muted-foreground">Share</div>
|
||||
<div>{number.format(round(pct, 1))} %</div>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</ChartTooltipContainer>
|
||||
</SankeyPortalTooltip>
|
||||
);
|
||||
}}
|
||||
linkTooltip={({ link }: any) => {
|
||||
const sourceLabel =
|
||||
link?.source?.data?.label ??
|
||||
link?.source?.label ??
|
||||
link?.source?.id;
|
||||
const targetLabel =
|
||||
link?.target?.data?.label ??
|
||||
link?.target?.label ??
|
||||
link?.target?.id;
|
||||
|
||||
const value = link?.value ?? 0;
|
||||
const sourceValue =
|
||||
link?.source?.data?.value ?? link?.source?.value ?? 0;
|
||||
|
||||
const pctOfTotal =
|
||||
totalSessions > 0 ? (value / totalSessions) * 100 : 0;
|
||||
const pctOfSource =
|
||||
sourceValue > 0 ? (value / sourceValue) * 100 : 0;
|
||||
|
||||
const sourceStep = link?.source?.data?.step;
|
||||
const targetStep = link?.target?.data?.step;
|
||||
|
||||
const color =
|
||||
link?.color ??
|
||||
link?.source?.color ??
|
||||
link?.source?.data?.nodeColor ??
|
||||
'#64748b';
|
||||
|
||||
return (
|
||||
<SankeyPortalTooltip>
|
||||
<ChartTooltipContainer>
|
||||
<ChartTooltipHeader>
|
||||
<div className="min-w-0 flex-1 font-medium break-words">
|
||||
{sourceLabel}
|
||||
<ArrowRightIcon className="size-2 inline-block mx-3" />
|
||||
{targetLabel}
|
||||
</div>
|
||||
{typeof sourceStep === 'number' &&
|
||||
typeof targetStep === 'number' && (
|
||||
<div className="shrink-0 text-muted-foreground">
|
||||
{sourceStep} → {targetStep}
|
||||
</div>
|
||||
)}
|
||||
</ChartTooltipHeader>
|
||||
|
||||
<ChartTooltipItem color={color} innerClassName="gap-2">
|
||||
<div className="flex items-center justify-between gap-8 font-mono font-medium">
|
||||
<div className="text-muted-foreground">Sessions</div>
|
||||
<div>{number.format(value)}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-8 font-mono text-sm">
|
||||
<div className="text-muted-foreground">% of total</div>
|
||||
<div>{number.format(round(pctOfTotal, 1))} %</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-8 font-mono text-sm">
|
||||
<div className="text-muted-foreground">% of source</div>
|
||||
<div>{number.format(round(pctOfSource, 1))} %</div>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</ChartTooltipContainer>
|
||||
</SankeyPortalTooltip>
|
||||
);
|
||||
}}
|
||||
label={(node: any) => {
|
||||
const label = node.data?.label || node.label || node.id;
|
||||
return truncate(label, 30, 'middle');
|
||||
}}
|
||||
labelTextColor={appTheme === 'dark' ? '#e2e8f0' : '#0f172a'}
|
||||
nodeSpacing={10}
|
||||
/>
|
||||
</div>
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
93
apps/start/src/components/report-chart/sankey/index.tsx
Normal file
93
apps/start/src/components/report-chart/sankey/index.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { IReportInput } from '@openpanel/validation';
|
||||
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
import { ReportChartLoading } from '../common/loading';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportSankeyChart() {
|
||||
const {
|
||||
report: {
|
||||
series,
|
||||
range,
|
||||
projectId,
|
||||
options,
|
||||
startDate,
|
||||
endDate,
|
||||
breakdowns,
|
||||
},
|
||||
isLazyLoading,
|
||||
} = useReportChartContext();
|
||||
|
||||
if (!options) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
const input: IReportInput = {
|
||||
series,
|
||||
range,
|
||||
projectId,
|
||||
interval: 'day',
|
||||
chartType: 'sankey',
|
||||
breakdowns,
|
||||
options,
|
||||
metric: 'sum',
|
||||
startDate,
|
||||
endDate,
|
||||
limit: 20,
|
||||
previous: false,
|
||||
};
|
||||
const trpc = useTRPC();
|
||||
const res = useQuery(
|
||||
trpc.chart.sankey.queryOptions(input, {
|
||||
enabled: !isLazyLoading && input.series.length > 0,
|
||||
}),
|
||||
);
|
||||
|
||||
if (isLazyLoading || res.isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (res.isError) {
|
||||
return <Error />;
|
||||
}
|
||||
|
||||
if (!res.data || res.data.nodes.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col gap-4">
|
||||
<Chart data={res.data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartLoading />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Error() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartError />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export const ReportChartShortcut = ({
|
||||
return (
|
||||
<ReportChart
|
||||
report={{
|
||||
name: 'Shortcut',
|
||||
projectId,
|
||||
range,
|
||||
breakdowns: breakdowns ?? [],
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ChartColumnIncreasingIcon,
|
||||
ConeIcon,
|
||||
GaugeIcon,
|
||||
GitBranchIcon,
|
||||
Globe2Icon,
|
||||
LineChartIcon,
|
||||
type LucideIcon,
|
||||
@@ -58,6 +59,7 @@ export function ReportChartType({
|
||||
retention: UsersIcon,
|
||||
map: Globe2Icon,
|
||||
conversion: TrendingUpIcon,
|
||||
sankey: GitBranchIcon,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
255
apps/start/src/components/report/report-item.tsx
Normal file
255
apps/start/src/components/report/report-item.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { CopyIcon, MoreHorizontal, Trash } from 'lucide-react';
|
||||
|
||||
import { timeWindows } from '@openpanel/constants';
|
||||
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
|
||||
export function ReportItemSkeleton() {
|
||||
return (
|
||||
<div className="card h-full flex flex-col animate-pulse">
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<div className="flex-1">
|
||||
<div className="h-5 w-32 bg-muted rounded mb-2" />
|
||||
<div className="h-4 w-24 bg-muted/50 rounded" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-muted rounded" />
|
||||
<div className="w-8 h-8 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 flex-1 flex items-center justify-center aspect-video" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportItem({
|
||||
report,
|
||||
organizationId,
|
||||
projectId,
|
||||
range,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
}: {
|
||||
report: any;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
range: any;
|
||||
startDate: any;
|
||||
endDate: any;
|
||||
interval: any;
|
||||
onDelete: (reportId: string) => void;
|
||||
onDuplicate: (reportId: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const chartRange = report.range;
|
||||
|
||||
return (
|
||||
<div className="card h-full flex flex-col">
|
||||
<div className="flex items-center hover:bg-muted/50 justify-between border-b border-border p-4 leading-none [&_svg]:hover:opacity-100">
|
||||
<div
|
||||
className="flex-1 cursor-pointer -m-4 p-4"
|
||||
onClick={(event) => {
|
||||
if (event.metaKey) {
|
||||
window.open(
|
||||
`/${organizationId}/${projectId}/reports/${report.id}`,
|
||||
'_blank',
|
||||
);
|
||||
return;
|
||||
}
|
||||
router.navigate({
|
||||
to: '/$organizationId/$projectId/reports/$reportId',
|
||||
params: {
|
||||
organizationId,
|
||||
projectId,
|
||||
reportId: report.id,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
router.navigate({
|
||||
to: '/$organizationId/$projectId/reports/$reportId',
|
||||
params: {
|
||||
organizationId,
|
||||
projectId,
|
||||
reportId: report.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="font-medium">{report.name}</div>
|
||||
{chartRange !== null && (
|
||||
<div className="mt-2 flex gap-2 ">
|
||||
<span
|
||||
className={
|
||||
(chartRange !== range && range !== null) ||
|
||||
(startDate && endDate)
|
||||
? 'line-through'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
|
||||
</span>
|
||||
{startDate && endDate ? (
|
||||
<span>Custom dates</span>
|
||||
) : (
|
||||
range !== null &&
|
||||
chartRange !== range && (
|
||||
<span>
|
||||
{timeWindows[range as keyof typeof timeWindows]?.label}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="drag-handle cursor-move p-2 hover:bg-muted rounded">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
className="opacity-30 hover:opacity-100"
|
||||
>
|
||||
<circle cx="4" cy="4" r="1.5" />
|
||||
<circle cx="4" cy="8" r="1.5" />
|
||||
<circle cx="4" cy="12" r="1.5" />
|
||||
<circle cx="12" cy="4" r="1.5" />
|
||||
<circle cx="12" cy="8" r="1.5" />
|
||||
<circle cx="12" cy="12" r="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex h-8 w-8 items-center justify-center rounded hover:border">
|
||||
<MoreHorizontal size={16} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDuplicate(report.id);
|
||||
}}
|
||||
>
|
||||
<CopyIcon size={16} className="mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(report.id);
|
||||
}}
|
||||
>
|
||||
<Trash size={16} className="mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 overflow-auto flex-1',
|
||||
report.chartType === 'metric' && 'p-0',
|
||||
)}
|
||||
>
|
||||
<ReportChart
|
||||
report={{
|
||||
...report,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? null,
|
||||
endDate: endDate ?? null,
|
||||
interval: interval ?? report.interval,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportItemReadOnly({
|
||||
report,
|
||||
shareId,
|
||||
range,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
}: {
|
||||
report: any;
|
||||
shareId: string;
|
||||
range: any;
|
||||
startDate: any;
|
||||
endDate: any;
|
||||
interval: any;
|
||||
}) {
|
||||
const chartRange = report.range;
|
||||
|
||||
return (
|
||||
<div className="card h-full flex flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border p-4 leading-none">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{report.name}</div>
|
||||
{chartRange !== null && (
|
||||
<div className="mt-2 flex gap-2 ">
|
||||
<span
|
||||
className={
|
||||
(chartRange !== range && range !== null) ||
|
||||
(startDate && endDate)
|
||||
? 'line-through'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
|
||||
</span>
|
||||
{startDate && endDate ? (
|
||||
<span>Custom dates</span>
|
||||
) : (
|
||||
range !== null &&
|
||||
chartRange !== range && (
|
||||
<span>
|
||||
{timeWindows[range as keyof typeof timeWindows]?.label}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 overflow-auto flex-1',
|
||||
report.chartType === 'metric' && 'p-0',
|
||||
)}
|
||||
>
|
||||
<ReportChart
|
||||
report={{
|
||||
...report,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? null,
|
||||
endDate: endDate ?? null,
|
||||
interval: interval ?? report.interval,
|
||||
}}
|
||||
shareId={shareId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { endOfDay, format, isSameDay, isSameMonth, startOfDay } from 'date-fns';
|
||||
|
||||
import { shortId } from '@openpanel/common';
|
||||
import {
|
||||
@@ -12,18 +11,19 @@ import {
|
||||
import type {
|
||||
IChartBreakdown,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
IChartLineType,
|
||||
IChartProps,
|
||||
IChartRange,
|
||||
IChartType,
|
||||
IInterval,
|
||||
IReport,
|
||||
IReportOptions,
|
||||
UnionOmit,
|
||||
zCriteria,
|
||||
} from '@openpanel/validation';
|
||||
import type { z } from 'zod';
|
||||
|
||||
type InitialState = IChartProps & {
|
||||
type InitialState = IReport & {
|
||||
id?: string;
|
||||
dirty: boolean;
|
||||
ready: boolean;
|
||||
startDate: string | null;
|
||||
@@ -34,7 +34,6 @@ type InitialState = IChartProps & {
|
||||
const initialState: InitialState = {
|
||||
ready: false,
|
||||
dirty: false,
|
||||
// TODO: remove this
|
||||
projectId: '',
|
||||
name: '',
|
||||
chartType: 'linear',
|
||||
@@ -50,9 +49,7 @@ const initialState: InitialState = {
|
||||
unit: undefined,
|
||||
metric: 'sum',
|
||||
limit: 500,
|
||||
criteria: 'on_or_after',
|
||||
funnelGroup: undefined,
|
||||
funnelWindow: undefined,
|
||||
options: undefined,
|
||||
};
|
||||
|
||||
export const reportSlice = createSlice({
|
||||
@@ -74,7 +71,7 @@ export const reportSlice = createSlice({
|
||||
ready: true,
|
||||
};
|
||||
},
|
||||
setReport(state, action: PayloadAction<IChartProps>) {
|
||||
setReport(state, action: PayloadAction<IReport>) {
|
||||
return {
|
||||
...state,
|
||||
...action.payload,
|
||||
@@ -187,6 +184,16 @@ export const reportSlice = createSlice({
|
||||
state.dirty = true;
|
||||
state.chartType = action.payload;
|
||||
|
||||
// Initialize sankey options if switching to sankey
|
||||
if (action.payload === 'sankey' && !state.options) {
|
||||
state.options = {
|
||||
type: 'sankey',
|
||||
mode: 'after',
|
||||
steps: 5,
|
||||
exclude: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!isMinuteIntervalEnabledByRange(state.range) &&
|
||||
state.interval === 'minute'
|
||||
@@ -254,7 +261,14 @@ export const reportSlice = createSlice({
|
||||
|
||||
changeCriteria(state, action: PayloadAction<z.infer<typeof zCriteria>>) {
|
||||
state.dirty = true;
|
||||
state.criteria = action.payload;
|
||||
if (!state.options || state.options.type !== 'retention') {
|
||||
state.options = {
|
||||
type: 'retention',
|
||||
criteria: action.payload,
|
||||
};
|
||||
} else {
|
||||
state.options.criteria = action.payload;
|
||||
}
|
||||
},
|
||||
|
||||
changeUnit(state, action: PayloadAction<string | undefined>) {
|
||||
@@ -264,12 +278,88 @@ export const reportSlice = createSlice({
|
||||
|
||||
changeFunnelGroup(state, action: PayloadAction<string | undefined>) {
|
||||
state.dirty = true;
|
||||
state.funnelGroup = action.payload || undefined;
|
||||
if (!state.options || state.options.type !== 'funnel') {
|
||||
state.options = {
|
||||
type: 'funnel',
|
||||
funnelGroup: action.payload,
|
||||
funnelWindow: undefined,
|
||||
};
|
||||
} else {
|
||||
state.options.funnelGroup = action.payload;
|
||||
}
|
||||
},
|
||||
|
||||
changeFunnelWindow(state, action: PayloadAction<number | undefined>) {
|
||||
state.dirty = true;
|
||||
state.funnelWindow = action.payload || undefined;
|
||||
if (!state.options || state.options.type !== 'funnel') {
|
||||
state.options = {
|
||||
type: 'funnel',
|
||||
funnelGroup: undefined,
|
||||
funnelWindow: action.payload,
|
||||
};
|
||||
} else {
|
||||
state.options.funnelWindow = action.payload;
|
||||
}
|
||||
},
|
||||
changeOptions(state, action: PayloadAction<IReportOptions | undefined>) {
|
||||
state.dirty = true;
|
||||
state.options = action.payload || undefined;
|
||||
},
|
||||
changeSankeyMode(
|
||||
state,
|
||||
action: PayloadAction<'between' | 'after' | 'before'>,
|
||||
) {
|
||||
state.dirty = true;
|
||||
if (!state.options) {
|
||||
state.options = {
|
||||
type: 'sankey',
|
||||
mode: action.payload,
|
||||
steps: 5,
|
||||
exclude: [],
|
||||
};
|
||||
} else if (state.options.type === 'sankey') {
|
||||
state.options.mode = action.payload;
|
||||
}
|
||||
},
|
||||
changeSankeySteps(state, action: PayloadAction<number>) {
|
||||
state.dirty = true;
|
||||
if (!state.options) {
|
||||
state.options = {
|
||||
type: 'sankey',
|
||||
mode: 'after',
|
||||
steps: action.payload,
|
||||
exclude: [],
|
||||
};
|
||||
} else if (state.options.type === 'sankey') {
|
||||
state.options.steps = action.payload;
|
||||
}
|
||||
},
|
||||
changeSankeyExclude(state, action: PayloadAction<string[]>) {
|
||||
state.dirty = true;
|
||||
if (!state.options) {
|
||||
state.options = {
|
||||
type: 'sankey',
|
||||
mode: 'after',
|
||||
steps: 5,
|
||||
exclude: action.payload,
|
||||
};
|
||||
} else if (state.options.type === 'sankey') {
|
||||
state.options.exclude = action.payload;
|
||||
}
|
||||
},
|
||||
changeSankeyInclude(state, action: PayloadAction<string[] | undefined>) {
|
||||
state.dirty = true;
|
||||
if (!state.options) {
|
||||
state.options = {
|
||||
type: 'sankey',
|
||||
mode: 'after',
|
||||
steps: 5,
|
||||
exclude: [],
|
||||
include: action.payload,
|
||||
};
|
||||
} else if (state.options.type === 'sankey') {
|
||||
state.options.include = action.payload;
|
||||
}
|
||||
},
|
||||
reorderEvents(
|
||||
state,
|
||||
@@ -311,6 +401,11 @@ export const {
|
||||
changeUnit,
|
||||
changeFunnelGroup,
|
||||
changeFunnelWindow,
|
||||
changeOptions,
|
||||
changeSankeyMode,
|
||||
changeSankeySteps,
|
||||
changeSankeyExclude,
|
||||
changeSankeyInclude,
|
||||
reorderEvents,
|
||||
} = reportSlice.actions;
|
||||
|
||||
|
||||
@@ -23,15 +23,13 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { shortId } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
} from '@openpanel/validation';
|
||||
import { FilterIcon, HandIcon, PiIcon } from 'lucide-react';
|
||||
import { ReportSegment } from '../ReportSegment';
|
||||
import { HandIcon, PiIcon, PlusIcon } from 'lucide-react';
|
||||
import {
|
||||
addSerie,
|
||||
changeEvent,
|
||||
@@ -39,27 +37,21 @@ import {
|
||||
removeEvent,
|
||||
reorderEvents,
|
||||
} from '../reportSlice';
|
||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||
import { PropertiesCombobox } from './PropertiesCombobox';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
import { ReportEventMore } from './ReportEventMore';
|
||||
import { FiltersList } from './filters/FiltersList';
|
||||
import {
|
||||
ReportSeriesItem,
|
||||
type ReportSeriesItemProps,
|
||||
} from './ReportSeriesItem';
|
||||
|
||||
function SortableSeries({
|
||||
function SortableReportSeriesItem({
|
||||
event,
|
||||
index,
|
||||
showSegment,
|
||||
showAddFilter,
|
||||
isSelectManyEvents,
|
||||
...props
|
||||
}: {
|
||||
event: IChartEventItem | IChartEvent;
|
||||
index: number;
|
||||
showSegment: boolean;
|
||||
showAddFilter: boolean;
|
||||
isSelectManyEvents: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const dispatch = useDispatch();
|
||||
}: Omit<ReportSeriesItemProps, 'renderDragHandle'>) {
|
||||
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: eventId ?? '' });
|
||||
@@ -69,85 +61,26 @@ function SortableSeries({
|
||||
transition,
|
||||
};
|
||||
|
||||
// Normalize event to have type field
|
||||
const normalizedEvent: IChartEventItem =
|
||||
'type' in event ? event : { ...event, type: 'event' as const };
|
||||
|
||||
const isFormula = normalizedEvent.type === 'formula';
|
||||
const chartEvent = isFormula
|
||||
? null
|
||||
: (normalizedEvent as IChartEventItem & { type: 'event' });
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...props}>
|
||||
<div className="flex items-center gap-2 p-2 group">
|
||||
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
|
||||
<ColorSquare className="relative">
|
||||
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
|
||||
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
|
||||
{alphabetIds[index]}
|
||||
</span>
|
||||
</ColorSquare>
|
||||
</button>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{/* Segment and Filter buttons - only for events */}
|
||||
{chartEvent && (showSegment || showAddFilter) && (
|
||||
<div className="flex gap-2 p-2 pt-0">
|
||||
{showSegment && (
|
||||
<ReportSegment
|
||||
value={chartEvent.segment}
|
||||
onChange={(segment) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...chartEvent,
|
||||
segment,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showAddFilter && (
|
||||
<PropertiesCombobox
|
||||
event={chartEvent}
|
||||
onSelect={(action) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...chartEvent,
|
||||
filters: [
|
||||
...chartEvent.filters,
|
||||
{
|
||||
id: shortId(),
|
||||
name: action.value,
|
||||
operator: 'is',
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(setOpen) => (
|
||||
<button
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
|
||||
>
|
||||
<FilterIcon size={12} /> Add filter
|
||||
</button>
|
||||
)}
|
||||
</PropertiesCombobox>
|
||||
)}
|
||||
|
||||
{showSegment && chartEvent.segment.startsWith('property_') && (
|
||||
<EventPropertiesCombobox event={chartEvent} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters - only for events */}
|
||||
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
<ReportSeriesItem
|
||||
event={event}
|
||||
index={index}
|
||||
showSegment={showSegment}
|
||||
showAddFilter={showAddFilter}
|
||||
isSelectManyEvents={isSelectManyEvents}
|
||||
renderDragHandle={(index) => (
|
||||
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
|
||||
<ColorSquare className="relative">
|
||||
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
|
||||
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
|
||||
{alphabetIds[index]}
|
||||
</span>
|
||||
</ColorSquare>
|
||||
</button>
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -161,12 +94,23 @@ export function ReportSeries() {
|
||||
projectId,
|
||||
});
|
||||
|
||||
const showSegment = !['retention', 'funnel'].includes(chartType);
|
||||
const showAddFilter = !['retention'].includes(chartType);
|
||||
const showDisplayNameInput = !['retention'].includes(chartType);
|
||||
const showSegment = !['retention', 'funnel', 'sankey'].includes(chartType);
|
||||
const showAddFilter = !['retention', 'sankey'].includes(chartType);
|
||||
const showDisplayNameInput = !['retention', 'sankey'].includes(chartType);
|
||||
const options = useSelector((state) => state.report.options);
|
||||
const isSankey = chartType === 'sankey';
|
||||
const isAddEventDisabled =
|
||||
(chartType === 'retention' || chartType === 'conversion') &&
|
||||
selectedSeries.length >= 2;
|
||||
const isSankeyEventLimitReached =
|
||||
isSankey &&
|
||||
options &&
|
||||
((options.type === 'sankey' &&
|
||||
options.mode === 'between' &&
|
||||
selectedSeries.length >= 2) ||
|
||||
(options.type === 'sankey' &&
|
||||
options.mode !== 'between' &&
|
||||
selectedSeries.length >= 1));
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
|
||||
dispatch(changeEvent(event));
|
||||
});
|
||||
@@ -218,7 +162,8 @@ export function ReportSeries() {
|
||||
const showFormula =
|
||||
chartType !== 'conversion' &&
|
||||
chartType !== 'funnel' &&
|
||||
chartType !== 'retention';
|
||||
chartType !== 'retention' &&
|
||||
chartType !== 'sankey';
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -239,7 +184,7 @@ export function ReportSeries() {
|
||||
const isFormula = event.type === 'formula';
|
||||
|
||||
return (
|
||||
<SortableSeries
|
||||
<SortableReportSeriesItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
index={index}
|
||||
@@ -348,13 +293,14 @@ export function ReportSeries() {
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</>
|
||||
)}
|
||||
</SortableSeries>
|
||||
</SortableReportSeriesItem>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<ComboboxEvents
|
||||
disabled={isAddEventDisabled}
|
||||
className="flex-1"
|
||||
disabled={isAddEventDisabled || isSankeyEventLimitReached}
|
||||
value={''}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
@@ -386,13 +332,13 @@ export function ReportSeries() {
|
||||
}}
|
||||
placeholder="Select event"
|
||||
items={eventNames}
|
||||
className="flex-1"
|
||||
/>
|
||||
{showFormula && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
icon={PiIcon}
|
||||
className="flex-1 justify-start text-left px-4"
|
||||
onClick={() => {
|
||||
dispatch(
|
||||
addSerie({
|
||||
@@ -402,9 +348,9 @@ export function ReportSeries() {
|
||||
}),
|
||||
);
|
||||
}}
|
||||
className="px-4"
|
||||
>
|
||||
Add Formula
|
||||
<PlusIcon className="size-4 ml-auto text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
114
apps/start/src/components/report/sidebar/ReportSeriesItem.tsx
Normal file
114
apps/start/src/components/report/sidebar/ReportSeriesItem.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { useDispatch } from '@/redux';
|
||||
import { shortId } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type { IChartEvent, IChartEventItem } from '@openpanel/validation';
|
||||
import { FilterIcon } from 'lucide-react';
|
||||
import { ReportSegment } from '../ReportSegment';
|
||||
import { changeEvent } from '../reportSlice';
|
||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||
import { PropertiesCombobox } from './PropertiesCombobox';
|
||||
import { FiltersList } from './filters/FiltersList';
|
||||
|
||||
export interface ReportSeriesItemProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
event: IChartEventItem | IChartEvent;
|
||||
index: number;
|
||||
showSegment: boolean;
|
||||
showAddFilter: boolean;
|
||||
isSelectManyEvents: boolean;
|
||||
renderDragHandle?: (index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function ReportSeriesItem({
|
||||
event,
|
||||
index,
|
||||
showSegment,
|
||||
showAddFilter,
|
||||
isSelectManyEvents,
|
||||
renderDragHandle,
|
||||
...props
|
||||
}: ReportSeriesItemProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Normalize event to have type field
|
||||
const normalizedEvent: IChartEventItem =
|
||||
'type' in event ? event : { ...event, type: 'event' as const };
|
||||
|
||||
const isFormula = normalizedEvent.type === 'formula';
|
||||
const chartEvent = isFormula
|
||||
? null
|
||||
: (normalizedEvent as IChartEventItem & { type: 'event' });
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
<div className="flex items-center gap-2 p-2 group">
|
||||
{renderDragHandle ? (
|
||||
renderDragHandle(index)
|
||||
) : (
|
||||
<ColorSquare>
|
||||
<span className="block">{alphabetIds[index]}</span>
|
||||
</ColorSquare>
|
||||
)}
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{/* Segment and Filter buttons - only for events */}
|
||||
{chartEvent && (showSegment || showAddFilter) && (
|
||||
<div className="flex gap-2 p-2 pt-0">
|
||||
{showSegment && (
|
||||
<ReportSegment
|
||||
value={chartEvent.segment}
|
||||
onChange={(segment) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...chartEvent,
|
||||
segment,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showAddFilter && (
|
||||
<PropertiesCombobox
|
||||
event={chartEvent}
|
||||
onSelect={(action) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...chartEvent,
|
||||
filters: [
|
||||
...chartEvent.filters,
|
||||
{
|
||||
id: shortId(),
|
||||
name: action.value,
|
||||
operator: 'is',
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(setOpen) => (
|
||||
<button
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
|
||||
>
|
||||
<FilterIcon size={12} /> Add filter
|
||||
</button>
|
||||
)}
|
||||
</PropertiesCombobox>
|
||||
)}
|
||||
|
||||
{showSegment && chartEvent.segment.startsWith('property_') && (
|
||||
<EventPropertiesCombobox event={chartEvent} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters - only for events */}
|
||||
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,46 @@
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
|
||||
import { ComboboxEvents } from '@/components/ui/combobox-events';
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useEventNames } from '@/hooks/use-event-names';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
changeCriteria,
|
||||
changeFunnelGroup,
|
||||
changeFunnelWindow,
|
||||
changePrevious,
|
||||
changeSankeyExclude,
|
||||
changeSankeyInclude,
|
||||
changeSankeyMode,
|
||||
changeSankeySteps,
|
||||
changeUnit,
|
||||
} from '../reportSlice';
|
||||
|
||||
export function ReportSettings() {
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
const previous = useSelector((state) => state.report.previous);
|
||||
const criteria = useSelector((state) => state.report.criteria);
|
||||
const unit = useSelector((state) => state.report.unit);
|
||||
const funnelGroup = useSelector((state) => state.report.funnelGroup);
|
||||
const funnelWindow = useSelector((state) => state.report.funnelWindow);
|
||||
const options = useSelector((state) => state.report.options);
|
||||
|
||||
const retentionOptions = options?.type === 'retention' ? options : undefined;
|
||||
const criteria = retentionOptions?.criteria ?? 'on_or_after';
|
||||
|
||||
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||
const funnelGroup = funnelOptions?.funnelGroup;
|
||||
const funnelWindow = funnelOptions?.funnelWindow;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const eventNames = useEventNames({ projectId });
|
||||
|
||||
const fields = useMemo(() => {
|
||||
const fields = [];
|
||||
|
||||
if (chartType !== 'retention') {
|
||||
if (chartType !== 'retention' && chartType !== 'sankey') {
|
||||
fields.push('previous');
|
||||
}
|
||||
|
||||
@@ -40,6 +54,13 @@ export function ReportSettings() {
|
||||
fields.push('funnelWindow');
|
||||
}
|
||||
|
||||
if (chartType === 'sankey') {
|
||||
fields.push('sankeyMode');
|
||||
fields.push('sankeySteps');
|
||||
fields.push('sankeyExclude');
|
||||
fields.push('sankeyInclude');
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [chartType]);
|
||||
|
||||
@@ -50,7 +71,7 @@ export function ReportSettings() {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Settings</h3>
|
||||
<div className="col rounded-lg border bg-card p-4 gap-2">
|
||||
<div className="col rounded-lg border bg-card p-4 gap-4">
|
||||
{fields.includes('previous') && (
|
||||
<Label className="flex items-center justify-between mb-0">
|
||||
<span className="whitespace-nowrap">
|
||||
@@ -64,7 +85,9 @@ export function ReportSettings() {
|
||||
)}
|
||||
{fields.includes('criteria') && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="whitespace-nowrap font-medium">Criteria</span>
|
||||
<Label className="whitespace-nowrap font-medium mb-0">
|
||||
Criteria
|
||||
</Label>
|
||||
<Combobox
|
||||
align="end"
|
||||
placeholder="Select criteria"
|
||||
@@ -85,7 +108,7 @@ export function ReportSettings() {
|
||||
)}
|
||||
{fields.includes('unit') && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="whitespace-nowrap font-medium">Unit</span>
|
||||
<Label className="whitespace-nowrap font-medium mb-0">Unit</Label>
|
||||
<Combobox
|
||||
align="end"
|
||||
placeholder="Unit"
|
||||
@@ -108,7 +131,9 @@ export function ReportSettings() {
|
||||
)}
|
||||
{fields.includes('funnelGroup') && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="whitespace-nowrap font-medium">Funnel Group</span>
|
||||
<Label className="whitespace-nowrap font-medium mb-0">
|
||||
Funnel Group
|
||||
</Label>
|
||||
<Combobox
|
||||
align="end"
|
||||
placeholder="Default: Session"
|
||||
@@ -133,7 +158,9 @@ export function ReportSettings() {
|
||||
)}
|
||||
{fields.includes('funnelWindow') && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="whitespace-nowrap font-medium">Funnel Window</span>
|
||||
<Label className="whitespace-nowrap font-medium mb-0">
|
||||
Funnel Window
|
||||
</Label>
|
||||
<InputEnter
|
||||
type="number"
|
||||
value={funnelWindow ? String(funnelWindow) : ''}
|
||||
@@ -149,6 +176,89 @@ export function ReportSettings() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{fields.includes('sankeyMode') && options?.type === 'sankey' && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label className="whitespace-nowrap font-medium mb-0">Mode</Label>
|
||||
<Combobox
|
||||
align="end"
|
||||
placeholder="Select mode"
|
||||
value={options?.mode || 'after'}
|
||||
onChange={(val) => {
|
||||
dispatch(
|
||||
changeSankeyMode(val as 'between' | 'after' | 'before'),
|
||||
);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
label: 'After',
|
||||
value: 'after',
|
||||
},
|
||||
{
|
||||
label: 'Before',
|
||||
value: 'before',
|
||||
},
|
||||
{
|
||||
label: 'Between',
|
||||
value: 'between',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{fields.includes('sankeySteps') && options?.type === 'sankey' && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label className="whitespace-nowrap font-medium mb-0">Steps</Label>
|
||||
<InputEnter
|
||||
type="number"
|
||||
value={options?.steps ? String(options.steps) : '5'}
|
||||
placeholder="Default: 5"
|
||||
onChangeValue={(value) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsed) || parsed < 2 || parsed > 10) {
|
||||
dispatch(changeSankeySteps(5));
|
||||
} else {
|
||||
dispatch(changeSankeySteps(parsed));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{fields.includes('sankeyExclude') && options?.type === 'sankey' && (
|
||||
<div className="flex flex-col">
|
||||
<Label className="whitespace-nowrap font-medium">
|
||||
Exclude Events
|
||||
</Label>
|
||||
<ComboboxEvents
|
||||
multiple
|
||||
searchable
|
||||
value={options?.exclude || []}
|
||||
onChange={(value) => {
|
||||
dispatch(changeSankeyExclude(value));
|
||||
}}
|
||||
items={eventNames.filter((item) => item.name !== '*')}
|
||||
placeholder="Select events to exclude"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{fields.includes('sankeyInclude') && options?.type === 'sankey' && (
|
||||
<div className="flex flex-col">
|
||||
<Label className="whitespace-nowrap font-medium">
|
||||
Include events
|
||||
</Label>
|
||||
<ComboboxEvents
|
||||
multiple
|
||||
searchable
|
||||
value={options?.include || []}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeSankeyInclude(value.length > 0 ? value : undefined),
|
||||
);
|
||||
}}
|
||||
items={eventNames.filter((item) => item.name !== '*')}
|
||||
placeholder="Leave empty to include all"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,14 +5,24 @@ import { useSelector } from '@/redux';
|
||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||
import { ReportSeries } from './ReportSeries';
|
||||
import { ReportSettings } from './ReportSettings';
|
||||
import { ReportFixedEvents } from './report-fixed-events';
|
||||
|
||||
export function ReportSidebar() {
|
||||
const { chartType } = useSelector((state) => state.report);
|
||||
const showBreakdown = chartType !== 'retention';
|
||||
const { chartType, options } = useSelector((state) => state.report);
|
||||
const showBreakdown = chartType !== 'retention' && chartType !== 'sankey';
|
||||
const showFixedEvents = chartType === 'sankey';
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-8">
|
||||
<ReportSeries />
|
||||
{showFixedEvents ? (
|
||||
<ReportFixedEvents
|
||||
numberOfEvents={
|
||||
options?.type === 'sankey' && options.mode === 'between' ? 2 : 1
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ReportSeries />
|
||||
)}
|
||||
{showBreakdown && <ReportBreakdowns />}
|
||||
<ReportSettings />
|
||||
</div>
|
||||
|
||||
223
apps/start/src/components/report/sidebar/report-fixed-events.tsx
Normal file
223
apps/start/src/components/report/sidebar/report-fixed-events.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { ComboboxEvents } from '@/components/ui/combobox-events';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useDebounceFn } from '@/hooks/use-debounce-fn';
|
||||
import { useEventNames } from '@/hooks/use-event-names';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
} from '@openpanel/validation';
|
||||
import {
|
||||
addSerie,
|
||||
changeEvent,
|
||||
duplicateEvent,
|
||||
removeEvent,
|
||||
} from '../reportSlice';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
import { ReportEventMore } from './ReportEventMore';
|
||||
import { ReportSeriesItem } from './ReportSeriesItem';
|
||||
|
||||
export function ReportFixedEvents({
|
||||
numberOfEvents,
|
||||
}: {
|
||||
numberOfEvents: number;
|
||||
}) {
|
||||
const selectedSeries = useSelector((state) => state.report.series);
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const eventNames = useEventNames({
|
||||
projectId,
|
||||
});
|
||||
|
||||
const showSegment = !['retention', 'funnel', 'sankey'].includes(chartType);
|
||||
const showAddFilter = !['retention'].includes(chartType);
|
||||
const showDisplayNameInput = !['retention', 'sankey'].includes(chartType);
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
|
||||
dispatch(changeEvent(event));
|
||||
});
|
||||
const isSelectManyEvents = chartType === 'retention';
|
||||
|
||||
const handleMore = (event: IChartEventItem | IChartEvent) => {
|
||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||
switch (action) {
|
||||
case 'remove': {
|
||||
return dispatch(
|
||||
removeEvent({
|
||||
id: 'type' in event ? event.id : (event as IChartEvent).id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case 'duplicate': {
|
||||
const normalized =
|
||||
'type' in event ? event : { ...event, type: 'event' as const };
|
||||
return dispatch(duplicateEvent(normalized));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return callback;
|
||||
};
|
||||
|
||||
const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => {
|
||||
dispatch(changeEvent(formula));
|
||||
});
|
||||
|
||||
const showFormula =
|
||||
chartType !== 'conversion' &&
|
||||
chartType !== 'funnel' &&
|
||||
chartType !== 'retention' &&
|
||||
chartType !== 'sankey';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Metrics</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{Array.from({ length: numberOfEvents }, (_, index) => ({
|
||||
slotId: `fixed-event-slot-${index}`,
|
||||
index,
|
||||
})).map(({ slotId, index }) => {
|
||||
const event = selectedSeries[index];
|
||||
|
||||
// If no event exists at this index, render an empty slot
|
||||
if (!event) {
|
||||
return (
|
||||
<div key={slotId} className="rounded-lg border bg-def-100">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
<ColorSquare>
|
||||
<span className="block">{alphabetIds[index]}</span>
|
||||
</ColorSquare>
|
||||
<ComboboxEvents
|
||||
className="flex-1"
|
||||
searchable
|
||||
multiple={isSelectManyEvents as false}
|
||||
value={''}
|
||||
onChange={(value) => {
|
||||
if (isSelectManyEvents) {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
name: value,
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: [value],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'event',
|
||||
name: value,
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
items={eventNames}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isFormula = event.type === 'formula';
|
||||
if (isFormula) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReportSeriesItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
index={index}
|
||||
showSegment={showSegment}
|
||||
showAddFilter={showAddFilter}
|
||||
isSelectManyEvents={isSelectManyEvents}
|
||||
className="rounded-lg border bg-def-100"
|
||||
>
|
||||
<ComboboxEvents
|
||||
className="flex-1"
|
||||
searchable
|
||||
multiple={isSelectManyEvents as false}
|
||||
value={
|
||||
(isSelectManyEvents
|
||||
? ((
|
||||
event as IChartEventItem & {
|
||||
type: 'event';
|
||||
}
|
||||
).filters[0]?.value ?? [])
|
||||
: (
|
||||
event as IChartEventItem & {
|
||||
type: 'event';
|
||||
}
|
||||
).name) as any
|
||||
}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent(
|
||||
Array.isArray(value)
|
||||
? {
|
||||
id: event.id,
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}
|
||||
: {
|
||||
...event,
|
||||
type: 'event',
|
||||
name: value,
|
||||
filters: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
}}
|
||||
items={eventNames}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={
|
||||
(event as IChartEventItem & { type: 'event' }).name
|
||||
? `${(event as IChartEventItem & { type: 'event' }).name} (${alphabetIds[index]})`
|
||||
: 'Display name'
|
||||
}
|
||||
defaultValue={
|
||||
(event as IChartEventItem & { type: 'event' }).displayName
|
||||
}
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
...(event as IChartEventItem & {
|
||||
type: 'event';
|
||||
}),
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</ReportSeriesItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -178,7 +178,7 @@ export function ComboboxEvents<
|
||||
|
||||
<CommandEmpty>Nothing selected</CommandEmpty>
|
||||
<VirtualList
|
||||
height={400}
|
||||
height={300}
|
||||
data={items.filter((item) => {
|
||||
if (search === '') return true;
|
||||
return item.name.toLowerCase().includes(search.toLowerCase());
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
|
||||
const labelVariants = cva(
|
||||
'mb-3 text-sm block font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
'mb-3 text-sm block font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-foreground/70',
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
|
||||
@@ -25,12 +25,40 @@ export function createTRPCClientWithHeaders(apiUrl: string) {
|
||||
transformer: superjson,
|
||||
url: `${apiUrl}/trpc`,
|
||||
headers: () => getIsomorphicHeaders(),
|
||||
fetch: (url, options) => {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
mode: 'cors',
|
||||
credentials: 'include',
|
||||
});
|
||||
fetch: async (url, options) => {
|
||||
try {
|
||||
console.log('fetching', url, options);
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
mode: 'cors',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
// Log HTTP errors on server
|
||||
if (!response.ok && typeof window === 'undefined') {
|
||||
const text = await response.clone().text();
|
||||
console.error('[tRPC SSR Error]', {
|
||||
url: url.toString(),
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: text,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Log fetch errors on server
|
||||
if (typeof window === 'undefined') {
|
||||
console.error('[tRPC SSR Error]', {
|
||||
url: url.toString(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
options,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -30,7 +30,9 @@ import OverviewFilters from './overview-filters';
|
||||
import RequestPasswordReset from './request-reset-password';
|
||||
import SaveReport from './save-report';
|
||||
import SelectBillingPlan from './select-billing-plan';
|
||||
import ShareDashboardModal from './share-dashboard-modal';
|
||||
import ShareOverviewModal from './share-overview-modal';
|
||||
import ShareReportModal from './share-report-modal';
|
||||
import ViewChartUsers from './view-chart-users';
|
||||
|
||||
const modals = {
|
||||
@@ -51,6 +53,8 @@ const modals = {
|
||||
EditReport: EditReport,
|
||||
EditReference: EditReference,
|
||||
ShareOverviewModal: ShareOverviewModal,
|
||||
ShareDashboardModal: ShareDashboardModal,
|
||||
ShareReportModal: ShareReportModal,
|
||||
AddReference: AddReference,
|
||||
ViewChartUsers: ViewChartUsers,
|
||||
Instructions: Instructions,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
import type { IChartProps } from '@openpanel/validation';
|
||||
import type { IReport } from '@openpanel/validation';
|
||||
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
|
||||
type Props = {
|
||||
chart: IChartProps;
|
||||
chart: IReport;
|
||||
};
|
||||
|
||||
const OverviewChartDetails = (props: Props) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Controller, useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { IChartProps } from '@openpanel/validation';
|
||||
import type { IReport } from '@openpanel/validation';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
@@ -21,7 +21,7 @@ import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
|
||||
type SaveReportProps = {
|
||||
report: IChartProps;
|
||||
report: IReport;
|
||||
disableRedirect?: boolean;
|
||||
};
|
||||
|
||||
|
||||
192
apps/start/src/modals/share-dashboard-modal.tsx
Normal file
192
apps/start/src/modals/share-dashboard-modal.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { ButtonContainer } from '@/components/button-container';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { handleError } from '@/integrations/trpc/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { zShareDashboard } from '@openpanel/validation';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckCircle2, Copy, ExternalLink, TrashIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
|
||||
const validator = zShareDashboard;
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
|
||||
export default function ShareDashboardModal({
|
||||
dashboardId,
|
||||
}: {
|
||||
dashboardId: string;
|
||||
}) {
|
||||
const { projectId, organizationId } = useAppParams();
|
||||
const navigate = useNavigate();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch current share status
|
||||
const shareQuery = useQuery(
|
||||
trpc.share.dashboard.queryOptions({
|
||||
dashboardId,
|
||||
}),
|
||||
);
|
||||
|
||||
const existingShare = shareQuery.data;
|
||||
const isShared = existingShare?.public ?? false;
|
||||
const shareUrl = existingShare?.id
|
||||
? `${window.location.origin}/share/dashboard/${existingShare.id}`
|
||||
: '';
|
||||
|
||||
const { register, handleSubmit, watch } = useForm<IForm>({
|
||||
resolver: zodResolver(validator),
|
||||
defaultValues: {
|
||||
public: true,
|
||||
password: existingShare?.password ? '••••••••' : '',
|
||||
projectId,
|
||||
organizationId,
|
||||
dashboardId,
|
||||
},
|
||||
});
|
||||
|
||||
const password = watch('password');
|
||||
|
||||
const mutation = useMutation(
|
||||
trpc.share.createDashboard.mutationOptions({
|
||||
onError: handleError,
|
||||
onSuccess(res) {
|
||||
queryClient.invalidateQueries(trpc.share.dashboard.pathFilter());
|
||||
toast('Success', {
|
||||
description: `Your dashboard is now ${
|
||||
res.public ? 'public' : 'private'
|
||||
}`,
|
||||
action: res.public
|
||||
? {
|
||||
label: 'View',
|
||||
onClick: () =>
|
||||
navigate({
|
||||
to: '/share/dashboard/$shareId',
|
||||
params: {
|
||||
shareId: res.id,
|
||||
},
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
popModal();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
toast('Link copied to clipboard');
|
||||
};
|
||||
|
||||
const handleMakePrivate = () => {
|
||||
mutation.mutate({
|
||||
public: false,
|
||||
password: null,
|
||||
projectId,
|
||||
organizationId,
|
||||
dashboardId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalContent className="max-w-md">
|
||||
<ModalHeader
|
||||
title="Dashboard public availability"
|
||||
text={
|
||||
isShared
|
||||
? 'Your dashboard is currently public and can be accessed by anyone with the link.'
|
||||
: 'You can choose if you want to add a password to make it a bit more private.'
|
||||
}
|
||||
/>
|
||||
|
||||
{isShared && (
|
||||
<div className="p-4 bg-def-100 border rounded-lg space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span className="font-medium">Currently shared</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input value={shareUrl} readOnly className="flex-1 text-sm" />
|
||||
<Tooltiper content="Copy link">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckCircle2 className="size-4" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltiper>
|
||||
<Tooltiper content="Open in new tab">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(shareUrl, '_blank')}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</Tooltiper>
|
||||
<Tooltiper content="Make private">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleMakePrivate}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltiper>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate({
|
||||
...values,
|
||||
// Only send password if it's not the placeholder
|
||||
password:
|
||||
values.password === '••••••••' ? null : values.password || null,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Input
|
||||
{...register('password')}
|
||||
placeholder="Enter your password (optional)"
|
||||
size="large"
|
||||
type={password === '••••••••' ? 'text' : 'password'}
|
||||
/>
|
||||
<ButtonContainer>
|
||||
<Button type="button" variant="outline" onClick={() => popModal()}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
{isShared ? 'Update' : 'Make it public'}
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</form>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
@@ -11,8 +11,11 @@ import type { z } from 'zod';
|
||||
import { zShareOverview } from '@openpanel/validation';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckCircle2, Copy, ExternalLink, TrashIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
|
||||
@@ -23,19 +26,36 @@ type IForm = z.infer<typeof validator>;
|
||||
export default function ShareOverviewModal() {
|
||||
const { projectId, organizationId } = useAppParams();
|
||||
const navigate = useNavigate();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const { register, handleSubmit } = useForm<IForm>({
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch current share status
|
||||
const shareQuery = useQuery(
|
||||
trpc.share.overview.queryOptions({
|
||||
projectId,
|
||||
}),
|
||||
);
|
||||
|
||||
const existingShare = shareQuery.data;
|
||||
const isShared = existingShare?.public ?? false;
|
||||
const shareUrl = existingShare?.id
|
||||
? `${window.location.origin}/share/overview/${existingShare.id}`
|
||||
: '';
|
||||
|
||||
const { register, handleSubmit, watch } = useForm<IForm>({
|
||||
resolver: zodResolver(validator),
|
||||
defaultValues: {
|
||||
public: true,
|
||||
password: '',
|
||||
password: existingShare?.password ? '••••••••' : '',
|
||||
projectId,
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const password = watch('password');
|
||||
|
||||
const mutation = useMutation(
|
||||
trpc.share.createOverview.mutationOptions({
|
||||
onError: handleError,
|
||||
@@ -45,47 +65,122 @@ export default function ShareOverviewModal() {
|
||||
description: `Your overview is now ${
|
||||
res.public ? 'public' : 'private'
|
||||
}`,
|
||||
action: {
|
||||
label: 'View',
|
||||
onClick: () =>
|
||||
navigate({
|
||||
to: '/share/overview/$shareId',
|
||||
params: {
|
||||
shareId: res.id,
|
||||
},
|
||||
}),
|
||||
},
|
||||
action: res.public
|
||||
? {
|
||||
label: 'View',
|
||||
onClick: () =>
|
||||
navigate({
|
||||
to: '/share/overview/$shareId',
|
||||
params: {
|
||||
shareId: res.id,
|
||||
},
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
popModal();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
toast('Link copied to clipboard');
|
||||
};
|
||||
|
||||
const handleMakePrivate = () => {
|
||||
mutation.mutate({
|
||||
public: false,
|
||||
password: null,
|
||||
projectId,
|
||||
organizationId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalContent className="max-w-md">
|
||||
<ModalHeader
|
||||
title="Dashboard public availability"
|
||||
text="You can choose if you want to add a password to make it a bit more private."
|
||||
title="Overview public availability"
|
||||
text={
|
||||
isShared
|
||||
? 'Your overview is currently public and can be accessed by anyone with the link.'
|
||||
: 'You can choose if you want to add a password to make it a bit more private.'
|
||||
}
|
||||
/>
|
||||
|
||||
{isShared && (
|
||||
<div className="p-4 bg-def-100 border rounded-lg space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span className="font-medium">Currently shared</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input value={shareUrl} readOnly className="flex-1 text-sm" />
|
||||
<Tooltiper content="Copy link">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckCircle2 className="size-4" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltiper>
|
||||
<Tooltiper content="Open in new tab">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(shareUrl, '_blank')}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</Tooltiper>
|
||||
<Tooltiper content="Make private">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleMakePrivate}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltiper>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
mutation.mutate({
|
||||
...values,
|
||||
// Only send password if it's not the placeholder
|
||||
password:
|
||||
values.password === '••••••••' ? null : values.password || null,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Input
|
||||
{...register('password')}
|
||||
placeholder="Enter your password"
|
||||
placeholder="Enter your password (optional)"
|
||||
size="large"
|
||||
type={password === '••••••••' ? 'text' : 'password'}
|
||||
/>
|
||||
<ButtonContainer>
|
||||
<Button type="button" variant="outline" onClick={() => popModal()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={mutation.isPending}>
|
||||
Make it public
|
||||
|
||||
<Button type="submit">
|
||||
{isShared ? 'Update' : 'Make it public'}
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</form>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
186
apps/start/src/modals/share-report-modal.tsx
Normal file
186
apps/start/src/modals/share-report-modal.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { ButtonContainer } from '@/components/button-container';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { handleError } from '@/integrations/trpc/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { zShareReport } from '@openpanel/validation';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckCircle2, Copy, ExternalLink, TrashIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
|
||||
const validator = zShareReport;
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
|
||||
export default function ShareReportModal({ reportId }: { reportId: string }) {
|
||||
const { projectId, organizationId } = useAppParams();
|
||||
const navigate = useNavigate();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch current share status
|
||||
const shareQuery = useQuery(
|
||||
trpc.share.report.queryOptions({
|
||||
reportId,
|
||||
}),
|
||||
);
|
||||
|
||||
const existingShare = shareQuery.data;
|
||||
const isShared = existingShare?.public ?? false;
|
||||
const shareUrl = existingShare?.id
|
||||
? `${window.location.origin}/share/report/${existingShare.id}`
|
||||
: '';
|
||||
|
||||
const { register, handleSubmit, watch } = useForm<IForm>({
|
||||
resolver: zodResolver(validator),
|
||||
defaultValues: {
|
||||
public: true,
|
||||
password: existingShare?.password ? '••••••••' : '',
|
||||
projectId,
|
||||
organizationId,
|
||||
reportId,
|
||||
},
|
||||
});
|
||||
|
||||
const password = watch('password');
|
||||
|
||||
const mutation = useMutation(
|
||||
trpc.share.createReport.mutationOptions({
|
||||
onError: handleError,
|
||||
onSuccess(res) {
|
||||
queryClient.invalidateQueries(trpc.share.report.pathFilter());
|
||||
toast('Success', {
|
||||
description: `Your report is now ${res.public ? 'public' : 'private'}`,
|
||||
action: res.public
|
||||
? {
|
||||
label: 'View',
|
||||
onClick: () =>
|
||||
navigate({
|
||||
to: '/share/report/$shareId',
|
||||
params: {
|
||||
shareId: res.id,
|
||||
},
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
popModal();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
toast('Link copied to clipboard');
|
||||
};
|
||||
|
||||
const handleMakePrivate = () => {
|
||||
mutation.mutate({
|
||||
public: false,
|
||||
password: null,
|
||||
projectId,
|
||||
organizationId,
|
||||
reportId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalContent className="max-w-md">
|
||||
<ModalHeader
|
||||
title="Report public availability"
|
||||
text={
|
||||
isShared
|
||||
? 'Your report is currently public and can be accessed by anyone with the link.'
|
||||
: 'You can choose if you want to add a password to make it a bit more private.'
|
||||
}
|
||||
/>
|
||||
|
||||
{isShared && (
|
||||
<div className="p-4 bg-def-100 border rounded-lg space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span className="font-medium">Currently shared</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input value={shareUrl} readOnly className="flex-1 text-sm" />
|
||||
<Tooltiper content="Copy link">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckCircle2 className="size-4" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltiper>
|
||||
<Tooltiper content="Open in new tab">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(shareUrl, '_blank')}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</Tooltiper>
|
||||
<Tooltiper content="Make private">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleMakePrivate}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltiper>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate({
|
||||
...values,
|
||||
// Only send password if it's not the placeholder
|
||||
password:
|
||||
values.password === '••••••••' ? null : values.password || null,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Input
|
||||
{...register('password')}
|
||||
placeholder="Enter your password (optional)"
|
||||
size="large"
|
||||
type={password === '••••••••' ? 'text' : 'password'}
|
||||
/>
|
||||
<ButtonContainer>
|
||||
<Button type="button" variant="outline" onClick={() => popModal()}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
{isShared ? 'Update' : 'Make it public'}
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</form>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ 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 type { IReportInput } from '@openpanel/validation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
@@ -152,7 +152,7 @@ function ProfileList({ profiles }: { profiles: any[] }) {
|
||||
// Chart-specific props and component
|
||||
interface ChartUsersViewProps {
|
||||
chartData: IChartData;
|
||||
report: IChartInput;
|
||||
report: IReportInput;
|
||||
date: string;
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
|
||||
|
||||
// Funnel-specific props and component
|
||||
interface FunnelUsersViewProps {
|
||||
report: IChartInput;
|
||||
report: IReportInput;
|
||||
stepIndex: number;
|
||||
}
|
||||
|
||||
@@ -297,8 +297,14 @@ function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
|
||||
series: report.series,
|
||||
stepIndex: stepIndex,
|
||||
showDropoffs: showDropoffs,
|
||||
funnelWindow: report.funnelWindow,
|
||||
funnelGroup: report.funnelGroup,
|
||||
funnelWindow:
|
||||
report.options?.type === 'funnel'
|
||||
? report.options.funnelWindow
|
||||
: undefined,
|
||||
funnelGroup:
|
||||
report.options?.type === 'funnel'
|
||||
? report.options.funnelGroup
|
||||
: undefined,
|
||||
breakdowns: report.breakdowns,
|
||||
},
|
||||
{
|
||||
@@ -371,12 +377,12 @@ type ViewChartUsersProps =
|
||||
| {
|
||||
type: 'chart';
|
||||
chartData: IChartData;
|
||||
report: IChartInput;
|
||||
report: IReportInput;
|
||||
date: string;
|
||||
}
|
||||
| {
|
||||
type: 'funnel';
|
||||
report: IChartInput;
|
||||
report: IReportInput;
|
||||
stepIndex: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ import { Route as PublicRouteImport } from './routes/_public'
|
||||
import { Route as LoginRouteImport } from './routes/_login'
|
||||
import { Route as AppRouteImport } from './routes/_app'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as WidgetTestRouteImport } from './routes/widget/test'
|
||||
import { Route as WidgetRealtimeRouteImport } from './routes/widget/realtime'
|
||||
import { Route as WidgetCounterRouteImport } from './routes/widget/counter'
|
||||
import { Route as ApiHealthcheckRouteImport } from './routes/api/healthcheck'
|
||||
import { Route as ApiConfigRouteImport } from './routes/api/config'
|
||||
import { Route as PublicOnboardingRouteImport } from './routes/_public.onboarding'
|
||||
@@ -23,7 +26,9 @@ import { Route as LoginResetPasswordRouteImport } from './routes/_login.reset-pa
|
||||
import { Route as LoginLoginRouteImport } from './routes/_login.login'
|
||||
import { Route as AppOrganizationIdRouteImport } from './routes/_app.$organizationId'
|
||||
import { Route as AppOrganizationIdIndexRouteImport } from './routes/_app.$organizationId.index'
|
||||
import { Route as ShareReportShareIdRouteImport } from './routes/share.report.$shareId'
|
||||
import { Route as ShareOverviewShareIdRouteImport } from './routes/share.overview.$shareId'
|
||||
import { Route as ShareDashboardShareIdRouteImport } from './routes/share.dashboard.$shareId'
|
||||
import { Route as StepsOnboardingProjectRouteImport } from './routes/_steps.onboarding.project'
|
||||
import { Route as AppOrganizationIdSettingsRouteImport } from './routes/_app.$organizationId.settings'
|
||||
import { Route as AppOrganizationIdBillingRouteImport } from './routes/_app.$organizationId.billing'
|
||||
@@ -58,6 +63,7 @@ import { Route as AppOrganizationIdProjectIdSettingsTabsIndexRouteImport } from
|
||||
import { Route as AppOrganizationIdProjectIdProfilesTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles._tabs.index'
|
||||
import { Route as AppOrganizationIdProjectIdNotificationsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.index'
|
||||
import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.index'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details'
|
||||
@@ -117,6 +123,21 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WidgetTestRoute = WidgetTestRouteImport.update({
|
||||
id: '/widget/test',
|
||||
path: '/widget/test',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WidgetRealtimeRoute = WidgetRealtimeRouteImport.update({
|
||||
id: '/widget/realtime',
|
||||
path: '/widget/realtime',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WidgetCounterRoute = WidgetCounterRouteImport.update({
|
||||
id: '/widget/counter',
|
||||
path: '/widget/counter',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiHealthcheckRoute = ApiHealthcheckRouteImport.update({
|
||||
id: '/api/healthcheck',
|
||||
path: '/api/healthcheck',
|
||||
@@ -164,11 +185,21 @@ const AppOrganizationIdIndexRoute = AppOrganizationIdIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => AppOrganizationIdRoute,
|
||||
} as any)
|
||||
const ShareReportShareIdRoute = ShareReportShareIdRouteImport.update({
|
||||
id: '/share/report/$shareId',
|
||||
path: '/share/report/$shareId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ShareOverviewShareIdRoute = ShareOverviewShareIdRouteImport.update({
|
||||
id: '/share/overview/$shareId',
|
||||
path: '/share/overview/$shareId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ShareDashboardShareIdRoute = ShareDashboardShareIdRouteImport.update({
|
||||
id: '/share/dashboard/$shareId',
|
||||
path: '/share/dashboard/$shareId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const StepsOnboardingProjectRoute = StepsOnboardingProjectRouteImport.update({
|
||||
id: '/onboarding/project',
|
||||
path: '/onboarding/project',
|
||||
@@ -396,6 +427,12 @@ const AppOrganizationIdProjectIdEventsTabsIndexRoute =
|
||||
path: '/',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdEventsTabsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdSettingsTabsWidgetsRoute =
|
||||
AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport.update({
|
||||
id: '/widgets',
|
||||
path: '/widgets',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdSettingsTabsImportsRoute =
|
||||
AppOrganizationIdProjectIdSettingsTabsImportsRouteImport.update({
|
||||
id: '/imports',
|
||||
@@ -494,11 +531,16 @@ export interface FileRoutesByFullPath {
|
||||
'/onboarding': typeof PublicOnboardingRoute
|
||||
'/api/config': typeof ApiConfigRoute
|
||||
'/api/healthcheck': typeof ApiHealthcheckRoute
|
||||
'/widget/counter': typeof WidgetCounterRoute
|
||||
'/widget/realtime': typeof WidgetRealtimeRoute
|
||||
'/widget/test': typeof WidgetTestRoute
|
||||
'/$organizationId/$projectId': typeof AppOrganizationIdProjectIdRouteWithChildren
|
||||
'/$organizationId/billing': typeof AppOrganizationIdBillingRoute
|
||||
'/$organizationId/settings': typeof AppOrganizationIdSettingsRoute
|
||||
'/onboarding/project': typeof StepsOnboardingProjectRoute
|
||||
'/share/dashboard/$shareId': typeof ShareDashboardShareIdRoute
|
||||
'/share/overview/$shareId': typeof ShareOverviewShareIdRoute
|
||||
'/share/report/$shareId': typeof ShareReportShareIdRoute
|
||||
'/$organizationId/': typeof AppOrganizationIdIndexRoute
|
||||
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
@@ -539,6 +581,7 @@ export interface FileRoutesByFullPath {
|
||||
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||
'/$organizationId/$projectId/events/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
|
||||
'/$organizationId/$projectId/notifications/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
|
||||
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
||||
@@ -553,10 +596,15 @@ export interface FileRoutesByTo {
|
||||
'/onboarding': typeof PublicOnboardingRoute
|
||||
'/api/config': typeof ApiConfigRoute
|
||||
'/api/healthcheck': typeof ApiHealthcheckRoute
|
||||
'/widget/counter': typeof WidgetCounterRoute
|
||||
'/widget/realtime': typeof WidgetRealtimeRoute
|
||||
'/widget/test': typeof WidgetTestRoute
|
||||
'/$organizationId/billing': typeof AppOrganizationIdBillingRoute
|
||||
'/$organizationId/settings': typeof AppOrganizationIdSettingsRoute
|
||||
'/onboarding/project': typeof StepsOnboardingProjectRoute
|
||||
'/share/dashboard/$shareId': typeof ShareDashboardShareIdRoute
|
||||
'/share/overview/$shareId': typeof ShareOverviewShareIdRoute
|
||||
'/share/report/$shareId': typeof ShareReportShareIdRoute
|
||||
'/$organizationId': typeof AppOrganizationIdIndexRoute
|
||||
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
@@ -595,6 +643,7 @@ export interface FileRoutesByTo {
|
||||
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
@@ -610,11 +659,16 @@ export interface FileRoutesById {
|
||||
'/_public/onboarding': typeof PublicOnboardingRoute
|
||||
'/api/config': typeof ApiConfigRoute
|
||||
'/api/healthcheck': typeof ApiHealthcheckRoute
|
||||
'/widget/counter': typeof WidgetCounterRoute
|
||||
'/widget/realtime': typeof WidgetRealtimeRoute
|
||||
'/widget/test': typeof WidgetTestRoute
|
||||
'/_app/$organizationId/$projectId': typeof AppOrganizationIdProjectIdRouteWithChildren
|
||||
'/_app/$organizationId/billing': typeof AppOrganizationIdBillingRoute
|
||||
'/_app/$organizationId/settings': typeof AppOrganizationIdSettingsRoute
|
||||
'/_steps/onboarding/project': typeof StepsOnboardingProjectRoute
|
||||
'/share/dashboard/$shareId': typeof ShareDashboardShareIdRoute
|
||||
'/share/overview/$shareId': typeof ShareOverviewShareIdRoute
|
||||
'/share/report/$shareId': typeof ShareReportShareIdRoute
|
||||
'/_app/$organizationId/': typeof AppOrganizationIdIndexRoute
|
||||
'/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||
'/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
@@ -662,6 +716,7 @@ export interface FileRoutesById {
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||
'/_app/$organizationId/$projectId/events/_tabs/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
|
||||
'/_app/$organizationId/$projectId/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
|
||||
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
||||
@@ -679,11 +734,16 @@ export interface FileRouteTypes {
|
||||
| '/onboarding'
|
||||
| '/api/config'
|
||||
| '/api/healthcheck'
|
||||
| '/widget/counter'
|
||||
| '/widget/realtime'
|
||||
| '/widget/test'
|
||||
| '/$organizationId/$projectId'
|
||||
| '/$organizationId/billing'
|
||||
| '/$organizationId/settings'
|
||||
| '/onboarding/project'
|
||||
| '/share/dashboard/$shareId'
|
||||
| '/share/overview/$shareId'
|
||||
| '/share/report/$shareId'
|
||||
| '/$organizationId/'
|
||||
| '/$organizationId/$projectId/chat'
|
||||
| '/$organizationId/$projectId/dashboards'
|
||||
@@ -724,6 +784,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/settings/details'
|
||||
| '/$organizationId/$projectId/settings/events'
|
||||
| '/$organizationId/$projectId/settings/imports'
|
||||
| '/$organizationId/$projectId/settings/widgets'
|
||||
| '/$organizationId/$projectId/events/'
|
||||
| '/$organizationId/$projectId/notifications/'
|
||||
| '/$organizationId/$projectId/profiles/'
|
||||
@@ -738,10 +799,15 @@ export interface FileRouteTypes {
|
||||
| '/onboarding'
|
||||
| '/api/config'
|
||||
| '/api/healthcheck'
|
||||
| '/widget/counter'
|
||||
| '/widget/realtime'
|
||||
| '/widget/test'
|
||||
| '/$organizationId/billing'
|
||||
| '/$organizationId/settings'
|
||||
| '/onboarding/project'
|
||||
| '/share/dashboard/$shareId'
|
||||
| '/share/overview/$shareId'
|
||||
| '/share/report/$shareId'
|
||||
| '/$organizationId'
|
||||
| '/$organizationId/$projectId/chat'
|
||||
| '/$organizationId/$projectId/dashboards'
|
||||
@@ -780,6 +846,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/settings/details'
|
||||
| '/$organizationId/$projectId/settings/events'
|
||||
| '/$organizationId/$projectId/settings/imports'
|
||||
| '/$organizationId/$projectId/settings/widgets'
|
||||
| '/$organizationId/$projectId/profiles/$profileId/events'
|
||||
id:
|
||||
| '__root__'
|
||||
@@ -794,11 +861,16 @@ export interface FileRouteTypes {
|
||||
| '/_public/onboarding'
|
||||
| '/api/config'
|
||||
| '/api/healthcheck'
|
||||
| '/widget/counter'
|
||||
| '/widget/realtime'
|
||||
| '/widget/test'
|
||||
| '/_app/$organizationId/$projectId'
|
||||
| '/_app/$organizationId/billing'
|
||||
| '/_app/$organizationId/settings'
|
||||
| '/_steps/onboarding/project'
|
||||
| '/share/dashboard/$shareId'
|
||||
| '/share/overview/$shareId'
|
||||
| '/share/report/$shareId'
|
||||
| '/_app/$organizationId/'
|
||||
| '/_app/$organizationId/$projectId/chat'
|
||||
| '/_app/$organizationId/$projectId/dashboards'
|
||||
@@ -846,6 +918,7 @@ export interface FileRouteTypes {
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/details'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/events'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/imports'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/widgets'
|
||||
| '/_app/$organizationId/$projectId/events/_tabs/'
|
||||
| '/_app/$organizationId/$projectId/notifications/_tabs/'
|
||||
| '/_app/$organizationId/$projectId/profiles/_tabs/'
|
||||
@@ -862,7 +935,12 @@ export interface RootRouteChildren {
|
||||
StepsRoute: typeof StepsRouteWithChildren
|
||||
ApiConfigRoute: typeof ApiConfigRoute
|
||||
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
|
||||
WidgetCounterRoute: typeof WidgetCounterRoute
|
||||
WidgetRealtimeRoute: typeof WidgetRealtimeRoute
|
||||
WidgetTestRoute: typeof WidgetTestRoute
|
||||
ShareDashboardShareIdRoute: typeof ShareDashboardShareIdRoute
|
||||
ShareOverviewShareIdRoute: typeof ShareOverviewShareIdRoute
|
||||
ShareReportShareIdRoute: typeof ShareReportShareIdRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -902,6 +980,27 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/widget/test': {
|
||||
id: '/widget/test'
|
||||
path: '/widget/test'
|
||||
fullPath: '/widget/test'
|
||||
preLoaderRoute: typeof WidgetTestRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/widget/realtime': {
|
||||
id: '/widget/realtime'
|
||||
path: '/widget/realtime'
|
||||
fullPath: '/widget/realtime'
|
||||
preLoaderRoute: typeof WidgetRealtimeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/widget/counter': {
|
||||
id: '/widget/counter'
|
||||
path: '/widget/counter'
|
||||
fullPath: '/widget/counter'
|
||||
preLoaderRoute: typeof WidgetCounterRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/healthcheck': {
|
||||
id: '/api/healthcheck'
|
||||
path: '/api/healthcheck'
|
||||
@@ -965,6 +1064,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdIndexRouteImport
|
||||
parentRoute: typeof AppOrganizationIdRoute
|
||||
}
|
||||
'/share/report/$shareId': {
|
||||
id: '/share/report/$shareId'
|
||||
path: '/share/report/$shareId'
|
||||
fullPath: '/share/report/$shareId'
|
||||
preLoaderRoute: typeof ShareReportShareIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/share/overview/$shareId': {
|
||||
id: '/share/overview/$shareId'
|
||||
path: '/share/overview/$shareId'
|
||||
@@ -972,6 +1078,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ShareOverviewShareIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/share/dashboard/$shareId': {
|
||||
id: '/share/dashboard/$shareId'
|
||||
path: '/share/dashboard/$shareId'
|
||||
fullPath: '/share/dashboard/$shareId'
|
||||
preLoaderRoute: typeof ShareDashboardShareIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_steps/onboarding/project': {
|
||||
id: '/_steps/onboarding/project'
|
||||
path: '/onboarding/project'
|
||||
@@ -1245,6 +1358,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdEventsTabsIndexRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdEventsTabsRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/widgets': {
|
||||
id: '/_app/$organizationId/$projectId/settings/_tabs/widgets'
|
||||
path: '/widgets'
|
||||
fullPath: '/$organizationId/$projectId/settings/widgets'
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/imports': {
|
||||
id: '/_app/$organizationId/$projectId/settings/_tabs/imports'
|
||||
path: '/imports'
|
||||
@@ -1508,6 +1628,7 @@ interface AppOrganizationIdProjectIdSettingsTabsRouteChildren {
|
||||
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||
AppOrganizationIdProjectIdSettingsTabsIndexRoute: typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
||||
}
|
||||
|
||||
@@ -1521,6 +1642,8 @@ const AppOrganizationIdProjectIdSettingsTabsRouteChildren: AppOrganizationIdProj
|
||||
AppOrganizationIdProjectIdSettingsTabsEventsRoute,
|
||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute:
|
||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute,
|
||||
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute:
|
||||
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute,
|
||||
AppOrganizationIdProjectIdSettingsTabsIndexRoute:
|
||||
AppOrganizationIdProjectIdSettingsTabsIndexRoute,
|
||||
}
|
||||
@@ -1751,7 +1874,12 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
StepsRoute: StepsRouteWithChildren,
|
||||
ApiConfigRoute: ApiConfigRoute,
|
||||
ApiHealthcheckRoute: ApiHealthcheckRoute,
|
||||
WidgetCounterRoute: WidgetCounterRoute,
|
||||
WidgetRealtimeRoute: WidgetRealtimeRoute,
|
||||
WidgetTestRoute: WidgetTestRoute,
|
||||
ShareDashboardShareIdRoute: ShareDashboardShareIdRoute,
|
||||
ShareOverviewShareIdRoute: ShareOverviewShareIdRoute,
|
||||
ShareReportShareIdRoute: ShareReportShareIdRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
HeadContent,
|
||||
Scripts,
|
||||
createRootRouteWithContext,
|
||||
useRouteContext,
|
||||
} from '@tanstack/react-router';
|
||||
|
||||
import 'flag-icons/css/flag-icons.min.css';
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
BarChartHorizontalIcon,
|
||||
ChartScatterIcon,
|
||||
ConeIcon,
|
||||
GitBranchIcon,
|
||||
Globe2Icon,
|
||||
HashIcon,
|
||||
LayoutPanelTopIcon,
|
||||
@@ -153,6 +154,7 @@ function Component() {
|
||||
area: AreaChartIcon,
|
||||
retention: ChartScatterIcon,
|
||||
conversion: TrendingUpIcon,
|
||||
sankey: GitBranchIcon,
|
||||
}[report.chartType];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import { Button, LinkButton } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -9,48 +8,36 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { createProjectTitle } from '@/utils/title';
|
||||
import {
|
||||
CopyIcon,
|
||||
LayoutPanelTopIcon,
|
||||
MoreHorizontal,
|
||||
PlusIcon,
|
||||
RotateCcw,
|
||||
Trash,
|
||||
ShareIcon,
|
||||
TrashIcon,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { timeWindows } from '@openpanel/constants';
|
||||
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import {
|
||||
GrafanaGrid,
|
||||
type Layout,
|
||||
useReportLayouts,
|
||||
} from '@/components/grafana-grid';
|
||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||
import { OverviewRange } from '@/components/overview/overview-range';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import {
|
||||
ReportItem,
|
||||
ReportItemSkeleton,
|
||||
} from '@/components/report/report-item';
|
||||
import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react';
|
||||
import { showConfirm } from '@/modals';
|
||||
import { pushModal, showConfirm } from '@/modals';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, useRouter } from '@tanstack/react-router';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Responsive, WidthProvider } from 'react-grid-layout';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(Responsive);
|
||||
|
||||
type Layout = {
|
||||
i: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
maxW?: number;
|
||||
maxH?: number;
|
||||
};
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/dashboards_/$dashboardId',
|
||||
@@ -94,180 +81,6 @@ export const Route = createFileRoute(
|
||||
pendingComponent: FullPageLoadingState,
|
||||
});
|
||||
|
||||
// Report Skeleton Component
|
||||
function ReportSkeleton() {
|
||||
return (
|
||||
<div className="card h-full flex flex-col animate-pulse">
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<div className="flex-1">
|
||||
<div className="h-5 w-32 bg-muted rounded mb-2" />
|
||||
<div className="h-4 w-24 bg-muted/50 rounded" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-muted rounded" />
|
||||
<div className="w-8 h-8 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 flex-1 flex items-center justify-center aspect-video" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Report Item Component
|
||||
function ReportItem({
|
||||
report,
|
||||
organizationId,
|
||||
projectId,
|
||||
range,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
}: {
|
||||
report: any;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
range: any;
|
||||
startDate: any;
|
||||
endDate: any;
|
||||
interval: any;
|
||||
onDelete: (reportId: string) => void;
|
||||
onDuplicate: (reportId: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const chartRange = report.range;
|
||||
|
||||
return (
|
||||
<div className="card h-full flex flex-col">
|
||||
<div className="flex items-center hover:bg-muted/50 justify-between border-b border-border p-4 leading-none [&_svg]:hover:opacity-100">
|
||||
<div
|
||||
className="flex-1 cursor-pointer -m-4 p-4"
|
||||
onClick={(event) => {
|
||||
if (event.metaKey) {
|
||||
window.open(
|
||||
`/${organizationId}/${projectId}/reports/${report.id}`,
|
||||
'_blank',
|
||||
);
|
||||
return;
|
||||
}
|
||||
router.navigate({
|
||||
from: Route.fullPath,
|
||||
to: '/$organizationId/$projectId/reports/$reportId',
|
||||
params: {
|
||||
reportId: report.id,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
router.navigate({
|
||||
from: Route.fullPath,
|
||||
to: '/$organizationId/$projectId/reports/$reportId',
|
||||
params: {
|
||||
reportId: report.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="font-medium">{report.name}</div>
|
||||
{chartRange !== null && (
|
||||
<div className="mt-2 flex gap-2 ">
|
||||
<span
|
||||
className={
|
||||
(chartRange !== range && range !== null) ||
|
||||
(startDate && endDate)
|
||||
? 'line-through'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
|
||||
</span>
|
||||
{startDate && endDate ? (
|
||||
<span>Custom dates</span>
|
||||
) : (
|
||||
range !== null &&
|
||||
chartRange !== range && (
|
||||
<span>
|
||||
{timeWindows[range as keyof typeof timeWindows]?.label}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="drag-handle cursor-move p-2 hover:bg-muted rounded">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
className="opacity-30 hover:opacity-100"
|
||||
>
|
||||
<circle cx="4" cy="4" r="1.5" />
|
||||
<circle cx="4" cy="8" r="1.5" />
|
||||
<circle cx="4" cy="12" r="1.5" />
|
||||
<circle cx="12" cy="4" r="1.5" />
|
||||
<circle cx="12" cy="8" r="1.5" />
|
||||
<circle cx="12" cy="12" r="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex h-8 w-8 items-center justify-center rounded hover:border">
|
||||
<MoreHorizontal size={16} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDuplicate(report.id);
|
||||
}}
|
||||
>
|
||||
<CopyIcon size={16} className="mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(report.id);
|
||||
}}
|
||||
>
|
||||
<Trash size={16} className="mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 overflow-auto flex-1',
|
||||
report.chartType === 'metric' && 'p-0',
|
||||
)}
|
||||
>
|
||||
<ReportChart
|
||||
report={
|
||||
{
|
||||
...report,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? null,
|
||||
endDate: endDate ?? null,
|
||||
interval: interval ?? report.interval,
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Component() {
|
||||
const router = useRouter();
|
||||
const { organizationId, dashboardId, projectId } = Route.useParams();
|
||||
@@ -363,26 +176,7 @@ function Component() {
|
||||
);
|
||||
|
||||
// Convert reports to grid layout format for all breakpoints
|
||||
const layouts = useMemo(() => {
|
||||
const baseLayout = reports.map((report, index) => ({
|
||||
i: report.id,
|
||||
x: report.layout?.x ?? (index % 2) * 6,
|
||||
y: report.layout?.y ?? Math.floor(index / 2) * 4,
|
||||
w: report.layout?.w ?? 6,
|
||||
h: report.layout?.h ?? 4,
|
||||
minW: 3,
|
||||
minH: 3,
|
||||
}));
|
||||
|
||||
// Create responsive layouts for different breakpoints
|
||||
return {
|
||||
lg: baseLayout,
|
||||
md: baseLayout,
|
||||
sm: baseLayout.map((item) => ({ ...item, w: Math.min(item.w, 6) })),
|
||||
xs: baseLayout.map((item) => ({ ...item, w: 4, x: 0 })),
|
||||
xxs: baseLayout.map((item) => ({ ...item, w: 2, x: 0 })),
|
||||
};
|
||||
}, [reports]);
|
||||
const layouts = useReportLayouts(reports);
|
||||
|
||||
const handleLayoutChange = useCallback((newLayout: Layout[]) => {
|
||||
// This is called during dragging/resizing, we'll save on drag/resize stop
|
||||
@@ -463,7 +257,7 @@ function Component() {
|
||||
<PageHeader
|
||||
title={dashboard.name}
|
||||
description="View and manage your reports"
|
||||
className="mb-0"
|
||||
className="mb-4"
|
||||
actions={
|
||||
<>
|
||||
<OverviewRange />
|
||||
@@ -484,6 +278,14 @@ function Component() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
pushModal('ShareDashboardModal', { dashboardId })
|
||||
}
|
||||
>
|
||||
<ShareIcon className="mr-2 size-4" />
|
||||
Share dashboard
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
showConfirm({
|
||||
@@ -532,69 +334,43 @@ function Component() {
|
||||
</FullPageEmptyState>
|
||||
) : !isGridReady || reportsQuery.isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ReportSkeleton />
|
||||
<ReportSkeleton />
|
||||
<ReportSkeleton />
|
||||
<ReportSkeleton />
|
||||
<ReportSkeleton />
|
||||
<ReportSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full overflow-hidden -mx-4">
|
||||
<style>{`
|
||||
.react-grid-item {
|
||||
transition: ${enableTransitions ? 'transform 200ms ease, width 200ms ease, height 200ms ease' : 'none'} !important;
|
||||
}
|
||||
.react-grid-item.react-grid-placeholder {
|
||||
background: none !important;
|
||||
opacity: 0.5;
|
||||
transition-duration: 100ms;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px dashed var(--primary);
|
||||
}
|
||||
.react-grid-item.resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
`}</style>
|
||||
<ResponsiveGridLayout
|
||||
className="layout"
|
||||
layouts={layouts}
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
|
||||
rowHeight={100}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
onDragStop={handleDragStop}
|
||||
onResizeStop={handleResizeStop}
|
||||
draggableHandle=".drag-handle"
|
||||
compactType="vertical"
|
||||
preventCollision={false}
|
||||
isDraggable={true}
|
||||
isResizable={true}
|
||||
margin={[16, 16]}
|
||||
transformScale={1}
|
||||
useCSSTransforms={true}
|
||||
>
|
||||
{reports.map((report) => (
|
||||
<div key={report.id}>
|
||||
<ReportItem
|
||||
report={report}
|
||||
organizationId={organizationId}
|
||||
projectId={projectId}
|
||||
range={range}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
interval={interval}
|
||||
onDelete={(reportId) => {
|
||||
reportDeletion.mutate({ reportId });
|
||||
}}
|
||||
onDuplicate={(reportId) => {
|
||||
reportDuplicate.mutate({ reportId });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
</div>
|
||||
<GrafanaGrid
|
||||
transitions={enableTransitions}
|
||||
layouts={layouts}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
onDragStop={handleDragStop}
|
||||
onResizeStop={handleResizeStop}
|
||||
isDraggable={true}
|
||||
isResizable={true}
|
||||
>
|
||||
{reports.map((report) => (
|
||||
<div key={report.id}>
|
||||
<ReportItem
|
||||
report={report}
|
||||
organizationId={organizationId}
|
||||
projectId={projectId}
|
||||
range={range}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
interval={interval}
|
||||
onDelete={(reportId) => {
|
||||
reportDeletion.mutate({ reportId });
|
||||
}}
|
||||
onDuplicate={(reportId) => {
|
||||
reportDuplicate.mutate({ reportId });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</GrafanaGrid>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@@ -274,20 +274,16 @@ const PageCard = memo(
|
||||
</div>
|
||||
<ReportChart
|
||||
options={{
|
||||
hideID: true,
|
||||
hideXAxis: true,
|
||||
hideYAxis: true,
|
||||
aspectRatio: 0.15,
|
||||
}}
|
||||
report={{
|
||||
lineType: 'linear',
|
||||
breakdowns: [],
|
||||
name: 'screen_view',
|
||||
metric: 'sum',
|
||||
range,
|
||||
interval,
|
||||
previous: true,
|
||||
|
||||
chartType: 'linear',
|
||||
projectId,
|
||||
series: [
|
||||
|
||||
@@ -36,5 +36,6 @@ function Component() {
|
||||
const { reportId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const query = useSuspenseQuery(trpc.report.get.queryOptions({ reportId }));
|
||||
console.log(query.data);
|
||||
return <ReportEditor report={query.data} />;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ function ProjectDashboard() {
|
||||
{ id: 'details', label: 'Details' },
|
||||
{ id: 'events', label: 'Events' },
|
||||
{ id: 'clients', label: 'Clients' },
|
||||
{ id: 'widgets', label: 'Widgets' },
|
||||
{ id: 'imports', label: 'Imports' },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import CopyInput from '@/components/forms/copy-input';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import Syntax from '@/components/syntax';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import type {
|
||||
IRealtimeWidgetOptions,
|
||||
IWidgetType,
|
||||
} from '@openpanel/validation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { ExternalLinkIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/widgets',
|
||||
)({
|
||||
component: Component,
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const { projectId, organizationId } = useAppParams();
|
||||
const { dashboardUrl } = useAppContext();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch both widget types
|
||||
const realtimeWidgetQuery = useQuery(
|
||||
trpc.widget.get.queryOptions({ projectId, type: 'realtime' }),
|
||||
);
|
||||
const counterWidgetQuery = useQuery(
|
||||
trpc.widget.get.queryOptions({ projectId, type: 'counter' }),
|
||||
);
|
||||
|
||||
// Toggle mutation
|
||||
const toggleMutation = useMutation(
|
||||
trpc.widget.toggle.mutationOptions({
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries(
|
||||
trpc.widget.get.queryFilter({ projectId, type: variables.type }),
|
||||
);
|
||||
toast.success(variables.enabled ? 'Widget enabled' : 'Widget disabled');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to update widget');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Update options mutation
|
||||
const updateOptionsMutation = useMutation(
|
||||
trpc.widget.updateOptions.mutationOptions({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(
|
||||
trpc.widget.get.queryFilter({ projectId, type: 'realtime' }),
|
||||
);
|
||||
toast.success('Widget options updated');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to update options');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const handleToggle = (type: IWidgetType, enabled: boolean) => {
|
||||
toggleMutation.mutate({
|
||||
projectId,
|
||||
organizationId,
|
||||
type,
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
|
||||
if (realtimeWidgetQuery.isLoading || counterWidgetQuery.isLoading) {
|
||||
return <FullPageLoadingState />;
|
||||
}
|
||||
|
||||
const realtimeWidget = realtimeWidgetQuery.data;
|
||||
const counterWidget = counterWidgetQuery.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{realtimeWidget && (
|
||||
<RealtimeWidgetSection
|
||||
widget={realtimeWidget as any}
|
||||
dashboardUrl={dashboardUrl}
|
||||
isToggling={toggleMutation.isPending}
|
||||
isUpdatingOptions={updateOptionsMutation.isPending}
|
||||
onToggle={(enabled) => handleToggle('realtime', enabled)}
|
||||
onUpdateOptions={(options) =>
|
||||
updateOptionsMutation.mutate({
|
||||
projectId,
|
||||
organizationId,
|
||||
options,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{counterWidget && (
|
||||
<CounterWidgetSection
|
||||
widget={counterWidget}
|
||||
dashboardUrl={dashboardUrl}
|
||||
isToggling={toggleMutation.isPending}
|
||||
onToggle={(enabled) => handleToggle('counter', enabled)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RealtimeWidgetSectionProps {
|
||||
widget: {
|
||||
id: string;
|
||||
public: boolean;
|
||||
options: IRealtimeWidgetOptions;
|
||||
} | null;
|
||||
dashboardUrl: string;
|
||||
isToggling: boolean;
|
||||
isUpdatingOptions: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onUpdateOptions: (options: IRealtimeWidgetOptions) => void;
|
||||
}
|
||||
|
||||
function RealtimeWidgetSection({
|
||||
widget,
|
||||
dashboardUrl,
|
||||
isToggling,
|
||||
isUpdatingOptions,
|
||||
onToggle,
|
||||
onUpdateOptions,
|
||||
}: RealtimeWidgetSectionProps) {
|
||||
const isEnabled = widget?.public ?? false;
|
||||
const widgetUrl =
|
||||
isEnabled && widget?.id
|
||||
? `${dashboardUrl}/widget/realtime?shareId=${widget.id}`
|
||||
: null;
|
||||
const embedCode = widgetUrl
|
||||
? `<iframe src="${widgetUrl}" width="100%" height="400" frameborder="0" style="border-radius: 8px;"></iframe>`
|
||||
: null;
|
||||
|
||||
// Default options
|
||||
const defaultOptions: IRealtimeWidgetOptions = {
|
||||
type: 'realtime',
|
||||
referrers: true,
|
||||
countries: true,
|
||||
paths: false,
|
||||
};
|
||||
|
||||
const [options, setOptions] = useState<IRealtimeWidgetOptions>(
|
||||
(widget?.options as IRealtimeWidgetOptions) || defaultOptions,
|
||||
);
|
||||
|
||||
// Update local options when widget data changes
|
||||
useEffect(() => {
|
||||
if (widget?.options) {
|
||||
setOptions(widget.options as IRealtimeWidgetOptions);
|
||||
}
|
||||
}, [widget?.options]);
|
||||
|
||||
const handleUpdateOptions = (newOptions: IRealtimeWidgetOptions) => {
|
||||
setOptions(newOptions);
|
||||
onUpdateOptions(newOptions);
|
||||
};
|
||||
|
||||
return (
|
||||
<Widget className="max-w-screen-md w-full">
|
||||
<WidgetHead className="row items-center justify-between gap-6">
|
||||
<div className="space-y-2">
|
||||
<span className="title">Realtime Widget</span>
|
||||
<p className="text-muted-foreground">
|
||||
Embed a realtime visitor counter widget on your website. The widget
|
||||
shows live visitor count, activity histogram, top countries,
|
||||
referrers and paths.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={onToggle}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</WidgetHead>
|
||||
{isEnabled && (
|
||||
<WidgetBody className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Widget Options</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="referrers" className="text-sm">
|
||||
Show Referrers
|
||||
</Label>
|
||||
<Switch
|
||||
id="referrers"
|
||||
checked={options.referrers}
|
||||
onCheckedChange={(checked) =>
|
||||
handleUpdateOptions({ ...options, referrers: checked })
|
||||
}
|
||||
disabled={isUpdatingOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="countries" className="text-sm">
|
||||
Show Countries
|
||||
</Label>
|
||||
<Switch
|
||||
id="countries"
|
||||
checked={options.countries}
|
||||
onCheckedChange={(checked) =>
|
||||
handleUpdateOptions({ ...options, countries: checked })
|
||||
}
|
||||
disabled={isUpdatingOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="paths" className="text-sm">
|
||||
Show Paths
|
||||
</Label>
|
||||
<Switch
|
||||
id="paths"
|
||||
checked={options.paths}
|
||||
onCheckedChange={(checked) =>
|
||||
handleUpdateOptions({ ...options, paths: checked })
|
||||
}
|
||||
disabled={isUpdatingOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Widget URL</h3>
|
||||
<CopyInput label="" value={widgetUrl!} className="w-full" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Direct link to the widget. You can open this in a new tab or embed
|
||||
it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Embed Code</h3>
|
||||
<Syntax code={embedCode!} language="bash" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Copy this code and paste it into your website HTML where you want
|
||||
the widget to appear.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Preview</h3>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<iframe
|
||||
src={widgetUrl!}
|
||||
width="100%"
|
||||
height="600"
|
||||
className="border-0"
|
||||
title="Realtime Widget Preview"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={ExternalLinkIcon}
|
||||
onClick={() =>
|
||||
window.open(widgetUrl!, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
>
|
||||
Open in new tab
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
interface CounterWidgetSectionProps {
|
||||
widget: {
|
||||
id: string;
|
||||
public: boolean;
|
||||
} | null;
|
||||
dashboardUrl: string;
|
||||
isToggling: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
function CounterWidgetSection({
|
||||
widget,
|
||||
dashboardUrl,
|
||||
isToggling,
|
||||
onToggle,
|
||||
}: CounterWidgetSectionProps) {
|
||||
const isEnabled = widget?.public ?? false;
|
||||
const counterUrl =
|
||||
isEnabled && widget?.id
|
||||
? `${dashboardUrl}/widget/counter?shareId=${widget.id}`
|
||||
: null;
|
||||
const counterEmbedCode = counterUrl
|
||||
? `<iframe src="${counterUrl}" height="32" style="border: none; overflow: hidden;" title="Visitor Counter"></iframe>`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Widget className="max-w-screen-md w-full">
|
||||
<WidgetHead className="row items-center justify-between gap-6">
|
||||
<div className="space-y-2">
|
||||
<span className="title">Counter Widget</span>
|
||||
<p className="text-muted-foreground">
|
||||
A compact live visitor counter badge you can embed anywhere. Shows
|
||||
the current number of unique visitors with a live indicator.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={onToggle}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</WidgetHead>
|
||||
{isEnabled && counterUrl && (
|
||||
<WidgetBody className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Widget URL</h3>
|
||||
<CopyInput label="" value={counterUrl} className="w-full" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Direct link to the counter widget.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Embed Code</h3>
|
||||
<Syntax code={counterEmbedCode!} language="bash" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Copy this code and paste it into your website HTML where you want
|
||||
the counter to appear.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Preview</h3>
|
||||
<div className="border rounded-lg p-4 bg-muted/30">
|
||||
<iframe
|
||||
src={counterUrl}
|
||||
height="32"
|
||||
className="border-0"
|
||||
title="Counter Widget Preview"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={ExternalLinkIcon}
|
||||
onClick={() =>
|
||||
window.open(counterUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
>
|
||||
Open in new tab
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
158
apps/start/src/routes/share.dashboard.$shareId.tsx
Normal file
158
apps/start/src/routes/share.dashboard.$shareId.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { GrafanaGrid, useReportLayouts } from '@/components/grafana-grid';
|
||||
import { LoginNavbar } from '@/components/login-navbar';
|
||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||
import { OverviewRange } from '@/components/overview/overview-range';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import {
|
||||
ReportItem,
|
||||
ReportItemReadOnly,
|
||||
ReportItemSkeleton,
|
||||
} from '@/components/report/report-item';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { timeWindows } from '@openpanel/constants';
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const shareSearchSchema = z.object({
|
||||
header: z.optional(z.number().or(z.string().or(z.boolean()))),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/share/dashboard/$shareId')({
|
||||
component: RouteComponent,
|
||||
validateSearch: shareSearchSchema,
|
||||
loader: async ({ context, params }) => {
|
||||
const share = await context.queryClient.ensureQueryData(
|
||||
context.trpc.share.dashboard.queryOptions({
|
||||
shareId: params.shareId,
|
||||
}),
|
||||
);
|
||||
|
||||
return { share };
|
||||
},
|
||||
head: ({ loaderData }) => {
|
||||
if (!loaderData || !loaderData.share) {
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: 'Share not found - OpenPanel.dev',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: `${loaderData.share.dashboard?.name} - ${loaderData.share.organization?.name} - OpenPanel.dev`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
pendingComponent: FullPageLoadingState,
|
||||
errorComponent: () => (
|
||||
<FullPageEmptyState
|
||||
title="Share not found"
|
||||
description="The dashboard you are looking for does not exist."
|
||||
className="min-h-[calc(100vh-theme(spacing.16))]"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { shareId } = Route.useParams();
|
||||
const { header } = useSearch({ from: '/share/dashboard/$shareId' });
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const shareQuery = useSuspenseQuery(
|
||||
trpc.share.dashboard.queryOptions({
|
||||
shareId,
|
||||
}),
|
||||
);
|
||||
|
||||
const reportsQuery = useQuery(
|
||||
trpc.share.dashboardReports.queryOptions({
|
||||
shareId,
|
||||
}),
|
||||
);
|
||||
|
||||
const hasAccess = shareQuery.data?.hasAccess;
|
||||
|
||||
if (!shareQuery.data) {
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
if (!shareQuery.data.public) {
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
const share = shareQuery.data;
|
||||
|
||||
// Handle password protection
|
||||
if (share.password && !hasAccess) {
|
||||
return <ShareEnterPassword shareId={share.id} shareType="dashboard" />;
|
||||
}
|
||||
|
||||
const isHeaderVisible =
|
||||
header !== '0' && header !== 0 && header !== 'false' && header !== false;
|
||||
|
||||
const reports = reportsQuery.data ?? [];
|
||||
|
||||
// Convert reports to grid layout format for all breakpoints
|
||||
const layouts = useReportLayouts(reports);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isHeaderVisible && (
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<LoginNavbar className="relative p-4" />
|
||||
</div>
|
||||
)}
|
||||
<div className="sticky-header [animation-range:50px_100px]!">
|
||||
<div className="p-4 col gap-2 mx-auto max-w-7xl">
|
||||
<div className="row justify-between">
|
||||
<div className="flex gap-2">
|
||||
<OverviewRange />
|
||||
<OverviewInterval />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-7xl p-4">
|
||||
{reportsQuery.isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
</div>
|
||||
) : reports.length === 0 ? (
|
||||
<FullPageEmptyState title="No reports" />
|
||||
) : (
|
||||
<GrafanaGrid layouts={layouts}>
|
||||
{reports.map((report) => (
|
||||
<div key={report.id}>
|
||||
<ReportItemReadOnly
|
||||
report={report}
|
||||
shareId={shareId}
|
||||
range={range}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
interval={interval}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</GrafanaGrid>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,8 @@ import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
||||
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
|
||||
import { EyeClosedIcon, FrownIcon } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
|
||||
const shareSearchSchema = z.object({
|
||||
|
||||
123
apps/start/src/routes/share.report.$shareId.tsx
Normal file
123
apps/start/src/routes/share.report.$shareId.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { LoginNavbar } from '@/components/login-navbar';
|
||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||
import { OverviewRange } from '@/components/overview/overview-range';
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const shareSearchSchema = z.object({
|
||||
header: z.optional(z.number().or(z.string().or(z.boolean()))),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/share/report/$shareId')({
|
||||
component: RouteComponent,
|
||||
validateSearch: shareSearchSchema,
|
||||
loader: async ({ context, params }) => {
|
||||
const share = await context.queryClient.ensureQueryData(
|
||||
context.trpc.share.report.queryOptions({
|
||||
shareId: params.shareId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!share) {
|
||||
return { share: null };
|
||||
}
|
||||
|
||||
return { share };
|
||||
},
|
||||
head: ({ loaderData }) => {
|
||||
if (!loaderData || !loaderData.share) {
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: 'Share not found - OpenPanel.dev',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: `${loaderData.share.report.name || 'Report'} - ${loaderData.share.organization?.name} - OpenPanel.dev`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
pendingComponent: FullPageLoadingState,
|
||||
errorComponent: () => (
|
||||
<FullPageEmptyState
|
||||
title="Share not found"
|
||||
description="The report you are looking for does not exist."
|
||||
className="min-h-[calc(100vh-theme(spacing.16))]"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { shareId } = Route.useParams();
|
||||
const { header } = useSearch({ from: '/share/report/$shareId' });
|
||||
const trpc = useTRPC();
|
||||
const shareQuery = useSuspenseQuery(
|
||||
trpc.share.report.queryOptions({
|
||||
shareId,
|
||||
}),
|
||||
);
|
||||
|
||||
const hasAccess = shareQuery.data?.hasAccess;
|
||||
|
||||
if (!shareQuery.data) {
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
if (!shareQuery.data.public) {
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
const share = shareQuery.data;
|
||||
|
||||
console.log('share', share);
|
||||
|
||||
// Handle password protection
|
||||
if (share.password && !hasAccess) {
|
||||
return <ShareEnterPassword shareId={share.id} shareType="report" />;
|
||||
}
|
||||
|
||||
const isHeaderVisible =
|
||||
header !== '0' && header !== 0 && header !== 'false' && header !== false;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isHeaderVisible && (
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<LoginNavbar className="relative p-4" />
|
||||
</div>
|
||||
)}
|
||||
<div className="sticky-header [animation-range:50px_100px]!">
|
||||
<div className="p-4 col gap-2 mx-auto max-w-7xl">
|
||||
<div className="row justify-between">
|
||||
<div className="flex gap-2">
|
||||
<OverviewRange />
|
||||
<OverviewInterval />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-7xl p-4">
|
||||
<div className="card">
|
||||
<div className="p-4 border-b">
|
||||
<div className="font-medium text-xl">{share.report.name}</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<ReportChart report={share.report} shareId={shareId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
apps/start/src/routes/widget/counter.tsx
Normal file
86
apps/start/src/routes/widget/counter.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { AnimatedNumber } from '@/components/animated-number';
|
||||
import { Ping } from '@/components/ping';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import useWS from '@/hooks/use-ws';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const widgetSearchSchema = z.object({
|
||||
shareId: z.string(),
|
||||
limit: z.number().default(10),
|
||||
color: z.string().optional(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/widget/counter')({
|
||||
component: RouteComponent,
|
||||
validateSearch: widgetSearchSchema,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { shareId, limit, color } = Route.useSearch();
|
||||
const trpc = useTRPC();
|
||||
|
||||
// Fetch widget data
|
||||
const { data, isLoading } = useQuery(
|
||||
trpc.widget.counter.queryOptions({ shareId }),
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 h-8">
|
||||
<Ping />
|
||||
<AnimatedNumber value={0} suffix=" unique visitors" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 h-8">
|
||||
<Ping className="bg-orange-500" />
|
||||
<AnimatedNumber value={0} suffix=" unique visitors" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <CounterWidget shareId={shareId} data={data} />;
|
||||
}
|
||||
|
||||
interface RealtimeWidgetProps {
|
||||
shareId: string;
|
||||
data: RouterOutputs['widget']['counter'];
|
||||
}
|
||||
|
||||
function CounterWidget({ shareId, data }: RealtimeWidgetProps) {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const number = useNumber();
|
||||
|
||||
// WebSocket subscription for real-time updates
|
||||
useWS<number>(
|
||||
`/live/visitors/${data.projectId}`,
|
||||
(res) => {
|
||||
if (!document.hidden) {
|
||||
queryClient.refetchQueries(
|
||||
trpc.widget.counter.queryFilter({ shareId }),
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
debounce: {
|
||||
delay: 1000,
|
||||
maxWait: 60000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 h-8">
|
||||
<Ping />
|
||||
<AnimatedNumber value={data.counter} suffix=" unique visitors" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
528
apps/start/src/routes/widget/realtime.tsx
Normal file
528
apps/start/src/routes/widget/realtime.tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
import { AnimatedNumber } from '@/components/animated-number';
|
||||
import {
|
||||
ChartTooltipContainer,
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { LogoSquare } from '@/components/logo';
|
||||
import { Ping } from '@/components/ping';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import useWS from '@/hooks/use-ws';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { countries } from '@/translations/countries';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import type React from 'react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { z } from 'zod';
|
||||
|
||||
const widgetSearchSchema = z.object({
|
||||
shareId: z.string(),
|
||||
limit: z.number().default(10),
|
||||
color: z.string().optional(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/widget/realtime')({
|
||||
component: RouteComponent,
|
||||
validateSearch: widgetSearchSchema,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { shareId, limit, color } = Route.useSearch();
|
||||
const trpc = useTRPC();
|
||||
|
||||
// Fetch widget data
|
||||
const { data: widgetData, isLoading } = useQuery(
|
||||
trpc.widget.realtimeData.queryOptions({ shareId }),
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <RealtimeWidgetSkeleton limit={limit} />;
|
||||
}
|
||||
|
||||
if (!widgetData) {
|
||||
return (
|
||||
<div className="flex h-screen w-full center-center bg-background text-foreground col p-4">
|
||||
<LogoSquare className="size-10 mb-4" />
|
||||
<h1 className="text-xl font-semibold">Widget not found</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
This widget is not available or has been removed.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RealtimeWidget
|
||||
shareId={shareId}
|
||||
limit={limit}
|
||||
data={widgetData}
|
||||
color={color}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface RealtimeWidgetProps {
|
||||
shareId: string;
|
||||
limit: number;
|
||||
color: string | undefined;
|
||||
data: RouterOutputs['widget']['realtimeData'];
|
||||
}
|
||||
|
||||
function RealtimeWidget({ shareId, data, limit, color }: RealtimeWidgetProps) {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const number = useNumber();
|
||||
|
||||
// WebSocket subscription for real-time updates
|
||||
useWS<number>(
|
||||
`/live/visitors/${data.projectId}`,
|
||||
() => {
|
||||
if (!document.hidden) {
|
||||
queryClient.refetchQueries(
|
||||
trpc.widget.realtimeData.queryFilter({ shareId }),
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
debounce: {
|
||||
delay: 1000,
|
||||
maxWait: 60000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const maxDomain =
|
||||
Math.max(...data.histogram.map((item) => item.sessionCount), 1) * 1.2;
|
||||
|
||||
const grids = (() => {
|
||||
const countries = data.countries.length > 0 ? 1 : 0;
|
||||
const referrers = data.referrers.length > 0 ? 1 : 0;
|
||||
const paths = data.paths.length > 0 ? 1 : 0;
|
||||
const value = countries + referrers + paths;
|
||||
if (value === 3) return 'md:grid-cols-3';
|
||||
if (value === 2) return 'md:grid-cols-2';
|
||||
return 'md:grid-cols-1';
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col bg-background text-foreground">
|
||||
{/* Header with live counter */}
|
||||
<div className="border-b p-6 pb-3">
|
||||
<div className="flex items-center justify-between w-full h-4">
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<Ping />
|
||||
<div className="text-sm font-medium text-muted-foreground flex-1">
|
||||
USERS IN LAST 30 MINUTES
|
||||
</div>
|
||||
{data.project.domain && <SerieIcon name={data.project.domain} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="font-mono text-6xl font-bold h-18 text-foreground">
|
||||
<AnimatedNumber value={data.liveCount} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-20 w-full flex-col -mt-4">
|
||||
<div className="flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data.histogram}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
>
|
||||
<Tooltip
|
||||
content={CustomTooltip}
|
||||
cursor={{ fill: 'var(--def-100)', radius: 4 }}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
|
||||
ticks={[
|
||||
data.histogram[0].time,
|
||||
data.histogram[data.histogram.length - 1].time,
|
||||
]}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis hide domain={[0, maxDomain]} />
|
||||
<Bar
|
||||
dataKey="sessionCount"
|
||||
isAnimationActive={false}
|
||||
radius={[4, 4, 4, 4]}
|
||||
fill={color || 'var(--chart-0)'}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-6 overflow-auto p-6 hide-scrollbar">
|
||||
{/* Histogram */}
|
||||
{/* Countries and Referrers */}
|
||||
{(data.countries.length > 0 || data.referrers.length > 0) && (
|
||||
<div className={cn('grid grid-cols-1 gap-6', grids)}>
|
||||
{/* Countries */}
|
||||
{data.countries.length > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||
COUNTRY
|
||||
</div>
|
||||
<div className="col">
|
||||
{(() => {
|
||||
const { visible, rest, restCount } = getRestItems(
|
||||
data.countries,
|
||||
limit,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{visible.map((item) => (
|
||||
<RowItem key={item.country} count={item.count}>
|
||||
<div className="flex items-center gap-2">
|
||||
<SerieIcon name={item.country} />
|
||||
<span className="text-sm">
|
||||
{countries[
|
||||
item.country as keyof typeof countries
|
||||
] || item.country}
|
||||
</span>
|
||||
</div>
|
||||
</RowItem>
|
||||
))}
|
||||
{rest.length > 0 && (
|
||||
<RestRow
|
||||
firstName={
|
||||
countries[
|
||||
rest[0].country as keyof typeof countries
|
||||
] || rest[0].country
|
||||
}
|
||||
restCount={rest.length}
|
||||
totalCount={restCount}
|
||||
type="countries"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Referrers */}
|
||||
{data.referrers.length > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||
REFERRER
|
||||
</div>
|
||||
<div className="col">
|
||||
{(() => {
|
||||
const { visible, rest, restCount } = getRestItems(
|
||||
data.referrers,
|
||||
limit,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{visible.map((item) => (
|
||||
<RowItem key={item.referrer} count={item.count}>
|
||||
<div className="flex items-center gap-2">
|
||||
<SerieIcon name={item.referrer} />
|
||||
<span className="truncate text-sm">
|
||||
{item.referrer}
|
||||
</span>
|
||||
</div>
|
||||
</RowItem>
|
||||
))}
|
||||
{rest.length > 0 && (
|
||||
<RestRow
|
||||
firstName={rest[0].referrer}
|
||||
restCount={rest.length}
|
||||
totalCount={restCount}
|
||||
type="referrers"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paths */}
|
||||
{data.paths.length > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||
PATH
|
||||
</div>
|
||||
<div className="col">
|
||||
{(() => {
|
||||
const { visible, rest, restCount } = getRestItems(
|
||||
data.paths,
|
||||
limit,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{visible.map((item) => (
|
||||
<RowItem key={item.path} count={item.count}>
|
||||
<span className="truncate text-sm">
|
||||
{item.path}
|
||||
</span>
|
||||
</RowItem>
|
||||
))}
|
||||
{rest.length > 0 && (
|
||||
<RestRow
|
||||
firstName={rest[0].path}
|
||||
restCount={rest.length}
|
||||
totalCount={restCount}
|
||||
type="paths"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom tooltip component that uses portals to escape overflow hidden
|
||||
const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
const number = useNumber();
|
||||
|
||||
if (!active || !payload || !payload.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = payload[0].payload;
|
||||
|
||||
return (
|
||||
<ChartTooltipContainer className="max-w-[100px]">
|
||||
<ChartTooltipHeader>
|
||||
<div>{data.time}</div>
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color={getChartColor(0)} innerClassName="row gap-1">
|
||||
<div className="flex-1">Visitors</div>
|
||||
<div>{number.short(data.sessionCount)}</div>
|
||||
</ChartTooltipItem>
|
||||
</ChartTooltipContainer>
|
||||
);
|
||||
};
|
||||
|
||||
function RowItem({
|
||||
children,
|
||||
count,
|
||||
}: { children: React.ReactNode; count: number }) {
|
||||
const number = useNumber();
|
||||
return (
|
||||
<div className="h-10 text-sm flex items-center justify-between px-3 py-2 border-b hover:bg-foreground/5 -mx-3">
|
||||
{children}
|
||||
<span className="font-semibold">{number.short(count)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRestItems<T extends { count: number }>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
): { visible: T[]; rest: T[]; restCount: number } {
|
||||
const visible = items.slice(0, limit);
|
||||
const rest = items.slice(limit);
|
||||
const restCount = rest.reduce((sum, item) => sum + item.count, 0);
|
||||
return { visible, rest, restCount };
|
||||
}
|
||||
|
||||
function RestRow({
|
||||
firstName,
|
||||
restCount,
|
||||
totalCount,
|
||||
type,
|
||||
}: {
|
||||
firstName: string;
|
||||
restCount: number;
|
||||
totalCount: number;
|
||||
type: 'countries' | 'referrers' | 'paths';
|
||||
}) {
|
||||
const number = useNumber();
|
||||
const otherCount = restCount - 1;
|
||||
const typeLabel =
|
||||
type === 'countries'
|
||||
? otherCount === 1
|
||||
? 'country'
|
||||
: 'countries'
|
||||
: type === 'referrers'
|
||||
? otherCount === 1
|
||||
? 'referrer'
|
||||
: 'referrers'
|
||||
: otherCount === 1
|
||||
? 'path'
|
||||
: 'paths';
|
||||
|
||||
return (
|
||||
<div className="h-10 text-sm flex items-center justify-between px-3 py-2 border-b hover:bg-foreground/5 -mx-3">
|
||||
<span className="truncate">
|
||||
{firstName} and {otherCount} more {typeLabel}...
|
||||
</span>
|
||||
<span className="font-semibold">{number.short(totalCount)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-generated skeleton keys to avoid index-based keys in render
|
||||
const SKELETON_KEYS = {
|
||||
countries: [
|
||||
'country-0',
|
||||
'country-1',
|
||||
'country-2',
|
||||
'country-3',
|
||||
'country-4',
|
||||
'country-5',
|
||||
'country-6',
|
||||
'country-7',
|
||||
'country-8',
|
||||
'country-9',
|
||||
],
|
||||
referrers: [
|
||||
'referrer-0',
|
||||
'referrer-1',
|
||||
'referrer-2',
|
||||
'referrer-3',
|
||||
'referrer-4',
|
||||
'referrer-5',
|
||||
'referrer-6',
|
||||
'referrer-7',
|
||||
'referrer-8',
|
||||
'referrer-9',
|
||||
],
|
||||
paths: [
|
||||
'path-0',
|
||||
'path-1',
|
||||
'path-2',
|
||||
'path-3',
|
||||
'path-4',
|
||||
'path-5',
|
||||
'path-6',
|
||||
'path-7',
|
||||
'path-8',
|
||||
'path-9',
|
||||
],
|
||||
};
|
||||
|
||||
// Pre-generated skeleton histogram data
|
||||
const SKELETON_HISTOGRAM = [
|
||||
24, 48, 21, 32, 19, 16, 52, 14, 11, 7, 12, 18, 25, 65, 55, 62, 9, 68, 10, 31,
|
||||
58, 70, 10, 47, 43, 10, 38, 35, 41, 28,
|
||||
];
|
||||
|
||||
function RealtimeWidgetSkeleton({ limit }: { limit: number }) {
|
||||
const itemCount = Math.min(limit, 5);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col bg-background text-foreground animate-pulse">
|
||||
{/* Header with live counter */}
|
||||
<div className="border-b p-6 pb-3">
|
||||
<div className="flex items-center justify-between w-full h-4">
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<div className="size-2 rounded-full bg-muted" />
|
||||
<div className="text-sm font-medium text-muted-foreground flex-1">
|
||||
USERS IN LAST 30 MINUTES
|
||||
</div>
|
||||
</div>
|
||||
<div className="size-4 shrink-0 rounded bg-muted" />
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="font-mono text-6xl font-bold h-18 flex items-center py-4 gap-1 row">
|
||||
<div className="h-full w-6 bg-muted rounded" />
|
||||
<div className="h-full w-6 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-20 w-full flex-col -mt-4 pb-2.5">
|
||||
<div className="flex-1 row gap-1 h-full">
|
||||
{SKELETON_HISTOGRAM.map((item, index) => (
|
||||
<div
|
||||
key={index.toString()}
|
||||
style={{ height: `${item}%` }}
|
||||
className="h-full w-full bg-muted rounded mt-auto"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="row justify-between pt-2">
|
||||
<div className="h-3 w-8 bg-muted rounded" />
|
||||
<div className="h-3 w-8 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-6 overflow-auto p-6 hide-scrollbar">
|
||||
{/* Countries, Referrers, and Paths skeleton */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{/* Countries skeleton */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||
COUNTRY
|
||||
</div>
|
||||
<div className="col">
|
||||
{SKELETON_KEYS.countries.slice(0, itemCount).map((key) => (
|
||||
<RowItemSkeleton key={key} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referrers skeleton */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||
REFERRER
|
||||
</div>
|
||||
<div className="col">
|
||||
{SKELETON_KEYS.referrers.slice(0, itemCount).map((key) => (
|
||||
<RowItemSkeleton key={key} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Paths skeleton */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||
PATH
|
||||
</div>
|
||||
<div className="col">
|
||||
{SKELETON_KEYS.paths.slice(0, itemCount).map((key) => (
|
||||
<RowItemSkeleton key={key} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowItemSkeleton() {
|
||||
return (
|
||||
<div className="h-10 text-sm flex items-center justify-between px-3 py-2 border-b -mx-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-5 rounded bg-muted" />
|
||||
<div className="h-4 w-24 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="h-4 w-8 bg-muted rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
apps/start/src/routes/widget/test.tsx
Normal file
34
apps/start/src/routes/widget/test.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/widget/test')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div className="center-center h-screen w-screen gap-4">
|
||||
<iframe
|
||||
title="Realtime Widget"
|
||||
src="http://localhost:3000/widget/realtime?shareId=qkC561&limit=2"
|
||||
width="300"
|
||||
height="400"
|
||||
className="rounded-xl border"
|
||||
/>
|
||||
<iframe
|
||||
title="Realtime Widget"
|
||||
src="http://localhost:3000/widget/realtime?shareId=qkC562&limit=2"
|
||||
width="300"
|
||||
height="400"
|
||||
className="rounded-xl border"
|
||||
/>
|
||||
<iframe
|
||||
title="Counter Widget"
|
||||
src="http://localhost:3000/widget/counter?shareId=qkC561"
|
||||
height="32"
|
||||
width="auto"
|
||||
frameBorder="0"
|
||||
className="rounded-xl border"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user