feat: gmb
This commit is contained in:
parent
98edbc4588
commit
4eac6b9ff8
|
|
@ -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,
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -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<{
|
|||
<img src={`/icons/platforms/youtube.svg`} />
|
||||
) : (
|
||||
<img
|
||||
className="w-[32px] h-[32px] rounded-full"
|
||||
className={clsx("w-[32px] h-[32px]", item.identifier !== 'google_my_business' && 'rounded-full')}
|
||||
src={`/icons/platforms/${item.identifier}.png`}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
className="fixed start-0 top-0 w-full h-full bg-primary/40 z-[499]"
|
||||
onClick={closeModal}
|
||||
>
|
||||
<div
|
||||
className="w-[100%] max-w-[674px] absolute start-[50%] top-[65px] bg-customColor3 z-[500] -translate-x-[50%] text-textColor p-[16px] !pt-0 border border-customColor6 min-h-[300px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-full h-full relative">
|
||||
<TopTitle title="Configure Provider" />
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="outline-none absolute end-0 top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path
|
||||
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="pt-[16px] max-h-[600px] overflow-hidden overflow-y-auto">
|
||||
<IntegrationContext.Provider
|
||||
value={{
|
||||
date: newDayjs(),
|
||||
value: [],
|
||||
allIntegrations: [],
|
||||
integration: {
|
||||
editor: 'normal',
|
||||
additionalSettings: '',
|
||||
display: '',
|
||||
time: [
|
||||
{
|
||||
time: 0,
|
||||
},
|
||||
],
|
||||
id: continueId,
|
||||
type: '',
|
||||
name: '',
|
||||
picture: '',
|
||||
inBetweenSteps: true,
|
||||
changeNickName: false,
|
||||
changeProfilePicture: false,
|
||||
identifier: added,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Provider
|
||||
closeModal={closeModal}
|
||||
existingId={integrations.map((p: any) => p.internalId)}
|
||||
/>
|
||||
</IntegrationContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ContinueModal
|
||||
added={added}
|
||||
continueId={continueId}
|
||||
integrations={integrations.map((p: any) => p.internalId)}
|
||||
provider={Provider}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ModalContent: FC<{
|
||||
continueId: string;
|
||||
added: any;
|
||||
provider: any;
|
||||
closeModal: () => void;
|
||||
integrations: string[];
|
||||
}> = ({ continueId, added, provider: Provider, closeModal, integrations }) => {
|
||||
return (
|
||||
<IntegrationContext.Provider
|
||||
value={{
|
||||
date: newDayjs(),
|
||||
value: [],
|
||||
allIntegrations: [],
|
||||
integration: {
|
||||
editor: 'normal',
|
||||
additionalSettings: '',
|
||||
display: '',
|
||||
time: [
|
||||
{
|
||||
time: 0,
|
||||
},
|
||||
],
|
||||
id: continueId,
|
||||
type: '',
|
||||
name: '',
|
||||
picture: '',
|
||||
inBetweenSteps: true,
|
||||
changeNickName: false,
|
||||
changeProfilePicture: false,
|
||||
identifier: added,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Provider closeModal={closeModal} existingId={integrations} />
|
||||
</IntegrationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ContinueModal: FC<{
|
||||
continueId: string;
|
||||
added: any;
|
||||
provider: any;
|
||||
integrations: string[];
|
||||
}> = (props) => {
|
||||
const modals = useModals();
|
||||
|
||||
useEffect(() => {
|
||||
modals.openModal({
|
||||
title: 'Configure Channel',
|
||||
children: (close) => <ModalContent {...props} closeModal={close} />,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 | {
|
||||
id: string;
|
||||
accountId: string;
|
||||
locationName: string;
|
||||
}>(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 (
|
||||
<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;
|
||||
accountId: 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,
|
||||
accountId: p.accountId,
|
||||
locationName: p.locationName,
|
||||
})}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Button disabled={!location} onClick={saveGmb}>
|
||||
{t('save', 'Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-[10px]">
|
||||
<Select
|
||||
label="Post Type"
|
||||
{...register('topicType', {
|
||||
value: 'STANDARD',
|
||||
})}
|
||||
>
|
||||
{topicTypes.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
label="Call to Action"
|
||||
{...register('callToActionType', {
|
||||
value: '',
|
||||
})}
|
||||
>
|
||||
{callToActionTypes.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{callToActionType && callToActionType !== 'CALL' && (
|
||||
<Input
|
||||
label="Call to Action URL"
|
||||
placeholder="https://example.com"
|
||||
{...register('callToActionUrl')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{topicType === 'EVENT' && (
|
||||
<div className="flex flex-col gap-[10px] mt-[10px] p-[15px] border border-input rounded-[8px]">
|
||||
<div className="text-[14px] font-medium mb-[5px]">Event Details</div>
|
||||
<Input
|
||||
label="Event Title"
|
||||
placeholder="Event name"
|
||||
{...register('eventTitle')}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-[10px]">
|
||||
<Input
|
||||
label="Start Date"
|
||||
type="date"
|
||||
{...register('eventStartDate')}
|
||||
/>
|
||||
<Input
|
||||
label="End Date"
|
||||
type="date"
|
||||
{...register('eventEndDate')}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-[10px]">
|
||||
<Input
|
||||
label="Start Time (optional)"
|
||||
type="time"
|
||||
{...register('eventStartTime')}
|
||||
/>
|
||||
<Input
|
||||
label="End Time (optional)"
|
||||
type="time"
|
||||
{...register('eventEndTime')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{topicType === 'OFFER' && (
|
||||
<div className="flex flex-col gap-[10px] mt-[10px] p-[15px] border border-input rounded-[8px]">
|
||||
<div className="text-[14px] font-medium mb-[5px]">Offer Details</div>
|
||||
<Input
|
||||
label="Coupon Code (optional)"
|
||||
placeholder="SAVE20"
|
||||
{...register('offerCouponCode')}
|
||||
/>
|
||||
<Input
|
||||
label="Redeem Online URL (optional)"
|
||||
placeholder="https://example.com/redeem"
|
||||
{...register('offerRedeemUrl')}
|
||||
/>
|
||||
<Input
|
||||
label="Terms & Conditions (optional)"
|
||||
placeholder="Valid until..."
|
||||
{...register('offerTerms')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
|
|
@ -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 } =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<T extends string, M> = { __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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<AuthTokenDetails> {
|
||||
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<AuthTokenDetails> {
|
||||
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<GmbSettingsDto>[]
|
||||
): Promise<PostResponse[]> {
|
||||
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<AnalyticsData[]> {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue