Files
stats/apps/start/src/components/organization/billing.tsx
Carl-Gerhard Lindesvärd c8bea685db fix: invalidate queries better
2025-10-17 11:01:20 +02:00

431 lines
14 KiB
TypeScript

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