431 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|