Compare commits
6 Commits
feature/nu
...
feature/bi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b72cf2dfa0 | ||
|
|
77df6dc638 | ||
|
|
eb5ca8f6e0 | ||
|
|
aa0120a79d | ||
|
|
e9fc9713e4 | ||
|
|
d9e3883d11 |
@@ -13,7 +13,7 @@ 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.',
|
||||
'For our Cloud plan we offer a 30 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?',
|
||||
|
||||
@@ -8,7 +8,7 @@ export function FeedbackButton() {
|
||||
return (
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className="text-left justify-start"
|
||||
className="text-left justify-start text-[13px]"
|
||||
icon={SparklesIcon}
|
||||
onClick={() => {
|
||||
op.track('feedback_button_clicked');
|
||||
|
||||
@@ -10,7 +10,7 @@ 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.',
|
||||
'For our Cloud plan we offer a 30 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?',
|
||||
@@ -37,13 +37,19 @@ const questions = [
|
||||
'You can change your billing information by clicking the "Manage your subscription" button in the billing section.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'We need a custom plan, can you help us?',
|
||||
answer: [
|
||||
'Yes, we can help you with that. Please contact us at hello@openpanel.com to request a quote.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function BillingFaq() {
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Usage</span>
|
||||
<span className="title">Frequently asked questions</span>
|
||||
</WidgetHead>
|
||||
<Accordion
|
||||
type="single"
|
||||
|
||||
201
apps/start/src/components/organization/billing-prompt.tsx
Normal file
201
apps/start/src/components/organization/billing-prompt.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button, LinkButton } from '@/components/ui/button';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { op } from '@/utils/op';
|
||||
import type { IServiceOrganization } from '@openpanel/db';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
BarChart3Icon,
|
||||
DollarSignIcon,
|
||||
InfinityIcon,
|
||||
type LucideIcon,
|
||||
MapIcon,
|
||||
ShieldCheckIcon,
|
||||
TrendingUpIcon,
|
||||
} from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const COPY = {
|
||||
expired: {
|
||||
title: 'Subscription expired',
|
||||
description:
|
||||
'Reactivate your subscription to regain access to your analytics data and insights.',
|
||||
body: [
|
||||
"Your subscription has expired, but your data is safe and waiting for you. Reactivate now to continue tracking your users' behavior and making data-driven decisions.",
|
||||
"Don't let gaps in your analytics cost you valuable insights. Every day without data is a day of missed opportunities to understand and grow your audience.",
|
||||
],
|
||||
},
|
||||
trialEnded: {
|
||||
title: 'Trial ended',
|
||||
description:
|
||||
'Upgrade now to keep the momentum going and continue optimizing your product.',
|
||||
body: [
|
||||
"You've experienced the power of OpenPanel. Keep the insights flowing and maintain continuity in your analytics data.",
|
||||
"We'll still process all your incoming events for the coming 30 days.",
|
||||
],
|
||||
},
|
||||
freePlan: {
|
||||
title: 'Free plan is removed',
|
||||
description:
|
||||
"We've removed the free plan to focus on delivering exceptional value to our paid customers.",
|
||||
body: [
|
||||
"We've evolved our offering to provide better features, faster performance, and dedicated support. Our paid plans ensure we can continue building the analytics platform you deserve.",
|
||||
'Simple, transparent pricing with no hidden fees. Pay for what you use, and scale as you grow. Your investment in analytics pays for itself through better decisions and improved user experiences.',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function BillingPrompt({
|
||||
organization,
|
||||
type,
|
||||
}: {
|
||||
organization: IServiceOrganization;
|
||||
type: keyof typeof COPY;
|
||||
}) {
|
||||
const number = useNumber();
|
||||
const trpc = useTRPC();
|
||||
const { data: products, isLoading: isLoadingProducts } = useQuery(
|
||||
trpc.subscription.products.queryOptions({
|
||||
organizationId: organization.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 { title, description, body } = COPY[type];
|
||||
|
||||
const bestProductFit = products?.find(
|
||||
(product) =>
|
||||
typeof product.metadata.eventsLimit === 'number' &&
|
||||
product.metadata.eventsLimit >=
|
||||
organization.subscriptionPeriodEventsCount,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
op.track('billing_prompt_viewed', {
|
||||
type,
|
||||
});
|
||||
}, [type]);
|
||||
|
||||
const price = bestProductFit
|
||||
? new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'usd',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(
|
||||
bestProductFit.prices[0] && 'priceAmount' in bestProductFit.prices[0]
|
||||
? bestProductFit.prices[0].priceAmount / 100
|
||||
: 0,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-20 max-w-7xl mx-auto">
|
||||
<div className="border rounded-lg overflow-hidden bg-def-200 p-2 items-center">
|
||||
<div className="md:row">
|
||||
<div className="p-6 bg-background rounded-md border col gap-4 flex-1">
|
||||
<PageHeader title={title} description={description} />
|
||||
{body.map((paragraph) => (
|
||||
<p key={paragraph}>
|
||||
{paragraph.replace(
|
||||
'{{events}}',
|
||||
number.format(
|
||||
organization.subscriptionPeriodEventsCount ?? 0,
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
<div className="col gap-2 mt-auto">
|
||||
{bestProductFit && (
|
||||
<div className="text-sm text-muted-foreground leading-normal">
|
||||
Based on your usage (
|
||||
{number.format(
|
||||
organization.subscriptionPeriodEventsCount ?? 0,
|
||||
)}{' '}
|
||||
events) we recommend upgrading <br />
|
||||
to the <strong>{bestProductFit.name}</strong> plan for{' '}
|
||||
<strong>{price}</strong> per month.
|
||||
</div>
|
||||
)}
|
||||
<div className="col md:row gap-2">
|
||||
<Button
|
||||
size="lg"
|
||||
loading={isLoadingProducts}
|
||||
disabled={!bestProductFit}
|
||||
onClick={() => {
|
||||
if (bestProductFit) {
|
||||
op.track('billing_prompt_upgrade_clicked', {
|
||||
type,
|
||||
price:
|
||||
bestProductFit.prices[0] &&
|
||||
'priceAmount' in bestProductFit.prices[0]
|
||||
? bestProductFit.prices[0].priceAmount / 100
|
||||
: 0,
|
||||
});
|
||||
checkout.mutate({
|
||||
organizationId: organization.id,
|
||||
productPriceId: bestProductFit.prices[0].id,
|
||||
productId: bestProductFit.id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Upgrade to {price}
|
||||
</Button>
|
||||
<LinkButton
|
||||
size="lg"
|
||||
variant="outline"
|
||||
to="/$organizationId/billing"
|
||||
params={{ organizationId: organization.id }}
|
||||
>
|
||||
View pricing
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex-1 p-6 gap-4 col min-w-[200px] max-w-[300px]">
|
||||
<Point icon={DollarSignIcon}>Plans start at just $2.5/month</Point>
|
||||
<Point icon={InfinityIcon}>
|
||||
Unlimited reports, members and projects
|
||||
</Point>
|
||||
<Point icon={BarChart3Icon}>Advanced funnels and conversions</Point>
|
||||
<Point icon={MapIcon}>Real-time analytics</Point>
|
||||
<Point icon={TrendingUpIcon}>
|
||||
Track KPIs and custom events (revenue soon)
|
||||
</Point>
|
||||
<Point icon={ShieldCheckIcon}>
|
||||
Privacy-focused and GDPR compliant
|
||||
</Point>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Point({
|
||||
icon: Icon,
|
||||
children,
|
||||
}: { icon: LucideIcon; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="row gap-2">
|
||||
<div className="size-6 shrink-0 center-center rounded-full bg-amber-500 text-white">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<h3 className="font-medium mt-[1.5px]">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartTooltip,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
@@ -38,7 +37,7 @@ function Card({ title, value }: { title: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Usage({ organization }: Props) {
|
||||
export default function BillingUsage({ organization }: Props) {
|
||||
const number = useNumber();
|
||||
const trpc = useTRPC();
|
||||
const usageQuery = useQuery(
|
||||
@@ -82,6 +81,7 @@ export default function Usage({ organization }: Props) {
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
if (usageQuery.isError) {
|
||||
return wrapper(
|
||||
<div className="center-center p-8 font-medium">
|
||||
@@ -90,6 +90,12 @@ export default function Usage({ organization }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
if (usageQuery.data?.length === 0) {
|
||||
return wrapper(
|
||||
<div className="center-center p-8 font-medium">No usage data yet</div>,
|
||||
);
|
||||
}
|
||||
|
||||
const subscriptionPeriodEventsLimit = organization.hasSubscription
|
||||
? organization.subscriptionPeriodEventsLimit
|
||||
: 0;
|
||||
@@ -159,7 +165,7 @@ export default function Usage({ organization }: Props) {
|
||||
|
||||
return wrapper(
|
||||
<>
|
||||
<div className="border-b divide-x divide-border -m-4 mb-4 grid grid-cols-2 md:grid-cols-4">
|
||||
<div className="-m-4 mb-4 grid grid-cols-2 [&>div]:shadow-[0_0_0_0.5px] [&_div]:shadow-border border-b">
|
||||
{organization.hasSubscription ? (
|
||||
<>
|
||||
<Card
|
||||
@@ -171,14 +177,6 @@ export default function Usage({ organization }: Props) {
|
||||
: '🤷♂️'
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
title="Limit"
|
||||
value={number.format(subscriptionPeriodEventsLimit)}
|
||||
/>
|
||||
<Card
|
||||
title="Events count"
|
||||
value={number.format(subscriptionPeriodEventsCount)}
|
||||
/>
|
||||
<Card
|
||||
title="Left to use"
|
||||
value={
|
||||
@@ -192,6 +190,14 @@ export default function Usage({ organization }: Props) {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
title="Events count"
|
||||
value={number.format(subscriptionPeriodEventsCount)}
|
||||
/>
|
||||
<Card
|
||||
title="Limit"
|
||||
value={number.format(subscriptionPeriodEventsLimit)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -209,95 +215,36 @@ export default function Usage({ organization }: Props) {
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
{/* 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} />}
|
||||
cursor={{
|
||||
fill: 'var(--def-200)',
|
||||
stroke: 'var(--def-200)',
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
</>,
|
||||
@@ -1,45 +1,55 @@
|
||||
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 { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import useWS from '@/hooks/use-ws';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { showConfirm } from '@/modals';
|
||||
import { op } from '@/utils/op';
|
||||
import { pushModal, useOnPushModal } from '@/modals';
|
||||
import { formatDate } from '@/utils/date';
|
||||
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 { differenceInDays } from 'date-fns';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Progress } from '../ui/progress';
|
||||
import { Widget, WidgetBody, WidgetHead } from '../widget';
|
||||
import { BillingFaq } from './billing-faq';
|
||||
import BillingUsage from './billing-usage';
|
||||
|
||||
type Props = {
|
||||
organization: IServiceOrganization;
|
||||
};
|
||||
|
||||
export default function Billing({ organization }: Props) {
|
||||
const [success, setSuccess] = useQueryState('customer_session_token');
|
||||
const queryClient = useQueryClient();
|
||||
const trpc = useTRPC();
|
||||
const [customerSessionToken, setCustomerSessionToken] = useQueryState(
|
||||
'customer_session_token',
|
||||
);
|
||||
const number = useNumber();
|
||||
|
||||
const productsQuery = useQuery(
|
||||
trpc.subscription.products.queryOptions({
|
||||
organizationId: organization.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const currentProductQuery = useQuery(
|
||||
trpc.subscription.getCurrent.queryOptions({
|
||||
organizationId: organization.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const portalMutation = useMutation(
|
||||
trpc.subscription.portal.mutationOptions({
|
||||
onSuccess(data) {
|
||||
if (data?.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
useWS(`/live/organization/${organization.id}`, () => {
|
||||
queryClient.invalidateQueries(trpc.organization.pathFilter());
|
||||
});
|
||||
@@ -54,378 +64,228 @@ export default function Billing({ organization }: Props) {
|
||||
.filter((product) => product.prices.some((p) => p.amountType !== 'free'));
|
||||
}, [productsQuery.data, recurringInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
if (organization.subscriptionInterval) {
|
||||
setRecurringInterval(
|
||||
organization.subscriptionInterval as 'year' | 'month',
|
||||
const currentProduct = currentProductQuery.data ?? null;
|
||||
const currentPrice = currentProduct?.prices.flatMap((p) =>
|
||||
p.type === 'recurring' && p.amountType === 'fixed' ? [p] : [],
|
||||
)[0];
|
||||
|
||||
const renderStatus = () => {
|
||||
if (organization.isActive && organization.subscriptionCurrentPeriodEnd) {
|
||||
return (
|
||||
<p>
|
||||
Your subscription will be renewed on{' '}
|
||||
{formatDate(organization.subscriptionCurrentPeriodEnd)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}, [organization.subscriptionInterval]);
|
||||
|
||||
if (organization.isCanceled && organization.subscriptionCanceledAt) {
|
||||
return (
|
||||
<p>
|
||||
Your subscription was canceled on{' '}
|
||||
{formatDate(organization.subscriptionCanceledAt)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (
|
||||
organization.isWillBeCanceled &&
|
||||
organization.subscriptionCurrentPeriodEnd
|
||||
) {
|
||||
return (
|
||||
<p>
|
||||
Your subscription will be canceled on{' '}
|
||||
{formatDate(organization.subscriptionCurrentPeriodEnd)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
organization.subscriptionStatus === 'expired' &&
|
||||
organization.subscriptionCurrentPeriodEnd
|
||||
) {
|
||||
return (
|
||||
<p>
|
||||
Your subscription expired on{' '}
|
||||
{formatDate(organization.subscriptionCurrentPeriodEnd)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (
|
||||
organization.subscriptionStatus === 'trialing' &&
|
||||
organization.subscriptionEndsAt
|
||||
) {
|
||||
return (
|
||||
<p>
|
||||
Your trial will end on {formatDate(organization.subscriptionEndsAt)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (customerSessionToken) {
|
||||
op.track('subscription_created');
|
||||
if (success) {
|
||||
pushModal('BillingSuccess');
|
||||
}
|
||||
}, [customerSessionToken]);
|
||||
}, [success]);
|
||||
|
||||
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]);
|
||||
|
||||
// Preferred default selection when there is no active subscription
|
||||
const defaultSelectedIndex = useMemo(() => {
|
||||
const defaultIndex = products.findIndex(
|
||||
(product) => product.metadata?.eventsLimit === 100_000,
|
||||
);
|
||||
return defaultIndex >= 0 ? defaultIndex : 0;
|
||||
}, [products]);
|
||||
|
||||
// Find current subscription index (-1 when no subscription)
|
||||
const currentSubscriptionIndex = useMemo(() => {
|
||||
if (!organization.subscriptionProductId) {
|
||||
return -1;
|
||||
// Clear query state when modal is closed
|
||||
useOnPushModal('BillingSuccess', (open) => {
|
||||
if (!open) {
|
||||
setSuccess(null);
|
||||
}
|
||||
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 or default plan when none
|
||||
useEffect(() => {
|
||||
if (currentSubscriptionIndex >= 0) {
|
||||
setSelectedProductIndex(currentSubscriptionIndex);
|
||||
} else {
|
||||
setSelectedProductIndex(defaultSelectedIndex);
|
||||
}
|
||||
}, [currentSubscriptionIndex, defaultSelectedIndex]);
|
||||
|
||||
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>
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<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="text-lg">
|
||||
<span className="font-bold">
|
||||
{number.currency(currentPrice.priceAmount / 100)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{' / '}
|
||||
{recurringInterval === 'year' ? 'year' : 'month'}
|
||||
</span>
|
||||
</div>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
{renderStatus()}
|
||||
<div className="col mt-4">
|
||||
<div className="font-semibold mb-2">
|
||||
{number.format(organization.subscriptionPeriodEventsCount)} /{' '}
|
||||
{number.format(Number(currentProduct.metadata.eventsLimit))}
|
||||
</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"
|
||||
<Progress
|
||||
value={
|
||||
(organization.subscriptionPeriodEventsCount /
|
||||
Number(currentProduct.metadata.eventsLimit)) *
|
||||
100
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="row justify-between mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
portalMutation.mutate({ organizationId: organization.id })
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="size-4 mr-2"
|
||||
width="300"
|
||||
height="300"
|
||||
viewBox="0 0 300 300"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
hello@openpanel.dev
|
||||
</a>{' '}
|
||||
and we'll help you with a custom quota.
|
||||
</p>
|
||||
<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"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_4">
|
||||
<rect width="300" height="300" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
Customer portal
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
pushModal('SelectBillingPlan', {
|
||||
organization,
|
||||
currentProduct,
|
||||
})
|
||||
}
|
||||
>
|
||||
{organization.isWillBeCanceled
|
||||
? 'Reactivate subscription'
|
||||
: 'Change subscription'}
|
||||
</Button>
|
||||
</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>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
) : (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<div className="font-bold text-lg flex-1">
|
||||
{organization.isTrial
|
||||
? 'Get started'
|
||||
: 'No active subscription'}
|
||||
</div>
|
||||
<div className="text-lg">
|
||||
<span className="">
|
||||
{organization.isTrial ? '30 days free trial' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
{organization.isTrial && organization.subscriptionEndsAt ? (
|
||||
<p>
|
||||
Your trial will end on{' '}
|
||||
{formatDate(organization.subscriptionEndsAt)} (
|
||||
{differenceInDays(
|
||||
organization.subscriptionEndsAt,
|
||||
new Date(),
|
||||
) + 1}{' '}
|
||||
days left)
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Your trial has expired. Please upgrade your account to
|
||||
continue using Openpanel.
|
||||
</p>
|
||||
)}
|
||||
<div className="col mt-4">
|
||||
<div className="font-semibold mb-2">
|
||||
{number.format(organization.subscriptionPeriodEventsCount)} /{' '}
|
||||
{number.format(
|
||||
Number(organization.subscriptionPeriodEventsLimit),
|
||||
)}
|
||||
</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}
|
||||
buttonText={
|
||||
isUpgrade
|
||||
? 'Upgrade'
|
||||
: isDowngrade
|
||||
? 'Downgrade'
|
||||
: 'Activate'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCurrentPlan && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" disabled>
|
||||
Current Plan
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
(organization.subscriptionPeriodEventsCount /
|
||||
Number(organization.subscriptionPeriodEventsLimit)) *
|
||||
100
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="row justify-end mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
pushModal('SelectBillingPlan', {
|
||||
organization,
|
||||
currentProduct,
|
||||
})
|
||||
}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
)}
|
||||
|
||||
<BillingUsage organization={organization} />
|
||||
</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,
|
||||
disabled,
|
||||
buttonText,
|
||||
}: {
|
||||
price: IPolarPrice;
|
||||
organization: IServiceOrganization;
|
||||
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({
|
||||
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>
|
||||
<BillingFaq />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
147
apps/start/src/components/organization/supporter-prompt.tsx
Normal file
147
apps/start/src/components/organization/supporter-prompt.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Button, LinkButton } from '@/components/ui/button';
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import { useCookieStore } from '@/hooks/use-cookie-store';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
AwardIcon,
|
||||
HeartIcon,
|
||||
type LucideIcon,
|
||||
MessageCircleIcon,
|
||||
RocketIcon,
|
||||
SparklesIcon,
|
||||
XIcon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
const PERKS = [
|
||||
{
|
||||
icon: RocketIcon,
|
||||
text: 'Latest Docker Images',
|
||||
description: 'Bleeding-edge builds on every commit',
|
||||
},
|
||||
{
|
||||
icon: MessageCircleIcon,
|
||||
text: 'Prioritized Support',
|
||||
description: 'Get help faster with priority Discord support',
|
||||
},
|
||||
{
|
||||
icon: SparklesIcon,
|
||||
text: 'Feature Requests',
|
||||
description: 'Your ideas get prioritized in our roadmap',
|
||||
},
|
||||
{
|
||||
icon: AwardIcon,
|
||||
text: 'Exclusive Discord Role',
|
||||
description: 'Special badge and recognition in our community',
|
||||
},
|
||||
{
|
||||
icon: ZapIcon,
|
||||
text: 'Early Access',
|
||||
description: 'Try new features before public release',
|
||||
},
|
||||
{
|
||||
icon: HeartIcon,
|
||||
text: 'Direct Impact',
|
||||
description: 'Your support directly funds development',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function PerkPoint({
|
||||
icon: Icon,
|
||||
text,
|
||||
description,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
text: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="row gap-4 items-center">
|
||||
<Icon className="size-4" />
|
||||
<div className="flex-1 min-w-0 col gap-1.5">
|
||||
<h3 className="font-medium text-sm">{text}</h3>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SupporterPrompt() {
|
||||
const { isSelfHosted } = useAppContext();
|
||||
const [supporterPromptClosed, setSupporterPromptClosed] = useCookieStore(
|
||||
'supporter-prompt-closed',
|
||||
false,
|
||||
);
|
||||
|
||||
if (!isSelfHosted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{!supporterPromptClosed && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 100, scale: 0.95 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 100, scale: 0.95 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
}}
|
||||
className="fixed bottom-0 right-0 z-50 p-4 max-w-md"
|
||||
>
|
||||
<div className="bg-card border p-6 rounded-lg shadow-lg col gap-4">
|
||||
<div>
|
||||
<div className="row items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Support OpenPanel</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
onClick={() => setSupporterPromptClosed(true)}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Help us build the future of open analytics
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="col gap-3">
|
||||
{PERKS.map((perk) => (
|
||||
<PerkPoint
|
||||
key={perk.text}
|
||||
icon={perk.icon}
|
||||
text={perk.text}
|
||||
description={perk.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<LinkButton
|
||||
className="w-full"
|
||||
href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV"
|
||||
>
|
||||
Become a Supporter
|
||||
</LinkButton>
|
||||
<p className="text-xs text-muted-foreground text-center mt-4">
|
||||
Starting at $20/month • Cancel anytime •{' '}
|
||||
<a
|
||||
href="https://openpanel.dev/supporter"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -135,9 +135,12 @@ export function SidebarContainer({
|
||||
<ProfileToggle />
|
||||
</div>
|
||||
{isSelfHosted && (
|
||||
<div className={cn('text-sm w-full text-left mt-2')}>
|
||||
Self-hosted instance
|
||||
</div>
|
||||
<a
|
||||
href="https://openpanel.dev/supporter"
|
||||
className="text-center text-sm w-full mt-2 border rounded p-2 font-medium block hover:underline hover:text-primary outline-none"
|
||||
>
|
||||
Self-hosted instance, support us!
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,20 @@ import { pick } from 'ramda';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
const VALID_COOKIES = ['ui-theme', 'chartType', 'range'] as const;
|
||||
const VALID_COOKIES = [
|
||||
'ui-theme',
|
||||
'chartType',
|
||||
'range',
|
||||
'supporter-prompt-closed',
|
||||
] as const;
|
||||
const COOKIE_EVENT_NAME = '__cookie-change';
|
||||
|
||||
const setCookieFn = createServerFn({ method: 'POST' })
|
||||
.inputValidator(z.object({ key: z.enum(VALID_COOKIES), value: z.string() }))
|
||||
.handler(({ data: { key, value } }) => {
|
||||
if (!VALID_COOKIES.includes(key)) {
|
||||
return;
|
||||
}
|
||||
setCookie(key, value);
|
||||
});
|
||||
|
||||
|
||||
47
apps/start/src/modals/billing-success.tsx
Normal file
47
apps/start/src/modals/billing-success.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { popModal } from '.';
|
||||
import { ModalContent } from './Modal/Container';
|
||||
|
||||
export default function BillingSuccess() {
|
||||
return (
|
||||
<ModalContent className="max-w-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => popModal()}
|
||||
className="absolute right-6 top-6 z-10 rounded-full bg-black text-white p-2.5 hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-center justify-center py-12 px-8">
|
||||
{/* Success Icon with animated rings */}
|
||||
<div className="relative mb-10 h-64 w-64 flex items-center justify-center">
|
||||
<div className="absolute inset-0 flex items-center justify-center animate-ping-slow opacity-10">
|
||||
<div className="h-64 w-64 rounded-full bg-emerald-400" />
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-52 w-52 rounded-full bg-emerald-200/30" />
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-40 w-40 rounded-full bg-emerald-300/40" />
|
||||
</div>
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="h-32 w-32 rounded-full bg-emerald-500 shadow-lg flex items-center justify-center">
|
||||
<Check className="h-16 w-16 text-white stroke-[3]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
<h2 className="text-3xl font-semibold mb-4 text-gray-900">
|
||||
Subscription updated successfully
|
||||
</h2>
|
||||
<p className="text-center mb-12 max-w-md text-base leading-normal">
|
||||
Thank you for your purchase! You have now full access to OpenPanel. If
|
||||
you have any questions or feedback, please don't hesitate to contact
|
||||
us.
|
||||
</p>
|
||||
</div>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createPushModal } from 'pushmodal';
|
||||
|
||||
import OverviewTopGenericModal from '@/components/overview/overview-top-generic-modal';
|
||||
import OverviewTopPagesModal from '@/components/overview/overview-top-pages-modal';
|
||||
import { op } from '@/utils/op';
|
||||
import Instructions from './Instructions';
|
||||
import AddClient from './add-client';
|
||||
import AddDashboard from './add-dashboard';
|
||||
@@ -10,6 +11,7 @@ import AddIntegration from './add-integration';
|
||||
import AddNotificationRule from './add-notification-rule';
|
||||
import AddProject from './add-project';
|
||||
import AddReference from './add-reference';
|
||||
import BillingSuccess from './billing-success';
|
||||
import Confirm from './confirm';
|
||||
import type { ConfirmProps } from './confirm';
|
||||
import CreateInvite from './create-invite';
|
||||
@@ -27,6 +29,7 @@ import OverviewChartDetails from './overview-chart-details';
|
||||
import OverviewFilters from './overview-filters';
|
||||
import RequestPasswordReset from './request-reset-password';
|
||||
import SaveReport from './save-report';
|
||||
import SelectBillingPlan from './select-billing-plan';
|
||||
import ShareOverviewModal from './share-overview-modal';
|
||||
|
||||
const modals = {
|
||||
@@ -57,6 +60,8 @@ const modals = {
|
||||
AddNotificationRule: AddNotificationRule,
|
||||
OverviewFilters: OverviewFilters,
|
||||
CreateInvite: CreateInvite,
|
||||
SelectBillingPlan: SelectBillingPlan,
|
||||
BillingSuccess: BillingSuccess,
|
||||
};
|
||||
|
||||
export const {
|
||||
@@ -66,8 +71,13 @@ export const {
|
||||
popAllModals,
|
||||
ModalProvider,
|
||||
useOnPushModal,
|
||||
onPushModal,
|
||||
} = createPushModal({
|
||||
modals,
|
||||
});
|
||||
|
||||
onPushModal('*', (open, props, name) => {
|
||||
op.screenView(`modal:${name}`, props as Record<string, unknown>);
|
||||
});
|
||||
|
||||
export const showConfirm = (props: ConfirmProps) => pushModal('Confirm', props);
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function SaveReport({
|
||||
const queryClient = useQueryClient();
|
||||
const { organizationId, projectId } = useAppParams();
|
||||
const searchParams = useSearch({
|
||||
from: '/_app/$organizationId/$projectId_/reports',
|
||||
from: '/_app/$organizationId/$projectId/reports',
|
||||
shouldThrow: false,
|
||||
});
|
||||
const dashboardId = searchParams?.dashboardId;
|
||||
|
||||
309
apps/start/src/modals/select-billing-plan.tsx
Normal file
309
apps/start/src/modals/select-billing-plan.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { op } from '@/utils/op';
|
||||
import type { IServiceOrganization } from '@openpanel/db';
|
||||
import type { IPolarProduct } from '@openpanel/payments';
|
||||
import { current } from '@reduxjs/toolkit';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckIcon, ShuffleIcon } from 'lucide-react';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
|
||||
interface Props {
|
||||
organization: IServiceOrganization;
|
||||
currentProduct: IPolarProduct | null;
|
||||
}
|
||||
|
||||
const getPrice = (product: IPolarProduct) => {
|
||||
return product.prices[0] && 'priceAmount' in product.prices[0]
|
||||
? product.prices[0].priceAmount / 100
|
||||
: 0;
|
||||
};
|
||||
|
||||
export default function SelectBillingPlan({
|
||||
organization,
|
||||
currentProduct,
|
||||
}: Props) {
|
||||
const number = useNumber();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const productsQuery = useQuery(
|
||||
trpc.subscription.products.queryOptions({
|
||||
organizationId: organization.id,
|
||||
}),
|
||||
);
|
||||
const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>(
|
||||
(organization.subscriptionInterval as 'year' | 'month') || 'month',
|
||||
);
|
||||
const [selectedProductId, setSelectedProductId] = useState<string | null>(
|
||||
organization.subscriptionProductId || null,
|
||||
);
|
||||
const products = productsQuery.data || [];
|
||||
const selectedProduct = products.find(
|
||||
(product) => product.id === selectedProductId,
|
||||
);
|
||||
|
||||
const checkoutMutation = useMutation(
|
||||
trpc.subscription.checkout.mutationOptions({
|
||||
onSuccess(data) {
|
||||
if (data?.url) {
|
||||
window.location.href = data.url;
|
||||
} else {
|
||||
queryClient.invalidateQueries(
|
||||
trpc.organization.get.queryOptions({
|
||||
organizationId: organization.id,
|
||||
}),
|
||||
);
|
||||
|
||||
queryClient.invalidateQueries(
|
||||
trpc.subscription.getCurrent.queryOptions({
|
||||
organizationId: organization.id,
|
||||
}),
|
||||
);
|
||||
toast.success('Subscription updated', {
|
||||
description: 'It might take a few seconds to update',
|
||||
});
|
||||
popModal();
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const cancelSubscription = useMutation(
|
||||
trpc.subscription.cancelSubscription.mutationOptions({
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries(
|
||||
trpc.organization.get.queryOptions({
|
||||
organizationId: organization.id,
|
||||
}),
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
trpc.subscription.getCurrent.queryOptions({
|
||||
organizationId: organization.id,
|
||||
}),
|
||||
);
|
||||
toast.success('Subscription canceled', {
|
||||
description: 'It might take a few seconds to update',
|
||||
});
|
||||
popModal();
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const handleCheckout = () => {
|
||||
if (!selectedProduct) return;
|
||||
op.track('subscription_checkout_started', {
|
||||
organizationId: organization.id,
|
||||
limit: selectedProduct.metadata.eventsLimit,
|
||||
price: getPrice(selectedProduct),
|
||||
});
|
||||
checkoutMutation.mutate({
|
||||
organizationId: organization.id,
|
||||
productPriceId: selectedProduct.prices[0].id,
|
||||
productId: selectedProduct.id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelSubscription = () => {
|
||||
if (!selectedProduct) return;
|
||||
op.track('subscription_canceled', {
|
||||
organizationId: organization.id,
|
||||
limit: selectedProduct.metadata.eventsLimit,
|
||||
price: getPrice(selectedProduct),
|
||||
});
|
||||
cancelSubscription.mutate({
|
||||
organizationId: organization.id,
|
||||
});
|
||||
};
|
||||
|
||||
const renderAction = () => {
|
||||
if (!selectedProduct) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCurrentProduct = selectedProduct.id === currentProduct?.id;
|
||||
|
||||
if (isCurrentProduct && organization.isActive) {
|
||||
return (
|
||||
<Button
|
||||
className="w-full mt-4"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
onClick={handleCancelSubscription}
|
||||
>
|
||||
Cancel subscription
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const payLabel = (() => {
|
||||
if (
|
||||
organization.isCanceled ||
|
||||
organization.isWillBeCanceled ||
|
||||
organization.isExpired
|
||||
) {
|
||||
return isCurrentProduct
|
||||
? 'Reactivate subscription'
|
||||
: 'Change subscription';
|
||||
}
|
||||
|
||||
if (currentProduct) {
|
||||
return 'Change subscription';
|
||||
}
|
||||
|
||||
return 'Pay with Polar';
|
||||
})();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full mt-4 rounded-lg overflow-hidden hover:translate-y-[-1px] transition-all group"
|
||||
onClick={handleCheckout}
|
||||
>
|
||||
{currentProduct && (
|
||||
<div className="row justify-between p-2 px-4 border-t border-l border-r border-border rounded-t-lg bg-def-200 group-hover:bg-def-100 transition-colors line-through">
|
||||
<span>{currentProduct?.name}</span>
|
||||
<span>{number.currency(getPrice(currentProduct))}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'row justify-between p-2 px-4 border-t border-l border-r border-border bg-def-200 group-hover:bg-def-100 transition-colors',
|
||||
!currentProduct && 'rounded-t-lg',
|
||||
)}
|
||||
>
|
||||
<span>{selectedProduct.name}</span>
|
||||
<span>{number.currency(getPrice(selectedProduct))}</span>
|
||||
</div>
|
||||
<div className="center-center gap-4 row bg-primary text-primary-foreground p-4 group-hover:bg-primary/90 transition-colors">
|
||||
<svg
|
||||
className="size-6"
|
||||
width="300"
|
||||
height="300"
|
||||
viewBox="0 0 300 300"
|
||||
fill="none"
|
||||
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"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_4">
|
||||
<rect width="300" height="300" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span className="font-semibold">{payLabel}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalContent>
|
||||
<ModalHeader title="Select a billing plan" />
|
||||
<div className="col gap-4">
|
||||
{currentProduct && (
|
||||
<div className="font-medium">
|
||||
Your current usage is{' '}
|
||||
{number.format(organization.subscriptionPeriodEventsCount)} out of{' '}
|
||||
{number.format(Number(currentProduct?.metadata.eventsLimit))}{' '}
|
||||
events.{' '}
|
||||
<span className="text-muted-foreground">
|
||||
You cannot downgrade if your usage exceeds the limit of the new
|
||||
plan.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="row items-center justify-between gap-2 -mb-2">
|
||||
<div className="font-medium">
|
||||
{recurringInterval === 'year' ? (
|
||||
'Switch to monthly'
|
||||
) : (
|
||||
<>
|
||||
Switch to yearly and get{' '}
|
||||
<span className="underline text-emerald-500">
|
||||
2 months for free
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setRecurringInterval((p) => (p === 'year' ? 'month' : 'year'))
|
||||
}
|
||||
>
|
||||
{recurringInterval === 'year' ? 'Monthly' : 'Yearly'}
|
||||
<ShuffleIcon className="size-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col divide-y divide-border border rounded-lg overflow-hidden">
|
||||
{products
|
||||
.filter((product) =>
|
||||
product.prices.some((p) => p.amountType !== 'free'),
|
||||
)
|
||||
.filter((product) => product.metadata.eventsLimit)
|
||||
.filter((product) => product.recurringInterval === recurringInterval)
|
||||
.map((product) => {
|
||||
const price = getPrice(product);
|
||||
|
||||
const limit = product.metadata.eventsLimit
|
||||
? Number(product.metadata.eventsLimit)
|
||||
: 0;
|
||||
|
||||
const isProductDisabled =
|
||||
(limit > 0 &&
|
||||
organization.subscriptionPeriodEventsCount >= limit) ||
|
||||
!!product.disabled;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={product.id}
|
||||
type="button"
|
||||
disabled={isProductDisabled}
|
||||
className={cn(
|
||||
'row justify-between p-4 py-3 hover:bg-def-100',
|
||||
currentProduct?.id === product.id &&
|
||||
selectedProductId !== product.id &&
|
||||
'text-muted-foreground line-through',
|
||||
isProductDisabled && 'opacity-50 !cursor-not-allowed',
|
||||
)}
|
||||
onClick={() => setSelectedProductId(product.id)}
|
||||
>
|
||||
<span className={'font-medium'}>{product.name}</span>
|
||||
<div className="row items-center gap-2">
|
||||
<span className="font-bold">{number.currency(price)}</span>
|
||||
{selectedProductId === product.id && (
|
||||
<div className="size-4 center-center rounded-full bg-emerald-600 text-primary-foreground">
|
||||
<CheckIcon className="size-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{renderAction()}
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import { keepPreviousData, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import type { UIMessage } from 'ai';
|
||||
|
||||
export const Route = createFileRoute('/_app/$organizationId/$projectId_/chat')({
|
||||
export const Route = createFileRoute('/_app/$organizationId/$projectId/chat')({
|
||||
component: Component,
|
||||
pendingComponent: FullPageLoadingState,
|
||||
head: () => {
|
||||
@@ -32,7 +32,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Link, createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/dashboards',
|
||||
'/_app/$organizationId/$projectId/dashboards',
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -53,7 +53,7 @@ type Layout = {
|
||||
};
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/dashboards_/$dashboardId',
|
||||
'/_app/$organizationId/$projectId/dashboards_/$dashboardId',
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -6,7 +6,7 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/events/_tabs/conversions',
|
||||
'/_app/$organizationId/$projectId/events/_tabs/conversions',
|
||||
)({
|
||||
component: Component,
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/events/_tabs/events',
|
||||
'/_app/$organizationId/$projectId/events/_tabs/events',
|
||||
)({
|
||||
component: Component,
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/events/_tabs/',
|
||||
'/_app/$organizationId/$projectId/events/_tabs/',
|
||||
)({
|
||||
component: Component,
|
||||
beforeLoad({ params }) {
|
||||
@@ -14,7 +14,7 @@ import type { IChartEvent } from '@openpanel/validation';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/events/_tabs/stats',
|
||||
'/_app/$organizationId/$projectId/events/_tabs/stats',
|
||||
)({
|
||||
component: Component,
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
||||
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/events/_tabs',
|
||||
'/_app/$organizationId/$projectId/events/_tabs',
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
OverviewFilterButton,
|
||||
OverviewFiltersButtons,
|
||||
} from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { LiveCounter } from '@/components/overview/live-counter';
|
||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||
import OverviewMetrics from '@/components/overview/overview-metrics';
|
||||
import { OverviewRange } from '@/components/overview/overview-range';
|
||||
import { OverviewShare } from '@/components/overview/overview-share';
|
||||
import OverviewTopDevices from '@/components/overview/overview-top-devices';
|
||||
import OverviewTopEvents from '@/components/overview/overview-top-events';
|
||||
import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
||||
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/$organizationId/$projectId/')({
|
||||
component: ProjectDashboard,
|
||||
head: () => {
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: createProjectTitle(PAGE_TITLES.DASHBOARD),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function ProjectDashboard() {
|
||||
const { projectId } = Route.useParams();
|
||||
return (
|
||||
<div>
|
||||
<div className="col gap-2 p-4">
|
||||
<div className="flex justify-between gap-2">
|
||||
<div className="flex gap-2">
|
||||
<OverviewRange />
|
||||
<OverviewInterval />
|
||||
<OverviewFilterButton mode="events" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<LiveCounter projectId={projectId} />
|
||||
<OverviewShare projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
<OverviewFiltersButtons />
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-4 p-4 pt-0">
|
||||
<OverviewMetrics projectId={projectId} />
|
||||
<OverviewTopSources projectId={projectId} />
|
||||
<OverviewTopPages projectId={projectId} />
|
||||
<OverviewTopDevices projectId={projectId} />
|
||||
<OverviewTopEvents projectId={projectId} />
|
||||
<OverviewTopGeo projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/notifications/_tabs/',
|
||||
'/_app/$organizationId/$projectId/notifications/_tabs/',
|
||||
)({
|
||||
component: Component,
|
||||
beforeLoad({ params }) {
|
||||
@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/notifications/_tabs/notifications',
|
||||
'/_app/$organizationId/$projectId/notifications/_tabs/notifications',
|
||||
)({
|
||||
component: Component,
|
||||
loader: async ({ context, params }) => {
|
||||
@@ -12,7 +12,7 @@ import { PencilRulerIcon, PlusIcon } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/notifications/_tabs/rules',
|
||||
'/_app/$organizationId/$projectId/notifications/_tabs/rules',
|
||||
)({
|
||||
component: Component,
|
||||
loader: async ({ context, params }) => {
|
||||
@@ -5,7 +5,7 @@ import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
||||
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/notifications/_tabs',
|
||||
'/_app/$organizationId/$projectId/notifications/_tabs',
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -24,7 +24,7 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const Route = createFileRoute('/_app/$organizationId/$projectId_/pages')(
|
||||
export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')(
|
||||
{
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -9,7 +9,7 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/profiles/$profileId/_tabs/events',
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events',
|
||||
)({
|
||||
component: Component,
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/profiles/$profileId/_tabs/',
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/',
|
||||
)({
|
||||
component: Component,
|
||||
loader: async ({ context, params }) => {
|
||||
@@ -11,7 +11,7 @@ import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/profiles/$profileId/_tabs',
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs',
|
||||
)({
|
||||
component: Component,
|
||||
loader: async ({ context, params }) => {
|
||||
@@ -7,7 +7,7 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/profiles/_tabs/anonymous',
|
||||
'/_app/$organizationId/$projectId/profiles/_tabs/anonymous',
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -7,7 +7,7 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/profiles/_tabs/identified',
|
||||
'/_app/$organizationId/$projectId/profiles/_tabs/identified',
|
||||
)({
|
||||
head: () => {
|
||||
return {
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/profiles/_tabs/',
|
||||
'/_app/$organizationId/$projectId/profiles/_tabs/',
|
||||
)({
|
||||
component: Component,
|
||||
beforeLoad({ params }) {
|
||||
@@ -6,7 +6,7 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/profiles/_tabs/power-users',
|
||||
'/_app/$organizationId/$projectId/profiles/_tabs/power-users',
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -5,7 +5,7 @@ import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
||||
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/profiles/_tabs',
|
||||
'/_app/$organizationId/$projectId/profiles/_tabs',
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -12,7 +12,7 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/realtime',
|
||||
'/_app/$organizationId/$projectId/realtime',
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -33,7 +33,7 @@ import { PlusIcon } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/references',
|
||||
'/_app/$organizationId/$projectId/references',
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -4,7 +4,7 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/reports',
|
||||
'/_app/$organizationId/$projectId/reports',
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -7,7 +7,7 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/reports_/$reportId',
|
||||
'/_app/$organizationId/$projectId/reports_/$reportId',
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -13,7 +13,7 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/sessions',
|
||||
'/_app/$organizationId/$projectId/sessions',
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -16,7 +16,7 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/sessions_/$sessionId',
|
||||
'/_app/$organizationId/$projectId/sessions_/$sessionId',
|
||||
)({
|
||||
component: Component,
|
||||
loader: async ({ context, params }) => {
|
||||
@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/settings/_tabs/clients',
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/clients',
|
||||
)({
|
||||
component: Component,
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/settings/_tabs/details',
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/details',
|
||||
)({
|
||||
component: Component,
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/settings/_tabs/events',
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/events',
|
||||
)({
|
||||
component: Component,
|
||||
});
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/settings/_tabs/imports',
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/imports',
|
||||
)({
|
||||
component: ImportsSettings,
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/settings/_tabs/',
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/',
|
||||
)({
|
||||
component: Component,
|
||||
beforeLoad: ({ params }) => {
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId_/settings/_tabs',
|
||||
'/_app/$organizationId/$projectId/settings/_tabs',
|
||||
)({
|
||||
component: ProjectDashboard,
|
||||
head: () => {
|
||||
@@ -1,19 +1,9 @@
|
||||
import {
|
||||
OverviewFilterButton,
|
||||
OverviewFiltersButtons,
|
||||
} from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { LiveCounter } from '@/components/overview/live-counter';
|
||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||
import OverviewMetrics from '@/components/overview/overview-metrics';
|
||||
import { OverviewRange } from '@/components/overview/overview-range';
|
||||
import { OverviewShare } from '@/components/overview/overview-share';
|
||||
import OverviewTopDevices from '@/components/overview/overview-top-devices';
|
||||
import OverviewTopEvents from '@/components/overview/overview-top-events';
|
||||
import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
||||
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||
import BillingPrompt from '@/components/organization/billing-prompt';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { FREE_PRODUCT_IDS } from '@openpanel/payments';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Outlet, createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/$organizationId/$projectId')({
|
||||
component: ProjectDashboard,
|
||||
@@ -26,34 +16,43 @@ export const Route = createFileRoute('/_app/$organizationId/$projectId')({
|
||||
],
|
||||
};
|
||||
},
|
||||
loader: async ({ context, params }) => {
|
||||
await context.queryClient.prefetchQuery(
|
||||
context.trpc.organization.get.queryOptions({
|
||||
organizationId: params.organizationId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function ProjectDashboard() {
|
||||
const { projectId } = Route.useParams();
|
||||
return (
|
||||
<div>
|
||||
<div className="col gap-2 p-4">
|
||||
<div className="flex justify-between gap-2">
|
||||
<div className="flex gap-2">
|
||||
<OverviewRange />
|
||||
<OverviewInterval />
|
||||
<OverviewFilterButton mode="events" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<LiveCounter projectId={projectId} />
|
||||
<OverviewShare projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
<OverviewFiltersButtons />
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-4 p-4 pt-0">
|
||||
<OverviewMetrics projectId={projectId} />
|
||||
<OverviewTopSources projectId={projectId} />
|
||||
<OverviewTopPages projectId={projectId} />
|
||||
<OverviewTopDevices projectId={projectId} />
|
||||
<OverviewTopEvents projectId={projectId} />
|
||||
<OverviewTopGeo projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
const { organizationId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const { data: organization } = useSuspenseQuery(
|
||||
trpc.organization.get.queryOptions({
|
||||
organizationId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
organization.subscriptionProductId &&
|
||||
FREE_PRODUCT_IDS.includes(organization.subscriptionProductId)
|
||||
) {
|
||||
return <BillingPrompt organization={organization} type={'freePlan'} />;
|
||||
}
|
||||
|
||||
if (organization.isExpired) {
|
||||
return (
|
||||
<BillingPrompt
|
||||
organization={organization}
|
||||
type={
|
||||
organization.subscriptionStatus === 'trialing'
|
||||
? 'trialEnded'
|
||||
: 'expired'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import Billing from '@/components/organization/billing';
|
||||
import { BillingFaq } from '@/components/organization/billing-faq';
|
||||
import CurrentSubscription from '@/components/organization/current-subscription';
|
||||
import Usage from '@/components/organization/usage';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { PAGE_TITLES, createOrganizationTitle } from '@/utils/title';
|
||||
@@ -22,6 +19,20 @@ export const Route = createFileRoute('/_app/$organizationId/billing')({
|
||||
],
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ params, context }) => {
|
||||
await Promise.all([
|
||||
context.queryClient.prefetchQuery(
|
||||
context.trpc.subscription.products.queryOptions({
|
||||
organizationId: params.organizationId,
|
||||
}),
|
||||
),
|
||||
context.queryClient.prefetchQuery(
|
||||
context.trpc.subscription.getCurrent.queryOptions({
|
||||
organizationId: params.organizationId,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
function OrganizationPage() {
|
||||
@@ -51,14 +62,7 @@ function OrganizationPage() {
|
||||
className="mb-8"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col-reverse md:flex-row gap-8 max-w-screen-lg">
|
||||
<div className="col gap-8 w-full">
|
||||
<Billing organization={organization} />
|
||||
<Usage organization={organization} />
|
||||
<BillingFaq />
|
||||
</div>
|
||||
<CurrentSubscription organization={organization} />
|
||||
</div>
|
||||
<Billing organization={organization} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import SupporterPrompt from '@/components/organization/supporter-prompt';
|
||||
import { LinkButton } from '@/components/ui/button';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { FREE_PRODUCT_IDS } from '@openpanel/payments';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Outlet,
|
||||
@@ -106,24 +106,9 @@ function Component() {
|
||||
</LinkButton>
|
||||
</Alert>
|
||||
)}
|
||||
{organization.subscriptionEndsAt && organization.isExpired && (
|
||||
<Alert
|
||||
title="Subscription expired"
|
||||
description={`Your subscription has expired. You can reactivate it by choosing a new plan below. It expired on ${format(organization.subscriptionEndsAt, 'PPP')}`}
|
||||
>
|
||||
<LinkButton
|
||||
to="/$organizationId/billing"
|
||||
params={{
|
||||
organizationId: organizationId,
|
||||
}}
|
||||
>
|
||||
Reactivate
|
||||
</LinkButton>
|
||||
</Alert>
|
||||
)}
|
||||
{organization.subscriptionEndsAt && organization.isWillBeCanceled && (
|
||||
<Alert
|
||||
title="Subscription will becanceled"
|
||||
title="Subscription will be canceled"
|
||||
description={`You have canceled your subscription. You can reactivate it by choosing a new plan below. It'll expire on ${format(organization.subscriptionEndsAt, 'PPP')}`}
|
||||
>
|
||||
<LinkButton
|
||||
@@ -151,25 +136,8 @@ function Component() {
|
||||
</LinkButton>
|
||||
</Alert>
|
||||
)}
|
||||
{organization.subscriptionProductId &&
|
||||
FREE_PRODUCT_IDS.includes(organization.subscriptionProductId) && (
|
||||
<Alert
|
||||
title="Free plan is removed"
|
||||
description="We've removed the free plan. You can upgrade to a paid plan to continue using OpenPanel."
|
||||
className="bg-orange-400/40 border-orange-400/50"
|
||||
>
|
||||
<LinkButton
|
||||
className="bg-orange-400 text-white hover:bg-orange-400/80"
|
||||
to="/$organizationId/billing"
|
||||
params={{
|
||||
organizationId: organizationId,
|
||||
}}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
</Alert>
|
||||
)}
|
||||
<Outlet />
|
||||
<SupporterPrompt />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -327,4 +327,16 @@ button {
|
||||
.animate-float-2 {
|
||||
animation: float-2 3.5s ease-in-out infinite;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
/* Slow ping animation for success modal */
|
||||
@keyframes ping-slow {
|
||||
75%, 100% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-ping-slow {
|
||||
animation: ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
}
|
||||
@@ -117,6 +117,22 @@ const getPrismaClient = () => {
|
||||
return new Date(Date.now() + 1000 * 60 * 60 * 24);
|
||||
},
|
||||
},
|
||||
isActive: {
|
||||
needs: {
|
||||
subscriptionStatus: true,
|
||||
subscriptionEndsAt: true,
|
||||
subscriptionCanceledAt: true,
|
||||
},
|
||||
compute(org) {
|
||||
return (
|
||||
org.subscriptionStatus === 'active' &&
|
||||
org.subscriptionEndsAt &&
|
||||
org.subscriptionEndsAt > new Date() &&
|
||||
!isCanceled(org) &&
|
||||
!isWillBeCanceled(org)
|
||||
);
|
||||
},
|
||||
},
|
||||
isTrial: {
|
||||
needs: { subscriptionStatus: true, subscriptionEndsAt: true },
|
||||
compute(org) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export type { ProductPrice } from '@polar-sh/sdk/models/components/productprice.js';
|
||||
|
||||
export type IPrice = {
|
||||
price: number;
|
||||
events: number;
|
||||
|
||||
@@ -5,7 +5,11 @@ import { importQueue } from '@openpanel/queue';
|
||||
import { zCreateImport } from '@openpanel/validation';
|
||||
|
||||
import { getProjectAccess } from '../access';
|
||||
import { TRPCAccessError } from '../errors';
|
||||
import {
|
||||
TRPCAccessError,
|
||||
TRPCBadRequestError,
|
||||
TRPCNotFoundError,
|
||||
} from '../errors';
|
||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||
|
||||
export const importRouter = createTRPCRouter({
|
||||
@@ -69,6 +73,28 @@ export const importRouter = createTRPCRouter({
|
||||
);
|
||||
}
|
||||
|
||||
const organization = await db.organization.findFirst({
|
||||
where: {
|
||||
projects: {
|
||||
some: {
|
||||
id: input.projectId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw TRPCNotFoundError(
|
||||
'Could not start import, organization not found',
|
||||
);
|
||||
}
|
||||
|
||||
if (!organization.isActive) {
|
||||
throw TRPCBadRequestError(
|
||||
'You cannot start an import without an active subscription!',
|
||||
);
|
||||
}
|
||||
|
||||
// Create import record
|
||||
const importRecord = await db.import.create({
|
||||
data: {
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -1364,7 +1364,7 @@ importers:
|
||||
packages/sdks/astro:
|
||||
dependencies:
|
||||
'@openpanel/web':
|
||||
specifier: workspace:1.0.1-local
|
||||
specifier: workspace:1.0.2-local
|
||||
version: link:../web
|
||||
devDependencies:
|
||||
astro:
|
||||
@@ -1402,10 +1402,10 @@ importers:
|
||||
packages/sdks/nextjs:
|
||||
dependencies:
|
||||
'@openpanel/web':
|
||||
specifier: workspace:1.0.1-local
|
||||
specifier: workspace:1.0.2-local
|
||||
version: link:../web
|
||||
next:
|
||||
specifier: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0
|
||||
specifier: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
|
||||
version: 15.0.3(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
react:
|
||||
specifier: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
@@ -30999,7 +30999,7 @@ snapshots:
|
||||
|
||||
postcss@8.4.31:
|
||||
dependencies:
|
||||
nanoid: 3.3.7
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user