feat: independent provider adding
Some checks failed
Build Containers / build-containers-common (push) Has been cancelled
Build / build (22.12.0) (push) Has been cancelled
Build Containers / build-containers (amd64, ubuntu-latest) (push) Has been cancelled
Build Containers / build-containers (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build Containers / build-container-manifest (push) Has been cancelled
Some checks failed
Build Containers / build-containers-common (push) Has been cancelled
Build / build (22.12.0) (push) Has been cancelled
Build Containers / build-containers (amd64, ubuntu-latest) (push) Has been cancelled
Build Containers / build-containers (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build Containers / build-container-manifest (push) Has been cancelled
This commit is contained in:
parent
5f830f11b1
commit
fe457573ec
22 changed files with 1271 additions and 913 deletions
|
|
@ -31,6 +31,7 @@ import { AutopostController } from '@gitroom/backend/api/routes/autopost.control
|
|||
import { SetsController } from '@gitroom/backend/api/routes/sets.controller';
|
||||
import { ThirdPartyController } from '@gitroom/backend/api/routes/third-party.controller';
|
||||
import { MonitorController } from '@gitroom/backend/api/routes/monitor.controller';
|
||||
import { NoAuthIntegrationsController } from '@gitroom/backend/api/routes/no.auth.integrations.controller';
|
||||
|
||||
const authenticatedController = [
|
||||
UsersController,
|
||||
|
|
@ -56,6 +57,7 @@ const authenticatedController = [
|
|||
AuthController,
|
||||
PublicController,
|
||||
MonitorController,
|
||||
NoAuthIntegrationsController,
|
||||
...authenticatedController,
|
||||
],
|
||||
providers: [
|
||||
|
|
|
|||
|
|
@ -3,15 +3,12 @@ import {
|
|||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
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';
|
||||
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
|
||||
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
|
||||
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
|
||||
|
|
@ -21,16 +18,11 @@ import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permis
|
|||
import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
|
||||
import { NotEnoughScopesFilter } from '@gitroom/nestjs-libraries/integrations/integration.missing.scopes';
|
||||
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
|
||||
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 { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
|
||||
import { timer } from '@gitroom/helpers/utils/timer';
|
||||
import { TelegramProvider } from '@gitroom/nestjs-libraries/integrations/social/telegram.provider';
|
||||
import {
|
||||
|
|
@ -196,7 +188,8 @@ export class IntegrationsController {
|
|||
@Param('integration') integration: string,
|
||||
@Query('refresh') refresh: string,
|
||||
@Query('externalUrl') externalUrl: string,
|
||||
@Query('onboarding') onboarding: string
|
||||
@Query('onboarding') onboarding: string,
|
||||
@GetOrgFromRequest() org: Organization
|
||||
) {
|
||||
if (
|
||||
!this._integrationManager
|
||||
|
|
@ -225,19 +218,20 @@ export class IntegrationsController {
|
|||
await integrationProvider.generateAuthUrl(getExternalUrl);
|
||||
|
||||
if (refresh) {
|
||||
await ioRedis.set(`refresh:${state}`, refresh, 'EX', 300);
|
||||
await ioRedis.set(`refresh:${state}`, refresh, 'EX', 3600);
|
||||
}
|
||||
|
||||
if (onboarding === 'true') {
|
||||
await ioRedis.set(`onboarding:${state}`, 'true', 'EX', 300);
|
||||
await ioRedis.set(`onboarding:${state}`, 'true', 'EX', 3600);
|
||||
}
|
||||
|
||||
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 300);
|
||||
await ioRedis.set(`organization:${state}`, org.id, 'EX', 3600);
|
||||
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 3600);
|
||||
await ioRedis.set(
|
||||
`external:${state}`,
|
||||
JSON.stringify(getExternalUrl),
|
||||
'EX',
|
||||
300
|
||||
3600
|
||||
);
|
||||
|
||||
return { url };
|
||||
|
|
@ -371,171 +365,6 @@ export class IntegrationsController {
|
|||
throw new Error('Function not found');
|
||||
}
|
||||
|
||||
@Post('/social/:integration/connect')
|
||||
@CheckPolicies([AuthorizationActions.Create, Sections.CHANNEL])
|
||||
@UseFilters(new NotEnoughScopesFilter())
|
||||
async connectSocialMedia(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Param('integration') integration: string,
|
||||
@Body() body: ConnectIntegrationDto
|
||||
) {
|
||||
if (
|
||||
!this._integrationManager
|
||||
.getAllowedSocialsIntegrations()
|
||||
.includes(integration)
|
||||
) {
|
||||
throw new Error('Integration not allowed');
|
||||
}
|
||||
|
||||
const integrationProvider =
|
||||
this._integrationManager.getSocialIntegration(integration);
|
||||
|
||||
const getCodeVerifier = integrationProvider.customFields
|
||||
? 'none'
|
||||
: await ioRedis.get(`login:${body.state}`);
|
||||
if (!getCodeVerifier) {
|
||||
throw new Error('Invalid state');
|
||||
}
|
||||
|
||||
if (!integrationProvider.customFields) {
|
||||
await ioRedis.del(`login:${body.state}`);
|
||||
}
|
||||
|
||||
const details = integrationProvider.externalUrl
|
||||
? await ioRedis.get(`external:${body.state}`)
|
||||
: undefined;
|
||||
|
||||
if (details) {
|
||||
await ioRedis.del(`external:${body.state}`);
|
||||
}
|
||||
|
||||
const refresh = await ioRedis.get(`refresh:${body.state}`);
|
||||
if (refresh) {
|
||||
await ioRedis.del(`refresh:${body.state}`);
|
||||
}
|
||||
|
||||
const onboarding = await ioRedis.get(`onboarding:${body.state}`);
|
||||
if (onboarding) {
|
||||
await ioRedis.del(`onboarding:${body.state}`);
|
||||
}
|
||||
|
||||
const {
|
||||
error,
|
||||
accessToken,
|
||||
expiresIn,
|
||||
refreshToken,
|
||||
id,
|
||||
name,
|
||||
picture,
|
||||
username,
|
||||
additionalSettings,
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
} = await new Promise<AuthTokenDetails>(async (res) => {
|
||||
const auth = await integrationProvider.authenticate(
|
||||
{
|
||||
code: body.code,
|
||||
codeVerifier: getCodeVerifier,
|
||||
refresh: body.refresh,
|
||||
},
|
||||
details ? JSON.parse(details) : undefined
|
||||
);
|
||||
|
||||
if (typeof auth === 'string') {
|
||||
return res({
|
||||
error: auth,
|
||||
accessToken: '',
|
||||
id: '',
|
||||
name: '',
|
||||
picture: '',
|
||||
username: '',
|
||||
additionalSettings: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (refresh && integrationProvider.reConnect) {
|
||||
const newAuth = await integrationProvider.reConnect(
|
||||
auth.id,
|
||||
refresh,
|
||||
auth.accessToken
|
||||
);
|
||||
return res({ ...newAuth, refreshToken: body.refresh });
|
||||
}
|
||||
|
||||
return res(auth);
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new NotEnoughScopes(error);
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
throw new NotEnoughScopes('Invalid API key');
|
||||
}
|
||||
|
||||
if (refresh && String(id) !== String(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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.STRIPE_PUBLISHABLE_KEY &&
|
||||
org.isTrailing &&
|
||||
(await this._integrationService.checkPreviousConnections(
|
||||
org.id,
|
||||
String(id)
|
||||
))
|
||||
) {
|
||||
throw new HttpException('', 412);
|
||||
}
|
||||
|
||||
const createUpdate =
|
||||
await this._integrationService.createOrUpdateIntegration(
|
||||
additionalSettings,
|
||||
!!integrationProvider.oneTimeToken,
|
||||
org.id,
|
||||
validName.trim(),
|
||||
picture,
|
||||
'social',
|
||||
String(id),
|
||||
integration,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn,
|
||||
username,
|
||||
refresh ? false : integrationProvider.isBetweenSteps,
|
||||
body.refresh,
|
||||
+body.timezone,
|
||||
details
|
||||
? AuthService.fixedEncryption(details)
|
||||
: integrationProvider.customFields
|
||||
? AuthService.fixedEncryption(
|
||||
Buffer.from(body.code, 'base64').toString()
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
|
||||
this._refreshIntegrationService
|
||||
.startRefreshWorkflow(org.id, createUpdate.id, integrationProvider)
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
|
||||
return {
|
||||
...createUpdate,
|
||||
onboarding: onboarding === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
@Post('/disable')
|
||||
disableChannel(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
|
|
@ -544,15 +373,6 @@ export class IntegrationsController {
|
|||
return this._integrationService.disableChannel(org.id, id);
|
||||
}
|
||||
|
||||
@Post('/provider/:id/connect')
|
||||
async saveProviderPage(
|
||||
@Param('id') id: string,
|
||||
@Body() body: any,
|
||||
@GetOrgFromRequest() org: Organization
|
||||
) {
|
||||
return this._integrationService.saveProviderPage(org.id, id, body);
|
||||
}
|
||||
|
||||
@Post('/enable')
|
||||
enableChannel(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
|
|
@ -577,7 +397,7 @@ export class IntegrationsController {
|
|||
);
|
||||
if (isTherePosts.length) {
|
||||
for (const post of isTherePosts) {
|
||||
await this._postService.deletePost(org.id, post.group);
|
||||
this._postService.deletePost(org.id, post.group).catch((err) => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
281
apps/backend/src/api/routes/no.auth.integrations.controller.ts
Normal file
281
apps/backend/src/api/routes/no.auth.integrations.controller.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
UseFilters,
|
||||
} from '@nestjs/common';
|
||||
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
|
||||
import { ConnectIntegrationDto } from '@gitroom/nestjs-libraries/dtos/integrations/connect.integration.dto';
|
||||
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
|
||||
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
|
||||
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { NotEnoughScopesFilter } from '@gitroom/nestjs-libraries/integrations/integration.missing.scopes';
|
||||
import { AuthService } from '@gitroom/helpers/auth/auth.service';
|
||||
import { AuthTokenDetails } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
|
||||
import { NotEnoughScopes } from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
import {
|
||||
AuthorizationActions,
|
||||
Sections,
|
||||
} from '@gitroom/backend/services/auth/permissions/permission.exception.class';
|
||||
import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service';
|
||||
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
|
||||
|
||||
@ApiTags('Integrations')
|
||||
@Controller('/integrations')
|
||||
export class NoAuthIntegrationsController {
|
||||
constructor(
|
||||
private _integrationManager: IntegrationManager,
|
||||
private _integrationService: IntegrationService,
|
||||
private _refreshIntegrationService: RefreshIntegrationService,
|
||||
private _organizationService: OrganizationService
|
||||
) {}
|
||||
|
||||
@Post('/social-connect/:integration')
|
||||
@CheckPolicies([AuthorizationActions.Create, Sections.CHANNEL])
|
||||
@UseFilters(new NotEnoughScopesFilter())
|
||||
async connectSocialMedia(
|
||||
@Param('integration') integration: string,
|
||||
@Body() body: ConnectIntegrationDto
|
||||
) {
|
||||
if (
|
||||
!this._integrationManager
|
||||
.getAllowedSocialsIntegrations()
|
||||
.includes(integration)
|
||||
) {
|
||||
throw new Error('Integration not allowed');
|
||||
}
|
||||
|
||||
const integrationProvider =
|
||||
this._integrationManager.getSocialIntegration(integration);
|
||||
|
||||
const getCodeVerifier = integrationProvider.customFields
|
||||
? 'none'
|
||||
: await ioRedis.get(`login:${body.state}`);
|
||||
if (!getCodeVerifier) {
|
||||
throw new Error('Invalid state');
|
||||
}
|
||||
|
||||
const organization = await ioRedis.get(`organization:${body.state}`);
|
||||
if (!organization) {
|
||||
throw new Error('Organization not found');
|
||||
}
|
||||
|
||||
const org = await this._organizationService.getOrgById(organization);
|
||||
|
||||
if (!integrationProvider.customFields) {
|
||||
await ioRedis.del(`login:${body.state}`);
|
||||
}
|
||||
|
||||
const details = integrationProvider.externalUrl
|
||||
? await ioRedis.get(`external:${body.state}`)
|
||||
: undefined;
|
||||
|
||||
if (details) {
|
||||
await ioRedis.del(`external:${body.state}`);
|
||||
}
|
||||
|
||||
const refresh = await ioRedis.get(`refresh:${body.state}`);
|
||||
if (refresh) {
|
||||
await ioRedis.del(`refresh:${body.state}`);
|
||||
}
|
||||
|
||||
const onboarding = await ioRedis.get(`onboarding:${body.state}`);
|
||||
if (onboarding) {
|
||||
await ioRedis.del(`onboarding:${body.state}`);
|
||||
}
|
||||
|
||||
const {
|
||||
error,
|
||||
accessToken,
|
||||
expiresIn,
|
||||
refreshToken,
|
||||
id,
|
||||
name,
|
||||
picture,
|
||||
username,
|
||||
additionalSettings,
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
} = await new Promise<AuthTokenDetails>(async (res) => {
|
||||
try {
|
||||
const auth = await integrationProvider.authenticate(
|
||||
{
|
||||
code: body.code,
|
||||
codeVerifier: getCodeVerifier,
|
||||
refresh: body.refresh,
|
||||
},
|
||||
details ? JSON.parse(details) : undefined
|
||||
);
|
||||
|
||||
if (typeof auth === 'string') {
|
||||
return res({
|
||||
error: auth,
|
||||
accessToken: '',
|
||||
id: '',
|
||||
name: '',
|
||||
picture: '',
|
||||
username: '',
|
||||
additionalSettings: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (refresh && integrationProvider.reConnect) {
|
||||
console.log('reconnect');
|
||||
try {
|
||||
const newAuth = await integrationProvider.reConnect(
|
||||
auth.id,
|
||||
refresh,
|
||||
auth.accessToken
|
||||
);
|
||||
return res({ ...newAuth, refreshToken: body.refresh });
|
||||
} catch (err: any) {
|
||||
return res({
|
||||
error: err.message,
|
||||
accessToken: '',
|
||||
id: '',
|
||||
name: '',
|
||||
picture: '',
|
||||
username: '',
|
||||
additionalSettings: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res(auth);
|
||||
} catch (err) {
|
||||
if (err instanceof NotEnoughScopes) {
|
||||
return res({
|
||||
error: err.message,
|
||||
accessToken: '',
|
||||
id: '',
|
||||
name: '',
|
||||
picture: '',
|
||||
username: '',
|
||||
additionalSettings: [],
|
||||
});
|
||||
}
|
||||
|
||||
return res({
|
||||
error: 'Authentication failed',
|
||||
accessToken: '',
|
||||
id: '',
|
||||
name: '',
|
||||
picture: '',
|
||||
username: '',
|
||||
additionalSettings: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new NotEnoughScopes(error);
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
throw new NotEnoughScopes('Invalid API key');
|
||||
}
|
||||
|
||||
if (refresh && String(id) !== String(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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.STRIPE_PUBLISHABLE_KEY &&
|
||||
org.isTrailing &&
|
||||
(await this._integrationService.checkPreviousConnections(
|
||||
org.id,
|
||||
String(id)
|
||||
))
|
||||
) {
|
||||
throw new HttpException('', 412);
|
||||
}
|
||||
|
||||
const createUpdate =
|
||||
await this._integrationService.createOrUpdateIntegration(
|
||||
additionalSettings,
|
||||
!!integrationProvider.oneTimeToken,
|
||||
org.id,
|
||||
validName.trim(),
|
||||
picture,
|
||||
'social',
|
||||
String(id),
|
||||
integration,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn,
|
||||
username,
|
||||
refresh ? false : integrationProvider.isBetweenSteps,
|
||||
body.refresh,
|
||||
+body.timezone,
|
||||
details
|
||||
? AuthService.fixedEncryption(details)
|
||||
: integrationProvider.customFields
|
||||
? AuthService.fixedEncryption(
|
||||
Buffer.from(body.code, 'base64').toString()
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
|
||||
this._refreshIntegrationService
|
||||
.startRefreshWorkflow(org.id, createUpdate.id, integrationProvider)
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
|
||||
// Fetch pages if this is a two-step provider and not a refresh
|
||||
let pages: any[] = [];
|
||||
if (integrationProvider.isBetweenSteps && !refresh) {
|
||||
try {
|
||||
// Check which method the provider uses (pages or companies)
|
||||
const fetchMethod =
|
||||
'pages' in integrationProvider
|
||||
? 'pages'
|
||||
: 'companies' in integrationProvider
|
||||
? 'companies'
|
||||
: null;
|
||||
|
||||
if (fetchMethod) {
|
||||
// @ts-ignore - dynamic method call
|
||||
pages = await integrationProvider[fetchMethod](accessToken);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Failed to fetch pages:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...createUpdate,
|
||||
onboarding: onboarding === 'true',
|
||||
pages,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('/provider/:id/connect')
|
||||
async saveProviderPage(@Param('id') id: string, @Body() body: any) {
|
||||
if (!body.state) {
|
||||
throw new Error('Invalid state');
|
||||
}
|
||||
|
||||
const organization = await ioRedis.get(`organization:${body.state}`);
|
||||
if (!organization) {
|
||||
throw new Error('Organization not found');
|
||||
}
|
||||
|
||||
const org = await this._organizationService.getOrgById(organization);
|
||||
|
||||
return this._integrationService.saveProviderPage(org.id, id, body);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,9 @@ export class PoliciesGuard implements CanActivate {
|
|||
const request: Request = context.switchToHttp().getRequest();
|
||||
if (
|
||||
request.path.indexOf('/auth') > -1 ||
|
||||
request.path.indexOf('/stripe') > -1
|
||||
request.path.indexOf('/auth') > -1 ||
|
||||
request.path.indexOf('/integrations/social-connect') > -1 ||
|
||||
request.path.indexOf('/integrations/provider') > -1
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const clientAndYoutube = () => {
|
|||
|
||||
export class GoogleProvider implements ProvidersInterface {
|
||||
generateLink() {
|
||||
const state = makeId(7);
|
||||
const state = 'login';
|
||||
const { client } = clientAndYoutube();
|
||||
return client.generateAuthUrl({
|
||||
access_type: 'online',
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { getT } from '@gitroom/react/translation/get.translation.service.backend';
|
||||
export default async function IntegrationLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const t = await getT();
|
||||
|
||||
return (
|
||||
<div className="bg-newBgColorInner p-[20px] flex flex-col transition-all flex-1">
|
||||
<div className="text-6xl text-center mt-[50px]">
|
||||
{t('adding_channel_redirecting_you', 'Adding channel, Redirecting You')}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import { ContinueIntegration } from '@gitroom/frontend/components/launches/continue.integration';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function Page({
|
||||
params: { provider },
|
||||
searchParams,
|
||||
|
|
@ -9,5 +12,6 @@ export default async function Page({
|
|||
};
|
||||
searchParams: any;
|
||||
}) {
|
||||
return <ContinueIntegration searchParams={searchParams} provider={provider} />;
|
||||
const get = cookies().get('auth');
|
||||
return <ContinueIntegration searchParams={searchParams} provider={provider} logged={!!get?.name} />;
|
||||
}
|
||||
13
apps/frontend/src/app/(app)/integrations/social/layout.tsx
Normal file
13
apps/frontend/src/app/(app)/integrations/social/layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { ReactNode } from 'react';
|
||||
|
||||
export default async function IntegrationLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-[#0B0A0A] flex flex-1 min-h-screen w-screen">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,10 +16,11 @@ import { object, string } from 'yup';
|
|||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { web3List } from '@gitroom/frontend/components/launches/web3/web3.list';
|
||||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||
import { ModalWrapperComponent } from '@gitroom/frontend/components/new-launch/modal.wrapper.component';
|
||||
import clsx from 'clsx';
|
||||
import copy from 'copy-to-clipboard';
|
||||
const resolver = classValidatorResolver(ApiKeyDto);
|
||||
export const useAddProvider = (update?: () => void) => {
|
||||
|
||||
export const useAddProvider = (update?: () => void, invite?: boolean) => {
|
||||
const modal = useModals();
|
||||
const fetch = useFetch();
|
||||
return useCallback(async () => {
|
||||
|
|
@ -27,7 +28,9 @@ export const useAddProvider = (update?: () => void) => {
|
|||
modal.openModal({
|
||||
title: 'Add Channel',
|
||||
withCloseButton: true,
|
||||
children: <AddProviderComponent update={update} {...data} />,
|
||||
children: (
|
||||
<AddProviderComponent invite={!!invite} update={update} {...data} />
|
||||
),
|
||||
});
|
||||
}, []);
|
||||
};
|
||||
|
|
@ -36,34 +39,69 @@ export const AddProviderButton: FC<{
|
|||
}> = (props) => {
|
||||
const { update } = props;
|
||||
const add = useAddProvider(update);
|
||||
const invite = useAddProvider(update, true);
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<button
|
||||
className="text-btnText bg-btnSimple h-[44px] pt-[12px] pb-[14px] ps-[16px] pe-[20px] justify-center items-center flex rounded-[8px] gap-[8px]"
|
||||
onClick={add}
|
||||
>
|
||||
<div>
|
||||
<div className="flex group-[.sidebar]:block gap-[8px]">
|
||||
<button
|
||||
className="flex-1 group-[.sidebar]:w-[100%] group-[.sidebar]:flex-none text-btnText bg-btnSimple h-[44px] pt-[12px] pb-[14px] ps-[16px] pe-[20px] justify-center items-center flex rounded-[8px] gap-[8px]"
|
||||
onClick={add}
|
||||
>
|
||||
<div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M1.66675 10.0417C3.35907 10.2299 4.93698 10.9884 6.14101 12.1924C7.34504 13.3964 8.10353 14.9743 8.29175 16.6667M1.66675 13.4167C2.46749 13.58 3.20253 13.9751 3.7804 14.553C4.35827 15.1309 4.75344 15.8659 4.91675 16.6667M1.66675 16.6667H1.67508M11.6667 17.5H14.3334C15.7335 17.5 16.4336 17.5 16.9684 17.2275C17.4388 16.9878 17.8212 16.6054 18.0609 16.135C18.3334 15.6002 18.3334 14.9001 18.3334 13.5V6.5C18.3334 5.09987 18.3334 4.3998 18.0609 3.86502C17.8212 3.39462 17.4388 3.01217 16.9684 2.77248C16.4336 2.5 15.7335 2.5 14.3334 2.5H5.66675C4.26662 2.5 3.56655 2.5 3.03177 2.77248C2.56137 3.01217 2.17892 3.39462 1.93923 3.86502C1.66675 4.3998 1.66675 5.09987 1.66675 6.5V6.66667"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-start text-[14px] group-[.sidebar]:hidden">
|
||||
{t('add_channel', 'Add Channel')}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={invite}
|
||||
data-tooltip-id="tooltip"
|
||||
data-tooltip-content={t(
|
||||
'invite_link',
|
||||
'Send Invite Link to a customer'
|
||||
)}
|
||||
className="group-[.sidebar]:hidden min-h-[44px] min-w-[44px] bg-btnSimple justify-center items-center flex rounded-[8px] cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M1.66675 10.0417C3.35907 10.2299 4.93698 10.9884 6.14101 12.1924C7.34504 13.3964 8.10353 14.9743 8.29175 16.6667M1.66675 13.4167C2.46749 13.58 3.20253 13.9751 3.7804 14.553C4.35827 15.1309 4.75344 15.8659 4.91675 16.6667M1.66675 16.6667H1.67508M11.6667 17.5H14.3334C15.7335 17.5 16.4336 17.5 16.9684 17.2275C17.4388 16.9878 17.8212 16.6054 18.0609 16.135C18.3334 15.6002 18.3334 14.9001 18.3334 13.5V6.5C18.3334 5.09987 18.3334 4.3998 18.0609 3.86502C17.8212 3.39462 17.4388 3.01217 16.9684 2.77248C16.4336 2.5 15.7335 2.5 14.3334 2.5H5.66675C4.26662 2.5 3.56655 2.5 3.03177 2.77248C2.56137 3.01217 2.17892 3.39462 1.93923 3.86502C1.66675 4.3998 1.66675 5.09987 1.66675 6.5V6.66667"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<g clip-path="url(#clip0_2452_193804)">
|
||||
<path
|
||||
d="M6.6668 8.66599C6.9531 9.04875 7.31837 9.36545 7.73783 9.59462C8.1573 9.82379 8.62114 9.96007 9.0979 9.99422C9.57466 10.0284 10.0532 9.95957 10.501 9.79251C10.9489 9.62546 11.3555 9.36404 11.6935 9.02599L13.6935 7.02599C14.3007 6.39732 14.6366 5.55531 14.629 4.68132C14.6215 3.80733 14.2709 2.97129 13.6529 2.35326C13.0348 1.73524 12.1988 1.38467 11.3248 1.37708C10.4508 1.36948 9.60881 1.70547 8.98013 2.31266L7.83347 3.45266M9.33347 7.33266C9.04716 6.94991 8.68189 6.6332 8.26243 6.40403C7.84297 6.17486 7.37913 6.03858 6.90237 6.00444C6.4256 5.97029 5.94708 6.03908 5.49924 6.20614C5.0514 6.3732 4.64472 6.63461 4.3068 6.97266L2.3068 8.97266C1.69961 9.60133 1.36363 10.4433 1.37122 11.3173C1.37881 12.1913 1.72938 13.0274 2.3474 13.6454C2.96543 14.2634 3.80147 14.614 4.67546 14.6216C5.54945 14.6292 6.39146 14.2932 7.02013 13.686L8.16013 12.546"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2452_193804">
|
||||
<rect width="16" height="16" fill="textColor"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-start text-[16px] group-[.sidebar]:hidden">
|
||||
{t('add_channel', 'Add Channel')}
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const ApiModal: FC<{
|
||||
|
|
@ -307,6 +345,7 @@ export const AddProviderComponent: FC<{
|
|||
identifier: string;
|
||||
name: string;
|
||||
}>;
|
||||
invite: boolean;
|
||||
update?: () => void;
|
||||
onboarding?: boolean;
|
||||
}> = (props) => {
|
||||
|
|
@ -318,6 +357,7 @@ export const AddProviderComponent: FC<{
|
|||
const modal = useModals();
|
||||
const getSocialLink = useCallback(
|
||||
(
|
||||
invite: boolean,
|
||||
identifier: string,
|
||||
isExternal: boolean,
|
||||
isWeb3: boolean,
|
||||
|
|
@ -336,7 +376,11 @@ export const AddProviderComponent: FC<{
|
|||
(item) => item.identifier === identifier
|
||||
)!;
|
||||
const { url } = await (
|
||||
await fetch(`/integrations/social/${identifier}${onboarding ? '?onboarding=true' : ''}`)
|
||||
await fetch(
|
||||
`/integrations/social/${identifier}${
|
||||
onboarding ? '?onboarding=true' : ''
|
||||
}`
|
||||
)
|
||||
).json();
|
||||
modal.openModal({
|
||||
title: t('web3_provider', 'Web3 provider'),
|
||||
|
|
@ -347,7 +391,9 @@ export const AddProviderComponent: FC<{
|
|||
children: (
|
||||
<Web3Providers
|
||||
onComplete={(code, newState) => {
|
||||
window.location.href = `/integrations/social/${identifier}?code=${code}&state=${newState}${onboarding ? '&onboarding=true' : ''}`;
|
||||
window.location.href = `/integrations/social/${identifier}?code=${code}&state=${newState}${
|
||||
onboarding ? '&onboarding=true' : ''
|
||||
}`;
|
||||
}}
|
||||
nonce={url}
|
||||
/>
|
||||
|
|
@ -359,16 +405,35 @@ export const AddProviderComponent: FC<{
|
|||
const params = [
|
||||
externalUrl ? `externalUrl=${externalUrl}` : '',
|
||||
onboardingParam,
|
||||
].filter(Boolean).join('&');
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('&');
|
||||
const { url, err } = await (
|
||||
await fetch(
|
||||
`/integrations/social/${identifier}${params ? `?${params}` : ''}`
|
||||
)
|
||||
).json();
|
||||
if (err) {
|
||||
toaster.show(t('could_not_connect_to_platform', 'Could not connect to the platform'), 'warning');
|
||||
toaster.show(
|
||||
t(
|
||||
'could_not_connect_to_platform',
|
||||
'Could not connect to the platform'
|
||||
),
|
||||
'warning'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (invite) {
|
||||
toaster.show(
|
||||
'Invite link copied to clipboard, link will be available for 1 hour',
|
||||
'success'
|
||||
);
|
||||
modal.closeAll();
|
||||
copy(url);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = url;
|
||||
};
|
||||
if (isWeb3) {
|
||||
|
|
@ -430,56 +495,74 @@ export const AddProviderComponent: FC<{
|
|||
return (
|
||||
<div className="w-full flex flex-col gap-[20px] rounded-[4px] relative]">
|
||||
<div className="flex flex-col">
|
||||
<div className={clsx("grid grid-cols-5 gap-[10px] justify-items-center justify-center", onboarding ? 'grid-cols-9' : 'grid-cols-5')}>
|
||||
{social.map((item) => (
|
||||
<div
|
||||
key={item.identifier}
|
||||
onClick={getSocialLink(
|
||||
item.identifier,
|
||||
item.isExternal,
|
||||
item.isWeb3,
|
||||
item.customFields
|
||||
)}
|
||||
{...(!!item.toolTip
|
||||
? {
|
||||
'data-tooltip-id': 'tooltip',
|
||||
'data-tooltip-content': item.toolTip,
|
||||
}
|
||||
: {})}
|
||||
className={
|
||||
'w-full h-[100px] text-[14px] p-[10px] rounded-[8px] bg-newTableHeader text-textColor relative justify-center items-center flex flex-col gap-[10px] cursor-pointer'
|
||||
<div
|
||||
className={clsx(
|
||||
'grid grid-cols-5 gap-[10px] justify-items-center justify-center',
|
||||
onboarding ? 'grid-cols-9' : 'grid-cols-5'
|
||||
)}
|
||||
>
|
||||
{social
|
||||
.filter((item) => {
|
||||
if (!props.invite) {
|
||||
return true;
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{item.identifier === 'youtube' ? (
|
||||
<img src={`/icons/platforms/youtube.svg`} />
|
||||
) : (
|
||||
<img
|
||||
className={clsx("w-[32px] h-[32px]", item.identifier !== 'google_my_business' && 'rounded-full')}
|
||||
src={`/icons/platforms/${item.identifier}.png`}
|
||||
/>
|
||||
|
||||
return !item.isExternal && !item.isWeb3 && !item.customFields;
|
||||
})
|
||||
.map((item) => (
|
||||
<div
|
||||
key={item.identifier}
|
||||
onClick={getSocialLink(
|
||||
props.invite,
|
||||
item.identifier,
|
||||
item.isExternal,
|
||||
item.isWeb3,
|
||||
item.customFields
|
||||
)}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-center">
|
||||
{item.name}
|
||||
{!!item.toolTip && (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 26 26"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="absolute top-[10px] end-[10px]"
|
||||
>
|
||||
<path
|
||||
d="M13 0C10.4288 0 7.91543 0.762437 5.77759 2.1909C3.63975 3.61935 1.97351 5.64968 0.989572 8.02512C0.0056327 10.4006 -0.251811 13.0144 0.249797 15.5362C0.751405 18.0579 1.98953 20.3743 3.80762 22.1924C5.6257 24.0105 7.94208 25.2486 10.4638 25.7502C12.9856 26.2518 15.5995 25.9944 17.9749 25.0104C20.3503 24.0265 22.3807 22.3603 23.8091 20.2224C25.2376 18.0846 26 15.5712 26 13C25.9964 9.5533 24.6256 6.24882 22.1884 3.81163C19.7512 1.37445 16.4467 0.00363977 13 0ZM13 21C12.7033 21 12.4133 20.912 12.1667 20.7472C11.92 20.5824 11.7277 20.3481 11.6142 20.074C11.5007 19.7999 11.471 19.4983 11.5288 19.2074C11.5867 18.9164 11.7296 18.6491 11.9393 18.4393C12.1491 18.2296 12.4164 18.0867 12.7074 18.0288C12.9983 17.9709 13.2999 18.0007 13.574 18.1142C13.8481 18.2277 14.0824 18.42 14.2472 18.6666C14.412 18.9133 14.5 19.2033 14.5 19.5C14.5 19.8978 14.342 20.2794 14.0607 20.5607C13.7794 20.842 13.3978 21 13 21ZM14 14.91V15C14 15.2652 13.8946 15.5196 13.7071 15.7071C13.5196 15.8946 13.2652 16 13 16C12.7348 16 12.4804 15.8946 12.2929 15.7071C12.1054 15.5196 12 15.2652 12 15V14C12 13.7348 12.1054 13.4804 12.2929 13.2929C12.4804 13.1054 12.7348 13 13 13C14.6538 13 16 11.875 16 10.5C16 9.125 14.6538 8 13 8C11.3463 8 10 9.125 10 10.5V11C10 11.2652 9.89465 11.5196 9.70711 11.7071C9.51958 11.8946 9.26522 12 9.00001 12C8.73479 12 8.48044 11.8946 8.2929 11.7071C8.10536 11.5196 8.00001 11.2652 8.00001 11V10.5C8.00001 8.01875 10.2425 6 13 6C15.7575 6 18 8.01875 18 10.5C18 12.6725 16.28 14.4913 14 14.91Z"
|
||||
fill="currentColor"
|
||||
{...(!!item.toolTip
|
||||
? {
|
||||
'data-tooltip-id': 'tooltip',
|
||||
'data-tooltip-content': item.toolTip,
|
||||
}
|
||||
: {})}
|
||||
className={
|
||||
'w-full h-[100px] text-[14px] p-[10px] rounded-[8px] bg-newTableHeader text-textColor relative justify-center items-center flex flex-col gap-[10px] cursor-pointer'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{item.identifier === 'youtube' ? (
|
||||
<img src={`/icons/platforms/youtube.svg`} />
|
||||
) : (
|
||||
<img
|
||||
className={clsx(
|
||||
'w-[32px] h-[32px]',
|
||||
item.identifier !== 'google_my_business' &&
|
||||
'rounded-full'
|
||||
)}
|
||||
src={`/icons/platforms/${item.identifier}.png`}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-center">
|
||||
{item.name}
|
||||
{!!item.toolTip && (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 26 26"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="absolute top-[10px] end-[10px]"
|
||||
>
|
||||
<path
|
||||
d="M13 0C10.4288 0 7.91543 0.762437 5.77759 2.1909C3.63975 3.61935 1.97351 5.64968 0.989572 8.02512C0.0056327 10.4006 -0.251811 13.0144 0.249797 15.5362C0.751405 18.0579 1.98953 20.3743 3.80762 22.1924C5.6257 24.0105 7.94208 25.2486 10.4638 25.7502C12.9856 26.2518 15.5995 25.9944 17.9749 25.0104C20.3503 24.0265 22.3807 22.3603 23.8091 20.2224C25.2376 18.0846 26 15.5712 26 13C25.9964 9.5533 24.6256 6.24882 22.1884 3.81163C19.7512 1.37445 16.4467 0.00363977 13 0ZM13 21C12.7033 21 12.4133 20.912 12.1667 20.7472C11.92 20.5824 11.7277 20.3481 11.6142 20.074C11.5007 19.7999 11.471 19.4983 11.5288 19.2074C11.5867 18.9164 11.7296 18.6491 11.9393 18.4393C12.1491 18.2296 12.4164 18.0867 12.7074 18.0288C12.9983 17.9709 13.2999 18.0007 13.574 18.1142C13.8481 18.2277 14.0824 18.42 14.2472 18.6666C14.412 18.9133 14.5 19.2033 14.5 19.5C14.5 19.8978 14.342 20.2794 14.0607 20.5607C13.7794 20.842 13.3978 21 13 21ZM14 14.91V15C14 15.2652 13.8946 15.5196 13.7071 15.7071C13.5196 15.8946 13.2652 16 13 16C12.7348 16 12.4804 15.8946 12.2929 15.7071C12.1054 15.5196 12 15.2652 12 15V14C12 13.7348 12.1054 13.4804 12.2929 13.2929C12.4804 13.1054 12.7348 13 13 13C14.6538 13 16 11.875 16 10.5C16 9.125 14.6538 8 13 8C11.3463 8 10 9.125 10 10.5V11C10 11.2652 9.89465 11.5196 9.70711 11.7071C9.51958 11.8946 9.26522 12 9.00001 12C8.73479 12 8.48044 11.8946 8.2929 11.7071C8.10536 11.5196 8.00001 11.2652 8.00001 11V10.5C8.00001 8.01875 10.2425 6 13 6C15.7575 6 18 8.01875 18 10.5C18 12.6725 16.28 14.4913 14 14.91Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!isGeneral && (
|
||||
|
|
|
|||
|
|
@ -1,56 +1,121 @@
|
|||
'use client';
|
||||
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { HttpStatusCode } from 'axios';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Redirect } from '@gitroom/frontend/components/layout/redirect';
|
||||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
||||
import dayjs from 'dayjs';
|
||||
import { continueProviderList } from '@gitroom/frontend/components/new-launch/providers/continue-provider/list';
|
||||
import { IntegrationContext } from '@gitroom/frontend/components/launches/helpers/use.integration';
|
||||
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
|
||||
|
||||
interface TwoStepState {
|
||||
integrationId: string;
|
||||
onboarding: boolean;
|
||||
pages: any[];
|
||||
returnURL?: string;
|
||||
}
|
||||
|
||||
interface SuccessState {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const ContinueIntegration: FC<{
|
||||
provider: string;
|
||||
searchParams: any;
|
||||
logged: boolean;
|
||||
}> = (props) => {
|
||||
const { provider, searchParams } = props;
|
||||
const { provider, searchParams, logged } = props;
|
||||
const { push } = useRouter();
|
||||
const t = useT();
|
||||
const fetch = useFetch();
|
||||
const [error, setError] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [twoStepState, setTwoStepState] = useState<TwoStepState | null>(null);
|
||||
const [successState, setSuccessState] = useState<SuccessState | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Helper to handle navigation - redirects if logged or returnURL exists, otherwise shows inline
|
||||
const navigateOrShow = useCallback(
|
||||
(
|
||||
path: string,
|
||||
returnURL: string | undefined,
|
||||
successMessage: string
|
||||
) => {
|
||||
if (returnURL) {
|
||||
// If returnURL exists, always redirect to it with the path params
|
||||
const params = path.includes('?') ? path.split('?')[1] : '';
|
||||
push(params ? `${returnURL}?${params}` : returnURL);
|
||||
} else if (logged) {
|
||||
// If logged in without returnURL, use normal navigation
|
||||
push(path);
|
||||
} else {
|
||||
// If not logged in without returnURL, show success inline
|
||||
setSuccessState({ message: successMessage });
|
||||
}
|
||||
},
|
||||
[logged, push]
|
||||
);
|
||||
const modifiedParams = useMemo(() => {
|
||||
if (provider === 'x') {
|
||||
return {
|
||||
state: searchParams.oauth_token || '',
|
||||
code: searchParams.oauth_verifier || '',
|
||||
refresh: searchParams.refresh || '',
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'vk') {
|
||||
return {
|
||||
...searchParams,
|
||||
state: searchParams.state || '',
|
||||
code: searchParams.code + '&&&&' + searchParams.device_id,
|
||||
};
|
||||
}
|
||||
|
||||
return searchParams;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const timezone = String(dayjs.tz().utcOffset());
|
||||
const modifiedParams = { ...searchParams };
|
||||
if (provider === 'x') {
|
||||
Object.assign(modifiedParams, {
|
||||
state: searchParams.oauth_token || '',
|
||||
code: searchParams.oauth_verifier || '',
|
||||
refresh: searchParams.refresh || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (provider === 'vk') {
|
||||
Object.assign(modifiedParams, {
|
||||
...searchParams,
|
||||
state: searchParams.state || '',
|
||||
code: searchParams.code + '&&&&' + searchParams.device_id,
|
||||
});
|
||||
}
|
||||
|
||||
const data = await fetch(`/integrations/social/${provider}/connect`, {
|
||||
// Try public endpoint first (handles both public and fallback scenarios)
|
||||
let data = await fetch(`/integrations/social-connect/${provider}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...modifiedParams, timezone }),
|
||||
});
|
||||
|
||||
// If public endpoint fails with specific errors, try authenticated endpoint
|
||||
if (data.status === HttpStatusCode.BadRequest) {
|
||||
const errorData = await data.json().catch(() => ({}));
|
||||
// "Invalid connection type" means this wasn't started as a public flow
|
||||
if (
|
||||
errorData.message?.includes('Invalid connection type') ||
|
||||
errorData.message?.includes('Invalid or expired state')
|
||||
) {
|
||||
data = await fetch(`/integrations/social-connect/${provider}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...modifiedParams, timezone }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.status === HttpStatusCode.PreconditionFailed) {
|
||||
push(`/launches?precondition=true`);
|
||||
return ;
|
||||
const { returnURL } = await data.json().catch(() => ({}));
|
||||
navigateOrShow(
|
||||
`/launches?precondition=true`,
|
||||
returnURL,
|
||||
'Precondition failed'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === HttpStatusCode.NotAcceptable) {
|
||||
const { msg } = await data.json();
|
||||
push(`/launches?msg=${msg}`);
|
||||
const { msg, returnURL } = await data.json();
|
||||
navigateOrShow(`/launches?msg=${msg}`, returnURL, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -58,28 +123,259 @@ export const ContinueIntegration: FC<{
|
|||
data.status !== HttpStatusCode.Ok &&
|
||||
data.status !== HttpStatusCode.Created
|
||||
) {
|
||||
const errorData = await data.json().catch(() => ({}));
|
||||
setErrorMessage(errorData.message || errorData.msg || 'Could not add provider');
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const { inBetweenSteps, id, onboarding: resOnboarding } = await data.json();
|
||||
const {
|
||||
inBetweenSteps,
|
||||
id,
|
||||
onboarding: resOnboarding,
|
||||
pages,
|
||||
returnURL,
|
||||
} = await data.json();
|
||||
const onboarding = resOnboarding || searchParams.onboarding === 'true';
|
||||
|
||||
// If it's a two-step provider, show the selection UI inline
|
||||
if (inBetweenSteps && !searchParams.refresh) {
|
||||
push(`/launches?added=${provider}&continue=${id}${onboarding ? '&onboarding=true' : ''}`);
|
||||
setTwoStepState({
|
||||
integrationId: id,
|
||||
onboarding,
|
||||
pages: pages || [],
|
||||
returnURL,
|
||||
});
|
||||
return;
|
||||
}
|
||||
push(`/launches?added=${provider}&msg=Channel Updated${onboarding ? '&onboarding=true' : ''}`);
|
||||
})();
|
||||
}, [provider, searchParams]);
|
||||
|
||||
return error ? (
|
||||
<>
|
||||
<div className="mt-[50px] text-[50px]">
|
||||
{t('could_not_add_provider', 'Could not add provider.')}
|
||||
<br />
|
||||
{t('you_are_being_redirected_back', 'You are being redirected back')}
|
||||
navigateOrShow(
|
||||
`/launches?added=${provider}&msg=Channel Updated${
|
||||
onboarding ? '&onboarding=true' : ''
|
||||
}`,
|
||||
returnURL,
|
||||
'Channel Updated'
|
||||
);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const onSave = useCallback(
|
||||
async (data: any) => {
|
||||
if (!twoStepState) return;
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
// Use public or authenticated endpoint based on the flow
|
||||
const endpoint = `/integrations/provider/${twoStepState.integrationId}/connect`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...modifiedParams, ...data }),
|
||||
});
|
||||
|
||||
if (
|
||||
response.status !== HttpStatusCode.Ok &&
|
||||
response.status !== HttpStatusCode.Created
|
||||
) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
setErrorMessage(
|
||||
errorData.message || 'Failed to save channel configuration'
|
||||
);
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
navigateOrShow(
|
||||
`/launches?added=${provider}&msg=Channel Added${
|
||||
twoStepState.onboarding ? '&onboarding=true' : ''
|
||||
}`,
|
||||
twoStepState.returnURL,
|
||||
'Channel Added'
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[twoStepState, fetch, modifiedParams, provider, navigateOrShow]
|
||||
);
|
||||
|
||||
const Provider = useMemo(() => {
|
||||
return (
|
||||
continueProviderList[provider as keyof typeof continueProviderList] ||
|
||||
null
|
||||
);
|
||||
}, [provider]);
|
||||
|
||||
const providerDisplayName = useMemo(() => {
|
||||
const names: Record<string, string> = {
|
||||
facebook: 'Facebook',
|
||||
instagram: 'Instagram',
|
||||
'linkedin-page': 'LinkedIn',
|
||||
youtube: 'YouTube',
|
||||
gmb: 'Google Business',
|
||||
};
|
||||
return names[provider] || provider;
|
||||
}, [provider]);
|
||||
|
||||
// Success state for non-logged users without returnURL
|
||||
if (successState) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-white relative overflow-hidden">
|
||||
{/* Background gradient decoration */}
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-[20%] left-[10%] w-[300px] h-[300px] bg-[#612BD3] rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[20%] right-[10%] w-[250px] h-[250px] bg-[#FC69FF] rounded-full blur-[120px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 text-center">
|
||||
<div className="w-[80px] h-[80px] mx-auto mb-[24px] rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-[40px] h-[40px] text-green-500"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-[28px] font-semibold mb-[12px]">
|
||||
{t('channel_connected', 'Channel Connected!')}
|
||||
</div>
|
||||
<div className="text-[16px] text-gray-400 max-w-[400px]">
|
||||
{successState.message ||
|
||||
t(
|
||||
'channel_connected_description',
|
||||
`Your ${providerDisplayName} channel has been successfully connected. You can close this window now.`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Redirect url="/launches" delay={3000} />
|
||||
</>
|
||||
) : null;
|
||||
);
|
||||
}
|
||||
|
||||
// Show the two-step selection UI
|
||||
if (twoStepState && Provider) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-white relative overflow-hidden">
|
||||
{/* Background gradient decoration */}
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-[20%] left-[10%] w-[300px] h-[300px] bg-[#612BD3] rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[20%] right-[10%] w-[250px] h-[250px] bg-[#FC69FF] rounded-full blur-[120px]" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 w-full max-w-[550px] mx-auto px-[20px]">
|
||||
<div className="bg-[#1A1919] rounded-[16px] p-[32px] flex flex-col gap-[24px]">
|
||||
<div className="flex flex-col gap-[8px] text-center">
|
||||
<h1 className="text-[24px] font-semibold">
|
||||
{t('configure_your_channel', 'Configure Your Channel')}
|
||||
</h1>
|
||||
<p className="text-[14px] text-gray-400">
|
||||
{t(
|
||||
'select_the_page_or_account',
|
||||
`Select the ${providerDisplayName} page or account you want to connect.`
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<IntegrationContext.Provider
|
||||
value={{
|
||||
date: newDayjs(),
|
||||
value: [],
|
||||
allIntegrations: [],
|
||||
integration: {
|
||||
editor: 'normal',
|
||||
additionalSettings: '',
|
||||
display: '',
|
||||
time: [{ time: 0 }],
|
||||
id: twoStepState.integrationId,
|
||||
type: '',
|
||||
name: '',
|
||||
picture: '',
|
||||
inBetweenSteps: true,
|
||||
changeNickName: false,
|
||||
changeProfilePicture: false,
|
||||
identifier: provider,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Provider
|
||||
onSave={onSave}
|
||||
existingId={[]}
|
||||
initialData={twoStepState.pages}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</IntegrationContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-white relative overflow-hidden">
|
||||
{/* Background gradient decoration */}
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-[20%] left-[10%] w-[300px] h-[300px] bg-[#612BD3] rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[20%] right-[10%] w-[250px] h-[250px] bg-[#FC69FF] rounded-full blur-[120px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 text-center">
|
||||
<div className="w-[80px] h-[80px] mx-auto mb-[24px] rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-[40px] h-[40px] text-red-500"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-[28px] font-semibold mb-[12px]">
|
||||
{t('could_not_add_provider', 'Could not add provider')}
|
||||
</div>
|
||||
<div className="text-[16px] text-gray-400 max-w-[400px]">
|
||||
{errorMessage ||
|
||||
t(
|
||||
'you_are_being_redirected_back',
|
||||
'An error occurred. Please try again.'
|
||||
)}
|
||||
</div>
|
||||
{logged && <Redirect url="/launches" delay={3000} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-white relative overflow-hidden">
|
||||
{/* Background gradient decoration */}
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-[20%] left-[10%] w-[300px] h-[300px] bg-[#612BD3] rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[20%] right-[10%] w-[250px] h-[250px] bg-[#FC69FF] rounded-full blur-[120px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 text-center">
|
||||
<div className="text-[28px] font-semibold mb-[12px]">
|
||||
{t('adding_channel', 'Adding Channel')}
|
||||
</div>
|
||||
<div className="text-[16px] text-gray-400">
|
||||
{t('please_wait', 'Please wait while we connect your account...')}
|
||||
</div>
|
||||
{/* Loading spinner */}
|
||||
<div className="mt-[32px] flex justify-center">
|
||||
<div className="w-[48px] h-[48px] border-[3px] border-[#612BD3] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ export const NewPost = () => {
|
|||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex-1 text-start text-[16px] group-[.sidebar]:hidden">
|
||||
<div className="flex-1 text-start text-[14px] group-[.sidebar]:hidden">
|
||||
{t('create_new_post', 'Create Post')}
|
||||
</div>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,112 +1,47 @@
|
|||
'use client';
|
||||
|
||||
import { FC, useCallback, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import clsx from 'clsx';
|
||||
import { Button } from '@gitroom/react/form/button';
|
||||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
|
||||
import { withContinueProvider } from '../with-continue-provider';
|
||||
|
||||
export const FacebookContinue: FC<{
|
||||
onSave: (data: any) => Promise<void>;
|
||||
existingId: string[];
|
||||
}> = (props) => {
|
||||
const { onSave, existingId } = props;
|
||||
const call = useCustomProviderFunction();
|
||||
const [page, setSelectedPage] = useState<null | string>(null);
|
||||
const loadPages = useCallback(async () => {
|
||||
try {
|
||||
const pages = await call.get('pages');
|
||||
return pages;
|
||||
} catch (e) {
|
||||
// Handle error silently
|
||||
}
|
||||
}, []);
|
||||
const setPage = useCallback(
|
||||
(id: string) => () => {
|
||||
setSelectedPage(id);
|
||||
interface FacebookItem {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
picture: {
|
||||
data: {
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const FacebookContinue = withContinueProvider<FacebookItem, string>({
|
||||
endpoint: 'pages',
|
||||
swrKey: 'load-facebook-pages',
|
||||
titleKey: 'select_page',
|
||||
titleDefault: 'Select Page:',
|
||||
emptyStateMessages: [
|
||||
{
|
||||
key: 'we_couldn_t_find_any_business_connected_to_the_selected_pages',
|
||||
text: "We couldn't find any business connected to the selected pages.",
|
||||
},
|
||||
[]
|
||||
);
|
||||
const { data, isLoading } = useSWR('load-pages', loadPages, {
|
||||
refreshWhenHidden: false,
|
||||
refreshWhenOffline: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnMount: true,
|
||||
revalidateOnReconnect: false,
|
||||
refreshInterval: 0,
|
||||
});
|
||||
const t = useT();
|
||||
|
||||
const saveFacebook = useCallback(async () => {
|
||||
await onSave({ page });
|
||||
}, [onSave, page]);
|
||||
const filteredData = useMemo(() => {
|
||||
return (
|
||||
data?.filter((p: { id: string }) => !existingId.includes(p.id)) || []
|
||||
);
|
||||
}, [data]);
|
||||
if (!isLoading && !data?.length) {
|
||||
return (
|
||||
<div className="text-center flex justify-center items-center text-[18px] leading-[50px] h-[300px]">
|
||||
{t(
|
||||
'we_couldn_t_find_any_business_connected_to_the_selected_pages',
|
||||
"We couldn't find any business connected to the selected pages."
|
||||
)}
|
||||
<br />
|
||||
{t(
|
||||
'we_recommend_you_to_connect_all_the_pages_and_all_the_businesses',
|
||||
'We recommend you to connect all the pages and all the businesses.'
|
||||
)}
|
||||
<br />
|
||||
{t(
|
||||
'please_close_this_dialog_delete_your_integration_and_add_a_new_channel_again',
|
||||
'Please close this dialog, delete your integration and add a new channel\n again.'
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-[20px]">
|
||||
<div>{t('select_page', 'Select Page:')}</div>
|
||||
<div className="grid grid-cols-3 justify-items-center select-none cursor-pointer">
|
||||
{filteredData?.map(
|
||||
(p: {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
picture: {
|
||||
data: {
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
}) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={clsx(
|
||||
'flex flex-col w-full text-center gap-[10px] border border-input p-[10px] hover:bg-seventh',
|
||||
page === p.id && 'bg-seventh'
|
||||
)}
|
||||
onClick={setPage(p.id)}
|
||||
>
|
||||
<div>
|
||||
<img
|
||||
className="w-full"
|
||||
src={p.picture.data.url}
|
||||
alt="profile"
|
||||
/>
|
||||
</div>
|
||||
<div>{p.name}</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
key: 'we_recommend_you_to_connect_all_the_pages_and_all_the_businesses',
|
||||
text: 'We recommend you to connect all the pages and all the businesses.',
|
||||
},
|
||||
{
|
||||
key: 'please_close_this_dialog_delete_your_integration_and_add_a_new_channel_again',
|
||||
text: 'Please close this dialog, delete your integration and add a new channel again.',
|
||||
},
|
||||
],
|
||||
getItemId: (item) => item.id,
|
||||
getSelectionValue: (item) => item.id,
|
||||
transformSaveData: (selection) => ({ page: selection }),
|
||||
isSelected: (item, selection) => selection === item.id,
|
||||
renderItem: (item) => (
|
||||
<>
|
||||
<div>
|
||||
<Button disabled={!page} onClick={saveFacebook}>
|
||||
{t('save', 'Save')}
|
||||
</Button>
|
||||
<img className="w-full" src={item.picture.data.url} alt="profile" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
<div>{item.name}</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,149 +1,81 @@
|
|||
'use client';
|
||||
|
||||
import { FC, useCallback, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import clsx from 'clsx';
|
||||
import { Button } from '@gitroom/react/form/button';
|
||||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
|
||||
import { withContinueProvider } from '../with-continue-provider';
|
||||
|
||||
export const GmbContinue: FC<{
|
||||
onSave: (data: any) => Promise<void>;
|
||||
existingId: string[];
|
||||
}> = (props) => {
|
||||
const { onSave, existingId } = props;
|
||||
const call = useCustomProviderFunction();
|
||||
const [location, setSelectedLocation] = useState<null | {
|
||||
id: string;
|
||||
accountName: string;
|
||||
locationName: string;
|
||||
}>(null);
|
||||
const t = useT();
|
||||
interface GmbItem {
|
||||
id: string;
|
||||
name: string;
|
||||
accountName: string;
|
||||
locationName: string;
|
||||
picture?: {
|
||||
data: {
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const loadPages = useCallback(async () => {
|
||||
try {
|
||||
const pages = await call.get('pages');
|
||||
return pages;
|
||||
} catch (e) {
|
||||
// Handle error silently
|
||||
}
|
||||
}, []);
|
||||
interface GmbSelection {
|
||||
id: string;
|
||||
accountName: string;
|
||||
locationName: string;
|
||||
}
|
||||
|
||||
const setLocation = useCallback(
|
||||
(param: { id: string; accountName: string; locationName: string }) => () => {
|
||||
setSelectedLocation(param);
|
||||
export const GmbContinue = withContinueProvider<GmbItem, GmbSelection>({
|
||||
endpoint: 'pages',
|
||||
swrKey: 'load-gmb-locations',
|
||||
titleKey: 'select_location',
|
||||
titleDefault: 'Select Business Location:',
|
||||
emptyStateMessages: [
|
||||
{
|
||||
key: 'gmb_no_locations_found',
|
||||
text: "We couldn't find any business locations connected to your account.",
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const { data, isLoading } = useSWR('load-gmb-locations', loadPages, {
|
||||
refreshWhenHidden: false,
|
||||
refreshWhenOffline: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnMount: true,
|
||||
revalidateOnReconnect: false,
|
||||
refreshInterval: 0,
|
||||
});
|
||||
|
||||
const saveGmb = useCallback(async () => {
|
||||
await onSave(location);
|
||||
}, [onSave, location]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return (
|
||||
data?.filter((p: { id: string }) => !existingId.includes(p.id)) || []
|
||||
);
|
||||
}, [data, existingId]);
|
||||
|
||||
if (!isLoading && !data?.length) {
|
||||
return (
|
||||
<div className="text-center flex flex-col justify-center items-center text-[18px] leading-[26px] h-[300px]">
|
||||
{t(
|
||||
'gmb_no_locations_found',
|
||||
"We couldn't find any business locations connected to your account."
|
||||
)}
|
||||
<br />
|
||||
<br />
|
||||
{t(
|
||||
'gmb_ensure_business_verified',
|
||||
'Please ensure your business is verified on Google My Business.'
|
||||
)}
|
||||
<br />
|
||||
<br />
|
||||
{t(
|
||||
'gmb_try_again',
|
||||
'Please close this dialog, delete the integration and try again.'
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[20px]">
|
||||
<div>{t('select_location', 'Select Business Location:')}</div>
|
||||
<div className="grid grid-cols-3 justify-items-center select-none cursor-pointer gap-[10px]">
|
||||
{filteredData?.map(
|
||||
(p: {
|
||||
id: string;
|
||||
name: string;
|
||||
accountName: string;
|
||||
locationName: string;
|
||||
picture: {
|
||||
data: {
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
}) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={clsx(
|
||||
'flex flex-col w-full text-center gap-[10px] border border-input p-[10px] hover:bg-seventh rounded-[8px]',
|
||||
location?.id === p.id && 'bg-seventh border-primary'
|
||||
)}
|
||||
onClick={setLocation({
|
||||
id: p.id,
|
||||
accountName: p.accountName,
|
||||
locationName: p.locationName,
|
||||
})}
|
||||
{
|
||||
key: 'gmb_ensure_business_verified',
|
||||
text: 'Please ensure your business is verified on Google My Business.',
|
||||
},
|
||||
{
|
||||
key: 'gmb_try_again',
|
||||
text: 'Please close this dialog, delete the integration and try again.',
|
||||
},
|
||||
],
|
||||
getItemId: (item) => item.id,
|
||||
getSelectionValue: (item) => ({
|
||||
id: item.id,
|
||||
accountName: item.accountName,
|
||||
locationName: item.locationName,
|
||||
}),
|
||||
transformSaveData: (selection) => selection,
|
||||
isSelected: (item, selection) => selection?.id === item.id,
|
||||
renderItem: (item) => (
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
{item.picture?.data?.url ? (
|
||||
<img
|
||||
className="w-[80px] h-[80px] object-cover rounded-[8px]"
|
||||
src={item.picture.data.url}
|
||||
alt={item.name}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[80px] h-[80px] bg-input rounded-[8px] flex items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
{p.picture?.data?.url ? (
|
||||
<img
|
||||
className="w-[80px] h-[80px] object-cover rounded-[8px]"
|
||||
src={p.picture.data.url}
|
||||
alt={p.name}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[80px] h-[80px] bg-input rounded-[8px] flex items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium">{p.name}</div>
|
||||
</div>
|
||||
)
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Button disabled={!location} onClick={saveGmb}>
|
||||
{t('save', 'Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
<div className="text-sm font-medium">{item.name}</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,116 +1,60 @@
|
|||
'use client';
|
||||
|
||||
import { FC, useCallback, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import clsx from 'clsx';
|
||||
import { Button } from '@gitroom/react/form/button';
|
||||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
|
||||
import { withContinueProvider } from '../with-continue-provider';
|
||||
|
||||
export const InstagramContinue: FC<{
|
||||
onSave: (data: any) => Promise<void>;
|
||||
existingId: string[];
|
||||
}> = (props) => {
|
||||
const { onSave, existingId } = props;
|
||||
const call = useCustomProviderFunction();
|
||||
const [page, setSelectedPage] = useState<null | {
|
||||
id: string;
|
||||
pageId: string;
|
||||
}>(null);
|
||||
const loadPages = useCallback(async () => {
|
||||
try {
|
||||
const pages = await call.get('pages');
|
||||
return pages;
|
||||
} catch (e) {
|
||||
// Handle error silently
|
||||
}
|
||||
}, []);
|
||||
const t = useT();
|
||||
interface InstagramItem {
|
||||
id: string;
|
||||
pageId: string;
|
||||
username: string;
|
||||
name: string;
|
||||
picture: {
|
||||
data: {
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const setPage = useCallback(
|
||||
(param: { id: string; pageId: string }) => () => {
|
||||
setSelectedPage(param);
|
||||
interface InstagramSelection {
|
||||
id: string;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export const InstagramContinue = withContinueProvider<
|
||||
InstagramItem,
|
||||
InstagramSelection
|
||||
>({
|
||||
endpoint: 'pages',
|
||||
swrKey: 'load-instagram-pages',
|
||||
titleKey: 'select_instagram_account',
|
||||
titleDefault: 'Select Instagram Account:',
|
||||
emptyStateMessages: [
|
||||
{
|
||||
key: 'we_couldn_t_find_any_business_connected_to_the_selected_pages',
|
||||
text: "We couldn't find any business connected to the selected pages.",
|
||||
},
|
||||
[]
|
||||
);
|
||||
const { data, isLoading } = useSWR('load-pages', loadPages, {
|
||||
refreshWhenHidden: false,
|
||||
refreshWhenOffline: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnMount: true,
|
||||
revalidateOnReconnect: false,
|
||||
refreshInterval: 0,
|
||||
});
|
||||
const saveInstagram = useCallback(async () => {
|
||||
await onSave(page);
|
||||
}, [onSave, page]);
|
||||
const filteredData = useMemo(() => {
|
||||
return (
|
||||
data?.filter((p: { id: string }) => !existingId.includes(p.id)) || []
|
||||
);
|
||||
}, [data]);
|
||||
if (!isLoading && !data?.length) {
|
||||
return (
|
||||
<div className="text-center flex justify-center items-center text-[18px] leading-[50px] h-[300px]">
|
||||
{t(
|
||||
'we_couldn_t_find_any_business_connected_to_the_selected_pages',
|
||||
"We couldn't find any business connected to the selected pages."
|
||||
)}
|
||||
<br />
|
||||
{t(
|
||||
'we_recommend_you_to_connect_all_the_pages_and_all_the_businesses',
|
||||
'We recommend you to connect all the pages and all the businesses.'
|
||||
)}
|
||||
<br />
|
||||
{t(
|
||||
'please_close_this_dialog_delete_your_integration_and_add_a_new_channel_again',
|
||||
'Please close this dialog, delete your integration and add a new channel\n again.'
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-[20px]">
|
||||
<div>{t('select_instagram_account', 'Select Instagram Account:')}</div>
|
||||
<div className="grid grid-cols-3 justify-items-center select-none cursor-pointer">
|
||||
{filteredData?.map(
|
||||
(p: {
|
||||
id: string;
|
||||
pageId: string;
|
||||
username: string;
|
||||
name: string;
|
||||
picture: {
|
||||
data: {
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
}) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={clsx(
|
||||
'flex flex-col w-full text-center gap-[10px] border border-input p-[10px] hover:bg-seventh',
|
||||
page?.id === p.id && 'bg-seventh'
|
||||
)}
|
||||
onClick={setPage(p)}
|
||||
>
|
||||
<div>
|
||||
<img
|
||||
className="w-full max-w-[156px]"
|
||||
src={p.picture.data.url}
|
||||
alt="profile"
|
||||
/>
|
||||
</div>
|
||||
<div>{p.name}</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
key: 'we_recommend_you_to_connect_all_the_pages_and_all_the_businesses',
|
||||
text: 'We recommend you to connect all the pages and all the businesses.',
|
||||
},
|
||||
{
|
||||
key: 'please_close_this_dialog_delete_your_integration_and_add_a_new_channel_again',
|
||||
text: 'Please close this dialog, delete your integration and add a new channel again.',
|
||||
},
|
||||
],
|
||||
getItemId: (item) => item.id,
|
||||
getSelectionValue: (item) => ({ id: item.id, pageId: item.pageId }),
|
||||
transformSaveData: (selection) => selection,
|
||||
isSelected: (item, selection) => selection?.id === item.id,
|
||||
renderItem: (item) => (
|
||||
<>
|
||||
<div>
|
||||
<Button disabled={!page} onClick={saveInstagram}>
|
||||
{t('save', 'Save')}
|
||||
</Button>
|
||||
<img
|
||||
className="w-full max-w-[156px]"
|
||||
src={item.picture.data.url}
|
||||
alt="profile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
<div>{item.name}</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,103 +1,48 @@
|
|||
'use client';
|
||||
|
||||
import { FC, useCallback, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import clsx from 'clsx';
|
||||
import { Button } from '@gitroom/react/form/button';
|
||||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
|
||||
import { withContinueProvider } from '../with-continue-provider';
|
||||
|
||||
export const LinkedinContinue: FC<{
|
||||
onSave: (data: any) => Promise<void>;
|
||||
existingId: string[];
|
||||
}> = (props) => {
|
||||
const { onSave, existingId } = props;
|
||||
const t = useT();
|
||||
interface LinkedinItem {
|
||||
id: string;
|
||||
pageId: string;
|
||||
username: string;
|
||||
name: string;
|
||||
picture: string;
|
||||
}
|
||||
|
||||
const call = useCustomProviderFunction();
|
||||
const [page, setSelectedPage] = useState<null | {
|
||||
id: string;
|
||||
pageId: string;
|
||||
}>(null);
|
||||
const loadPages = useCallback(async () => {
|
||||
try {
|
||||
const pages = await call.get('companies');
|
||||
return pages;
|
||||
} catch (e) {
|
||||
// Handle error silently
|
||||
}
|
||||
}, []);
|
||||
const setPage = useCallback(
|
||||
(param: { id: string; pageId: string }) => () => {
|
||||
setSelectedPage(param);
|
||||
interface LinkedinSelection {
|
||||
id: string;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export const LinkedinContinue = withContinueProvider<
|
||||
LinkedinItem,
|
||||
LinkedinSelection
|
||||
>({
|
||||
endpoint: 'companies',
|
||||
swrKey: 'load-linkedin-pages',
|
||||
titleKey: 'select_linkedin_page',
|
||||
titleDefault: 'Select Linkedin Page:',
|
||||
emptyStateMessages: [
|
||||
{
|
||||
key: 'we_couldn_t_find_any_business_connected_to_your_linkedin_page',
|
||||
text: "We couldn't find any business connected to your LinkedIn Page.",
|
||||
},
|
||||
[]
|
||||
);
|
||||
const { data, isLoading } = useSWR('load-pages', loadPages, {
|
||||
refreshWhenHidden: false,
|
||||
refreshWhenOffline: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnMount: true,
|
||||
revalidateOnReconnect: false,
|
||||
refreshInterval: 0,
|
||||
});
|
||||
const saveLinkedin = useCallback(async () => {
|
||||
await onSave({ page: page?.id });
|
||||
}, [onSave, page]);
|
||||
const filteredData = useMemo(() => {
|
||||
return (
|
||||
data?.filter((p: { id: string }) => !existingId.includes(p.id)) || []
|
||||
);
|
||||
}, [data]);
|
||||
if (!isLoading && !data?.length) {
|
||||
return (
|
||||
<div className="text-center flex justify-center items-center text-[18px] leading-[50px] h-[300px]">
|
||||
{t(
|
||||
'we_couldn_t_find_any_business_connected_to_your_linkedin_page',
|
||||
"We couldn't find any business connected to your LinkedIn Page."
|
||||
)}
|
||||
<br />
|
||||
{t(
|
||||
'please_close_this_dialog_create_a_new_page_and_add_a_new_channel_again',
|
||||
'Please close this dialog, create a new page, and add a new channel again.'
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-[20px]">
|
||||
<div>{t('select_linkedin_page', 'Select Linkedin Page:')}</div>
|
||||
<div className="grid grid-cols-3 justify-items-center select-none cursor-pointer">
|
||||
{filteredData?.map(
|
||||
(p: {
|
||||
id: string;
|
||||
pageId: string;
|
||||
username: string;
|
||||
name: string;
|
||||
picture: string;
|
||||
}) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={clsx(
|
||||
'flex flex-col w-full text-center gap-[10px] border border-input p-[10px] hover:bg-seventh',
|
||||
page?.id === p.id && 'bg-seventh'
|
||||
)}
|
||||
onClick={setPage(p)}
|
||||
>
|
||||
<div>
|
||||
<img className="w-full" src={p.picture} alt="profile" />
|
||||
</div>
|
||||
<div>{p.name}</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
key: 'please_close_this_dialog_create_a_new_page_and_add_a_new_channel_again',
|
||||
text: 'Please close this dialog, create a new page, and add a new channel again.',
|
||||
},
|
||||
],
|
||||
getItemId: (item) => item.id,
|
||||
getSelectionValue: (item) => ({ id: item.id, pageId: item.pageId }),
|
||||
transformSaveData: (selection) => ({ page: selection.id }),
|
||||
isSelected: (item, selection) => selection?.id === item.id,
|
||||
renderItem: (item) => (
|
||||
<>
|
||||
<div>
|
||||
<Button disabled={!page} onClick={saveLinkedin}>
|
||||
{t('save', 'Save')}
|
||||
</Button>
|
||||
<img className="w-full" src={item.picture} alt="profile" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
<div>{item.name}</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
'use client';
|
||||
|
||||
import { FC, ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import clsx from 'clsx';
|
||||
import { Button } from '@gitroom/react/form/button';
|
||||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
|
||||
|
||||
const SWR_OPTIONS = {
|
||||
refreshWhenHidden: false,
|
||||
refreshWhenOffline: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnMount: true,
|
||||
revalidateOnReconnect: false,
|
||||
refreshInterval: 0,
|
||||
};
|
||||
|
||||
export interface ContinueProviderProps {
|
||||
onSave: (data: any) => Promise<void>;
|
||||
existingId: string[];
|
||||
initialData?: any[];
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
export interface EmptyStateMessage {
|
||||
key: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ContinueProviderConfig<TItem, TSelection> {
|
||||
endpoint: string;
|
||||
swrKey: string;
|
||||
titleKey: string;
|
||||
titleDefault: string;
|
||||
emptyStateMessages: EmptyStateMessage[];
|
||||
getSelectionValue: (item: TItem) => TSelection;
|
||||
transformSaveData: (selection: TSelection) => any;
|
||||
renderItem: (item: TItem, isSelected: boolean) => ReactNode;
|
||||
isSelected: (item: TItem, selection: TSelection | null) => boolean;
|
||||
getItemId: (item: TItem) => string;
|
||||
}
|
||||
|
||||
export function withContinueProvider<TItem, TSelection>(
|
||||
config: ContinueProviderConfig<TItem, TSelection>
|
||||
): FC<ContinueProviderProps> {
|
||||
const {
|
||||
endpoint,
|
||||
swrKey,
|
||||
titleKey,
|
||||
titleDefault,
|
||||
emptyStateMessages,
|
||||
getSelectionValue,
|
||||
transformSaveData,
|
||||
renderItem,
|
||||
isSelected,
|
||||
getItemId,
|
||||
} = config;
|
||||
|
||||
return function ContinueProviderComponent(props: ContinueProviderProps) {
|
||||
const { onSave, existingId, initialData, isSaving } = props;
|
||||
const call = useCustomProviderFunction();
|
||||
const t = useT();
|
||||
const [selection, setSelection] = useState<TSelection | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
// Skip fetch if initial data was provided
|
||||
if (initialData) {
|
||||
return initialData;
|
||||
}
|
||||
try {
|
||||
return await call.get(endpoint);
|
||||
} catch (e) {
|
||||
// Handle error silently
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
const { data, isLoading } = useSWR(
|
||||
initialData ? null : swrKey,
|
||||
loadData,
|
||||
SWR_OPTIONS
|
||||
);
|
||||
|
||||
const resolvedData = initialData || data;
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: TItem) => () => {
|
||||
setSelection(getSelectionValue(item));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (selection) {
|
||||
await onSave(transformSaveData(selection));
|
||||
}
|
||||
}, [onSave, selection]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return (
|
||||
(resolvedData as TItem[])?.filter(
|
||||
(item) => !existingId.includes(getItemId(item))
|
||||
) || []
|
||||
);
|
||||
}, [resolvedData, existingId]);
|
||||
|
||||
if (!isLoading && !resolvedData?.length) {
|
||||
return (
|
||||
<div className="text-center flex flex-col justify-center items-center text-[18px] leading-[26px] h-[300px]">
|
||||
{emptyStateMessages.map((msg, index) => (
|
||||
<span key={msg.key}>
|
||||
{t(msg.key, msg.text)}
|
||||
{index < emptyStateMessages.length - 1 && (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[20px]">
|
||||
<div>{t(titleKey, titleDefault)}</div>
|
||||
<div className="grid grid-cols-3 justify-items-center select-none cursor-pointer gap-[10px]">
|
||||
{filteredData.map((item) => (
|
||||
<div
|
||||
key={getItemId(item)}
|
||||
className={clsx(
|
||||
'flex flex-col w-full text-center gap-[10px] border border-input p-[10px] hover:bg-seventh rounded-[8px]',
|
||||
isSelected(item, selection) && 'bg-seventh border-primary'
|
||||
)}
|
||||
onClick={handleSelect(item)}
|
||||
>
|
||||
{renderItem(item, isSelected(item, selection))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<Button disabled={!selection || isSaving} loading={isSaving} onClick={handleSave}>
|
||||
{t('save', 'Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,149 +1,86 @@
|
|||
'use client';
|
||||
|
||||
import { FC, useCallback, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import clsx from 'clsx';
|
||||
import { Button } from '@gitroom/react/form/button';
|
||||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
|
||||
import { withContinueProvider } from '../with-continue-provider';
|
||||
|
||||
export const YoutubeContinue: FC<{
|
||||
onSave: (data: any) => Promise<void>;
|
||||
existingId: string[];
|
||||
}> = (props) => {
|
||||
const { onSave, existingId } = props;
|
||||
const call = useCustomProviderFunction();
|
||||
const [channel, setSelectedChannel] = useState<null | { id: string }>(null);
|
||||
const t = useT();
|
||||
interface YoutubeItem {
|
||||
id: string;
|
||||
name: string;
|
||||
username?: string;
|
||||
subscriberCount?: string;
|
||||
picture?: {
|
||||
data: {
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const loadChannels = useCallback(async () => {
|
||||
try {
|
||||
const channels = await call.get('pages');
|
||||
return channels;
|
||||
} catch (e) {
|
||||
// Handle error silently
|
||||
}
|
||||
}, []);
|
||||
interface YoutubeSelection {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const setChannel = useCallback(
|
||||
(param: { id: string }) => () => {
|
||||
setSelectedChannel(param);
|
||||
export const YoutubeContinue = withContinueProvider<
|
||||
YoutubeItem,
|
||||
YoutubeSelection
|
||||
>({
|
||||
endpoint: 'pages',
|
||||
swrKey: 'load-youtube-channels',
|
||||
titleKey: 'select_channel',
|
||||
titleDefault: 'Select YouTube Channel:',
|
||||
emptyStateMessages: [
|
||||
{
|
||||
key: 'youtube_no_channels_found',
|
||||
text: "We couldn't find any YouTube channels connected to your account.",
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const { data, isLoading } = useSWR('load-youtube-channels', loadChannels, {
|
||||
refreshWhenHidden: false,
|
||||
refreshWhenOffline: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnMount: true,
|
||||
revalidateOnReconnect: false,
|
||||
refreshInterval: 0,
|
||||
});
|
||||
|
||||
const saveYoutube = useCallback(async () => {
|
||||
await onSave(channel);
|
||||
}, [onSave, channel]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return (
|
||||
data?.filter((p: { id: string }) => !existingId.includes(p.id)) || []
|
||||
);
|
||||
}, [data, existingId]);
|
||||
|
||||
if (!isLoading && !data?.length) {
|
||||
return (
|
||||
<div className="text-center flex flex-col justify-center items-center text-[18px] leading-[26px] h-[300px]">
|
||||
{t(
|
||||
'youtube_no_channels_found',
|
||||
"We couldn't find any YouTube channels connected to your account."
|
||||
)}
|
||||
<br />
|
||||
<br />
|
||||
{t(
|
||||
'youtube_ensure_channel_exists',
|
||||
'Please ensure you have a YouTube channel created.'
|
||||
)}
|
||||
<br />
|
||||
<br />
|
||||
{t(
|
||||
'youtube_try_again',
|
||||
'Please close this dialog, delete the integration and try again.'
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[20px]">
|
||||
<div>{t('select_channel', 'Select YouTube Channel:')}</div>
|
||||
<div className="grid grid-cols-3 justify-items-center select-none cursor-pointer gap-[10px]">
|
||||
{filteredData?.map(
|
||||
(p: {
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
subscriberCount: string;
|
||||
picture: {
|
||||
data: {
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
}) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={clsx(
|
||||
'flex flex-col w-full text-center gap-[10px] border border-input p-[10px] hover:bg-seventh rounded-[8px]',
|
||||
channel?.id === p.id && 'bg-seventh border-primary'
|
||||
)}
|
||||
onClick={setChannel({ id: p.id })}
|
||||
{
|
||||
key: 'youtube_ensure_channel_exists',
|
||||
text: 'Please ensure you have a YouTube channel created.',
|
||||
},
|
||||
{
|
||||
key: 'youtube_try_again',
|
||||
text: 'Please close this dialog, delete the integration and try again.',
|
||||
},
|
||||
],
|
||||
getItemId: (item) => item.id,
|
||||
getSelectionValue: (item) => ({ id: item.id }),
|
||||
transformSaveData: (selection) => selection,
|
||||
isSelected: (item, selection) => selection?.id === item.id,
|
||||
renderItem: (item) => (
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
{item.picture?.data?.url ? (
|
||||
<img
|
||||
className="w-[80px] h-[80px] object-cover rounded-full"
|
||||
src={item.picture.data.url}
|
||||
alt={item.name}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[80px] h-[80px] bg-input rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
{p.picture?.data?.url ? (
|
||||
<img
|
||||
className="w-[80px] h-[80px] object-cover rounded-full"
|
||||
src={p.picture.data.url}
|
||||
alt={p.name}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[80px] h-[80px] bg-input rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.33z" />
|
||||
<polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium">{p.name}</div>
|
||||
{p.username && (
|
||||
<div className="text-xs text-gray-500">{p.username}</div>
|
||||
)}
|
||||
{p.subscriberCount && (
|
||||
<div className="text-xs text-gray-400">
|
||||
{parseInt(p.subscriberCount).toLocaleString()} subscribers
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.33z" />
|
||||
<polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Button disabled={!channel} onClick={saveYoutube}>
|
||||
{t('save', 'Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
<div className="text-sm font-medium">{item.name}</div>
|
||||
{item.username && (
|
||||
<div className="text-xs text-gray-500">{item.username}</div>
|
||||
)}
|
||||
{item.subscriberCount && (
|
||||
<div className="text-xs text-gray-400">
|
||||
{parseInt(item.subscriberCount).toLocaleString()} subscribers
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ const OnboardingStep1: FC<{ onNext: () => void; onSkip: () => void }> = ({
|
|||
</div>
|
||||
{data && (
|
||||
<AddProviderComponent
|
||||
invite={false}
|
||||
social={data.social || []}
|
||||
article={data.article || []}
|
||||
onboarding={true}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,14 @@ export async function middleware(request: NextRequest) {
|
|||
) {
|
||||
return topResponse;
|
||||
}
|
||||
|
||||
if (
|
||||
nextUrl.pathname.startsWith('/integrations/social/') &&
|
||||
nextUrl.href.indexOf('state=login') === -1
|
||||
) {
|
||||
return topResponse;
|
||||
}
|
||||
|
||||
// If the URL is logout, delete the cookie and redirect to login
|
||||
if (nextUrl.href.indexOf('/auth/logout') > -1) {
|
||||
const response = NextResponse.redirect(
|
||||
|
|
|
|||
|
|
@ -151,12 +151,31 @@ export class IntegrationRepository {
|
|||
params.picture = await this.storage.uploadSimple(params.picture);
|
||||
}
|
||||
|
||||
const existing = await this._integration.model.integration.findUnique({
|
||||
where: {
|
||||
organizationId_internalId: {
|
||||
organizationId: params.organizationId!,
|
||||
internalId: params.internalId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await this._integration.model.integration.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this._integration.model.integration.update({
|
||||
where: {
|
||||
id,
|
||||
...(existing ? { id: existing.id } : { id }),
|
||||
},
|
||||
data: {
|
||||
...params,
|
||||
disabled: false,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -316,6 +316,7 @@ export class IntegrationService {
|
|||
await this._integrationRepository.updateIntegration(id, {
|
||||
picture: getIntegrationInformation.picture,
|
||||
internalId: String(getIntegrationInformation.id),
|
||||
organizationId: org,
|
||||
name: getIntegrationInformation.name,
|
||||
inBetweenSteps: false,
|
||||
token: getIntegrationInformation.access_token,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@ export class BadBody extends ApplicationFailure {
|
|||
}
|
||||
|
||||
export class NotEnoughScopes {
|
||||
constructor(public message = 'Not enough scopes') {}
|
||||
constructor(
|
||||
public message = 'Not enough scopes, when choosing a provider, please add all the scopes'
|
||||
) {}
|
||||
}
|
||||
|
||||
function safeStringify(obj: any) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue