feat: google my business

This commit is contained in:
Nevo David 2025-11-27 20:56:57 +07:00
parent 4eac6b9ff8
commit 69e8944437
6 changed files with 63 additions and 36 deletions

View File

@ -563,7 +563,7 @@ export class IntegrationsController {
@Post('/gmb/:id')
async saveGmb(
@Param('id') id: string,
@Body() body: { id: string; accountId: string; locationName: string },
@Body() body: { id: string; accountName: string; locationName: string },
@GetOrgFromRequest() org: Organization
) {
return this._integrationService.saveGmb(org.id, id, body);

View File

@ -18,7 +18,7 @@ export const GmbContinue: FC<{
const { integration } = useIntegration();
const [location, setSelectedLocation] = useState<null | {
id: string;
accountId: string;
accountName: string;
locationName: string;
}>(null);
const fetch = useFetch();
@ -34,7 +34,7 @@ export const GmbContinue: FC<{
}, []);
const setLocation = useCallback(
(param: { id: string; accountId: string; locationName: string }) => () => {
(param: { id: string; accountName: string; locationName: string }) => () => {
setSelectedLocation(param);
},
[]
@ -95,7 +95,7 @@ export const GmbContinue: FC<{
(p: {
id: string;
name: string;
accountId: string;
accountName: string;
locationName: string;
picture: {
data: {
@ -111,7 +111,7 @@ export const GmbContinue: FC<{
)}
onClick={setLocation({
id: p.id,
accountId: p.accountId,
accountName: p.accountName,
locationName: p.locationName,
})}
>

View File

@ -29,7 +29,7 @@ const topicTypes = [
const callToActionTypes = [
{
label: 'None',
value: '',
value: 'NONE',
},
{
label: 'Book',
@ -84,7 +84,7 @@ const GmbSettings: FC = () => {
<Select
label="Call to Action"
{...register('callToActionType', {
value: '',
value: 'NONE',
})}
>
{callToActionTypes.map((t) => (
@ -94,13 +94,15 @@ const GmbSettings: FC = () => {
))}
</Select>
{callToActionType && callToActionType !== 'CALL' && (
<Input
label="Call to Action URL"
placeholder="https://example.com"
{...register('callToActionUrl')}
/>
)}
{callToActionType &&
callToActionType !== 'NONE' &&
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]">
@ -116,11 +118,7 @@ const GmbSettings: FC = () => {
type="date"
{...register('eventStartDate')}
/>
<Input
label="End Date"
type="date"
{...register('eventEndDate')}
/>
<Input label="End Date" type="date" {...register('eventEndDate')} />
</div>
<div className="grid grid-cols-2 gap-[10px]">
<Input
@ -172,7 +170,7 @@ export default withProvider({
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];
@ -190,4 +188,3 @@ export default withProvider({
},
maximumCharacters: 1500,
});

View File

@ -371,7 +371,7 @@ export class IntegrationService {
async saveGmb(
org: string,
id: string,
data: { id: string; accountId: string; locationName: string }
data: { id: string; accountName: string; locationName: string }
) {
const getIntegration = await this._integrationRepository.getIntegrationById(
org,

View File

@ -7,6 +7,7 @@ export class GmbSettingsDto {
@IsOptional()
@IsIn([
'NONE',
'BOOK',
'ORDER',
'SHOP',
@ -16,6 +17,7 @@ export class GmbSettingsDto {
'CALL',
])
callToActionType?:
| 'NONE'
| 'BOOK'
| 'ORDER'
| 'SHOP'
@ -65,4 +67,3 @@ export class GmbSettingsDto {
@IsString()
offerTerms?: string;
}

View File

@ -186,7 +186,7 @@ export class GmbProvider extends SocialAbstract implements SocialProvider {
id: string;
name: string;
picture: { data: { url: string } };
accountId: string;
accountName: string;
locationName: string;
}> = [];
@ -206,6 +206,11 @@ export class GmbProvider extends SocialAbstract implements SocialProvider {
if (locationsData.locations) {
for (const location of locationsData.locations) {
// location.name is in format: locations/{locationId}
// We need the full path: accounts/{accountId}/locations/{locationId}
const locationId = location.name.replace('locations/', '');
const fullResourceName = `${accountName}/locations/${locationId}`;
// Get profile photo if available
let photoUrl = '';
try {
@ -235,17 +240,21 @@ export class GmbProvider extends SocialAbstract implements SocialProvider {
}
allLocations.push({
id: location.name, // format: locations/{locationId}
// id is the full resource path for the v4 API: accounts/{accountId}/locations/{locationId}
id: fullResourceName,
name: location.title || 'Unnamed Location',
picture: { data: { url: photoUrl } },
accountId: accountName,
accountName: accountName,
locationName: location.name,
});
}
}
} catch (error) {
// Continue with other accounts if one fails
console.error(`Failed to fetch locations for account ${accountName}:`, error);
console.error(
`Failed to fetch locations for account ${accountName}:`,
error
);
}
}
@ -254,11 +263,13 @@ export class GmbProvider extends SocialAbstract implements SocialProvider {
async fetchPageInformation(
accessToken: string,
data: { id: string; accountId: string; locationName: string }
data: { id: string; accountName: string; locationName: string }
) {
// Fetch location details
// data.id is the full resource path: accounts/{accountId}/locations/{locationId}
// data.locationName is the v1 API format: locations/{locationId}
// Fetch location details using the v1 API format
const locationResponse = await fetch(
`https://mybusinessbusinessinformation.googleapis.com/v1/${data.id}?readMask=name,title,storefrontAddress,metadata`,
`https://mybusinessbusinessinformation.googleapis.com/v1/${data.locationName}?readMask=name,title,storefrontAddress,metadata`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
@ -271,7 +282,7 @@ export class GmbProvider extends SocialAbstract implements SocialProvider {
let photoUrl = '';
try {
const mediaResponse = await fetch(
`https://mybusinessbusinessinformation.googleapis.com/v1/${data.id}/media`,
`https://mybusinessbusinessinformation.googleapis.com/v1/${data.locationName}/media`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
@ -296,6 +307,7 @@ export class GmbProvider extends SocialAbstract implements SocialProvider {
}
return {
// Return the full resource path as id (for v4 Local Posts API)
id: data.id,
name: locationData.title || 'Unnamed Location',
access_token: accessToken,
@ -318,7 +330,7 @@ export class GmbProvider extends SocialAbstract implements SocialProvider {
const information = await this.fetchPageInformation(accessToken, {
id: requiredId,
accountId: findPage.accountId,
accountName: findPage.accountName,
locationName: findPage.locationName,
});
@ -348,8 +360,12 @@ export class GmbProvider extends SocialAbstract implements SocialProvider {
topicType: settings?.topicType || 'STANDARD',
};
// Add call to action if provided
if (settings?.callToActionType && settings?.callToActionUrl) {
// Add call to action if provided (and not NONE)
if (
settings?.callToActionType &&
settings.callToActionType !== 'NONE' &&
settings?.callToActionUrl
) {
postBody.callToAction = {
actionType: settings.callToActionType,
url: settings.callToActionUrl,
@ -466,7 +482,17 @@ export class GmbProvider extends SocialAbstract implements SocialProvider {
// 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()}`,
`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}`,
@ -497,7 +523,10 @@ export class GmbProvider extends SocialAbstract implements SocialProvider {
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')}`,
date: `${dv.date.year}-${String(dv.date.month).padStart(
2,
'0'
)}-${String(dv.date.day).padStart(2, '0')}`,
})) || [];
if (dataPoints.length > 0) {