feat: add subscription selection page
This commit is contained in:
parent
fffc989336
commit
843a581d42
166
src/client/components/billing/SubscriptionSelection.tsx
Normal file
166
src/client/components/billing/SubscriptionSelection.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from '@i18next-toolkit/react';
|
||||||
|
import { useEvent } from '@/hooks/useEvent';
|
||||||
|
import { defaultErrorHandler, trpc } from '@/api/trpc';
|
||||||
|
import { useCurrentWorkspaceId } from '@/store/user';
|
||||||
|
import { cn } from '@/utils/style';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
|
||||||
|
import { LuInfo } from 'react-icons/lu';
|
||||||
|
|
||||||
|
interface SubscriptionSelectionProps {
|
||||||
|
tier: 'FREE' | 'PRO' | 'TEAM' | 'UNLIMITED' | undefined;
|
||||||
|
}
|
||||||
|
export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> =
|
||||||
|
React.memo((props) => {
|
||||||
|
const { tier } = props;
|
||||||
|
const workspaceId = useCurrentWorkspaceId();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const checkoutMutation = trpc.billing.checkout.useMutation({
|
||||||
|
onError: defaultErrorHandler,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCheckoutSubscribe = useEvent(
|
||||||
|
async (tier: 'free' | 'pro' | 'team') => {
|
||||||
|
const { url } = await checkoutMutation.mutateAsync({
|
||||||
|
workspaceId,
|
||||||
|
tier,
|
||||||
|
redirectUrl: location.href,
|
||||||
|
});
|
||||||
|
|
||||||
|
location.href = url;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const plans = [
|
||||||
|
{
|
||||||
|
id: 'FREE',
|
||||||
|
name: t('Free'),
|
||||||
|
price: 0,
|
||||||
|
features: [
|
||||||
|
t('Basic trial'),
|
||||||
|
t('Basic Usage'),
|
||||||
|
t('Up to 3 websites'),
|
||||||
|
t('Up to 3 surveys'),
|
||||||
|
t('Up to 3 feed channels'),
|
||||||
|
t('100K website events per month'),
|
||||||
|
t('100K monitor execution per month'),
|
||||||
|
t('10K feed event per month'),
|
||||||
|
t('Discord Community Support'),
|
||||||
|
],
|
||||||
|
onClick: () => handleCheckoutSubscribe('free'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'PRO',
|
||||||
|
name: 'Pro',
|
||||||
|
price: 19.99,
|
||||||
|
features: [
|
||||||
|
t('Sufficient for most situations'),
|
||||||
|
t('Priority access to advanced features'),
|
||||||
|
t('Up to 10 websites'),
|
||||||
|
t('Up to 20 surveys'),
|
||||||
|
t('Up to 20 feed channels'),
|
||||||
|
t('1M website events per month'),
|
||||||
|
t('1M monitor execution per month'),
|
||||||
|
t('100K feed events per month'),
|
||||||
|
t('Discord Community Support'),
|
||||||
|
],
|
||||||
|
onClick: () => handleCheckoutSubscribe('pro'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'TEAM',
|
||||||
|
name: 'Team',
|
||||||
|
price: 99.99,
|
||||||
|
features: [
|
||||||
|
t('Fully sufficient'),
|
||||||
|
t('Priority access to advanced features'),
|
||||||
|
t('Unlimited websites'),
|
||||||
|
t('Unlimited surveys'),
|
||||||
|
t('Unlimited feed channels'),
|
||||||
|
t('20M website events per month'),
|
||||||
|
t('20M monitor execution per month'),
|
||||||
|
t('1M feed events per month'),
|
||||||
|
t('Priority email support'),
|
||||||
|
],
|
||||||
|
onClick: () => handleCheckoutSubscribe('team'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="mb-8 text-center text-3xl font-bold">
|
||||||
|
{t('Subscription Plan')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<Alert className="mb-4">
|
||||||
|
<LuInfo className="h-4 w-4" />
|
||||||
|
<AlertTitle>{t('Current Plan')}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t('Your Current Plan is:')}{' '}
|
||||||
|
<span className="font-bold">{tier}</span>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
{plans.map((plan) => {
|
||||||
|
const isCurrent = plan.id === tier;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={plan.name}
|
||||||
|
className={cn('flex flex-col', isCurrent && 'border-primary')}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{plan.name}</CardTitle>
|
||||||
|
<CardDescription>${plan.price} per month</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-grow">
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{plan.features.map((feature) => (
|
||||||
|
<li key={feature} className="flex items-center">
|
||||||
|
<Check className="mr-2 h-4 w-4 text-green-500" />
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
{isCurrent ? (
|
||||||
|
<Button className="w-full" disabled variant="outline">
|
||||||
|
{t('Current')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={checkoutMutation.isLoading}
|
||||||
|
onClick={plan.onClick}
|
||||||
|
>
|
||||||
|
{t('{{action}} to {{plan}}', {
|
||||||
|
action:
|
||||||
|
plans.indexOf(plan) <
|
||||||
|
plans.findIndex((p) => p.id === tier)
|
||||||
|
? t('Downgrade')
|
||||||
|
: t('Upgrade'),
|
||||||
|
plan: plan.name,
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
SubscriptionSelection.displayName = 'SubscriptionSelection';
|
@ -22,6 +22,9 @@ export function useGlobalConfig(): AppRouterOutput['global']['config'] {
|
|||||||
{
|
{
|
||||||
staleTime: 1000 * 60 * 60 * 1, // 1 hour
|
staleTime: 1000 * 60 * 60 * 1, // 1 hour
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
/**
|
||||||
|
* Call anonymous telemetry if not disabled
|
||||||
|
*/
|
||||||
if (data.disableAnonymousTelemetry !== true) {
|
if (data.disableAnonymousTelemetry !== true) {
|
||||||
callAnonymousTelemetry();
|
callAnonymousTelemetry();
|
||||||
}
|
}
|
||||||
|
@ -34,6 +34,7 @@ import { Route as SettingsWorkspaceImport } from './routes/settings/workspace'
|
|||||||
import { Route as SettingsUsageImport } from './routes/settings/usage'
|
import { Route as SettingsUsageImport } from './routes/settings/usage'
|
||||||
import { Route as SettingsProfileImport } from './routes/settings/profile'
|
import { Route as SettingsProfileImport } from './routes/settings/profile'
|
||||||
import { Route as SettingsNotificationsImport } from './routes/settings/notifications'
|
import { Route as SettingsNotificationsImport } from './routes/settings/notifications'
|
||||||
|
import { Route as SettingsBillingImport } from './routes/settings/billing'
|
||||||
import { Route as SettingsAuditLogImport } from './routes/settings/auditLog'
|
import { Route as SettingsAuditLogImport } from './routes/settings/auditLog'
|
||||||
import { Route as SettingsApiKeyImport } from './routes/settings/apiKey'
|
import { Route as SettingsApiKeyImport } from './routes/settings/apiKey'
|
||||||
import { Route as PageAddImport } from './routes/page/add'
|
import { Route as PageAddImport } from './routes/page/add'
|
||||||
@ -167,6 +168,11 @@ const SettingsNotificationsRoute = SettingsNotificationsImport.update({
|
|||||||
getParentRoute: () => SettingsRoute,
|
getParentRoute: () => SettingsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
|
const SettingsBillingRoute = SettingsBillingImport.update({
|
||||||
|
path: '/billing',
|
||||||
|
getParentRoute: () => SettingsRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
const SettingsAuditLogRoute = SettingsAuditLogImport.update({
|
const SettingsAuditLogRoute = SettingsAuditLogImport.update({
|
||||||
path: '/auditLog',
|
path: '/auditLog',
|
||||||
getParentRoute: () => SettingsRoute,
|
getParentRoute: () => SettingsRoute,
|
||||||
@ -326,6 +332,10 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SettingsAuditLogImport
|
preLoaderRoute: typeof SettingsAuditLogImport
|
||||||
parentRoute: typeof SettingsImport
|
parentRoute: typeof SettingsImport
|
||||||
}
|
}
|
||||||
|
'/settings/billing': {
|
||||||
|
preLoaderRoute: typeof SettingsBillingImport
|
||||||
|
parentRoute: typeof SettingsImport
|
||||||
|
}
|
||||||
'/settings/notifications': {
|
'/settings/notifications': {
|
||||||
preLoaderRoute: typeof SettingsNotificationsImport
|
preLoaderRoute: typeof SettingsNotificationsImport
|
||||||
parentRoute: typeof SettingsImport
|
parentRoute: typeof SettingsImport
|
||||||
@ -423,6 +433,7 @@ export const routeTree = rootRoute.addChildren([
|
|||||||
SettingsRoute.addChildren([
|
SettingsRoute.addChildren([
|
||||||
SettingsApiKeyRoute,
|
SettingsApiKeyRoute,
|
||||||
SettingsAuditLogRoute,
|
SettingsAuditLogRoute,
|
||||||
|
SettingsBillingRoute,
|
||||||
SettingsNotificationsRoute,
|
SettingsNotificationsRoute,
|
||||||
SettingsProfileRoute,
|
SettingsProfileRoute,
|
||||||
SettingsUsageRoute,
|
SettingsUsageRoute,
|
||||||
|
@ -2,6 +2,7 @@ import { CommonHeader } from '@/components/CommonHeader';
|
|||||||
import { CommonList } from '@/components/CommonList';
|
import { CommonList } from '@/components/CommonList';
|
||||||
import { CommonWrapper } from '@/components/CommonWrapper';
|
import { CommonWrapper } from '@/components/CommonWrapper';
|
||||||
import { Layout } from '@/components/layout';
|
import { Layout } from '@/components/layout';
|
||||||
|
import { useGlobalConfig } from '@/hooks/useConfig';
|
||||||
import { routeAuthBeforeLoad } from '@/utils/route';
|
import { routeAuthBeforeLoad } from '@/utils/route';
|
||||||
import { useTranslation } from '@i18next-toolkit/react';
|
import { useTranslation } from '@i18next-toolkit/react';
|
||||||
import {
|
import {
|
||||||
@ -9,6 +10,7 @@ import {
|
|||||||
useNavigate,
|
useNavigate,
|
||||||
useRouterState,
|
useRouterState,
|
||||||
} from '@tanstack/react-router';
|
} from '@tanstack/react-router';
|
||||||
|
import { compact } from 'lodash-es';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export const Route = createFileRoute('/settings')({
|
export const Route = createFileRoute('/settings')({
|
||||||
@ -22,8 +24,9 @@ function PageComponent() {
|
|||||||
const pathname = useRouterState({
|
const pathname = useRouterState({
|
||||||
select: (state) => state.location.pathname,
|
select: (state) => state.location.pathname,
|
||||||
});
|
});
|
||||||
|
const { enableBilling } = useGlobalConfig();
|
||||||
|
|
||||||
const items = [
|
const items = compact([
|
||||||
{
|
{
|
||||||
id: 'profile',
|
id: 'profile',
|
||||||
title: t('Profile'),
|
title: t('Profile'),
|
||||||
@ -54,7 +57,12 @@ function PageComponent() {
|
|||||||
title: t('Usage'),
|
title: t('Usage'),
|
||||||
href: '/settings/usage',
|
href: '/settings/usage',
|
||||||
},
|
},
|
||||||
];
|
enableBilling && {
|
||||||
|
id: 'billing',
|
||||||
|
title: t('Billing'),
|
||||||
|
href: '/settings/billing',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pathname === Route.fullPath) {
|
if (pathname === Route.fullPath) {
|
||||||
|
124
src/client/routes/settings/billing.tsx
Normal file
124
src/client/routes/settings/billing.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { routeAuthBeforeLoad } from '@/utils/route';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from '@i18next-toolkit/react';
|
||||||
|
import { CommonWrapper } from '@/components/CommonWrapper';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
defaultErrorHandler,
|
||||||
|
defaultSuccessHandler,
|
||||||
|
trpc,
|
||||||
|
} from '../../api/trpc';
|
||||||
|
import { useCurrentWorkspace, useCurrentWorkspaceId } from '../../store/user';
|
||||||
|
import { CommonHeader } from '@/components/CommonHeader';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { formatNumber } from '@/utils/common';
|
||||||
|
import { UsageCard } from '@/components/UsageCard';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { useEvent } from '@/hooks/useEvent';
|
||||||
|
import { SubscriptionSelection } from '@/components/billing/SubscriptionSelection';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/settings/billing')({
|
||||||
|
beforeLoad: routeAuthBeforeLoad,
|
||||||
|
component: PageComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function PageComponent() {
|
||||||
|
const workspaceId = useCurrentWorkspaceId();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: currentTier } = trpc.billing.currentTier.useQuery({
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
const checkoutMutation = trpc.billing.checkout.useMutation({
|
||||||
|
onError: defaultErrorHandler,
|
||||||
|
});
|
||||||
|
const changePlanMutation = trpc.billing.changePlan.useMutation({
|
||||||
|
onSuccess: defaultSuccessHandler,
|
||||||
|
onError: defaultErrorHandler,
|
||||||
|
});
|
||||||
|
const cancelSubscriptionMutation =
|
||||||
|
trpc.billing.cancelSubscription.useMutation({
|
||||||
|
onSuccess: defaultSuccessHandler,
|
||||||
|
onError: defaultErrorHandler,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, refetch, isInitialLoading, isLoading } =
|
||||||
|
trpc.billing.currentSubscription.useQuery({
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChangeSubscribe = useEvent(
|
||||||
|
async (tier: 'free' | 'pro' | 'team') => {
|
||||||
|
await changePlanMutation.mutateAsync({
|
||||||
|
workspaceId,
|
||||||
|
tier,
|
||||||
|
});
|
||||||
|
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const plan = data ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
loading={changePlanMutation.isLoading}
|
||||||
|
onClick={() => handleChangeSubscribe('free')}
|
||||||
|
>
|
||||||
|
Change plan to Free
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
loading={changePlanMutation.isLoading}
|
||||||
|
onClick={() => handleChangeSubscribe('pro')}
|
||||||
|
>
|
||||||
|
Change plan to Pro
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
loading={changePlanMutation.isLoading}
|
||||||
|
onClick={() => handleChangeSubscribe('team')}
|
||||||
|
>
|
||||||
|
Change plan to Team
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
loading={cancelSubscriptionMutation.isLoading}
|
||||||
|
onClick={() =>
|
||||||
|
cancelSubscriptionMutation.mutateAsync({
|
||||||
|
workspaceId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<SubscriptionSelection tier={currentTier} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommonWrapper header={<CommonHeader title={t('Billing')} />}>
|
||||||
|
<ScrollArea className="h-full overflow-hidden p-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<div>Current: {JSON.stringify(data)}</div>
|
||||||
|
|
||||||
|
<Button loading={isLoading} onClick={() => refetch()}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-2" />
|
||||||
|
|
||||||
|
{isInitialLoading === false && plan}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CommonWrapper>
|
||||||
|
);
|
||||||
|
}
|
@ -21,6 +21,7 @@ import {
|
|||||||
getWorkspaceUsage,
|
getWorkspaceUsage,
|
||||||
} from '../../model/billing/workspace.js';
|
} from '../../model/billing/workspace.js';
|
||||||
import { getTierLimit, TierLimitSchema } from '../../model/billing/limit.js';
|
import { getTierLimit, TierLimitSchema } from '../../model/billing/limit.js';
|
||||||
|
import { WorkspaceSubscriptionTier } from '@prisma/client';
|
||||||
|
|
||||||
export const billingRouter = router({
|
export const billingRouter = router({
|
||||||
usage: workspaceProcedure
|
usage: workspaceProcedure
|
||||||
@ -66,6 +67,20 @@ export const billingRouter = router({
|
|||||||
|
|
||||||
return getTierLimit(tier);
|
return getTierLimit(tier);
|
||||||
}),
|
}),
|
||||||
|
currentTier: workspaceProcedure
|
||||||
|
.meta(
|
||||||
|
buildBillingOpenapi({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/currentTier',
|
||||||
|
description: 'get workspace current tier',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.output(z.nativeEnum(WorkspaceSubscriptionTier))
|
||||||
|
.query(({ input }) => {
|
||||||
|
const { workspaceId } = input;
|
||||||
|
|
||||||
|
return getWorkspaceSubscription(workspaceId);
|
||||||
|
}),
|
||||||
currentSubscription: workspaceProcedure
|
currentSubscription: workspaceProcedure
|
||||||
.meta(
|
.meta(
|
||||||
buildBillingOpenapi({
|
buildBillingOpenapi({
|
||||||
@ -99,7 +114,7 @@ export const billingRouter = router({
|
|||||||
checkout: workspaceOwnerProcedure
|
checkout: workspaceOwnerProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
tier: z.string(),
|
tier: z.enum(['free', 'pro', 'team']),
|
||||||
redirectUrl: z.string().optional(),
|
redirectUrl: z.string().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@ -114,7 +129,7 @@ export const billingRouter = router({
|
|||||||
const checkout = await createCheckoutBilling(
|
const checkout = await createCheckoutBilling(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
userId,
|
userId,
|
||||||
input.tier as SubscriptionTierType,
|
input.tier,
|
||||||
redirectUrl
|
redirectUrl
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -24,9 +24,10 @@ export const globalRouter = router({
|
|||||||
disableAnonymousTelemetry: z.boolean(),
|
disableAnonymousTelemetry: z.boolean(),
|
||||||
customTrackerScriptName: z.string().optional(),
|
customTrackerScriptName: z.string().optional(),
|
||||||
authProvider: z.array(z.string()),
|
authProvider: z.array(z.string()),
|
||||||
|
enableBilling: z.boolean(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async () => {
|
||||||
return {
|
return {
|
||||||
allowRegister: env.allowRegister,
|
allowRegister: env.allowRegister,
|
||||||
websiteId: env.websiteId,
|
websiteId: env.websiteId,
|
||||||
@ -36,6 +37,7 @@ export const globalRouter = router({
|
|||||||
disableAnonymousTelemetry: env.disableAnonymousTelemetry,
|
disableAnonymousTelemetry: env.disableAnonymousTelemetry,
|
||||||
customTrackerScriptName: env.customTrackerScriptName,
|
customTrackerScriptName: env.customTrackerScriptName,
|
||||||
authProvider: env.auth.provider,
|
authProvider: env.auth.provider,
|
||||||
|
enableBilling: env.billing.enable,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@ -47,7 +47,7 @@ export const env = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
billing: {
|
billing: {
|
||||||
enable: process.env.ENABLE_BILLING,
|
enable: checkEnvTrusty(process.env.ENABLE_BILLING),
|
||||||
lemonSqueezy: {
|
lemonSqueezy: {
|
||||||
signatureSecret: process.env.LEMON_SQUEEZY_SIGNATURE_SECRET ?? '',
|
signatureSecret: process.env.LEMON_SQUEEZY_SIGNATURE_SECRET ?? '',
|
||||||
apiKey: process.env.LEMON_SQUEEZY_API_KEY ?? '',
|
apiKey: process.env.LEMON_SQUEEZY_API_KEY ?? '',
|
||||||
|
Loading…
Reference in New Issue
Block a user