fix: remove free tier
This commit is contained in:
@@ -21,6 +21,7 @@ import type { LucideIcon } from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import type { IServiceDashboards, IServiceOrganization } from '@openpanel/db';
|
||||
import { differenceInDays, format } from 'date-fns';
|
||||
@@ -72,10 +73,29 @@ export default function LayoutMenu({
|
||||
subscriptionEndsAt,
|
||||
subscriptionPeriodEventsCount,
|
||||
subscriptionPeriodEventsLimit,
|
||||
subscriptionProductId,
|
||||
} = organization;
|
||||
return (
|
||||
<>
|
||||
<div className="col border rounded mb-2 divide-y">
|
||||
{(subscriptionProductId === '036efa2a-b3b4-4c75-b24a-9cac6bb8893b' ||
|
||||
subscriptionProductId === 'a18b4bee-d3db-4404-be6f-fba2f042d9ed') && (
|
||||
<ProjectLink
|
||||
href={'/settings/organization?tab=billing'}
|
||||
className={cn(
|
||||
'rounded p-2 row items-center gap-2 hover:bg-def-200 text-destructive',
|
||||
)}
|
||||
>
|
||||
<BanknoteIcon size={20} />
|
||||
<div className="flex-1 col gap-1">
|
||||
<div className="font-medium">Free plan is removed</div>
|
||||
<div className="text-sm opacity-80">
|
||||
We've removed the free plan. You can upgrade to a paid plan to
|
||||
continue using OpenPanel.
|
||||
</div>
|
||||
</div>
|
||||
</ProjectLink>
|
||||
)}
|
||||
{process.env.SELF_HOSTED && (
|
||||
<ProjectLink
|
||||
href={'/settings/organization?tab=billing'}
|
||||
|
||||
@@ -10,9 +10,13 @@ import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
|
||||
const questions = [
|
||||
{
|
||||
question: "What's the free tier?",
|
||||
question: 'Does OpenPanel have a free tier?',
|
||||
answer: [
|
||||
'You get 5000 events per month for free. This is mostly for you to try out OpenPanel but also for solo developers or people who want to try out OpenPanel without committing to a paid plan.',
|
||||
'For our Cloud plan we offer a 14 days free trial, this is mostly for you to be able to try out OpenPanel before committing to a paid plan.',
|
||||
'OpenPanel is also open-source and you can self-host it for free!',
|
||||
'',
|
||||
'Why does OpenPanel not have a free tier?',
|
||||
'We want to make sure that OpenPanel is used by people who are serious about using it. We also need to invest time and resources to maintain the platform and provide support to our users.',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -47,9 +47,9 @@ export default function Billing({ organization }: Props) {
|
||||
);
|
||||
|
||||
const products = useMemo(() => {
|
||||
return (productsQuery.data || []).filter(
|
||||
(product) => product.recurringInterval === recurringInterval,
|
||||
);
|
||||
return (productsQuery.data || [])
|
||||
.filter((product) => product.recurringInterval === recurringInterval)
|
||||
.filter((product) => product.prices.some((p) => p.amountType !== 'free'));
|
||||
}, [productsQuery.data, recurringInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,19 +77,22 @@ export default function Billing({ organization }: Props) {
|
||||
}
|
||||
return (
|
||||
<WidgetTable
|
||||
className="w-full max-w-full [&_td]:text-left"
|
||||
className="w-full max-w-full [&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
|
||||
columnClassName="!h-auto"
|
||||
data={products}
|
||||
keyExtractor={(item) => item.id}
|
||||
columns={[
|
||||
{
|
||||
name: 'Tier',
|
||||
className: 'text-left',
|
||||
width: 'auto',
|
||||
render(item) {
|
||||
return <div className="font-medium">{item.name}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Price',
|
||||
width: 'auto',
|
||||
render(item) {
|
||||
const price = item.prices[0];
|
||||
if (!price) {
|
||||
@@ -97,20 +100,21 @@ export default function Billing({ organization }: Props) {
|
||||
}
|
||||
|
||||
if (price.amountType === 'free') {
|
||||
return (
|
||||
<div className="row gap-2 whitespace-nowrap">
|
||||
<div className="items-center text-right justify-end gap-4 flex-1 row">
|
||||
<span>Free</span>
|
||||
<CheckoutButton
|
||||
disabled={item.disabled}
|
||||
key={price.id}
|
||||
price={price}
|
||||
organization={organization}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
// return (
|
||||
// <div className="row gap-2 whitespace-nowrap">
|
||||
// <div className="items-center text-right justify-end gap-4 flex-1 row">
|
||||
// <span>Free</span>
|
||||
// <CheckoutButton
|
||||
// disabled={item.disabled}
|
||||
// key={price.id}
|
||||
// price={price}
|
||||
// organization={organization}
|
||||
// projectId={projectId}
|
||||
// />
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
}
|
||||
|
||||
if (price.amountType !== 'fixed') {
|
||||
|
||||
@@ -107,6 +107,15 @@ export default function CurrentSubscription({ organization }: Props) {
|
||||
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>
|
||||
|
||||
@@ -114,10 +114,9 @@ export default function Usage({ organization }: Props) {
|
||||
subscriptionPeriodEventsLimit === 0
|
||||
? '👀'
|
||||
: number.formatWithUnit(
|
||||
(1 -
|
||||
1 -
|
||||
subscriptionPeriodEventsCount /
|
||||
subscriptionPeriodEventsLimit) *
|
||||
100,
|
||||
subscriptionPeriodEventsLimit,
|
||||
'%',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,11 +24,11 @@ export const OverviewWidgetTable = <T,>({
|
||||
<WidgetTable
|
||||
data={data ?? []}
|
||||
keyExtractor={keyExtractor}
|
||||
className={'text-sm min-h-[358px] @container'}
|
||||
columnClassName="group/row [&>*:first-child]:pl-4 [&>*:last-child]:pr-4 [&_th]:pt-3"
|
||||
className={'text-sm min-h-[358px] @container [&_.head]:pt-3'}
|
||||
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
|
||||
eachRow={(item) => {
|
||||
return (
|
||||
<div className="absolute inset-0 !p-0">
|
||||
<div className="absolute top-0 left-0 !p-0 w-full h-full">
|
||||
<div
|
||||
className="h-full bg-def-200 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors relative"
|
||||
style={{
|
||||
@@ -44,7 +44,7 @@ export const OverviewWidgetTable = <T,>({
|
||||
className: cn(
|
||||
index === 0
|
||||
? 'text-left w-full font-medium min-w-0'
|
||||
: 'text-right w-20 font-mono',
|
||||
: 'text-right font-mono',
|
||||
index !== 0 &&
|
||||
index !== columns.length - 1 &&
|
||||
'hidden @[310px]:table-cell',
|
||||
@@ -72,10 +72,12 @@ export function OverviewWidgetTableLoading({
|
||||
{
|
||||
name: 'Path',
|
||||
render: () => <Skeleton className="h-4 w-1/3" />,
|
||||
width: '1fr',
|
||||
},
|
||||
{
|
||||
name: 'BR',
|
||||
render: () => <Skeleton className="h-4 w-[30px]" />,
|
||||
width: '60px',
|
||||
},
|
||||
// {
|
||||
// name: 'Duration',
|
||||
@@ -84,6 +86,7 @@ export function OverviewWidgetTableLoading({
|
||||
{
|
||||
name: 'Sessions',
|
||||
render: () => <Skeleton className="h-4 w-[30px]" />,
|
||||
width: '84px',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -131,6 +134,7 @@ export function OverviewWidgetTablePages({
|
||||
columns={[
|
||||
{
|
||||
name: 'Path',
|
||||
width: '1fr',
|
||||
render(item) {
|
||||
return (
|
||||
<Tooltiper asChild content={item.origin + item.path} side="left">
|
||||
@@ -173,20 +177,21 @@ export function OverviewWidgetTablePages({
|
||||
},
|
||||
{
|
||||
name: 'BR',
|
||||
className: 'w-16',
|
||||
width: '60px',
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.bounce_rate, '%');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Duration',
|
||||
width: '75px',
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.avg_duration, 'min');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: lastColumnName,
|
||||
// className: 'w-28',
|
||||
width: '84px',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
@@ -228,6 +233,7 @@ export function OverviewWidgetTableBots({
|
||||
columns={[
|
||||
{
|
||||
name: 'Path',
|
||||
width: '1fr',
|
||||
render(item) {
|
||||
return (
|
||||
<Tooltiper asChild content={item.origin + item.path} side="left">
|
||||
@@ -256,7 +262,7 @@ export function OverviewWidgetTableBots({
|
||||
},
|
||||
{
|
||||
name: 'Bot',
|
||||
// className: 'w-28',
|
||||
width: '60px',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
@@ -267,7 +273,7 @@ export function OverviewWidgetTableBots({
|
||||
},
|
||||
{
|
||||
name: 'Date',
|
||||
// className: 'w-28',
|
||||
width: '60px',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
@@ -290,6 +296,7 @@ export function OverviewWidgetTableGeneric({
|
||||
data: RouterOutputs['overview']['topGeneric'];
|
||||
column: {
|
||||
name: string;
|
||||
width: string;
|
||||
render: (
|
||||
item: RouterOutputs['overview']['topGeneric'][number],
|
||||
) => React.ReactNode;
|
||||
@@ -307,7 +314,7 @@ export function OverviewWidgetTableGeneric({
|
||||
column,
|
||||
{
|
||||
name: 'BR',
|
||||
className: 'w-16',
|
||||
width: '60px',
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.bounce_rate, '%');
|
||||
},
|
||||
@@ -320,6 +327,7 @@ export function OverviewWidgetTableGeneric({
|
||||
// },
|
||||
{
|
||||
name: 'Sessions',
|
||||
width: '84px',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
|
||||
@@ -212,12 +212,14 @@ export function Tables({
|
||||
<span className="truncate">{item.event.displayName}</span>
|
||||
</div>
|
||||
),
|
||||
width: '1fr',
|
||||
className: 'text-left font-mono font-semibold',
|
||||
},
|
||||
{
|
||||
name: 'Completed',
|
||||
render: (item) => number.format(item.count),
|
||||
className: 'text-right font-mono',
|
||||
width: '82px',
|
||||
},
|
||||
{
|
||||
name: 'Dropped after',
|
||||
@@ -226,11 +228,13 @@ export function Tables({
|
||||
? number.format(item.dropoffCount)
|
||||
: null,
|
||||
className: 'text-right font-mono',
|
||||
width: '110px',
|
||||
},
|
||||
{
|
||||
name: 'Conversion',
|
||||
render: (item) => number.formatWithUnit(item.percent / 100, '%'),
|
||||
className: 'text-right font-mono font-semibold',
|
||||
width: '90px',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface Props<T> {
|
||||
name: React.ReactNode;
|
||||
render: (item: T, index: number) => React.ReactNode;
|
||||
className?: string;
|
||||
width: string;
|
||||
}[];
|
||||
keyExtractor: (item: T) => string;
|
||||
data: T[];
|
||||
@@ -40,58 +41,71 @@ export function WidgetTable<T>({
|
||||
eachRow,
|
||||
columnClassName,
|
||||
}: Props<T>) {
|
||||
const gridTemplateColumns =
|
||||
columns.length > 1
|
||||
? `1fr ${columns
|
||||
.slice(1)
|
||||
.map(() => 'auto')
|
||||
.join(' ')}`
|
||||
: '1fr';
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div className={cn('w-full', className)}>
|
||||
<table className="w-full table-fixed">
|
||||
<thead>
|
||||
<tr
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn('grid border-b border-border head', columnClassName)}
|
||||
style={{ gridTemplateColumns }}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<div
|
||||
key={column.name?.toString()}
|
||||
className={cn(
|
||||
'border-b border-border text-right last:border-0 [&_td:first-child]:text-left',
|
||||
'[&>td]:p-2',
|
||||
'p-2 font-medium font-sans text-sm whitespace-nowrap cell',
|
||||
columns.length > 1 && column !== columns[0]
|
||||
? 'text-right'
|
||||
: 'text-left',
|
||||
column.className,
|
||||
)}
|
||||
style={{ width: column.width }}
|
||||
>
|
||||
{column.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-col body">
|
||||
{data.map((item, index) => (
|
||||
<div
|
||||
key={keyExtractor(item)}
|
||||
className={cn(
|
||||
'group/row relative border-b border-border last:border-0 h-8',
|
||||
columnClassName,
|
||||
)}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.name?.toString()}
|
||||
className={cn(
|
||||
column.className,
|
||||
'font-medium font-sans text-sm p-2 whitespace-nowrap',
|
||||
)}
|
||||
>
|
||||
{column.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item, index) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
className={cn(
|
||||
'h-8 border-b border-border text-right last:border-0 [&_td:first-child]:text-left relative',
|
||||
'[&>td]:p-2',
|
||||
columnClassName,
|
||||
)}
|
||||
>
|
||||
{columns.map((column, columnIndex) => (
|
||||
<td
|
||||
{eachRow?.(item, index)}
|
||||
<div className="grid" style={{ gridTemplateColumns }}>
|
||||
{columns.map((column) => (
|
||||
<div
|
||||
key={column.name?.toString()}
|
||||
className={cn(
|
||||
'h-8',
|
||||
columnIndex !== 0 && 'relative z-5',
|
||||
'p-2 relative cell',
|
||||
columns.length > 1 && column !== columns[0]
|
||||
? 'text-right'
|
||||
: 'text-left',
|
||||
column.className,
|
||||
column.width === '1fr' && 'w-full min-w-0',
|
||||
)}
|
||||
style={{ width: column.width }}
|
||||
>
|
||||
{columnIndex === 0 && eachRow?.(item, index)}
|
||||
{column.render(item, index)}
|
||||
</td>
|
||||
</div>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user