diff --git a/.github/ISSUE_TEMPLATE/01_installation.prolem.yml b/.github/ISSUE_TEMPLATE/01_installation.prolem.yml new file mode 100644 index 00000000..6348c629 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01_installation.prolem.yml @@ -0,0 +1,19 @@ +name: "🙏🏻 Installation Problem" +description: "Report an issue with installation" +title: "Installation Problem" +labels: ["type: installation"] +body: + - type: markdown + attributes: + value: For installation issues, please visit our [Discord Support](https://discord.postiz.com) for assistance. + - type: textarea + id: feature-description + validations: + required: true + attributes: + label: For installation issues, please visit our https://discord.postiz.com for assistance. + description: For installation issues, please visit our [Discord Support](https://discord.postiz.com) for assistance. + placeholder: | + For installation issues, please visit our https://discord.postiz.com for assistance. + Please do not save this issue - do not submit installation issues on GitHub. + diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/02_bug_report.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/bug_report.yml rename to .github/ISSUE_TEMPLATE/02_bug_report.yml diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/03_feature_request.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/feature_request.yml rename to .github/ISSUE_TEMPLATE/03_feature_request.yml diff --git a/README.md b/README.md index 37a882c3..17878c53 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ +

+ + + +

+

- Novu Logo + Postiz Logo

@@ -58,22 +64,6 @@
- -

-


-

We participate in Hacktoberfest 2024! 🎉🎊

-

We are sending a t-shirt for every merged PR! (max 1 per person)

-

Rules:

- -

-


-

-

diff --git a/apps/backend/src/api/routes/copilot.controller.ts b/apps/backend/src/api/routes/copilot.controller.ts index e66a13fd..65928b20 100644 --- a/apps/backend/src/api/routes/copilot.controller.ts +++ b/apps/backend/src/api/routes/copilot.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Req, Res } from '@nestjs/common'; +import { Logger, Controller, Get, Post, Req, Res } from '@nestjs/common'; import { CopilotRuntime, OpenAIAdapter, @@ -13,6 +13,11 @@ export class CopilotController { constructor(private _subscriptionService: SubscriptionService) {} @Post('/chat') chat(@Req() req: Request, @Res() res: Response) { + if (process.env.OPENAI_API_KEY === undefined || process.env.OPENAI_API_KEY === '') { + Logger.warn('OpenAI API key not set, chat functionality will not work'); + return + } + const copilotRuntimeHandler = copilotRuntimeNestEndpoint({ endpoint: '/copilot/chat', runtime: new CopilotRuntime(), diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index f7c4ad74..ae587603 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -1,12 +1,5 @@ import { - Body, - Controller, - Delete, - Get, - Param, - Post, - Query, - UseFilters, + Body, Controller, Delete, Get, Param, Post, Put, Query, UseFilters } from '@nestjs/common'; import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; import { ConnectIntegrationDto } from '@gitroom/nestjs-libraries/dtos/integrations/connect.integration.dto'; @@ -29,6 +22,12 @@ import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/po import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto'; import { AuthService } from '@gitroom/helpers/auth/auth.service'; import { AuthTokenDetails } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto'; +import { + NotEnoughScopes, + RefreshToken, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { timer } from '@gitroom/helpers/utils/timer'; @ApiTags('Integrations') @Controller('/integrations') @@ -43,6 +42,37 @@ export class IntegrationsController { return this._integrationManager.getAllIntegrations(); } + @Get('/customers') + getCustomers(@GetOrgFromRequest() org: Organization) { + return this._integrationService.customers(org.id); + } + + @Put('/:id/group') + async updateIntegrationGroup( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string, + @Body() body: { group: string } + ) { + return this._integrationService.updateIntegrationGroup( + org.id, + id, + body.group + ); + } + + @Put('/:id/customer-name') + async updateOnCustomerName( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string, + @Body() body: { name: string } + ) { + return this._integrationService.updateOnCustomerName( + org.id, + id, + body.name + ); + } + @Get('/list') async getIntegrationList(@GetOrgFromRequest() org: Organization) { return { @@ -57,7 +87,7 @@ export class IntegrationsController { id: p.id, internalId: p.internalId, disabled: p.disabled, - picture: p.picture, + picture: p.picture || '/no-picture.jpg', identifier: p.providerIdentifier, inBetweenSteps: p.inBetweenSteps, refreshNeeded: p.refreshNeeded, @@ -66,6 +96,7 @@ export class IntegrationsController { time: JSON.parse(p.postingTimes), changeProfilePicture: !!findIntegration?.changeProfilePicture, changeNickName: !!findIntegration?.changeNickname, + customer: p.customer, }; }), }; @@ -207,11 +238,51 @@ export class IntegrationsController { } if (integrationProvider[body.name]) { - return integrationProvider[body.name]( - getIntegration.token, - body.data, - getIntegration.internalId - ); + try { + const load = await integrationProvider[body.name]( + getIntegration.token, + body.data, + getIntegration.internalId + ); + + return load; + } catch (err) { + if (err instanceof RefreshToken) { + const { accessToken, refreshToken, expiresIn } = + await integrationProvider.refreshToken( + getIntegration.refreshToken + ); + + if (accessToken) { + await this._integrationService.createOrUpdateIntegration( + getIntegration.organizationId, + getIntegration.name, + getIntegration.picture!, + 'social', + getIntegration.internalId, + getIntegration.providerIdentifier, + accessToken, + refreshToken, + expiresIn + ); + + getIntegration.token = accessToken; + + if (integrationProvider.refreshWait) { + await timer(10000); + } + return this.functionIntegration(org, body); + } else { + await this._integrationService.disconnectChannel( + org.id, + getIntegration + ); + return false; + } + } + + return false; + } } throw new Error('Function not found'); } @@ -323,6 +394,7 @@ export class IntegrationsController { } const { + error, accessToken, expiresIn, refreshToken, @@ -341,6 +413,17 @@ export class IntegrationsController { details ? JSON.parse(details) : undefined ); + if (typeof auth === 'string') { + return res({ + error: auth, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }); + } + if (refresh && integrationProvider.reConnect) { const newAuth = await integrationProvider.reConnect( auth.id, @@ -353,13 +436,31 @@ export class IntegrationsController { return res(auth); }); - if (!id) { - throw new Error('Invalid api key'); + if (error) { + throw new NotEnoughScopes(error); } + if (!id) { + throw new NotEnoughScopes('Invalid API key'); + } + + if (refresh && id !== refresh) { + throw new NotEnoughScopes( + 'Please refresh the channel that needs to be refreshed' + ); + } + + let validName = name; + if (!validName) { + if (username) { + validName = username.split('.')[0] ?? username; + } else { + validName = `Channel_${String(id).slice(0, 8)}`; + } + } return this._integrationService.createOrUpdateIntegration( org.id, - name, + validName.trim(), picture, 'social', String(id), @@ -446,4 +547,35 @@ export class IntegrationsController { return this._integrationService.deleteChannel(org.id, id); } + + @Get('/plug/list') + async getPlugList() { + return { plugs: this._integrationManager.getAllPlugs() }; + } + + @Get('/:id/plugs') + async getPlugsByIntegrationId( + @Param('id') id: string, + @GetOrgFromRequest() org: Organization + ) { + return this._integrationService.getPlugsByIntegrationId(org.id, id); + } + + @Post('/:id/plugs') + async postPlugsByIntegrationId( + @Param('id') id: string, + @GetOrgFromRequest() org: Organization, + @Body() body: PlugDto + ) { + return this._integrationService.createOrUpdatePlug(org.id, id, body); + } + + @Put('/plugs/:id/activate') + async changePlugActivation( + @Param('id') id: string, + @GetOrgFromRequest() org: Organization, + @Body('status') status: boolean + ) { + return this._integrationService.changePlugActivation(org.id, id, status); + } } diff --git a/apps/backend/src/api/routes/media.controller.ts b/apps/backend/src/api/routes/media.controller.ts index 9daccbef..b28619f4 100644 --- a/apps/backend/src/api/routes/media.controller.ts +++ b/apps/backend/src/api/routes/media.controller.ts @@ -10,7 +10,6 @@ import handleR2Upload from '@gitroom/nestjs-libraries/upload/r2.uploader'; import { FileInterceptor } from '@nestjs/platform-express'; import { CustomFileValidationPipe } from '@gitroom/nestjs-libraries/upload/custom.upload.validation'; import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; -import { basename } from 'path'; import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory'; @ApiTags('Media') diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 85311535..2a63ca8c 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -47,7 +47,7 @@ export class UsersController { if (!organization) { throw new HttpForbiddenException(); } - + // @ts-ignore return { ...user, orgId: organization.id, @@ -61,6 +61,8 @@ export class UsersController { isLifetime: !!organization?.subscription?.isLifetime, admin: !!user.isSuperAdmin, impersonate: !!req.cookies.impersonate, + // @ts-ignore + publicApi: (organization?.users[0]?.role === 'SUPERADMIN' || organization?.users[0]?.role === 'ADMIN') ? organization?.apiKey : '', }; } diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 154dff84..2d3139d1 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -6,12 +6,31 @@ import { APP_GUARD } from '@nestjs/core'; import { PoliciesGuard } from '@gitroom/backend/services/auth/permissions/permissions.guard'; import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bull.mq.module'; import { PluginModule } from '@gitroom/plugins/plugin.module'; +import { PublicApiModule } from '@gitroom/backend/public-api/public.api.module'; +import { ThrottlerBehindProxyGuard } from '@gitroom/nestjs-libraries/throttler/throttler.provider'; +import { ThrottlerModule } from '@nestjs/throttler'; @Global() @Module({ - imports: [BullMqModule, DatabaseModule, ApiModule, PluginModule], + imports: [ + BullMqModule, + DatabaseModule, + ApiModule, + PluginModule, + PublicApiModule, + ThrottlerModule.forRoot([ + { + ttl: 3600000, + limit: 20, + }, + ]), + ], controllers: [], providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerBehindProxyGuard, + }, { provide: APP_GUARD, useClass: PoliciesGuard, diff --git a/apps/backend/src/public-api/public.api.module.ts b/apps/backend/src/public-api/public.api.module.ts new file mode 100644 index 00000000..ac4389d0 --- /dev/null +++ b/apps/backend/src/public-api/public.api.module.ts @@ -0,0 +1,42 @@ +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { AuthService } from '@gitroom/backend/services/auth/auth.service'; +import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service'; +import { PoliciesGuard } from '@gitroom/backend/services/auth/permissions/permissions.guard'; +import { PermissionsService } from '@gitroom/backend/services/auth/permissions/permissions.service'; +import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager'; +import { UploadModule } from '@gitroom/nestjs-libraries/upload/upload.module'; +import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service'; +import { ExtractContentService } from '@gitroom/nestjs-libraries/openai/extract.content.service'; +import { CodesService } from '@gitroom/nestjs-libraries/services/codes.service'; +import { PublicIntegrationsController } from '@gitroom/backend/public-api/routes/v1/public.integrations.controller'; +import { PublicAuthMiddleware } from '@gitroom/backend/services/auth/public.auth.middleware'; + +const authenticatedController = [ + PublicIntegrationsController +]; +@Module({ + imports: [ + UploadModule, + ], + controllers: [ + ...authenticatedController, + ], + providers: [ + AuthService, + StripeService, + OpenaiService, + ExtractContentService, + PoliciesGuard, + PermissionsService, + CodesService, + IntegrationManager, + ], + get exports() { + return [...this.imports, ...this.providers]; + }, +}) +export class PublicApiModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(PublicAuthMiddleware).forRoutes(...authenticatedController); + } +} diff --git a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts new file mode 100644 index 00000000..af9913e7 --- /dev/null +++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts @@ -0,0 +1,77 @@ +import { + Body, Controller, Get, HttpException, Post, UploadedFile, UseInterceptors +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; +import { Organization } from '@prisma/client'; +import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; +import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability'; +import { + AuthorizationActions, + Sections, +} from '@gitroom/backend/services/auth/permissions/permissions.service'; +import { CreatePostDto } from '@gitroom/nestjs-libraries/dtos/posts/create.post.dto'; +import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory'; +import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service'; + +@ApiTags('Public API') +@Controller('/public/v1') +export class PublicIntegrationsController { + private storage = UploadFactory.createStorage(); + + constructor( + private _integrationService: IntegrationService, + private _postsService: PostsService, + private _mediaService: MediaService + ) {} + + @Post('/upload') + @UseInterceptors(FileInterceptor('file')) + async uploadSimple( + @GetOrgFromRequest() org: Organization, + @UploadedFile('file') file: Express.Multer.File + ) { + if (!file) { + throw new HttpException({msg: 'No file provided'}, 400); + } + + const getFile = await this.storage.uploadFile(file); + return this._mediaService.saveFile( + org.id, + getFile.originalname, + getFile.path + ); + } + + @Post('/posts') + @CheckPolicies([AuthorizationActions.Create, Sections.POSTS_PER_MONTH]) + createPost( + @GetOrgFromRequest() org: Organization, + @Body() body: CreatePostDto + ) { + console.log(JSON.stringify(body, null, 2)); + return this._postsService.createPost(org.id, body); + } + + @Get('/integrations') + async listIntegration(@GetOrgFromRequest() org: Organization) { + return (await this._integrationService.getIntegrationsList(org.id)).map( + (org) => ({ + id: org.id, + name: org.name, + identifier: org.providerIdentifier, + picture: org.picture, + disabled: org.disabled, + profile: org.profile, + customer: org.customer + ? { + id: org.customer.id, + name: org.customer.name, + } + : undefined, + }) + ); + } +} diff --git a/apps/backend/src/services/auth/public.auth.middleware.ts b/apps/backend/src/services/auth/public.auth.middleware.ts new file mode 100644 index 00000000..0c32fd32 --- /dev/null +++ b/apps/backend/src/services/auth/public.auth.middleware.ts @@ -0,0 +1,35 @@ +import { HttpStatus, Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; +import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter'; + +@Injectable() +export class PublicAuthMiddleware implements NestMiddleware { + constructor(private _organizationService: OrganizationService) {} + async use(req: Request, res: Response, next: NextFunction) { + const auth = (req.headers.authorization || + req.headers.Authorization) as string; + if (!auth) { + res.status(HttpStatus.UNAUTHORIZED).json({ msg: 'No API Key found' }); + return; + } + try { + const org = await this._organizationService.getOrgByApiKey(auth); + if (!org) { + res.status(HttpStatus.UNAUTHORIZED).json({ msg: 'Invalid API key' }); + return ; + } + + if (!!process.env.STRIPE_SECRET_KEY && !org.subscription) { + res.status(HttpStatus.UNAUTHORIZED).json({ msg: 'No subscription found' }); + return ; + } + + // @ts-ignore + req.org = {...org, users: [{users: {role: 'SUPERADMIN'}}]}; + } catch (err) { + throw new HttpForbiddenException(); + } + next(); + } +} diff --git a/apps/frontend/public/favicon.ico b/apps/frontend/public/favicon.ico old mode 100644 new mode 100755 index 317ebcb2..5b622e17 Binary files a/apps/frontend/public/favicon.ico and b/apps/frontend/public/favicon.ico differ diff --git a/apps/frontend/public/no-picture.jpg b/apps/frontend/public/no-picture.jpg new file mode 100644 index 00000000..7a8a8432 Binary files /dev/null and b/apps/frontend/public/no-picture.jpg differ diff --git a/apps/frontend/src/app/(site)/integrations/social/[provider]/continue/page.tsx b/apps/frontend/src/app/(site)/integrations/social/[provider]/continue/page.tsx index 8a06e542..5beb10e0 100644 --- a/apps/frontend/src/app/(site)/integrations/social/[provider]/continue/page.tsx +++ b/apps/frontend/src/app/(site)/integrations/social/[provider]/continue/page.tsx @@ -28,7 +28,8 @@ export default async function Page({ }); if (data.status === HttpStatusCode.NotAcceptable) { - return redirect(`/launches?scope=missing`); + const { msg } = await data.json(); + return redirect(`/launches?msg=${msg}`); } if ( @@ -53,5 +54,5 @@ export default async function Page({ return redirect(`/launches?added=${provider}&continue=${id}`); } - return redirect(`/launches?added=${provider}`); + return redirect(`/launches?added=${provider}&msg=Channel Updated`); } diff --git a/apps/frontend/src/app/(site)/plugs/page.tsx b/apps/frontend/src/app/(site)/plugs/page.tsx new file mode 100644 index 00000000..64a417e3 --- /dev/null +++ b/apps/frontend/src/app/(site)/plugs/page.tsx @@ -0,0 +1,19 @@ +import { Plugs } from '@gitroom/frontend/components/plugs/plugs'; + +export const dynamic = 'force-dynamic'; + +import { Metadata } from 'next'; +import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; + +export const metadata: Metadata = { + title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Plugs`, + description: '', +}; + +export default async function Index() { + return ( + <> + + + ); +} diff --git a/apps/frontend/src/app/global.scss b/apps/frontend/src/app/global.scss index da791348..885b1e7d 100644 --- a/apps/frontend/src/app/global.scss +++ b/apps/frontend/src/app/global.scss @@ -10,7 +10,6 @@ html { } .box { position: relative; - padding: 8px 24px; } .box span { position: relative; @@ -385,3 +384,14 @@ div div .set-font-family { font-style: normal !important; font-weight: 400 !important; } + +.col-calendar:hover:before { + content: "Date passed"; + color: white; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + white-space: nowrap; + opacity: 30%; +} \ No newline at end of file diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index df4509cd..9a99ad77 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -10,22 +10,32 @@ import { Chakra_Petch } from 'next/font/google'; import PlausibleProvider from 'next-plausible'; import clsx from 'clsx'; import { VariableContextComponent } from '@gitroom/react/helpers/variable.context'; +import { Fragment } from 'react'; +import { PHProvider } from '@gitroom/react/helpers/posthog'; +import UtmSaver from '@gitroom/helpers/utils/utm.saver'; +import { ToltScript } from '@gitroom/frontend/components/layout/tolt.script'; const chakra = Chakra_Petch({ weight: '400', subsets: ['latin'] }); export default async function AppLayout({ children }: { children: ReactNode }) { + const Plausible = !!process.env.STRIPE_PUBLISHABLE_KEY + ? PlausibleProvider + : Fragment; + return ( - + - {children} - + + + {children} + + diff --git a/apps/frontend/src/components/analytics/chart-social.tsx b/apps/frontend/src/components/analytics/chart-social.tsx index f51db426..b4191ba8 100644 --- a/apps/frontend/src/components/analytics/chart-social.tsx +++ b/apps/frontend/src/components/analytics/chart-social.tsx @@ -2,7 +2,6 @@ import { FC, useEffect, useMemo, useRef } from 'react'; import DrawChart from 'chart.js/auto'; import { TotalList } from '@gitroom/frontend/components/analytics/stars.and.forks.interface'; -import dayjs from 'dayjs'; import { chunk } from 'lodash'; function mergeDataPoints(data: TotalList[], numPoints: number): TotalList[] { diff --git a/apps/frontend/src/components/analytics/chart.tsx b/apps/frontend/src/components/analytics/chart.tsx index 06ff4354..7d46d349 100644 --- a/apps/frontend/src/components/analytics/chart.tsx +++ b/apps/frontend/src/components/analytics/chart.tsx @@ -1,5 +1,5 @@ 'use client'; -import { FC, useEffect, useMemo, useRef } from 'react'; +import { FC, useEffect, useRef } from 'react'; import DrawChart from 'chart.js/auto'; import { ForksList, diff --git a/apps/frontend/src/components/billing/main.billing.component.tsx b/apps/frontend/src/components/billing/main.billing.component.tsx index fc2f88d2..c2e9395c 100644 --- a/apps/frontend/src/components/billing/main.billing.component.tsx +++ b/apps/frontend/src/components/billing/main.billing.component.tsx @@ -3,8 +3,6 @@ import { Slider } from '@gitroom/react/form/slider'; import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { Button } from '@gitroom/react/form/button'; -import { sortBy } from 'lodash'; -import { Track } from '@gitroom/react/form/track'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { Subscription } from '@prisma/client'; import { useDebouncedCallback } from 'use-debounce'; @@ -21,9 +19,11 @@ import interClass from '@gitroom/react/helpers/inter.font'; import { useRouter } from 'next/navigation'; import { useVariables } from '@gitroom/react/helpers/variable.context'; import { useModals } from '@mantine/modals'; -import { AddProviderComponent } from '@gitroom/frontend/components/launches/add.provider.component'; import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component'; import { Textarea } from '@gitroom/react/form/textarea'; +import { useFireEvents } from '@gitroom/helpers/utils/use.fire.events'; +import { useUtmUrl } from '@gitroom/helpers/utils/utm.saver'; +import { useTolt } from '@gitroom/frontend/components/layout/tolt.script'; export interface Tiers { month: Array<{ @@ -156,9 +156,11 @@ export const Features: FC<{ const Info: FC<{ proceed: (feedback: string) => void }> = (props) => { const [feedback, setFeedback] = useState(''); const modal = useModals(); + const events = useFireEvents(); const cancel = useCallback(() => { props.proceed(feedback); + events('cancel_subscription'); modal.closeAll(); }, [modal, feedback]); @@ -219,6 +221,8 @@ export const MainBillingComponent: FC<{ const user = useUser(); const modal = useModals(); const router = useRouter(); + const utm = useUtmUrl(); + const tolt = useTolt(); const [subscription, setSubscription] = useState( sub @@ -344,7 +348,9 @@ export const MainBillingComponent: FC<{ method: 'POST', body: JSON.stringify({ period: monthlyOrYearly === 'on' ? 'YEARLY' : 'MONTHLY', + utm, billing, + tolt: tolt() }), }) ).json(); @@ -386,7 +392,7 @@ export const MainBillingComponent: FC<{ setLoading(false); }, - [monthlyOrYearly, subscription, user] + [monthlyOrYearly, subscription, user, utm] ); if (user?.isLifetime) { diff --git a/apps/frontend/src/components/launches/add.edit.model.tsx b/apps/frontend/src/components/launches/add.edit.model.tsx index 966a2db8..38c434de 100644 --- a/apps/frontend/src/components/launches/add.edit.model.tsx +++ b/apps/frontend/src/components/launches/add.edit.model.tsx @@ -49,6 +49,17 @@ import { CopilotPopup } from '@copilotkit/react-ui'; import { useUser } from '@gitroom/frontend/components/layout/user.context'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import Image from 'next/image'; +import { weightedLength } from '@gitroom/helpers/utils/count.length'; +import { uniqBy } from 'lodash'; +import { Select } from '@gitroom/react/form/select'; + +function countCharacters(text: string, type: string): number { + if (type !== 'x') { + return text.length; + } + + return weightedLength(text); +} export const AddEditModal: FC<{ date: dayjs.Dayjs; @@ -56,17 +67,36 @@ export const AddEditModal: FC<{ reopenModal: () => void; mutate: () => void; }> = (props) => { - const { date, integrations, reopenModal, mutate } = props; - const [dateState, setDateState] = useState(date); - - // hook to open a new modal - const modal = useModals(); + const { date, integrations: ints, reopenModal, mutate } = props; + const [customer, setCustomer] = useState(''); // selected integrations to allow edit const [selectedIntegrations, setSelectedIntegrations] = useStateCallback< Integrations[] >([]); + const integrations = useMemo(() => { + if (!customer) { + return ints; + } + + const list = ints.filter((f) => f?.customer?.id === customer); + if (list.length === 1) { + setSelectedIntegrations([list[0]]); + } + + return list; + }, [customer, ints]); + + const totalCustomers = useMemo(() => { + return uniqBy(ints, (i) => i?.customer?.id).length; + }, [ints]); + + const [dateState, setDateState] = useState(date); + + // hook to open a new modal + const modal = useModals(); + // value of each editor const [value, setValue] = useState< Array<{ @@ -267,7 +297,8 @@ export const AddEditModal: FC<{ for (const key of allKeys) { if (key.checkValidity) { const check = await key.checkValidity( - key?.value.map((p: any) => p.image || []) + key?.value.map((p: any) => p.image || []), + key.settings ); if (typeof check === 'string') { toaster.show(check, 'warning'); @@ -276,9 +307,12 @@ export const AddEditModal: FC<{ } if ( - key.value.some( - (p) => p.content.length > (key.maximumCharacters || 1000000) - ) + key.value.some((p) => { + return ( + countCharacters(p.content, key?.integration?.identifier || '') > + (key.maximumCharacters || 1000000) + ); + }) ) { if ( !(await deleteDialog( @@ -386,14 +420,15 @@ export const AddEditModal: FC<{ /> )}
@@ -404,6 +439,26 @@ export const AddEditModal: FC<{ information={data} onChange={setPostFor} /> + {totalCustomers > 1 && ( + + )}
@@ -419,7 +474,7 @@ export const AddEditModal: FC<{ ) : (
) : null}
-
-
-
- ; + close?: () => void; identifier: string; gotoUrl(url: string): void; }> = (props) => { - const { gotoUrl, identifier, variables } = props; + const { close, gotoUrl, identifier, variables } = props; const modals = useModals(); const schema = useMemo(() => { return object({ @@ -241,7 +242,7 @@ export const CustomVariables: FC<{
{ {hours.map((hour) => (
- {hour.toString().padStart(2, '0')}:00 + {/* {hour.toString().padStart(2, '0')}:00 */} + {convertTimeFormatBasedOnLocality(hour)}
{days.map((day, indexDay) => ( @@ -230,7 +241,7 @@ export const Calendar = () => { const { display } = useCalendar(); return ( - + <> {display === 'day' ? ( ) : display === 'week' ? ( @@ -238,7 +249,7 @@ export const Calendar = () => { ) : ( )} - + ); }; @@ -432,8 +443,9 @@ export const CalendarColumn: FC<{ )}
@@ -499,7 +512,10 @@ export const CalendarColumn: FC<{ className={`w-full h-full rounded-[10px] hover:border hover:border-seventh flex justify-center items-center gap-[20px] opacity-30 grayscale hover:grayscale-0 hover:opacity-100`} > {integrations.map((selectedIntegrations) => ( -
+
{state === 'DRAFT' ? 'Draft: ' : ''} - {post.content} + {removeMd(post.content).replace(/\n/g, ' ')}
); diff --git a/apps/frontend/src/components/launches/customer.modal.tsx b/apps/frontend/src/components/launches/customer.modal.tsx new file mode 100644 index 00000000..10963225 --- /dev/null +++ b/apps/frontend/src/components/launches/customer.modal.tsx @@ -0,0 +1,88 @@ +import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component'; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { useModals } from '@mantine/modals'; +import { Integration } from '@prisma/client'; +import { Autocomplete } from '@mantine/core'; +import useSWR from 'swr'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { Button } from '@gitroom/react/form/button'; + +export const CustomerModal: FC<{ + integration: Integration & { customer?: { id: string; name: string } }; + onClose: () => void; +}> = (props) => { + const fetch = useFetch(); + const { onClose, integration } = props; + const [customer, setCustomer] = useState( + integration.customer?.name || undefined + ); + const modal = useModals(); + + const loadCustomers = useCallback(async () => { + return (await fetch('/integrations/customers')).json(); + }, []); + + const removeFromCustomer = useCallback(async () => { + saveCustomer(true); + }, []); + + const saveCustomer = useCallback(async (removeCustomer?: boolean) => { + if (!customer) { + return; + } + + await fetch(`/integrations/${integration.id}/customer-name`, { + method: 'PUT', + body: JSON.stringify({ name: removeCustomer ? '' : customer }), + }); + + modal.closeAll(); + onClose(); + }, [customer]); + + const { data } = useSWR('/customers', loadCustomers); + + return ( +
+ + + +
+ p.name) || []} + /> +
+ +
+ + {!!integration?.customer?.name && } +
+
+ ); +}; diff --git a/apps/frontend/src/components/launches/editor.tsx b/apps/frontend/src/components/launches/editor.tsx index 8d7d4ba2..e2906b01 100644 --- a/apps/frontend/src/components/launches/editor.tsx +++ b/apps/frontend/src/components/launches/editor.tsx @@ -1,9 +1,8 @@ -import { forwardRef, useCallback, useRef } from 'react'; +import { forwardRef } from 'react'; import type { MDEditorProps } from '@uiw/react-md-editor/src/Types'; import { RefMDEditor } from '@uiw/react-md-editor/src/Editor'; import MDEditor from '@uiw/react-md-editor'; import { useCopilotAction, useCopilotReadable } from '@copilotkit/react-core'; -import dayjs from 'dayjs'; import { CopilotTextarea } from '@copilotkit/react-textarea'; import clsx from 'clsx'; import { useUser } from '@gitroom/frontend/components/layout/user.context'; diff --git a/apps/frontend/src/components/launches/filters.tsx b/apps/frontend/src/components/launches/filters.tsx index f87cea68..db21fa5b 100644 --- a/apps/frontend/src/components/launches/filters.tsx +++ b/apps/frontend/src/components/launches/filters.tsx @@ -3,6 +3,7 @@ import { useCalendar } from '@gitroom/frontend/components/launches/calendar.cont import clsx from 'clsx'; import dayjs from 'dayjs'; import { useCallback } from 'react'; +import { isUSCitizen } from './helpers/isuscitizen.utils'; export const Filters = () => { const week = useCalendar(); @@ -12,30 +13,30 @@ export const Filters = () => { .year(week.currentYear) .isoWeek(week.currentWeek) .day(week.currentDay) - .format('DD/MM/YYYY') + .format(isUSCitizen() ? 'MM/DD/YYYY' :'DD/MM/YYYY') : week.display === 'week' ? dayjs() .year(week.currentYear) .isoWeek(week.currentWeek) .startOf('isoWeek') - .format('DD/MM/YYYY') + + .format(isUSCitizen() ? 'MM/DD/YYYY' :'DD/MM/YYYY') + ' - ' + dayjs() .year(week.currentYear) .isoWeek(week.currentWeek) .endOf('isoWeek') - .format('DD/MM/YYYY') + .format(isUSCitizen() ? 'MM/DD/YYYY' :'DD/MM/YYYY') : dayjs() .year(week.currentYear) .month(week.currentMonth) .startOf('month') - .format('DD/MM/YYYY') + + .format(isUSCitizen() ? 'MM/DD/YYYY' :'DD/MM/YYYY') + ' - ' + dayjs() .year(week.currentYear) .month(week.currentMonth) .endOf('month') - .format('DD/MM/YYYY'); + .format(isUSCitizen() ? 'MM/DD/YYYY' :'DD/MM/YYYY'); const setDay = useCallback(() => { week.setFilters({ @@ -145,74 +146,78 @@ export const Filters = () => { week.currentDay, ]); return ( -
-
- - - -
-
- {week.display === 'day' - ? `${dayjs() - .month(week.currentMonth) - .week(week.currentWeek) - .day(week.currentDay) - .format('dddd')}` - : week.display === 'week' - ? `Week ${week.currentWeek}` - : `${dayjs().month(week.currentMonth).format('MMMM')}`} -
-
- - - -
-
{betweenDates}
-
- Day -
-
- Week -
-
- Month -
+
+
+
+ + + +
+
+ {week.display === 'day' + ? `${dayjs() + .month(week.currentMonth) + .week(week.currentWeek) + .day(week.currentDay) + .format('dddd')}` + : week.display === 'week' + ? `Week ${week.currentWeek}` + : `${dayjs().month(week.currentMonth).format('MMMM')}`} +
+
+ + + +
+
{betweenDates}
+
+
+
+ Day +
+
+ Week +
+
+ Month +
+
); }; diff --git a/apps/frontend/src/components/launches/general.preview.component.tsx b/apps/frontend/src/components/launches/general.preview.component.tsx index ac55c38e..91241635 100644 --- a/apps/frontend/src/components/launches/general.preview.component.tsx +++ b/apps/frontend/src/components/launches/general.preview.component.tsx @@ -3,9 +3,9 @@ import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory'; import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting'; import clsx from 'clsx'; import { VideoOrImage } from '@gitroom/react/helpers/video.or.image'; -import { Chakra_Petch } from 'next/font/google'; import { FC } from 'react'; -const chakra = Chakra_Petch({ weight: '400', subsets: ['latin'] }); +import { textSlicer } from '@gitroom/helpers/utils/count.length'; +import interClass from '@gitroom/react/helpers/inter.font'; export const GeneralPreviewComponent: FC<{maximumCharacters?: number}> = (props) => { const { value: topValue, integration } = useIntegration(); @@ -14,12 +14,13 @@ export const GeneralPreviewComponent: FC<{maximumCharacters?: number}> = (props) removeMarkdown: true, saveBreaklines: true, specialFunc: (text: string) => { - return text.slice(0, props.maximumCharacters || 10000) + '' + text?.slice(props.maximumCharacters || 10000) + ''; + const {start, end} = textSlicer(integration?.identifier || '', props.maximumCharacters || 10000, text); + return text.slice(start, end) + '' + text?.slice(end) + ''; }, }); return ( -
+
{newValues.map((value, index) => (
= (props) {integration?.display || '@username'}
-
+              
               {!!value?.images?.length && (
                 
3 ? 'grid grid-cols-2 gap-[4px]' : 'flex gap-[4px]')}> {value.images.map((image, index) => ( diff --git a/apps/frontend/src/components/launches/generator/generator.tsx b/apps/frontend/src/components/launches/generator/generator.tsx index dc6b20dd..e75b32c1 100644 --- a/apps/frontend/src/components/launches/generator/generator.tsx +++ b/apps/frontend/src/components/launches/generator/generator.tsx @@ -1,5 +1,4 @@ import React, { - ChangeEventHandler, FC, useCallback, useMemo, diff --git a/apps/frontend/src/components/launches/helpers/date.picker.tsx b/apps/frontend/src/components/launches/helpers/date.picker.tsx index 1e1b42c7..38bed701 100644 --- a/apps/frontend/src/components/launches/helpers/date.picker.tsx +++ b/apps/frontend/src/components/launches/helpers/date.picker.tsx @@ -3,6 +3,7 @@ import dayjs from 'dayjs'; import { Calendar, TimeInput } from '@mantine/dates'; import { useClickOutside } from '@mantine/hooks'; import { Button } from '@gitroom/react/form/button'; +import { isUSCitizen } from './isuscitizen.utils'; export const DatePicker: FC<{ date: dayjs.Dayjs; @@ -39,7 +40,7 @@ export const DatePicker: FC<{ onClick={changeShow} ref={ref} > -
{date.format('DD/MM/YYYY HH:mm')}
+
{date.format(isUSCitizen() ? 'MM/DD/YYYY hh:mm A' : 'DD/MM/YYYY HH:mm')}
{ + const userLanguage = navigator.language || navigator.languages[0]; + return userLanguage.startsWith('en-US') +} \ No newline at end of file diff --git a/apps/frontend/src/components/launches/helpers/linkedin.component.tsx b/apps/frontend/src/components/launches/helpers/linkedin.component.tsx index af1c36a2..cc218851 100644 --- a/apps/frontend/src/components/launches/helpers/linkedin.component.tsx +++ b/apps/frontend/src/components/launches/helpers/linkedin.component.tsx @@ -13,8 +13,7 @@ import { import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { Input } from '@gitroom/react/form/input'; import { Button } from '@gitroom/react/form/button'; -import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function'; -import dayjs from 'dayjs'; +import { useToaster } from '@gitroom/react/toaster/toaster'; const postUrlEmitter = new EventEmitter(); @@ -78,26 +77,32 @@ export const LinkedinCompany: FC<{ const { onClose, onSelect, id } = props; const fetch = useFetch(); const [company, setCompany] = useState(null); + const toast = useToaster(); const getCompany = async () => { if (!company) { - return ; + return; } - const {options} = await ( - await fetch('/integrations/function', { - method: 'POST', - body: JSON.stringify({ - id, - name: 'company', - data: { - url: company, - }, - }), - }) - ).json(); - onSelect(options.value); - onClose(); + try { + const { options } = await ( + await fetch('/integrations/function', { + method: 'POST', + body: JSON.stringify({ + id, + name: 'company', + data: { + url: company, + }, + }), + }) + ).json(); + + onSelect(options.value); + onClose(); + } catch (e) { + toast.show('Failed to load profile', 'warning'); + } }; return ( diff --git a/apps/frontend/src/components/launches/helpers/use.formatting.ts b/apps/frontend/src/components/launches/helpers/use.formatting.ts index be759f00..8c2e71a1 100644 --- a/apps/frontend/src/components/launches/helpers/use.formatting.ts +++ b/apps/frontend/src/components/launches/helpers/use.formatting.ts @@ -26,6 +26,9 @@ export const useFormatting = ( if (params.removeMarkdown) { newText = removeMd(newText); } + newText = newText.replace(/@\w{1,15}/g, function(match) { + return `${match}`; + }); if (params.saveBreaklines) { newText = newText.replace('𝔫𝔢𝔴𝔩𝔦𝔫𝔢', '\n'); } diff --git a/apps/frontend/src/components/launches/helpers/use.values.ts b/apps/frontend/src/components/launches/helpers/use.values.ts index d156da9d..a863e776 100644 --- a/apps/frontend/src/components/launches/helpers/use.values.ts +++ b/apps/frontend/src/components/launches/helpers/use.values.ts @@ -8,7 +8,10 @@ const finalInformation = {} as { settings: () => object; trigger: () => Promise; isValid: boolean; - checkValidity?: (value: Array>) => Promise; + checkValidity?: ( + value: Array>, + settings: any + ) => Promise; maximumCharacters?: number; }; }; @@ -18,8 +21,11 @@ export const useValues = ( identifier: string, value: Array<{ id?: string; content: string; media?: Array }>, dto: any, - checkValidity?: (value: Array>) => Promise, - maximumCharacters?: number, + checkValidity?: ( + value: Array>, + settings: any + ) => Promise, + maximumCharacters?: number ) => { const resolver = useMemo(() => { return classValidatorResolver(dto); @@ -43,8 +49,7 @@ export const useValues = ( finalInformation[integration].trigger = form.trigger; if (checkValidity) { - finalInformation[integration].checkValidity = - checkValidity; + finalInformation[integration].checkValidity = checkValidity; } if (maximumCharacters) { diff --git a/apps/frontend/src/components/launches/launches.component.tsx b/apps/frontend/src/components/launches/launches.component.tsx index 8134d4e6..cf528836 100644 --- a/apps/frontend/src/components/launches/launches.component.tsx +++ b/apps/frontend/src/components/launches/launches.component.tsx @@ -1,10 +1,9 @@ 'use client'; import { AddProviderButton } from '@gitroom/frontend/components/launches/add.provider.component'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import Image from 'next/image'; -import { orderBy } from 'lodash'; -// import { Calendar } from '@gitroom/frontend/components/launches/calendar'; +import { groupBy, orderBy } from 'lodash'; import { CalendarWeekProvider } from '@gitroom/frontend/components/launches/calendar.context'; import { Filters } from '@gitroom/frontend/components/launches/filters'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; @@ -19,7 +18,201 @@ import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback'; import { useToaster } from '@gitroom/react/toaster/toaster'; import { useFireEvents } from '@gitroom/helpers/utils/use.fire.events'; import { Calendar } from './calendar'; +import { useDrag, useDrop } from 'react-dnd'; +import { DNDProvider } from '@gitroom/frontend/components/launches/helpers/dnd.provider'; +interface MenuComponentInterface { + refreshChannel: ( + integration: Integration & { identifier: string } + ) => () => void; + continueIntegration: (integration: Integration) => () => void; + totalNonDisabledChannels: number; + mutate: (shouldReload?: boolean) => void; + update: (shouldReload: boolean) => void; +} + +export const MenuGroupComponent: FC< + MenuComponentInterface & { + changeItemGroup: (id: string, group: string) => void; + group: { + id: string; + name: string; + values: Array< + Integration & { + identifier: string; + changeProfilePicture: boolean; + changeNickName: boolean; + } + >; + }; + } +> = (props) => { + const { + group, + mutate, + update, + continueIntegration, + totalNonDisabledChannels, + refreshChannel, + changeItemGroup, + } = props; + + const [collectedProps, drop] = useDrop(() => ({ + accept: 'menu', + drop: (item: { id: string }, monitor) => { + changeItemGroup(item.id, group.id); + }, + collect: (monitor) => ({ + isOver: !!monitor.isOver(), + }), + })); + + return ( +
+ {collectedProps.isOver && ( +
+
+
+
+
+ )} + {!!group.name &&
{group.name}
} + {group.values.map((integration) => ( + + ))} +
+ ); +}; +export const MenuComponent: FC< + MenuComponentInterface & { + integration: Integration & { + identifier: string; + changeProfilePicture: boolean; + changeNickName: boolean; + }; + } +> = (props) => { + const { + totalNonDisabledChannels, + continueIntegration, + refreshChannel, + mutate, + update, + integration, + } = props; + + const user = useUser(); + const [collected, drag, dragPreview] = useDrag(() => ({ + type: 'menu', + item: { id: integration.id }, + })); + + return ( +
+
+ {(integration.inBetweenSteps || integration.refreshNeeded) && ( +
+
+ ! +
+
+
+ )} + + {integration.identifier === 'youtube' ? ( + + ) : ( + {integration.identifier} + )} +
+
+ {integration.name} +
+ totalNonDisabledChannels && + integration.disabled + } + canDisable={!integration.disabled} + /> +
+ ); +}; export const LaunchesComponent = () => { const fetch = useFetch(); const router = useRouter(); @@ -31,7 +224,6 @@ export const LaunchesComponent = () => { const load = useCallback(async (path: string) => { return (await (await fetch(path)).json()).integrations; }, []); - const user = useUser(); const { isLoading, @@ -48,6 +240,28 @@ export const LaunchesComponent = () => { ); }, [integrations]); + const changeItemGroup = useCallback( + async (id: string, group: string) => { + mutate( + integrations.map((integration: any) => { + if (integration.id === id) { + return { ...integration, customer: { id: group } }; + } + return integration; + }), + false + ); + + await fetch(`/integrations/${id}/group`, { + method: 'PUT', + body: JSON.stringify({ group }), + }); + + mutate(); + }, + [integrations] + ); + const sortedIntegrations = useMemo(() => { return orderBy( integrations, @@ -56,6 +270,25 @@ export const LaunchesComponent = () => { ); }, [integrations]); + const menuIntegrations = useMemo(() => { + return orderBy( + Object.values( + groupBy(sortedIntegrations, (o) => o?.customer?.id || '') + ).map((p) => ({ + name: (p[0].customer?.name || '') as string, + id: (p[0].customer?.id || '') as string, + isEmpty: p.length === 0, + values: orderBy( + p, + ['type', 'disabled', 'identifier'], + ['desc', 'asc', 'asc'] + ), + })), + ['isEmpty', 'name'], + ['desc', 'asc'] + ); + }, [sortedIntegrations]); + const update = useCallback(async (shouldReload: boolean) => { if (shouldReload) { setReload(true); @@ -96,11 +329,13 @@ export const LaunchesComponent = () => { if (typeof window === 'undefined') { return; } - if (search.get('scope') === 'missing') { - toast.show('You have to approve all the channel permissions', 'warning'); + if (search.get('msg')) { + toast.show(search.get('msg')!, 'warning'); + window?.opener?.postMessage({msg: search.get('msg')!, success: false}, '*'); } if (search.get('added')) { fireEvents('channel_added'); + window?.opener?.postMessage({msg: 'Channel added', success: true}, '*'); } if (window.opener) { window.close(); @@ -113,114 +348,41 @@ export const LaunchesComponent = () => { // @ts-ignore return ( - -
-
-
-
-

Channels

-
- {sortedIntegrations.length === 0 && ( -
No channels
- )} - {sortedIntegrations.map((integration) => ( -
-
- {(integration.inBetweenSteps || - integration.refreshNeeded) && ( -
-
- ! -
-
-
- )} - - {integration.identifier === 'youtube' ? ( - - ) : ( - {integration.identifier} - )} -
-
- {integration.name} -
- + +
+
+
+
+

Channels

+
+ {sortedIntegrations.length === 0 && ( +
No channels
+ )} + {menuIntegrations.map((menu) => ( + totalNonDisabledChannels && - integration.disabled - } - canDisable={!integration.disabled} + continueIntegration={continueIntegration} + update={update} + refreshChannel={refreshChannel} + totalNonDisabledChannels={totalNonDisabledChannels} /> -
- ))} + ))} +
+ update(true)} /> + {/*{sortedIntegrations?.length > 0 && user?.tier?.ai && }*/} +
+
+ +
- update(true)} /> - {/*{sortedIntegrations?.length > 0 && user?.tier?.ai && }*/} -
-
- -
-
- + + ); }; diff --git a/apps/frontend/src/components/launches/menu/menu.tsx b/apps/frontend/src/components/launches/menu/menu.tsx index c31930ec..7995e5d2 100644 --- a/apps/frontend/src/components/launches/menu/menu.tsx +++ b/apps/frontend/src/components/launches/menu/menu.tsx @@ -1,4 +1,4 @@ -import { FC, useCallback, useState } from 'react'; +import { FC, MouseEventHandler, useCallback, useState } from 'react'; import { useClickOutside } from '@mantine/hooks'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; @@ -8,6 +8,7 @@ import { useModals } from '@mantine/modals'; import { TimeTable } from '@gitroom/frontend/components/launches/time.table'; import { useCalendar } from '@gitroom/frontend/components/launches/calendar.context'; import { BotPicture } from '@gitroom/frontend/components/launches/bot.picture'; +import { CustomerModal } from '@gitroom/frontend/components/launches/customer.modal'; export const Menu: FC<{ canEnable: boolean; @@ -36,9 +37,13 @@ export const Menu: FC<{ setShow(false); }); - const changeShow = useCallback(() => { - setShow(!show); - }, [show]); + const changeShow: MouseEventHandler = useCallback( + (e) => { + e.stopPropagation(); + setShow(!show); + }, + [show] + ); const disableChannel = useCallback(async () => { if ( @@ -138,6 +143,34 @@ export const Menu: FC<{ setShow(false); }, [integrations]); + const addToCustomer = useCallback(() => { + const findIntegration = integrations.find( + (integration) => integration.id === id + ); + + modal.openModal({ + classNames: { + modal: 'w-[100%] max-w-[600px] bg-transparent text-textColor', + }, + size: '100%', + withCloseButton: false, + closeOnEscape: true, + closeOnClickOutside: true, + children: ( + { + mutate(); + toast.show('Customer Updated', 'success'); + }} + /> + ), + }); + + setShow(false); + }, [integrations]); + return (
)} +
+
+ + + +
+
Move / add to customer
+
{ } return true; -}); +}, 300); diff --git a/apps/frontend/src/components/launches/providers/discord/discord.provider.tsx b/apps/frontend/src/components/launches/providers/discord/discord.provider.tsx index 2c22647a..b3c1bb32 100644 --- a/apps/frontend/src/components/launches/providers/discord/discord.provider.tsx +++ b/apps/frontend/src/components/launches/providers/discord/discord.provider.tsx @@ -17,5 +17,5 @@ export default withProvider( undefined, DiscordDto, undefined, - 280 + 1980 ); 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 8249f0c7..86b67569 100644 --- a/apps/frontend/src/components/launches/providers/high.order.provider.tsx +++ b/apps/frontend/src/components/launches/providers/high.order.provider.tsx @@ -28,12 +28,16 @@ import { newImage } from '@gitroom/frontend/components/launches/helpers/new.imag import { postSelector } from '@gitroom/frontend/components/post-url-selector/post.url.selector'; import { UpDownArrow } from '@gitroom/frontend/components/launches/up.down.arrow'; import { arrayMoveImmutable } from 'array-move'; -import { linkedinCompany } from '@gitroom/frontend/components/launches/helpers/linkedin.component'; +import { + LinkedinCompany, + linkedinCompany, +} from '@gitroom/frontend/components/launches/helpers/linkedin.component'; import { Editor } from '@gitroom/frontend/components/launches/editor'; import { useCopilotAction, useCopilotReadable } from '@copilotkit/react-core'; import { AddPostButton } from '@gitroom/frontend/components/launches/add.post.button'; import { GeneralPreviewComponent } from '@gitroom/frontend/components/launches/general.preview.component'; import { capitalize } from 'lodash'; +import { useModals } from '@mantine/modals'; // Simple component to change back to settings on after changing tab export const SetTab: FC<{ changeTab: () => void }> = (props) => { @@ -68,15 +72,16 @@ export const EditorWrapper: FC<{ children: ReactNode }> = ({ children }) => { return children; }; -export const withProvider = ( - SettingsComponent: FC<{values?: any}> | null, - CustomPreviewComponent?: FC<{maximumCharacters?: number}>, +export const withProvider = function ( + SettingsComponent: FC<{ values?: any }> | null, + CustomPreviewComponent?: FC<{ maximumCharacters?: number }>, dto?: any, checkValidity?: ( - value: Array> + value: Array>, + settings: T ) => Promise, maximumCharacters?: number -) => { +) { return (props: { identifier: string; id: string; @@ -90,6 +95,8 @@ export const withProvider = ( }) => { const existingData = useExistingData(); const { integration, date } = useIntegration(); + const [showLinkedinPopUp, setShowLinkedinPopUp] = useState(false); + useCopilotReadable({ description: integration?.type === 'social' @@ -254,6 +261,21 @@ export const withProvider = ( }, }); + const tagPersonOrCompany = useCallback( + (integration: string, editor: (value: string) => void) => () => { + setShowLinkedinPopUp( + { + editor(tag); + }} + id={integration} + onClose={() => setShowLinkedinPopUp(false)} + /> + ); + }, + [] + ); + // this is a trick to prevent the data from being deleted, yet we don't render the elements if (!props.show) { return null; @@ -262,6 +284,7 @@ export const withProvider = ( return ( setShowTab(0)} /> + {showLinkedinPopUp ? showLinkedinPopUp : null}
{!props.hideMenu && (
@@ -318,6 +341,20 @@ export const withProvider = (
+ {integration?.identifier === 'linkedin' && ( + + )} 1 ? 200 : 250} diff --git a/apps/frontend/src/components/launches/providers/instagram/instagram.collaborators.tsx b/apps/frontend/src/components/launches/providers/instagram/instagram.collaborators.tsx new file mode 100644 index 00000000..befe797f --- /dev/null +++ b/apps/frontend/src/components/launches/providers/instagram/instagram.collaborators.tsx @@ -0,0 +1,91 @@ +import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider'; +import { FC } from 'react'; +import { Select } from '@gitroom/react/form/select'; +import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values'; +import { InstagramDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/instagram.dto'; +import { InstagramCollaboratorsTags } from '@gitroom/frontend/components/launches/providers/instagram/instagram.tags'; + +const postType = [ + { + value: 'post', + label: 'Post / Reel', + }, + { + value: 'story', + label: 'Story', + }, +]; +const InstagramCollaborators: FC<{ values?: any }> = (props) => { + const { watch, register, formState, control } = useSettings(); + const postCurrentType = watch('post_type'); + return ( + <> + + + {postCurrentType !== 'story' && ( + + )} + + ); +}; + +export default withProvider( + InstagramCollaborators, + undefined, + InstagramDto, + async ([firstPost, ...otherPosts], settings) => { + if (!firstPost.length) { + return 'Instagram should have at least one media'; + } + + if (firstPost.length > 1 && settings.post_type === 'story') { + return 'Instagram stories can only have one media'; + } + + const checkVideosLength = await Promise.all( + firstPost + .filter((f) => f.path.indexOf('mp4') > -1) + .flatMap((p) => p.path) + .map((p) => { + return new Promise((res) => { + const video = document.createElement('video'); + video.preload = 'metadata'; + video.src = p; + video.addEventListener('loadedmetadata', () => { + res(video.duration); + }); + }); + }) + ); + + for (const video of checkVideosLength) { + if (video > 60 && settings.post_type === 'story') { + return 'Instagram stories should be maximum 60 seconds'; + } + + if (video > 90 && settings.post_type === 'post') { + return 'Instagram reel should be maximum 90 seconds'; + } + } + + return true; + }, + 2200 +); diff --git a/apps/frontend/src/components/launches/providers/instagram/instagram.provider.tsx b/apps/frontend/src/components/launches/providers/instagram/instagram.provider.tsx deleted file mode 100644 index 8c12e352..00000000 --- a/apps/frontend/src/components/launches/providers/instagram/instagram.provider.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider'; - -export default withProvider( - null, - undefined, - undefined, - async ([firstPost, ...otherPosts]) => { - if (!firstPost.length) { - return 'Instagram should have at least one media'; - } - - return true; - }, - 2200 -); diff --git a/apps/frontend/src/components/launches/providers/instagram/instagram.tags.tsx b/apps/frontend/src/components/launches/providers/instagram/instagram.tags.tsx new file mode 100644 index 00000000..b233923f --- /dev/null +++ b/apps/frontend/src/components/launches/providers/instagram/instagram.tags.tsx @@ -0,0 +1,61 @@ +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values'; +import { ReactTags } from 'react-tag-autocomplete'; +import interClass from '@gitroom/react/helpers/inter.font'; + +export const InstagramCollaboratorsTags: FC<{ + name: string; + label: string; + onChange: (event: { target: { value: any[]; name: string } }) => void; +}> = (props) => { + const { onChange, name, label } = props; + const { getValues } = useSettings(); + const [tagValue, setTagValue] = useState([]); + const [suggestions, setSuggestions] = useState(''); + + const onDelete = useCallback( + (tagIndex: number) => { + const modify = tagValue.filter((_, i) => i !== tagIndex); + setTagValue(modify); + onChange({ target: { value: modify, name } }); + }, + [tagValue] + ); + + const onAddition = useCallback( + (newTag: any) => { + if (tagValue.length >= 3) { + return; + } + const modify = [...tagValue, newTag]; + setTagValue(modify); + onChange({ target: { value: modify, name } }); + }, + [tagValue] + ); + + useEffect(() => { + const settings = getValues()[props.name]; + if (settings) { + setTagValue(settings); + } + }, []); + + const suggestionsArray = useMemo(() => { + return [...tagValue, { label: suggestions, value: suggestions }].filter(f => f.label); + }, [suggestions, tagValue]); + + return ( +
+
{label}
+ +
+ ); +}; 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 0fece0eb..3b26f62b 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"; import FacebookProvider from '@gitroom/frontend/components/launches/providers/facebook/facebook.provider'; -import InstagramProvider from '@gitroom/frontend/components/launches/providers/instagram/instagram.provider'; +import InstagramProvider from '@gitroom/frontend/components/launches/providers/instagram/instagram.collaborators'; import YoutubeProvider from '@gitroom/frontend/components/launches/providers/youtube/youtube.provider'; import TiktokProvider from '@gitroom/frontend/components/launches/providers/tiktok/tiktok.provider'; import PinterestProvider from '@gitroom/frontend/components/launches/providers/pinterest/pinterest.provider'; diff --git a/apps/frontend/src/components/launches/providers/x/x.provider.tsx b/apps/frontend/src/components/launches/providers/x/x.provider.tsx index 0a517836..a9ffc5fe 100644 --- a/apps/frontend/src/components/launches/providers/x/x.provider.tsx +++ b/apps/frontend/src/components/launches/providers/x/x.provider.tsx @@ -1,8 +1,52 @@ import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider'; -export default withProvider(null, undefined, undefined, async (posts) => { - if (posts.some(p => p.length > 4)) { - return 'There can be maximum 4 pictures in a post.'; - } +export default withProvider( + null, + undefined, + undefined, + async (posts) => { + if (posts.some((p) => p.length > 4)) { + return 'There can be maximum 4 pictures in a post.'; + } - return true; -}, 280); + if ( + posts.some( + (p) => p.some((m) => m.path.indexOf('mp4') > -1) && p.length > 1 + ) + ) { + return 'There can be maximum 1 video in a post.'; + } + + for (const load of posts.flatMap((p) => p.flatMap((a) => a.path))) { + if (load.indexOf('mp4') > -1) { + const isValid = await checkVideoDuration(load); + if (!isValid) { + return 'Video duration must be less than or equal to 140 seconds.'; + } + } + } + return true; + }, + 280 +); + +const checkVideoDuration = async (url: string): Promise => { + return new Promise((resolve, reject) => { + const video = document.createElement('video'); + video.src = url; + video.preload = 'metadata'; + + video.onloadedmetadata = () => { + // Check if the duration is less than or equal to 140 seconds + const duration = video.duration; + if (duration <= 140) { + resolve(true); // Video duration is acceptable + } else { + resolve(false); // Video duration exceeds 140 seconds + } + }; + + video.onerror = () => { + reject(new Error('Failed to load video metadata.')); + }; + }); +}; diff --git a/apps/frontend/src/components/launches/providers/youtube/youtube.provider.tsx b/apps/frontend/src/components/launches/providers/youtube/youtube.provider.tsx index a0859429..e6fe34b2 100644 --- a/apps/frontend/src/components/launches/providers/youtube/youtube.provider.tsx +++ b/apps/frontend/src/components/launches/providers/youtube/youtube.provider.tsx @@ -17,7 +17,7 @@ const YoutubeSettings: FC = () => { const { register, control } = useSettings(); return (
- +