feat: new billing and restrict access when trial has ended

* fix: simply billing

* fix usage graph

* imporve billing more + supporter prompt on self-hosting

* revert service change

* revert query builder

* fix: comments
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-11 11:09:11 +01:00
committed by GitHub
parent bbd30ca6e0
commit 9cafd61b25
57 changed files with 1642 additions and 1269 deletions

View File

@@ -0,0 +1,601 @@
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,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/utils/cn';
import { createProjectTitle } from '@/utils/title';
import {
CopyIcon,
LayoutPanelTopIcon,
MoreHorizontal,
PlusIcon,
RotateCcw,
Trash,
TrashIcon,
} from 'lucide-react';
import { toast } from 'sonner';
import { timeWindows } from '@openpanel/constants';
import FullPageLoadingState from '@/components/full-page-loading-state';
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 { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react';
import { 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;
};
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/dashboards_/$dashboardId',
)({
component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle('Dashboard'),
},
],
};
},
loader: async ({ context, params }) => {
await Promise.all([
context.queryClient.prefetchQuery(
context.trpc.dashboard.byId.queryOptions({
id: params.dashboardId,
projectId: params.projectId,
}),
),
context.queryClient.prefetchQuery(
context.trpc.report.list.queryOptions({
dashboardId: params.dashboardId,
projectId: params.projectId,
}),
),
context.queryClient.prefetchQuery(
context.trpc.project.getProjectWithClients.queryOptions({
projectId: params.projectId,
}),
),
context.queryClient.prefetchQuery(
context.trpc.organization.get.queryOptions({
organizationId: params.organizationId,
}),
),
]);
},
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();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const dashboardQuery = useQuery(
trpc.dashboard.byId.queryOptions({
id: dashboardId,
projectId,
}),
);
const reportsQuery = useQuery(
trpc.report.list.queryOptions({
dashboardId,
projectId,
}),
);
const dashboardDeletion = useMutation(
trpc.dashboard.delete.mutationOptions({
onError: handleErrorToastOptions({}),
onSuccess() {
toast('Dashboard deleted');
router.navigate({
to: '/$organizationId/$projectId/dashboards',
params: {
organizationId,
projectId,
},
});
},
}),
);
const reports = reportsQuery.data ?? [];
const dashboard = dashboardQuery.data;
const [isGridReady, setIsGridReady] = useState(false);
const [enableTransitions, setEnableTransitions] = useState(false);
// Wait for initial render to ensure grid has proper dimensions
useEffect(() => {
if (reports.length > 0 && !isGridReady) {
// Small delay to ensure container has rendered with proper width
const timer = setTimeout(() => {
setIsGridReady(true);
// Enable transitions after initial render
setTimeout(() => setEnableTransitions(true), 100);
}, 0);
return () => clearTimeout(timer);
}
}, [reports.length, isGridReady]);
const reportDeletion = useMutation(
trpc.report.delete.mutationOptions({
onError: handleErrorToastOptions({}),
onSuccess() {
reportsQuery.refetch();
toast('Report deleted');
},
}),
);
const reportDuplicate = useMutation(
trpc.report.duplicate.mutationOptions({
onError: handleErrorToastOptions({}),
onSuccess() {
reportsQuery.refetch();
toast('Report duplicated');
},
}),
);
const updateLayout = useMutation(
trpc.report.updateLayout.mutationOptions({
onError: handleErrorToastOptions({}),
onSuccess() {
// Silently refetch reports (which includes layouts)
reportsQuery.refetch();
},
}),
);
const resetLayout = useMutation(
trpc.report.resetLayout.mutationOptions({
onError: handleErrorToastOptions({}),
onSuccess() {
toast('Layout reset to default');
reportsQuery.refetch();
},
}),
);
// 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 handleLayoutChange = useCallback((newLayout: Layout[]) => {
// This is called during dragging/resizing, we'll save on drag/resize stop
}, []);
const handleDragStop = useCallback(
(newLayout: Layout[]) => {
// Save each changed layout after drag stops
newLayout.forEach((item) => {
const report = reports.find((r) => r.id === item.i);
if (report) {
const oldLayout = report.layout;
// Only update if layout actually changed
if (
!oldLayout ||
oldLayout.x !== item.x ||
oldLayout.y !== item.y ||
oldLayout.w !== item.w ||
oldLayout.h !== item.h
) {
updateLayout.mutate({
reportId: item.i,
layout: {
x: item.x,
y: item.y,
w: item.w,
h: item.h,
minW: item.minW ?? 3,
minH: item.minH ?? 3,
},
});
}
}
});
},
[reports, updateLayout],
);
const handleResizeStop = useCallback(
(newLayout: Layout[]) => {
// Save each changed layout after resize stops
newLayout.forEach((item) => {
const report = reports.find((r) => r.id === item.i);
if (report) {
const oldLayout = report.layout;
// Only update if layout actually changed
if (
!oldLayout ||
oldLayout.x !== item.x ||
oldLayout.y !== item.y ||
oldLayout.w !== item.w ||
oldLayout.h !== item.h
) {
updateLayout.mutate({
reportId: item.i,
layout: {
x: item.x,
y: item.y,
w: item.w,
h: item.h,
minW: item.minW ?? 3,
minH: item.minH ?? 3,
},
});
}
}
});
},
[reports, updateLayout],
);
if (!dashboard) {
return null; // Loading handled by suspense
}
return (
<PageContainer>
<PageHeader
title={dashboard.name}
description="View and manage your reports"
className="mb-0"
actions={
<>
<OverviewRange />
<OverviewInterval />
<LinkButton
from={Route.fullPath}
to={'/$organizationId/$projectId/reports'}
icon={PlusIcon}
>
<span className="max-sm:hidden">Create report</span>
<span className="sm:hidden">Report</span>
</LinkButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() =>
showConfirm({
title: 'Reset layout',
text: 'Are you sure you want to reset the layout to default? This will clear all custom positioning and sizing.',
onConfirm: () =>
resetLayout.mutate({ dashboardId, projectId }),
})
}
>
<RotateCcw className="mr-2 size-4" />
Reset layout
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() =>
showConfirm({
title: 'Delete dashboard',
text: 'Are you sure you want to delete this dashboard? All your reports will be deleted!',
onConfirm: () =>
dashboardDeletion.mutate({ id: dashboardId }),
})
}
>
<TrashIcon className="mr-2 size-4" />
Delete dashboard
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</>
}
/>
{reports.length === 0 ? (
<FullPageEmptyState title="No reports" icon={LayoutPanelTopIcon}>
<p>You can visualize your data with a report</p>
<LinkButton
from={Route.fullPath}
to={'/$organizationId/$projectId/reports'}
className="mt-14"
icon={PlusIcon}
>
Create report
</LinkButton>
</FullPageEmptyState>
) : !isGridReady || reportsQuery.isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ReportSkeleton />
<ReportSkeleton />
<ReportSkeleton />
<ReportSkeleton />
<ReportSkeleton />
<ReportSkeleton />
</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>
)}
</PageContainer>
);
}