feat: lifetime
This commit is contained in:
parent
c7fc90f86c
commit
57ac90b334
|
|
@ -24,6 +24,7 @@ import { MarketplaceController } from '@gitroom/backend/api/routes/marketplace.c
|
|||
import { MessagesController } from '@gitroom/backend/api/routes/messages.controller';
|
||||
import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service';
|
||||
import { ExtractContentService } from '@gitroom/nestjs-libraries/openai/extract.content.service';
|
||||
import { CodesService } from '@gitroom/nestjs-libraries/services/codes.service';
|
||||
|
||||
const authenticatedController = [
|
||||
UsersController,
|
||||
|
|
@ -66,6 +67,7 @@ const authenticatedController = [
|
|||
AuthMiddleware,
|
||||
PoliciesGuard,
|
||||
PermissionsService,
|
||||
CodesService,
|
||||
IntegrationManager,
|
||||
],
|
||||
get exports() {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service
|
|||
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
|
||||
import { Organization } from '@prisma/client';
|
||||
import { BillingSubscribeDto } from '@gitroom/nestjs-libraries/dtos/billing/billing.subscribe.dto';
|
||||
import {ApiTags} from "@nestjs/swagger";
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('Billing')
|
||||
@Controller('/billing')
|
||||
|
|
@ -58,9 +58,17 @@ export class BillingController {
|
|||
|
||||
@Post('/prorate')
|
||||
prorate(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Body() body: BillingSubscribeDto
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Body() body: BillingSubscribeDto
|
||||
) {
|
||||
return this._stripeService.prorate(org.id, body);
|
||||
}
|
||||
|
||||
@Post('/lifetime')
|
||||
async lifetime(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Body() body: { code: string }
|
||||
) {
|
||||
return this._stripeService.lifetimeDeal(org.id, body.code);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,23 @@
|
|||
import { Controller, Post, RawBodyRequest, Req } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Header,
|
||||
Param,
|
||||
Post,
|
||||
RawBodyRequest,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { CodesService } from '@gitroom/nestjs-libraries/services/codes.service';
|
||||
|
||||
@ApiTags('Stripe')
|
||||
@Controller('/stripe')
|
||||
export class StripeController {
|
||||
constructor(private readonly _stripeService: StripeService) {}
|
||||
constructor(
|
||||
private readonly _stripeService: StripeService,
|
||||
private readonly _codesService: CodesService
|
||||
) {}
|
||||
@Post('/connect')
|
||||
stripeConnect(@Req() req: RawBodyRequest<Request>) {
|
||||
const event = this._stripeService.validateRequest(
|
||||
|
|
@ -61,4 +73,11 @@ export class StripeController {
|
|||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
@Get('/lifetime-deal-codes/:provider')
|
||||
@Header('Content-disposition', 'attachment; filename=codes.csv')
|
||||
@Header('Content-type', 'text/csv')
|
||||
async getStripeCodes(@Param('provider') providerToken: string) {
|
||||
return this._codesService.generateCodes(providerToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ export class UsersController {
|
|||
tier: organization?.subscription?.subscriptionTier || 'FREE',
|
||||
// @ts-ignore
|
||||
role: organization?.users[0]?.role,
|
||||
// @ts-ignore
|
||||
isLifetime: !!organization?.subscription?.isLifetime,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const nextConfig = {
|
|||
},
|
||||
env: {
|
||||
isBillingEnabled: String(!!process.env.STRIPE_PUBLISHABLE_KEY),
|
||||
isGeneral: String(!!process.env.IS_GENERAL),
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { LifetimeDeal } from '@gitroom/frontend/components/billing/lifetime.deal';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Gitroom Lifetime deal',
|
||||
description: '',
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
return <LifetimeDeal />;
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
'use client';
|
||||
|
||||
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
||||
import { useUser } from '@gitroom/frontend/components/layout/user.context';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
|
||||
import { Input } from '@gitroom/react/form/input';
|
||||
import { Button } from '@gitroom/react/form/button';
|
||||
import { useSWRConfig } from 'swr';
|
||||
import { useToaster } from '@gitroom/react/toaster/toaster';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export const LifetimeDeal = () => {
|
||||
const fetch = useFetch();
|
||||
const user = useUser();
|
||||
const [code, setCode] = useState('');
|
||||
const toast = useToaster();
|
||||
const { mutate } = useSWRConfig();
|
||||
const router = useRouter();
|
||||
|
||||
const claim = useCallback(async () => {
|
||||
const { success } = await (
|
||||
await fetch('/billing/lifetime', {
|
||||
body: JSON.stringify({ code }),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
if (success) {
|
||||
mutate('/user/self');
|
||||
toast.show('Successfully claimed the code');
|
||||
} else {
|
||||
toast.show('Code already claimed or invalid code', 'warning');
|
||||
}
|
||||
|
||||
setCode('');
|
||||
}, [code]);
|
||||
|
||||
const nextPackage = useMemo(() => {
|
||||
if (user?.tier?.current === 'STANDARD') {
|
||||
return 'PRO';
|
||||
}
|
||||
|
||||
return 'STANDARD';
|
||||
}, [user?.tier]);
|
||||
|
||||
const features = useMemo(() => {
|
||||
if (!user?.tier) {
|
||||
return [];
|
||||
}
|
||||
const currentPricing = user?.tier;
|
||||
const channelsOr = currentPricing.channel;
|
||||
const list = [];
|
||||
list.push(`${channelsOr} ${channelsOr === 1 ? 'channel' : 'channels'}`);
|
||||
list.push(
|
||||
`${
|
||||
currentPricing.posts_per_month > 10000
|
||||
? 'Unlimited'
|
||||
: currentPricing.posts_per_month
|
||||
} posts per month`
|
||||
);
|
||||
if (currentPricing.team_members) {
|
||||
list.push(`Unlimited team members`);
|
||||
}
|
||||
|
||||
if (currentPricing.import_from_channels) {
|
||||
list.push(`Import content from channels (coming soon)`);
|
||||
}
|
||||
|
||||
if (currentPricing.community_features) {
|
||||
list.push(`Community features (coming soon)`);
|
||||
}
|
||||
|
||||
if (currentPricing.ai) {
|
||||
list.push(`AI auto-complete (coming soon)`);
|
||||
}
|
||||
|
||||
if (currentPricing.featured_by_gitroom) {
|
||||
list.push(`Become featured by Gitroom (coming soon)`);
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [user]);
|
||||
|
||||
const nextFeature = useMemo(() => {
|
||||
if (!user?.tier) {
|
||||
return [];
|
||||
}
|
||||
const currentPricing = pricing[nextPackage];
|
||||
const channelsOr = currentPricing.channel;
|
||||
const list = [];
|
||||
list.push(`${channelsOr} ${channelsOr === 1 ? 'channel' : 'channels'}`);
|
||||
list.push(
|
||||
`${
|
||||
currentPricing.posts_per_month > 10000
|
||||
? 'Unlimited'
|
||||
: currentPricing.posts_per_month
|
||||
} posts per month`
|
||||
);
|
||||
if (currentPricing.team_members) {
|
||||
list.push(`Unlimited team members`);
|
||||
}
|
||||
|
||||
if (currentPricing.import_from_channels) {
|
||||
list.push(`Import content from channels (coming soon)`);
|
||||
}
|
||||
|
||||
if (currentPricing.community_features) {
|
||||
list.push(`Community features (coming soon)`);
|
||||
}
|
||||
|
||||
if (currentPricing.ai) {
|
||||
list.push(`AI auto-complete`);
|
||||
}
|
||||
|
||||
if (currentPricing.featured_by_gitroom) {
|
||||
list.push(`Become featured by Gitroom (coming soon)`);
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [user, nextPackage]);
|
||||
|
||||
if (!user?.tier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (user?.id && user?.tier?.current !== 'FREE' && !user?.isLifetime) {
|
||||
router.replace('/billing');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-[30px]">
|
||||
<div className="border border-[#172034] bg-[#0B101B] p-[24px] flex flex-col gap-[20px] flex-1 rounded-[4px]">
|
||||
<div className="text-[30px]">
|
||||
Current Package: {user?.tier?.current}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-[10px] justify-center text-[16px] text-[#AAA]">
|
||||
{features.map((feature) => (
|
||||
<div key={feature} className="flex gap-[20px]">
|
||||
<div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M16.2806 9.21937C16.3504 9.28903 16.4057 9.37175 16.4434 9.46279C16.4812 9.55384 16.5006 9.65144 16.5006 9.75C16.5006 9.84856 16.4812 9.94616 16.4434 10.0372C16.4057 10.1283 16.3504 10.211 16.2806 10.2806L11.0306 15.5306C10.961 15.6004 10.8783 15.6557 10.7872 15.6934C10.6962 15.7312 10.5986 15.7506 10.5 15.7506C10.4014 15.7506 10.3038 15.7312 10.2128 15.6934C10.1218 15.6557 10.039 15.6004 9.96938 15.5306L7.71938 13.2806C7.57865 13.1399 7.49959 12.949 7.49959 12.75C7.49959 12.551 7.57865 12.3601 7.71938 12.2194C7.86011 12.0786 8.05098 11.9996 8.25 11.9996C8.44903 11.9996 8.6399 12.0786 8.78063 12.2194L10.5 13.9397L15.2194 9.21937C15.289 9.14964 15.3718 9.09432 15.4628 9.05658C15.5538 9.01884 15.6514 8.99941 15.75 8.99941C15.8486 8.99941 15.9462 9.01884 16.0372 9.05658C16.1283 9.09432 16.211 9.14964 16.2806 9.21937ZM21.75 12C21.75 13.9284 21.1782 15.8134 20.1068 17.4168C19.0355 19.0202 17.5127 20.2699 15.7312 21.0078C13.9496 21.7458 11.9892 21.9389 10.0979 21.5627C8.20656 21.1865 6.46928 20.2579 5.10571 18.8943C3.74215 17.5307 2.81355 15.7934 2.43735 13.9021C2.06114 12.0108 2.25422 10.0504 2.99218 8.26884C3.73013 6.48726 4.97982 4.96451 6.58319 3.89317C8.18657 2.82183 10.0716 2.25 12 2.25C14.585 2.25273 17.0634 3.28084 18.8913 5.10872C20.7192 6.93661 21.7473 9.41498 21.75 12ZM20.25 12C20.25 10.3683 19.7661 8.77325 18.8596 7.41655C17.9531 6.05984 16.6646 5.00242 15.1571 4.37799C13.6497 3.75357 11.9909 3.59019 10.3905 3.90852C8.79017 4.22685 7.32016 5.01259 6.16637 6.16637C5.01259 7.32015 4.22685 8.79016 3.90853 10.3905C3.5902 11.9908 3.75358 13.6496 4.378 15.1571C5.00242 16.6646 6.05984 17.9531 7.41655 18.8596C8.77326 19.7661 10.3683 20.25 12 20.25C14.1873 20.2475 16.2843 19.3775 17.8309 17.8309C19.3775 16.2843 20.2475 14.1873 20.25 12Z"
|
||||
fill="#06ff00"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>{feature}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user?.tier?.current !== 'PRO' && (
|
||||
<div className="border border-[#172034] bg-[#0B101B] p-[24px] flex flex-col gap-[20px] flex-1 rounded-[4px]">
|
||||
<div className="text-[30px]">Next Package: {nextPackage}</div>
|
||||
|
||||
<div className="flex flex-col gap-[10px] justify-center text-[16px] text-[#AAA]">
|
||||
{nextFeature.map((feature) => (
|
||||
<div key={feature} className="flex gap-[20px]">
|
||||
<div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M16.2806 9.21937C16.3504 9.28903 16.4057 9.37175 16.4434 9.46279C16.4812 9.55384 16.5006 9.65144 16.5006 9.75C16.5006 9.84856 16.4812 9.94616 16.4434 10.0372C16.4057 10.1283 16.3504 10.211 16.2806 10.2806L11.0306 15.5306C10.961 15.6004 10.8783 15.6557 10.7872 15.6934C10.6962 15.7312 10.5986 15.7506 10.5 15.7506C10.4014 15.7506 10.3038 15.7312 10.2128 15.6934C10.1218 15.6557 10.039 15.6004 9.96938 15.5306L7.71938 13.2806C7.57865 13.1399 7.49959 12.949 7.49959 12.75C7.49959 12.551 7.57865 12.3601 7.71938 12.2194C7.86011 12.0786 8.05098 11.9996 8.25 11.9996C8.44903 11.9996 8.6399 12.0786 8.78063 12.2194L10.5 13.9397L15.2194 9.21937C15.289 9.14964 15.3718 9.09432 15.4628 9.05658C15.5538 9.01884 15.6514 8.99941 15.75 8.99941C15.8486 8.99941 15.9462 9.01884 16.0372 9.05658C16.1283 9.09432 16.211 9.14964 16.2806 9.21937ZM21.75 12C21.75 13.9284 21.1782 15.8134 20.1068 17.4168C19.0355 19.0202 17.5127 20.2699 15.7312 21.0078C13.9496 21.7458 11.9892 21.9389 10.0979 21.5627C8.20656 21.1865 6.46928 20.2579 5.10571 18.8943C3.74215 17.5307 2.81355 15.7934 2.43735 13.9021C2.06114 12.0108 2.25422 10.0504 2.99218 8.26884C3.73013 6.48726 4.97982 4.96451 6.58319 3.89317C8.18657 2.82183 10.0716 2.25 12 2.25C14.585 2.25273 17.0634 3.28084 18.8913 5.10872C20.7192 6.93661 21.7473 9.41498 21.75 12ZM20.25 12C20.25 10.3683 19.7661 8.77325 18.8596 7.41655C17.9531 6.05984 16.6646 5.00242 15.1571 4.37799C13.6497 3.75357 11.9909 3.59019 10.3905 3.90852C8.79017 4.22685 7.32016 5.01259 6.16637 6.16637C5.01259 7.32015 4.22685 8.79016 3.90853 10.3905C3.5902 11.9908 3.75358 13.6496 4.378 15.1571C5.00242 16.6646 6.05984 17.9531 7.41655 18.8596C8.77326 19.7661 10.3683 20.25 12 20.25C14.1873 20.2475 16.2843 19.3775 17.8309 17.8309C19.3775 16.2843 20.2475 14.1873 20.25 12Z"
|
||||
fill="#06ff00"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>{feature}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-[20px] flex items-center gap-[10px]">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Code"
|
||||
placeholder="Enter your code"
|
||||
disableForm={true}
|
||||
name="code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button disabled={code.length < 4} onClick={claim}>
|
||||
Claim
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -18,6 +18,7 @@ import { FAQComponent } from '@gitroom/frontend/components/billing/faq.component
|
|||
import { useSWRConfig } from 'swr';
|
||||
import { useUser } from '@gitroom/frontend/components/layout/user.context';
|
||||
import interClass from '@gitroom/react/helpers/inter.font';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export interface Tiers {
|
||||
month: Array<{
|
||||
|
|
@ -155,6 +156,7 @@ export const MainBillingComponent: FC<{
|
|||
const fetch = useFetch();
|
||||
const toast = useToaster();
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
|
||||
const [subscription, setSubscription] = useState<Subscription | undefined>(
|
||||
sub
|
||||
|
|
@ -307,6 +309,11 @@ export const MainBillingComponent: FC<{
|
|||
[monthlyOrYearly, subscription, user]
|
||||
);
|
||||
|
||||
if (user?.isLifetime) {
|
||||
router.replace('/billing/lifetime');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[16px]">
|
||||
<div className="flex flex-row">
|
||||
|
|
|
|||
|
|
@ -11,12 +11,16 @@ import { UserDetailDto } from '@gitroom/nestjs-libraries/dtos/users/user.details
|
|||
import { useToaster } from '@gitroom/react/toaster/toaster';
|
||||
import { useSWRConfig } from 'swr';
|
||||
import clsx from 'clsx';
|
||||
import { TeamsComponent } from '@gitroom/frontend/components/settings/teams.component';
|
||||
import { isGeneral } from '@gitroom/react/helpers/is.general';
|
||||
import { useUser } from '@gitroom/frontend/components/layout/user.context';
|
||||
|
||||
export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
|
||||
const { getRef } = props;
|
||||
const fetch = useFetch();
|
||||
const toast = useToaster();
|
||||
const swr = useSWRConfig();
|
||||
const user = useUser();
|
||||
|
||||
const resolver = useMemo(() => {
|
||||
return classValidatorResolver(UserDetailDto);
|
||||
|
|
@ -52,7 +56,7 @@ export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
|
|||
});
|
||||
|
||||
if (getRef) {
|
||||
return ;
|
||||
return;
|
||||
}
|
||||
|
||||
toast.show('Profile updated');
|
||||
|
|
@ -67,7 +71,9 @@ export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
|
|||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(submit)}>
|
||||
{!!getRef && <button type="submit" className="hidden" ref={getRef}></button>}
|
||||
{!!getRef && (
|
||||
<button type="submit" className="hidden" ref={getRef}></button>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'w-full max-w-[920px] mx-auto bg-[#0B101B] gap-[24px] flex flex-col relative',
|
||||
|
|
@ -180,6 +186,7 @@ export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
|
|||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
)}
|
||||
{!!user?.tier?.team_members && isGeneral() && <TeamsComponent />}
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
|
|
|||
|
|
@ -5,24 +5,35 @@ import Link from 'next/link';
|
|||
import clsx from 'clsx';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUser } from '@gitroom/frontend/components/layout/user.context';
|
||||
import { isGeneral } from '@gitroom/react/helpers/is.general';
|
||||
|
||||
const general = isGeneral();
|
||||
|
||||
export const menuItems = [
|
||||
{
|
||||
name: 'Analytics',
|
||||
icon: 'analytics',
|
||||
path: '/analytics',
|
||||
},
|
||||
...(!general
|
||||
? [
|
||||
{
|
||||
name: 'Analytics',
|
||||
icon: 'analytics',
|
||||
path: '/analytics',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Launches',
|
||||
icon: 'launches',
|
||||
path: '/launches',
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
icon: 'settings',
|
||||
path: '/settings',
|
||||
role: ['ADMIN', 'SUPERADMIN'],
|
||||
},
|
||||
...(!general
|
||||
? [
|
||||
{
|
||||
name: 'Settings',
|
||||
icon: 'settings',
|
||||
path: '/settings',
|
||||
role: ['ADMIN', 'SUPERADMIN'],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Marketplace',
|
||||
icon: 'marketplace',
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const UserContext = createContext<
|
|||
tier: PricingInnerInterface;
|
||||
role: 'USER' | 'ADMIN' | 'SUPERADMIN';
|
||||
totalChannels: number;
|
||||
isLifetime?: boolean;
|
||||
})
|
||||
>(undefined);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ import { GithubOnboarding } from '@gitroom/frontend/components/onboarding/github
|
|||
import { SettingsPopup } from '@gitroom/frontend/components/layout/settings.component';
|
||||
import { Button } from '@gitroom/react/form/button';
|
||||
import { ConnectChannels } from '@gitroom/frontend/components/onboarding/connect.channels';
|
||||
import { isGeneral } from '@gitroom/react/helpers/is.general';
|
||||
|
||||
export const Step: FC<{ step: number; title: string; currentStep: number, lastStep: number }> = (
|
||||
props
|
||||
) => {
|
||||
export const Step: FC<{
|
||||
step: number;
|
||||
title: string;
|
||||
currentStep: number;
|
||||
lastStep: number;
|
||||
}> = (props) => {
|
||||
const { step, title, currentStep, lastStep } = props;
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
|
|
@ -87,40 +91,51 @@ const SkipOnboarding: FC = () => {
|
|||
const onSkip = useCallback(() => {
|
||||
const keys = Array.from(searchParams.keys());
|
||||
|
||||
const buildNewQuery = keys.reduce((all, current) => {
|
||||
if (current === 'onboarding') {
|
||||
const buildNewQuery = keys
|
||||
.reduce((all, current) => {
|
||||
if (current === 'onboarding') {
|
||||
return all;
|
||||
}
|
||||
const value = searchParams.get(current);
|
||||
all.push(`${current}=${value}`);
|
||||
return all;
|
||||
}
|
||||
const value = searchParams.get(current);
|
||||
all.push(`${current}=${value}`);
|
||||
return all;
|
||||
}, [] as string[]).join('&');
|
||||
}, [] as string[])
|
||||
.join('&');
|
||||
router.push(`?${buildNewQuery}`);
|
||||
}, [searchParams]);
|
||||
return (
|
||||
<Button secondary={true} className="border-[2px] border-[#506490]" onClick={onSkip}>Skip onboarding</Button>
|
||||
<Button
|
||||
secondary={true}
|
||||
className="border-[2px] border-[#506490]"
|
||||
onClick={onSkip}
|
||||
>
|
||||
Skip onboarding
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
};
|
||||
const Welcome: FC = () => {
|
||||
const [step, setStep] = useState(1);
|
||||
const ref = useRef();
|
||||
const router = useRouter();
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
setStep(step + 1);
|
||||
}, [step]);
|
||||
const nextStep = useCallback(
|
||||
(stepIt?: number) => () => {
|
||||
setStep(stepIt ? stepIt : step + 1);
|
||||
},
|
||||
[step]
|
||||
);
|
||||
|
||||
const firstNext = useCallback(() => {
|
||||
// @ts-ignore
|
||||
ref?.current?.click();
|
||||
nextStep();
|
||||
nextStep(isGeneral() ? 3 : 2)();
|
||||
}, [nextStep]);
|
||||
|
||||
const goToAnalytics = useCallback(() => {
|
||||
router.push('/analytics');
|
||||
}, []);
|
||||
|
||||
const goToLaunches = useCallback(() => {
|
||||
const goToLaunches = useCallback(() => {
|
||||
router.push('/launches');
|
||||
}, []);
|
||||
|
||||
|
|
@ -130,9 +145,23 @@ const Welcome: FC = () => {
|
|||
<div className="flex">
|
||||
<Step title="Profile" step={1} currentStep={step} lastStep={4} />
|
||||
<StepSpace />
|
||||
<Step title="Connect Github" step={2} currentStep={step} lastStep={4} />
|
||||
<StepSpace />
|
||||
<Step title="Connect Channels" step={3} currentStep={step} lastStep={4} />
|
||||
{!isGeneral() && (
|
||||
<>
|
||||
<Step
|
||||
title="Connect Github"
|
||||
step={2}
|
||||
currentStep={step}
|
||||
lastStep={4}
|
||||
/>
|
||||
<StepSpace />
|
||||
</>
|
||||
)}
|
||||
<Step
|
||||
title="Connect Channels"
|
||||
step={3}
|
||||
currentStep={step}
|
||||
lastStep={4}
|
||||
/>
|
||||
<StepSpace />
|
||||
<Step title="Finish" step={4} currentStep={step} lastStep={4} />
|
||||
</div>
|
||||
|
|
@ -152,7 +181,7 @@ const Welcome: FC = () => {
|
|||
<GithubOnboarding />
|
||||
<div className="flex justify-end gap-[8px]">
|
||||
<SkipOnboarding />
|
||||
<Button onClick={nextStep}>Next</Button>
|
||||
<Button onClick={nextStep(3)}>Next</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -161,7 +190,7 @@ const Welcome: FC = () => {
|
|||
<ConnectChannels />
|
||||
<div className="flex justify-end gap-[8px]">
|
||||
<SkipOnboarding />
|
||||
<Button onClick={nextStep}>Next</Button>
|
||||
<Button onClick={nextStep(4)}>Next</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -171,10 +200,20 @@ const Welcome: FC = () => {
|
|||
<img src="/success.svg" alt="success" />
|
||||
</div>
|
||||
<div className="text-[18px] text-center">
|
||||
You are done, you can now video your GitHub analytics or<br />schedule new posts
|
||||
You are done, you can now video your GitHub analytics or
|
||||
<br />
|
||||
schedule new posts
|
||||
</div>
|
||||
<div className="flex gap-[8px]">
|
||||
<Button onClick={goToAnalytics} secondary={true} className="border-[2px] border-[#506490]">View Analytics</Button>
|
||||
{!isGeneral() && (
|
||||
<Button
|
||||
onClick={goToAnalytics}
|
||||
secondary={true}
|
||||
className="border-[2px] border-[#506490]"
|
||||
>
|
||||
View Analytics
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={goToLaunches}>Schedule a new post</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
|||
import useSWR from 'swr';
|
||||
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { isGeneral } from '@gitroom/react/helpers/is.general';
|
||||
|
||||
const general = isGeneral();
|
||||
|
||||
export const SettingsComponent = () => {
|
||||
const user = useUser();
|
||||
|
|
@ -50,22 +53,24 @@ export const SettingsComponent = () => {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-[68px]">
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-[20px]">Your Git Repository</h3>
|
||||
<div className="text-[#AAA] mt-[4px]">
|
||||
Connect your GitHub repository to receive updates and analytics
|
||||
{!general && (
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-[20px]">Your Git Repository</h3>
|
||||
<div className="text-[#AAA] mt-[4px]">
|
||||
Connect your GitHub repository to receive updates and analytics
|
||||
</div>
|
||||
<GithubComponent
|
||||
github={loadAll.github}
|
||||
organizations={loadAll.organizations}
|
||||
/>
|
||||
{/*<div className="flex gap-[5px]">*/}
|
||||
{/* <div>*/}
|
||||
{/* <Checkbox disableForm={true} checked={true} name="Send Email" />*/}
|
||||
{/* </div>*/}
|
||||
{/* <div>Show news with everybody in Gitroom</div>*/}
|
||||
{/*</div>*/}
|
||||
</div>
|
||||
<GithubComponent
|
||||
github={loadAll.github}
|
||||
organizations={loadAll.organizations}
|
||||
/>
|
||||
{/*<div className="flex gap-[5px]">*/}
|
||||
{/* <div>*/}
|
||||
{/* <Checkbox disableForm={true} checked={true} name="Send Email" />*/}
|
||||
{/* </div>*/}
|
||||
{/* <div>Show news with everybody in Gitroom</div>*/}
|
||||
{/*</div>*/}
|
||||
</div>
|
||||
)}
|
||||
{!!user?.tier?.team_members && <TeamsComponent />}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
|||
import type { NextRequest } from 'next/server';
|
||||
import { fetchBackend } from '@gitroom/helpers/utils/custom.fetch.func';
|
||||
import { removeSubdomain } from '@gitroom/helpers/subdomain/subdomain.management';
|
||||
import { isGeneral } from '@gitroom/react/helpers/is.general';
|
||||
|
||||
// This function can be marked `async` if using `await` inside
|
||||
export async function middleware(request: NextRequest) {
|
||||
|
|
@ -86,8 +87,16 @@ export async function middleware(request: NextRequest) {
|
|||
return redirect;
|
||||
}
|
||||
|
||||
if (isGeneral() && (nextUrl.pathname.indexOf('/analytics') > -1 || nextUrl.pathname.indexOf('/settings') > -1)) {
|
||||
return NextResponse.redirect(
|
||||
new URL('/launches', nextUrl.href)
|
||||
);
|
||||
}
|
||||
|
||||
if (nextUrl.pathname === '/') {
|
||||
return NextResponse.redirect(new URL(`/analytics`, nextUrl.href));
|
||||
return NextResponse.redirect(
|
||||
new URL(isGeneral() ? '/launches' : `/analytics`, nextUrl.href)
|
||||
);
|
||||
}
|
||||
|
||||
const next = NextResponse.next();
|
||||
|
|
|
|||
|
|
@ -1,17 +1,44 @@
|
|||
import {sign, verify} from 'jsonwebtoken';
|
||||
import {hashSync, compareSync} from 'bcrypt';
|
||||
import { sign, verify } from 'jsonwebtoken';
|
||||
import { hashSync, compareSync } from 'bcrypt';
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export class AuthService {
|
||||
static hashPassword (password: string) {
|
||||
static hashPassword(password: string) {
|
||||
return hashSync(password, 10);
|
||||
}
|
||||
static comparePassword (password: string, hash: string) {
|
||||
}
|
||||
static comparePassword(password: string, hash: string) {
|
||||
return compareSync(password, hash);
|
||||
}
|
||||
static signJWT (value: object) {
|
||||
return sign(value, process.env.JWT_SECRET!);
|
||||
}
|
||||
static verifyJWT (token: string) {
|
||||
return verify(token, process.env.JWT_SECRET!);
|
||||
}
|
||||
}
|
||||
static signJWT(value: object) {
|
||||
return sign(value, process.env.JWT_SECRET!);
|
||||
}
|
||||
static verifyJWT(token: string) {
|
||||
return verify(token, process.env.JWT_SECRET!);
|
||||
}
|
||||
|
||||
static fixedEncryption(value: string) {
|
||||
// encryption algorithm
|
||||
const algorithm = 'aes-256-cbc';
|
||||
|
||||
// create a cipher object
|
||||
const cipher = crypto.createCipher(algorithm, process.env.JWT_SECRET);
|
||||
|
||||
// encrypt the plain text
|
||||
let encrypted = cipher.update(value, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
static fixedDecryption(hash: string) {
|
||||
const algorithm = 'aes-256-cbc';
|
||||
const decipher = crypto.createDecipher(algorithm, process.env.JWT_SECRET);
|
||||
|
||||
// decrypt the encrypted text
|
||||
let decrypted = decipher.update(hash, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export class OrganizationRepository {
|
|||
select: {
|
||||
subscriptionTier: true,
|
||||
totalChannels: true,
|
||||
isLifetime: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -67,6 +67,15 @@ model User {
|
|||
@@index([pictureId])
|
||||
}
|
||||
|
||||
model UsedCodes {
|
||||
id String @id @default(uuid())
|
||||
code String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([code])
|
||||
}
|
||||
|
||||
model UserOrganization {
|
||||
id String @id @default(uuid())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
|
@ -163,6 +172,7 @@ model Subscription {
|
|||
cancelAt DateTime?
|
||||
period Period
|
||||
totalChannels Int
|
||||
isLifetime Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export interface PricingInnerInterface {
|
||||
current: string;
|
||||
month_price: number;
|
||||
year_price: number;
|
||||
channel?: number;
|
||||
|
|
@ -14,6 +15,7 @@ export interface PricingInterface {
|
|||
}
|
||||
export const pricing: PricingInterface = {
|
||||
FREE: {
|
||||
current: 'FREE',
|
||||
month_price: 0,
|
||||
year_price: 0,
|
||||
channel: 2,
|
||||
|
|
@ -25,6 +27,7 @@ export const pricing: PricingInterface = {
|
|||
import_from_channels: false,
|
||||
},
|
||||
STANDARD: {
|
||||
current: 'STANDARD',
|
||||
month_price: 30,
|
||||
year_price: 288,
|
||||
channel: 5,
|
||||
|
|
@ -36,6 +39,7 @@ export const pricing: PricingInterface = {
|
|||
import_from_channels: true,
|
||||
},
|
||||
PRO: {
|
||||
current: 'PRO',
|
||||
month_price: 40,
|
||||
year_price: 384,
|
||||
channel: 8,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ export class SubscriptionRepository {
|
|||
constructor(
|
||||
private readonly _subscription: PrismaRepository<'subscription'>,
|
||||
private readonly _organization: PrismaRepository<'organization'>,
|
||||
private readonly _user: PrismaRepository<'user'>
|
||||
private readonly _user: PrismaRepository<'user'>,
|
||||
private _usedCodes: PrismaRepository<'usedCodes'>
|
||||
) {}
|
||||
|
||||
getUserAccount(userId: string) {
|
||||
|
|
@ -21,6 +22,14 @@ export class SubscriptionRepository {
|
|||
});
|
||||
}
|
||||
|
||||
getCode(code: string) {
|
||||
return this._usedCodes.model.usedCodes.findFirst({
|
||||
where: {
|
||||
code,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateAccount(userId: string, account: string) {
|
||||
return this._user.model.user.update({
|
||||
where: {
|
||||
|
|
@ -107,27 +116,37 @@ export class SubscriptionRepository {
|
|||
totalChannels: number,
|
||||
billing: 'STANDARD' | 'PRO',
|
||||
period: 'MONTHLY' | 'YEARLY',
|
||||
cancelAt: number | null
|
||||
cancelAt: number | null,
|
||||
code?: string,
|
||||
org?: { id: string }
|
||||
) {
|
||||
const findOrg = (await this.getOrganizationByCustomerId(customerId))!;
|
||||
const findOrg =
|
||||
org || (await this.getOrganizationByCustomerId(customerId))!;
|
||||
|
||||
await this._subscription.model.subscription.upsert({
|
||||
where: {
|
||||
organizationId: findOrg.id,
|
||||
organization: {
|
||||
paymentId: customerId,
|
||||
},
|
||||
...(!code
|
||||
? {
|
||||
organization: {
|
||||
paymentId: customerId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
update: {
|
||||
subscriptionTier: billing,
|
||||
totalChannels,
|
||||
period,
|
||||
identifier,
|
||||
isLifetime: !!code,
|
||||
cancelAt: cancelAt ? new Date(cancelAt * 1000) : null,
|
||||
deletedAt: null,
|
||||
},
|
||||
create: {
|
||||
organizationId: findOrg.id,
|
||||
subscriptionTier: billing,
|
||||
isLifetime: !!code,
|
||||
totalChannels,
|
||||
period,
|
||||
cancelAt: cancelAt ? new Date(cancelAt * 1000) : null,
|
||||
|
|
@ -135,6 +154,14 @@ export class SubscriptionRepository {
|
|||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (code) {
|
||||
await this._usedCodes.model.usedCodes.create({
|
||||
data: {
|
||||
code,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getSubscription(organizationId: string) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ export class SubscriptionService {
|
|||
);
|
||||
}
|
||||
|
||||
getCode(code: string) {
|
||||
return this._subscriptionRepository.getCode(code);
|
||||
}
|
||||
|
||||
updateAccount(userId: string, account: string) {
|
||||
return this._subscriptionRepository.updateAccount(userId, account);
|
||||
}
|
||||
|
|
@ -52,7 +56,10 @@ export class SubscriptionService {
|
|||
}
|
||||
|
||||
updateConnectedStatus(account: string, accountCharges: boolean) {
|
||||
return this._subscriptionRepository.updateConnectedStatus(account, accountCharges);
|
||||
return this._subscriptionRepository.updateConnectedStatus(
|
||||
account,
|
||||
accountCharges
|
||||
);
|
||||
}
|
||||
|
||||
async modifySubscription(
|
||||
|
|
@ -60,7 +67,10 @@ export class SubscriptionService {
|
|||
totalChannels: number,
|
||||
billing: 'FREE' | 'STANDARD' | 'PRO'
|
||||
) {
|
||||
const getOrgByCustomerId = await this._subscriptionRepository.getOrganizationByCustomerId(customerId);
|
||||
const getOrgByCustomerId =
|
||||
await this._subscriptionRepository.getOrganizationByCustomerId(
|
||||
customerId
|
||||
);
|
||||
|
||||
const getCurrentSubscription =
|
||||
(await this._subscriptionRepository.getSubscriptionByCustomerId(
|
||||
|
|
@ -120,16 +130,22 @@ export class SubscriptionService {
|
|||
totalChannels: number,
|
||||
billing: 'STANDARD' | 'PRO',
|
||||
period: 'MONTHLY' | 'YEARLY',
|
||||
cancelAt: number | null
|
||||
cancelAt: number | null,
|
||||
code?: string,
|
||||
org?: string
|
||||
) {
|
||||
await this.modifySubscription(customerId, totalChannels, billing);
|
||||
if (!code) {
|
||||
await this.modifySubscription(customerId, totalChannels, billing);
|
||||
}
|
||||
return this._subscriptionRepository.createOrUpdateSubscription(
|
||||
identifier,
|
||||
customerId,
|
||||
totalChannels,
|
||||
billing,
|
||||
period,
|
||||
cancelAt
|
||||
cancelAt,
|
||||
code,
|
||||
org ? { id: org } : undefined
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthService } from '@gitroom/helpers/auth/auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class CodesService {
|
||||
generateCodes(providerToken: string) {
|
||||
try {
|
||||
const decrypt = AuthService.fixedDecryption(providerToken);
|
||||
return [...new Array(10000)].map((_, index) => {
|
||||
return AuthService.fixedEncryption(`${decrypt}:${index}`);
|
||||
}).join('\n');
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { BillingSubscribeDto } from '@gitroom/nestjs-libraries/dtos/billing/bill
|
|||
import { capitalize, groupBy } from 'lodash';
|
||||
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
|
||||
import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
|
||||
import { AuthService } from '@gitroom/helpers/auth/auth.service';
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: '2024-04-10',
|
||||
|
|
@ -511,4 +512,45 @@ export class StripeService {
|
|||
transfer_group: orderId,
|
||||
});
|
||||
}
|
||||
|
||||
async lifetimeDeal(organizationId: string, code: string) {
|
||||
const getCurrentSubscription =
|
||||
await this._subscriptionService.getSubscriptionByOrganizationId(
|
||||
organizationId
|
||||
);
|
||||
if (getCurrentSubscription && !getCurrentSubscription?.isLifetime) {
|
||||
throw new Error('You already have a non lifetime subscription');
|
||||
}
|
||||
|
||||
try {
|
||||
const testCode = AuthService.fixedDecryption(code);
|
||||
const findCode = await this._subscriptionService.getCode(testCode);
|
||||
if (findCode) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const nextPackage = !getCurrentSubscription ? 'STANDARD' : 'PRO';
|
||||
const findPricing = pricing[nextPackage];
|
||||
await this._subscriptionService.createOrUpdateSubscription(
|
||||
makeId(10),
|
||||
organizationId,
|
||||
findPricing.channel!,
|
||||
nextPackage,
|
||||
'MONTHLY',
|
||||
null,
|
||||
testCode,
|
||||
organizationId
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
export const isGeneral = () => {
|
||||
return process.env.isGeneral === 'true';
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
"docs": "nx run docs:serve:development",
|
||||
"workers": "nx run workers:serve:development",
|
||||
"cron": "nx run cron:serve:development",
|
||||
"command": "nx run commands:build && nx run commands:command",
|
||||
"command": "rm -rf dist/apps/commands && nx run commands:build && nx run commands:command",
|
||||
"prisma-generate": "cd ./libraries/nestjs-libraries/src/database/prisma && prisma generate",
|
||||
"prisma-db-push": "cd ./libraries/nestjs-libraries/src/database/prisma && prisma db push"
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue