feat: youtube select page
This commit is contained in:
parent
fd7b755bef
commit
e1225681a9
|
|
@ -569,6 +569,15 @@ export class IntegrationsController {
|
|||
return this._integrationService.saveGmb(org.id, id, body);
|
||||
}
|
||||
|
||||
@Post('/youtube/:id')
|
||||
async saveYoutube(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { id: string },
|
||||
@GetOrgFromRequest() org: Organization
|
||||
) {
|
||||
return this._integrationService.saveYoutube(org.id, id, body);
|
||||
}
|
||||
|
||||
@Post('/enable')
|
||||
enableChannel(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { InstagramContinue } from '@gitroom/frontend/components/new-launch/provi
|
|||
import { FacebookContinue } from '@gitroom/frontend/components/new-launch/providers/continue-provider/facebook/facebook.continue';
|
||||
import { LinkedinContinue } from '@gitroom/frontend/components/new-launch/providers/continue-provider/linkedin/linkedin.continue';
|
||||
import { GmbContinue } from '@gitroom/frontend/components/new-launch/providers/continue-provider/gmb/gmb.continue';
|
||||
import { YoutubeContinue } from '@gitroom/frontend/components/new-launch/providers/continue-provider/youtube/youtube.continue';
|
||||
|
||||
export const continueProviderList = {
|
||||
instagram: InstagramContinue,
|
||||
facebook: FacebookContinue,
|
||||
'linkedin-page': LinkedinContinue,
|
||||
gmb: GmbContinue,
|
||||
youtube: YoutubeContinue,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
'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 { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
||||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
|
||||
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
|
||||
|
||||
export const YoutubeContinue: FC<{
|
||||
closeModal: () => void;
|
||||
existingId: string[];
|
||||
}> = (props) => {
|
||||
const { closeModal, existingId } = props;
|
||||
const call = useCustomProviderFunction();
|
||||
const { integration } = useIntegration();
|
||||
const [channel, setSelectedChannel] = useState<null | { id: string }>(null);
|
||||
const fetch = useFetch();
|
||||
const t = useT();
|
||||
|
||||
const loadChannels = useCallback(async () => {
|
||||
try {
|
||||
const channels = await call.get('pages');
|
||||
return channels;
|
||||
} catch (e) {
|
||||
closeModal();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setChannel = useCallback(
|
||||
(param: { id: string }) => () => {
|
||||
setSelectedChannel(param);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
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 fetch(`/integrations/youtube/${integration?.id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(channel),
|
||||
});
|
||||
closeModal();
|
||||
}, [integration, 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 })}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Button disabled={!channel} onClick={saveYoutube}>
|
||||
{t('save', 'Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -12,6 +12,7 @@ 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 { GmbProvider } from '@gitroom/nestjs-libraries/integrations/social/gmb.provider';
|
||||
import { YoutubeProvider } from '@gitroom/nestjs-libraries/integrations/social/youtube.provider';
|
||||
import dayjs from 'dayjs';
|
||||
import { timer } from '@gitroom/helpers/utils/timer';
|
||||
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
|
||||
|
|
@ -402,6 +403,36 @@ export class IntegrationService {
|
|||
return { success: true };
|
||||
}
|
||||
|
||||
async saveYoutube(org: string, id: string, data: { id: string }) {
|
||||
const getIntegration = await this._integrationRepository.getIntegrationById(
|
||||
org,
|
||||
id
|
||||
);
|
||||
if (getIntegration && !getIntegration.inBetweenSteps) {
|
||||
throw new HttpException('Invalid request', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const youtube = this._integrationManager.getSocialIntegration(
|
||||
'youtube'
|
||||
) as YoutubeProvider;
|
||||
const getIntegrationInformation = await youtube.fetchPageInformation(
|
||||
getIntegration?.token!,
|
||||
data
|
||||
);
|
||||
|
||||
await this.checkForDeletedOnceAndUpdate(org, getIntegrationInformation.id);
|
||||
await this._integrationRepository.updateIntegration(id, {
|
||||
picture: getIntegrationInformation.picture,
|
||||
internalId: getIntegrationInformation.id,
|
||||
name: getIntegrationInformation.name,
|
||||
inBetweenSteps: false,
|
||||
token: getIntegration?.token!,
|
||||
profile: getIntegrationInformation.username,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async checkAnalytics(
|
||||
org: Organization,
|
||||
integration: string,
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
|
|||
override maxConcurrentJob = 1; // YouTube has strict upload quotas
|
||||
identifier = 'youtube';
|
||||
name = 'YouTube';
|
||||
isBetweenSteps = false;
|
||||
isBetweenSteps = true;
|
||||
dto = YoutubeSettingsDto;
|
||||
scopes = [
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
|
|
@ -189,6 +189,97 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
|
|||
};
|
||||
}
|
||||
|
||||
async pages(accessToken: string) {
|
||||
const { client, youtube } = clientAndYoutube();
|
||||
client.setCredentials({ access_token: accessToken });
|
||||
const youtubeClient = youtube(client);
|
||||
|
||||
try {
|
||||
// Get all channels the user has access to
|
||||
const response = await youtubeClient.channels.list({
|
||||
part: ['snippet', 'contentDetails', 'statistics'],
|
||||
mine: true,
|
||||
});
|
||||
|
||||
const channels = response.data.items || [];
|
||||
|
||||
return channels.map((channel) => ({
|
||||
id: channel.id!,
|
||||
name: channel.snippet?.title || 'Unnamed Channel',
|
||||
picture: {
|
||||
data: {
|
||||
url: channel.snippet?.thumbnails?.default?.url || '',
|
||||
},
|
||||
},
|
||||
username: channel.snippet?.customUrl || '',
|
||||
subscriberCount: channel.statistics?.subscriberCount || '0',
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch YouTube channels:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async fetchPageInformation(
|
||||
accessToken: string,
|
||||
data: { id: string }
|
||||
) {
|
||||
const { client, youtube } = clientAndYoutube();
|
||||
client.setCredentials({ access_token: accessToken });
|
||||
const youtubeClient = youtube(client);
|
||||
|
||||
try {
|
||||
const response = await youtubeClient.channels.list({
|
||||
part: ['snippet', 'contentDetails', 'statistics'],
|
||||
id: [data.id],
|
||||
});
|
||||
|
||||
const channel = response.data.items?.[0];
|
||||
|
||||
if (!channel) {
|
||||
throw new Error('Channel not found');
|
||||
}
|
||||
|
||||
return {
|
||||
id: channel.id!,
|
||||
name: channel.snippet?.title || 'Unnamed Channel',
|
||||
access_token: accessToken,
|
||||
picture: channel.snippet?.thumbnails?.default?.url || '',
|
||||
username: channel.snippet?.customUrl || '',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch YouTube channel information:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async reConnect(
|
||||
id: string,
|
||||
requiredId: string,
|
||||
accessToken: string
|
||||
): Promise<AuthTokenDetails> {
|
||||
const pages = await this.pages(accessToken);
|
||||
const findPage = pages.find((p) => p.id === requiredId);
|
||||
|
||||
if (!findPage) {
|
||||
throw new Error('Channel not found');
|
||||
}
|
||||
|
||||
const information = await this.fetchPageInformation(accessToken, {
|
||||
id: requiredId,
|
||||
});
|
||||
|
||||
return {
|
||||
id: information.id,
|
||||
name: information.name,
|
||||
accessToken: information.access_token,
|
||||
refreshToken: information.access_token,
|
||||
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
|
||||
picture: information.picture,
|
||||
username: information.username,
|
||||
};
|
||||
}
|
||||
|
||||
async post(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
|
|
|
|||
Loading…
Reference in New Issue