feat: youtube select page

This commit is contained in:
Nevo David 2025-12-02 15:39:20 +07:00
parent fd7b755bef
commit e1225681a9
5 changed files with 291 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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