feat: public api

This commit is contained in:
Nevo David 2024-12-14 16:59:18 +07:00
commit 6fc237f44f
106 changed files with 10119 additions and 6442 deletions

View File

@ -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.

View File

@ -1,8 +1,14 @@
<p align="center">
<a href="https://affiliate.postiz.com">
<img src="https://github.com/user-attachments/assets/af9f47b3-e20c-402b-bd11-02f39248d738" />
</a>
</p>
<p align="center">
<a href="https://postiz.com" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/user-attachments/assets/765e9d72-3ee7-4a56-9d59-a2c9befe2311">
<img alt="Novu Logo" src="https://github.com/user-attachments/assets/f0d30d70-dddb-4142-8876-e9aa6ed1cb99" width="280"/>
<img alt="Postiz Logo" src="https://github.com/user-attachments/assets/f0d30d70-dddb-4142-8876-e9aa6ed1cb99" width="280"/>
</picture>
</a>
</p>
@ -58,22 +64,6 @@
<br />
<p align="center">
<br /><br /><br />
<h1>We participate in Hacktoberfest 2024! 🎉🎊</h1>
<p align="left">We are sending a t-shirt for every merged PR! (max 1 per person)</p>
<p align="left"><strong>Rules:</strong></p>
<ul align="left">
<li>You must create an issue before making a pull request.</li>
<li>You can also ask to be assigned to an issue. During Hacktoberfest, each issue can have multiple assignees.</li>
<li>We have to approve the issue and add a "hacktoberfest" tag.</li>
<li>We encourage everybody to contribute to all types of issues. We will only send swag for issues with features and bug fixes (no typos, sorry).</li>
</ul>
<p align="center"><img align="center" width="400" src="https://github.com/user-attachments/assets/3ceffccc-e4b3-4098-b9ba-44a94cf01294" /></p>
<br /><br /><br />
</p>
<p align="center">
<video src="https://github.com/user-attachments/assets/05436a01-19c8-4827-b57f-05a5e7637a67" width="100%" />
</p>

View File

@ -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(),

View File

@ -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);
}
}

View File

@ -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')

View File

@ -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 : '',
};
}

View File

@ -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,

View File

@ -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);
}
}

View File

@ -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,
})
);
}
}

View File

@ -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();
}
}

BIN
apps/frontend/public/favicon.ico Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -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`);
}

View File

@ -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 (
<>
<Plugs />
</>
);
}

View File

@ -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%;
}

View File

@ -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 (
<html className={interClass}>
<head>
<link
rel="icon"
href={!!process.env.IS_GENERAL ? '/favicon.png' : '/postiz-fav.png'}
href="/favicon.ico"
sizes="any"
/>
</head>
<body className={clsx(chakra.className, 'text-primary dark')}>
<VariableContextComponent
storageProvider={process.env.STORAGE_PROVIDER! as 'local' | 'cloudflare'}
storageProvider={
process.env.STORAGE_PROVIDER! as 'local' | 'cloudflare'
}
backendUrl={process.env.NEXT_PUBLIC_BACKEND_URL!}
plontoKey={process.env.NEXT_PUBLIC_POLOTNO!}
billingEnabled={!!process.env.STRIPE_PUBLISHABLE_KEY}
@ -33,12 +43,20 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
frontEndUrl={process.env.FRONTEND_URL!}
isGeneral={!!process.env.IS_GENERAL}
uploadDirectory={process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY!}
tolt={process.env.NEXT_PUBLIC_TOLT!}
>
<PlausibleProvider
<ToltScript />
<Plausible
domain={!!process.env.IS_GENERAL ? 'postiz.com' : 'gitroom.com'}
>
<LayoutContext>{children}</LayoutContext>
</PlausibleProvider>
<PHProvider
phkey={process.env.NEXT_PUBLIC_POSTHOG_KEY}
host={process.env.NEXT_PUBLIC_POSTHOG_HOST}
>
<UtmSaver />
<LayoutContext>{children}</LayoutContext>
</PHProvider>
</Plausible>
</VariableContextComponent>
</body>
</html>

View File

@ -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[] {

View File

@ -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,

View File

@ -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<Subscription | undefined>(
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) {

View File

@ -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<{
/>
)}
<div
className={clsx('flex p-[10px] rounded-[4px] bg-primary gap-[20px]')}
id="add-edit-modal"
className={clsx(
'flex flex-col md:flex-row p-[10px] rounded-[4px] bg-primary gap-[20px]'
)}
>
<div
className={clsx(
'flex flex-col gap-[16px] transition-all duration-700 whitespace-nowrap',
!expend.expend
? 'flex-1 w-1 animate-overflow'
: 'w-0 overflow-hidden'
!expend.expend ? 'flex-1 animate-overflow' : 'w-0 overflow-hidden'
)}
>
<div className="relative flex gap-[20px] flex-col flex-1 rounded-[4px] border border-customColor6 bg-sixth p-[16px] pt-0">
@ -404,6 +439,26 @@ export const AddEditModal: FC<{
information={data}
onChange={setPostFor}
/>
{totalCustomers > 1 && (
<Select
hideErrors={true}
label=""
name="customer"
value={customer}
onChange={(e) => {
setCustomer(e.target.value);
setSelectedIntegrations([]);
}}
disableForm={true}
>
<option value="">Selected Customer</option>
{uniqBy(ints, (u) => u?.customer?.name).map((p) => (
<option key={p.customer?.id} value={p.customer?.id}>
Customer: {p.customer?.name}
</option>
))}
</Select>
)}
<DatePicker onChange={setDateState} date={dateState} />
</div>
</TopTitle>
@ -419,7 +474,7 @@ export const AddEditModal: FC<{
) : (
<div
className={clsx(
'relative w-[34px] h-[34px] rounded-full flex justify-center items-center bg-fifth filter transition-all duration-500',
'relative w-[34px] h-[34px] rounded-full flex justify-center items-center bg-fifth filter transition-all duration-500'
)}
>
<Image
@ -539,13 +594,13 @@ export const AddEditModal: FC<{
</>
) : null}
</div>
<div className="relative h-[68px] flex flex-col rounded-[4px] border border-customColor6 bg-sixth">
<div className="flex flex-1 gap-[10px] relative">
<div className="absolute w-full h-full flex gap-[10px] justify-end items-center right-[16px]">
<Button
className="bg-transparent text-inputText"
onClick={askClose}
>
<div className="relative min-h-[68px] flex flex-col rounded-[4px] border border-customColor6 bg-sixth">
<div className="gap-[10px] relative flex flex-col justify-center items-center min-h-full pr-[16px]">
<div
id="add-edit-post-dialog-buttons"
className="flex flex-row flex-wrap w-full h-full gap-[10px] justify-end items-center"
>
<Button className="rounded-[4px]" onClick={askClose}>
Cancel
</Button>
<Submitted

View File

@ -191,10 +191,11 @@ export const CustomVariables: FC<{
validation: string;
type: 'text' | 'password';
}>;
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<{
<div className="rounded-[4px] border border-customColor6 bg-sixth px-[16px] pb-[16px] relative">
<TopTitle title={`Custom URL`} />
<button
onClick={modals.closeAll}
onClick={close || modals.closeAll}
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>

View File

@ -6,10 +6,7 @@ import { Input } from '@gitroom/react/form/input';
import { Button } from '@gitroom/react/form/button';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useToaster } from '@gitroom/react/toaster/toaster';
import {
MediaComponent,
showMediaBox,
} from '@gitroom/frontend/components/media/media.component';
import { showMediaBox } from '@gitroom/frontend/components/media/media.component';
export const BotPicture: FC<{
integration: Integrations;

View File

@ -19,8 +19,9 @@ import { useSearchParams } from 'next/navigation';
import isoWeek from 'dayjs/plugin/isoWeek';
import weekOfYear from 'dayjs/plugin/weekOfYear';
dayjs.extend(isoWeek);
dayjs.extend(weekOfYear);
import { extend } from 'dayjs';
extend(isoWeek);
extend(weekOfYear);
export const CalendarContext = createContext({
currentDay: dayjs().day() as 0 | 1 | 2 | 3 | 4 | 5 | 6,
@ -61,6 +62,10 @@ export interface Integrations {
changeProfilePicture: boolean;
changeNickName: boolean;
time: { time: number }[];
customer?: {
name?: string;
id?: string;
}
}
function getWeekNumber(date: Date) {

View File

@ -13,7 +13,6 @@ import clsx from 'clsx';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { ExistingDataContextProvider } from '@gitroom/frontend/components/launches/helpers/use.existing.data';
import { useDrag, useDrop } from 'react-dnd';
import { DNDProvider } from '@gitroom/frontend/components/launches/helpers/dnd.provider';
import { Integration, Post, State } from '@prisma/client';
import { useAddProvider } from '@gitroom/frontend/components/launches/add.provider.component';
import { CommentComponent } from '@gitroom/frontend/components/launches/comments/comment.component';
@ -26,8 +25,19 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import { groupBy, sortBy } from 'lodash';
import Image from 'next/image';
dayjs.extend(isSameOrAfter);
dayjs.extend(isSameOrBefore);
import { extend } from 'dayjs';
import { isUSCitizen } from './helpers/isuscitizen.utils';
import removeMd from 'remove-markdown';
extend(isSameOrAfter);
extend(isSameOrBefore);
const convertTimeFormatBasedOnLocality = (time: number) => {
if (isUSCitizen()) {
return `${time === 12 ? 12 : time % 12}:00 ${time >= 12 ? 'PM' : 'AM'}`;
} else {
return `${time}:00`;
}
};
export const days = [
'Monday',
@ -90,7 +100,7 @@ export const DayView = () => {
.startOf('day')
.add(option[0].time, 'minute')
.local()
.format('HH:mm')}
.format(isUSCitizen() ? 'hh:mm A' : 'HH:mm')}
</div>
<div
key={option[0].time}
@ -139,7 +149,8 @@ export const WeekView = () => {
{hours.map((hour) => (
<Fragment key={hour}>
<div className="p-2 pr-4 bg-secondary text-center items-center justify-center flex">
{hour.toString().padStart(2, '0')}:00
{/* {hour.toString().padStart(2, '0')}:00 */}
{convertTimeFormatBasedOnLocality(hour)}
</div>
{days.map((day, indexDay) => (
<Fragment key={`${day}-${hour}`}>
@ -230,7 +241,7 @@ export const Calendar = () => {
const { display } = useCalendar();
return (
<DNDProvider>
<>
{display === 'day' ? (
<DayView />
) : display === 'week' ? (
@ -238,7 +249,7 @@ export const Calendar = () => {
) : (
<MonthView />
)}
</DNDProvider>
</>
);
};
@ -432,8 +443,9 @@ export const CalendarColumn: FC<{
)}
<div
className={clsx(
'relative flex flex-col flex-1',
canDrop && 'bg-white/80'
'relative flex flex-col flex-1 text-white',
canDrop && 'bg-white/80',
isBeforeNow && postList.length === 0 && 'cursor-not-allowed'
)}
>
<div
@ -444,8 +456,9 @@ export const CalendarColumn: FC<{
}
: {})}
className={clsx(
'flex-col text-[12px] pointer w-full cursor-pointer overflow-hidden overflow-x-auto flex scrollbar scrollbar-thumb-tableBorder scrollbar-track-secondary',
isBeforeNow && 'bg-customColor23 flex-1',
'flex-col text-[12px] pointer w-full overflow-hidden overflow-x-auto flex scrollbar scrollbar-thumb-tableBorder scrollbar-track-secondary',
isBeforeNow ? 'bg-customColor23 flex-1' : 'cursor-pointer',
isBeforeNow && postList.length === 0 && 'col-calendar',
canBeTrending && 'bg-customColor24'
)}
>
@ -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) => (
<div className="relative" key={selectedIntegrations.identifier}>
<div
className="relative"
key={selectedIntegrations.identifier}
>
<div
className={clsx(
'relative w-[34px] h-[34px] rounded-full flex justify-center items-center bg-fifth filter transition-all duration-500'
@ -589,7 +605,7 @@ const CalendarItem: FC<{
</div>
<div className="whitespace-pre-wrap line-clamp-3">
{state === 'DRAFT' ? 'Draft: ' : ''}
{post.content}
{removeMd(post.content).replace(/\n/g, ' ')}
</div>
</div>
);

View File

@ -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 (
<div className="rounded-[4px] border border-customColor6 bg-sixth px-[16px] pb-[16px] relative w-full">
<TopTitle title={`Move / Add to customer`} />
<button
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
onClick={() => modal.closeAll()}
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
<div className="mt-[16px]">
<Autocomplete
value={customer}
onChange={setCustomer}
classNames={{
label: 'text-white',
}}
label="Select Customer"
placeholder="Start typing..."
data={data?.map((p: any) => p.name) || []}
/>
</div>
<div className="my-[16px] flex gap-[10px]">
<Button onClick={() => saveCustomer()}>Save</Button>
{!!integration?.customer?.name && <Button className="bg-red-700" onClick={removeFromCustomer}>Remove from customer</Button>}
</div>
</div>
);
};

View File

@ -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';

View File

@ -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 (
<div className="text-textColor flex gap-[8px] items-center select-none">
<div onClick={previous} className="cursor-pointer">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
d="M13.1644 15.5866C13.3405 15.7628 13.4395 16.0016 13.4395 16.2507C13.4395 16.4998 13.3405 16.7387 13.1644 16.9148C12.9883 17.0909 12.7494 17.1898 12.5003 17.1898C12.2513 17.1898 12.0124 17.0909 11.8363 16.9148L5.58629 10.6648C5.49889 10.5777 5.42954 10.4742 5.38222 10.3602C5.3349 10.2463 5.31055 10.1241 5.31055 10.0007C5.31055 9.87732 5.3349 9.75515 5.38222 9.64119C5.42954 9.52724 5.49889 9.42375 5.58629 9.33665L11.8363 3.08665C12.0124 2.91053 12.2513 2.81158 12.5003 2.81158C12.7494 2.81158 12.9883 2.91053 13.1644 3.08665C13.3405 3.26277 13.4395 3.50164 13.4395 3.75071C13.4395 3.99978 13.3405 4.23865 13.1644 4.41477L7.57925 9.99993L13.1644 15.5866Z"
fill="#E9E9F1"
/>
</svg>
</div>
<div className="w-[80px] text-center">
{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')}`}
</div>
<div onClick={next} className="cursor-pointer">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
d="M14.4137 10.6633L8.16374 16.9133C7.98761 17.0894 7.74874 17.1884 7.49967 17.1884C7.2506 17.1884 7.01173 17.0894 6.83561 16.9133C6.65949 16.7372 6.56055 16.4983 6.56055 16.2492C6.56055 16.0002 6.65949 15.7613 6.83561 15.5852L12.4223 10L6.83717 4.41331C6.74997 4.3261 6.68079 4.22257 6.6336 4.10863C6.5864 3.99469 6.56211 3.87257 6.56211 3.74925C6.56211 3.62592 6.5864 3.5038 6.6336 3.38986C6.68079 3.27592 6.74997 3.17239 6.83717 3.08518C6.92438 2.99798 7.02791 2.9288 7.14185 2.88161C7.25579 2.83441 7.37791 2.81012 7.50124 2.81012C7.62456 2.81012 7.74668 2.83441 7.86062 2.88161C7.97456 2.9288 8.07809 2.99798 8.1653 3.08518L14.4153 9.33518C14.5026 9.42238 14.5718 9.52596 14.619 9.63997C14.6662 9.75398 14.6904 9.87618 14.6903 9.99957C14.6901 10.123 14.6656 10.2451 14.6182 10.359C14.5707 10.4729 14.5012 10.5763 14.4137 10.6633Z"
fill="#E9E9F1"
/>
</svg>
</div>
<div className="flex-1">{betweenDates}</div>
<div
className={clsx(
'border border-tableBorder p-[10px] cursor-pointer',
week.display === 'day' && 'bg-tableBorder'
)}
onClick={setDay}
>
Day
</div>
<div
className={clsx(
'border border-tableBorder p-[10px] cursor-pointer',
week.display === 'week' && 'bg-tableBorder'
)}
onClick={setWeek}
>
Week
</div>
<div
className={clsx(
'border border-tableBorder p-[10px] cursor-pointer',
week.display === 'month' && 'bg-tableBorder'
)}
onClick={setMonth}
>
Month
</div>
<div className="text-textColor flex flex-col md:flex-row gap-[8px] items-center select-none">
<div className = "flex flex-grow flex-row">
<div onClick={previous} className="cursor-pointer">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
d="M13.1644 15.5866C13.3405 15.7628 13.4395 16.0016 13.4395 16.2507C13.4395 16.4998 13.3405 16.7387 13.1644 16.9148C12.9883 17.0909 12.7494 17.1898 12.5003 17.1898C12.2513 17.1898 12.0124 17.0909 11.8363 16.9148L5.58629 10.6648C5.49889 10.5777 5.42954 10.4742 5.38222 10.3602C5.3349 10.2463 5.31055 10.1241 5.31055 10.0007C5.31055 9.87732 5.3349 9.75515 5.38222 9.64119C5.42954 9.52724 5.49889 9.42375 5.58629 9.33665L11.8363 3.08665C12.0124 2.91053 12.2513 2.81158 12.5003 2.81158C12.7494 2.81158 12.9883 2.91053 13.1644 3.08665C13.3405 3.26277 13.4395 3.50164 13.4395 3.75071C13.4395 3.99978 13.3405 4.23865 13.1644 4.41477L7.57925 9.99993L13.1644 15.5866Z"
fill="#E9E9F1"
/>
</svg>
</div>
<div className="w-[80px] text-center">
{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')}`}
</div>
<div onClick={next} className="cursor-pointer">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
d="M14.4137 10.6633L8.16374 16.9133C7.98761 17.0894 7.74874 17.1884 7.49967 17.1884C7.2506 17.1884 7.01173 17.0894 6.83561 16.9133C6.65949 16.7372 6.56055 16.4983 6.56055 16.2492C6.56055 16.0002 6.65949 15.7613 6.83561 15.5852L12.4223 10L6.83717 4.41331C6.74997 4.3261 6.68079 4.22257 6.6336 4.10863C6.5864 3.99469 6.56211 3.87257 6.56211 3.74925C6.56211 3.62592 6.5864 3.5038 6.6336 3.38986C6.68079 3.27592 6.74997 3.17239 6.83717 3.08518C6.92438 2.99798 7.02791 2.9288 7.14185 2.88161C7.25579 2.83441 7.37791 2.81012 7.50124 2.81012C7.62456 2.81012 7.74668 2.83441 7.86062 2.88161C7.97456 2.9288 8.07809 2.99798 8.1653 3.08518L14.4153 9.33518C14.5026 9.42238 14.5718 9.52596 14.619 9.63997C14.6662 9.75398 14.6904 9.87618 14.6903 9.99957C14.6901 10.123 14.6656 10.2451 14.6182 10.359C14.5707 10.4729 14.5012 10.5763 14.4137 10.6633Z"
fill="#E9E9F1"
/>
</svg>
</div>
<div className="flex-1">{betweenDates}</div>
</div>
<div className="flex flex-row">
<div
className={clsx(
'border border-tableBorder p-[10px] cursor-pointer',
week.display === 'day' && 'bg-tableBorder'
)}
onClick={setDay}
>
Day
</div>
<div
className={clsx(
'border border-tableBorder p-[10px] cursor-pointer',
week.display === 'week' && 'bg-tableBorder'
)}
onClick={setWeek}
>
Week
</div>
<div
className={clsx(
'border border-tableBorder p-[10px] cursor-pointer',
week.display === 'month' && 'bg-tableBorder'
)}
onClick={setMonth}
>
Month
</div>
</div>
</div>
);
};

View File

@ -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) + '<mark class="bg-red-500" data-tooltip-id="tooltip" data-tooltip-content="This text will be cropped">' + text?.slice(props.maximumCharacters || 10000) + '</mark>';
const {start, end} = textSlicer(integration?.identifier || '', props.maximumCharacters || 10000, text);
return text.slice(start, end) + '<mark class="bg-red-500" data-tooltip-id="tooltip" data-tooltip-content="This text will be cropped">' + text?.slice(end) + '</mark>';
},
});
return (
<div className={clsx('w-[555px] px-[16px]')}>
<div className={clsx('w-full md:w-[555px] px-[16px]')}>
<div className="w-full h-full relative flex flex-col">
{newValues.map((value, index) => (
<div
@ -62,7 +63,7 @@ export const GeneralPreviewComponent: FC<{maximumCharacters?: number}> = (props)
{integration?.display || '@username'}
</div>
</div>
<pre className={clsx('text-wrap', chakra.className)} dangerouslySetInnerHTML={{__html: value.text}} />
<pre className={clsx('text-wrap', interClass)} dangerouslySetInnerHTML={{__html: value.text}} />
{!!value?.images?.length && (
<div className={clsx("w-full rounded-[16px] overflow-hidden mt-[12px]", value?.images?.length > 3 ? 'grid grid-cols-2 gap-[4px]' : 'flex gap-[4px]')}>
{value.images.map((image, index) => (

View File

@ -1,5 +1,4 @@
import React, {
ChangeEventHandler,
FC,
useCallback,
useMemo,

View File

@ -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}
>
<div className="cursor-pointer">{date.format('DD/MM/YYYY HH:mm')}</div>
<div className="cursor-pointer">{date.format(isUSCitizen() ? 'MM/DD/YYYY hh:mm A' : 'DD/MM/YYYY HH:mm')}</div>
<div className="cursor-pointer">
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@ -0,0 +1,5 @@
export const isUSCitizen = () => {
const userLanguage = navigator.language || navigator.languages[0];
return userLanguage.startsWith('en-US')
}

View File

@ -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<any>(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 (

View File

@ -26,6 +26,9 @@ export const useFormatting = (
if (params.removeMarkdown) {
newText = removeMd(newText);
}
newText = newText.replace(/@\w{1,15}/g, function(match) {
return `<strong>${match}</strong>`;
});
if (params.saveBreaklines) {
newText = newText.replace('𝔫𝔢𝔴𝔩𝔦𝔫𝔢', '\n');
}

View File

@ -8,7 +8,10 @@ const finalInformation = {} as {
settings: () => object;
trigger: () => Promise<boolean>;
isValid: boolean;
checkValidity?: (value: Array<Array<{path: string}>>) => Promise<string|true>;
checkValidity?: (
value: Array<Array<{ path: string }>>,
settings: any
) => Promise<string | true>;
maximumCharacters?: number;
};
};
@ -18,8 +21,11 @@ export const useValues = (
identifier: string,
value: Array<{ id?: string; content: string; media?: Array<string> }>,
dto: any,
checkValidity?: (value: Array<Array<{path: string}>>) => Promise<string|true>,
maximumCharacters?: number,
checkValidity?: (
value: Array<Array<{ path: string }>>,
settings: any
) => Promise<string | true>,
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) {

View File

@ -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 (
<div
className="gap-[16px] flex flex-col relative"
// @ts-ignore
ref={drop}
>
{collectedProps.isOver && (
<div className="absolute left-0 top-0 w-full h-full pointer-events-none">
<div className="w-full h-full left-0 top-0 relative">
<div className="bg-white/30 w-full h-full p-[8px] box-content rounded-md" />
</div>
</div>
)}
{!!group.name && <div>{group.name}</div>}
{group.values.map((integration) => (
<MenuComponent
key={integration.id}
integration={integration}
mutate={mutate}
continueIntegration={continueIntegration}
update={update}
refreshChannel={refreshChannel}
totalNonDisabledChannels={totalNonDisabledChannels}
/>
))}
</div>
);
};
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 (
<div
// @ts-ignore
ref={dragPreview}
{...(integration.refreshNeeded && {
onClick: refreshChannel(integration),
'data-tooltip-id': 'tooltip',
'data-tooltip-content': 'Channel disconnected, click to reconnect.',
})}
key={integration.id}
className={clsx(
'flex gap-[8px] items-center',
integration.refreshNeeded && 'cursor-pointer'
)}
>
<div
className={clsx(
'relative w-[34px] h-[34px] rounded-full flex justify-center items-center bg-fifth',
integration.disabled && 'opacity-50'
)}
>
{(integration.inBetweenSteps || integration.refreshNeeded) && (
<div
className="absolute left-0 top-0 w-[39px] h-[46px] cursor-pointer"
onClick={
integration.refreshNeeded
? refreshChannel(integration)
: continueIntegration(integration)
}
>
<div className="bg-red-500 w-[15px] h-[15px] rounded-full -left-[5px] -top-[5px] absolute z-[200] text-[10px] flex justify-center items-center">
!
</div>
<div className="bg-primary/60 w-[39px] h-[46px] left-0 top-0 absolute rounded-full z-[199]" />
</div>
)}
<ImageWithFallback
fallbackSrc={`/icons/platforms/${integration.identifier}.png`}
src={integration.picture!}
className="rounded-full"
alt={integration.identifier}
width={32}
height={32}
/>
{integration.identifier === 'youtube' ? (
<img
src="/icons/platforms/youtube.svg"
className="absolute z-10 -bottom-[5px] -right-[5px]"
width={20}
/>
) : (
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
alt={integration.identifier}
width={20}
height={20}
/>
)}
</div>
<div
// @ts-ignore
ref={drag}
{...(integration.disabled &&
totalNonDisabledChannels === user?.totalChannels
? {
'data-tooltip-id': 'tooltip',
'data-tooltip-content':
'This channel is disabled, please upgrade your plan to enable it.',
}
: {})}
role="Handle"
className={clsx(
'flex-1 whitespace-nowrap text-ellipsis overflow-hidden cursor-move',
integration.disabled && 'opacity-50'
)}
>
{integration.name}
</div>
<Menu
canChangeProfilePicture={integration.changeProfilePicture}
canChangeNickName={integration.changeNickName}
mutate={mutate}
onChange={update}
id={integration.id}
canEnable={
user?.totalChannels! > totalNonDisabledChannels &&
integration.disabled
}
canDisable={!integration.disabled}
/>
</div>
);
};
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 (
<CalendarWeekProvider integrations={sortedIntegrations}>
<div className="flex flex-1 flex-col">
<div className="flex flex-1 relative">
<div className="outline-none w-full h-full grid grid-cols-[220px_minmax(0,1fr)] gap-[30px] scrollbar scrollbar-thumb-tableBorder scrollbar-track-secondary">
<div className="w-[220px] bg-third p-[16px] flex flex-col gap-[24px] min-h-[100%]">
<h2 className="text-[20px]">Channels</h2>
<div className="gap-[16px] flex flex-col">
{sortedIntegrations.length === 0 && (
<div className="text-[12px]">No channels</div>
)}
{sortedIntegrations.map((integration) => (
<div
{...(integration.refreshNeeded && {
onClick: refreshChannel(integration),
'data-tooltip-id': 'tooltip',
'data-tooltip-content':
'Channel disconnected, click to reconnect.',
})}
key={integration.id}
className={clsx("flex gap-[8px] items-center", integration.refreshNeeded && 'cursor-pointer')}
>
<div
className={clsx(
'relative w-[34px] h-[34px] rounded-full flex justify-center items-center bg-fifth',
integration.disabled && 'opacity-50'
)}
>
{(integration.inBetweenSteps ||
integration.refreshNeeded) && (
<div
className="absolute left-0 top-0 w-[39px] h-[46px] cursor-pointer"
onClick={
integration.refreshNeeded
? refreshChannel(integration)
: continueIntegration(integration)
}
>
<div className="bg-red-500 w-[15px] h-[15px] rounded-full -left-[5px] -top-[5px] absolute z-[200] text-[10px] flex justify-center items-center">
!
</div>
<div className="bg-primary/60 w-[39px] h-[46px] left-0 top-0 absolute rounded-full z-[199]" />
</div>
)}
<ImageWithFallback
fallbackSrc={`/icons/platforms/${integration.identifier}.png`}
src={integration.picture}
className="rounded-full"
alt={integration.identifier}
width={32}
height={32}
/>
{integration.identifier === 'youtube' ? (
<img
src="/icons/platforms/youtube.svg"
className="absolute z-10 -bottom-[5px] -right-[5px]"
width={20}
/>
) : (
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
alt={integration.identifier}
width={20}
height={20}
/>
)}
</div>
<div
{...(integration.disabled &&
totalNonDisabledChannels === user?.totalChannels
? {
'data-tooltip-id': 'tooltip',
'data-tooltip-content':
'This channel is disabled, please upgrade your plan to enable it.',
}
: {})}
className={clsx(
'flex-1 whitespace-nowrap text-ellipsis overflow-hidden',
integration.disabled && 'opacity-50'
)}
>
{integration.name}
</div>
<Menu
canChangeProfilePicture={integration.changeProfilePicture}
canChangeNickName={integration.changeNickName}
<DNDProvider>
<CalendarWeekProvider integrations={sortedIntegrations}>
<div className="flex flex-1 flex-col">
<div className="flex flex-1 relative">
<div className="outline-none w-full h-full grid grid-cols[1fr] md:grid-cols-[220px_minmax(0,1fr)] gap-[30px] scrollbar scrollbar-thumb-tableBorder scrollbar-track-secondary">
<div className="bg-third p-[16px] flex flex-col gap-[24px] min-h-[100%]">
<h2 className="text-[20px]">Channels</h2>
<div className="gap-[16px] flex flex-col select-none">
{sortedIntegrations.length === 0 && (
<div className="text-[12px]">No channels</div>
)}
{menuIntegrations.map((menu) => (
<MenuGroupComponent
changeItemGroup={changeItemGroup}
key={menu.name}
group={menu}
mutate={mutate}
onChange={update}
id={integration.id}
canEnable={
user?.totalChannels! > totalNonDisabledChannels &&
integration.disabled
}
canDisable={!integration.disabled}
continueIntegration={continueIntegration}
update={update}
refreshChannel={refreshChannel}
totalNonDisabledChannels={totalNonDisabledChannels}
/>
</div>
))}
))}
</div>
<AddProviderButton update={() => update(true)} />
{/*{sortedIntegrations?.length > 0 && user?.tier?.ai && <GeneratorComponent />}*/}
</div>
<div className="flex-1 flex flex-col gap-[14px]">
<Filters />
<Calendar />
</div>
<AddProviderButton update={() => update(true)} />
{/*{sortedIntegrations?.length > 0 && user?.tier?.ai && <GeneratorComponent />}*/}
</div>
<div className="flex-1 flex flex-col gap-[14px]">
<Filters />
<Calendar />
</div>
</div>
</div>
</div>
</CalendarWeekProvider>
</CalendarWeekProvider>
</DNDProvider>
);
};

View File

@ -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<HTMLDivElement> = 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: (
<CustomerModal
// @ts-ignore
integration={findIntegration}
onClose={() => {
mutate();
toast.show('Customer Updated', 'success');
}}
/>
),
});
setShow(false);
}, [integrations]);
return (
<div
className="cursor-pointer relative select-none"
@ -191,6 +224,23 @@ export const Menu: FC<{
</div>
</div>
)}
<div className="flex gap-[12px] items-center" onClick={addToCustomer}>
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
width={18}
height={18}
viewBox="0 0 32 32"
fill="none"
>
<path
d="M31.9997 17C31.9997 17.2652 31.8943 17.5196 31.7068 17.7071C31.5192 17.8946 31.2649 18 30.9997 18H28.9997V20C28.9997 20.2652 28.8943 20.5196 28.7068 20.7071C28.5192 20.8946 28.2649 21 27.9997 21C27.7345 21 27.4801 20.8946 27.2926 20.7071C27.105 20.5196 26.9997 20.2652 26.9997 20V18H24.9997C24.7345 18 24.4801 17.8946 24.2926 17.7071C24.105 17.5196 23.9997 17.2652 23.9997 17C23.9997 16.7348 24.105 16.4804 24.2926 16.2929C24.4801 16.1054 24.7345 16 24.9997 16H26.9997V14C26.9997 13.7348 27.105 13.4804 27.2926 13.2929C27.4801 13.1054 27.7345 13 27.9997 13C28.2649 13 28.5192 13.1054 28.7068 13.2929C28.8943 13.4804 28.9997 13.7348 28.9997 14V16H30.9997C31.2649 16 31.5192 16.1054 31.7068 16.2929C31.8943 16.4804 31.9997 16.7348 31.9997 17ZM24.7659 24.3562C24.9367 24.5595 25.0197 24.8222 24.9967 25.0866C24.9737 25.351 24.8466 25.5955 24.6434 25.7662C24.4402 25.937 24.1775 26.02 23.9131 25.997C23.6486 25.974 23.4042 25.847 23.2334 25.6437C20.7184 22.6487 17.2609 21 13.4997 21C9.73843 21 6.28093 22.6487 3.76593 25.6437C3.59519 25.8468 3.35079 25.9737 3.08648 25.9966C2.82217 26.0194 2.55961 25.9364 2.35655 25.7656C2.15349 25.5949 2.02658 25.3505 2.00372 25.0862C1.98087 24.8219 2.06394 24.5593 2.23468 24.3562C4.10218 22.1337 6.42468 20.555 9.00593 19.71C7.43831 18.7336 6.23133 17.2733 5.56759 15.5498C4.90386 13.8264 4.81949 11.9337 5.32724 10.1581C5.83499 8.38242 6.90724 6.82045 8.38176 5.70847C9.85629 4.59649 11.6529 3.995 13.4997 3.995C15.3465 3.995 17.1431 4.59649 18.6176 5.70847C20.0921 6.82045 21.1644 8.38242 21.6721 10.1581C22.1799 11.9337 22.0955 13.8264 21.4318 15.5498C20.768 17.2733 19.561 18.7336 17.9934 19.71C20.5747 20.555 22.8972 22.1337 24.7659 24.3562ZM13.4997 19C14.7853 19 16.042 18.6188 17.1109 17.9045C18.1798 17.1903 19.0129 16.1752 19.5049 14.9874C19.9969 13.7997 20.1256 12.4928 19.8748 11.2319C19.624 9.97103 19.0049 8.81284 18.0959 7.9038C17.1868 6.99476 16.0286 6.37569 14.7678 6.12489C13.5069 5.87409 12.2 6.00281 11.0122 6.49478C9.82451 6.98675 8.80935 7.81987 8.09512 8.88879C7.38089 9.95771 6.99968 11.2144 6.99968 12.5C7.00166 14.2233 7.68712 15.8754 8.90567 17.094C10.1242 18.3126 11.7764 18.998 13.4997 19Z"
fill="green"
/>
</svg>
</div>
<div className="text-[12px]">Move / add to customer</div>
</div>
<div className="flex gap-[12px] items-center" onClick={editTimeTable}>
<div>
<svg

View File

@ -6,4 +6,4 @@ export default withProvider(null, undefined, undefined, async (posts) => {
}
return true;
});
}, 300);

View File

@ -17,5 +17,5 @@ export default withProvider(
undefined,
DiscordDto,
undefined,
280
1980
);

View File

@ -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 <T extends object>(
SettingsComponent: FC<{ values?: any }> | null,
CustomPreviewComponent?: FC<{ maximumCharacters?: number }>,
dto?: any,
checkValidity?: (
value: Array<Array<{ path: string }>>
value: Array<Array<{ path: string }>>,
settings: T
) => Promise<string | true>,
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<any>(false);
useCopilotReadable({
description:
integration?.type === 'social'
@ -254,6 +261,21 @@ export const withProvider = (
},
});
const tagPersonOrCompany = useCallback(
(integration: string, editor: (value: string) => void) => () => {
setShowLinkedinPopUp(
<LinkedinCompany
onSelect={(tag) => {
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 (
<FormProvider {...form}>
<SetTab changeTab={() => setShowTab(0)} />
{showLinkedinPopUp ? showLinkedinPopUp : null}
<div className="mt-[15px] w-full flex flex-col flex-1">
{!props.hideMenu && (
<div className="flex gap-[4px]">
@ -318,6 +341,20 @@ export const withProvider = (
<div>
<div className="flex gap-[4px]">
<div className="flex-1 text-textColor editor">
{integration?.identifier === 'linkedin' && (
<Button
className="mb-[5px]"
onClick={tagPersonOrCompany(
integration.id,
(newValue: string) =>
changeValue(index)(
val.content + newValue
)
)}
>
Tag a company
</Button>
)}
<Editor
order={index}
height={InPlaceValue.length > 1 ? 200 : 250}

View File

@ -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 (
<>
<Select
label="Post Type"
{...register('post_type', {
value: 'post',
})}
>
<option value="">Select Post Type...</option>
{postType.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</Select>
{postCurrentType !== 'story' && (
<InstagramCollaboratorsTags
label="Collaborators (max 3) - accounts can't be private"
{...register('collaborators', {
value: []
})}
/>
)}
</>
);
};
export default withProvider<InstagramDto>(
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<number>((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
);

View File

@ -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
);

View File

@ -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<any[]>([]);
const [suggestions, setSuggestions] = useState<string>('');
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 (
<div>
<div className={`${interClass} text-[14px] mb-[6px]`}>{label}</div>
<ReactTags
placeholderText="Add a tag"
suggestions={suggestionsArray}
selected={tagValue}
onAdd={onAddition}
onInput={setSuggestions}
onDelete={onDelete}
/>
</div>
);
};

View File

@ -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';

View File

@ -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<boolean> => {
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.'));
};
});
};

View File

@ -17,7 +17,7 @@ const YoutubeSettings: FC = () => {
const { register, control } = useSettings();
return (
<div className="flex flex-col">
<Input label="Title" {...register('title')} />
<Input label="Title" {...register('title')} maxLength={100} />
<Select label="Type" {...register('type', { value: 'public' })}>
{type.map((t) => (
<option key={t.value} value={t.value}>

View File

@ -107,7 +107,7 @@ export const Impersonate = () => {
}, [data]);
return (
<div className="px-[23px]">
<div className="md:px-[23px]">
<div className="bg-forth h-[52px] flex justify-center items-center border-input border rounded-[8px]">
<div className="relative flex flex-col w-[600px]">
<div className="relative z-[999]">

View File

@ -3,7 +3,6 @@
import { ReactNode, useCallback } from 'react';
import { FetchWrapperComponent } from '@gitroom/helpers/utils/custom.fetch';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { isGeneral } from '@gitroom/react/helpers/is.general';
import { useReturnUrl } from '@gitroom/frontend/app/auth/return.url.component';
import { useVariables } from '@gitroom/react/helpers/variable.context';

View File

@ -15,7 +15,6 @@ import NotificationComponent from '@gitroom/frontend/components/notifications/no
import Link from 'next/link';
import useSWR from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import isoWeek from 'dayjs/plugin/isoWeek';
@ -37,10 +36,12 @@ const ModeComponent = dynamic(
{ ssr: false }
);
dayjs.extend(utc);
dayjs.extend(weekOfYear);
dayjs.extend(isoWeek);
dayjs.extend(isBetween);
import { extend } from 'dayjs';
extend(utc);
extend(weekOfYear);
extend(isoWeek);
extend(isBetween);
export const LayoutSettings = ({ children }: { children: ReactNode }) => {
const fetch = useFetch();
@ -77,12 +78,12 @@ export const LayoutSettings = ({ children }: { children: ReactNode }) => {
{user.tier !== 'FREE' && <Onboarding />}
<Support />
<ContinueProvider />
<div className="min-h-[100vh] w-full max-w-[1440px] mx-auto bg-primary px-[12px] text-textColor flex flex-col">
<div className="min-h-[100vh] w-full max-w-[1440px] mx-auto bg-primary sm:px-6 px-0 text-textColor flex flex-col">
{user?.admin && <Impersonate />}
<div className="px-[23px] flex h-[80px] items-center justify-between z-[200] sticky top-0 bg-primary">
<nav className="px-0 md:px-[23px] gap-2 grid grid-rows-[repeat(2,_auto)] grid-cols-2 md:grid-rows-1 md:grid-cols-[repeat(3,_auto)] items-center justify-between z-[200] sticky top-0 bg-primary">
<Link
href="/"
className="text-2xl flex items-center gap-[10px] text-textColor"
className="text-2xl flex items-center gap-[10px] text-textColor order-1"
>
<div className="min-w-[55px]">
<Image
@ -93,9 +94,7 @@ export const LayoutSettings = ({ children }: { children: ReactNode }) => {
/>
</div>
<div
className={clsx(
!isGeneral ? 'mt-[12px]' : 'min-w-[80px]'
)}
className={clsx(!isGeneral ? 'mt-[12px]' : 'min-w-[80px]')}
>
{isGeneral ? (
<svg
@ -127,21 +126,22 @@ export const LayoutSettings = ({ children }: { children: ReactNode }) => {
)}
</div>
</Link>
{user?.orgId && (user.tier !== 'FREE' || !isGeneral || !billingEnabled) ? (
{user?.orgId &&
(user.tier !== 'FREE' || !isGeneral || !billingEnabled) ? (
<TopMenu />
) : (
<div />
)}
<div className="flex items-center gap-[8px]">
<div id = "systray-buttons" className="flex items-center justify-self-end gap-[8px] order-2 md:order-3">
<ModeComponent />
<SettingsComponent />
<NotificationComponent />
<OrganizationSelector />
</div>
</div>
</nav>
<div className="flex-1 flex">
<div className="flex-1 rounded-3xl px-[23px] py-[17px] flex flex-col">
{(user.tier === 'FREE' && isGeneral) && billingEnabled ? (
<div className="flex-1 rounded-3xl px-0 md:px-[23px] py-[17px] flex flex-col">
{user.tier === 'FREE' && isGeneral && billingEnabled ? (
<>
<div className="text-center mb-[20px] text-xl">
<h1 className="text-3xl">

View File

@ -18,6 +18,7 @@ import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { LogoutComponent } from '@gitroom/frontend/components/layout/logout.component';
import { useSearchParams } from 'next/navigation';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { PublicComponent } from '@gitroom/frontend/components/public-api/public.component';
export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
const {isGeneral} = useVariables();
@ -38,7 +39,7 @@ export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
}, []);
const url = useSearchParams();
const showLogout = !url.get('onboarding');
const showLogout = !url.get('onboarding') || user?.tier?.current === "FREE";
const loadProfile = useCallback(async () => {
const personal = await (await fetch('/user/personal')).json();
@ -195,6 +196,7 @@ export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
</div>
)}
{!!user?.tier?.team_members && isGeneral && <TeamsComponent />}
{!!user?.tier?.public_api && isGeneral && <PublicComponent />}
{showLogout && <LogoutComponent />}
</div>
</form>

View File

@ -0,0 +1,23 @@
'use client';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import Script from 'next/script';
export const useTolt = () => {
return () => {
// @ts-ignore
return window?.tolt_referral || '';
};
};
export const ToltScript = () => {
const { tolt } = useVariables();
if (!tolt) return null;
return (
<Script
async={true}
src="https://cdn.tolt.io/tolt.js"
data-tolt={tolt}
/>
);
};

View File

@ -8,16 +8,16 @@ import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { useVariables } from '@gitroom/react/helpers/variable.context';
export const useMenuItems = () => {
const {isGeneral} = useVariables();
const { isGeneral } = useVariables();
return [
...(!isGeneral
? [
{
name: 'Analytics',
icon: 'analytics',
path: '/analytics',
},
]
{
name: 'Analytics',
icon: 'analytics',
path: '/analytics',
},
]
: []),
{
name: isGeneral ? 'Calendar' : 'Launches',
@ -26,32 +26,27 @@ export const useMenuItems = () => {
},
...(isGeneral
? [
{
name: 'Analytics',
icon: 'analytics',
path: '/analytics',
},
]
{
name: 'Analytics',
icon: 'analytics',
path: '/analytics',
},
]
: []),
...(!isGeneral
? [
{
name: 'Settings',
icon: 'settings',
path: '/settings',
role: ['ADMIN', 'SUPERADMIN'],
},
]
{
name: 'Settings',
icon: 'settings',
path: '/settings',
role: ['ADMIN', 'SUPERADMIN'],
},
]
: []),
{
name: 'Marketplace',
icon: 'marketplace',
path: '/marketplace',
},
{
name: 'Messages',
icon: 'messages',
path: '/messages',
name: 'Plugs',
icon: 'plugs',
path: '/plugs',
},
{
name: 'Billing',
@ -60,18 +55,25 @@ export const useMenuItems = () => {
role: ['ADMIN', 'SUPERADMIN'],
requireBilling: true,
},
{
name: 'Affiliate',
icon: 'affiliate',
path: 'https://affiliate.postiz.com',
role: ['ADMIN', 'SUPERADMIN', 'USER'],
requireBilling: true,
},
];
}
};
export const TopMenu: FC = () => {
const path = usePathname();
const user = useUser();
const {billingEnabled} = useVariables();
const { billingEnabled } = useVariables();
const menuItems = useMenuItems();
return (
<div className="flex flex-col h-full animate-normalFadeDown">
<ul className="gap-5 flex flex-1 items-center text-[18px]">
<div className="flex flex-col h-full animate-normalFadeDown order-3 md:order-2 col-span-2 md:col-span-1">
<ul className="gap-0 md:gap-5 flex flex-1 items-center text-[18px]">
{menuItems
.filter((f) => {
if (f.requireBilling && !billingEnabled) {
@ -86,9 +88,10 @@ export const TopMenu: FC = () => {
<li key={item.name}>
<Link
prefetch={true}
target={item.path.indexOf('http') > -1 ? '_blank' : '_self'}
href={item.path}
className={clsx(
'flex gap-2 items-center box',
'flex gap-2 items-center box px-[6px] md:px-[24px] py-[8px]',
menuItems
.filter((f) => {
if (f.role) {

View File

@ -12,6 +12,7 @@ export const UserContext = createContext<
| (User & {
orgId: string;
tier: PricingInnerInterface;
publicApi: string;
role: 'USER' | 'ADMIN' | 'SUPERADMIN';
totalChannels: number;
isLifetime?: boolean;
@ -24,6 +25,7 @@ export const ContextWrapper: FC<{
orgId: string;
tier: 'FREE' | 'STANDARD' | 'PRO' | 'ULTIMATE' | 'TEAM';
role: 'USER' | 'ADMIN' | 'SUPERADMIN';
publicApi: string;
totalChannels: number;
};
children: ReactNode;

View File

@ -1,5 +1,5 @@
import 'reflect-metadata';
import { FC, useCallback } from 'react';
import { FC } from 'react';
import { Post as PrismaPost } from '.prisma/client';
import { Providers } from '@gitroom/frontend/components/launches/providers/show.all.providers';

View File

@ -42,7 +42,7 @@ export const NotificationOpenComponent = () => {
const { data, isLoading } = useSWR('notifications', loadNotifications);
return (
<div className="opacity-0 animate-normalFadeDown mt-[10px] absolute w-[420px] min-h-[200px] top-[100%] right-0 bg-third text-textColor rounded-[16px] flex flex-col border border-tableBorder">
<div id="notification-popup" className="opacity-0 animate-normalFadeDown mt-[10px] absolute w-[420px] min-h-[200px] top-[100%] right-0 bg-third text-textColor rounded-[16px] flex flex-col border border-tableBorder z-[2]">
<div className={`p-[16px] border-b border-tableBorder ${interClass} font-bold`}>
Notifications
</div>

View File

@ -1,13 +1,6 @@
'use client';
import React, {
FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { orderBy } from 'lodash';
@ -15,9 +8,17 @@ import { useUser } from '@gitroom/frontend/components/layout/user.context';
import clsx from 'clsx';
import Image from 'next/image';
import { Menu } from '@gitroom/frontend/components/launches/menu/menu';
import { ApiModal } from '@gitroom/frontend/components/launches/add.provider.component';
import {
AddProviderComponent,
ApiModal,
CustomVariables,
UrlModal,
} from '@gitroom/frontend/components/launches/add.provider.component';
import { useRouter } from 'next/navigation';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { string } from 'yup';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { useModals } from '@mantine/modals';
export const ConnectChannels: FC = () => {
const fetch = useFetch();
@ -25,6 +26,9 @@ export const ConnectChannels: FC = () => {
const router = useRouter();
const [identifier, setIdentifier] = useState<any>(undefined);
const [popup, setPopups] = useState<undefined | string[]>(undefined);
const toaster = useToaster();
const modal = useModals();
const [showCustom, setShowCustom] = useState<any>(undefined);
const getIntegrations = useCallback(async () => {
return (await fetch('/integrations')).json();
@ -32,17 +36,101 @@ export const ConnectChannels: FC = () => {
const [reload, setReload] = useState(false);
const getSocialLink = useCallback(
(identifier: string) => async () => {
const { url } = await (
await fetch('/integrations/social/' + identifier)
).json();
// const getSocialLink = useCallback(
// (identifier: string) => async () => {
// const { url } = await (
// await fetch('/integrations/social/' + identifier)
// ).json();
//
// window.open(url, 'Social Connect', 'width=700,height=700');
// },
// []
// );
window.open(url, 'Social Connect', 'width=700,height=700');
const addMessage = useCallback(
(event: MessageEvent<{ msg: string; success: boolean }>) => {
if (!event.data.msg) {
return;
}
toaster.show(event.data.msg, event.data.success ? 'success' : 'warning');
setShowCustom(undefined);
},
[]
);
useEffect(() => {
window.addEventListener('message', addMessage);
return () => {
window.removeEventListener('message', addMessage);
};
});
const getSocialLink = useCallback(
(
identifier: string,
isExternal: boolean,
customFields?: Array<{
key: string;
label: string;
validation: string;
defaultValue?: string;
type: 'text' | 'password';
}>
) =>
async () => {
const gotoIntegration = async (externalUrl?: string) => {
const { url, err } = await (
await fetch(
`/integrations/social/${identifier}${
externalUrl ? `?externalUrl=${externalUrl}` : ``
}`
)
).json();
if (err) {
toaster.show('Could not connect to the platform', 'warning');
return;
}
setShowCustom(undefined);
window.open(url, 'Social Connect', 'width=700,height=700');
};
// if (isExternal) {
// modal.closeAll();
//
// modal.openModal({
// title: '',
// withCloseButton: false,
// classNames: {
// modal: 'bg-transparent text-textColor',
// },
// children: <UrlModal gotoUrl={gotoIntegration} />,
// });
//
// return;
// }
if (customFields) {
setShowCustom(
<CustomVariables
identifier={identifier}
gotoUrl={(url: string) =>
window.open(url, 'Social Connect', 'width=700,height=700')
}
variables={customFields}
close={() => setShowCustom(undefined)}
/>
);
return;
}
await gotoIntegration();
},
[]
);
const load = useCallback(async (path: string) => {
const list = (await (await fetch(path)).json()).integrations;
setPopups(list.map((p: any) => p.id));
@ -115,6 +203,13 @@ export const ConnectChannels: FC = () => {
return (
<>
{!!showCustom && (
<div className="absolute w-full h-full top-0 left-0 bg-black/40 z-[400]">
<div className="absolute w-full h-full bg-primary/80 left-0 top-0 z-[200] p-[50px] flex justify-center">
<div className="w-[400px]">{showCustom}</div>
</div>
</div>
)}
{!!identifier && (
<div className="absolute w-full h-full bg-primary/80 left-0 top-0 z-[200] p-[30px] flex items-center justify-center">
<div className="w-[400px]">
@ -142,7 +237,11 @@ export const ConnectChannels: FC = () => {
{data?.social.map((social: any) => (
<div
key={social.identifier}
onClick={getSocialLink(social.identifier)}
onClick={getSocialLink(
social.identifier,
social.isExternal,
social.customFields
)}
className="h-[96px] bg-input flex flex-col justify-center items-center gap-[10px] cursor-pointer"
>
<div>

View File

@ -1,4 +1,4 @@
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FC, useCallback, useMemo, useState } from 'react';
import { Integration } from '@prisma/client';
import useSWR from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';

View File

@ -0,0 +1,316 @@
'use client';
import {
PlugSettings,
PlugsInterface,
usePlugs,
} from '@gitroom/frontend/components/plugs/plugs.context';
import { Button } from '@gitroom/react/form/button';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR, { mutate } from 'swr';
import { useModals } from '@mantine/modals';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import {
FormProvider,
SubmitHandler,
useForm,
useFormContext,
} from 'react-hook-form';
import { Input } from '@gitroom/react/form/input';
import { CopilotTextarea } from '@copilotkit/react-textarea';
import clsx from 'clsx';
import { string, object } from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';
import { Slider } from '@gitroom/react/form/slider';
import { useToaster } from '@gitroom/react/toaster/toaster';
export function convertBackRegex(s: string) {
const matches = s.match(/\/(.*)\/([a-z]*)/);
const pattern = matches?.[1] || '';
const flags = matches?.[2] || '';
return new RegExp(pattern, flags);
}
export const TextArea: FC<{ name: string; placeHolder: string }> = (props) => {
const form = useFormContext();
const { onChange, onBlur, ...all } = form.register(props.name);
const value = form.watch(props.name);
return (
<>
<textarea className="hidden" {...all}></textarea>
<CopilotTextarea
disableBranding={true}
placeholder={props.placeHolder}
value={value}
className={clsx(
'!min-h-40 !max-h-80 p-[24px] overflow-hidden bg-customColor2 outline-none rounded-[4px] border-fifth border'
)}
onChange={(e) => {
onChange({ target: { name: props.name, value: e.target.value } });
}}
autosuggestionsConfig={{
textareaPurpose: `Assist me in writing social media posts.`,
chatApiConfigs: {},
}}
/>
<div className="text-red-400 text-[12px]">
{form?.formState?.errors?.[props.name]?.message as string}
</div>
</>
);
};
export const PlugPop: FC<{
plug: PlugsInterface;
settings: PlugSettings;
data?: {
activated: boolean;
data: string;
id: string;
integrationId: string;
organizationId: string;
plugFunction: string;
};
}> = (props) => {
const { plug, settings, data } = props;
const { closeAll } = useModals();
const fetch = useFetch();
const toaster = useToaster();
const values = useMemo(() => {
if (!data?.data) {
return {};
}
return JSON.parse(data.data).reduce((acc: any, current: any) => {
return {
...acc,
[current.name]: current.value,
};
}, {} as any);
}, []);
const yupSchema = useMemo(() => {
return object(
plug.fields.reduce((acc, field) => {
return {
...acc,
[field.name]: field.validation
? string().matches(convertBackRegex(field.validation), {
message: 'Invalid value',
})
: null,
};
}, {})
);
}, []);
const form = useForm({
resolver: yupResolver(yupSchema),
values,
mode: 'all',
});
const submit: SubmitHandler<any> = useCallback(async (data) => {
await fetch(`/integrations/${settings.providerId}/plugs`, {
method: 'POST',
body: JSON.stringify({
func: plug.methodName,
fields: Object.keys(data).map((key) => ({
name: key,
value: data[key],
})),
}),
});
toaster.show('Plug updated', 'success');
closeAll();
}, []);
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(submit)}>
<div className="fixed left-0 top-0 bg-primary/80 z-[300] w-full min-h-full p-4 md:p-[60px] animate-fade">
<div className="max-w-[1000px] w-full h-full bg-sixth border-tableBorder border-2 rounded-xl pb-[20px] px-[20px] relative mx-auto">
<div className="flex flex-col">
<div className="flex-1">
<TopTitle title={`Auto Plug: ${plug.title}`} />
</div>
<button
onClick={closeAll}
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-primary hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
<div className="my-[20px]">{plug.description}</div>
<div>
{plug.fields.map((field) => (
<div key={field.name}>
{field.type === 'richtext' ? (
<TextArea
name={field.name}
placeHolder={field.placeholder}
/>
) : (
<Input
name={field.name}
label={field.description}
className="w-full mt-[8px] p-[8px] border border-tableBorder rounded-md text-black"
placeholder={field.placeholder}
type={field.type}
/>
)}
</div>
))}
</div>
<div className="mt-[20px]">
<Button type="submit">Activate</Button>
</div>
</div>
</div>
</form>
</FormProvider>
);
};
export const PlugItem: FC<{
plug: PlugsInterface;
addPlug: (data: any) => void;
data?: {
activated: boolean;
data: string;
id: string;
integrationId: string;
organizationId: string;
plugFunction: string;
};
}> = (props) => {
const { plug, addPlug, data } = props;
const [activated, setActivated] = useState(!!data?.activated);
useEffect(() => {
setActivated(!!data?.activated);
}, [data?.activated]);
const fetch = useFetch();
const changeActivated = useCallback(
async (status: 'on' | 'off') => {
await fetch(`/integrations/plugs/${data?.id}/activate`, {
body: JSON.stringify({
status: status === 'on',
}),
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
});
setActivated(status === 'on');
},
[activated]
);
return (
<div
onClick={() => addPlug(data)}
key={plug.title}
className="w-full h-[300px] bg-customColor48 hover:bg-customColor2 hover:border-customColor48 hover:border"
>
<div key={plug.title} className="p-[16px] h-full flex flex-col flex-1">
<div className="flex">
<div className="text-[20px] mb-[8px] flex-1">{plug.title}</div>
{!!data && (
<div onClick={(e) => e.stopPropagation()}>
<Slider
value={activated ? 'on' : 'off'}
onChange={changeActivated}
fill={true}
/>
</div>
)}
</div>
<div className="flex-1">{plug.description}</div>
<Button>{!data ? 'Set Plug' : 'Edit Plug'}</Button>
</div>
</div>
);
};
export const Plug = () => {
const plug = usePlugs();
const modals = useModals();
const fetch = useFetch();
const load = useCallback(async () => {
return (await fetch(`/integrations/${plug.providerId}/plugs`)).json();
}, [plug.providerId]);
const { data, isLoading, mutate } = useSWR(`plugs-${plug.providerId}`, load);
const addEditPlug = useCallback(
(p: PlugsInterface) =>
(data?: {
activated: boolean;
data: string;
id: string;
integrationId: string;
organizationId: string;
plugFunction: string;
}) => {
modals.openModal({
classNames: {
modal: 'bg-transparent text-textColor',
},
withCloseButton: false,
onClose() {
mutate();
},
size: '100%',
children: (
<PlugPop
plug={p}
data={data}
settings={{
identifier: plug.identifier,
providerId: plug.providerId,
name: plug.name,
}}
/>
),
});
},
[data]
);
if (isLoading) {
return null;
}
return (
<div className="grid grid-cols-3 gap-[30px]">
{plug.plugs.map((p) => (
<PlugItem
key={p.title + '-' + plug.providerId}
addPlug={addEditPlug(p)}
plug={p}
data={data?.find((a: any) => a.plugFunction === p.methodName)}
/>
))}
</div>
);
};

View File

@ -0,0 +1,46 @@
'use client';
import { createContext, useContext } from 'react';
export interface PlugSettings {
providerId: string;
name: string;
identifier: string;
}
export interface PlugInterface extends PlugSettings {
plugs: PlugsInterface[];
}
export interface FieldsInterface {
name: string;
type: string;
validation: string;
placeholder: string;
description: string;
}
export interface PlugsInterface {
title: string;
description: string;
runEveryMilliseconds: number;
methodName: string;
fields: FieldsInterface[];
}
export const PlugsContext = createContext<PlugInterface>({
providerId: '',
name: '',
identifier: '',
plugs: [
{
title: '',
description: '',
runEveryMilliseconds: 0,
methodName: '',
fields: [{ name: '', type: '', placeholder: '', description: '', validation: '' }],
},
],
});
export const usePlugs = () => useContext(PlugsContext);

View File

@ -0,0 +1,173 @@
'use client';
import useSWR from 'swr';
import { useCallback, useMemo, useState } from 'react';
import { capitalize, orderBy } from 'lodash';
import clsx from 'clsx';
import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback';
import Image from 'next/image';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { Select } from '@gitroom/react/form/select';
import { Button } from '@gitroom/react/form/button';
import { useRouter } from 'next/navigation';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { PlugsContext } from '@gitroom/frontend/components/plugs/plugs.context';
import { Plug } from '@gitroom/frontend/components/plugs/plug';
export const Plugs = () => {
const fetch = useFetch();
const router = useRouter();
const [current, setCurrent] = useState(0);
const [refresh, setRefresh] = useState(false);
const toaster = useToaster();
const load = useCallback(async () => {
return (await (await fetch('/integrations/list')).json()).integrations;
}, []);
const load2 = useCallback(async (path: string) => {
return await (await fetch(path)).json();
}, []);
const { data: plugList, isLoading: plugLoading } = useSWR(
'/integrations/plug/list',
load2,
{
fallbackData: [],
}
);
const { data, isLoading } = useSWR('analytics-list', load, {
fallbackData: [],
});
const sortedIntegrations = useMemo(() => {
return orderBy(
data.filter((integration: any) =>
plugList?.plugs?.some(
(f: any) => f.identifier === integration.identifier
)
),
// data.filter((integration) => !integration.disabled),
['type', 'disabled', 'identifier'],
['desc', 'asc', 'asc']
);
}, [data, plugList]);
const currentIntegration = useMemo(() => {
return sortedIntegrations[current];
}, [current, sortedIntegrations]);
const currentIntegrationPlug = useMemo(() => {
const plug = plugList?.plugs?.find(
(f: any) => f?.identifier === currentIntegration?.identifier
);
if (!plug) {
return null;
}
return {
providerId: currentIntegration.id,
...plug,
};
}, [currentIntegration, plugList]);
if (isLoading || plugLoading) {
return null;
}
if (!sortedIntegrations.length && !isLoading) {
return (
<div className="flex flex-col items-center mt-[100px] gap-[27px] text-center">
<div>
<img src="/peoplemarketplace.svg" />
</div>
<div className="text-[48px]">
There are not plugs matching your channels
<br />
You have to add: X or LinkedIn or Threads
</div>
<Button onClick={() => router.push('/launches')}>
Go to the calendar to add channels
</Button>
</div>
);
}
return (
<div className="flex gap-[30px] flex-1">
<div className="p-[16px] bg-customColor48 overflow-hidden flex w-[220px]">
<div className="flex gap-[16px] flex-col overflow-hidden">
<div className="text-[20px] mb-[8px]">Channels</div>
{sortedIntegrations.map((integration, index) => (
<div
key={integration.id}
onClick={() => {
if (integration.refreshNeeded) {
toaster.show(
'Please refresh the integration from the calendar',
'warning'
);
return;
}
setRefresh(true);
setTimeout(() => {
setRefresh(false);
}, 10);
setCurrent(index);
}}
className={clsx(
'flex gap-[8px] items-center',
currentIntegration.id !== integration.id &&
'opacity-20 hover:opacity-100 cursor-pointer'
)}
>
<div
className={clsx(
'relative w-[34px] h-[34px] rounded-full flex justify-center items-center bg-fifth',
integration.disabled && 'opacity-50'
)}
>
{(integration.inBetweenSteps || integration.refreshNeeded) && (
<div className="absolute left-0 top-0 w-[39px] h-[46px] cursor-pointer">
<div className="bg-red-500 w-[15px] h-[15px] rounded-full left-0 -top-[5px] absolute z-[200] text-[10px] flex justify-center items-center">
!
</div>
<div className="bg-primary/60 w-[39px] h-[46px] left-0 top-0 absolute rounded-full z-[199]" />
</div>
)}
<ImageWithFallback
fallbackSrc={`/icons/platforms/${integration.identifier}.png`}
src={integration.picture}
className="rounded-full"
alt={integration.identifier}
width={32}
height={32}
/>
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
alt={integration.identifier}
width={20}
height={20}
/>
</div>
<div
className={clsx(
'flex-1 whitespace-nowrap text-ellipsis overflow-hidden',
integration.disabled && 'opacity-50'
)}
>
{integration.name}
</div>
</div>
))}
</div>
</div>
<div className="flex-1 flex flex-col gap-[14px]">
<PlugsContext.Provider value={currentIntegrationPlug}>
<Plug />
</PlugsContext.Provider>
</div>
</div>
);
};

View File

@ -0,0 +1,50 @@
'use client';
import { useState, useCallback } from 'react';
import { useUser } from '../layout/user.context';
import { Button } from '@gitroom/react/form/button';
import copy from 'copy-to-clipboard';
import { useToaster } from '@gitroom/react/toaster/toaster';
export const PublicComponent = () => {
const user = useUser();
const toaster = useToaster();
const [reveal, setReveal] = useState(false);
const copyToClipboard = useCallback(() => {
toaster.show('API Key copied to clipboard', 'success');
copy(user?.publicApi!);
}, [user]);
if (!user || !user.publicApi) {
return null;
}
return (
<div className="flex flex-col">
<h2 className="text-[24px]">Public API</h2>
<div className="text-customColor18 mt-[4px]">
Use Postiz API to integrate with your tools.
</div>
<div className="my-[16px] mt-[16px] bg-sixth border-fifth items-center border rounded-[4px] p-[24px] flex gap-[24px]">
<div className="flex items-center">
{reveal ? (
user.publicApi
) : (
<>
<div className="blur-sm">{user.publicApi.slice(0, -5)}</div>
<div>{user.publicApi.slice(-5)}</div>
</>
)}
</div>
<div>
{!reveal ? (
<Button onClick={() => setReveal(true)}>Reveal</Button>
) : (
<Button onClick={copyToClipboard}>Copy Key</Button>
)}
</div>
</div>
</div>
);
};

View File

@ -210,6 +210,7 @@ export const TeamsComponent = () => {
<Button
className={`!bg-customColor3 !h-[24px] border border-customColor21 rounded-[4px] text-[12px] ${interClass}`}
onClick={remove(p)}
secondary={true}
>
<div className="flex justify-center items-center gap-[4px]">
<div>
@ -222,7 +223,7 @@ export const TeamsComponent = () => {
>
<path
d="M11.8125 3.125H9.625V2.6875C9.625 2.3394 9.48672 2.00556 9.24058 1.75942C8.99444 1.51328 8.6606 1.375 8.3125 1.375H5.6875C5.3394 1.375 5.00556 1.51328 4.75942 1.75942C4.51328 2.00556 4.375 2.3394 4.375 2.6875V3.125H2.1875C2.07147 3.125 1.96019 3.17109 1.87814 3.25314C1.79609 3.33519 1.75 3.44647 1.75 3.5625C1.75 3.67853 1.79609 3.78981 1.87814 3.87186C1.96019 3.95391 2.07147 4 2.1875 4H2.625V11.875C2.625 12.1071 2.71719 12.3296 2.88128 12.4937C3.04538 12.6578 3.26794 12.75 3.5 12.75H10.5C10.7321 12.75 10.9546 12.6578 11.1187 12.4937C11.2828 12.3296 11.375 12.1071 11.375 11.875V4H11.8125C11.9285 4 12.0398 3.95391 12.1219 3.87186C12.2039 3.78981 12.25 3.67853 12.25 3.5625C12.25 3.44647 12.2039 3.33519 12.1219 3.25314C12.0398 3.17109 11.9285 3.125 11.8125 3.125ZM5.25 2.6875C5.25 2.57147 5.29609 2.46019 5.37814 2.37814C5.46019 2.29609 5.57147 2.25 5.6875 2.25H8.3125C8.42853 2.25 8.53981 2.29609 8.62186 2.37814C8.70391 2.46019 8.75 2.57147 8.75 2.6875V3.125H5.25V2.6875ZM10.5 11.875H3.5V4H10.5V11.875ZM6.125 6.1875V9.6875C6.125 9.80353 6.07891 9.91481 5.99686 9.99686C5.91481 10.0789 5.80353 10.125 5.6875 10.125C5.57147 10.125 5.46019 10.0789 5.37814 9.99686C5.29609 9.91481 5.25 9.80353 5.25 9.6875V6.1875C5.25 6.07147 5.29609 5.96019 5.37814 5.87814C5.46019 5.79609 5.57147 5.75 5.6875 5.75C5.80353 5.75 5.91481 5.79609 5.99686 5.87814C6.07891 5.96019 6.125 6.07147 6.125 6.1875ZM8.75 6.1875V9.6875C8.75 9.80353 8.70391 9.91481 8.62186 9.99686C8.53981 10.0789 8.42853 10.125 8.3125 10.125C8.19647 10.125 8.08519 10.0789 8.00314 9.99686C7.92109 9.91481 7.875 9.80353 7.875 9.6875V6.1875C7.875 6.07147 7.92109 5.96019 8.00314 5.87814C8.08519 5.79609 8.19647 5.75 8.3125 5.75C8.42853 5.75 8.53981 5.79609 8.62186 5.87814C8.70391 5.96019 8.75 6.07147 8.75 6.1875Z"
fill="white"
fill="currentColor"
/>
</svg>
</div>

View File

@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { fetchBackend } from '@gitroom/helpers/utils/custom.fetch.func';
import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management';
import { internalFetch } from '@gitroom/helpers/utils/internal.fetch';
// This function can be marked `async` if using `await` inside
export async function middleware(request: NextRequest) {
@ -68,13 +68,10 @@ export async function middleware(request: NextRequest) {
try {
if (org) {
const { id } = await (
await fetchBackend('/user/join-org', {
await internalFetch('/user/join-org', {
body: JSON.stringify({
org,
}),
headers: {
auth: authCookie?.value!,
},
method: 'POST',
})
).json();

View File

@ -1,14 +1,19 @@
import { Module } from '@nestjs/common';
import { StarsController } from './stars.controller';
import {DatabaseModule} from "@gitroom/nestjs-libraries/database/prisma/database.module";
import {TrendingService} from "@gitroom/nestjs-libraries/services/trending.service";
import {PostsController} from "@gitroom/workers/app/posts.controller";
import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/database.module';
import { TrendingService } from '@gitroom/nestjs-libraries/services/trending.service';
import { PostsController } from '@gitroom/workers/app/posts.controller';
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bull.mq.module';
import { PlugsController } from '@gitroom/workers/app/plugs.controller';
@Module({
imports: [DatabaseModule, BullMqModule],
controllers: [...!process.env.IS_GENERAL ? [StarsController] : [], PostsController],
controllers: [
...(!process.env.IS_GENERAL ? [StarsController] : []),
PostsController,
PlugsController,
],
providers: [TrendingService],
})
export class AppModule {}

View File

@ -0,0 +1,21 @@
import { Controller } from '@nestjs/common';
import { EventPattern, Transport } from '@nestjs/microservices';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
@Controller()
export class PlugsController {
constructor(
private _integrationService: IntegrationService
) {}
@EventPattern('plugs', Transport.REDIS)
async plug(data: {
plugId: string;
postId: string;
delay: number;
totalRuns: number;
currentRun: number;
}) {
return this._integrationService.processPlugs(data);
}
}

View File

@ -12,7 +12,7 @@ export class PostsController {
}
@EventPattern('submit', Transport.REDIS)
async payout(data: { id: string, releaseURL: string }) {
async payout(data: { id: string; releaseURL: string }) {
return this._postsService.payout(data.id, data.releaseURL);
}
}

View File

@ -30,12 +30,7 @@ export class ConfigurationChecker {
this.checkIsValidUrl('FRONTEND_URL')
this.checkIsValidUrl('NEXT_PUBLIC_BACKEND_URL')
this.checkIsValidUrl('BACKEND_INTERNAL_URL')
this.checkNonEmpty('CLOUDFLARE_ACCOUNT_ID', 'Needed to setup providers.')
this.checkNonEmpty('CLOUDFLARE_ACCESS_KEY', 'Needed to setup providers.')
this.checkNonEmpty('CLOUDFLARE_SECRET_ACCESS_KEY', 'Needed to setup providers.')
this.checkNonEmpty('CLOUDFLARE_BUCKETNAME', 'Needed to setup providers.')
this.checkNonEmpty('CLOUDFLARE_BUCKET_URL', 'Needed to setup providers.')
this.checkNonEmpty('CLOUDFLARE_REGION', 'Needed to setup providers.')
this.checkNonEmpty('STORAGE_PROVIDER', 'Needed to setup storage.')
}
checkNonEmpty (key: string, description?: string): boolean {

View File

@ -0,0 +1,27 @@
import 'reflect-metadata';
export function Plug(params: {
identifier: string;
title: string;
description: string;
runEveryMilliseconds: number;
totalRuns: number;
fields: {
name: string;
description: string;
type: string;
placeholder: string;
validation?: RegExp;
}[];
}) {
return function (target: Object, propertyKey: string | symbol, descriptor: any) {
// Retrieve existing metadata or initialize an empty array
const existingMetadata = Reflect.getMetadata('custom:plug', target) || [];
// Add the metadata information for this method
existingMetadata.push({ methodName: propertyKey, ...params });
// Define metadata on the class prototype (so it can be retrieved from the class)
Reflect.defineMetadata('custom:plug', existingMetadata, target);
};
}

View File

@ -0,0 +1,25 @@
// @ts-ignore
import twitter from 'twitter-text';
export const textSlicer = (
integrationType: string,
end: number,
text: string
): {start: number, end: number} => {
if (integrationType !== 'x') {
return {
start: 0,
end
}
}
const {validRangeEnd, valid} = twitter.parseTweet(text);
return {
start: 0,
end: valid ? end : validRangeEnd
}
};
export const weightedLength = (text: string): number => {
return twitter.parseTweet(text).weightedLength;
}

View File

@ -1,4 +1,3 @@
import { loadVars } from '@gitroom/react/helpers/variable.context';
export interface Params {
baseUrl: string;
@ -48,6 +47,6 @@ export const customFetch = (
export const fetchBackend = customFetch({
get baseUrl() {
return loadVars().backendUrl;
return process.env.BACKEND_URL!;
},
});

View File

@ -1,9 +1,28 @@
import {usePlausible} from 'next-plausible'
import {useCallback} from "react";
import { usePlausible } from 'next-plausible';
import { useCallback } from 'react';
import { usePostHog } from 'posthog-js/react';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
export const useFireEvents = () => {
const plausible = usePlausible();
return useCallback((name: string, props?: any) => {
plausible(name, {props});
}, []);
}
const { billingEnabled } = useVariables();
const plausible = usePlausible();
const posthog = usePostHog();
const user = useUser();
return useCallback(
(name: string, props?: any) => {
if (!billingEnabled) {
return;
}
if (user) {
posthog.identify(user.id, { email: user.email, name: user.name });
}
posthog.capture(name, props);
plausible(name, { props });
},
[user]
);
};

View File

@ -1,47 +1,35 @@
import {FC, useCallback, useEffect} from "react";
import {useSearchParams} from "next/navigation";
'use client';
import { FC, useCallback, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { useLocalStorage } from '@mantine/hooks';
const UtmSaver: FC = () => {
const query = useSearchParams();
useEffect(() => {
const landingUrl = localStorage.getItem('landingUrl');
if (landingUrl) {
return ;
}
const query = useSearchParams();
const [value, setValue] = useLocalStorage({ key: 'utm', defaultValue: '' });
localStorage.setItem('landingUrl', window.location.href);
localStorage.setItem('referrer', document.referrer);
}, []);
useEffect(() => {
const landingUrl = localStorage.getItem('landingUrl');
if (landingUrl) {
return;
}
useEffect(() => {
const utm = query.get('utm_source') || query.get('utm');
const utmMedium = query.get('utm_medium');
const utmCampaign = query.get('utm_campaign');
localStorage.setItem('landingUrl', window.location.href);
localStorage.setItem('referrer', document.referrer);
}, []);
if (utm) {
localStorage.setItem('utm', utm);
}
if (utmMedium) {
localStorage.setItem('utm_medium', utmMedium);
}
if (utmCampaign) {
localStorage.setItem('utm_campaign', utmCampaign);
}
}, [query]);
useEffect(() => {
const utm = query.get('utm_source') || query.get('utm') || query.get('ref');
if (utm && !value) {
setValue(utm);
}
}, [query, value]);
return <></>;
}
return <></>;
};
export const useUtmSaver = () => {
return useCallback(() => {
return {
utm: localStorage.getItem('utm'),
utmMedium: localStorage.getItem('utm_medium'),
utmCampaign: localStorage.getItem('utm_campaign'),
landingUrl: localStorage.getItem('landingUrl'),
referrer: localStorage.getItem('referrer'),
}
}, []);
}
export default UtmSaver;
export const useUtmUrl = () => {
const [value] = useLocalStorage({ key: 'utm', defaultValue: '' });
return value || '';
};
export default UtmSaver;

View File

@ -40,8 +40,8 @@ export class BullMqClient extends ClientProxy {
const job = await queue.add(packet.pattern, packet.data, {
jobId: packet.data.id ?? v4(),
...packet.data.options,
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: !packet.data.options.attempts,
removeOnFail: !packet.data.options.attempts,
});
try {

View File

@ -2,13 +2,13 @@ import { Injectable } from '@nestjs/common';
import { AgenciesRepository } from '@gitroom/nestjs-libraries/database/prisma/agencies/agencies.repository';
import { User } from '@prisma/client';
import { CreateAgencyDto } from '@gitroom/nestjs-libraries/dtos/agencies/create.agency.dto';
import { EmailService } from '@gitroom/nestjs-libraries/services/email.service';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
@Injectable()
export class AgenciesService {
constructor(
private _agenciesRepository: AgenciesRepository,
private _emailService: EmailService
private _notificationService: NotificationService
) {}
getAgencyByUser(user: User) {
return this._agenciesRepository.getAgencyByUser(user);
@ -35,7 +35,7 @@ export class AgenciesService {
const agency = await this._agenciesRepository.getAgencyById(id);
if (action === 'approve') {
await this._emailService.sendEmail(
await this._notificationService.sendEmail(
agency?.user?.email!,
'Your Agency has been approved and added to Postiz 🚀',
`
@ -59,7 +59,7 @@ export class AgenciesService {
return;
}
await this._emailService.sendEmail(
await this._notificationService.sendEmail(
agency?.user?.email!,
'Your Agency has been declined 😔',
`
@ -84,7 +84,7 @@ export class AgenciesService {
async createAgency(user: User, body: CreateAgencyDto) {
const agency = await this._agenciesRepository.createAgency(user, body);
await this._emailService.sendEmail(
await this._notificationService.sendEmail(
'nevo@postiz.com',
'New agency created',
`

View File

@ -5,13 +5,17 @@ import { Integration } from '@prisma/client';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto';
@Injectable()
export class IntegrationRepository {
private storage = UploadFactory.createStorage();
constructor(
private _integration: PrismaRepository<'integration'>,
private _posts: PrismaRepository<'post'>
private _posts: PrismaRepository<'post'>,
private _plugs: PrismaRepository<'plugs'>,
private _exisingPlugData: PrismaRepository<'exisingPlugData'>,
private _customers: PrismaRepository<'customer'>
) {}
async setTimes(org: string, id: string, times: IntegrationTimeDto) {
@ -29,6 +33,35 @@ export class IntegrationRepository {
});
}
getPlug(plugId: string) {
return this._plugs.model.plugs.findFirst({
where: {
id: plugId,
},
include: {
integration: true
}
});
}
async getPlugs(orgId: string, integrationId: string) {
return this._plugs.model.plugs.findMany({
where: {
integrationId,
organizationId: orgId,
activated: true,
},
include: {
integration: {
select: {
id: true,
providerIdentifier: true,
},
},
},
});
}
async updateIntegration(id: string, params: Partial<Integration>) {
if (
params.picture &&
@ -63,7 +96,7 @@ export class IntegrationRepository {
createOrUpdateIntegration(
org: string,
name: string,
picture: string,
picture: string | undefined,
type: 'article' | 'social',
internalId: string,
provider: string,
@ -98,7 +131,7 @@ export class IntegrationRepository {
providerIdentifier: provider,
token,
profile: username,
picture,
...(picture ? { picture } : {}),
inBetweenSteps: isBetweenSteps,
refreshToken,
...(expiresIn
@ -117,7 +150,7 @@ export class IntegrationRepository {
inBetweenSteps: isBetweenSteps,
}
: {}),
picture,
...(picture ? { picture } : {}),
profile: username,
providerIdentifier: provider,
token,
@ -215,12 +248,79 @@ export class IntegrationRepository {
return integration?.integration;
}
async updateOnCustomerName(org: string, id: string, name: string) {
const customer = !name
? undefined
: (await this._customers.model.customer.findFirst({
where: {
orgId: org,
name,
},
})) ||
(await this._customers.model.customer.create({
data: {
name,
orgId: org,
},
}));
return this._integration.model.integration.update({
where: {
id,
organizationId: org,
},
data: {
customer: !customer
? { disconnect: true }
: {
connect: {
id: customer.id,
},
},
},
});
}
updateIntegrationGroup(org: string, id: string, group: string) {
return this._integration.model.integration.update({
where: {
id,
organizationId: org,
},
data: !group
? {
customer: {
disconnect: true,
},
}
: {
customer: {
connect: {
id: group,
},
},
},
});
}
customers(orgId: string) {
return this._customers.model.customer.findMany({
where: {
orgId,
deletedAt: null,
},
});
}
getIntegrationsList(org: string) {
return this._integration.model.integration.findMany({
where: {
organizationId: org,
deletedAt: null,
},
include: {
customer: true,
},
});
}
@ -310,4 +410,80 @@ export class IntegrationRepository {
});
}
}
getPlugsByIntegrationId(org: string, id: string) {
return this._plugs.model.plugs.findMany({
where: {
organizationId: org,
integrationId: id,
},
});
}
createOrUpdatePlug(org: string, integrationId: string, body: PlugDto) {
return this._plugs.model.plugs.upsert({
where: {
organizationId: org,
plugFunction_integrationId: {
integrationId,
plugFunction: body.func,
},
},
create: {
integrationId,
organizationId: org,
plugFunction: body.func,
data: JSON.stringify(body.fields),
activated: true,
},
update: {
data: JSON.stringify(body.fields),
},
select: {
activated: true,
},
});
}
changePlugActivation(orgId: string, plugId: string, status: boolean) {
return this._plugs.model.plugs.update({
where: {
organizationId: orgId,
id: plugId,
},
data: {
activated: !!status,
},
});
}
async loadExisingData(
methodName: string,
integrationId: string,
id: string[]
) {
return this._exisingPlugData.model.exisingPlugData.findMany({
where: {
integrationId,
methodName,
value: {
in: id,
},
},
});
}
async saveExisingData(
methodName: string,
integrationId: string,
value: string[]
) {
return this._exisingPlugData.model.exisingPlugData.createMany({
data: value.map((p) => ({
integrationId,
methodName,
value: p,
})),
});
}
}

View File

@ -1,27 +1,24 @@
import {
HttpException,
HttpStatus,
Injectable,
Param,
Query,
} from '@nestjs/common';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { IntegrationRepository } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.repository';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { InstagramProvider } from '@gitroom/nestjs-libraries/integrations/social/instagram.provider';
import { FacebookProvider } from '@gitroom/nestjs-libraries/integrations/social/facebook.provider';
import { AnalyticsData, SocialProvider } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import {
AnalyticsData,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { Integration, Organization } from '@prisma/client';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { LinkedinPageProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.page.provider';
import { simpleUpload } from '@gitroom/nestjs-libraries/upload/r2.uploader';
import axios from 'axios';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import dayjs from 'dayjs';
import { timer } from '@gitroom/helpers/utils/timer';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
import { difference } from 'lodash';
@Injectable()
export class IntegrationService {
@ -29,17 +26,22 @@ export class IntegrationService {
constructor(
private _integrationRepository: IntegrationRepository,
private _integrationManager: IntegrationManager,
private _notificationService: NotificationService
private _notificationService: NotificationService,
private _workerServiceProducer: BullMqClient
) {}
async setTimes(orgId: string, integrationId: string, times: IntegrationTimeDto) {
async setTimes(
orgId: string,
integrationId: string,
times: IntegrationTimeDto
) {
return this._integrationRepository.setTimes(orgId, integrationId, times);
}
async createOrUpdateIntegration(
org: string,
name: string,
picture: string,
picture: string | undefined,
type: 'article' | 'social',
internalId: string,
provider: string,
@ -52,7 +54,9 @@ export class IntegrationService {
timezone?: number,
customInstanceDetails?: string
) {
const uploadedPicture = await this.storage.uploadSimple(picture);
const uploadedPicture = picture
? await this.storage.uploadSimple(picture)
: undefined;
return this._integrationRepository.createOrUpdateIntegration(
org,
name,
@ -71,6 +75,14 @@ export class IntegrationService {
);
}
updateIntegrationGroup(org: string, id: string, group: string) {
return this._integrationRepository.updateIntegrationGroup(org, id, group);
}
updateOnCustomerName(org: string, id: string, name: string) {
return this._integrationRepository.updateOnCustomerName(org, id, name);
}
getIntegrationsList(org: string) {
return this._integrationRepository.getIntegrationsList(org);
}
@ -151,7 +163,7 @@ export class IntegrationService {
await this.createOrUpdateIntegration(
integration.organizationId,
integration.name,
integration.picture!,
undefined,
'social',
integration.internalId,
integration.providerIdentifier,
@ -292,7 +304,12 @@ export class IntegrationService {
return { success: true };
}
async checkAnalytics(org: Organization, integration: string, date: string, forceRefresh = false): Promise<AnalyticsData[]> {
async checkAnalytics(
org: Organization,
integration: string,
date: string,
forceRefresh = false
): Promise<AnalyticsData[]> {
const getIntegration = await this.getIntegrationById(org.id, integration);
if (!getIntegration) {
@ -307,7 +324,10 @@ export class IntegrationService {
getIntegration.providerIdentifier
);
if (dayjs(getIntegration?.tokenExpiration).isBefore(dayjs()) || forceRefresh) {
if (
dayjs(getIntegration?.tokenExpiration).isBefore(dayjs()) ||
forceRefresh
) {
const { accessToken, expiresIn, refreshToken } =
await integrationProvider.refreshToken(getIntegration.refreshToken!);
@ -367,4 +387,117 @@ export class IntegrationService {
return [];
}
customers(orgId: string) {
return this._integrationRepository.customers(orgId);
}
getPlugsByIntegrationId(org: string, integrationId: string) {
return this._integrationRepository.getPlugsByIntegrationId(
org,
integrationId
);
}
async processPlugs(data: {
plugId: string;
postId: string;
delay: number;
totalRuns: number;
currentRun: number;
}) {
const getPlugById = await this._integrationRepository.getPlug(data.plugId);
if (!getPlugById) {
return ;
}
const integration = this._integrationManager.getSocialIntegration(
getPlugById.integration.providerIdentifier
);
const findPlug = this._integrationManager
.getAllPlugs()
.find(
(p) => p.identifier === getPlugById.integration.providerIdentifier
)!;
console.log(data.postId);
// @ts-ignore
const process = await integration[getPlugById.plugFunction](
getPlugById.integration,
data.postId,
JSON.parse(getPlugById.data).reduce((all: any, current: any) => {
all[current.name] = current.value;
return all;
}, {})
);
if (process) {
return ;
}
if (data.totalRuns === data.currentRun) {
return ;
}
this._workerServiceProducer.emit('plugs', {
id: 'plug_' + data.postId + '_' + findPlug.identifier,
options: {
delay: 0, // runPlug.runEveryMilliseconds,
},
payload: {
plugId: data.plugId,
postId: data.postId,
delay: data.delay,
totalRuns: data.totalRuns,
currentRun: data.currentRun + 1,
},
});
}
async createOrUpdatePlug(
orgId: string,
integrationId: string,
body: PlugDto
) {
const { activated } = await this._integrationRepository.createOrUpdatePlug(
orgId,
integrationId,
body
);
return {
activated,
};
}
async changePlugActivation(orgId: string, plugId: string, status: boolean) {
const { id, integrationId, plugFunction } =
await this._integrationRepository.changePlugActivation(
orgId,
plugId,
status
);
return { id };
}
async getPlugs(orgId: string, integrationId: string) {
return this._integrationRepository.getPlugs(orgId, integrationId);
}
async loadExisingData(
methodName: string,
integrationId: string,
id: string[]
) {
const exisingData = await this._integrationRepository.loadExisingData(
methodName,
integrationId,
id
);
const loadOnlyIds = exisingData.map((p) => p.value);
return difference(id, loadOnlyIds);
}
}

View File

@ -13,6 +13,23 @@ export class OrganizationRepository {
private _user: PrismaRepository<'user'>
) {}
getOrgByApiKey(api: string) {
return this._organization.model.organization.findFirst({
where: {
apiKey: api,
},
include: {
subscription: {
select: {
subscriptionTier: true,
totalChannels: true,
isLifetime: true,
},
},
},
});
}
getUserOrg(id: string) {
return this._userOrg.model.userOrganization.findFirst({
where: {
@ -161,9 +178,9 @@ export class OrganizationRepository {
});
if (
!process.env.STRIPE_PUBLISHABLE_KEY ||
checkForSubscription?.subscription?.subscriptionTier !==
SubscriptionTier.PRO
process.env.STRIPE_PUBLISHABLE_KEY &&
checkForSubscription?.subscription?.subscriptionTier ===
SubscriptionTier.STANDARD
) {
return false;
}

View File

@ -17,7 +17,10 @@ export class OrganizationService {
async createOrgAndUser(
body: Omit<CreateOrgUserDto, 'providerToken'> & { providerId?: string }
) {
return this._organizationRepository.createOrgAndUser(body, this._notificationsService.hasEmailProvider());
return this._organizationRepository.createOrgAndUser(
body,
this._notificationsService.hasEmailProvider()
);
}
addUserToOrg(
@ -33,6 +36,10 @@ export class OrganizationService {
return this._organizationRepository.getOrgById(id);
}
getOrgByApiKey(api: string) {
return this._organizationRepository.getOrgByApiKey(api);
}
getUserOrg(id: string) {
return this._organizationRepository.getUserOrg(id);
}
@ -50,7 +57,7 @@ export class OrganizationService {
}
async inviteTeamMember(orgId: string, body: AddTeamMemberDto) {
const timeLimit = dayjs().add(15, 'minutes').format('YYYY-MM-DD HH:mm:ss');
const timeLimit = dayjs().add(1, 'hour').format('YYYY-MM-DD HH:mm:ss');
const id = makeId(5);
const url =
process.env.FRONTEND_URL +
@ -59,7 +66,7 @@ export class OrganizationService {
await this._notificationsService.sendEmail(
body.email,
'You have been invited to join an organization',
`You have been invited to join an organization. Click <a href="${url}">here</a> to join.<br />The link will expire in 15 minutes.`
`You have been invited to join an organization. Click <a href="${url}">here</a> to join.<br />The link will expire in 1 hour.`
);
}
return { url };
@ -86,6 +93,9 @@ export class OrganizationService {
}
disableOrEnableNonSuperAdminUsers(orgId: string, disable: boolean) {
return this._organizationRepository.disableOrEnableNonSuperAdminUsers(orgId, disable);
return this._organizationRepository.disableOrEnableNonSuperAdminUsers(
orgId,
disable
);
}
}

View File

@ -299,6 +299,13 @@ export class PostsService {
true
);
await this.checkPlugs(
integration.organizationId,
getIntegration.identifier,
integration.id,
publishedPosts[0].postId
);
return {
postId: publishedPosts[0].postId,
releaseURL: publishedPosts[0].releaseURL,
@ -312,6 +319,42 @@ export class PostsService {
}
}
private async checkPlugs(
orgId: string,
providerName: string,
integrationId: string,
postId: string
) {
const loadAllPlugs = this._integrationManager.getAllPlugs();
const getPlugs = await this._integrationService.getPlugs(
orgId,
integrationId
);
const currentPlug = loadAllPlugs.find((p) => p.identifier === providerName);
for (const plug of getPlugs) {
const runPlug = currentPlug?.plugs?.find((p: any) => p.methodName === plug.plugFunction)!;
if (!runPlug) {
continue;
}
this._workerServiceProducer.emit('plugs', {
id: 'plug_' + postId + '_' + runPlug.identifier,
options: {
delay: runPlug.runEveryMilliseconds,
},
payload: {
plugId: plug.id,
postId,
delay: runPlug.runEveryMilliseconds,
totalRuns: runPlug.totalRuns,
currentRun: 1,
},
});
}
}
private async postArticle(integration: Integration, posts: Post[]) {
const getIntegration = this._integrationManager.getArticlesIntegration(
integration.providerIdentifier

View File

@ -30,6 +30,8 @@ model Organization {
buyerOrganization MessagesGroup[]
usedCodes UsedCodes[]
credits Credits[]
plugs Plugs[]
customers Customer[]
}
model User {
@ -242,6 +244,19 @@ model Subscription {
@@index([deletedAt])
}
model Customer {
id String @id @default(uuid())
name String
orgId String
organization Organization @relation(fields: [orgId], references: [id])
integrations Integration[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@unique([orgId, name, deletedAt])
}
model Integration {
id String @id @default(cuid())
internalId String
@ -265,6 +280,10 @@ model Integration {
refreshNeeded Boolean @default(false)
postingTimes String @default("[{\"time\":120}, {\"time\":400}, {\"time\":700}]")
customInstanceDetails String?
customerId String?
customer Customer? @relation(fields: [customerId], references: [id])
plugs Plugs[]
exisingPlugData ExisingPlugData[]
@@index([updatedAt])
@@index([deletedAt])
@ -440,6 +459,30 @@ model Messages {
@@index([deletedAt])
}
model Plugs {
id String @id @default(uuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
plugFunction String
data String
integrationId String
integration Integration @relation(fields: [integrationId], references: [id])
activated Boolean @default(true)
@@unique([plugFunction, integrationId])
@@index([organizationId])
}
model ExisingPlugData {
id String @id @default(uuid())
integrationId String
integration Integration @relation(fields: [integrationId], references: [id])
methodName String
value String
@@unique([integrationId, methodName, value])
}
enum OrderStatus {
PENDING
ACCEPTED

View File

@ -11,6 +11,7 @@ export interface PricingInnerInterface {
import_from_channels: boolean;
image_generator?: boolean;
image_generation_count: number;
public_api: boolean;
}
export interface PricingInterface {
[key: string]: PricingInnerInterface;
@ -29,6 +30,7 @@ export const pricing: PricingInterface = {
ai: false,
import_from_channels: false,
image_generator: false,
public_api: false,
},
STANDARD: {
current: 'STANDARD',
@ -43,6 +45,7 @@ export const pricing: PricingInterface = {
featured_by_gitroom: false,
import_from_channels: true,
image_generator: false,
public_api: true,
},
TEAM: {
current: 'TEAM',
@ -57,6 +60,7 @@ export const pricing: PricingInterface = {
ai: true,
import_from_channels: true,
image_generator: true,
public_api: true,
},
PRO: {
current: 'PRO',
@ -71,6 +75,7 @@ export const pricing: PricingInterface = {
ai: true,
import_from_channels: true,
image_generator: true,
public_api: true,
},
ULTIMATE: {
current: 'ULTIMATE',
@ -85,5 +90,6 @@ export const pricing: PricingInterface = {
ai: true,
import_from_channels: true,
image_generator: true,
public_api: true,
},
};

View File

@ -6,4 +6,8 @@ export class BillingSubscribeDto {
@IsIn(['STANDARD', 'PRO', 'TEAM', 'ULTIMATE'])
billing: 'STANDARD' | 'PRO' | 'TEAM' | 'ULTIMATE';
utm: string;
tolt: string;
}

View File

@ -0,0 +1,23 @@
import { IsDefined, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class FieldsDto {
@IsString()
@IsDefined()
name: string;
@IsString()
@IsDefined()
value: string;
}
export class PlugDto {
@IsString()
@IsDefined()
func: string;
@Type(() => FieldsDto)
@ValidateNested({ each: true })
@IsDefined()
fields: FieldsDto[];
}

View File

@ -0,0 +1,19 @@
import { Type } from 'class-transformer';
import { IsArray, IsDefined, IsIn, IsString, ValidateNested, IsOptional } from 'class-validator';
export class Collaborators {
@IsDefined()
@IsString()
label: string;
}
export class InstagramDto {
@IsIn(['post', 'story'])
@IsDefined()
post_type: 'post' | 'story';
@Type(() => Collaborators)
@ValidateNested({ each: true })
@IsArray()
@IsOptional()
collaborators: Collaborators[];
}

View File

@ -1,3 +1,5 @@
import 'reflect-metadata';
import { Injectable } from '@nestjs/common';
import { XProvider } from '@gitroom/nestjs-libraries/integrations/social/x.provider';
import { SocialProvider } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
@ -64,6 +66,27 @@ export class IntegrationManager {
})),
};
}
getAllPlugs() {
return socialIntegrationList
.map((p) => {
return {
name: p.name,
identifier: p.identifier,
plugs: (
Reflect.getMetadata('custom:plug', p.constructor.prototype) || []
).map((p: any) => ({
...p,
fields: p.fields.map((c: any) => ({
...c,
validation: c?.validation?.toString(),
})),
})),
};
})
.filter((f) => f.plugs.length);
}
getAllowedSocialsIntegrations() {
return socialIntegrationList.map((p) => p.identifier);
}

View File

@ -9,6 +9,6 @@ export class NotEnoughScopesFilter implements ExceptionFilter {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
response.status(HttpStatusCode.NotAcceptable).json({ invalid: true });
response.status(HttpStatusCode.NotAcceptable).json({ msg: exception.message });
}
}

View File

@ -1,3 +1,5 @@
import { timer } from '@gitroom/helpers/utils/timer';
export class RefreshToken {
constructor(
public identifier: string,
@ -13,10 +15,16 @@ export class BadBody {
) {}
}
export class NotEnoughScopes {}
export class NotEnoughScopes {
constructor(public message = 'Not enough scopes') {}
}
export abstract class SocialAbstract {
async fetch(url: string, options: RequestInit = {}, identifier = '') {
async fetch(
url: string,
options: RequestInit = {},
identifier = ''
): Promise<Response> {
const request = await fetch(url, options);
if (request.status === 200 || request.status === 201) {
@ -31,7 +39,15 @@ export abstract class SocialAbstract {
json = '{}';
}
if (request.status === 401 || json.includes('OAuthException')) {
if (json.includes('rate_limit_exceeded') || json.includes('Rate limit')) {
await timer(2000);
return this.fetch(url, options, identifier);
}
if (
request.status === 401 ||
(json.includes('OAuthException') && !json.includes("Unsupported format") && !json.includes('2207018'))
) {
throw new RefreshToken(identifier, json, options.body!);
}

View File

@ -5,12 +5,14 @@ import {
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { BskyAgent } from '@atproto/api';
import { NotEnoughScopes, SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { BskyAgent, RichText } from '@atproto/api';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import sharp from 'sharp';
import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
import { timer } from '@gitroom/helpers/utils/timer';
export class BlueskyProvider extends SocialAbstract implements SocialProvider {
identifier = 'bluesky';
@ -70,30 +72,34 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
}) {
const body = JSON.parse(Buffer.from(params.code, 'base64').toString());
const agent = new BskyAgent({
service: body.service,
});
try {
const agent = new BskyAgent({
service: body.service,
});
const {
data: { accessJwt, refreshJwt, handle, did },
} = await agent.login({
identifier: body.identifier,
password: body.password,
});
const {
data: { accessJwt, refreshJwt, handle, did },
} = await agent.login({
identifier: body.identifier,
password: body.password,
});
const profile = await agent.getProfile({
actor: did,
});
const profile = await agent.getProfile({
actor: did,
});
return {
refreshToken: refreshJwt,
expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(),
accessToken: accessJwt,
id: did,
name: profile.data.displayName!,
picture: profile.data.avatar!,
username: profile.data.handle!,
};
return {
refreshToken: refreshJwt,
expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(),
accessToken: accessJwt,
id: did,
name: profile.data.displayName!,
picture: profile.data.avatar!,
username: profile.data.handle!,
};
} catch (e) {
return 'Invalid credentials';
}
}
async post(
@ -116,7 +122,7 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
let loadCid = '';
let loadUri = '';
const cidUrl = [] as { cid: string; url: string, rev: string }[];
const cidUrl = [] as { cid: string; url: string; rev: string }[];
for (const post of postDetails) {
const images = await Promise.all(
post.media?.map(async (p) => {
@ -132,9 +138,16 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
}) || []
);
const rt = new RichText({
text: post.message,
});
await rt.detectFacets(agent);
// @ts-ignore
const { cid, uri, commit } = await agent.post({
text: post.message,
text: rt.text,
facets: rt.facets,
createdAt: new Date().toISOString(),
...(images.length
? {
@ -172,9 +185,143 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
return postDetails.map((p, index) => ({
id: p.id,
postId: cidUrl[index].cid,
postId: cidUrl[index].url,
status: 'completed',
releaseURL: `https://bsky.app/profile/${id}/post/${cidUrl[index].url.split('/').pop()}`,
releaseURL: `https://bsky.app/profile/${id}/post/${cidUrl[index].url
.split('/')
.pop()}`,
}));
}
@Plug({
identifier: 'bluesky-autoRepostPost',
title: 'Auto Repost Posts',
description:
'When a post reached a certain number of likes, repost it to increase engagement (1 week old posts)',
runEveryMilliseconds: 21600000,
totalRuns: 3,
fields: [
{
name: 'likesAmount',
type: 'number',
placeholder: 'Amount of likes',
description: 'The amount of likes to trigger the repost',
validation: /^\d+$/,
},
],
})
async autoRepostPost(
integration: Integration,
id: string,
fields: { likesAmount: string }
) {
const body = JSON.parse(
AuthService.fixedDecryption(integration.customInstanceDetails!)
);
const agent = new BskyAgent({
service: body.service,
});
await agent.login({
identifier: body.identifier,
password: body.password,
});
const getThread = await agent.getPostThread({
uri: id,
depth: 0,
});
// @ts-ignore
if (getThread.data.thread.post?.likeCount >= +fields.likesAmount) {
await timer(2000);
await agent.repost(
// @ts-ignore
getThread.data.thread.post?.uri,
// @ts-ignore
getThread.data.thread.post?.cid
);
return true;
}
return true;
}
@Plug({
identifier: 'bluesky-autoPlugPost',
title: 'Auto plug post',
description:
'When a post reached a certain number of likes, add another post to it so you followers get a notification about your promotion',
runEveryMilliseconds: 21600000,
totalRuns: 3,
fields: [
{
name: 'likesAmount',
type: 'number',
placeholder: 'Amount of likes',
description: 'The amount of likes to trigger the repost',
validation: /^\d+$/,
},
{
name: 'post',
type: 'richtext',
placeholder: 'Post to plug',
description: 'Message content to plug',
validation: /^[\s\S]{3,}$/g,
},
],
})
async autoPlugPost(
integration: Integration,
id: string,
fields: { likesAmount: string; post: string }
) {
const body = JSON.parse(
AuthService.fixedDecryption(integration.customInstanceDetails!)
);
const agent = new BskyAgent({
service: body.service,
});
await agent.login({
identifier: body.identifier,
password: body.password,
});
const getThread = await agent.getPostThread({
uri: id,
depth: 0,
});
// @ts-ignore
if (getThread.data.thread.post?.likeCount >= +fields.likesAmount) {
await timer(2000);
const rt = new RichText({
text: fields.post,
});
await agent.post({
text: rt.text,
facets: rt.facets,
createdAt: new Date().toISOString(),
reply: {
root: {
// @ts-ignore
uri: getThread.data.thread.post?.uri,
// @ts-ignore
cid: getThread.data.thread.post?.cid,
},
parent: {
// @ts-ignore
uri: getThread.data.thread.post?.uri,
// @ts-ignore
cid: getThread.data.thread.post?.cid,
},
},
});
return true;
}
return true;
}
}

View File

@ -298,8 +298,8 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
accessToken: string,
date: number
): Promise<AnalyticsData[]> {
const until = dayjs().format('YYYY-MM-DD');
const since = dayjs().subtract(date, 'day').format('YYYY-MM-DD');
const until = dayjs().endOf('day').unix()
const since = dayjs().subtract(date, 'day').unix();
const { data } = await (
await this.fetch(

View File

@ -9,7 +9,7 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { timer } from '@gitroom/helpers/utils/timer';
import dayjs from 'dayjs';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { string } from 'yup';
import { InstagramDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/instagram.dto';
export class InstagramProvider
extends SocialAbstract
@ -204,10 +204,11 @@ export class InstagramProvider
async post(
id: string,
accessToken: string,
postDetails: PostDetails[]
postDetails: PostDetails<InstagramDto>[]
): Promise<PostResponse[]> {
const [firstPost, ...theRest] = postDetails;
console.log('in progress');
const isStory = firstPost.settings.post_type === 'story';
const medias = await Promise.all(
firstPost?.media?.map(async (m) => {
const caption =
@ -219,18 +220,34 @@ export class InstagramProvider
const mediaType =
m.url.indexOf('.mp4') > -1
? firstPost?.media?.length === 1
? `video_url=${m.url}&media_type=REELS`
? isStory
? `video_url=${m.url}&media_type=STORIES`
: `video_url=${m.url}&media_type=REELS`
: isStory
? `video_url=${m.url}&media_type=STORIES`
: `video_url=${m.url}&media_type=VIDEO`
: isStory
? `image_url=${m.url}&media_type=STORIES`
: `image_url=${m.url}`;
console.log('in progress1');
const collaborators =
firstPost?.settings?.collaborators?.length && !isStory
? `&collaborators=${JSON.stringify(
firstPost?.settings?.collaborators.map((p) => p.label)
)}`
: ``;
console.log(collaborators);
const { id: photoId } = await (
await this.fetch(
`https://graph.facebook.com/v20.0/${id}/media?${mediaType}${isCarousel}&access_token=${accessToken}${caption}`,
`https://graph.facebook.com/v20.0/${id}/media?${mediaType}${isCarousel}${collaborators}&access_token=${accessToken}${caption}`,
{
method: 'POST',
}
)
).json();
console.log('in progress2');
let status = 'IN_PROGRESS';
while (status === 'IN_PROGRESS') {
@ -242,6 +259,7 @@ export class InstagramProvider
await timer(3000);
status = status_code;
}
console.log('in progress3');
return photoId;
}) || []
@ -357,8 +375,8 @@ export class InstagramProvider
accessToken: string,
date: number
): Promise<AnalyticsData[]> {
const until = dayjs().format('YYYY-MM-DD');
const since = dayjs().subtract(date, 'day').format('YYYY-MM-DD');
const until = dayjs().endOf('day').unix();
const since = dayjs().subtract(date, 'day').unix();
const { data, ...all } = await (
await fetch(
@ -377,4 +395,12 @@ export class InstagramProvider
})) || []
);
}
music(accessToken: string, data: { q: string }) {
return this.fetch(
`https://graph.facebook.com/v20.0/music/search?q=${encodeURIComponent(
data.q
)}&access_token=${accessToken}`
);
}
}

View File

@ -9,6 +9,8 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { LinkedinProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.provider';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
import { timer } from '@gitroom/helpers/utils/timer';
export class LinkedinPageProvider
extends LinkedinProvider
@ -97,12 +99,14 @@ export class LinkedinPageProvider
}
async companies(accessToken: string) {
const { elements } = await (
const { elements, ...all } = await (
await fetch(
'https://api.linkedin.com/v2/organizationalEntityAcls?q=roleAssignee&role=ADMINISTRATOR&projection=(elements*(organizationalTarget~(localizedName,vanityName,logoV2(original~:playableStreams))))',
{
headers: {
Authorization: `Bearer ${accessToken}`,
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': '202402',
},
}
)
@ -124,7 +128,10 @@ export class LinkedinPageProvider
requiredId: string,
accessToken: string
): Promise<AuthTokenDetails> {
const information = await this.fetchPageInformation(accessToken, requiredId);
const information = await this.fetchPageInformation(
accessToken,
requiredId
);
return {
id: information.id,
@ -355,6 +362,150 @@ export class LinkedinPageProvider
percentageChange: 5,
}));
}
@Plug({
identifier: 'linkedin-page-autoRepostPost',
title: 'Auto Repost Posts',
description:
'When a post reached a certain number of likes, repost it to increase engagement (1 week old posts)',
runEveryMilliseconds: 21600000,
totalRuns: 3,
fields: [
{
name: 'likesAmount',
type: 'number',
placeholder: 'Amount of likes',
description: 'The amount of likes to trigger the repost',
validation: /^\d+$/,
},
],
})
async autoRepostPost(
integration: Integration,
id: string,
fields: { likesAmount: string }
) {
const {
likesSummary: { totalLikes },
} = await (
await this.fetch(
`https://api.linkedin.com/v2/socialActions/${encodeURIComponent(id)}`,
{
method: 'GET',
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json',
'LinkedIn-Version': '202402',
Authorization: `Bearer ${integration.token}`,
},
}
)
).json();
if (totalLikes >= +fields.likesAmount) {
await timer(2000);
await this.fetch(`https://api.linkedin.com/rest/posts`, {
body: JSON.stringify({
author: `urn:li:organization:${integration.internalId}`,
commentary: '',
visibility: 'PUBLIC',
distribution: {
feedDistribution: 'MAIN_FEED',
targetEntities: [],
thirdPartyDistributionChannels: [],
},
lifecycleState: 'PUBLISHED',
isReshareDisabledByAuthor: false,
reshareContext: {
parent: id,
},
}),
method: 'POST',
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json',
'LinkedIn-Version': '202402',
Authorization: `Bearer ${integration.token}`,
},
});
return true;
}
return false;
}
@Plug({
identifier: 'linkedin-page-autoPlugPost',
title: 'Auto plug post',
description:
'When a post reached a certain number of likes, add another post to it so you followers get a notification about your promotion',
runEveryMilliseconds: 21600000,
totalRuns: 3,
fields: [
{
name: 'likesAmount',
type: 'number',
placeholder: 'Amount of likes',
description: 'The amount of likes to trigger the repost',
validation: /^\d+$/,
},
{
name: 'post',
type: 'richtext',
placeholder: 'Post to plug',
description: 'Message content to plug',
validation: /^[\s\S]{3,}$/g,
},
],
})
async autoPlugPost(
integration: Integration,
id: string,
fields: { likesAmount: string; post: string }
) {
const {
likesSummary: { totalLikes },
} = await (
await this.fetch(
`https://api.linkedin.com/v2/socialActions/${encodeURIComponent(id)}`,
{
method: 'GET',
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json',
'LinkedIn-Version': '202402',
Authorization: `Bearer ${integration.token}`,
},
}
)
).json();
if (totalLikes >= fields.likesAmount) {
await timer(2000);
await this.fetch(
`https://api.linkedin.com/v2/socialActions/${decodeURIComponent(
id
)}/comments`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${integration.token}`,
},
body: JSON.stringify({
actor: `urn:li:organization:${integration.internalId}`,
object: id,
message: {
text: this.fixText(fields.post)
},
}),
}
);
return true;
}
return false;
}
}
export interface Root {

View File

@ -14,7 +14,6 @@ import {
SocialAbstract,
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { Integration } from '@prisma/client';
export class LinkedinProvider extends SocialAbstract implements SocialProvider {
identifier = 'linkedin';
name = 'LinkedIn';
@ -156,7 +155,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
async company(token: string, data: { url: string }) {
const { url } = data;
const getCompanyVanity = url.match(
/^https?:\/\/?www\.?linkedin\.com\/company\/([^/]+)\/$/
/^https?:\/\/(?:www\.)?linkedin\.com\/company\/([^/]+)\/?$/
);
if (!getCompanyVanity || !getCompanyVanity?.length) {
throw new Error('Invalid LinkedIn company URL');
@ -282,6 +281,32 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
}
}
protected fixText(text: string) {
const pattern = /@\[.+?]\(urn:li:organization.+?\)/g;
const matches = text.match(pattern) || [];
const splitAll = text.split(pattern);
const splitTextReformat = splitAll.map((p) => {
return p
.replace(/\*/g, '\\*')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)')
.replace(/\{/g, '\\{')
.replace(/}/g, '\\}')
.replace(/@/g, '\\@');
});
const connectAll = splitTextReformat.reduce((all, current) => {
const match = matches.shift();
all.push(current);
if (match) {
all.push(match);
}
return all;
}, [] as string[]);
return connectAll.join('');
}
async post(
id: string,
accessToken: string,
@ -305,6 +330,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
: await sharp(await readOrFetch(m.url), {
animated: lookup(m.url) === 'image/gif',
})
.toFormat('jpeg')
.resize({
width: 1000,
})
@ -340,10 +366,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
type === 'personal'
? `urn:li:person:${id}`
: `urn:li:organization:${id}`,
commentary: removeMarkdown({
text: firstPost.message.replace('\n', '𝔫𝔢𝔴𝔩𝔦𝔫𝔢'),
except: [/@\[(.*?)]\(urn:li:organization:(\d+)\)/g],
}).replace('𝔫𝔢𝔴𝔩𝔦𝔫𝔢', '\n'),
commentary: this.fixText(firstPost.message),
visibility: 'PUBLIC',
distribution: {
feedDistribution: 'MAIN_FEED',
@ -409,10 +432,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
: `urn:li:organization:${id}`,
object: topPostId,
message: {
text: removeMarkdown({
text: post.message.replace('\n', '𝔫𝔢𝔴𝔩𝔦𝔫𝔢'),
except: [/@\[(.*?)]\(urn:li:organization:(\d+)\)/g],
}).replace('𝔫𝔢𝔴𝔩𝔦𝔫𝔢', '\n'),
text: this.fixText(post.message),
},
}),
}

View File

@ -76,7 +76,7 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
access_token: accessToken,
refresh_token: refreshToken,
expires_in: expiresIn,
scope
scope,
} = await (
await this.fetch('https://www.reddit.com/api/v1/access_token', {
method: 'POST',
@ -300,18 +300,28 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
)
).json();
const newData = await (
await this.fetch(
`https://oauth.reddit.com/${data.subreddit}/api/link_flair_v2`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
)
).json();
// eslint-disable-next-line no-async-promise-executor
const newData = await new Promise<{id: string, name: string}[]>(async (res) => {
try {
const flair = await (
await this.fetch(
`https://oauth.reddit.com/${data.subreddit}/api/link_flair_v2`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
)
).json();
res(flair);
}
catch (err) {
return res([]);
}
});
return {
subreddit: data.subreddit,

View File

@ -13,7 +13,7 @@ export interface IAuthenticator {
refresh?: string;
},
clientInformation?: ClientInformation
): Promise<AuthTokenDetails>;
): Promise<AuthTokenDetails|string>;
refreshToken(refreshToken: string): Promise<AuthTokenDetails>;
reConnect?(id: string, requiredId: string, accessToken: string): Promise<AuthTokenDetails>;
generateAuthUrl(
@ -51,6 +51,7 @@ export type GenerateAuthUrlResponse = {
export type AuthTokenDetails = {
id: string;
name: string;
error?: string;
accessToken: string; // The obtained access token
refreshToken?: string; // The refresh token, if applicable
expiresIn?: number; // The duration in seconds for which the access token is valid

View File

@ -10,6 +10,8 @@ import { timer } from '@gitroom/helpers/utils/timer';
import dayjs from 'dayjs';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { capitalize, chunk } from 'lodash';
import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
import { Integration } from '@prisma/client';
export class ThreadsProvider extends SocialAbstract implements SocialProvider {
identifier = 'threads';
@ -152,7 +154,6 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
let globalThread = '';
let link = '';
if (firstPost?.media?.length! <= 1) {
const type = !firstPost?.media?.[0]?.url
? undefined
@ -323,8 +324,8 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
accessToken: string,
date: number
): Promise<AnalyticsData[]> {
const until = dayjs().format('YYYY-MM-DD');
const since = dayjs().subtract(date, 'day').format('YYYY-MM-DD');
const until = dayjs().endOf('day').unix();
const since = dayjs().subtract(date, 'day').unix();
const { data, ...all } = await (
await fetch(
@ -332,7 +333,6 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
)
).json();
console.log(data);
return (
data?.map((d: any) => ({
label: capitalize(d.name),
@ -346,4 +346,73 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
})) || []
);
}
@Plug({
identifier: 'threads-autoPlugPost',
title: 'Auto plug post',
description:
'When a post reached a certain number of likes, add another post to it so you followers get a notification about your promotion',
runEveryMilliseconds: 21600000,
totalRuns: 3,
fields: [
{
name: 'likesAmount',
type: 'number',
placeholder: 'Amount of likes',
description: 'The amount of likes to trigger the repost',
validation: /^\d+$/,
},
{
name: 'post',
type: 'richtext',
placeholder: 'Post to plug',
description: 'Message content to plug',
validation: /^[\s\S]{3,}$/g,
},
],
})
async autoPlugPost(
integration: Integration,
id: string,
fields: { likesAmount: string; post: string }
) {
const { data } = await (
await fetch(
`https://graph.threads.net/v1.0/${id}/insights?metric=likes&access_token=${integration.token}`
)
).json();
const {
values: [value],
} = data.find((p: any) => p.name === 'likes');
if (value.value >= fields.likesAmount) {
await timer(2000);
const form = new FormData();
form.append('media_type', 'TEXT');
form.append('text', fields.post);
form.append('reply_to_id', id);
form.append('access_token', integration.token);
const { id: replyId } = await (
await this.fetch('https://graph.threads.net/v1.0/me/threads', {
method: 'POST',
body: form,
})
).json();
await (
await this.fetch(
`https://graph.threads.net/v1.0/${integration.internalId}/threads_publish?creation_id=${replyId}&access_token=${integration.token}`,
{
method: 'POST',
}
)
).json();
return true;
}
return false;
}
}

View File

@ -10,6 +10,8 @@ import {
SocialAbstract,
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { TikTokDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/tiktok.dto';
import { timer } from '@gitroom/helpers/utils/timer';
import { Integration } from '@prisma/client';
export class TiktokProvider extends SocialAbstract implements SocialProvider {
identifier = 'tiktok';
@ -46,7 +48,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
},
} = await (
await fetch(
'https://open.tiktokapis.com/v2/user/info/?fields=open_id,avatar_url,display_name,username',
'https://open.tiktokapis.com/v2/user/info/?fields=open_id,avatar_url,display_name,union_id,username',
{
method: 'GET',
headers: {
@ -76,10 +78,10 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
`?client_key=${process.env.TIKTOK_CLIENT_ID}` +
`&redirect_uri=${encodeURIComponent(
`${
process.env.NODE_ENV === 'development' || !process.env.NODE_ENV
? `https://integration.git.sn/integrations/social/tiktok`
: `${process.env.FRONTEND_URL}/integrations/social/tiktok`
}`
process?.env?.FRONTEND_URL?.indexOf('https') === -1
? 'https://redirectmeto.com/'
: ''
}${process?.env?.FRONTEND_URL}/integrations/social/tiktok`
)}` +
`&state=${state}` +
`&response_type=code` +
@ -100,10 +102,11 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
code: params.code,
grant_type: 'authorization_code',
code_verifier: params.codeVerifier,
redirect_uri:
process.env.NODE_ENV === 'development' || !process.env.NODE_ENV
? `https://integration.git.sn/integrations/social/tiktok`
: `${process.env.FRONTEND_URL}/integrations/social/tiktok`,
redirect_uri: `${
process?.env?.FRONTEND_URL?.indexOf('https') === -1
? 'https://redirectmeto.com/'
: ''
}${process?.env?.FRONTEND_URL}/integrations/social/tiktok`
};
const { access_token, refresh_token, scope } = await (
@ -116,6 +119,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
})
).json();
console.log(this.scopes, scope);
this.checkScopes(this.scopes, scope);
const {
@ -166,18 +170,16 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
};
}
async post(
private async uploadedVideoSuccess(
id: string,
accessToken: string,
postDetails: PostDetails<TikTokDto>[]
): Promise<PostResponse[]> {
try {
const [firstPost, ...comments] = postDetails;
const {
data: { publish_id },
} = await (
publishId: string,
accessToken: string
): Promise<{ url: string; id: number }> {
// eslint-disable-next-line no-constant-condition
while (true) {
const post = await (
await this.fetch(
'https://open.tiktokapis.com/v2/post/publish/video/init/',
'https://open.tiktokapis.com/v2/post/publish/status/fetch/',
{
method: 'POST',
headers: {
@ -185,37 +187,88 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
post_info: {
title: firstPost.message,
privacy_level: firstPost.settings.privacy_level,
disable_duet: !firstPost.settings.duet,
disable_comment: !firstPost.settings.comment,
disable_stitch: !firstPost.settings.stitch,
brand_content_toggle: firstPost.settings.brand_content_toggle,
brand_organic_toggle: firstPost.settings.brand_organic_toggle,
},
source_info: {
source: 'PULL_FROM_URL',
video_url: firstPost?.media?.[0]?.url!,
},
publish_id: publishId,
}),
}
)
).json();
return [
{
id: firstPost.id,
releaseURL: `https://www.tiktok.com`,
postId: publish_id,
status: 'success',
},
];
} catch (err) {
throw new BadBody('titok-error', JSON.stringify(err), {
// @ts-ignore
postDetails,
});
const { status, publicaly_available_post_id } = post.data;
if (status === 'PUBLISH_COMPLETE') {
return {
url: !publicaly_available_post_id
? `https://www.tiktok.com/@${id}`
: `https://www.tiktok.com/@${id}/video/` +
publicaly_available_post_id,
id: !publicaly_available_post_id
? publishId
: publicaly_available_post_id?.[0],
};
}
if (status === 'FAILED') {
throw new BadBody('titok-error-upload', JSON.stringify(post), {
// @ts-ignore
postDetails,
});
}
await timer(3000);
}
}
async post(
id: string,
accessToken: string,
postDetails: PostDetails<TikTokDto>[],
integration: Integration
): Promise<PostResponse[]> {
const [firstPost, ...comments] = postDetails;
const {
data: { publish_id },
} = await (
await this.fetch(
'https://open.tiktokapis.com/v2/post/publish/video/init/',
{
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=UTF-8',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
post_info: {
title: firstPost.message,
privacy_level: firstPost.settings.privacy_level,
disable_duet: !firstPost.settings.duet,
disable_comment: !firstPost.settings.comment,
disable_stitch: !firstPost.settings.stitch,
brand_content_toggle: firstPost.settings.brand_content_toggle,
brand_organic_toggle: firstPost.settings.brand_organic_toggle,
},
source_info: {
source: 'PULL_FROM_URL',
video_url: firstPost?.media?.[0]?.url!,
},
}),
}
)
).json();
const { url, id: videoId } = await this.uploadedVideoSuccess(
integration.profile!,
publish_id,
accessToken
);
return [
{
id: firstPost.id,
releaseURL: url,
postId: String(videoId),
status: 'success',
},
];
}
}

View File

@ -10,6 +10,9 @@ import sharp from 'sharp';
import { readOrFetch } from '@gitroom/helpers/utils/read.or.fetch';
import removeMd from 'remove-markdown';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
import { Integration } from '@prisma/client';
import { timer } from '@gitroom/helpers/utils/timer';
export class XProvider extends SocialAbstract implements SocialProvider {
identifier = 'x';
@ -17,10 +20,112 @@ export class XProvider extends SocialAbstract implements SocialProvider {
isBetweenSteps = false;
scopes = [];
@Plug({
identifier: 'x-autoRepostPost',
title: 'Auto Repost Posts',
description:
'When a post reached a certain number of likes, repost it to increase engagement (1 week old posts)',
runEveryMilliseconds: 21600000,
totalRuns: 3,
fields: [
{
name: 'likesAmount',
type: 'number',
placeholder: 'Amount of likes',
description: 'The amount of likes to trigger the repost',
validation: /^\d+$/,
},
],
})
async autoRepostPost(
integration: Integration,
id: string,
fields: { likesAmount: string }
) {
// @ts-ignore
// eslint-disable-next-line prefer-rest-params
const [accessTokenSplit, accessSecretSplit] = integration.token.split(':');
const client = new TwitterApi({
appKey: process.env.X_API_KEY!,
appSecret: process.env.X_API_SECRET!,
accessToken: accessTokenSplit,
accessSecret: accessSecretSplit,
});
if (
(await client.v2.tweetLikedBy(id)).meta.result_count >=
+fields.likesAmount
) {
await timer(2000);
await client.v2.retweet(integration.internalId, id);
return true;
}
return false;
}
@Plug({
identifier: 'x-autoPlugPost',
title: 'Auto plug post',
description:
'When a post reached a certain number of likes, add another post to it so you followers get a notification about your promotion',
runEveryMilliseconds: 21600000,
totalRuns: 3,
fields: [
{
name: 'likesAmount',
type: 'number',
placeholder: 'Amount of likes',
description: 'The amount of likes to trigger the repost',
validation: /^\d+$/,
},
{
name: 'post',
type: 'richtext',
placeholder: 'Post to plug',
description: 'Message content to plug',
validation: /^[\s\S]{3,}$/g,
},
],
})
async autoPlugPost(
integration: Integration,
id: string,
fields: { likesAmount: string; post: string }
) {
// @ts-ignore
// eslint-disable-next-line prefer-rest-params
const [accessTokenSplit, accessSecretSplit] = integration.token.split(':');
const client = new TwitterApi({
appKey: process.env.X_API_KEY!,
appSecret: process.env.X_API_SECRET!,
accessToken: accessTokenSplit,
accessSecret: accessSecretSplit,
});
if (
(await client.v2.tweetLikedBy(id)).meta.result_count >=
+fields.likesAmount
) {
await timer(2000);
await client.v2.tweet({
text: removeMd(fields.post.replace('\n', '𝔫𝔢𝔴𝔩𝔦𝔫𝔢')).replace(
'𝔫𝔢𝔴𝔩𝔦𝔫𝔢',
'\n'
),
reply: { in_reply_to_tweet_id: id },
});
return true;
}
return false;
}
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
const startingClient = new TwitterApi({
clientId: process.env.TWITTER_CLIENT_ID!,
clientSecret: process.env.TWITTER_CLIENT_SECRET!,
appKey: process.env.X_API_KEY!,
appSecret: process.env.X_API_SECRET!,
});
const {
accessToken,

View File

@ -223,7 +223,7 @@ export class StripeService {
customer,
status: 'all',
})
).data.filter((f) => f.status === 'active' || f.status === 'trialing'),
).data,
};
const { cancel_at } = await stripe.subscriptions.update(
@ -270,12 +270,13 @@ export class StripeService {
body: BillingSubscribeDto,
price: string
) {
const isUtm = body.utm ? `&utm_source=${body.utm}` : '';
const { url } = await stripe.checkout.sessions.create({
customer,
cancel_url: process.env['FRONTEND_URL'] + `/billing`,
cancel_url: process.env['FRONTEND_URL'] + `/billing?cancel=true${isUtm}`,
success_url:
process.env['FRONTEND_URL'] +
`/launches?onboarding=true&check=${uniqueId}`,
`/launches?onboarding=true&check=${uniqueId}${isUtm}`,
mode: 'subscription',
subscription_data: {
trial_period_days: 7,
@ -285,6 +286,11 @@ export class StripeService {
uniqueId,
},
},
...body.tolt ? {
metadata: {
tolt_referral: body.tolt,
}
} : {},
allow_promotion_codes: true,
line_items: [
{

View File

@ -0,0 +1,17 @@
import { ThrottlerGuard } from '@nestjs/throttler';
import { ExecutionContext, Injectable } from '@nestjs/common';
@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
public override async canActivate(context: ExecutionContext): Promise<boolean> {
if (context.switchToHttp().getRequest().url.includes('/public/v1')) {
return super.canActivate(context);
}
return true;
}
protected override async getTracker(req: Record<string, any>): Promise<string> {
return req.org.id;
}
}

Some files were not shown because too many files have changed in this diff Show More