feat: dashboard v2, esm, upgrades (#211)

* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-16 12:27:44 +02:00
committed by GitHub
parent 436e81ecc9
commit 81a7e5d62e
741 changed files with 32695 additions and 16996 deletions

View File

@@ -0,0 +1,70 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Widget, WidgetHead } from '@/components/widget';
const questions = [
{
question: 'Does OpenPanel have a free tier?',
answer: [
'For our Cloud plan we offer a 14 days free trial, this is mostly for you to be able to try out OpenPanel before committing to a paid plan.',
'OpenPanel is also open-source and you can self-host it for free!',
'',
'Why does OpenPanel not have a free tier?',
'We want to make sure that OpenPanel is used by people who are serious about using it. We also need to invest time and resources to maintain the platform and provide support to our users.',
],
},
{
question: 'What happens if my site exceeds the limit?',
answer: [
"You will not see any new events in OpenPanel until your next billing period. If this happens 2 months in a row, we'll advice you to upgrade your plan.",
],
},
{
question: 'What happens if I cancel my subscription?',
answer: [
'If you cancel your subscription, you will still have access to OpenPanel until the end of your current billing period. You can reactivate your subscription at any time.',
'After your current billing period ends, you will not get access to new data.',
"NOTE: If your account has been inactive for 3 months, we'll delete your events.",
],
},
{
question: 'How do I change my billing information?',
answer: [
'You can change your billing information by clicking the "Manage your subscription" button in the billing section.',
],
},
];
export function BillingFaq() {
return (
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Usage</span>
</WidgetHead>
<Accordion
type="single"
collapsible
className="w-full max-w-screen-md self-center"
>
{questions.map((q) => (
<AccordionItem value={q.question} key={q.question}>
<AccordionTrigger className="text-left px-4">
{q.question}
</AccordionTrigger>
<AccordionContent>
<div className="col gap-2 p-4 pt-2">
{q.answer.map((a) => (
<p key={a}>{a}</p>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</Widget>
);
}

View File

@@ -0,0 +1,432 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from '@/components/ui/dialog';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Tooltiper } from '@/components/ui/tooltip';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useAppParams } from '@/hooks/use-app-params';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { showConfirm } from '@/modals';
import { op } from '@/utils/op';
import type { IServiceOrganization } from '@openpanel/db';
import type { IPolarPrice } from '@openpanel/payments';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2Icon } from 'lucide-react';
import { useQueryState } from 'nuqs';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
type Props = {
organization: IServiceOrganization;
};
export default function Billing({ organization }: Props) {
const { projectId } = useAppParams();
const queryClient = useQueryClient();
const trpc = useTRPC();
const [customerSessionToken, setCustomerSessionToken] = useQueryState(
'customer_session_token',
);
const productsQuery = useQuery(
trpc.subscription.products.queryOptions({
organizationId: organization.id,
}),
);
useWS(`/live/organization/${organization.id}`, () => {
queryClient.invalidateQueries(trpc.organization.pathFilter());
});
const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>(
(organization.subscriptionInterval as 'year' | 'month') || 'month',
);
const products = useMemo(() => {
return (productsQuery.data || [])
.filter((product) => product.recurringInterval === recurringInterval)
.filter((product) => product.prices.some((p) => p.amountType !== 'free'));
}, [productsQuery.data, recurringInterval]);
useEffect(() => {
if (organization.subscriptionInterval) {
setRecurringInterval(
organization.subscriptionInterval as 'year' | 'month',
);
}
}, [organization.subscriptionInterval]);
useEffect(() => {
if (customerSessionToken) {
op.track('subscription_created');
}
}, [customerSessionToken]);
const [selectedProductIndex, setSelectedProductIndex] = useState<number>(0);
// Check if organization has a custom product
const hasCustomProduct = useMemo(() => {
return products.some((product) => product.metadata?.custom === true);
}, [products]);
// Find current subscription index
const currentSubscriptionIndex = useMemo(() => {
if (!organization.subscriptionProductId) {
// Default to 100K events plan if no subscription
const defaultIndex = products.findIndex(
(product) => product.metadata?.eventsLimit === 100_000,
);
return defaultIndex >= 0 ? defaultIndex : 0;
}
return products.findIndex(
(product) => product.id === organization.subscriptionProductId,
);
}, [products, organization.subscriptionProductId]);
// Check if selected index is the "custom" option (beyond available products)
const isCustomOption = selectedProductIndex >= products.length;
// Find the highest event limit to make the custom option dynamic
const highestEventLimit = useMemo(() => {
const limits = products
.map((product) => product.metadata?.eventsLimit)
.filter((limit): limit is number => typeof limit === 'number');
return Math.max(...limits, 0);
}, [products]);
// Format the custom option label dynamically
const customOptionLabel = useMemo(() => {
if (highestEventLimit >= 1_000_000) {
return `+${(highestEventLimit / 1_000_000).toFixed(0)}M`;
}
if (highestEventLimit >= 1_000) {
return `+${(highestEventLimit / 1_000).toFixed(0)}K`;
}
return `+${highestEventLimit}`;
}, [highestEventLimit]);
// Set initial slider position to current subscription
useEffect(() => {
if (currentSubscriptionIndex >= 0) {
setSelectedProductIndex(currentSubscriptionIndex);
}
}, [currentSubscriptionIndex]);
const selectedProduct = products[selectedProductIndex];
const isUpgrade = selectedProductIndex > currentSubscriptionIndex;
const isDowngrade = selectedProductIndex < currentSubscriptionIndex;
const isCurrentPlan = selectedProductIndex === currentSubscriptionIndex;
function renderBillingSlider() {
if (productsQuery.isLoading) {
return (
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>
);
}
if (productsQuery.isError) {
return (
<div className="center-center p-8 font-medium">
Issues loading all tiers
</div>
);
}
if (hasCustomProduct) {
return (
<div className="p-8 text-center">
<div className="text-muted-foreground">
Not applicable since custom product
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Select your plan</span>
<span className="text-sm text-muted-foreground">
{selectedProduct?.name || 'No plan selected'}
</span>
</div>
<Slider
value={[selectedProductIndex]}
onValueChange={([value]) => setSelectedProductIndex(value)}
min={0}
max={products.length} // +1 for the custom option
step={1}
className="w-full"
disabled={hasCustomProduct}
/>
<div className="flex justify-between text-xs text-muted-foreground">
{products.map((product, index) => {
const eventsLimit = product.metadata?.eventsLimit;
return (
<div key={product.id} className="text-center">
<div className="font-medium">
{eventsLimit && typeof eventsLimit === 'number'
? `${(eventsLimit / 1000).toFixed(0)}K`
: 'Free'}
</div>
<div className="text-xs">events</div>
</div>
);
})}
{/* Add the custom option label */}
<div className="text-center">
<div className="font-medium">{customOptionLabel}</div>
<div className="text-xs">events</div>
</div>
</div>
</div>
{(selectedProduct || isCustomOption) && (
<div className="border rounded-lg p-4 space-y-4">
{isCustomOption ? (
// Custom option content
<>
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold">Custom Plan</h3>
<p className="text-sm text-muted-foreground">
{customOptionLabel} events per {recurringInterval}
</p>
</div>
<div className="text-right">
<span className="text-lg font-semibold">
Custom Pricing
</span>
</div>
</div>
<div className="bg-muted/50 rounded-lg p-4 text-center">
<p className="text-sm text-muted-foreground mb-2">
Need higher limits?
</p>
<p className="text-sm">
Reach out to{' '}
<a
className="underline font-medium"
href="mailto:hello@openpanel.dev"
>
hello@openpanel.dev
</a>{' '}
and we'll help you with a custom quota.
</p>
</div>
</>
) : (
// Regular product content
<>
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold">{selectedProduct.name}</h3>
<p className="text-sm text-muted-foreground">
{selectedProduct.metadata?.eventsLimit
? `${selectedProduct.metadata.eventsLimit.toLocaleString()} events per ${recurringInterval}`
: 'Free tier'}
</p>
</div>
<div className="text-right">
{selectedProduct.prices[0]?.amountType === 'free' ? (
<span className="text-lg font-semibold">Free</span>
) : (
<span className="text-lg font-semibold">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency:
selectedProduct.prices[0]?.priceCurrency || 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(
(selectedProduct.prices[0] &&
'priceAmount' in selectedProduct.prices[0]
? selectedProduct.prices[0].priceAmount
: 0) / 100,
)}
<span className="text-sm text-muted-foreground">
{' / '}
{recurringInterval === 'year' ? 'year' : 'month'}
</span>
</span>
)}
</div>
</div>
{!isCurrentPlan && selectedProduct.prices[0] && (
<div className="flex justify-end">
<CheckoutButton
disabled={selectedProduct.disabled}
key={selectedProduct.prices[0].id}
price={selectedProduct.prices[0]}
organization={organization}
projectId={projectId}
buttonText={
isUpgrade
? 'Upgrade'
: isDowngrade
? 'Downgrade'
: 'Activate'
}
/>
</div>
)}
{isCurrentPlan && (
<div className="flex justify-end">
<Button variant="outline" disabled>
Current Plan
</Button>
</div>
)}
</>
)}
</div>
)}
</div>
);
}
return (
<>
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Billing</span>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{recurringInterval === 'year'
? 'Yearly (2 months free)'
: 'Monthly'}
</span>
<Switch
checked={recurringInterval === 'year'}
onCheckedChange={(checked) =>
setRecurringInterval(checked ? 'year' : 'month')
}
/>
</div>
</WidgetHead>
<WidgetBody>
<div className="-m-4">{renderBillingSlider()}</div>
</WidgetBody>
</Widget>
<Dialog
open={!!customerSessionToken}
onOpenChange={(open) => {
setCustomerSessionToken(null);
if (!open) {
queryClient.invalidateQueries(trpc.organization.pathFilter());
}
}}
>
<DialogContent>
<DialogTitle>Subscription created</DialogTitle>
<DialogDescription>
We have registered your subscription. It'll be activated within a
couple of seconds.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button>OK</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function CheckoutButton({
price,
organization,
projectId,
disabled,
buttonText,
}: {
price: IPolarPrice;
organization: IServiceOrganization;
projectId: string;
disabled?: string | null;
buttonText?: string;
}) {
const trpc = useTRPC();
const isCurrentPrice = organization.subscriptionPriceId === price.id;
const checkout = useMutation(
trpc.subscription.checkout.mutationOptions({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
} else {
toast.success('Subscription updated', {
description: 'It might take a few seconds to update',
});
}
},
}),
);
const isCanceled =
organization.subscriptionStatus === 'active' &&
isCurrentPrice &&
organization.subscriptionCanceledAt;
const isActive =
organization.subscriptionStatus === 'active' && isCurrentPrice;
return (
<Tooltiper
content={disabled}
tooltipClassName="max-w-xs"
side="left"
disabled={!disabled}
>
<Button
disabled={disabled !== null || (isActive && !isCanceled)}
key={price.id}
onClick={() => {
const createCheckout = () =>
checkout.mutate({
projectId,
organizationId: organization.id,
productPriceId: price!.id,
productId: price.productId,
});
if (organization.subscriptionStatus === 'active') {
showConfirm({
title: 'Are you sure?',
text: `You're about the change your subscription.`,
onConfirm: () => {
op.track('subscription_change');
createCheckout();
},
});
} else {
op.track('subscription_checkout', {
product: price.productId,
});
createCheckout();
}
}}
loading={checkout.isPending}
className="w-28"
variant={isActive ? 'outline' : 'default'}
>
{buttonText ||
(isCanceled ? 'Reactivate' : isActive ? 'Active' : 'Activate')}
</Button>
</Tooltiper>
);
}

View File

@@ -0,0 +1,285 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useAppParams } from '@/hooks/use-app-params';
import { useNumber } from '@/hooks/use-numer-formatter';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { showConfirm } from '@/modals';
import { cn } from '@/utils/cn';
import type { IServiceOrganization } from '@openpanel/db';
import { FREE_PRODUCT_IDS } from '@openpanel/payments';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Loader2Icon } from 'lucide-react';
import { toast } from 'sonner';
type Props = {
organization: IServiceOrganization;
};
export default function CurrentSubscription({ organization }: Props) {
const { projectId } = useAppParams();
const queryClient = useQueryClient();
const number = useNumber();
const trpc = useTRPC();
const productQuery = useQuery(
trpc.subscription.getCurrent.queryOptions({
organizationId: organization.id,
}),
);
const cancelSubscription = useMutation(
trpc.subscription.cancelSubscription.mutationOptions({
onSuccess() {
toast.success('Subscription cancelled', {
description: 'It might take a few seconds to update',
});
},
onError(error) {
toast.error(error.message);
},
}),
);
const portalMutation = useMutation(
trpc.subscription.portal.mutationOptions({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
}
},
}),
);
const checkout = useMutation(
trpc.subscription.checkout.mutationOptions({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
} else {
toast.success('Subscription updated', {
description: 'It might take a few seconds to update',
});
}
},
}),
);
useWS(`/live/organization/${organization.id}`, () => {
queryClient.invalidateQueries(
trpc.subscription.getCurrent.queryOptions({
organizationId: organization.id,
}),
);
});
function render() {
if (productQuery.isLoading) {
return (
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>
);
}
if (productQuery.isError) {
return (
<div className="center-center p-8 font-medium">
Issues loading all tiers
</div>
);
}
if (!productQuery.data) {
return (
<div className="center-center p-8 font-medium">
No subscription found
</div>
);
}
const product = productQuery.data;
const price = product.prices[0]!;
return (
<>
<div className="gap-4 col">
{price.amountType === 'free' && (
<Alert variant="warning">
<AlertTitle>Free plan is removed</AlertTitle>
<AlertDescription>
We've removed the free plan. You can upgrade to a paid plan to
continue using OpenPanel.
</AlertDescription>
</Alert>
)}
<div className="row justify-between">
<div>Name</div>
<div className="text-right font-medium">{product.name}</div>
</div>
{price.amountType === 'fixed' ? (
<>
<div className="row justify-between">
<div>Price</div>
<div className="text-right font-medium font-mono">
{number.currency(price.priceAmount / 100)}
</div>
</div>
</>
) : (
<>
<div className="row justify-between">
<div>Price</div>
<div className="text-right font-medium font-mono">FREE</div>
</div>
</>
)}
<div className="row justify-between">
<div>Billing Cycle</div>
<div className="text-right font-medium">
{price.recurringInterval === 'month' ? 'Monthly' : 'Yearly'}
</div>
</div>
{typeof product.metadata.eventsLimit === 'number' && (
<div className="row justify-between">
<div>Events per mount</div>
<div className="text-right font-medium font-mono">
{number.format(product.metadata.eventsLimit)}
</div>
</div>
)}
</div>
{organization.subscriptionProductId &&
!FREE_PRODUCT_IDS.includes(organization.subscriptionProductId) && (
<div className="col gap-2">
{organization.isWillBeCanceled || organization.isCanceled ? (
<Button
loading={checkout.isPending}
onClick={() => {
checkout.mutate({
projectId,
organizationId: organization.id,
productPriceId: price!.id,
productId: price.productId,
});
}}
>
Reactivate subscription
</Button>
) : (
<Button
variant="destructive"
loading={cancelSubscription.isPending}
onClick={() => {
showConfirm({
title: 'Cancel subscription',
text: 'Are you sure you want to cancel your subscription?',
onConfirm() {
cancelSubscription.mutate({
organizationId: organization.id,
});
},
});
}}
>
Cancel subscription
</Button>
)}
</div>
)}
</>
);
}
return (
<div className="col gap-2 md:w-72 shrink-0">
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Current Subscription</span>
<div className="flex items-center gap-2">
<div className="relative">
<div
className={cn(
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all',
organization.isExceeded ||
organization.isExpired ||
(organization.subscriptionStatus !== 'active' &&
'bg-destructive'),
organization.isWillBeCanceled && 'bg-orange-400',
)}
/>
<div
className={cn(
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all',
organization.isExceeded ||
organization.isExpired ||
(organization.subscriptionStatus !== 'active' &&
'bg-destructive'),
organization.isWillBeCanceled && 'bg-orange-400',
)}
/>
</div>
</div>
</WidgetHead>
<WidgetBody className="col gap-8">
{organization.isTrial && organization.subscriptionEndsAt && (
<Alert variant="warning">
<AlertTitle>Free trial</AlertTitle>
<AlertDescription>
Your organization is on a free trial. It ends on{' '}
{format(organization.subscriptionEndsAt, 'PPP')}
</AlertDescription>
</Alert>
)}
{organization.isExpired && organization.subscriptionEndsAt && (
<Alert variant="destructive">
<AlertTitle>Subscription expired</AlertTitle>
<AlertDescription>
Your subscription has expired. You can reactivate it by choosing
a new plan below.
</AlertDescription>
<AlertDescription>
It expired on {format(organization.subscriptionEndsAt, 'PPP')}
</AlertDescription>
</Alert>
)}
{organization.isWillBeCanceled && (
<Alert variant="warning">
<AlertTitle>Subscription canceled</AlertTitle>
<AlertDescription>
You have canceled your subscription. You can reactivate it by
choosing a new plan below.
</AlertDescription>
<AlertDescription className="font-medium">
It'll expire on{' '}
{format(organization.subscriptionEndsAt!, 'PPP')}
</AlertDescription>
</Alert>
)}
{organization.isCanceled && (
<Alert variant="warning">
<AlertTitle>Subscription canceled</AlertTitle>
<AlertDescription>
Your subscription was canceled on{' '}
{format(organization.subscriptionCanceledAt!, 'PPP')}
</AlertDescription>
</Alert>
)}
{render()}
</WidgetBody>
</Widget>
{organization.hasSubscription && (
<button
className="text-center mt-2 w-2/3 hover:underline self-center"
type="button"
onClick={() =>
portalMutation.mutate({
organizationId: organization.id,
})
}
>
Manage your subscription with
<span className="font-medium ml-1">Polar Customer Portal</span>
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useTRPC } from '@/integrations/trpc/react';
import { handleError } from '@/trpc/client';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from '@tanstack/react-router';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { Combobox } from '@/components/ui/combobox';
import type { IServiceOrganization } from '@openpanel/db';
import { zEditOrganization } from '@openpanel/validation';
const validator = zEditOrganization;
type IForm = z.infer<typeof validator>;
interface EditOrganizationProps {
organization: IServiceOrganization;
}
export default function EditOrganization({
organization,
}: EditOrganizationProps) {
const router = useRouter();
const { register, handleSubmit, formState, reset, control } = useForm<IForm>({
defaultValues: {
id: organization.id,
name: organization.name,
timezone: organization.timezone ?? undefined,
},
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.organization.update.mutationOptions({
onSuccess(res: any) {
toast('Organization updated', {
description: 'Your organization has been updated.',
});
reset({
...res,
timezone: res.timezone!,
});
router.invalidate();
},
onError: handleError,
}),
);
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Details</span>
</WidgetHead>
<WidgetBody className="gap-4 col">
<InputWithLabel
className="flex-1"
label="Name"
{...register('name')}
defaultValue={organization?.name}
/>
<Controller
name="timezone"
control={control}
render={({ field }) => (
<WithLabel label="Timezone">
<Combobox
placeholder="Select timezone"
items={Intl.supportedValuesOf('timeZone').map((item) => ({
value: item,
label: item,
}))}
value={field.value}
onChange={field.onChange}
className="w-full"
/>
</WithLabel>
)}
/>
<Button
size="sm"
type="submit"
disabled={!formState.isDirty}
className="self-end"
>
Save
</Button>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -0,0 +1,15 @@
'use client';
import type { IServiceOrganization } from '@openpanel/db';
import EditOrganization from './edit-organization';
interface OrganizationProps {
organization: IServiceOrganization;
}
export default function Organization({ organization }: OrganizationProps) {
return (
<section className="max-w-screen-sm col gap-8">
<EditOrganization organization={organization} />
</section>
);
}

View File

@@ -0,0 +1,374 @@
'use client';
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,
CartesianGrid,
Tooltip as RechartTooltip,
ReferenceLine,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import { BarShapeBlue } from '../charts/common-bar';
type 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>
);
}
export default function Usage({ organization }: Props) {
const number = useNumber();
const trpc = useTRPC();
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';
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),
);
return daysDiff > 30 ? 'week' : 'day';
};
const interval = getDataInterval();
const useWeeklyIntervals = interval === 'week';
const xAxisProps = useXAxisProps({ interval });
const yAxisProps = useYAxisProps({});
const wrapper = (node: React.ReactNode) => (
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Usage</span>
</WidgetHead>
<WidgetBody>{node}</WidgetBody>
</Widget>
);
if (usageQuery.isLoading) {
return wrapper(
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>,
);
}
if (usageQuery.isError) {
return wrapper(
<div className="center-center p-8 font-medium">
Issues loading usage data
</div>,
);
}
const subscriptionPeriodEventsLimit = organization.hasSubscription
? organization.subscriptionPeriodEventsLimit
: 0;
const subscriptionPeriodEventsCount = organization.hasSubscription
? organization.subscriptionPeriodEventsCount
: 0;
// Group daily data into weekly intervals if data spans more than 30 days
const processChartData = () => {
if (!usageQuery.data) return [];
if (useWeeklyIntervals) {
// Group daily data into weekly intervals
const weeklyData: {
[key: string]: { count: number; startDate: Date; endDate: Date };
} = {};
usageQuery.data.forEach((item) => {
const date = new Date(item.day);
// Get the start of the week (Monday)
const startOfWeek = new Date(date);
const dayOfWeek = date.getDay();
const diff = date.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
startOfWeek.setDate(diff);
startOfWeek.setHours(0, 0, 0, 0);
const weekKey = startOfWeek.toISOString().split('T')[0];
if (!weeklyData[weekKey]) {
weeklyData[weekKey] = {
count: 0,
startDate: new Date(startOfWeek),
endDate: new Date(startOfWeek),
};
}
weeklyData[weekKey].count += item.count;
weeklyData[weekKey].endDate = new Date(date);
});
return Object.values(weeklyData).map((week) => ({
date: week.startDate.getTime(),
count: week.count,
weekRange: `${formatDate(week.startDate)} - ${formatDate(week.endDate)}`,
}));
}
// Use daily data for monthly subscriptions
return usageQuery.data.map((item) => ({
date: new Date(item.day).getTime(),
count: item.count,
}));
};
const chartData = processChartData();
const domain = [
0,
Math.max(
subscriptionPeriodEventsLimit,
subscriptionPeriodEventsCount,
...chartData.map((item) => item.count),
),
] as [number, number];
domain[1] += domain[1] * 0.05;
return wrapper(
<>
<div className="border-b divide-x divide-border -m-4 mb-4 grid grid-cols-2 md:grid-cols-4">
{organization.hasSubscription ? (
<>
<Card
title="Period"
value={
organization.subscriptionCurrentPeriodStart &&
organization.subscriptionCurrentPeriodEnd
? `${formatDate(organization.subscriptionCurrentPeriodStart)}-${formatDate(organization.subscriptionCurrentPeriodEnd)}`
: '🤷‍♂️'
}
/>
<Card
title="Limit"
value={number.format(subscriptionPeriodEventsLimit)}
/>
<Card
title="Events count"
value={number.format(subscriptionPeriodEventsCount)}
/>
<Card
title="Left to use"
value={
subscriptionPeriodEventsLimit === 0
? '👀'
: number.formatWithUnit(
1 -
subscriptionPeriodEventsCount /
subscriptionPeriodEventsLimit,
'%',
)
}
/>
</>
) : (
<>
<div className="col-span-2">
<Card title="Subscription" value={'No active subscription'} />
</div>
<div className="col-span-2">
<Card
title="Events from last 30 days"
value={number.format(
sum(usageQuery.data?.map((item) => item.count) ?? []),
)}
/>
</div>
</>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Events Chart */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">
{useWeeklyIntervals ? 'Weekly Events' : 'Daily Events'}
</h3>
<div className="max-h-[300px] h-[250px] w-full p-4">
<ResponsiveContainer>
<BarChart data={chartData} barSize={useWeeklyIntervals ? 20 : 8}>
<RechartTooltip
content={<EventsTooltip useWeekly={useWeeklyIntervals} />}
/>
<Bar
dataKey="count"
isAnimationActive={false}
shape={BarShapeBlue}
/>
<XAxis {...xAxisProps} dataKey="date" />
<YAxis {...yAxisProps} domain={[0, 'dataMax']} />
<CartesianGrid
horizontal={true}
vertical={false}
strokeDasharray="3 3"
strokeOpacity={0.5}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Total Events vs Limit Chart */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">
Total Events vs Limit
</h3>
<div className="max-h-[300px] h-[250px] w-full p-4">
<ResponsiveContainer>
<BarChart
data={[
{
name: 'Total Events',
count: subscriptionPeriodEventsCount,
limit: subscriptionPeriodEventsLimit,
},
]}
>
<RechartTooltip content={<TotalTooltip />} cursor={false} />
{organization.hasSubscription &&
subscriptionPeriodEventsLimit > 0 && (
<ReferenceLine
y={subscriptionPeriodEventsLimit}
stroke={getChartColor(1)}
strokeWidth={2}
strokeDasharray="3 3"
strokeOpacity={0.8}
strokeLinecap="round"
label={{
value: `Limit (${number.format(subscriptionPeriodEventsLimit)})`,
fill: getChartColor(1),
position: 'insideTopRight',
fontSize: 12,
}}
/>
)}
<Bar
dataKey="count"
isAnimationActive={false}
shape={BarShapeBlue}
/>
<XAxis {...X_AXIS_STYLE_PROPS} dataKey="name" />
<YAxis
{...yAxisProps}
domain={[
0,
Math.max(
subscriptionPeriodEventsLimit,
subscriptionPeriodEventsCount,
) * 1.1,
]}
/>
<CartesianGrid
horizontal={true}
vertical={false}
strokeDasharray="3 3"
strokeOpacity={0.5}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
</>,
);
}
function EventsTooltip({ useWeekly, ...props }: { useWeekly: boolean } & any) {
const number = useNumber();
const payload = props.payload?.[0]?.payload;
if (!payload) {
return null;
}
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">
{useWeekly && payload.weekRange
? payload.weekRange
: payload?.date
? formatDate(new Date(payload.date))
: 'Unknown date'}
</div>
<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">
Events {useWeekly ? 'this week' : 'this day'}
</div>
<div className="text-lg font-semibold text-chart-0">
{number.format(payload.count)}
</div>
</div>
</div>
</div>
);
}
function TotalTooltip(props: any) {
const number = useNumber();
const payload = props.payload?.[0]?.payload;
if (!payload) {
return null;
}
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="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">
{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="col gap-1">
<div className="text-sm text-muted-foreground">Your tier limit</div>
<div className="text-lg font-semibold text-chart-1">
{number.format(payload.limit)}
</div>
</div>
</div>
)}
</div>
);
}