fix: improve onboarding

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-27 22:45:21 +01:00
parent b0aa7f4196
commit 10da7d3a1d
25 changed files with 868 additions and 806 deletions

View File

@@ -1,18 +1,7 @@
import {
X_AXIS_STYLE_PROPS,
useXAxisProps,
useYAxisProps,
} from '@/components/report-chart/common/axis';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDate } from '@/utils/date';
import { getChartColor } from '@/utils/theme';
import { sum } from '@openpanel/common';
import type { IServiceOrganization } from '@openpanel/db';
import { useQuery } from '@tanstack/react-query';
import { Loader2Icon } from 'lucide-react';
import { pick } from 'ramda';
import {
Bar,
BarChart,
@@ -23,16 +12,24 @@ import {
YAxis,
} from 'recharts';
import { BarShapeBlue } from '../charts/common-bar';
import {
useXAxisProps,
useYAxisProps,
} from '@/components/report-chart/common/axis';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDate } from '@/utils/date';
type Props = {
interface Props {
organization: IServiceOrganization;
};
}
function Card({ title, value }: { title: string; value: string }) {
return (
<div className="col gap-2 p-4 flex-1 min-w-0" title={`${title}: ${value}`}>
<div className="text-muted-foreground truncate">{title}</div>
<div className="font-mono text-xl font-bold truncate">{value}</div>
<div className="col min-w-0 flex-1 gap-2 p-4" title={`${title}: ${value}`}>
<div className="truncate text-muted-foreground">{title}</div>
<div className="truncate font-bold font-mono text-xl">{value}</div>
</div>
);
}
@@ -43,18 +40,20 @@ export default function BillingUsage({ organization }: Props) {
const usageQuery = useQuery(
trpc.subscription.usage.queryOptions({
organizationId: organization.id,
}),
})
);
// Determine interval based on data range - use weekly if more than 30 days
const getDataInterval = () => {
if (!usageQuery.data || usageQuery.data.length === 0) return 'day';
if (!usageQuery.data || usageQuery.data.length === 0) {
return 'day';
}
const dates = usageQuery.data.map((item) => new Date(item.day));
const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
const daysDiff = Math.ceil(
(maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24),
(maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24)
);
return daysDiff > 30 ? 'week' : 'day';
@@ -78,7 +77,7 @@ export default function BillingUsage({ organization }: Props) {
return wrapper(
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>,
</div>
);
}
@@ -86,13 +85,16 @@ export default function BillingUsage({ organization }: Props) {
return wrapper(
<div className="center-center p-8 font-medium">
Issues loading usage data
</div>,
</div>
);
}
if (usageQuery.data?.length === 0) {
if (
usageQuery.data?.length === 0 ||
!usageQuery.data?.some((item) => item.count !== 0)
) {
return wrapper(
<div className="center-center p-8 font-medium">No usage data yet</div>,
<div className="center-center p-8 font-medium">No usage data yet</div>
);
}
@@ -105,7 +107,9 @@ export default function BillingUsage({ organization }: Props) {
// Group daily data into weekly intervals if data spans more than 30 days
const processChartData = () => {
if (!usageQuery.data) return [];
if (!usageQuery.data) {
return [];
}
if (useWeeklyIntervals) {
// Group daily data into weekly intervals
@@ -157,7 +161,7 @@ export default function BillingUsage({ organization }: Props) {
Math.max(
subscriptionPeriodEventsLimit,
subscriptionPeriodEventsCount,
...chartData.map((item) => item.count),
...chartData.map((item) => item.count)
),
] as [number, number];
@@ -165,7 +169,7 @@ export default function BillingUsage({ organization }: Props) {
return wrapper(
<>
<div className="-m-4 mb-4 grid grid-cols-2 [&>div]:shadow-[0_0_0_0.5px] [&_div]:shadow-border border-b">
<div className="-m-4 mb-4 grid grid-cols-2 border-b [&>div]:shadow-[0_0_0_0.5px] [&_div]:shadow-border">
{organization.hasSubscription ? (
<>
<Card
@@ -186,7 +190,7 @@ export default function BillingUsage({ organization }: Props) {
1 -
subscriptionPeriodEventsCount /
subscriptionPeriodEventsLimit,
'%',
'%'
)
}
/>
@@ -208,7 +212,7 @@ export default function BillingUsage({ organization }: Props) {
<Card
title="Events from last 30 days"
value={number.format(
sum(usageQuery.data?.map((item) => item.count) ?? []),
sum(usageQuery.data?.map((item) => item.count) ?? [])
)}
/>
</div>
@@ -217,12 +221,12 @@ export default function BillingUsage({ organization }: Props) {
</div>
{/* Events Chart */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">
<h3 className="font-medium text-muted-foreground text-sm">
{useWeeklyIntervals ? 'Weekly Events' : 'Daily Events'}
</h3>
<div className="max-h-[300px] h-[250px] w-full p-4">
<div className="h-[250px] max-h-[300px] w-full p-4">
<ResponsiveContainer>
<BarChart data={chartData} barSize={useWeeklyIntervals ? 20 : 8}>
<BarChart barSize={useWeeklyIntervals ? 20 : 8} data={chartData}>
<RechartTooltip
content={<EventsTooltip useWeekly={useWeeklyIntervals} />}
cursor={{
@@ -239,15 +243,15 @@ export default function BillingUsage({ organization }: Props) {
<YAxis {...yAxisProps} domain={[0, 'dataMax']} />
<CartesianGrid
horizontal={true}
vertical={false}
strokeDasharray="3 3"
strokeOpacity={0.5}
vertical={false}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</>,
</>
);
}
@@ -261,7 +265,7 @@ function EventsTooltip({ useWeekly, ...props }: { useWeekly: boolean } & any) {
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="text-sm text-muted-foreground">
<div className="text-muted-foreground text-sm">
{useWeekly && payload.weekRange
? payload.weekRange
: payload?.date
@@ -271,10 +275,10 @@ function EventsTooltip({ useWeekly, ...props }: { useWeekly: boolean } & any) {
<div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full bg-chart-0" />
<div className="col gap-1">
<div className="text-sm text-muted-foreground">
<div className="text-muted-foreground text-sm">
Events {useWeekly ? 'this week' : 'this day'}
</div>
<div className="text-lg font-semibold text-chart-0">
<div className="font-semibold text-chart-0 text-lg">
{number.format(payload.count)}
</div>
</div>
@@ -293,22 +297,22 @@ function TotalTooltip(props: any) {
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="text-sm text-muted-foreground">Total Events</div>
<div className="text-muted-foreground text-sm">Total Events</div>
<div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full bg-chart-2" />
<div className="col gap-1">
<div className="text-sm text-muted-foreground">Your events count</div>
<div className="text-lg font-semibold text-chart-2">
<div className="text-muted-foreground text-sm">Your events count</div>
<div className="font-semibold text-chart-2 text-lg">
{number.format(payload.count)}
</div>
</div>
</div>
{payload.limit > 0 && (
<div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full border-2 border-dashed border-chart-1" />
<div className="h-10 w-1 rounded-full border-2 border-chart-1 border-dashed" />
<div className="col gap-1">
<div className="text-sm text-muted-foreground">Your tier limit</div>
<div className="text-lg font-semibold text-chart-1">
<div className="text-muted-foreground text-sm">Your tier limit</div>
<div className="font-semibold text-chart-1 text-lg">
{number.format(payload.limit)}
</div>
</div>

View File

@@ -1,9 +1,3 @@
import { Button } from '@/components/ui/button';
import { useNumber } from '@/hooks/use-numer-formatter';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal, useOnPushModal } from '@/modals';
import { formatDate } from '@/utils/date';
import type { IServiceOrganization } from '@openpanel/db';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { differenceInDays } from 'date-fns';
@@ -14,6 +8,12 @@ import { Progress } from '../ui/progress';
import { Widget, WidgetBody, WidgetHead } from '../widget';
import { BillingFaq } from './billing-faq';
import BillingUsage from './billing-usage';
import { Button } from '@/components/ui/button';
import { useNumber } from '@/hooks/use-numer-formatter';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal, useOnPushModal } from '@/modals';
import { formatDate } from '@/utils/date';
type Props = {
organization: IServiceOrganization;
@@ -28,13 +28,13 @@ export default function Billing({ organization }: Props) {
const productsQuery = useQuery(
trpc.subscription.products.queryOptions({
organizationId: organization.id,
}),
})
);
const currentProductQuery = useQuery(
trpc.subscription.getCurrent.queryOptions({
organizationId: organization.id,
}),
})
);
const portalMutation = useMutation(
@@ -47,7 +47,7 @@ export default function Billing({ organization }: Props) {
onError(error) {
toast.error(error.message);
},
}),
})
);
useWS(`/live/organization/${organization.id}`, () => {
@@ -55,7 +55,7 @@ export default function Billing({ organization }: Props) {
});
const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>(
(organization.subscriptionInterval as 'year' | 'month') || 'month',
(organization.subscriptionInterval as 'year' | 'month') || 'month'
);
const products = useMemo(() => {
@@ -66,7 +66,7 @@ export default function Billing({ organization }: Props) {
const currentProduct = currentProductQuery.data ?? null;
const currentPrice = currentProduct?.prices.flatMap((p) =>
p.type === 'recurring' && p.amountType === 'fixed' ? [p] : [],
p.type === 'recurring' && p.amountType === 'fixed' ? [p] : []
)[0];
const renderStatus = () => {
@@ -138,12 +138,12 @@ export default function Billing({ organization }: Props) {
});
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="col gap-8">
{currentProduct && currentPrice ? (
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between gap-4">
<div className="flex-1 title truncate">{currentProduct.name}</div>
<div className="title flex-1 truncate">{currentProduct.name}</div>
<div className="text-lg">
<span className="font-bold">
{number.currency(currentPrice.priceAmount / 100)}
@@ -157,58 +157,58 @@ export default function Billing({ organization }: Props) {
<WidgetBody>
{renderStatus()}
<div className="col mt-4">
<div className="font-semibold mb-2">
<div className="mb-2 font-semibold">
{number.format(organization.subscriptionPeriodEventsCount)} /{' '}
{number.format(Number(currentProduct.metadata.eventsLimit))}
</div>
<Progress
size="sm"
value={
(organization.subscriptionPeriodEventsCount /
Number(currentProduct.metadata.eventsLimit)) *
100
}
size="sm"
/>
<div className="row justify-between mt-4">
<div className="row mt-4 justify-between">
<Button
variant="outline"
size="sm"
onClick={() =>
portalMutation.mutate({ organizationId: organization.id })
}
size="sm"
variant="outline"
>
<svg
className="size-4 mr-2"
width="300"
className="mr-2 size-4"
fill="none"
height="300"
viewBox="0 0 300 300"
fill="none"
width="300"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1_4)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M66.4284 274.26C134.876 320.593 227.925 302.666 274.258 234.219C320.593 165.771 302.666 72.7222 234.218 26.3885C165.77 -19.9451 72.721 -2.0181 26.3873 66.4297C-19.9465 134.877 -2.01938 227.927 66.4284 274.26ZM47.9555 116.67C30.8375 169.263 36.5445 221.893 59.2454 256.373C18.0412 217.361 7.27564 150.307 36.9437 92.318C55.9152 55.2362 87.5665 29.3937 122.5 18.3483C90.5911 36.7105 62.5549 71.8144 47.9555 116.67ZM175.347 283.137C211.377 272.606 244.211 246.385 263.685 208.322C293.101 150.825 282.768 84.4172 242.427 45.2673C264.22 79.7626 269.473 131.542 252.631 183.287C237.615 229.421 208.385 265.239 175.347 283.137ZM183.627 266.229C207.945 245.418 228.016 210.604 236.936 168.79C251.033 102.693 232.551 41.1978 195.112 20.6768C214.97 47.3945 225.022 99.2902 218.824 157.333C214.085 201.724 200.814 240.593 183.627 266.229ZM63.7178 131.844C49.5155 198.43 68.377 260.345 106.374 280.405C85.9962 254.009 75.5969 201.514 81.8758 142.711C86.5375 99.0536 99.4504 60.737 116.225 35.0969C92.2678 55.983 72.5384 90.4892 63.7178 131.844ZM199.834 149.561C200.908 217.473 179.59 272.878 152.222 273.309C124.853 273.742 101.797 219.039 100.724 151.127C99.6511 83.2138 120.968 27.8094 148.337 27.377C175.705 26.9446 198.762 81.648 199.834 149.561Z"
fill="currentColor"
fill-rule="evenodd"
/>
</g>
<defs>
<clipPath id="clip0_1_4">
<rect width="300" height="300" fill="white" />
<rect fill="white" height="300" width="300" />
</clipPath>
</defs>
</svg>
Customer portal
</Button>
<Button
size="sm"
onClick={() =>
pushModal('SelectBillingPlan', {
organization,
currentProduct,
})
}
size="sm"
>
{organization.isWillBeCanceled
? 'Reactivate subscription'
@@ -221,15 +221,13 @@ export default function Billing({ organization }: Props) {
) : (
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<div className="font-bold text-lg flex-1">
<div className="flex-1 font-bold text-lg">
{organization.isTrial
? 'Get started'
: 'No active subscription'}
</div>
<div className="text-lg">
<span className="">
{organization.isTrial ? '30 days free trial' : ''}
</span>
<div className="text-muted-foreground">
{organization.isTrial ? '30 days free trial' : ''}
</div>
</WidgetHead>
<WidgetBody>
@@ -239,7 +237,7 @@ export default function Billing({ organization }: Props) {
{formatDate(organization.subscriptionEndsAt)} (
{differenceInDays(
organization.subscriptionEndsAt,
new Date(),
new Date()
) + 1}{' '}
days left)
</p>
@@ -250,29 +248,29 @@ export default function Billing({ organization }: Props) {
</p>
)}
<div className="col mt-4">
<div className="font-semibold mb-2">
<div className="mb-2 font-semibold">
{number.format(organization.subscriptionPeriodEventsCount)} /{' '}
{number.format(
Number(organization.subscriptionPeriodEventsLimit),
Number(organization.subscriptionPeriodEventsLimit)
)}
</div>
<Progress
size="sm"
value={
(organization.subscriptionPeriodEventsCount /
Number(organization.subscriptionPeriodEventsLimit)) *
100
}
size="sm"
/>
<div className="row justify-end mt-4">
<div className="row mt-4 justify-end">
<Button
size="sm"
onClick={() =>
pushModal('SelectBillingPlan', {
organization,
currentProduct,
})
}
size="sm"
>
Upgrade
</Button>