From 46931be66dc0b76060333b057963db32036d66b4 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 14 May 2024 00:03:46 +0700 Subject: [PATCH] feat: more information in the marketplace, pricing change --- .../src/api/routes/integrations.controller.ts | 16 +- .../src/api/routes/marketplace.controller.ts | 140 ++- .../src/api/routes/messages.controller.ts | 16 +- .../src/api/routes/posts.controller.ts | 30 +- .../src/api/routes/stripe.controller.ts | 21 +- .../auth/permissions/permissions.service.ts | 17 +- apps/frontend/public/peoplemarketplace.svg | 68 ++ .../frontend/src/app/(site)/messages/page.tsx | 7 +- .../billing/main.billing.component.tsx | 110 +-- .../components/launches/add.edit.model.tsx | 179 ++-- .../src/components/launches/calendar.tsx | 23 +- .../launches/helpers/top.title.component.tsx | 6 +- .../launches/helpers/use.existing.data.tsx | 2 +- .../launches/post.to.organization.tsx | 90 ++ .../providers/high.order.provider.tsx | 64 +- .../launches/providers/show.all.providers.tsx | 2 +- .../src/components/launches/submitted.tsx | 39 + .../src/components/layout/top.menu.tsx | 12 +- .../src/components/marketplace/buyer.tsx | 71 +- .../marketplace/marketplace.provider.tsx | 40 + .../src/components/marketplace/order.list.tsx | 78 ++ .../marketplace/order.top.actions.tsx | 366 ++++++++ .../marketplace/preview.popup.dynamic.tsx | 33 + .../src/components/marketplace/seller.tsx | 128 +-- .../marketplace/special.message.tsx | 438 +++++++++ .../src/components/messages/layout.tsx | 81 +- .../src/components/messages/messages.tsx | 128 ++- apps/workers/src/app/posts.controller.ts | 18 +- .../src/database/prisma/database.module.ts | 2 + .../integrations/integration.repository.ts | 32 + .../integrations/integration.service.ts | 4 + .../prisma/marketplace/messages.repository.ts | 880 ++++++++++++++++-- .../prisma/marketplace/messages.service.ts | 241 ++++- .../database/prisma/posts/posts.repository.ts | 88 +- .../database/prisma/posts/posts.service.ts | 222 ++++- .../src/database/prisma/schema.prisma | 151 ++- .../database/prisma/subscriptions/pricing.ts | 12 +- .../src/dtos/billing/billing.subscribe.dto.ts | 14 +- .../src/dtos/marketplace/create.offer.dto.ts | 27 + .../src/dtos/posts/create.post.dto.ts | 4 + .../src/services/stripe.service.ts | 199 +++- .../src/form/custom.select.tsx | 146 +++ .../react-shared-libraries/src/form/input.tsx | 48 +- .../react-shared-libraries/src/form/total.tsx | 73 ++ .../src/helpers/use.is.visible.tsx | 40 + package-lock.json | 50 +- package.json | 4 +- videos.csv | 0 48 files changed, 3892 insertions(+), 568 deletions(-) create mode 100644 apps/frontend/public/peoplemarketplace.svg create mode 100644 apps/frontend/src/components/launches/post.to.organization.tsx create mode 100644 apps/frontend/src/components/launches/submitted.tsx create mode 100644 apps/frontend/src/components/marketplace/marketplace.provider.tsx create mode 100644 apps/frontend/src/components/marketplace/order.list.tsx create mode 100644 apps/frontend/src/components/marketplace/order.top.actions.tsx create mode 100644 apps/frontend/src/components/marketplace/preview.popup.dynamic.tsx create mode 100644 apps/frontend/src/components/marketplace/special.message.tsx create mode 100644 libraries/nestjs-libraries/src/dtos/marketplace/create.offer.dto.ts create mode 100644 libraries/react-shared-libraries/src/form/custom.select.tsx create mode 100644 libraries/react-shared-libraries/src/form/total.tsx create mode 100644 libraries/react-shared-libraries/src/helpers/use.is.visible.tsx create mode 100644 videos.csv diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index 685ede1e..ee11cff4 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -1,10 +1,10 @@ -import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; import { ConnectIntegrationDto } from '@gitroom/nestjs-libraries/dtos/integrations/connect.integration.dto'; import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager'; import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; -import { Organization } from '@prisma/client'; +import { Organization, User } from '@prisma/client'; import { ApiKeyDto } from '@gitroom/nestjs-libraries/dtos/integrations/api.key.dto'; import { IntegrationFunctionDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.function.dto'; import { @@ -14,6 +14,7 @@ import { import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability'; import {pricing} from "@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing"; import {ApiTags} from "@nestjs/swagger"; +import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request'; @ApiTags('Integrations') @Controller('/integrations') @@ -44,6 +45,16 @@ export class IntegrationsController { }; } + @Get('/:id') + getSingleIntegration( + @Param('id') id: string, + @Query('order') order: string, + @GetUserFromRequest() user: User, + @GetOrgFromRequest() org: Organization + ) { + return this._integrationService.getIntegrationForOrder(id, order, user.id, org.id); + } + @Get('/social/:integration') async getIntegrationUrl(@Param('integration') integration: string) { if ( @@ -135,7 +146,6 @@ export class IntegrationsController { throw new Error('Invalid api key'); } - console.log('asd'); return this._integrationService.createOrUpdateIntegration( org.id, name, diff --git a/apps/backend/src/api/routes/marketplace.controller.ts b/apps/backend/src/api/routes/marketplace.controller.ts index b2d07b99..fa249726 100644 --- a/apps/backend/src/api/routes/marketplace.controller.ts +++ b/apps/backend/src/api/routes/marketplace.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { Organization, User } from '@prisma/client'; import { ApiTags } from '@nestjs/swagger'; import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request'; @@ -12,6 +12,8 @@ import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.reque import { AudienceDto } from '@gitroom/nestjs-libraries/dtos/marketplace/audience.dto'; import { NewConversationDto } from '@gitroom/nestjs-libraries/dtos/marketplace/new.conversation.dto'; import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service'; +import { CreateOfferDto } from '@gitroom/nestjs-libraries/dtos/marketplace/create.offer.dto'; +import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; @ApiTags('Marketplace') @Controller('/marketplace') @@ -20,7 +22,8 @@ export class MarketplaceController { private _itemUserService: ItemUserService, private _stripeService: StripeService, private _userService: UsersService, - private _messagesService: MessagesService + private _messagesService: MessagesService, + private _postsService: PostsService ) {} @Post('/') @@ -39,9 +42,14 @@ export class MarketplaceController { @Post('/conversation') createConversation( @GetUserFromRequest() user: User, + @GetOrgFromRequest() organization: Organization, @Body() body: NewConversationDto ) { - return this._messagesService.createConversation(user.id, body); + return this._messagesService.createConversation( + user.id, + organization.id, + body + ); } @Get('/bank') @@ -78,6 +86,15 @@ export class MarketplaceController { return this._itemUserService.getItems(user.id); } + @Get('/orders') + async getOrders( + @GetUserFromRequest() user: User, + @GetOrgFromRequest() organization: Organization, + @Query('type') type: 'seller' | 'buyer' + ) { + return this._messagesService.getOrders(user.id, organization.id, type); + } + @Get('/account') async getAccount(@GetUserFromRequest() user: User) { const { account, marketplace, connectedAccount, name, picture, audience } = @@ -91,4 +108,121 @@ export class MarketplaceController { picture, }; } + + @Post('/offer') + async createOffer( + @GetUserFromRequest() user: User, + @Body() body: CreateOfferDto + ) { + return this._messagesService.createOffer(user.id, body); + } + + @Get('/posts/:id') + async post( + @GetUserFromRequest() user: User, + @GetOrgFromRequest() organization: Organization, + @Param('id') id: string + ) { + const getPost = await this._messagesService.getPost(user.id, organization.id, id); + if (!getPost) { + return ; + } + + return {...await this._postsService.getPost(getPost.organizationId, id), providerId: getPost.integration.providerIdentifier}; + } + + @Post('/posts/:id/revision') + async revision( + @GetUserFromRequest() user: User, + @GetOrgFromRequest() organization: Organization, + @Param('id') id: string, + @Body('message') message: string + ) { + return this._messagesService.requestRevision( + user.id, + organization.id, + id, + message + ); + } + + @Post('/posts/:id/approve') + async approve( + @GetUserFromRequest() user: User, + @GetOrgFromRequest() organization: Organization, + @Param('id') id: string, + @Body('message') message: string + ) { + return this._messagesService.requestApproved( + user.id, + organization.id, + id, + message + ); + } + + @Post('/posts/:id/cancel') + async cancel( + @GetOrgFromRequest() organization: Organization, + @Param('id') id: string + ) { + return this._messagesService.requestCancel(organization.id, id); + } + + @Post('/offer/:id/complete') + async completeOrder( + @GetOrgFromRequest() organization: Organization, + @Param('id') id: string + ) { + const order = await this._messagesService.completeOrderAndPay( + organization.id, + id + ); + + if (!order) { + return; + } + + try { + await this._stripeService.payout( + id, + order.charge, + order.account, + order.price + ); + } catch (e) { + await this._messagesService.payoutProblem( + id, + order.sellerId, + order.price + ); + } + await this._messagesService.completeOrder(id); + } + + @Post('/orders/:id/payment') + async payOrder( + @GetUserFromRequest() user: User, + @GetOrgFromRequest() organization: Organization, + @Param('id') id: string + ) { + const orderDetails = await this._messagesService.getOrderDetails( + user.id, + organization.id, + id + ); + const payment = await this._stripeService.payAccountStepOne( + user.id, + organization, + orderDetails.seller, + orderDetails.order.id, + orderDetails.order.ordersItems.map((p) => ({ + quantity: p.quantity, + integrationType: p.integration.providerIdentifier, + price: p.price, + })), + orderDetails.order.messageGroupId + ); + return payment; + } } diff --git a/apps/backend/src/api/routes/messages.controller.ts b/apps/backend/src/api/routes/messages.controller.ts index c028b217..ff894a42 100644 --- a/apps/backend/src/api/routes/messages.controller.ts +++ b/apps/backend/src/api/routes/messages.controller.ts @@ -2,8 +2,9 @@ import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service'; import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request'; -import { User } from '@prisma/client'; +import { Organization, User } from '@prisma/client'; import { AddMessageDto } from '@gitroom/nestjs-libraries/dtos/messages/add.message'; +import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; @ApiTags('Messages') @Controller('/messages') @@ -11,24 +12,29 @@ export class MessagesController { constructor(private _messagesService: MessagesService) {} @Get('/') - getMessagesGroup(@GetUserFromRequest() user: User) { - return this._messagesService.getMessagesGroup(user.id); + getMessagesGroup( + @GetUserFromRequest() user: User, + @GetOrgFromRequest() organization: Organization + ) { + return this._messagesService.getMessagesGroup(user.id, organization.id); } @Get('/:groupId/:page') getMessages( @GetUserFromRequest() user: User, + @GetOrgFromRequest() organization: Organization, @Param('groupId') groupId: string, @Param('page') page: string ) { - return this._messagesService.getMessages(user.id, groupId, +page); + return this._messagesService.getMessages(user.id, organization.id, groupId, +page); } @Post('/:groupId') createMessage( @GetUserFromRequest() user: User, + @GetOrgFromRequest() organization: Organization, @Param('groupId') groupId: string, @Body() message: AddMessageDto ) { - return this._messagesService.createMessage(user.id, groupId, message); + return this._messagesService.createMessage(user.id, organization.id, groupId, message); } } diff --git a/apps/backend/src/api/routes/posts.controller.ts b/apps/backend/src/api/routes/posts.controller.ts index a49cba74..ac2bdda7 100644 --- a/apps/backend/src/api/routes/posts.controller.ts +++ b/apps/backend/src/api/routes/posts.controller.ts @@ -10,14 +10,18 @@ import { } from '@nestjs/common'; import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; -import { Organization } from '@prisma/client'; +import { Organization, User } from '@prisma/client'; import { CreatePostDto } from '@gitroom/nestjs-libraries/dtos/posts/create.post.dto'; import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto'; import { CommentsService } from '@gitroom/nestjs-libraries/database/prisma/comments/comments.service'; -import {StarsService} from "@gitroom/nestjs-libraries/database/prisma/stars/stars.service"; -import {CheckPolicies} from "@gitroom/backend/services/auth/permissions/permissions.ability"; -import {AuthorizationActions, Sections} from "@gitroom/backend/services/auth/permissions/permissions.service"; -import {ApiTags} from "@nestjs/swagger"; +import { StarsService } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.service'; +import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability'; +import { + AuthorizationActions, + Sections, +} from '@gitroom/backend/services/auth/permissions/permissions.service'; +import { ApiTags } from '@nestjs/swagger'; +import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service'; @ApiTags('Posts') @Controller('/posts') @@ -25,9 +29,18 @@ export class PostsController { constructor( private _postsService: PostsService, private _commentsService: CommentsService, - private _starsService: StarsService + private _starsService: StarsService, + private _messagesService: MessagesService ) {} + @Get('/marketplace/:id?') + async getMarketplacePosts( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string + ) { + return this._messagesService.getMarketplaceAvailableOffers(org.id, id); + } + @Get('/') async getPosts( @GetOrgFromRequest() org: Organization, @@ -55,8 +68,8 @@ export class PostsController { @Get('/old') oldPosts( - @GetOrgFromRequest() org: Organization, - @Query('date') date: string + @GetOrgFromRequest() org: Organization, + @Query('date') date: string ) { return this._postsService.getOldPosts(org.id, date); } @@ -72,6 +85,7 @@ export class PostsController { @GetOrgFromRequest() org: Organization, @Body() body: CreatePostDto ) { + return this._postsService.createPost(org.id, body); } diff --git a/apps/backend/src/api/routes/stripe.controller.ts b/apps/backend/src/api/routes/stripe.controller.ts index bebc29d5..75d3c6a2 100644 --- a/apps/backend/src/api/routes/stripe.controller.ts +++ b/apps/backend/src/api/routes/stripe.controller.ts @@ -1,18 +1,13 @@ -import {Controller, Post, RawBodyRequest, Req} from "@nestjs/common"; -import {StripeService} from "@gitroom/nestjs-libraries/services/stripe.service"; -import {ApiTags} from "@nestjs/swagger"; +import { Controller, Post, RawBodyRequest, Req } from '@nestjs/common'; +import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service'; +import { ApiTags } from '@nestjs/swagger'; @ApiTags('Stripe') @Controller('/stripe') export class StripeController { - constructor( - private readonly _stripeService: StripeService - ) { - } + constructor(private readonly _stripeService: StripeService) {} @Post('/') - stripe( - @Req() req: RawBodyRequest - ) { + stripe(@Req() req: RawBodyRequest) { const event = this._stripeService.validateRequest( req.rawBody, req.headers['stripe-signature'], @@ -23,10 +18,12 @@ export class StripeController { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore if (event?.data?.object?.metadata?.service !== 'gitroom') { - return {ok: true}; + return { ok: true }; } switch (event.type) { + case 'checkout.session.completed': + return this._stripeService.updateOrder(event); case 'account.updated': return this._stripeService.updateAccount(event); case 'customer.subscription.created': @@ -36,7 +33,7 @@ export class StripeController { case 'customer.subscription.deleted': return this._stripeService.deleteSubscription(event); default: - return {ok: true}; + return { ok: true }; } } } diff --git a/apps/backend/src/services/auth/permissions/permissions.service.ts b/apps/backend/src/services/auth/permissions/permissions.service.ts index 7da47600..a1ce5dc8 100644 --- a/apps/backend/src/services/auth/permissions/permissions.service.ts +++ b/apps/backend/src/services/auth/permissions/permissions.service.ts @@ -36,14 +36,18 @@ export class PermissionsService { async getPackageOptions(orgId: string) { const subscription = await this._subscriptionService.getSubscriptionByOrganizationId(orgId); + + const tier = + subscription?.subscriptionTier || + (!process.env.STRIPE_PUBLISHABLE_KEY ? 'PRO' : 'FREE'); + + const { channel, ...all } = pricing[tier]; return { subscription, - options: - pricing[ - subscription?.subscriptionTier || !process.env.STRIPE_PUBLISHABLE_KEY - ? 'PRO' - : 'FREE' - ], + options: { + ...all, + ...{ channel: tier === 'FREE' ? { channel } : {} }, + }, }; } @@ -73,6 +77,7 @@ export class PermissionsService { ).length; if ( + // @ts-ignore (options.channel && options.channel > totalChannels) || (subscription?.totalChannels || 0) > totalChannels ) { diff --git a/apps/frontend/public/peoplemarketplace.svg b/apps/frontend/public/peoplemarketplace.svg new file mode 100644 index 00000000..247d2fb1 --- /dev/null +++ b/apps/frontend/public/peoplemarketplace.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/frontend/src/app/(site)/messages/page.tsx b/apps/frontend/src/app/(site)/messages/page.tsx index 7e1673ba..f089ccce 100644 --- a/apps/frontend/src/app/(site)/messages/page.tsx +++ b/apps/frontend/src/app/(site)/messages/page.tsx @@ -9,6 +9,11 @@ export const metadata: Metadata = { export default async function Index() { return ( -
asd
+
+
+
+ Select a conversation and chat away. +
+
); } diff --git a/apps/frontend/src/components/billing/main.billing.component.tsx b/apps/frontend/src/components/billing/main.billing.component.tsx index 6e0e63b3..76a2dd51 100644 --- a/apps/frontend/src/components/billing/main.billing.component.tsx +++ b/apps/frontend/src/components/billing/main.billing.component.tsx @@ -3,7 +3,7 @@ import { Slider } from '@gitroom/react/form/slider'; import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { Button } from '@gitroom/react/form/button'; -import { isEqual, sortBy } from 'lodash'; +import { sortBy } from 'lodash'; import { Track } from '@gitroom/react/form/track'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { Subscription } from '@prisma/client'; @@ -17,7 +17,6 @@ import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions import { FAQComponent } from '@gitroom/frontend/components/billing/faq.component'; import { useSWRConfig } from 'swr'; import { useUser } from '@gitroom/frontend/components/layout/user.context'; -import { useRouter } from 'next/navigation'; import interClass from '@gitroom/react/helpers/inter.font'; export interface Tiers { @@ -34,11 +33,10 @@ export interface Tiers { } export const Prorate: FC<{ - totalChannels: number; period: 'MONTHLY' | 'YEARLY'; pack: 'STANDARD' | 'PRO'; }> = (props) => { - const { totalChannels, period, pack } = props; + const { period, pack } = props; const fetch = useFetch(); const [price, setPrice] = useState(0); const [loading, setLoading] = useState(false); @@ -51,7 +49,6 @@ export const Prorate: FC<{ await fetch('/billing/prorate', { method: 'POST', body: JSON.stringify({ - total: totalChannels, period, billing: pack, }), @@ -65,7 +62,7 @@ export const Prorate: FC<{ useEffect(() => { setPrice(false); calculatePrice(); - }, [totalChannels, period, pack]); + }, [period, pack]); if (loading) { return ( @@ -88,12 +85,11 @@ export const Prorate: FC<{ export const Features: FC<{ pack: 'FREE' | 'STANDARD' | 'PRO'; - channels: number; }> = (props) => { - const { pack, channels } = props; + const { pack } = props; const features = useMemo(() => { const currentPricing = pricing[pack]; - const channelsOr = currentPricing.channel || channels; + const channelsOr = currentPricing.channel; const list = []; list.push(`${channelsOr} ${channelsOr === 1 ? 'channel' : 'channels'}`); list.push( @@ -124,7 +120,7 @@ export const Features: FC<{ } return list; - }, [pack, channels]); + }, [pack]); return (
@@ -152,10 +148,9 @@ export const Features: FC<{ }; export const MainBillingComponent: FC<{ - tiers: Tiers; sub?: Subscription; }> = (props) => { - const { tiers, sub } = props; + const { sub } = props; const { mutate } = useSWRConfig(); const fetch = useFetch(); const toast = useToaster(); @@ -176,17 +171,17 @@ export const MainBillingComponent: FC<{ const [initialChannels, setInitialChannels] = useState( sub?.totalChannels || 1 ); - const [totalChannels, setTotalChannels] = useState(initialChannels); useEffect(() => { if (initialChannels !== sub?.totalChannels) { - setTotalChannels(sub?.totalChannels || 1); setInitialChannels(sub?.totalChannels || 1); } if (period !== sub?.period) { setPeriod(sub?.period || 'MONTHLY'); - setMonthlyOrYearly((sub?.period || 'MONTHLY') === 'MONTHLY' ? 'off' : 'on'); + setMonthlyOrYearly( + (sub?.period || 'MONTHLY') === 'MONTHLY' ? 'off' : 'on' + ); } setSubscription(sub); @@ -201,9 +196,6 @@ export const MainBillingComponent: FC<{ if (!subscription) { return 'FREE'; } - if (initialChannels !== totalChannels) { - return ''; - } if (period === 'YEARLY' && monthlyOrYearly === 'off') { return ''; @@ -214,26 +206,11 @@ export const MainBillingComponent: FC<{ } return subscription?.subscriptionTier; - }, [subscription, totalChannels, initialChannels, monthlyOrYearly, period]); - - const currentDisplay = useMemo(() => { - return sortBy( - [ - { name: 'Free', price: 0 }, - ...(monthlyOrYearly === 'on' ? tiers.year : tiers.month), - ], - (p) => ['Free', 'Standard', 'Pro'].indexOf(p.name) - ); - }, [monthlyOrYearly]); + }, [subscription, initialChannels, monthlyOrYearly, period]); const moveToCheckout = useCallback( (billing: 'STANDARD' | 'PRO' | 'FREE') => async () => { const messages = []; - const beforeTotalChannels = pricing[billing].channel || initialChannels; - - if (totalChannels < beforeTotalChannels) { - messages.push(`Some of the channels will be disabled`); - } if ( !pricing[billing].team_members && @@ -284,7 +261,6 @@ export const MainBillingComponent: FC<{ await fetch('/billing/subscribe', { method: 'POST', body: JSON.stringify({ - total: totalChannels, period: monthlyOrYearly === 'on' ? 'YEARLY' : 'MONTHLY', billing, }), @@ -307,8 +283,6 @@ export const MainBillingComponent: FC<{ window.open(portal); } } else { - setTotalChannels(totalChannels); - setInitialChannels(totalChannels); setPeriod(monthlyOrYearly === 'on' ? 'YEARLY' : 'MONTHLY'); setSubscription((subs) => ({ ...subs!, @@ -319,7 +293,6 @@ export const MainBillingComponent: FC<{ '/user/self', { ...user, - totalChannels, tier: billing, }, { @@ -331,7 +304,7 @@ export const MainBillingComponent: FC<{ setLoading(false); }, - [monthlyOrYearly, totalChannels, subscription, user] + [monthlyOrYearly, subscription, user] ); return ( @@ -346,34 +319,26 @@ export const MainBillingComponent: FC<{
YEARLY
-
-
Total Channels
-
- -
-
- {currentDisplay.map((p) => ( + {Object.entries(pricing).map(([name, values]) => (
-
{p.name}
+
{name}
-
{p.price ? '$' + totalChannels * p.price : p.name}
- {!!p.price && ( -
- {monthlyOrYearly === 'on' ? '/year' : '/month'} -
- )} +
+ $ + {monthlyOrYearly === 'on' + ? values.year_price + : values.month_price} +
+
+ {monthlyOrYearly === 'on' ? '/year' : '/month'} +
- {currentPackage === p.name.toUpperCase() && + {currentPackage === name.toUpperCase() && subscription?.cancelAt ? (
@@ -384,24 +349,24 @@ export const MainBillingComponent: FC<{
) : ( )} {subscription && - currentPackage !== p.name.toUpperCase() && - !!p.price && ( + currentPackage !== name.toUpperCase() && + name !== 'FREE' && + !!name && ( )}
))} diff --git a/apps/frontend/src/components/launches/add.edit.model.tsx b/apps/frontend/src/components/launches/add.edit.model.tsx index 1996b360..0722d6c4 100644 --- a/apps/frontend/src/components/launches/add.edit.model.tsx +++ b/apps/frontend/src/components/launches/add.edit.model.tsx @@ -6,6 +6,7 @@ import React, { MouseEventHandler, useCallback, useEffect, + useMemo, useState, } from 'react'; import dayjs from 'dayjs'; @@ -33,23 +34,30 @@ import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.titl import { PickPlatforms } from '@gitroom/frontend/components/launches/helpers/pick.platform.component'; import { ProvidersOptions } from '@gitroom/frontend/components/launches/providers.options'; import { v4 as uuidv4 } from 'uuid'; -import { useSWRConfig } from 'swr'; +import useSWR, { useSWRConfig } from 'swr'; import { useToaster } from '@gitroom/react/toaster/toaster'; import { postSelector } from '@gitroom/frontend/components/post-url-selector/post.url.selector'; import { UpDownArrow } from '@gitroom/frontend/components/launches/up.down.arrow'; import { DatePicker } from '@gitroom/frontend/components/launches/helpers/date.picker'; import { arrayMoveImmutable } from 'array-move'; -import { linkedinCompany } from '@gitroom/frontend/components/launches/helpers/linkedin.component'; +import { + Information, + PostToOrganization, +} from '@gitroom/frontend/components/launches/post.to.organization'; +import { Submitted } from '@gitroom/frontend/components/launches/submitted'; export const AddEditModal: FC<{ date: dayjs.Dayjs; integrations: Integrations[]; + reopenModal: () => void; }> = (props) => { - const { date, integrations } = props; + const { date, integrations, reopenModal } = props; const [dateState, setDateState] = useState(date); - const { mutate } = useSWRConfig(); + // hook to open a new modal + const modal = useModals(); + // selected integrations to allow edit const [selectedIntegrations, setSelectedIntegrations] = useState< Integrations[] @@ -66,6 +74,11 @@ export const AddEditModal: FC<{ const fetch = useFetch(); + const updateOrder = useCallback(() => { + modal.closeAll(); + reopenModal(); + }, [reopenModal, modal]); + // prevent the window exit by mistake usePreventWindowUnload(true); @@ -77,12 +90,12 @@ export const AddEditModal: FC<{ const [showError, setShowError] = useState(false); - // hook to open a new modal - const modal = useModals(); - // are we in edit mode? const existingData = useExistingData(); + // Post for + const [postFor, setPostFor] = useState(); + const expend = useExpend(); const toaster = useToaster(); @@ -253,6 +266,7 @@ export const AddEditModal: FC<{ await fetch('/posts', { method: 'POST', body: JSON.stringify({ + ...(postFor ? { order: postFor.id } : {}), type, date: dateState.utc().format('YYYY-MM-DDTHH:mm:ss'), posts: allKeys, @@ -269,12 +283,41 @@ export const AddEditModal: FC<{ ); modal.closeAll(); }, - [] + [postFor, dateState, value, integrations, existingData] ); + const getPostsMarketplace = useCallback(async () => { + return ( + await fetch(`/posts/marketplace/${existingData?.posts?.[0]?.id}`) + ).json(); + }, []); + + const { data } = useSWR( + `/posts/marketplace/${existingData?.posts?.[0]?.id}`, + getPostsMarketplace + ); + + const canSendForPublication = useMemo(() => { + if (!postFor) { + return true; + } + + return selectedIntegrations.every((integration) => { + const find = postFor.missing.find( + (p) => p.integration.integration.id === integration.id + ); + + if (!find) { + return false; + } + + return find.missing !== 0; + }); + }, [data, postFor, selectedIntegrations]); + return ( <> -
+
- - -
- -
+ +
+ + +
+
{!existingData.integration && ( Cancel - {!!existingData.integration && ( + + {!!existingData.integration && ( + + )} - )} - -
- + +
diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 9d309724..4f860f16 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -18,6 +18,7 @@ import { useAddProvider } from '@gitroom/frontend/components/launches/add.provid import { CommentComponent } from '@gitroom/frontend/components/launches/comments/comment.component'; import { useSWRConfig } from 'swr'; import { useIntersectionObserver } from '@uidotdev/usehooks'; +import { useToaster } from '@gitroom/react/toaster/toaster'; export const days = [ '', @@ -203,6 +204,7 @@ const CalendarColumnRender: FC<{ day: number; hour: string }> = (props) => { changeDate, } = useCalendar(); + const toaster = useToaster(); const modal = useModals(); const fetch = useFetch(); @@ -243,15 +245,21 @@ const CalendarColumnRender: FC<{ day: number; hour: string }> = (props) => { const [{ canDrop }, drop] = useDrop(() => ({ accept: 'post', - drop: (item: any) => { + drop: async (item: any) => { if (isBeforeNow) return; - fetch(`/posts/${item.id}/date`, { + const { status } = await fetch(`/posts/${item.id}/date`, { method: 'PUT', body: JSON.stringify({ date: getDate.utc().format('YYYY-MM-DDTHH:mm:ss'), }), }); - changeDate(item.id, getDate); + + if (status !== 500) { + changeDate(item.id, getDate); + return; + } + + toaster.show('Can\'t change date, remove post from publication', 'warning'); }, collect: (monitor) => ({ canDrop: isBeforeNow ? false : !!monitor.canDrop() && !!monitor.isOver(), @@ -272,6 +280,7 @@ const CalendarColumnRender: FC<{ day: number; hour: string }> = (props) => { children: ( f.id === data.integration )} @@ -294,7 +303,13 @@ const CalendarColumnRender: FC<{ day: number; hour: string }> = (props) => { classNames: { modal: 'bg-transparent text-white', }, - children: , + children: ( + ({})} + integrations={integrations} + date={getDate} + /> + ), size: '80%', // title: `Adding posts for ${getDate.format('DD/MM/YYYY HH:mm')}`, }); diff --git a/apps/frontend/src/components/launches/helpers/top.title.component.tsx b/apps/frontend/src/components/launches/helpers/top.title.component.tsx index 9c6cdcc8..0075a63e 100644 --- a/apps/frontend/src/components/launches/helpers/top.title.component.tsx +++ b/apps/frontend/src/components/launches/helpers/top.title.component.tsx @@ -1,16 +1,18 @@ -import {FC} from "react"; +import { FC, ReactNode } from 'react'; export const TopTitle: FC<{ title: string; shouldExpend?: boolean; expend?: () => void; collapse?: () => void; + children?: ReactNode; }> = (props) => { - const { title, shouldExpend, expend, collapse } = props; + const { title, children, shouldExpend, expend, collapse } = props; return (
{title}
+ {children} {shouldExpend !== undefined && (
{!shouldExpend ? ( diff --git a/apps/frontend/src/components/launches/helpers/use.existing.data.tsx b/apps/frontend/src/components/launches/helpers/use.existing.data.tsx index 26c2e764..d55a4dd8 100644 --- a/apps/frontend/src/components/launches/helpers/use.existing.data.tsx +++ b/apps/frontend/src/components/launches/helpers/use.existing.data.tsx @@ -5,7 +5,7 @@ const ExistingDataContext = createContext({ integration: '', group: undefined as undefined | string, posts: [] as Post[], - settings: {} as any + settings: {} as any, }); diff --git a/apps/frontend/src/components/launches/post.to.organization.tsx b/apps/frontend/src/components/launches/post.to.organization.tsx new file mode 100644 index 00000000..ec0d5658 --- /dev/null +++ b/apps/frontend/src/components/launches/post.to.organization.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { FC, useEffect } from 'react'; +import { CustomSelect } from '@gitroom/react/form/custom.select'; +import { FormProvider, useForm } from 'react-hook-form'; + +export interface Information { + buyer: Buyer; + usedIds: Array<{ id: string; status: 'NO' | 'WAITING_CONFIRMATION' | 'YES' }>; + id: string; + missing: Missing[]; +} + +export interface Buyer { + id: string; + name: string; + picture: Picture; +} + +export interface Picture { + id: string; + path: string; +} + +export interface Missing { + integration: Integration; + missing: number; +} + +export interface Integration { + quantity: number; + integration: Integration2; +} + +export interface Integration2 { + id: string; + name: string; + providerIdentifier: string; +} + +export const PostToOrganization: FC<{ + information: Information[]; + onChange: (order?: Information) => void; + selected?: string; +}> = (props) => { + const { information, onChange, selected } = props; + const form = useForm(); + const postFor = form.watch('post_for'); + useEffect(() => { + onChange(information?.find((p) => p.id === postFor?.value)!); + }, [postFor]); + + useEffect(() => { + if (!selected || !information?.length) { + return; + } + + const findIt = information?.find((p) => p.id === selected); + form.setValue('post_for', { + value: findIt?.id, + }); + onChange(information?.find((p) => p.id === selected)!); + }, [selected, information]); + + if (!information?.length) { + return null; + } + + return ( + + ({ + label: 'For: ' + p?.buyer?.name, + value: p?.id, + icon: ( + + ), + }))} + /> + + ); +}; diff --git a/apps/frontend/src/components/launches/providers/high.order.provider.tsx b/apps/frontend/src/components/launches/providers/high.order.provider.tsx index b1eb878c..dc1d93b1 100644 --- a/apps/frontend/src/components/launches/providers/high.order.provider.tsx +++ b/apps/frontend/src/components/launches/providers/high.order.provider.tsx @@ -76,6 +76,7 @@ export const withProvider = ( id?: string; image?: Array<{ path: string; id: string }>; }>; + hideMenu?: boolean; show: boolean; }) => { const existingData = useExistingData(); @@ -228,40 +229,42 @@ export const withProvider = ( setShowTab(0)} />
-
-
- -
- {!!SettingsComponent && ( + {!props.hideMenu && ( +
+
+ {!!SettingsComponent && ( +
+ +
+ )} +
+
- )} -
-
-
+ )} {editInPlace && createPortal( @@ -285,7 +288,10 @@ export const withProvider = ( .filter((f) => f.name !== 'image'), newImage, postSelector(date), - ...linkedinCompany(integration?.identifier!, integration?.id!), + ...linkedinCompany( + integration?.identifier!, + integration?.id! + ), ]} preview="edit" // @ts-ignore diff --git a/apps/frontend/src/components/launches/providers/show.all.providers.tsx b/apps/frontend/src/components/launches/providers/show.all.providers.tsx index 174b7959..56dc86fc 100644 --- a/apps/frontend/src/components/launches/providers/show.all.providers.tsx +++ b/apps/frontend/src/components/launches/providers/show.all.providers.tsx @@ -7,7 +7,7 @@ import RedditProvider from "@gitroom/frontend/components/launches/providers/redd import MediumProvider from "@gitroom/frontend/components/launches/providers/medium/medium.provider"; import HashnodeProvider from "@gitroom/frontend/components/launches/providers/hashnode/hashnode.provider"; -const Providers = [ +export const Providers = [ {identifier: 'devto', component: DevtoProvider}, {identifier: 'x', component: XProvider}, {identifier: 'linkedin', component: LinkedinProvider}, diff --git a/apps/frontend/src/components/launches/submitted.tsx b/apps/frontend/src/components/launches/submitted.tsx new file mode 100644 index 00000000..4c8a4f72 --- /dev/null +++ b/apps/frontend/src/components/launches/submitted.tsx @@ -0,0 +1,39 @@ +import React, { FC, ReactNode, useCallback } from 'react'; +import { Button } from '@gitroom/react/form/button'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; + +export const Submitted: FC<{ + children: ReactNode; + postId: string; + status: 'YES' | 'NO' | 'WAITING_CONFIRMATION'; + updateOrder: () => void; +}> = (props) => { + const { postId, updateOrder, status, children } = props; + const fetch = useFetch(); + + const cancel = useCallback(async () => { + if (!await deleteDialog('Are you sure you want to cancel this publication?', 'Yes')) { + return ; + } + await fetch(`/marketplace/posts/${postId}/cancel`, { + method: 'POST' + }); + + updateOrder(); + }, [postId]); + + if (!status || status === 'NO') { + return <>{children}; + } + + return ( + + ); +}; diff --git a/apps/frontend/src/components/layout/top.menu.tsx b/apps/frontend/src/components/layout/top.menu.tsx index 1c67e7ea..7d4d0dea 100644 --- a/apps/frontend/src/components/layout/top.menu.tsx +++ b/apps/frontend/src/components/layout/top.menu.tsx @@ -23,12 +23,6 @@ export const menuItems = [ path: '/settings', role: ['ADMIN', 'SUPERADMIN'], }, - { - name: 'Billing', - icon: 'billing', - path: '/billing', - role: ['ADMIN', 'SUPERADMIN'], - }, { name: 'Marketplace', icon: 'marketplace', @@ -39,6 +33,12 @@ export const menuItems = [ icon: 'messages', path: '/messages', }, + { + name: 'Billing', + icon: 'billing', + path: '/billing', + role: ['ADMIN', 'SUPERADMIN'], + }, ]; export const TopMenu: FC = () => { diff --git a/apps/frontend/src/components/marketplace/buyer.tsx b/apps/frontend/src/components/marketplace/buyer.tsx index 3da792b2..9ec6ee00 100644 --- a/apps/frontend/src/components/marketplace/buyer.tsx +++ b/apps/frontend/src/components/marketplace/buyer.tsx @@ -27,6 +27,7 @@ import { Textarea } from '@gitroom/react/form/textarea'; import { FormProvider, SubmitHandler, useForm } from 'react-hook-form'; import { classValidatorResolver } from '@hookform/resolvers/class-validator'; import { NewConversationDto } from '@gitroom/nestjs-libraries/dtos/marketplace/new.conversation.dto'; +import { OrderList } from '@gitroom/frontend/components/marketplace/order.list'; export interface Root { list: List[]; @@ -295,14 +296,19 @@ export const RequestService: FC<{ toId: string; name: string }> = (props) => { return modal.closeAll(); }, []); - const createConversation: SubmitHandler = useCallback(async (data) => { - const {id} = await (await fetch('/marketplace/conversation', { - method: 'POST', - body: JSON.stringify(data), - })).json(); - close(); - router.push(`/messages/${id}`); - }, []); + const createConversation: SubmitHandler = useCallback( + async (data) => { + const { id } = await ( + await fetch('/marketplace/conversation', { + method: 'POST', + body: JSON.stringify(data), + }) + ).json(); + close(); + router.push(`/messages/${id}`); + }, + [] + ); return (
@@ -512,29 +518,36 @@ export const Buyer = () => { const { data: list } = useSWR('search' + services + page, marketplace); return ( -
-
-
-

Filter

-
- {tagsList.map((tag) => ( - - ))} + <> +
+ +
+
+
+
+

Filter

+
+ {tagsList.map((tag) => ( + + ))} +
+
+
+ {list?.count || 0} Result +
+ {list?.list?.map((item, index) => ( + + ))} + +
-
-
{list?.count || 0} Result
- {list?.list?.map((item, index) => ( - - ))} - -
-
+ ); }; diff --git a/apps/frontend/src/components/marketplace/marketplace.provider.tsx b/apps/frontend/src/components/marketplace/marketplace.provider.tsx new file mode 100644 index 00000000..4a463eb0 --- /dev/null +++ b/apps/frontend/src/components/marketplace/marketplace.provider.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { createContext } from 'react'; +import { Orders } from '@prisma/client'; + +export interface Root2 { + id: string; + buyerId: string; + sellerId: string; + createdAt: string; + updatedAt: string; + buyer: SellerBuyer; + seller: SellerBuyer; + messages: Message[]; + orders: Orders[]; +} + +export interface SellerBuyer { + id: string; + name: any; + picture: Picture; +} + +export interface Picture { + id: string; + path: string; +} + +export interface Message { + id: string; + from: string; + content: string; + groupId: string; + createdAt: string; + updatedAt: string; + deletedAt: any; +} + + +export const MarketplaceProvider = createContext<{message?: Root2}>({}); \ No newline at end of file diff --git a/apps/frontend/src/components/marketplace/order.list.tsx b/apps/frontend/src/components/marketplace/order.list.tsx new file mode 100644 index 00000000..91b8d78e --- /dev/null +++ b/apps/frontend/src/components/marketplace/order.list.tsx @@ -0,0 +1,78 @@ +import React, { FC, useCallback, useMemo } from 'react'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import useSWR from 'swr'; + +export const OrderList: FC<{ type: 'seller' | 'buyer' }> = (props) => { + const fetch = useFetch(); + + const { type } = props; + const getOrderDetails = useCallback(async () => { + return (await fetch(`/marketplace/orders?type=${type}`)).json(); + }, [type]); + + const { data, isLoading } = useSWR( + `/marketplace/orders/${type}`, + getOrderDetails + ); + + const biggerRow = useMemo(() => { + return data?.orders?.reduce((all: any, current: any) => { + if (current.details.length > all) return current.details.length; + return all; + }, 0); + }, [data]); + + if (isLoading || !data?.orders?.length) return <>; + + return ( +
+

Orders

+
+ + + + + + + {data.orders.map((order: any) => ( + + + {order.details.map((details: any, index: number) => ( + + ))} + + + + ))} +
+ {type === 'seller' ? 'Buyer' : 'Seller'} + PriceState
{order.name} +
+
+ platform + {details.integration.name} +
+
+ {details.integration.name} ({details.total}/ + {details.submitted}) +
+
+
{order.price}{order.status}
+
+
+ ); +}; diff --git a/apps/frontend/src/components/marketplace/order.top.actions.tsx b/apps/frontend/src/components/marketplace/order.top.actions.tsx new file mode 100644 index 00000000..a61d35a0 --- /dev/null +++ b/apps/frontend/src/components/marketplace/order.top.actions.tsx @@ -0,0 +1,366 @@ +import React, { FC, useCallback, useContext, useMemo, useState } from 'react'; +import { MarketplaceProvider } from '@gitroom/frontend/components/marketplace/marketplace.provider'; +import { useUser } from '@gitroom/frontend/components/layout/user.context'; +import { useModals } from '@mantine/modals'; +import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component'; +import { Input } from '@gitroom/react/form/input'; +import { CustomSelect } from '@gitroom/react/form/custom.select'; +import { FormProvider, useFieldArray, useForm } from 'react-hook-form'; +import { Total } from '@gitroom/react/form/total'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import useSWR from 'swr'; +import { Button } from '@gitroom/react/form/button'; +import { array, number, object, string } from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useToaster } from '@gitroom/react/toaster/toaster'; +import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; + +const schema = object({ + socialMedia: array() + .min(1) + .of( + object({ + total: number().required(), + value: object({ + value: string().required('Platform is required'), + }).required(), + price: string().matches(/^\d+$/, 'Price must be a number').required(), + }) + ) + .required(), +}).required(); + +export const NewOrder: FC<{ group: string }> = (props) => { + const { group } = props; + const modal = useModals(); + const fetch = useFetch(); + const [update, setUpdate] = useState(0); + const toast = useToaster(); + const loadIntegrations = useCallback(async () => { + return ( + await (await fetch('/integrations/list')).json() + ).integrations.filter((f: any) => !f.disabled); + }, []); + + const { data } = useSWR('integrations', loadIntegrations); + + const options: Array<{ label: string; value: string; icon: string }> = + useMemo(() => { + if (!data) { + return []; + } + + return data?.map((p: any) => ({ + label: p.name, + value: p.identifier, + id: p.id, + icon: ( +
+ {p.name} + {p.name} +
+ ), + })); + }, [data]); + + const change = useCallback(() => { + setUpdate((prev) => prev + 1); + }, [update]); + + const form = useForm<{ + price: string; + socialMedia: Array<{ value?: string; total: number; price: any }>; + }>({ + values: { + price: '', + socialMedia: [{ value: undefined, total: 1, price: '' }], + }, + criteriaMode: 'all', + // @ts-ignore + resolver: yupResolver(schema), + mode: 'onChange', + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: 'socialMedia', + }); + + const possibleOptions = useMemo(() => { + return fields.map((z, index) => { + const field = form.getValues(`socialMedia.${index}.value`) as { + value?: { value?: string; total?: number }; + }; + return options.filter((f) => { + const getAllValues = fields.reduce((all, p, innerIndex) => { + if (index === innerIndex) { + return all; + } + + const newField = form.getValues( + `socialMedia.${innerIndex}.value` + ) as { value?: { value?: string } }; + all.push(newField); + return all; + }, [] as any[]); + + return ( + field?.value?.value === f.value || + !getAllValues.some((v) => v?.value === f.value) + ); + }); + }); + }, [update, fields, options]); + + const canAddMoreOptions = useMemo(() => { + return fields.length < options.length; + }, [update, fields, options]); + + const close = useCallback(() => { + return modal.closeAll(); + }, []); + + const submit = useCallback(async (data: any) => { + await ( + await fetch('/marketplace/offer', { + method: 'POST', + body: JSON.stringify({ + group, + socialMedia: data.socialMedia.map((z: any) => ({ + total: z.total, + price: +z.price, + value: z.value.id, + })), + }), + }) + ).json(); + + toast.show('Offer sent successfully'); + modal.closeAll(); + }, []); + + const totalPrice = useMemo(() => { + return fields.reduce((total, field, index) => { + return ( + total + + (+(form.getValues(`socialMedia.${index}.price`) || 0) * + form.getValues(`socialMedia.${index}.total`) || 0) + ); + }, 0); + }, [update, fields, options]); + + return ( + + +
+ +
+ +
+ {fields.map((field, index) => ( +
+ {index !== 0 && ( +
remove(index)} + className="cursor-pointer top-[3px] z-[99] w-[15px] h-[15px] bg-red-500 rounded-full text-white absolute left-[60px] text-[12px] flex justify-center items-center pb-[2px] select-none" + > + x +
+ )} +
+ +
+
+ +
+
+ $
} + className="text-[14px]" + label="Price per post" + error={ + form.formState.errors?.socialMedia?.[index]?.price + ?.message + } + customUpdate={change} + name={`socialMedia.${index}.price`} + /> +
+
+ ))} + {canAddMoreOptions && ( +
+
+ append({ value: undefined, total: 1, price: '' }) + } + className="select-none rounded-[4px] border-2 border-[#506490] flex py-[9.5px] px-[24px] items-center gap-[4px] text-[14px] float-left cursor-pointer" + > +
+ + + +
+
Add another platform
+
+
+ )} +
+
+ +
+
+
+ + + ); +}; + +export const OrderInProgress: FC<{ group: string; buyer: boolean, order: string }> = ( + props +) => { + const { group, buyer, order } = props; + const fetch = useFetch(); + + const completeOrder = useCallback(async () => { + if (await deleteDialog('Are you sure you want to pay the seller and end the order? this is irreversible action')) { + await ( + await fetch(`/marketplace/offer/${order}/complete`, { + method: 'POST', + }) + ).json(); + } + }, [order]); + + return ( +
+ {buyer && ( +
+ Complete order and pay early +
+ )} +
+ Order in progress +
+
+ ); +}; + +export const CreateNewOrder: FC<{ group: string }> = (props) => { + const { group } = props; + const modals = useModals(); + + const createOrder = useCallback(() => { + modals.openModal({ + classNames: { + modal: 'bg-transparent text-white', + }, + withCloseButton: false, + size: '100%', + children: , + }); + }, [group]); + + return ( +
+ Create a new offer +
+ ); +}; + +enum OrderOptions { + CREATE_A_NEW_ORDER = 'CREATE_A_NEW_ORDER', + IN_PROGRESS = 'IN_PROGRESS', + WAITING_PUBLICATION = 'WAITING_PUBLICATION', +} + +export const OrderTopActions = () => { + const { message } = useContext(MarketplaceProvider); + const user = useUser(); + + const isBuyer = useMemo(() => { + return user?.id === message?.buyerId; + }, [user, message]); + + const myOptions: OrderOptions | undefined = useMemo(() => { + if ( + !isBuyer && + (!message?.orders.length || + message.orders[0].status === 'COMPLETED' || + message.orders[0].status === 'CANCELED') + ) { + return OrderOptions.CREATE_A_NEW_ORDER; + } + + if (message?.orders?.[0]?.status === 'PENDING') { + return OrderOptions.IN_PROGRESS; + } + + if (message?.orders?.[0]?.status === 'ACCEPTED') { + return OrderOptions.WAITING_PUBLICATION; + } + }, [isBuyer, user, message]); + + if (!myOptions) { + return null; + } + + switch (myOptions) { + case OrderOptions.CREATE_A_NEW_ORDER: + return ; + case OrderOptions.WAITING_PUBLICATION: + return ; + } + return
; +}; diff --git a/apps/frontend/src/components/marketplace/preview.popup.dynamic.tsx b/apps/frontend/src/components/marketplace/preview.popup.dynamic.tsx new file mode 100644 index 00000000..c806bcd8 --- /dev/null +++ b/apps/frontend/src/components/marketplace/preview.popup.dynamic.tsx @@ -0,0 +1,33 @@ +import 'reflect-metadata'; +import { FC, useCallback } from 'react'; +import { Post as PrismaPost } from '.prisma/client'; +import { Providers } from '@gitroom/frontend/components/launches/providers/show.all.providers'; + +export const PreviewPopupDynamic: FC<{ + postId: string; + providerId: string; + post: { + integration: string; + group: string; + posts: PrismaPost[]; + settings: any; + }; +}> = (props) => { + const { component: ProviderComponent } = Providers.find( + (p) => p.identifier === props.providerId + )!; + + return ( + ({ + id: p.id, + content: p.content, + image: p.image, + }))} + /> + ); +}; diff --git a/apps/frontend/src/components/marketplace/seller.tsx b/apps/frontend/src/components/marketplace/seller.tsx index b7c37120..0f9307de 100644 --- a/apps/frontend/src/components/marketplace/seller.tsx +++ b/apps/frontend/src/components/marketplace/seller.tsx @@ -9,6 +9,7 @@ import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import useSWR from 'swr'; import { Input } from '@gitroom/react/form/input'; import { useDebouncedCallback } from 'use-debounce'; +import { OrderList } from '@gitroom/frontend/components/marketplace/order.list'; export const Seller = () => { const fetch = useFetch(); @@ -106,75 +107,80 @@ export const Seller = () => { } return ( -
-
-

Seller Mode

-
-
- {!!data?.picture?.path && ( - avatar + <> + +
+
+

Seller Mode

+
+
+ {!!data?.picture?.path && ( + avatar + )} +
+
{data?.fullname || ''}
+ {data?.connectedAccount && ( +
+ +
Active
+
)} -
-
{data?.fullname || ''}
- {data?.connectedAccount && ( -
- -
Active
+
+
+
- )} -
-
-
-
-
-

Details

-
- {tagsList.map((tag) => ( - key.key)} - search={false} - options={tag.options} - title={tag.name} - /> - ))} -
- Audience Size -
-
-
- +

Details

+
+ {tagsList.map((tag) => ( + key.key)} + search={false} + options={tag.options} + title={tag.name} /> + ))} +
+ Audience Size +
+
+
+ +
-
+ ); }; diff --git a/apps/frontend/src/components/marketplace/special.message.tsx b/apps/frontend/src/components/marketplace/special.message.tsx new file mode 100644 index 00000000..d5998bf8 --- /dev/null +++ b/apps/frontend/src/components/marketplace/special.message.tsx @@ -0,0 +1,438 @@ +'use client'; + +import React, { FC, useCallback, useContext, useMemo } from 'react'; +import { MarketplaceProvider } from '@gitroom/frontend/components/marketplace/marketplace.provider'; +import { useUser } from '@gitroom/frontend/components/layout/user.context'; +import { Button } from '@gitroom/react/form/button'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import useSWR from 'swr'; +import { capitalize } from 'lodash'; +import removeMd from 'remove-markdown'; +import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; +import { useModals } from '@mantine/modals'; +import { Post as PrismaPost } from '@prisma/client'; +import dynamic from 'next/dynamic'; +import { IntegrationContext } from '@gitroom/frontend/components/launches/helpers/use.integration'; +import dayjs from 'dayjs'; +const PreviewPopupDynamic = dynamic(() => + import('@gitroom/frontend/components/marketplace/preview.popup.dynamic').then( + (mod) => mod.PreviewPopupDynamic + ) +); + +interface SpecialMessageInterface { + type: string; + data: { + id: string; + [key: string]: any; + }; +} + +export const OrderCompleted: FC = () => { + return ( +
+
+
Order completed
+
+
+ The order has been completed +
+
+ ); +}; + +export const Published: FC<{ + isCurrentOrder: boolean; + isSellerOrBuyer: 'BUYER' | 'SELLER'; + orderStatus: string; + data: SpecialMessageInterface; +}> = (props) => { + const { data, isSellerOrBuyer } = props; + return ( +
+
+
+ {isSellerOrBuyer === 'BUYER' ? 'Your' : 'The'} post has been published +
+
+ +
+
+
+ platform + {data.data.name} +
+ +
{data.data.name}
+
+
+ URL:{' '} + + {data.data.url} + +
+
+
+ ); +}; + +const PreviewPopup: FC<{ + postId: string; + providerId: string; + post: { + integration: string; + group: string; + posts: PrismaPost[]; + settings: any; + }; +}> = (props) => { + const modal = useModals(); + const close = useCallback(() => { + return modal.closeAll(); + }, []); + return ( +
+ + +
+ ); +}; + +export const Offer: FC<{ + isCurrentOrder: boolean; + isSellerOrBuyer: 'BUYER' | 'SELLER'; + orderStatus: string; + data: SpecialMessageInterface; +}> = (props) => { + const { data, isSellerOrBuyer, isCurrentOrder, orderStatus } = props; + const fetch = useFetch(); + + const acceptOrder = useCallback(async () => { + const { url } = await ( + await fetch(`/marketplace/orders/${data.data.id}/payment`, { + method: 'POST', + }) + ).json(); + + window.location.href = url; + }, [data.data.id]); + + const totalPrice = useMemo(() => { + return data?.data?.ordersItems?.reduce((all: any, current: any) => { + return all + current.price * current.quantity; + }, 0); + }, [data?.data?.ordersItems]); + return ( +
+
+
New Offer
+
${totalPrice}
+
+
+
Platform
+ {data.data.ordersItems.map((item: any) => ( +
+
+ platform + {item.integration.name} +
+
{item.integration.name}
+
{item.quantity} Posts
+
+ ))} + {orderStatus === 'PENDING' && + isCurrentOrder && + isSellerOrBuyer === 'BUYER' && ( +
+ +
+ )} + {orderStatus === 'ACCEPTED' && ( +
+ +
+ )} +
+
+ ); +}; + +export const Post: FC<{ + isCurrentOrder: boolean; + isSellerOrBuyer: 'BUYER' | 'SELLER'; + orderStatus: string; + message: string; + data: SpecialMessageInterface; +}> = (props) => { + const { data, isSellerOrBuyer, message, isCurrentOrder, orderStatus } = props; + const fetch = useFetch(); + const modal = useModals(); + + const getIntegration = useCallback(async () => { + return ( + await fetch( + `/integrations/${data.data.integration}?order=${data.data.id}`, + { + method: 'GET', + } + ) + ).json(); + }, []); + + const requestRevision = useCallback(async () => { + if ( + !(await deleteDialog( + 'Are you sure you want to request a revision?', + 'Yes' + )) + ) { + return; + } + + await fetch(`/marketplace/posts/${data.data.postId}/revision`, { + method: 'POST', + body: JSON.stringify({ + message, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }, [data]); + + const requestApproved = useCallback(async () => { + if ( + !(await deleteDialog( + 'Are you sure you want to approve this post?', + 'Yes' + )) + ) { + return; + } + + await fetch(`/marketplace/posts/${data.data.postId}/approve`, { + method: 'POST', + body: JSON.stringify({ + message, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }, [data]); + + const preview = useCallback(async () => { + const post = await ( + await fetch(`/marketplace/posts/${data.data.postId}`) + ).json(); + + const integration = await getIntegration(); + + modal.openModal({ + classNames: { + modal: 'bg-transparent text-white', + }, + size: 'auto', + withCloseButton: false, + children: ( + + + + ), + }); + }, [data?.data]); + + const { data: integrationData } = useSWR<{ + id: string; + name: string; + picture: string; + providerIdentifier: string; + }>(`/integrations/${data.data.integration}`, getIntegration); + + return ( +
+
+
+ Post Draft {capitalize(integrationData?.providerIdentifier || '')} +
+
+
+
+
+ platform + {integrationData?.name} +
+
+
+
{integrationData?.name}
+
{removeMd(data.data.description)}
+ {isSellerOrBuyer === 'BUYER' && + isCurrentOrder && + data.data.status === 'PENDING' && + orderStatus === 'ACCEPTED' && ( +
+ + + +
+ )} + + {data.data.status === 'REVISION' && ( +
+ +
+ )} + {data.data.status === 'APPROVED' && ( +
+ +
+ )} + + {data.data.status === 'CANCELED' && ( +
+ +
+ )} +
+
+
+ ); +}; + +export const SpecialMessage: FC<{ + data: SpecialMessageInterface; + id: string; +}> = (props) => { + const { data, id } = props; + const { message } = useContext(MarketplaceProvider); + const user = useUser(); + + const isCurrentOrder = useMemo(() => { + return message?.orders?.[0]?.id === data?.data?.id; + }, [message, data]); + + const isSellerOrBuyer = useMemo(() => { + return user?.id === message?.buyerId ? 'BUYER' : 'SELLER'; + }, [user, message]); + + if (data.type === 'offer') { + return ( + + ); + } + + if (data.type === 'post') { + return ( + + ); + } + + if (data.type === 'published') { + return ( + + ); + } + + if (data.type === 'order-completed') { + return ; + } + + return null; +}; diff --git a/apps/frontend/src/components/messages/layout.tsx b/apps/frontend/src/components/messages/layout.tsx index e1208de4..f4564eb3 100644 --- a/apps/frontend/src/components/messages/layout.tsx +++ b/apps/frontend/src/components/messages/layout.tsx @@ -1,50 +1,28 @@ 'use client'; -import { FC, ReactNode, useCallback } from 'react'; +import { FC, ReactNode, useCallback, useMemo } from 'react'; import clsx from 'clsx'; import useSWR from 'swr'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { useParams, useRouter } from 'next/navigation'; - -export interface Root2 { - id: string; - buyerId: string; - sellerId: string; - createdAt: string; - updatedAt: string; - seller: Seller; - messages: Message[]; -} - -export interface Seller { - name: any; - picture: Picture; -} - -export interface Picture { - id: string; - path: string; -} - -export interface Message { - id: string; - from: string; - content: string; - groupId: string; - createdAt: string; - updatedAt: string; - deletedAt: any; -} +import { MarketplaceProvider, Root2 } from '@gitroom/frontend/components/marketplace/marketplace.provider'; +import { useUser } from '@gitroom/frontend/components/layout/user.context'; +import { Button } from '@gitroom/react/form/button'; const Card: FC<{ message: Root2 }> = (props) => { const { message } = props; const path = useParams(); const router = useRouter(); + const user = useUser(); const changeConversation = useCallback(() => { router.push(`/messages/${message.id}`); }, []); + const showFrom = useMemo(() => { + return user?.id === message?.buyerId ? message?.seller : message?.buyer; + }, [message, user]); + return (
= (props) => { )} >
- {message?.seller?.picture?.path && ( - {message.seller.name + {showFrom?.picture?.path && ( + {showFrom.name )}
-
{message.seller.name || 'Noname'}
+
{showFrom?.name || 'Noname'}
{message.messages[0]?.content}
@@ -73,12 +55,37 @@ const Card: FC<{ message: Root2 }> = (props) => { export const Layout: FC<{ renderChildren: ReactNode }> = (props) => { const { renderChildren } = props; const fetch = useFetch(); + const params = useParams(); + const router = useRouter(); const loadMessagesGroup = useCallback(async () => { return await (await fetch('/messages')).json(); }, []); - const messagesGroup = useSWR('messagesGroup', loadMessagesGroup); + const messagesGroup = useSWR('messagesGroup', loadMessagesGroup, { + refreshInterval: 5000 + }); + + const marketplace = useCallback(() => { + router.push('/marketplace'); + }, [router]); + + const currentMessage = useMemo(() => { + return messagesGroup?.data?.find((message) => message.id === params.id); + }, [params.id, messagesGroup.data]); + + if (messagesGroup.isLoading) { + return null; + } + if (!messagesGroup.isLoading && !messagesGroup?.data?.length) { + return ( +
+
+
There are no messages yet.
Checkout the Marketplace
+
+
+ ); + } return (
@@ -90,7 +97,9 @@ export const Layout: FC<{ renderChildren: ReactNode }> = (props) => { ))}
-
{renderChildren}
+ +
{renderChildren}
+
); }; diff --git a/apps/frontend/src/components/messages/messages.tsx b/apps/frontend/src/components/messages/messages.tsx index 54a8f51e..bab9cbfe 100644 --- a/apps/frontend/src/components/messages/messages.tsx +++ b/apps/frontend/src/components/messages/messages.tsx @@ -8,12 +8,11 @@ export interface Root { sellerId: string; createdAt: string; updatedAt: string; - seller: SellerBuyer; - buyer: SellerBuyer; messages: Message[]; } export interface SellerBuyer { + id: string; name?: string; picture: Picture; } @@ -27,6 +26,7 @@ export interface Message { id: string; from: string; content: string; + special?: string; groupId: string; createdAt: string; updatedAt: string; @@ -37,7 +37,16 @@ import { Textarea } from '@gitroom/react/form/textarea'; import interClass from '@gitroom/react/helpers/inter.font'; import clsx from 'clsx'; import useSWR from 'swr'; -import { FC, UIEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + FC, + UIEventHandler, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useParams } from 'next/navigation'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { reverse } from 'lodash'; @@ -45,6 +54,11 @@ import { FormProvider, SubmitHandler, useForm } from 'react-hook-form'; import { classValidatorResolver } from '@hookform/resolvers/class-validator'; import { AddMessageDto } from '@gitroom/nestjs-libraries/dtos/messages/add.message'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { useUser } from '@gitroom/frontend/components/layout/user.context'; +import { OrderTopActions } from '@gitroom/frontend/components/marketplace/order.top.actions'; +import { MarketplaceProvider } from '@gitroom/frontend/components/marketplace/marketplace.provider'; +import { SpecialMessage } from '@gitroom/frontend/components/marketplace/special.message'; +import { usePageVisibility } from '@gitroom/react/helpers/use.is.visible'; export const Message: FC<{ message: Message; @@ -53,16 +67,40 @@ export const Message: FC<{ scrollDown: () => void; }> = (props) => { const { message, seller, buyer, scrollDown } = props; + const user = useUser(); + + const amITheBuyerOrSeller = useMemo(() => { + return user?.id === buyer?.id ? 'BUYER' : 'SELLER'; + }, [buyer, user]); + useEffect(() => { scrollDown(); }, []); + const person = useMemo(() => { - return message.from === 'BUYER' ? buyer : seller; + if (message.from === 'BUYER') { + return buyer; + } + + if (message.from === 'SELLER') { + return seller; + } + }, [amITheBuyerOrSeller, buyer, seller, message]); + + const data = useMemo(() => { + if (!message.special) { + return false; + } + + return JSON.parse(message.special); }, [message]); const isMe = useMemo(() => { - return message.from === 'BUYER'; - }, []); + return ( + (amITheBuyerOrSeller === 'BUYER' && message.from === 'BUYER') || + (amITheBuyerOrSeller === 'SELLER' && message.from === 'SELLER') + ); + }, [amITheBuyerOrSeller, message]); const time = useMemo(() => { return dayjs(message.createdAt).format('h:mm A'); @@ -71,7 +109,7 @@ export const Message: FC<{
- {!!person.picture?.path && ( + {!!person?.picture?.path && ( person
-
{isMe ? 'Me' : person.name}
+
{isMe ? 'Me' : person?.name}
{time}
@@ -93,6 +131,7 @@ export const Message: FC<{ )} > {message.content} + {data && }
@@ -102,16 +141,28 @@ export const Message: FC<{ const Page: FC<{ page: number; group: string; refChange: any }> = (props) => { const { page, group, refChange } = props; const fetch = useFetch(); + const { message } = useContext(MarketplaceProvider); + const visible = usePageVisibility(page); const loadMessages = useCallback(async () => { return await (await fetch(`/messages/${group}/${page}`)).json(); }, []); - const { data, mutate } = useSWR(`load-${page}-${group}`, loadMessages); + const { data, mutate } = useSWR(`load-${page}-${group}`, loadMessages, { + ...(page === 1 + ? { + refreshInterval: visible ? 5000 : 0, + refreshWhenHidden: false, + refreshWhenOffline: false, + revalidateOnFocus: false, + revalidateIfStale: false, + } + : {}), + }); const scrollDown = useCallback(() => { if (page > 1) { - return ; + return; } // @ts-ignore refChange.current?.scrollTo(0, refChange.current.scrollHeight); @@ -123,12 +174,12 @@ const Page: FC<{ page: number; group: string; refChange: any }> = (props) => { return ( <> - {messages.map((message) => ( + {messages.map((m) => ( ))} @@ -138,9 +189,16 @@ const Page: FC<{ page: number; group: string; refChange: any }> = (props) => { export const Messages = () => { const [pages, setPages] = useState([makeId(3)]); + const user = useUser(); const params = useParams(); const fetch = useFetch(); const ref = useRef(null); + const { message } = useContext(MarketplaceProvider); + + const showFrom = useMemo(() => { + return user?.id === message?.buyerId ? message?.seller : message?.buyer; + }, [message, user]); + const resolver = useMemo(() => { return classValidatorResolver(AddMessageDto); }, []); @@ -154,7 +212,10 @@ export const Messages = () => { return await (await fetch(`/messages/${params.id}/1`)).json(); }, []); - const { data, mutate, isLoading } = useSWR(`load-1-${params.id}`, loadMessages); + const { data, mutate, isLoading } = useSWR( + `load-1-${params.id}`, + loadMessages + ); const submit: SubmitHandler = useCallback(async (values) => { await fetch(`/messages/${params.id}`, { @@ -165,14 +226,17 @@ export const Messages = () => { form.reset(); }, []); - const changeScroll: UIEventHandler = useCallback((e) => { - // @ts-ignore - if (e.target.scrollTop === 0) { + const changeScroll: UIEventHandler = useCallback( + (e) => { // @ts-ignore - e.target.scrollTop = 1; - setPages((prev) => [...prev, makeId(3)]); - } - }, [pages, setPages]); + if (e.target.scrollTop === 0) { + // @ts-ignore + e.target.scrollTop = 1; + setPages((prev) => [...prev, makeId(3)]); + } + }, + [pages, setPages] + ); return (
@@ -180,15 +244,20 @@ export const Messages = () => {
- {!!data?.seller?.picture?.path && ( + {!!showFrom?.picture?.path && ( seller )}
-
{data?.seller?.name || 'Noname'}
+
+ {showFrom?.name || 'Noname'} +
+
+ +
{ ref={ref} > {pages.map((p, index) => ( - + ))}
diff --git a/apps/workers/src/app/posts.controller.ts b/apps/workers/src/app/posts.controller.ts index baaa930d..34b48b69 100644 --- a/apps/workers/src/app/posts.controller.ts +++ b/apps/workers/src/app/posts.controller.ts @@ -1,15 +1,17 @@ -import {Controller} from '@nestjs/common'; -import {EventPattern, Transport} from '@nestjs/microservices'; -import {PostsService} from "@gitroom/nestjs-libraries/database/prisma/posts/posts.service"; +import { Controller } from '@nestjs/common'; +import { EventPattern, Transport } from '@nestjs/microservices'; +import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; @Controller() export class PostsController { - constructor( - private _postsService: PostsService - ) { - } + constructor(private _postsService: PostsService) {} @EventPattern('post', Transport.REDIS) - async checkStars(data: {id: string}) { + async checkStars(data: { id: string }) { return this._postsService.post(data.id); } + + @EventPattern('submit', Transport.REDIS) + async submitOrderItemForPayout(data: { id: string, releaseURL: string }) { + return this._postsService.payout(data.id, data.releaseURL); + } } diff --git a/libraries/nestjs-libraries/src/database/prisma/database.module.ts b/libraries/nestjs-libraries/src/database/prisma/database.module.ts index 43c20396..c015ed8e 100644 --- a/libraries/nestjs-libraries/src/database/prisma/database.module.ts +++ b/libraries/nestjs-libraries/src/database/prisma/database.module.ts @@ -24,6 +24,7 @@ import { ItemUserRepository } from '@gitroom/nestjs-libraries/database/prisma/ma import { ItemUserService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/item.user.service'; import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service'; import { MessagesRepository } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.repository'; +import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service'; @Global() @Module({ @@ -46,6 +47,7 @@ import { MessagesRepository } from '@gitroom/nestjs-libraries/database/prisma/ma IntegrationRepository, PostsService, PostsRepository, + StripeService, MessagesRepository, MediaService, MediaRepository, diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts index e7409014..c6e8ca6b 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts @@ -1,6 +1,7 @@ import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; import { Injectable } from '@nestjs/common'; import dayjs from 'dayjs'; +import * as console from 'node:console'; @Injectable() export class IntegrationRepository { @@ -77,6 +78,37 @@ export class IntegrationRepository { }); } + async getIntegrationForOrder(id: string, order: string, user: string, org: string) { + console.log(id, order, user, org); + const integration = await this._posts.model.post.findFirst({ + where: { + integrationId: id, + submittedForOrder: { + id: order, + messageGroup: { + OR: [ + {sellerId: user}, + {buyerId: user}, + {buyerOrganizationId: org}, + ] + } + } + }, + select: { + integration: { + select: { + id: true, + name: true, + picture: true, + providerIdentifier: true, + }, + } + } + }); + + return integration?.integration; + } + getIntegrationsList(org: string) { return this._integration.model.integration.findMany({ where: { diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts index 54ea01a4..f4e2cf9a 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -36,6 +36,10 @@ export class IntegrationService { return this._integrationRepository.getIntegrationsList(org); } + getIntegrationForOrder(id: string, order: string, user: string, org: string) { + return this._integrationRepository.getIntegrationForOrder(id, order, user, org); + } + getIntegrationById(org: string, id: string) { return this._integrationRepository.getIntegrationById(org, id); } diff --git a/libraries/nestjs-libraries/src/database/prisma/marketplace/messages.repository.ts b/libraries/nestjs-libraries/src/database/prisma/marketplace/messages.repository.ts index 3702310d..f9d9ff36 100644 --- a/libraries/nestjs-libraries/src/database/prisma/marketplace/messages.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/marketplace/messages.repository.ts @@ -1,26 +1,38 @@ import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; import { Injectable } from '@nestjs/common'; import { NewConversationDto } from '@gitroom/nestjs-libraries/dtos/marketplace/new.conversation.dto'; -import { From } from '@prisma/client'; +import { From, OrderStatus } from '@prisma/client'; import { AddMessageDto } from '@gitroom/nestjs-libraries/dtos/messages/add.message'; +import { CreateOfferDto } from '@gitroom/nestjs-libraries/dtos/marketplace/create.offer.dto'; @Injectable() export class MessagesRepository { constructor( private _messagesGroup: PrismaRepository<'messagesGroup'>, - private _messages: PrismaRepository<'messages'> + private _messages: PrismaRepository<'messages'>, + private _orders: PrismaRepository<'orders'>, + private _organizations: PrismaRepository<'organization'>, + private _post: PrismaRepository<'post'>, + private _payoutProblems: PrismaRepository<'payoutProblems'>, + private _users: PrismaRepository<'user'> ) {} - async createConversation(userId: string, body: NewConversationDto) { + async createConversation( + userId: string, + organizationId: string, + body: NewConversationDto + ) { const { id } = (await this._messagesGroup.model.messagesGroup.findFirst({ where: { + buyerOrganizationId: organizationId, buyerId: userId, sellerId: body.to, }, })) || (await this._messagesGroup.model.messagesGroup.create({ data: { + buyerOrganizationId: organizationId, buyerId: userId, sellerId: body.to, }, @@ -46,77 +58,22 @@ export class MessagesRepository { return { id }; } - async getMessagesGroup(userId: string) { + async getMessagesGroup(userId: string, organizationId: string) { return this._messagesGroup.model.messagesGroup.findMany({ where: { - buyerId: userId, + OR: [ + { + buyerOrganizationId: organizationId, + buyerId: userId, + }, + { + sellerId: userId, + }, + ], }, orderBy: { updatedAt: 'desc', }, - include: { - seller: { - select: { - name: true, - picture: { - select: { - id: true, - path: true, - }, - }, - }, - }, - messages: { - orderBy: { - createdAt: 'desc', - }, - take: 1, - }, - }, - }); - } - - async createMessage(userId: string, groupId: string, body: AddMessageDto) { - const group = await this._messagesGroup.model.messagesGroup.findFirst({ - where: { - id: groupId, - OR: [ - { - buyerId: userId, - }, - { - sellerId: userId, - }, - ], - }, - }); - - if (!group) { - throw new Error('Group not found'); - } - - await this._messages.model.messages.create({ - data: { - groupId, - from: group.buyerId === userId ? From.BUYER : From.SELLER, - content: body.message, - }, - }); - } - - async getMessages(userId: string, groupId: string, page: number) { - return this._messagesGroup.model.messagesGroup.findFirst({ - where: { - id: groupId, - OR: [ - { - buyerId: userId, - }, - { - sellerId: userId, - }, - ], - }, include: { seller: { select: { @@ -142,6 +99,100 @@ export class MessagesRepository { }, }, }, + orders: { + orderBy: { + createdAt: 'desc', + }, + take: 1, + }, + messages: { + orderBy: { + createdAt: 'desc', + }, + take: 1, + }, + }, + }); + } + + async createMessage( + userId: string, + orgId: string, + groupId: string, + body: AddMessageDto + ) { + const group = await this._messagesGroup.model.messagesGroup.findFirst({ + where: { + id: groupId, + OR: [ + { + buyerOrganizationId: orgId, + buyerId: userId, + }, + { + sellerId: userId, + }, + ], + }, + }); + + if (!group) { + throw new Error('Group not found'); + } + + const create = await this.createNewMessage( + groupId, + group.buyerId === userId ? From.BUYER : From.SELLER, + body.message + ); + + await this._messagesGroup.model.messagesGroup.update({ + where: { + id: groupId, + }, + data: { + updatedAt: new Date(), + }, + }); + + if (userId === group.buyerId) { + return create.group.seller; + } + + return create.group.buyer; + } + + async updateOrderOnline(userId: string) { + await this._users.model.user.update({ + where: { + id: userId, + }, + data: { + lastOnline: new Date(), + }, + }); + } + + async getMessages( + userId: string, + organizationId: string, + groupId: string, + page: number + ) { + return this._messagesGroup.model.messagesGroup.findFirst({ + where: { + id: groupId, + OR: [ + { + buyerOrganizationId: organizationId, + buyerId: userId, + }, + { + sellerId: userId, + }, + ], + }, + include: { messages: { orderBy: { createdAt: 'desc', @@ -152,4 +203,697 @@ export class MessagesRepository { }, }); } + + async createOffer(userId: string, body: CreateOfferDto) { + const messageGroup = + await this._messagesGroup.model.messagesGroup.findFirst({ + where: { + id: body.group, + sellerId: userId, + }, + select: { + id: true, + buyer: { + select: { + id: true, + }, + }, + orders: { + orderBy: { + createdAt: 'desc', + }, + take: 1, + }, + }, + }); + + if (!messageGroup?.id) { + throw new Error('Group not found'); + } + + if ( + messageGroup.orders.length && + messageGroup.orders[0].status !== 'COMPLETED' && + messageGroup.orders[0].status !== 'CANCELED' + ) { + throw new Error('Order already exists'); + } + + const data = await this._orders.model.orders.create({ + data: { + sellerId: userId, + buyerId: messageGroup.buyer.id, + messageGroupId: messageGroup.id, + ordersItems: { + createMany: { + data: body.socialMedia.map((item) => ({ + quantity: item.total, + integrationId: item.value, + price: item.price, + })), + }, + }, + status: 'PENDING', + }, + select: { + id: true, + ordersItems: { + select: { + quantity: true, + price: true, + integration: { + select: { + name: true, + providerIdentifier: true, + picture: true, + id: true, + }, + }, + }, + }, + }, + }); + + await this._messages.model.messages.create({ + data: { + groupId: body.group, + from: From.SELLER, + content: '', + special: JSON.stringify({ type: 'offer', data: data }), + }, + }); + + return { success: true }; + } + + async createNewMessage( + group: string, + from: From, + content: string, + special?: object + ) { + return this._messages.model.messages.create({ + data: { + groupId: group, + from, + content, + special: JSON.stringify(special), + }, + select: { + id: true, + group: { + select: { + buyer: { + select: { + lastOnline: true, + id: true, + organizations: true, + }, + }, + seller: { + select: { + lastOnline: true, + id: true, + organizations: true, + }, + }, + }, + }, + }, + }); + } + + async getOrderDetails( + userId: string, + organizationId: string, + orderId: string + ) { + const order = await this._messagesGroup.model.messagesGroup.findFirst({ + where: { + buyerId: userId, + buyerOrganizationId: organizationId, + }, + select: { + buyer: true, + seller: true, + orders: { + include: { + ordersItems: { + select: { + quantity: true, + integration: true, + price: true, + }, + }, + }, + where: { + id: orderId, + status: 'PENDING', + }, + }, + }, + }); + + if (!order?.orders[0]?.id) { + throw new Error('Order not found'); + } + + return { + buyer: order.buyer, + seller: order.seller, + order: order.orders[0]!, + }; + } + + async canAddPost(id: string, order: string, integrationId: string) { + const findOrder = await this._orders.model.orders.findFirst({ + where: { + id: order, + status: 'ACCEPTED', + }, + select: { + posts: true, + ordersItems: true, + }, + }); + + if (!findOrder) { + return false; + } + + if ( + findOrder.posts.find( + (p) => p.id === id && p.approvedSubmitForOrder === 'YES' + ) + ) { + return false; + } + + if ( + findOrder.posts.find( + (p) => + p.id === id && p.approvedSubmitForOrder === 'WAITING_CONFIRMATION' + ) + ) { + return true; + } + + const postsForIntegration = findOrder.ordersItems.filter( + (p) => p.integrationId === integrationId + ); + const totalPostsRequired = postsForIntegration.reduce( + (acc, item) => acc + item.quantity, + 0 + ); + const usedPosts = findOrder.posts.filter( + (p) => + p.integrationId === integrationId && + ['WAITING_CONFIRMATION', 'YES'].indexOf(p.approvedSubmitForOrder) > -1 + ).length; + + return totalPostsRequired > usedPosts; + } + + changeOrderStatus( + orderId: string, + status: OrderStatus, + paymentIntent?: string + ) { + return this._orders.model.orders.update({ + where: { + id: orderId, + }, + data: { + status, + captureId: paymentIntent, + }, + }); + } + + async getMarketplaceAvailableOffers(orgId: string, id: string) { + const offers = await this._organizations.model.organization.findFirst({ + where: { + id: orgId, + }, + select: { + users: { + select: { + user: { + select: { + orderSeller: { + where: { + status: 'ACCEPTED', + }, + select: { + id: true, + posts: { + where: { + deletedAt: null, + }, + select: { + id: true, + integrationId: true, + approvedSubmitForOrder: true, + }, + }, + messageGroup: { + select: { + buyerOrganizationId: true, + }, + }, + buyer: { + select: { + id: true, + name: true, + picture: { + select: { + id: true, + path: true, + }, + }, + }, + }, + ordersItems: { + select: { + quantity: true, + integration: { + select: { + id: true, + name: true, + providerIdentifier: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const allOrders = + offers?.users.flatMap((user) => user.user.orderSeller) || []; + + const onlyValidItems = allOrders.filter( + (order) => + (order.posts.find((p) => p.id === id) + ? 0 + : order.posts.filter((f) => f.approvedSubmitForOrder !== 'NO') + .length) < + order.ordersItems.reduce((acc, item) => acc + item.quantity, 0) + ); + + return onlyValidItems + .map((order) => { + const postsNumbers = order.posts + .filter( + (p) => + ['WAITING_CONFIRMATION', 'YES'].indexOf( + p.approvedSubmitForOrder + ) > -1 + ) + .reduce((acc, post) => { + acc[post.integrationId] = acc[post.integrationId] + 1 || 1; + return acc; + }, {} as { [key: string]: number }); + + const missing = order.ordersItems.map((item) => { + return { + integration: item, + missing: item.quantity - (postsNumbers[item.integration.id] || 0), + }; + }); + + return { + id: order.id, + usedIds: order.posts.map((p) => ({ + id: p.id, + status: p.approvedSubmitForOrder, + })), + buyer: order.buyer, + missing, + }; + }) + .filter((f) => f.missing.length); + } + + async requestRevision( + userId: string, + orgId: string, + postId: string, + message: string + ) { + const loadMessage = await this._messages.model.messages.findFirst({ + where: { + id: message, + group: { + buyerOrganizationId: orgId, + }, + }, + select: { + id: true, + special: true, + }, + }); + + const post = await this._post.model.post.findFirst({ + where: { + id: postId, + approvedSubmitForOrder: 'WAITING_CONFIRMATION', + deletedAt: null, + }, + }); + + if (post && loadMessage) { + const special = JSON.parse(loadMessage.special!); + special.data.status = 'REVISION'; + await this._messages.model.messages.update({ + where: { + id: message, + }, + data: { + special: JSON.stringify(special), + }, + }); + + await this._post.model.post.update({ + where: { + id: postId, + deletedAt: null, + }, + data: { + approvedSubmitForOrder: 'NO', + }, + }); + } + } + + async requestCancel(orgId: string, postId: string) { + const getPost = await this._post.model.post.findFirst({ + where: { + id: postId, + organizationId: orgId, + approvedSubmitForOrder: { + in: ['WAITING_CONFIRMATION', 'YES'], + }, + }, + select: { + lastMessage: true, + }, + }); + + if (!getPost) { + throw new Error('Post not found'); + } + + await this._post.model.post.update({ + where: { + id: postId, + }, + data: { + approvedSubmitForOrder: 'NO', + }, + }); + + const special = JSON.parse(getPost.lastMessage!.special!); + special.data.status = 'CANCELED'; + await this._messages.model.messages.update({ + where: { + id: getPost.lastMessage!.id, + }, + data: { + special: JSON.stringify(special), + }, + }); + } + + async requestApproved( + userId: string, + orgId: string, + postId: string, + message: string + ) { + const loadMessage = await this._messages.model.messages.findFirst({ + where: { + id: message, + group: { + buyerOrganizationId: orgId, + }, + }, + select: { + id: true, + special: true, + }, + }); + + const post = await this._post.model.post.findFirst({ + where: { + id: postId, + approvedSubmitForOrder: 'WAITING_CONFIRMATION', + deletedAt: null, + }, + }); + + if (post && loadMessage) { + const special = JSON.parse(loadMessage.special!); + special.data.status = 'APPROVED'; + await this._messages.model.messages.update({ + where: { + id: message, + }, + data: { + special: JSON.stringify(special), + }, + }); + + await this._post.model.post.update({ + where: { + id: postId, + deletedAt: null, + }, + data: { + approvedSubmitForOrder: 'YES', + }, + }); + + return post; + } + + return false; + } + + completeOrder(orderId: string) { + return this._orders.model.orders.update({ + where: { + id: orderId, + }, + data: { + status: 'COMPLETED', + }, + }); + } + + async completeOrderAndPay(orgId: string, order: string) { + const findOrder = await this._orders.model.orders.findFirst({ + where: { + id: order, + messageGroup: { + buyerOrganizationId: orgId, + }, + }, + select: { + captureId: true, + seller: { + select: { + account: true, + id: true, + }, + }, + ordersItems: true, + posts: true, + }, + }); + + if (!findOrder) { + return false; + } + + const releasedPosts = findOrder.posts.filter((p) => p.releaseURL); + const nonReleasedPosts = findOrder.posts.filter((p) => !p.releaseURL); + + const totalPosts = releasedPosts.reduce((acc, item) => { + acc[item.integrationId] = (acc[item.integrationId] || 0) + 1; + return acc; + }, {} as { [key: string]: number }); + + const totalOrderItems = findOrder.ordersItems.reduce((acc, item) => { + acc[item.integrationId] = (acc[item.integrationId] || 0) + item.quantity; + return acc; + }, {} as { [key: string]: number }); + + const calculate = Object.keys(totalOrderItems).reduce((acc, key) => { + acc.push({ + price: findOrder.ordersItems.find((p) => p.integrationId === key)! + .price, + quantity: totalOrderItems[key] - (totalPosts[key] || 0), + }); + return acc; + }, [] as { price: number; quantity: number }[]); + + const price = calculate.reduce((acc, item) => { + acc += item.price * item.quantity; + return acc; + }, 0); + + return { + price, + account: findOrder.seller.account, + charge: findOrder.captureId, + posts: nonReleasedPosts, + sellerId: findOrder.seller.id, + }; + } + + payoutProblem( + orderId: string, + sellerId: string, + amount: number, + postId?: string + ) { + return this._payoutProblems.model.payoutProblems.create({ + data: { + amount, + orderId, + ...(postId ? { postId } : {}), + userId: sellerId, + status: 'PAYMENT_ERROR', + }, + }); + } + + async getOrders(userId: string, orgId: string, type: 'seller' | 'buyer') { + const orders = await this._orders.model.orders.findMany({ + where: { + status: { + in: ['ACCEPTED', 'PENDING', 'COMPLETED'], + }, + ...(type === 'seller' + ? { + sellerId: userId, + } + : { + messageGroup: { + buyerOrganizationId: orgId, + }, + }), + }, + orderBy: { + updatedAt: 'desc', + }, + select: { + id: true, + status: true, + ...(type === 'seller' + ? { + buyer: { + select: { + name: true, + }, + }, + } + : { + seller: { + select: { + name: true, + }, + }, + }), + ordersItems: { + select: { + id: true, + quantity: true, + price: true, + integration: { + select: { + id: true, + picture: true, + name: true, + providerIdentifier: true, + }, + }, + }, + }, + posts: { + select: { + id: true, + integrationId: true, + releaseURL: true, + approvedSubmitForOrder: true, + state: true, + }, + }, + }, + }); + + return { + orders: await Promise.all( + orders.map(async (order) => { + return { + id: order.id, + status: order.status, + // @ts-ignore + name: type === 'seller' ? order?.buyer?.name : order?.seller?.name, + price: order.ordersItems.reduce( + (acc, item) => acc + item.price * item.quantity, + 0 + ), + details: await Promise.all( + order.ordersItems.map((item) => { + return { + posted: order.posts.filter( + (p) => + p.releaseURL && p.integrationId === item.integration.id + ).length, + submitted: order.posts.filter( + (p) => + !p.releaseURL && + (p.approvedSubmitForOrder === 'WAITING_CONFIRMATION' || + p.approvedSubmitForOrder === 'YES') && + p.integrationId === item.integration.id + ).length, + integration: item.integration, + total: item.quantity, + price: item.price, + }; + }) + ), + }; + }) + ), + }; + } + + getPost(userId: string, orgId: string, postId: string) { + return this._post.model.post.findFirst({ + where: { + id: postId, + submittedForOrder: { + messageGroup: { + OR: [{ sellerId: userId }, {buyerOrganizationId: orgId}], + } + }, + }, + select: { + organizationId: true, + integration: { + select: { + providerIdentifier: true + } + } + } + }); + } } diff --git a/libraries/nestjs-libraries/src/database/prisma/marketplace/messages.service.ts b/libraries/nestjs-libraries/src/database/prisma/marketplace/messages.service.ts index 191d472a..2548a30f 100644 --- a/libraries/nestjs-libraries/src/database/prisma/marketplace/messages.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/marketplace/messages.service.ts @@ -2,24 +2,247 @@ import { Injectable } from '@nestjs/common'; import { MessagesRepository } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.repository'; import { NewConversationDto } from '@gitroom/nestjs-libraries/dtos/marketplace/new.conversation.dto'; import { AddMessageDto } from '@gitroom/nestjs-libraries/dtos/messages/add.message'; +import { CreateOfferDto } from '@gitroom/nestjs-libraries/dtos/marketplace/create.offer.dto'; +import { From, OrderStatus, User } from '@prisma/client'; +import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport/client/bull-mq.client'; +import { OrganizationRepository } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.repository'; +import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; +import dayjs from 'dayjs'; @Injectable() export class MessagesService { - constructor(private _messagesRepository: MessagesRepository) {} + constructor( + private _workerServiceProducer: BullMqClient, + private _messagesRepository: MessagesRepository, + private _organizationRepository: OrganizationRepository, + private _inAppNotificationService: NotificationService + ) {} - createConversation(userId: string, body: NewConversationDto) { - return this._messagesRepository.createConversation(userId, body); + async createConversation( + userId: string, + organizationId: string, + body: NewConversationDto + ) { + const conversation = await this._messagesRepository.createConversation( + userId, + organizationId, + body + ); + + const orgs = await this._organizationRepository.getOrgsByUserId(body.to); + await Promise.all( + orgs.map(async (org) => { + return this._inAppNotificationService.inAppNotification( + org.id, + 'Request for service', + 'A user has requested a service from you', + true + ); + }) + ); + + return conversation; } - getMessagesGroup(userId: string) { - return this._messagesRepository.getMessagesGroup(userId); + getMessagesGroup(userId: string, organizationId: string) { + return this._messagesRepository.getMessagesGroup(userId, organizationId); } - getMessages(userId: string, groupId: string, page: number) { - return this._messagesRepository.getMessages(userId, groupId, page); + async getMessages( + userId: string, + organizationId: string, + groupId: string, + page: number + ) { + if (page === 1) { + this._messagesRepository.updateOrderOnline(userId); + } + + return this._messagesRepository.getMessages( + userId, + organizationId, + groupId, + page + ); } - createMessage(userId: string, groupId: string, body: AddMessageDto) { - return this._messagesRepository.createMessage(userId, groupId, body); + async createNewMessage( + group: string, + from: From, + content: string, + special?: object + ) { + const message = await this._messagesRepository.createNewMessage( + group, + from, + content, + special + ); + + const user = from === 'BUYER' ? message.group.seller : message.group.buyer; + + await Promise.all( + user.organizations.map((p) => { + return this.sendMessageNotification({ + id: p.organizationId, + lastOnline: user.lastOnline, + }); + }) + ); + + return message; + } + + async sendMessageNotification(user: { id: string; lastOnline: Date }) { + if (dayjs(user.lastOnline).add(5, 'minute').isBefore(dayjs())) { + await this._inAppNotificationService.inAppNotification( + user.id, + 'New message', + 'You have a new message', + true + ); + } + } + + async createMessage( + userId: string, + orgId: string, + groupId: string, + body: AddMessageDto + ) { + const message = await this._messagesRepository.createMessage( + userId, + orgId, + groupId, + body + ); + + await Promise.all( + message.organizations.map((p) => { + return this.sendMessageNotification({ + id: p.organizationId, + lastOnline: message.lastOnline, + }); + }) + ); + + return message; + } + + createOffer(userId: string, body: CreateOfferDto) { + return this._messagesRepository.createOffer(userId, body); + } + + getOrderDetails(userId: string, organizationId: string, orderId: string) { + return this._messagesRepository.getOrderDetails( + userId, + organizationId, + orderId + ); + } + + canAddPost(id: string, order: string, integrationId: string) { + return this._messagesRepository.canAddPost(id, order, integrationId); + } + + changeOrderStatus( + orderId: string, + status: OrderStatus, + paymentIntent?: string + ) { + return this._messagesRepository.changeOrderStatus( + orderId, + status, + paymentIntent + ); + } + + getMarketplaceAvailableOffers(orgId: string, id: string) { + return this._messagesRepository.getMarketplaceAvailableOffers(orgId, id); + } + + getPost(userId: string, orgId: string, postId: string) { + return this._messagesRepository.getPost(userId, orgId, postId); + } + + requestRevision( + userId: string, + orgId: string, + postId: string, + message: string + ) { + return this._messagesRepository.requestRevision( + userId, + orgId, + postId, + message + ); + } + + async requestApproved( + userId: string, + orgId: string, + postId: string, + message: string + ) { + const post = await this._messagesRepository.requestApproved( + userId, + orgId, + postId, + message + ); + if (post) { + this._workerServiceProducer.emit('post', { + id: post.id, + options: { + delay: 0, //dayjs(post.publishDate).diff(dayjs(), 'millisecond'), + }, + payload: { + id: post.id, + }, + }); + } + } + + async requestCancel(orgId: string, postId: string) { + const cancel = await this._messagesRepository.requestCancel(orgId, postId); + await this._workerServiceProducer.delete('post', postId); + return cancel; + } + + async completeOrderAndPay(orgId: string, order: string) { + const orderList = await this._messagesRepository.completeOrderAndPay( + orgId, + order + ); + if (!orderList) { + return false; + } + orderList.posts.forEach((post) => { + this._workerServiceProducer.delete('post', post.id); + }); + return orderList; + } + + completeOrder(orderId: string) { + return this._messagesRepository.completeOrder(orderId); + } + + payoutProblem( + orderId: string, + sellerId: string, + amount: number, + postId?: string + ) { + return this._messagesRepository.payoutProblem( + orderId, + sellerId, + amount, + postId + ); + } + + getOrders(userId: string, orgId: string, type: 'seller' | 'buyer') { + return this._messagesRepository.getOrders(userId, orgId, type); } } diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts index 5f5d7d5f..5889599c 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts @@ -1,7 +1,7 @@ import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; import { Injectable } from '@nestjs/common'; import { Post as PostBody } from '@gitroom/nestjs-libraries/dtos/posts/create.post.dto'; -import { Post } from '@prisma/client'; +import { APPROVED_SUBMIT_FOR_ORDER, Post } from '@prisma/client'; import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto'; import dayjs from 'dayjs'; import isoWeek from 'dayjs/plugin/isoWeek'; @@ -114,7 +114,12 @@ export class PostsRepository { }); } - getPost(id: string, includeIntegration = false, orgId?: string) { + getPost( + id: string, + includeIntegration = false, + orgId?: string, + isFirst?: boolean + ) { return this._post.model.post.findUnique({ where: { id, @@ -122,7 +127,11 @@ export class PostsRepository { deletedAt: null, }, include: { - ...(includeIntegration ? { integration: true } : {}), + ...(includeIntegration + ? { + integration: true, + } + : {}), childrenPost: true, }, }); @@ -210,6 +219,7 @@ export class PostsRepository { : {}), content: value.content, group: uuid, + approvedSubmitForOrder: APPROVED_SUBMIT_FOR_ORDER.NO, state: state === 'draft' ? ('DRAFT' as const) : ('QUEUE' as const), image: JSON.stringify(value.image), settings: JSON.stringify(body.settings), @@ -225,8 +235,16 @@ export class PostsRepository { where: { id: value.id || uuidv4(), }, - create: updateData('create'), - update: updateData('update'), + create: { ...updateData('create') }, + update: { + ...updateData('update'), + lastMessage: { + disconnect: true, + }, + submittedForOrder: { + disconnect: true, + }, + }, }) ); } @@ -261,4 +279,64 @@ export class PostsRepository { return { previousPost, posts }; } + + async submit(id: string, order: string) { + return this._post.model.post.update({ + where: { + id, + }, + data: { + submittedForOrderId: order, + approvedSubmitForOrder: 'WAITING_CONFIRMATION', + }, + select: { + id: true, + description: true, + submittedForOrder: { + select: { + messageGroupId: true, + }, + }, + }, + }); + } + + updateMessage(id: string, messageId: string) { + return this._post.model.post.update({ + where: { + id, + }, + data: { + lastMessageId: messageId, + }, + }); + } + + getPostById(id: string, org?: string) { + return this._post.model.post.findUnique({ + where: { + id, + ...(org ? { organizationId: org } : {}), + }, + include: { + integration: true, + submittedForOrder: { + include: { + posts: { + where: { + state: 'PUBLISHED', + }, + }, + ordersItems: true, + seller: { + select: { + id: true, + account: true + }, + }, + }, + }, + }, + }); + } } diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts index 8f2978e8..f631a2c7 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -4,10 +4,12 @@ import { CreatePostDto } from '@gitroom/nestjs-libraries/dtos/posts/create.post. import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport/client/bull-mq.client'; import dayjs from 'dayjs'; import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager'; -import { Integration, Post, Media } from '@prisma/client'; +import { Integration, Post, Media, From } from '@prisma/client'; import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto'; import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; -import {capitalize} from "lodash"; +import { capitalize } from 'lodash'; +import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service'; +import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service'; type PostWithConditionals = Post & { integration?: Integration; @@ -20,23 +22,33 @@ export class PostsService { private _postRepository: PostsRepository, private _workerServiceProducer: BullMqClient, private _integrationManager: IntegrationManager, - private _notificationService: NotificationService + private _notificationService: NotificationService, + private _messagesService: MessagesService, + private _stripeService: StripeService ) {} async getPostsRecursively( id: string, includeIntegration = false, - orgId?: string + orgId?: string, + isFirst?: boolean ): Promise { const post = await this._postRepository.getPost( id, includeIntegration, - orgId + orgId, + isFirst ); + return [ post!, ...(post?.childrenPost?.length - ? await this.getPostsRecursively(post.childrenPost[0].id, false, orgId) + ? await this.getPostsRecursively( + post.childrenPost[0].id, + false, + orgId, + false + ) : []), ]; } @@ -46,7 +58,7 @@ export class PostsService { } async getPost(orgId: string, id: string) { - const posts = await this.getPostsRecursively(id, false, orgId); + const posts = await this.getPostsRecursively(id, false, orgId, true); return { group: posts?.[0]?.group, posts: posts.map((post) => ({ @@ -79,16 +91,29 @@ export class PostsService { } try { - if (firstPost.integration?.type === 'article') { - await this.postArticle(firstPost.integration!, [ - firstPost, - ...morePosts, - ]); + const finalPost = + firstPost.integration?.type === 'article' + ? await this.postArticle(firstPost.integration!, [ + firstPost, + ...morePosts, + ]) + : await this.postSocial(firstPost.integration!, [ + firstPost, + ...morePosts, + ]); + if (!finalPost?.postId || !finalPost?.releaseURL) { return; } - await this.postSocial(firstPost.integration!, [firstPost, ...morePosts]); + if (firstPost.submittedForOrderId) { + this._workerServiceProducer.emit('submit', { + payload: { + id: firstPost.id, + releaseURL: finalPost.releaseURL, + }, + }); + } } catch (err: any) { await this._notificationService.inAppNotification( firstPost.organizationId, @@ -147,7 +172,10 @@ export class PostsService { m.path : m.path, type: 'image', - path: m.path.indexOf('http') === -1 ? process.env.UPLOAD_DIRECTORY + m.path : m.path, + path: + m.path.indexOf('http') === -1 + ? process.env.UPLOAD_DIRECTORY + m.path + : m.path, })), })) ); @@ -162,10 +190,17 @@ export class PostsService { await this._notificationService.inAppNotification( integration.organizationId, - `Your post has been published on ${capitalize(integration.providerIdentifier)}`, + `Your post has been published on ${capitalize( + integration.providerIdentifier + )}`, `Your post has been published at ${publishedPosts[0].releaseURL}`, true ); + + return { + postId: publishedPosts[0].postId, + releaseURL: publishedPosts[0].releaseURL, + }; } private async postArticle(integration: Integration, posts: Post[]) { @@ -186,11 +221,18 @@ export class PostsService { await this._notificationService.inAppNotification( integration.organizationId, - `Your article has been published on ${capitalize(integration.providerIdentifier)}`, + `Your article has been published on ${capitalize( + integration.providerIdentifier + )}`, `Your article has been published at ${releaseURL}`, true ); await this._postRepository.updatePost(newPosts[0].id, postId, releaseURL); + + return { + postId, + releaseURL, + }; } async deletePost(orgId: string, group: string) { @@ -204,6 +246,38 @@ export class PostsService { return this._postRepository.countPostsFromDay(orgId, date); } + async submit( + id: string, + order: string, + message: string, + integrationId: string + ) { + if (!(await this._messagesService.canAddPost(id, order, integrationId))) { + console.log('hello'); + throw new Error('You can not add a post to this publication'); + } + const submit = await this._postRepository.submit(id, order); + const messageModel = await this._messagesService.createNewMessage( + submit?.submittedForOrder?.messageGroupId || '', + From.SELLER, + '', + { + type: 'post', + data: { + id: order, + postId: id, + status: 'PENDING', + integration: integrationId, + description: message.slice(0, 300) + '...', + }, + } + ); + + await this._postRepository.updateMessage(id, messageModel.id); + + return messageModel; + } + async createPost(orgId: string, body: CreatePostDto) { for (const post of body.posts) { const { previousPost, posts } = @@ -224,6 +298,17 @@ export class PostsService { 'post', previousPost ? previousPost : posts?.[0]?.id ); + + if (body.order && body.type !== 'draft') { + await this.submit( + posts[0].id, + body.order, + post.value[0].content, + post.integration.id + ); + continue; + } + if ( (body.type === 'schedule' || body.type === 'now') && dayjs(body.date).isAfter(dayjs()) @@ -245,16 +330,105 @@ export class PostsService { } async changeDate(orgId: string, id: string, date: string) { + const getPostById = await this._postRepository.getPostById(id, orgId); + if ( + getPostById?.submittedForOrderId && + getPostById.approvedSubmitForOrder !== 'NO' + ) { + throw new Error( + 'You can not change the date of a post that has been submitted' + ); + } + await this._workerServiceProducer.delete('post', id); - this._workerServiceProducer.emit('post', { - id: id, - options: { - delay: dayjs(date).diff(dayjs(), 'millisecond'), - }, - payload: { + if (getPostById?.state !== 'DRAFT' && !getPostById?.submittedForOrderId) { + this._workerServiceProducer.emit('post', { id: id, - }, - }); + options: { + delay: dayjs(date).diff(dayjs(), 'millisecond'), + }, + payload: { + id: id, + }, + }); + } + return this._postRepository.changeDate(orgId, id, date); } + + async payout(id: string, url: string) { + const getPost = await this._postRepository.getPostById(id); + if (!getPost || !getPost.submittedForOrder) { + return; + } + + const findPrice = getPost.submittedForOrder.ordersItems.find( + (orderItem) => orderItem.integrationId === getPost.integrationId + )!; + + await this._messagesService.createNewMessage( + getPost.submittedForOrder.messageGroupId, + From.SELLER, + '', + { + type: 'published', + data: { + id: getPost.submittedForOrder.id, + postId: id, + status: 'PUBLISHED', + integrationId: getPost.integrationId, + integration: getPost.integration.providerIdentifier, + picture: getPost.integration.picture, + name: getPost.integration.name, + url, + }, + } + ); + + const totalItems = getPost.submittedForOrder.ordersItems.reduce( + (all, p) => all + p.quantity, + 0 + ); + const totalPosts = getPost.submittedForOrder.posts.length; + + if (totalItems === totalPosts) { + await this._messagesService.completeOrder(getPost.submittedForOrder.id); + await this._messagesService.createNewMessage( + getPost.submittedForOrder.messageGroupId, + From.SELLER, + '', + { + type: 'order-completed', + data: { + id: getPost.submittedForOrder.id, + postId: id, + status: 'PUBLISHED', + }, + } + ); + } + + try { + await this._stripeService.payout( + getPost.submittedForOrder.id, + getPost.submittedForOrder.captureId!, + getPost.submittedForOrder.seller.account!, + findPrice.price + ); + + return this._notificationService.inAppNotification( + getPost.integration.organizationId, + 'Payout completed', + `You have received a payout of $${findPrice.price}`, + true + ); + } catch (err) { + await this._messagesService.payoutProblem( + getPost.submittedForOrder.id, + getPost.submittedForOrder.seller.id, + findPrice.price, + id + ); + } + } } diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma index 0555ecb7..437d860f 100644 --- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma +++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma @@ -25,6 +25,7 @@ model Organization { post Post[] Comments Comments[] notifications Notifications[] + buyerOrganization MessagesGroup[] } model User { @@ -51,12 +52,18 @@ model User { account String? connectedAccount Boolean @default(false) groupBuyer MessagesGroup[] @relation("groupBuyer") - groupSeller MessagesGroup[] @relation("groupSeller") + groupSeller MessagesGroup[] @relation("groupSeller") + orderBuyer Orders[] @relation("orderBuyer") + orderSeller Orders[] @relation("orderSeller") + payoutProblems PayoutProblems[] + lastOnline DateTime @default(now()) @@unique([email, providerName]) @@index([lastReadNotifications]) @@index([inviteId]) @@index([account]) + @@index([lastOnline]) + @@index([pictureId]) } model UserOrganization { @@ -71,6 +78,7 @@ model UserOrganization { updatedAt DateTime @updatedAt @@unique([userId, organizationId]) + @@index([disabled]) } model GitHub { @@ -179,6 +187,7 @@ model Integration { deletedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime? @updatedAt + orderItems OrderItems[] @@index([updatedAt]) @@index([deletedAt]) @@ -208,27 +217,33 @@ model Comments { } model Post { - id String @id @default(cuid()) - state State @default(QUEUE) - publishDate DateTime - organizationId String - integrationId String - content String - group String - organization Organization @relation(fields: [organizationId], references: [id]) - integration Integration @relation(fields: [integrationId], references: [id]) - title String? - description String? - parentPostId String? - releaseId String? - releaseURL String? - settings String? - parentPost Post? @relation("parentPostId", fields: [parentPostId], references: [id]) - childrenPost Post[] @relation("parentPostId") - image String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? + id String @id @default(cuid()) + state State @default(QUEUE) + publishDate DateTime + organizationId String + integrationId String + content String + group String + organization Organization @relation(fields: [organizationId], references: [id]) + integration Integration @relation(fields: [integrationId], references: [id]) + title String? + description String? + parentPostId String? + releaseId String? + releaseURL String? + settings String? + parentPost Post? @relation("parentPostId", fields: [parentPostId], references: [id]) + childrenPost Post[] @relation("parentPostId") + image String? + submittedForOrderId String? + submittedForOrder Orders? @relation(fields: [submittedForOrderId], references: [id]) + approvedSubmitForOrder APPROVED_SUBMIT_FOR_ORDER @default(NO) + lastMessageId String? + lastMessage Messages? @relation(fields: [lastMessageId], references: [id]) + payoutProblems PayoutProblems[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? @@index([group]) @@index([deletedAt]) @@ -236,6 +251,13 @@ model Post { @@index([state]) @@index([organizationId]) @@index([parentPostId]) + @@index([submittedForOrderId]) + @@index([approvedSubmitForOrder]) + @@index([lastMessageId]) + @@index([createdAt]) + @@index([updatedAt]) + @@index([releaseURL]) + @@index([integrationId]) } model Notifications { @@ -254,18 +276,72 @@ model Notifications { } model MessagesGroup { - id String @id @default(uuid()) - buyerId String - buyer User @relation("groupBuyer", fields: [buyerId], references: [id]) - sellerId String - seller User @relation("groupSeller", fields: [sellerId], references: [id]) - messages Messages[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + buyerOrganizationId String + buyerOrganization Organization @relation(fields: [buyerOrganizationId], references: [id]) + buyerId String + buyer User @relation("groupBuyer", fields: [buyerId], references: [id]) + sellerId String + seller User @relation("groupSeller", fields: [sellerId], references: [id]) + messages Messages[] + orders Orders[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([buyerId, sellerId]) @@index([createdAt]) @@index([updatedAt]) + @@index([buyerOrganizationId]) +} + +model PayoutProblems { + id String @id @default(uuid()) + status String + orderId String + order Orders @relation(fields: [orderId], references: [id]) + userId String + user User @relation(fields: [userId], references: [id]) + postId String? + post Post? @relation(fields: [postId], references: [id]) + amount Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Orders { + id String @id @default(uuid()) + buyerId String + sellerId String + posts Post[] + buyer User @relation("orderBuyer", fields: [buyerId], references: [id]) + seller User @relation("orderSeller", fields: [sellerId], references: [id]) + status OrderStatus + ordersItems OrderItems[] + messageGroupId String + messageGroup MessagesGroup @relation(fields: [messageGroupId], references: [id]) + captureId String? + payoutProblems PayoutProblems[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([buyerId]) + @@index([sellerId]) + @@index([updatedAt]) + @@index([createdAt]) + @@index([messageGroupId]) +} + +model OrderItems { + id String @id @default(uuid()) + orderId String + order Orders @relation(fields: [orderId], references: [id]) + integrationId String + integration Integration @relation(fields: [integrationId], references: [id]) + quantity Int + price Int + + @@index([orderId]) + @@index([integrationId]) } model Messages { @@ -274,6 +350,8 @@ model Messages { content String? groupId String group MessagesGroup @relation(fields: [groupId], references: [id]) + special String? + posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? @@ -283,6 +361,13 @@ model Messages { @@index([deletedAt]) } +enum OrderStatus { + PENDING + ACCEPTED + CANCELED + COMPLETED +} + enum From { BUYER SELLER @@ -315,3 +400,9 @@ enum Role { ADMIN USER } + +enum APPROVED_SUBMIT_FOR_ORDER { + NO + WAITING_CONFIRMATION + YES +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/pricing.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/pricing.ts index 574055ef..4367822f 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/pricing.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/pricing.ts @@ -1,4 +1,6 @@ export interface PricingInnerInterface { + month_price: number; + year_price: number; channel?: number; posts_per_month: number; team_members: boolean; @@ -12,7 +14,9 @@ export interface PricingInterface { } export const pricing: PricingInterface = { FREE: { - channel: 3, + month_price: 0, + year_price: 0, + channel: 2, posts_per_month: 30, team_members: false, community_features: false, @@ -21,6 +25,9 @@ export const pricing: PricingInterface = { import_from_channels: false, }, STANDARD: { + month_price: 30, + year_price: 288, + channel: 5, posts_per_month: 400, team_members: false, ai: true, @@ -29,6 +36,9 @@ export const pricing: PricingInterface = { import_from_channels: true, }, PRO: { + month_price: 40, + year_price: 384, + channel: 8, posts_per_month: 1000000, community_features: true, team_members: true, diff --git a/libraries/nestjs-libraries/src/dtos/billing/billing.subscribe.dto.ts b/libraries/nestjs-libraries/src/dtos/billing/billing.subscribe.dto.ts index 2254497e..eed1b49d 100644 --- a/libraries/nestjs-libraries/src/dtos/billing/billing.subscribe.dto.ts +++ b/libraries/nestjs-libraries/src/dtos/billing/billing.subscribe.dto.ts @@ -1,13 +1,9 @@ -import {IsIn, Max, Min} from "class-validator"; +import { IsIn } from 'class-validator'; export class BillingSubscribeDto { - @Min(1) - @Max(60) - total: number; + @IsIn(['MONTHLY', 'YEARLY']) + period: 'MONTHLY' | 'YEARLY'; - @IsIn(['MONTHLY', 'YEARLY']) - period: 'MONTHLY' | 'YEARLY'; - - @IsIn(['STANDARD', 'PRO']) - billing: 'STANDARD' | 'PRO'; + @IsIn(['STANDARD', 'PRO']) + billing: 'STANDARD' | 'PRO'; } diff --git a/libraries/nestjs-libraries/src/dtos/marketplace/create.offer.dto.ts b/libraries/nestjs-libraries/src/dtos/marketplace/create.offer.dto.ts new file mode 100644 index 00000000..6151b6e7 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/marketplace/create.offer.dto.ts @@ -0,0 +1,27 @@ +import { + ArrayMinSize, + IsNumber, + IsString, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class SocialMedia { + @IsNumber() + total: number; + + @IsString() + value: string; + + @IsNumber() + price: number; +} +export class CreateOfferDto { + @IsString() + group: string; + + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => SocialMedia) + socialMedia: SocialMedia[]; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts index c939dee2..f4336dde 100644 --- a/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts +++ b/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts @@ -71,6 +71,10 @@ export class CreatePostDto { @IsIn(['draft', 'schedule', 'now']) type: 'draft' | 'schedule' | 'now'; + @IsOptional() + @IsString() + order: string; + @IsDefined() @IsDateString() date: string; diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index f09e2687..406766d1 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -1,11 +1,13 @@ import Stripe from 'stripe'; import { Injectable } from '@nestjs/common'; -import { Organization } from '@prisma/client'; +import { OrderItems, Organization, User } from '@prisma/client'; import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import { BillingSubscribeDto } from '@gitroom/nestjs-libraries/dtos/billing/billing.subscribe.dto'; -import { groupBy } from 'lodash'; +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'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-04-10', @@ -15,7 +17,8 @@ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { export class StripeService { constructor( private _subscriptionService: SubscriptionService, - private _organizationService: OrganizationService + private _organizationService: OrganizationService, + private _messagesService: MessagesService ) {} validateRequest(rawBody: Buffer, signature: string, endpointSecret: string) { return stripe.webhooks.constructEvent(rawBody, signature, endpointSecret); @@ -23,12 +26,17 @@ export class StripeService { async updateAccount(event: Stripe.AccountUpdatedEvent) { if (!event.account) { - return ; + return; } - console.log(JSON.stringify(event.data.object, null, 2)); - const accountCharges = event.data.object.payouts_enabled && event.data.object.payouts_enabled && !event?.data?.object?.requirements?.disabled_reason; - await this._subscriptionService.updateConnectedStatus(event.account!, accountCharges); + const accountCharges = + event.data.object.payouts_enabled && + event.data.object.charges_enabled && + !event?.data?.object?.requirements?.disabled_reason; + await this._subscriptionService.updateConnectedStatus( + event.account!, + accountCharges + ); } createSubscription(event: Stripe.CustomerSubscriptionCreatedEvent) { @@ -46,7 +54,7 @@ export class StripeService { return this._subscriptionService.createOrUpdateSubscription( id, event.data.object.customer as string, - event?.data?.object?.items?.data?.[0]?.quantity || 0, + pricing[billing].channel!, billing, period, event.data.object.cancel_at @@ -67,7 +75,7 @@ export class StripeService { return this._subscriptionService.createOrUpdateSubscription( id, event.data.object.customer as string, - event?.data?.object?.items?.data?.[0]?.quantity || 0, + pricing[billing].channel!, billing, period, event.data.object.cancel_at @@ -121,24 +129,38 @@ export class StripeService { async prorate(organizationId: string, body: BillingSubscribeDto) { const org = await this._organizationService.getOrgById(organizationId); const customer = await this.createOrGetCustomer(org!); - + const priceData = pricing[body.billing]; const allProducts = await stripe.products.list({ active: true, expand: ['data.prices'], }); + const findProduct = allProducts.data.find( - (product) => product.name.toLowerCase() === body.billing.toLowerCase() - ); + (product) => product.name.toUpperCase() === body.billing.toUpperCase() + ) || await stripe.products.create({ + active: true, + name: body.billing, + }); + const pricesList = await stripe.prices.list({ active: true, - product: findProduct?.id, + product: findProduct!.id, }); const findPrice = pricesList.data.find( (p) => - p?.recurring?.interval?.toLowerCase() === - body?.period?.toLowerCase().replace('ly', '') - ); + p?.recurring?.interval?.toLowerCase() === (body.period === 'MONTHLY' ? 'month' : 'year') && + p?.unit_amount === (body.period === 'MONTHLY' ? priceData.month_price : priceData.year_price) * 100 + ) || await stripe.prices.create({ + active: true, + product: findProduct!.id, + currency: 'usd', + nickname: body.billing + ' ' + body.period, + unit_amount: (body.period === 'MONTHLY' ? priceData.month_price : priceData.year_price) * 100, + recurring: { + interval: body.period === 'MONTHLY' ? 'month' : 'year', + }, + }); const proration_date = Math.floor(Date.now() / 1000); @@ -157,7 +179,7 @@ export class StripeService { { id: currentUserSubscription?.data?.[0]?.items?.data?.[0]?.id, price: findPrice?.id!, - quantity: body?.total, + quantity: 1, }, ], subscription_proration_date: proration_date, @@ -221,9 +243,8 @@ export class StripeService { private async createCheckoutSession( uniqueId: string, customer: string, - metaData: any, - price: string, - quantity: number + body: BillingSubscribeDto, + price: string ) { const { url } = await stripe.checkout.sessions.create({ customer, @@ -232,15 +253,15 @@ export class StripeService { subscription_data: { metadata: { service: 'gitroom', - ...metaData, + ...body, uniqueId, }, }, allow_promotion_codes: true, line_items: [ { - price: price, - quantity: quantity, + price, + quantity: 1, }, ], }); @@ -271,7 +292,7 @@ export class StripeService { metadata: { service: 'gitroom', }, - email + email, }); await this._subscriptionService.updateAccount(userId, account.id); @@ -290,18 +311,80 @@ export class StripeService { return accountLink.url; } + async payAccountStepOne( + userId: string, + organization: Organization, + seller: User, + orderId: string, + ordersItems: Array<{ + integrationType: string; + quantity: number; + price: number; + }>, + groupId: string + ) { + const customer = (await this.createOrGetCustomer(organization))!; + + const price = ordersItems.reduce((all, current) => { + return all + current.price * current.quantity; + }, 0); + + const { url } = await stripe.checkout.sessions.create({ + customer, + mode: 'payment', + currency: 'usd', + success_url: process.env['FRONTEND_URL'] + `/messages/${groupId}`, + metadata: { + orderId, + service: 'gitroom', + type: 'marketplace', + }, + line_items: [ + ...ordersItems, + { + integrationType: `Gitroom Fee (${+process.env.FEE_AMOUNT! * 100}%)`, + quantity: 1, + price: price * +process.env.FEE_AMOUNT!, + }, + ].map((item) => ({ + price_data: { + currency: 'usd', + product_data: { + // @ts-ignore + name: + (!item.price ? 'Platform: ' : '') + + capitalize(item.integrationType), + }, + // @ts-ignore + unit_amount: item.price * 100, + }, + quantity: item.quantity, + })), + payment_intent_data: { + transfer_group: orderId, + }, + }); + + return { url }; + } + async subscribe(organizationId: string, body: BillingSubscribeDto) { const id = makeId(10); - + const priceData = pricing[body.billing]; const org = await this._organizationService.getOrgById(organizationId); const customer = await this.createOrGetCustomer(org!); const allProducts = await stripe.products.list({ active: true, expand: ['data.prices'], }); + const findProduct = allProducts.data.find( - (product) => product.name.toLowerCase() === body.billing.toLowerCase() - ); + (product) => product.name.toUpperCase() === body.billing.toUpperCase() + ) || await stripe.products.create({ + active: true, + name: body.billing, + }); + const pricesList = await stripe.prices.list({ active: true, product: findProduct!.id, @@ -309,22 +392,26 @@ export class StripeService { const findPrice = pricesList.data.find( (p) => - p?.recurring?.interval?.toLowerCase() === - body?.period?.toLowerCase().replace('ly', '') - ); + p?.recurring?.interval?.toLowerCase() === (body.period === 'MONTHLY' ? 'month' : 'year') && + p?.unit_amount === (body.period === 'MONTHLY' ? priceData.month_price : priceData.year_price) * 100 + ) || await stripe.prices.create({ + active: true, + product: findProduct!.id, + currency: 'usd', + nickname: body.billing + ' ' + body.period, + unit_amount: (body.period === 'MONTHLY' ? priceData.month_price : priceData.year_price) * 100, + recurring: { + interval: body.period === 'MONTHLY' ? 'month' : 'year', + }, + }); + const currentUserSubscription = await stripe.subscriptions.list({ customer, status: 'active', }); if (!currentUserSubscription.data.length) { - return this.createCheckoutSession( - id, - customer, - body, - findPrice!.id, - body.total - ); + return this.createCheckoutSession(id, customer, body, findPrice!.id); } try { @@ -340,7 +427,7 @@ export class StripeService { { id: currentUserSubscription.data[0].items.data[0].id, price: findPrice!.id, - quantity: body.total, + quantity: 1, }, ], }); @@ -353,4 +440,40 @@ export class StripeService { }; } } + + async updateOrder(event: Stripe.CheckoutSessionCompletedEvent) { + if (event?.data?.object?.metadata?.type !== 'marketplace') { + return { ok: true }; + } + + const { orderId } = event?.data?.object?.metadata || { orderId: '' }; + if (!orderId) { + return; + } + + const charge = ( + await stripe.paymentIntents.retrieve( + event.data.object.payment_intent as string + ) + ).latest_charge; + const id = typeof charge === 'string' ? charge : charge?.id; + + await this._messagesService.changeOrderStatus(orderId, 'ACCEPTED', id); + return { ok: true }; + } + + async payout( + orderId: string, + charge: string, + account: string, + price: number + ) { + return stripe.transfers.create({ + amount: price * 100, + currency: 'usd', + destination: account, + source_transaction: charge, + transfer_group: orderId, + }); + } } diff --git a/libraries/react-shared-libraries/src/form/custom.select.tsx b/libraries/react-shared-libraries/src/form/custom.select.tsx new file mode 100644 index 00000000..b95867a7 --- /dev/null +++ b/libraries/react-shared-libraries/src/form/custom.select.tsx @@ -0,0 +1,146 @@ +import { + FC, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import interClass from '../helpers/inter.font'; +import { clsx } from 'clsx'; +import { useFormContext } from 'react-hook-form'; + +export const CustomSelect: FC<{ + error?: any; + disableForm?: boolean; + label: string; + name: string; + placeholder?: string; + removeError?: boolean; + onChange?: () => void; + className?: string; + options: Array<{ value: string; label: string; icon?: ReactNode }>; +}> = (props) => { + const { options, onChange, placeholder, className, removeError, label, ...rest } = props; + const form = useFormContext(); + const value = form.watch(props.name); + const [isOpen, setIsOpen] = useState(false); + + const err = useMemo(() => { + const split = (props.name + '.value').split('.'); + let errIn = form?.formState?.errors; + for (let i = 0; i < split.length; i++) { + // @ts-ignore + errIn = errIn?.[split[i]]; + } + return errIn?.message; + }, [props.name, form]); + + const option = useMemo(() => { + if (value?.value && options.length) { + return ( + options.find((option) => option.value === value.value) || { + label: placeholder, + icon: false, + } + ); + } + + return { label: placeholder }; + }, [value, options]); + + const changeOpen = useCallback(() => { + setIsOpen(!isOpen); + }, [isOpen]); + + const setOption = useCallback( + (newOption: any) => (e: any) => { + form.setValue(props.name, newOption); + setIsOpen(false); + e.stopPropagation(); + }, + [] + ); + + useEffect(() => { + if (onChange) { + onChange(); + } + }, [value]); + + return ( +
+ {!!label && (
{label}
)} +
+
+ {!!option.icon && ( +
+ {option.icon} +
+ )} + + {option.label} +
+
+
+ + + +
+ {!!value && ( +
+ + + +
+ )} +
+
+ {isOpen && ( +
+ {options.map((option) => ( +
+ {!!option.icon && ( +
+ {option.icon} +
+ )} +
{option.label}
+
+ ))} +
+ )} + {!removeError && ( +
+ {(err as any) || <> } +
+ )} +
+ ); +}; diff --git a/libraries/react-shared-libraries/src/form/input.tsx b/libraries/react-shared-libraries/src/form/input.tsx index 610ba4a3..66e7febd 100644 --- a/libraries/react-shared-libraries/src/form/input.tsx +++ b/libraries/react-shared-libraries/src/form/input.tsx @@ -1,8 +1,10 @@ 'use client'; -import { DetailedHTMLProps, FC, InputHTMLAttributes, useMemo } from 'react'; +import { + DetailedHTMLProps, FC, InputHTMLAttributes, ReactNode, useEffect, useMemo +} from 'react'; import { clsx } from 'clsx'; -import { useFormContext } from 'react-hook-form'; +import { useFormContext, useWatch } from 'react-hook-form'; import interClass from '../helpers/inter.font'; export const Input: FC< @@ -10,11 +12,22 @@ export const Input: FC< removeError?: boolean; error?: any; disableForm?: boolean; + customUpdate?: () => void; label: string; name: string; + icon?: ReactNode; } > = (props) => { - const { label, removeError, className, disableForm, error, ...rest } = props; + const { + label, + icon, + removeError, + customUpdate, + className, + disableForm, + error, + ...rest + } = props; const form = useFormContext(); const err = useMemo(() => { if (error) return error; @@ -22,18 +35,35 @@ export const Input: FC< return form?.formState?.errors?.[props?.name!]?.message! as string; }, [form?.formState?.errors?.[props?.name!]?.message, error]); + const watch = customUpdate ? form?.watch(props.name) : null; + useEffect(() => { + if (customUpdate) { + customUpdate(); + } + }, [watch]); + return (
{label}
- - {!removeError &&
{err || <> }
} + > + {icon &&
{icon}
} + +
+ {!removeError && ( +
{err || <> }
+ )}
); }; diff --git a/libraries/react-shared-libraries/src/form/total.tsx b/libraries/react-shared-libraries/src/form/total.tsx new file mode 100644 index 00000000..885b3811 --- /dev/null +++ b/libraries/react-shared-libraries/src/form/total.tsx @@ -0,0 +1,73 @@ +import { FC, useCallback, useEffect } from 'react'; +import interClass from '../helpers/inter.font'; +import { clsx } from 'clsx'; +import { useFormContext } from 'react-hook-form'; + +export const Total: FC<{ name: string; customOnChange?: () => void }> = ( + props +) => { + const { name, customOnChange } = props; + const form = useFormContext(); + const value = form.watch(props.name); + + const changeNumber = useCallback( + (value: number) => () => { + if (value === 0) { + return; + } + form.setValue(name, value); + }, + [value] + ); + + useEffect(() => { + if (customOnChange) { + customOnChange(); + } + }, [value, customOnChange]); + + return ( +
+
Total
+
+
+
+ + + +
+
+ {value} +
+
+ + + +
+
+
+
+ ); +}; diff --git a/libraries/react-shared-libraries/src/helpers/use.is.visible.tsx b/libraries/react-shared-libraries/src/helpers/use.is.visible.tsx new file mode 100644 index 00000000..a66ddf55 --- /dev/null +++ b/libraries/react-shared-libraries/src/helpers/use.is.visible.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +export function usePageVisibility(page: number) { + if (typeof document === 'undefined') { + return true; + } + + const [isVisible, setIsVisible] = useState(!document.hidden); + + useEffect(() => { + if (page > 1) { + return; + } + + const handleVisibilityChange = () => { + setIsVisible(!document.hidden); + }; + + const onBlur = () => { + setIsVisible(false); + }; + + const onFocus = () => { + setIsVisible(true); + }; + + window.addEventListener('blur', onBlur); + window.addEventListener('focus', onFocus); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + document.removeEventListener('blur', onBlur); + document.removeEventListener('focus', focus); + }; + }, []); + + return isVisible; +} diff --git a/package-lock.json b/package-lock.json index 52e4f842..256fce1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@types/multer": "^1.4.11", "@types/remove-markdown": "^0.3.4", "@types/stripe": "^8.0.417", + "@types/yup": "^0.32.0", "@uidotdev/usehooks": "^2.4.1", "@uiw/react-md-editor": "^4.0.3", "@virtual-grid/react": "^2.0.2", @@ -92,7 +93,8 @@ "twitter-api-v2": "^1.16.0", "use-debounce": "^10.0.0", "utf-8-validate": "^5.0.10", - "yargs": "^17.7.2" + "yargs": "^17.7.2", + "yup": "^1.4.0" }, "devDependencies": { "@mintlify/scraping": "^3.0.90", @@ -13449,6 +13451,15 @@ "@types/node": "*" } }, + "node_modules/@types/yup": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@types/yup/-/yup-0.32.0.tgz", + "integrity": "sha512-Gr2lllWTDxGVYHgWfL8szjdedERpNgm44L9BDL2cmcHG7Bfd6taEpiW3ayMFLaYvlJr/6bFXDJdh6L406AGlFg==", + "deprecated": "This is a stub types definition. yup provides its own type definitions, so you do not need this installed.", + "dependencies": { + "yup": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -32723,6 +32734,11 @@ "react-is": "^16.13.1" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" + }, "node_modules/property-information": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.4.1.tgz", @@ -37800,6 +37816,11 @@ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -37894,6 +37915,11 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -40307,6 +40333,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.4.0.tgz", + "integrity": "sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.22.4", "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", diff --git a/package.json b/package.json index 1db8d988..4cad3037 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@types/multer": "^1.4.11", "@types/remove-markdown": "^0.3.4", "@types/stripe": "^8.0.417", + "@types/yup": "^0.32.0", "@uidotdev/usehooks": "^2.4.1", "@uiw/react-md-editor": "^4.0.3", "@virtual-grid/react": "^2.0.2", @@ -96,7 +97,8 @@ "twitter-api-v2": "^1.16.0", "use-debounce": "^10.0.0", "utf-8-validate": "^5.0.10", - "yargs": "^17.7.2" + "yargs": "^17.7.2", + "yup": "^1.4.0" }, "devDependencies": { "@mintlify/scraping": "^3.0.90", diff --git a/videos.csv b/videos.csv new file mode 100644 index 00000000..e69de29b