diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts
index b2aaf372..c1c2990b 100644
--- a/apps/backend/src/api/routes/integrations.controller.ts
+++ b/apps/backend/src/api/routes/integrations.controller.ts
@@ -560,6 +560,15 @@ export class IntegrationsController {
return this._integrationService.saveLinkedin(org.id, id, body.page);
}
+ @Post('/gmb/:id')
+ async saveGmb(
+ @Param('id') id: string,
+ @Body() body: { id: string; accountId: string; locationName: string },
+ @GetOrgFromRequest() org: Organization
+ ) {
+ return this._integrationService.saveGmb(org.id, id, body);
+ }
+
@Post('/enable')
enableChannel(
@GetOrgFromRequest() org: Organization,
diff --git a/apps/frontend/public/icons/platforms/gmb.png b/apps/frontend/public/icons/platforms/gmb.png
new file mode 100644
index 00000000..2ff782a9
Binary files /dev/null and b/apps/frontend/public/icons/platforms/gmb.png differ
diff --git a/apps/frontend/src/components/launches/add.provider.component.tsx b/apps/frontend/src/components/launches/add.provider.component.tsx
index b2d3ea98..8273fc1b 100644
--- a/apps/frontend/src/components/launches/add.provider.component.tsx
+++ b/apps/frontend/src/components/launches/add.provider.component.tsx
@@ -17,6 +17,7 @@ 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';
const resolver = classValidatorResolver(ApiKeyDto);
export const useAddProvider = (update?: () => void) => {
const modal = useModals();
@@ -457,7 +458,7 @@ export const AddProviderComponent: FC<{
) : (
)}
diff --git a/apps/frontend/src/components/layout/continue.provider.tsx b/apps/frontend/src/components/layout/continue.provider.tsx
index 46d7f9c8..0e5a5660 100644
--- a/apps/frontend/src/components/layout/continue.provider.tsx
+++ b/apps/frontend/src/components/layout/continue.provider.tsx
@@ -1,4 +1,4 @@
-import React, { FC, useCallback, useMemo } from 'react';
+import React, { FC, useCallback, useEffect, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { IntegrationContext } from '@gitroom/frontend/components/launches/helpers/use.integration';
@@ -7,6 +7,7 @@ import useSWR, { useSWRConfig } from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { continueProviderList } from '@gitroom/frontend/components/new-launch/providers/continue-provider/list';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
+import { useModals } from '@gitroom/frontend/components/layout/new-modal';
export const Null: FC<{
closeModal: () => void;
existingId: string[];
@@ -46,74 +47,73 @@ export const ContinueProvider: FC = () => {
continueProviderList[added as keyof typeof continueProviderList] || Null
);
}, [added]);
+
if (!added || !continueId || !integrations) {
return null;
}
+
return (
-
-
e.stopPropagation()}
- >
-
-
-
-
-
-
-
-
-
- p.internalId)}
- />
-
-
-
-
-
+ p.internalId)}
+ provider={Provider}
+ />
);
};
+
+const ModalContent: FC<{
+ continueId: string;
+ added: any;
+ provider: any;
+ closeModal: () => void;
+ integrations: string[];
+}> = ({ continueId, added, provider: Provider, closeModal, integrations }) => {
+ return (
+
+
+
+ );
+};
+
+const ContinueModal: FC<{
+ continueId: string;
+ added: any;
+ provider: any;
+ integrations: string[];
+}> = (props) => {
+ const modals = useModals();
+
+ useEffect(() => {
+ modals.openModal({
+ title: 'Configure Channel',
+ children: (close) => ,
+ });
+ }, []);
+
+ return null;
+};
diff --git a/apps/frontend/src/components/new-launch/providers/continue-provider/gmb/gmb.continue.tsx b/apps/frontend/src/components/new-launch/providers/continue-provider/gmb/gmb.continue.tsx
new file mode 100644
index 00000000..54ceb1ec
--- /dev/null
+++ b/apps/frontend/src/components/new-launch/providers/continue-provider/gmb/gmb.continue.tsx
@@ -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 GmbContinue: FC<{
+ closeModal: () => void;
+ existingId: string[];
+}> = (props) => {
+ const { closeModal, existingId } = props;
+ const call = useCustomProviderFunction();
+ const { integration } = useIntegration();
+ const [location, setSelectedLocation] = useState(null);
+ const fetch = useFetch();
+ const t = useT();
+
+ const loadPages = useCallback(async () => {
+ try {
+ const pages = await call.get('pages');
+ return pages;
+ } catch (e) {
+ closeModal();
+ }
+ }, []);
+
+ const setLocation = useCallback(
+ (param: { id: string; accountId: string; locationName: string }) => () => {
+ setSelectedLocation(param);
+ },
+ []
+ );
+
+ 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 fetch(`/integrations/gmb/${integration?.id}`, {
+ method: 'POST',
+ body: JSON.stringify(location),
+ });
+ closeModal();
+ }, [integration, location]);
+
+ const filteredData = useMemo(() => {
+ return (
+ data?.filter((p: { id: string }) => !existingId.includes(p.id)) || []
+ );
+ }, [data, existingId]);
+
+ if (!isLoading && !data?.length) {
+ return (
+
+ {t(
+ 'gmb_no_locations_found',
+ "We couldn't find any business locations connected to your account."
+ )}
+
+
+ {t(
+ 'gmb_ensure_business_verified',
+ 'Please ensure your business is verified on Google My Business.'
+ )}
+
+
+ {t(
+ 'gmb_try_again',
+ 'Please close this dialog, delete the integration and try again.'
+ )}
+
+ );
+ }
+
+ return (
+
+
{t('select_location', 'Select Business Location:')}
+
+ {filteredData?.map(
+ (p: {
+ id: string;
+ name: string;
+ accountId: string;
+ locationName: string;
+ picture: {
+ data: {
+ url: string;
+ };
+ };
+ }) => (
+
+
+ {p.picture?.data?.url ? (
+
+ ) : (
+
+ )}
+
+
{p.name}
+
+ )
+ )}
+
+
+
+ {t('save', 'Save')}
+
+
+
+ );
+};
+
diff --git a/apps/frontend/src/components/new-launch/providers/continue-provider/list.tsx b/apps/frontend/src/components/new-launch/providers/continue-provider/list.tsx
index 03b182e2..f8052fae 100644
--- a/apps/frontend/src/components/new-launch/providers/continue-provider/list.tsx
+++ b/apps/frontend/src/components/new-launch/providers/continue-provider/list.tsx
@@ -3,8 +3,11 @@
import { InstagramContinue } from '@gitroom/frontend/components/new-launch/providers/continue-provider/instagram/instagram.continue';
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';
+
export const continueProviderList = {
instagram: InstagramContinue,
facebook: FacebookContinue,
'linkedin-page': LinkedinContinue,
+ gmb: GmbContinue,
};
diff --git a/apps/frontend/src/components/new-launch/providers/gmb/gmb.provider.tsx b/apps/frontend/src/components/new-launch/providers/gmb/gmb.provider.tsx
new file mode 100644
index 00000000..2a1b428c
--- /dev/null
+++ b/apps/frontend/src/components/new-launch/providers/gmb/gmb.provider.tsx
@@ -0,0 +1,193 @@
+'use client';
+
+import { FC, useCallback, useEffect } from 'react';
+import {
+ PostComment,
+ withProvider,
+} from '@gitroom/frontend/components/new-launch/providers/high.order.provider';
+import { GmbSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/gmb.settings.dto';
+import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
+import { Input } from '@gitroom/react/form/input';
+import { Select } from '@gitroom/react/form/select';
+import { useWatch } from 'react-hook-form';
+
+const topicTypes = [
+ {
+ label: 'Standard Update',
+ value: 'STANDARD',
+ },
+ {
+ label: 'Event',
+ value: 'EVENT',
+ },
+ {
+ label: 'Offer',
+ value: 'OFFER',
+ },
+];
+
+const callToActionTypes = [
+ {
+ label: 'None',
+ value: '',
+ },
+ {
+ label: 'Book',
+ value: 'BOOK',
+ },
+ {
+ label: 'Order Online',
+ value: 'ORDER',
+ },
+ {
+ label: 'Shop',
+ value: 'SHOP',
+ },
+ {
+ label: 'Learn More',
+ value: 'LEARN_MORE',
+ },
+ {
+ label: 'Sign Up',
+ value: 'SIGN_UP',
+ },
+ {
+ label: 'Get Offer',
+ value: 'GET_OFFER',
+ },
+ {
+ label: 'Call',
+ value: 'CALL',
+ },
+];
+
+const GmbSettings: FC = () => {
+ const { register, control } = useSettings();
+ const topicType = useWatch({ control, name: 'topicType' });
+ const callToActionType = useWatch({ control, name: 'callToActionType' });
+
+ return (
+
+
+ {topicTypes.map((t) => (
+
+ {t.label}
+
+ ))}
+
+
+
+ {callToActionTypes.map((t) => (
+
+ {t.label}
+
+ ))}
+
+
+ {callToActionType && callToActionType !== 'CALL' && (
+
+ )}
+
+ {topicType === 'EVENT' && (
+
+ )}
+
+ {topicType === 'OFFER' && (
+
+ )}
+
+ );
+};
+
+export default withProvider({
+ postComment: PostComment.POST,
+ minimumCharacters: [],
+ SettingsComponent: GmbSettings,
+ CustomPreviewComponent: undefined,
+ dto: GmbSettingsDto,
+ checkValidity: async (items, settings) => {
+ // GMB posts can have text only, or text with one image
+ if (items.length > 0 && items[0].length > 1) {
+ return 'Google My Business posts can only have one image';
+ }
+
+ // Check for video - GMB doesn't support video in local posts
+ if (items.length > 0 && items[0].length > 0) {
+ const media = items[0][0];
+ if (media.path.indexOf('mp4') > -1) {
+ return 'Google My Business posts do not support video attachments';
+ }
+ }
+
+ // Event posts require a title
+ if (settings.topicType === 'EVENT' && !settings.eventTitle) {
+ return 'Event posts require an event title';
+ }
+
+ return true;
+ },
+ maximumCharacters: 1500,
+});
+
diff --git a/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx b/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx
index 9b1c9e51..187a9657 100644
--- a/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx
+++ b/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx
@@ -32,6 +32,7 @@ import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { PostComment } from '@gitroom/frontend/components/new-launch/providers/high.order.provider';
import WordpressProvider from '@gitroom/frontend/components/new-launch/providers/wordpress/wordpress.provider';
import ListmonkProvider from '@gitroom/frontend/components/new-launch/providers/listmonk/listmonk.provider';
+import GmbProvider from '@gitroom/frontend/components/new-launch/providers/gmb/gmb.provider';
export const Providers = [
{
@@ -138,6 +139,10 @@ export const Providers = [
identifier: 'listmonk',
component: ListmonkProvider,
},
+ {
+ identifier: 'gmb',
+ component: GmbProvider,
+ },
];
export const ShowAllProviders = forwardRef((props, ref) => {
const { date, current, global, selectedIntegrations, allIntegrations } =
diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts
index a54758e6..ef843407 100644
--- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts
+++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts
@@ -11,6 +11,7 @@ import {
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 dayjs from 'dayjs';
import { timer } from '@gitroom/helpers/utils/timer';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
@@ -367,6 +368,40 @@ export class IntegrationService {
return { success: true };
}
+ async saveGmb(
+ org: string,
+ id: string,
+ data: { id: string; accountId: string; locationName: string }
+ ) {
+ const getIntegration = await this._integrationRepository.getIntegrationById(
+ org,
+ id
+ );
+ if (getIntegration && !getIntegration.inBetweenSteps) {
+ throw new HttpException('Invalid request', HttpStatus.BAD_REQUEST);
+ }
+
+ const gmb = this._integrationManager.getSocialIntegration(
+ 'gmb'
+ ) as GmbProvider;
+ const getIntegrationInformation = await gmb.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,
diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts
index 0e892e60..7579f460 100644
--- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts
+++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts
@@ -15,6 +15,7 @@ import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers
import { HashnodeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/hashnode.settings.dto';
import { WordpressDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/wordpress.dto';
import { ListmonkDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/listmonk.dto';
+import { GmbSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/gmb.settings.dto';
export type ProviderExtension = { __type: T } & M;
export type AllProvidersSettings =
@@ -36,6 +37,7 @@ export type AllProvidersSettings =
| ProviderExtension<'hashnode', HashnodeSettingsDto>
| ProviderExtension<'wordpress', WordpressDto>
| ProviderExtension<'listmonk', ListmonkDto>
+ | ProviderExtension<'gmb', GmbSettingsDto>
| ProviderExtension<'facebook', None>
| ProviderExtension<'threads', None>
| ProviderExtension<'mastodon', None>
@@ -67,6 +69,7 @@ export const allProviders = (setEmpty?: any) => {
{ value: WordpressDto, name: 'wordpress' },
{ value: HashnodeSettingsDto, name: 'hashnode' },
{ value: ListmonkDto, name: 'listmonk' },
+ { value: GmbSettingsDto, name: 'gmb' },
{ value: setEmpty, name: 'facebook' },
{ value: setEmpty, name: 'threads' },
{ value: setEmpty, name: 'mastodon' },
diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/gmb.settings.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/gmb.settings.dto.ts
new file mode 100644
index 00000000..221880d8
--- /dev/null
+++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/gmb.settings.dto.ts
@@ -0,0 +1,68 @@
+import { IsOptional, IsString, IsIn, IsUrl, ValidateIf } from 'class-validator';
+
+export class GmbSettingsDto {
+ @IsOptional()
+ @IsIn(['STANDARD', 'EVENT', 'OFFER'])
+ topicType?: 'STANDARD' | 'EVENT' | 'OFFER';
+
+ @IsOptional()
+ @IsIn([
+ 'BOOK',
+ 'ORDER',
+ 'SHOP',
+ 'LEARN_MORE',
+ 'SIGN_UP',
+ 'GET_OFFER',
+ 'CALL',
+ ])
+ callToActionType?:
+ | 'BOOK'
+ | 'ORDER'
+ | 'SHOP'
+ | 'LEARN_MORE'
+ | 'SIGN_UP'
+ | 'GET_OFFER'
+ | 'CALL';
+
+ @IsOptional()
+ @ValidateIf((o) => o.callToActionType)
+ @IsUrl()
+ callToActionUrl?: string;
+
+ // Event-specific fields
+ @IsOptional()
+ @ValidateIf((o) => o.topicType === 'EVENT')
+ @IsString()
+ eventTitle?: string;
+
+ @IsOptional()
+ @IsString()
+ eventStartDate?: string;
+
+ @IsOptional()
+ @IsString()
+ eventEndDate?: string;
+
+ @IsOptional()
+ @IsString()
+ eventStartTime?: string;
+
+ @IsOptional()
+ @IsString()
+ eventEndTime?: string;
+
+ // Offer-specific fields
+ @IsOptional()
+ @IsString()
+ offerCouponCode?: string;
+
+ @IsOptional()
+ @ValidateIf((o) => o.offerRedeemUrl)
+ @IsUrl()
+ offerRedeemUrl?: string;
+
+ @IsOptional()
+ @IsString()
+ offerTerms?: string;
+}
+
diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts
index dea5122d..a8ef22a4 100644
--- a/libraries/nestjs-libraries/src/integrations/integration.manager.ts
+++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts
@@ -28,6 +28,7 @@ import { NostrProvider } from '@gitroom/nestjs-libraries/integrations/social/nos
import { VkProvider } from '@gitroom/nestjs-libraries/integrations/social/vk.provider';
import { WordpressProvider } from '@gitroom/nestjs-libraries/integrations/social/wordpress.provider';
import { ListmonkProvider } from '@gitroom/nestjs-libraries/integrations/social/listmonk.provider';
+import { GmbProvider } from '@gitroom/nestjs-libraries/integrations/social/gmb.provider';
export const socialIntegrationList: SocialProvider[] = [
new XProvider(),
@@ -39,6 +40,7 @@ export const socialIntegrationList: SocialProvider[] = [
new FacebookProvider(),
new ThreadsProvider(),
new YoutubeProvider(),
+ new GmbProvider(),
new TiktokProvider(),
new PinterestProvider(),
new DribbbleProvider(),
diff --git a/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts b/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts
new file mode 100644
index 00000000..002de587
--- /dev/null
+++ b/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts
@@ -0,0 +1,518 @@
+import {
+ AnalyticsData,
+ AuthTokenDetails,
+ PostDetails,
+ PostResponse,
+ SocialProvider,
+} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
+import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { google } from 'googleapis';
+import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client';
+import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
+import * as process from 'node:process';
+import dayjs from 'dayjs';
+import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
+import { GmbSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/gmb.settings.dto';
+
+const clientAndGmb = () => {
+ const client = new google.auth.OAuth2({
+ clientId: process.env.GOOGLE_GMB_CLIENT_ID || process.env.YOUTUBE_CLIENT_ID,
+ clientSecret:
+ process.env.GOOGLE_GMB_CLIENT_SECRET || process.env.YOUTUBE_CLIENT_SECRET,
+ redirectUri: `${process.env.FRONTEND_URL}/integrations/social/gmb`,
+ });
+
+ const oauth2 = (newClient: OAuth2Client) =>
+ google.oauth2({
+ version: 'v2',
+ auth: newClient,
+ });
+
+ return { client, oauth2 };
+};
+
+@Rules(
+ 'Google My Business posts can have text content and optionally one image. Posts can be updates, events, or offers.'
+)
+export class GmbProvider extends SocialAbstract implements SocialProvider {
+ override maxConcurrentJob = 3;
+ identifier = 'gmb';
+ name = 'Google My Business';
+ isBetweenSteps = true;
+ scopes = [
+ 'https://www.googleapis.com/auth/userinfo.profile',
+ 'https://www.googleapis.com/auth/userinfo.email',
+ 'https://www.googleapis.com/auth/business.manage',
+ ];
+ editor = 'normal' as const;
+ dto = GmbSettingsDto;
+
+ maxLength() {
+ return 1500;
+ }
+
+ override handleErrors(body: string):
+ | {
+ type: 'refresh-token' | 'bad-body';
+ value: string;
+ }
+ | undefined {
+ if (body.includes('UNAUTHENTICATED') || body.includes('invalid_grant')) {
+ return {
+ type: 'refresh-token',
+ value: 'Please re-authenticate your Google My Business account',
+ };
+ }
+
+ if (body.includes('PERMISSION_DENIED')) {
+ return {
+ type: 'refresh-token',
+ value:
+ 'Permission denied. Please ensure you have access to this business location.',
+ };
+ }
+
+ if (body.includes('NOT_FOUND')) {
+ return {
+ type: 'bad-body',
+ value: 'Business location not found. It may have been deleted.',
+ };
+ }
+
+ if (body.includes('INVALID_ARGUMENT')) {
+ return {
+ type: 'bad-body',
+ value: 'Invalid post content. Please check your post details.',
+ };
+ }
+
+ if (body.includes('RESOURCE_EXHAUSTED')) {
+ return {
+ type: 'bad-body',
+ value: 'Rate limit exceeded. Please try again later.',
+ };
+ }
+
+ return undefined;
+ }
+
+ async refreshToken(refresh_token: string): Promise {
+ const { client, oauth2 } = clientAndGmb();
+ client.setCredentials({ refresh_token });
+ const { credentials } = await client.refreshAccessToken();
+ const user = oauth2(client);
+ const expiryDate = new Date(credentials.expiry_date!);
+ const unixTimestamp =
+ Math.floor(expiryDate.getTime() / 1000) -
+ Math.floor(new Date().getTime() / 1000);
+
+ const { data } = await user.userinfo.get();
+
+ return {
+ accessToken: credentials.access_token!,
+ expiresIn: unixTimestamp!,
+ refreshToken: credentials.refresh_token || refresh_token,
+ id: data.id!,
+ name: data.name!,
+ picture: data?.picture || '',
+ username: '',
+ };
+ }
+
+ async generateAuthUrl() {
+ const state = makeId(7);
+ const { client } = clientAndGmb();
+ return {
+ url: client.generateAuthUrl({
+ access_type: 'offline',
+ prompt: 'consent',
+ state,
+ redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/gmb`,
+ scope: this.scopes.slice(0),
+ }),
+ codeVerifier: makeId(11),
+ state,
+ };
+ }
+
+ async authenticate(params: {
+ code: string;
+ codeVerifier: string;
+ refresh?: string;
+ }) {
+ const { client, oauth2 } = clientAndGmb();
+ const { tokens } = await client.getToken(params.code);
+ client.setCredentials(tokens);
+ const { scopes } = await client.getTokenInfo(tokens.access_token!);
+ this.checkScopes(this.scopes, scopes);
+
+ const user = oauth2(client);
+ const { data } = await user.userinfo.get();
+
+ const expiryDate = new Date(tokens.expiry_date!);
+ const unixTimestamp =
+ Math.floor(expiryDate.getTime() / 1000) -
+ Math.floor(new Date().getTime() / 1000);
+
+ return {
+ accessToken: tokens.access_token!,
+ expiresIn: unixTimestamp,
+ refreshToken: tokens.refresh_token!,
+ id: data.id!,
+ name: data.name!,
+ picture: data?.picture || '',
+ username: '',
+ };
+ }
+
+ async pages(accessToken: string) {
+ // Get all accounts first
+ const accountsResponse = await fetch(
+ 'https://mybusinessaccountmanagement.googleapis.com/v1/accounts',
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }
+ );
+ const accountsData = await accountsResponse.json();
+
+ if (!accountsData.accounts || accountsData.accounts.length === 0) {
+ return [];
+ }
+
+ // Get locations for each account
+ const allLocations: Array<{
+ id: string;
+ name: string;
+ picture: { data: { url: string } };
+ accountId: string;
+ locationName: string;
+ }> = [];
+
+ for (const account of accountsData.accounts) {
+ const accountName = account.name; // format: accounts/{accountId}
+
+ try {
+ const locationsResponse = await fetch(
+ `https://mybusinessbusinessinformation.googleapis.com/v1/${accountName}/locations?readMask=name,title,storefrontAddress,metadata`,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }
+ );
+ const locationsData = await locationsResponse.json();
+
+ if (locationsData.locations) {
+ for (const location of locationsData.locations) {
+ // Get profile photo if available
+ let photoUrl = '';
+ try {
+ const mediaResponse = await fetch(
+ `https://mybusinessbusinessinformation.googleapis.com/v1/${location.name}/media`,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }
+ );
+ const mediaData = await mediaResponse.json();
+ if (mediaData.mediaItems && mediaData.mediaItems.length > 0) {
+ const profilePhoto = mediaData.mediaItems.find(
+ (m: any) =>
+ m.mediaFormat === 'PHOTO' &&
+ m.locationAssociation?.category === 'PROFILE'
+ );
+ if (profilePhoto?.googleUrl) {
+ photoUrl = profilePhoto.googleUrl;
+ } else if (mediaData.mediaItems[0]?.googleUrl) {
+ photoUrl = mediaData.mediaItems[0].googleUrl;
+ }
+ }
+ } catch {
+ // Ignore media fetch errors
+ }
+
+ allLocations.push({
+ id: location.name, // format: locations/{locationId}
+ name: location.title || 'Unnamed Location',
+ picture: { data: { url: photoUrl } },
+ accountId: accountName,
+ locationName: location.name,
+ });
+ }
+ }
+ } catch (error) {
+ // Continue with other accounts if one fails
+ console.error(`Failed to fetch locations for account ${accountName}:`, error);
+ }
+ }
+
+ return allLocations;
+ }
+
+ async fetchPageInformation(
+ accessToken: string,
+ data: { id: string; accountId: string; locationName: string }
+ ) {
+ // Fetch location details
+ const locationResponse = await fetch(
+ `https://mybusinessbusinessinformation.googleapis.com/v1/${data.id}?readMask=name,title,storefrontAddress,metadata`,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }
+ );
+ const locationData = await locationResponse.json();
+
+ // Try to get profile photo
+ let photoUrl = '';
+ try {
+ const mediaResponse = await fetch(
+ `https://mybusinessbusinessinformation.googleapis.com/v1/${data.id}/media`,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }
+ );
+ const mediaData = await mediaResponse.json();
+ if (mediaData.mediaItems && mediaData.mediaItems.length > 0) {
+ const profilePhoto = mediaData.mediaItems.find(
+ (m: any) =>
+ m.mediaFormat === 'PHOTO' &&
+ m.locationAssociation?.category === 'PROFILE'
+ );
+ if (profilePhoto?.googleUrl) {
+ photoUrl = profilePhoto.googleUrl;
+ } else if (mediaData.mediaItems[0]?.googleUrl) {
+ photoUrl = mediaData.mediaItems[0].googleUrl;
+ }
+ }
+ } catch {
+ // Ignore media fetch errors
+ }
+
+ return {
+ id: data.id,
+ name: locationData.title || 'Unnamed Location',
+ access_token: accessToken,
+ picture: photoUrl,
+ username: '',
+ };
+ }
+
+ async reConnect(
+ id: string,
+ requiredId: string,
+ accessToken: string
+ ): Promise {
+ const pages = await this.pages(accessToken);
+ const findPage = pages.find((p) => p.id === requiredId);
+
+ if (!findPage) {
+ throw new Error('Location not found');
+ }
+
+ const information = await this.fetchPageInformation(accessToken, {
+ id: requiredId,
+ accountId: findPage.accountId,
+ locationName: findPage.locationName,
+ });
+
+ 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,
+ postDetails: PostDetails[]
+ ): Promise {
+ const [firstPost] = postDetails;
+ const { settings } = firstPost;
+
+ // Build the local post request body
+ const postBody: any = {
+ languageCode: 'en',
+ summary: firstPost.message,
+ topicType: settings?.topicType || 'STANDARD',
+ };
+
+ // Add call to action if provided
+ if (settings?.callToActionType && settings?.callToActionUrl) {
+ postBody.callToAction = {
+ actionType: settings.callToActionType,
+ url: settings.callToActionUrl,
+ };
+ }
+
+ // Add media if provided
+ if (firstPost.media && firstPost.media.length > 0) {
+ const mediaItem = firstPost.media[0];
+ postBody.media = [
+ {
+ mediaFormat: mediaItem.type === 'video' ? 'VIDEO' : 'PHOTO',
+ sourceUrl: mediaItem.path,
+ },
+ ];
+ }
+
+ // Add event details if it's an event post
+ if (settings?.topicType === 'EVENT' && settings?.eventTitle) {
+ postBody.event = {
+ title: settings.eventTitle,
+ schedule: {
+ startDate: this.formatDate(settings.eventStartDate),
+ endDate: this.formatDate(settings.eventEndDate),
+ ...(settings.eventStartTime && {
+ startTime: this.formatTime(settings.eventStartTime),
+ }),
+ ...(settings.eventEndTime && {
+ endTime: this.formatTime(settings.eventEndTime),
+ }),
+ },
+ };
+ }
+
+ // Add offer details if it's an offer post
+ if (settings?.topicType === 'OFFER') {
+ postBody.offer = {
+ couponCode: settings?.offerCouponCode || undefined,
+ redeemOnlineUrl: settings?.offerRedeemUrl || undefined,
+ termsConditions: settings?.offerTerms || undefined,
+ };
+ }
+
+ // Create the local post
+ const response = await this.fetch(
+ `https://mybusiness.googleapis.com/v4/${id}/localPosts`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(postBody),
+ },
+ 'create local post'
+ );
+
+ const postData = await response.json();
+
+ // Extract the post ID and construct the URL
+ const postId = postData.name || '';
+ const locationId = id.split('/').pop();
+
+ // GMB posts don't have direct URLs, but we can link to the business profile
+ const releaseURL = `https://business.google.com/locations/${locationId}`;
+
+ return [
+ {
+ id: firstPost.id,
+ postId: postId,
+ releaseURL: releaseURL,
+ status: 'success',
+ },
+ ];
+ }
+
+ private formatDate(dateString?: string): any {
+ if (!dateString) {
+ return {
+ year: dayjs().year(),
+ month: dayjs().month() + 1,
+ day: dayjs().date(),
+ };
+ }
+ const date = dayjs(dateString);
+ return {
+ year: date.year(),
+ month: date.month() + 1,
+ day: date.date(),
+ };
+ }
+
+ private formatTime(timeString?: string): any {
+ if (!timeString) {
+ return undefined;
+ }
+ const [hours, minutes] = timeString.split(':').map(Number);
+ return {
+ hours: hours || 0,
+ minutes: minutes || 0,
+ seconds: 0,
+ nanos: 0,
+ };
+ }
+
+ async analytics(
+ id: string,
+ accessToken: string,
+ date: number
+ ): Promise {
+ try {
+ const endDate = dayjs().format('YYYY-MM-DD');
+ const startDate = dayjs().subtract(date, 'day').format('YYYY-MM-DD');
+
+ // Use the Business Profile Performance API
+ const response = await fetch(
+ `https://businessprofileperformance.googleapis.com/v1/${id}:getDailyMetricsTimeSeries?dailyMetric=WEBSITE_CLICKS&dailyMetric=CALL_CLICKS&dailyMetric=BUSINESS_DIRECTION_REQUESTS&dailyMetric=BUSINESS_IMPRESSIONS_DESKTOP_MAPS&dailyMetric=BUSINESS_IMPRESSIONS_MOBILE_MAPS&dailyRange.startDate.year=${dayjs(startDate).year()}&dailyRange.startDate.month=${dayjs(startDate).month() + 1}&dailyRange.startDate.day=${dayjs(startDate).date()}&dailyRange.endDate.year=${dayjs(endDate).year()}&dailyRange.endDate.month=${dayjs(endDate).month() + 1}&dailyRange.endDate.day=${dayjs(endDate).date()}`,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }
+ );
+
+ const data = await response.json();
+
+ if (!data.timeSeries || data.timeSeries.length === 0) {
+ return [];
+ }
+
+ const metricLabels: { [key: string]: string } = {
+ WEBSITE_CLICKS: 'Website Clicks',
+ CALL_CLICKS: 'Phone Calls',
+ BUSINESS_DIRECTION_REQUESTS: 'Direction Requests',
+ BUSINESS_IMPRESSIONS_DESKTOP_MAPS: 'Desktop Map Views',
+ BUSINESS_IMPRESSIONS_MOBILE_MAPS: 'Mobile Map Views',
+ };
+
+ const analytics: AnalyticsData[] = [];
+
+ for (const series of data.timeSeries) {
+ const metricName = series.dailyMetric;
+ const label = metricLabels[metricName] || metricName;
+
+ const dataPoints =
+ series.timeSeries?.datedValues?.map((dv: any) => ({
+ total: dv.value || 0,
+ date: `${dv.date.year}-${String(dv.date.month).padStart(2, '0')}-${String(dv.date.day).padStart(2, '0')}`,
+ })) || [];
+
+ if (dataPoints.length > 0) {
+ analytics.push({
+ label,
+ percentageChange: 0,
+ data: dataPoints,
+ });
+ }
+ }
+
+ return analytics;
+ } catch (error) {
+ console.error('Error fetching GMB analytics:', error);
+ return [];
+ }
+ }
+}