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:
Carl-Gerhard Lindesvärd
2026-01-14 09:21:18 +01:00
committed by GitHub
parent 39251c8598
commit ed1c57dbb8
105 changed files with 6633 additions and 1273 deletions

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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;
};

View 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>
);
}

View File

@@ -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>
);
}
}

View 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>
);
}

View File

@@ -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;
};