diff --git a/.ai/rules/index.md b/.ai/rules/index.md new file mode 100644 index 000000000..3a16c9162 --- /dev/null +++ b/.ai/rules/index.md @@ -0,0 +1,8 @@ +# Project Rules Index + +Before planning or editing, find the row whose globs match the file's path and read that rule file. + +| Applies to | Rule file | +| --- | --- | +| app/Enums/SocialAccount/Platform.php | .ai/rules/social-account.md | +| app/Support/PostPlatformMetaRules.php | .ai/rules/support.md | diff --git a/.ai/rules/social-account.md b/.ai/rules/social-account.md new file mode 100644 index 000000000..2b28774d4 --- /dev/null +++ b/.ai/rules/social-account.md @@ -0,0 +1,9 @@ +--- +paths: + - app/Enums/SocialAccount/Platform.php +--- + +# Social Account + +## Adding a platform: grep for exhaustive Platform matches beyond the known touch-point list +Adding a new Platform enum case breaks any `match ($platform) { ... }` elsewhere in the codebase that enumerates every case with no `default` arm — these throw UnhandledMatchError only at runtime/test time, not statically. Known example found the hard way: `app/Services/Media/MediaOptimizer.php`'s per-platform image optimization settings match, which isn't part of the "usual" platform touch-point list (Platform enum, ContentType enum, config, PostPlatformMetaRules, publisher, controller, frontend registry). Before considering a new platform done, run the full test suite (`php artisan test --compact --parallel`) — a missing arm surfaces as a clean, unambiguous UnhandledMatchError failure, not a silent bug. diff --git a/.ai/rules/support.md b/.ai/rules/support.md new file mode 100644 index 000000000..a26e69c8f --- /dev/null +++ b/.ai/rules/support.md @@ -0,0 +1,9 @@ +--- +paths: + - app/Support/PostPlatformMetaRules.php +--- + +# Support + +## Never use a cross-field Laravel rule (required_unless, required_if, etc.) for a single platform's conditional meta field +`rules()` is shared by every platform via `platforms.*.meta.*` wildcards. Rules like `required_unless`/`required_if` are Laravel "implicit" rules — they validate even when the field itself is absent from the request. Adding one scoped in spirit to a single platform (e.g. `call_to_action.url` required_unless action_type is NONE/CALL, meant only for Google Business Profile) breaks every OTHER platform's create/update through web, API, and MCP, because their requests never send that field at all and the implicit rule still fires. The correct pattern (already used by Pinterest's `board_id`, Discord's `channel_id`): keep the field's `rules()` entry unconditional (`sometimes|nullable|...`), and enforce "required" semantics only in `requiredMetaViolation()`'s `match(true)` block, which is evaluated per the resolved `Platform` of the row being checked. This bug shipped once (fixed in commit 8887b3f3) and broke Pinterest/Discord/TikTok post updates — the task reviewer that approved the original `rules()` entry didn't catch it because it only reviewed Google Business's own test coverage, not sibling platforms'. diff --git a/.env.example b/.env.example index 2ca8be9ce..3787dac7d 100644 --- a/.env.example +++ b/.env.example @@ -149,6 +149,12 @@ GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_REDIRECT="${APP_URL}/accounts/youtube/callback" GOOGLE_AUTH_CALLBACK="${APP_URL}/auth/google/callback" +# Google Business Profile (https://console.cloud.google.com) +# Dedicated OAuth app, isolated from YouTube +GOOGLE_BUSINESS_CLIENT_ID= +GOOGLE_BUSINESS_CLIENT_SECRET= +GOOGLE_BUSINESS_CLIENT_REDIRECT="${APP_URL}/accounts/google-business/callback" + # GitHub (https://github.com/settings/developers) # Used for GitHub login/signup GITHUB_AUTH_ENABLED=false @@ -228,6 +234,7 @@ NIGHTWATCH_TOKEN= # MASTODON_ENABLED=true # BLUESKY_ENABLED=true # TELEGRAM_ENABLED=true +# GOOGLE_BUSINESS_ENABLED=true # Media Services UNSPLASH_ACCESS_KEY= diff --git a/CLAUDE.md b/CLAUDE.md index c2123195a..3ab5639bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -370,6 +370,7 @@ Standing constraints: - **Bluesky / AT Protocol**: official lexicons — https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo ; HTTP API reference — https://docs.bsky.app - **Discord**: Webhook resource (used for our webhook-based publishing) — https://docs.discord.com/developers/resources/webhook - **Telegram**: Bot API — https://core.telegram.org/bots/api +- **Google Business Profile**: Business Information API, Account Management API, Business Profile Performance API — https://developers.google.com/my-business/reference/rest ; legacy but still-active Local Posts v4 API (the only endpoint for creating/updating/deleting Local Posts) — https://developers.google.com/my-business/reference/rest/v4/accounts.locations.localPosts ## TryPost.it Documentation diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 0d56e74cd..c6ea21c5d 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -56,6 +56,9 @@ enum ContentType: string // Discord case DiscordMessage = 'discord_message'; + // Google Business Profile + case GoogleBusinessPost = 'google_business_post'; + /** * AI generation format for an Instagram carousel. Not a content type — * carousel posts are persisted as InstagramFeed. @@ -84,6 +87,7 @@ public function label(): string self::MastodonPost => 'Post', self::TelegramPost => 'Post', self::DiscordMessage => 'Message', + self::GoogleBusinessPost => 'Post', }; } @@ -108,6 +112,7 @@ public function platform(): SocialPlatform self::MastodonPost => SocialPlatform::Mastodon, self::TelegramPost => SocialPlatform::Telegram, self::DiscordMessage => SocialPlatform::Discord, + self::GoogleBusinessPost => SocialPlatform::GoogleBusiness, }; } @@ -177,6 +182,7 @@ public function maxMediaCount(): int self::MastodonPost => 4, self::TelegramPost => 10, self::DiscordMessage => 10, + self::GoogleBusinessPost => 1, }; } @@ -458,6 +464,7 @@ public function supportsVideo(): bool self::MastodonPost => true, self::TelegramPost => true, self::DiscordMessage => true, + self::GoogleBusinessPost => false, }; } @@ -526,6 +533,7 @@ public function requiresMedia(): bool self::TelegramPost => false, self::FacebookPost => false, self::DiscordMessage => false, + self::GoogleBusinessPost => false, default => true, }; } @@ -609,6 +617,7 @@ public static function defaultFor(SocialPlatform $platform): self SocialPlatform::Mastodon => self::MastodonPost, SocialPlatform::Telegram => self::TelegramPost, SocialPlatform::Discord => self::DiscordMessage, + SocialPlatform::GoogleBusiness => self::GoogleBusinessPost, }; } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index e665ed453..12fdc04e2 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -22,6 +22,7 @@ enum Platform: string case Mastodon = 'mastodon'; case Telegram = 'telegram'; case Discord = 'discord'; + case GoogleBusiness = 'google_business'; /** * The social network this platform belongs to. Variants that represent the @@ -69,6 +70,7 @@ public function label(): string self::Mastodon => 'Mastodon', self::Telegram => 'Telegram', self::Discord => 'Discord', + self::GoogleBusiness => 'Google Business Profile', }; } @@ -88,6 +90,7 @@ public function color(): string self::Mastodon => '#6364FF', self::Telegram => '#26A5E4', self::Discord => '#5865F2', + self::GoogleBusiness => '#4285F4', }; } @@ -106,6 +109,7 @@ public function allowedMediaTypes(): array self::Mastodon => [MediaType::Image, MediaType::Video], self::Telegram => [MediaType::Image, MediaType::Video], self::Discord => [MediaType::Image, MediaType::Video], + self::GoogleBusiness => [MediaType::Image], }; } @@ -124,6 +128,7 @@ public function maxImages(): int self::Mastodon => 4, self::Telegram => 10, self::Discord => 10, + self::GoogleBusiness => 1, }; } @@ -147,7 +152,7 @@ public function altTextMaxLength(): ?int self::Threads => 1000, self::Pinterest => 500, self::Discord => 1024, - self::TikTok, self::YouTube, self::Telegram => null, + self::TikTok, self::YouTube, self::Telegram, self::GoogleBusiness => null, }; } @@ -181,6 +186,7 @@ public function supportsAltText(): bool * - Mastodon: 500 default; instances may be higher (we stay conservative) * - Telegram: 4096 for a text message (media captions are capped at 1024, * handled in the publisher by sending long text as its own message) + * - Google Business Profile Local Post `summary`: 1500 */ public function maxContentLength(): int { @@ -197,6 +203,7 @@ public function maxContentLength(): int self::Mastodon => 500, self::Telegram => 4096, self::Discord => 2000, + self::GoogleBusiness => 1500, }; } @@ -242,6 +249,9 @@ public function recommendedAiContentLength(): int self::Telegram => 400, // Discord — conversational community posts read best when concise self::Discord => 280, + // Google Business Profile — image does most of the work, keep the + // summary tight and scannable + self::GoogleBusiness => 300, }; } @@ -265,6 +275,7 @@ public function requiredPublishScopes(): array self::Mastodon => ['write:statuses'], self::Telegram => [], self::Discord => [], + self::GoogleBusiness => ['https://www.googleapis.com/auth/business.manage'], }; } @@ -283,6 +294,7 @@ public function supportsTextOnly(): bool self::Mastodon => true, self::Telegram => true, self::Discord => true, + self::GoogleBusiness => true, }; } @@ -324,7 +336,7 @@ public function hasTokenRefreshFlow(): bool return match ($this) { self::LinkedIn, self::LinkedInPage, self::X, self::Bluesky, self::YouTube, self::TikTok, self::Pinterest, - self::Threads, self::Instagram => true, + self::Threads, self::Instagram, self::GoogleBusiness => true, default => false, }; } @@ -352,6 +364,7 @@ public static function accessTokenExtendingPlatformValues(): array * * - X: a 2-hour access token. * - Instagram / Threads: Meta's 60-day long-lived token. + * - Google Business Profile: standard Google OAuth2 1-hour access token. * * Networks that always return expires_in (LinkedIn, TikTok, YouTube, * Pinterest), whose refresh sets a fixed lifetime directly (Bluesky), or @@ -362,6 +375,7 @@ public function defaultTokenTtlSeconds(): ?int { return match ($this) { self::X => 7200, + self::GoogleBusiness => 3600, self::Instagram, self::Threads => 5184000, default => null, }; diff --git a/app/Exceptions/Social/GoogleBusinessPublishException.php b/app/Exceptions/Social/GoogleBusinessPublishException.php new file mode 100644 index 000000000..001c4f409 --- /dev/null +++ b/app/Exceptions/Social/GoogleBusinessPublishException.php @@ -0,0 +1,96 @@ +status(); + $reason = (string) data_get($response->json(), 'error.status', ''); + $message = (string) data_get($response->json(), 'error.message', ''); + $rawResponse = $response->body(); + + if (self::isConfirmedDeadToken($response)) { + throw new TokenExpiredException( + message: $message !== '' ? $message : 'Google Business Profile access token has expired or been revoked', + platformErrorCode: $reason !== '' ? $reason : (string) $status, + ); + } + + if ($reason === 'PERMISSION_DENIED') { + return new static( + userMessage: 'Permission denied. Please reconnect and confirm access to this business location.', + category: ErrorCategory::Permission, + platformErrorCode: $reason, + rawResponse: $rawResponse, + ); + } + + if ($reason === 'NOT_FOUND') { + return new static( + userMessage: 'Business location not found. It may have been deleted — please reconnect.', + category: ErrorCategory::ContentPolicy, + platformErrorCode: $reason, + rawResponse: $rawResponse, + ); + } + + if ($reason === 'INVALID_ARGUMENT') { + return new static( + userMessage: $message !== '' ? $message : 'Invalid post content. Please check your post details.', + category: ErrorCategory::ContentPolicy, + platformErrorCode: $reason, + rawResponse: $rawResponse, + ); + } + + if ($reason === 'RESOURCE_EXHAUSTED' || $status === 429) { + return new static( + userMessage: 'Rate limit exceeded. Please try again later.', + category: ErrorCategory::RateLimit, + platformErrorCode: $reason !== '' ? $reason : (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status >= 500) { + return new static( + userMessage: 'Google Business Profile server error. Please try again.', + category: ErrorCategory::ServerError, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + return new static( + userMessage: $message !== '' ? $message : $rawResponse, + category: ErrorCategory::Unknown, + platformErrorCode: $reason !== '' ? $reason : (string) $status, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'google_business'; + } + + /** + * Whether this response confirms the account's own access_token is dead + * (not merely a transient or content-specific failure). Shared with + * ConnectionVerifier so both the publish and verify paths agree on what + * a dead Google Business Profile token looks like. + */ + public static function isConfirmedDeadToken(Response $response): bool + { + return $response->status() === 401 + || data_get($response->json(), 'error.status') === 'UNAUTHENTICATED'; + } +} diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php index cac42f7c6..0ebfa04d3 100644 --- a/app/Http/Controllers/App/AnalyticsController.php +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Controller; use App\Models\SocialAccount; use App\Services\Social\FacebookAnalytics; +use App\Services\Social\GoogleBusinessAnalytics; use App\Services\Social\InstagramAnalytics; use App\Services\Social\LinkedInPageAnalytics; use App\Services\Social\PinterestAnalytics; @@ -36,6 +37,7 @@ class AnalyticsController extends Controller Platform::Pinterest, Platform::YouTube, Platform::Telegram, + Platform::GoogleBusiness, ]; public function index(Request $request): Response @@ -82,6 +84,7 @@ public function show(Request $request, SocialAccount $account): JsonResponse Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until), Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until), Platform::Telegram => app(TelegramAnalytics::class)->getMetrics($account), + Platform::GoogleBusiness => app(GoogleBusinessAnalytics::class)->getMetrics($account, $since, $until), default => [], }; diff --git a/app/Http/Controllers/Auth/GoogleBusinessController.php b/app/Http/Controllers/Auth/GoogleBusinessController.php new file mode 100644 index 000000000..a06cd8b1c --- /dev/null +++ b/app/Http/Controllers/Auth/GoogleBusinessController.php @@ -0,0 +1,263 @@ +ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + $this->authorize('manageAccounts', $workspace); + + session([ + 'social_connect_workspace' => $workspace->id, + 'social_reconnect_id' => null, + ]); + + return $this->redirectToGoogle(); + } + + public function callback(Request $request): InertiaResponse|RedirectResponse + { + $workspaceId = session('social_connect_workspace'); + + if (! $workspaceId) { + return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + } + + $workspace = Workspace::find($workspaceId); + + if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { + return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); + } + + $reconnectId = session('social_reconnect_id'); + + try { + $socialUser = Socialite::driver($this->driver)->user(); + + $locations = $this->publisher->fetchLocations($socialUser->token); + + if (empty($locations)) { + return $this->popupCallback(false, __('accounts.popup_callback.no_google_business_locations'), $this->platform->value); + } + + if (count($locations) === 1) { + $this->connectLocation($workspace, $locations[0], $socialUser->token, $socialUser->refreshToken, $socialUser->expiresIn, $socialUser->getId()); + + return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + } + + session([ + 'google_business_oauth' => [ + 'access_token' => $socialUser->token, + 'refresh_token' => $socialUser->refreshToken, + 'expires_in' => $socialUser->expiresIn, + 'user_id' => $socialUser->getId(), + 'reconnect_id' => $reconnectId, + 'locations' => $locations, + ], + ]); + + return redirect()->route('app.social.google-business.select-location'); + } catch (NetworkAlreadyConnectedException) { + return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); + } catch (\Exception $e) { + Log::error('Google Business Profile OAuth Error', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value); + } + } + + public function selectLocation(Request $request): InertiaResponse + { + $oauthData = session('google_business_oauth'); + $workspaceId = session('social_connect_workspace'); + + if (! $oauthData || ! $workspaceId) { + return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + } + + $workspace = Workspace::find($workspaceId); + + if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { + return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); + } + + $locations = data_get($oauthData, 'locations', []); + + if (empty($locations)) { + $this->forgetSocialConnectSession(); + session()->forget('google_business_oauth'); + + return $this->popupCallback(false, __('accounts.popup_callback.no_google_business_locations'), $this->platform->value); + } + + return Inertia::render('accounts/GoogleBusinessLocationSelect', [ + 'workspace' => $workspace, + 'locations' => $locations, + ]); + } + + public function select(SelectGoogleBusinessLocationRequest $request): InertiaResponse + { + $oauthData = session('google_business_oauth'); + $workspaceId = session('social_connect_workspace'); + + if (! $oauthData || ! $workspaceId) { + return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + } + + $workspace = Workspace::find($workspaceId); + + if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { + return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); + } + + try { + $selectedLocation = collect(data_get($oauthData, 'locations')) + ->firstWhere('id', $request->validated('location_id')); + + if (! $selectedLocation) { + session()->forget(['google_business_oauth', 'social_reconnect_id']); + + return $this->popupCallback(false, __('accounts.popup_callback.location_not_found'), $this->platform->value); + } + + $reconnectId = data_get($oauthData, 'reconnect_id'); + + if ($reconnectId) { + $existingAccount = $workspace->socialAccounts()->find($reconnectId); + + if ($existingAccount) { + $existingAccount->update([ + ...$this->locationAttributes( + $selectedLocation, + data_get($oauthData, 'access_token'), + data_get($oauthData, 'refresh_token'), + data_get($oauthData, 'expires_in'), + data_get($oauthData, 'user_id'), + ), + 'platform_user_id' => data_get($selectedLocation, 'id'), + ]); + $existingAccount->markAsConnected(); + + session()->forget(['google_business_oauth', 'social_reconnect_id']); + + return $this->popupCallback(true, __('accounts.popup_callback.reconnected'), $this->platform->value); + } + } + + $this->connectLocation( + $workspace, + $selectedLocation, + data_get($oauthData, 'access_token'), + data_get($oauthData, 'refresh_token'), + data_get($oauthData, 'expires_in'), + data_get($oauthData, 'user_id'), + ); + + session()->forget(['google_business_oauth', 'social_reconnect_id']); + + return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + } catch (NetworkAlreadyConnectedException) { + return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); + } catch (\Exception $e) { + Log::error('Google Business Profile location selection error', [ + 'error' => $e->getMessage(), + ]); + + return $this->popupCallback(false, __('accounts.popup_callback.error_connecting_location'), $this->platform->value); + } + } + + private function connectLocation(Workspace $workspace, array $location, string $accessToken, ?string $refreshToken, ?int $expiresIn, ?string $googleUserId): void + { + $workspace->socialAccounts()->updateOrCreate( + [ + 'platform' => $this->platform->value, + 'platform_user_id' => data_get($location, 'id'), + ], + [ + ...$this->locationAttributes($location, $accessToken, $refreshToken, $expiresIn, $googleUserId), + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + ], + ); + } + + /** + * The social account attributes derived from a picked location and its OAuth + * tokens. Shared by the fresh-connect and reconnect paths so both store the + * same shape. + * + * @return array + */ + private function locationAttributes(array $location, string $accessToken, ?string $refreshToken, ?int $expiresIn, ?string $googleUserId): array + { + return [ + 'username' => data_get($location, 'title'), + 'display_name' => data_get($location, 'title'), + 'access_token' => $accessToken, + 'refresh_token' => $refreshToken, + 'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null, + 'scopes' => $this->scopes, + 'meta' => [ + 'location_id' => data_get($location, 'id'), + 'account_name' => data_get($location, 'account_name'), + 'location_name' => data_get($location, 'location_name'), + 'google_user_id' => $googleUserId, + ], + ]; + } + + private function redirectToGoogle(): Response + { + return Inertia::location( + Socialite::driver($this->driver) + ->scopes($this->scopes) + ->with([ + 'access_type' => 'offline', + 'prompt' => 'consent', + 'include_granted_scopes' => 'true', + ]) + ->redirect() + ->getTargetUrl() + ); + } +} diff --git a/app/Http/Requests/Auth/SelectGoogleBusinessLocationRequest.php b/app/Http/Requests/Auth/SelectGoogleBusinessLocationRequest.php new file mode 100644 index 000000000..2578b5d3b --- /dev/null +++ b/app/Http/Requests/Auth/SelectGoogleBusinessLocationRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'location_id' => ['required', 'string'], + ]; + } +} diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 106a9f92e..81c3b0b49 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -22,6 +22,7 @@ use App\Services\Social\ConnectionVerifier; use App\Services\Social\Discord\DiscordPublisher; use App\Services\Social\FacebookPublisher; +use App\Services\Social\GoogleBusinessPublisher; use App\Services\Social\InstagramPublisher; use App\Services\Social\LinkedInPagePublisher; use App\Services\Social\LinkedInPublisher; @@ -337,7 +338,7 @@ private function safeFailureMessage(Throwable $e): string : 'An unexpected error occurred while publishing. Please try again.'; } - private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher + private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher|GoogleBusinessPublisher { return match ($this->postPlatform->platform) { SocialPlatform::LinkedIn => app(LinkedInPublisher::class), @@ -353,6 +354,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis SocialPlatform::Mastodon => app(MastodonPublisher::class), SocialPlatform::Telegram => app(TelegramPublisher::class), SocialPlatform::Discord => app(DiscordPublisher::class), + SocialPlatform::GoogleBusiness => app(GoogleBusinessPublisher::class), }; } diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 61adc357b..a0815d49c 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -11,6 +11,7 @@ use App\Jobs\SendNotification; use App\Mail\AccountDisconnected; use App\Observers\SocialAccountObserver; +use App\Support\GoogleBusinessResourceName; use Database\Factories\SocialAccountFactory; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Builder; @@ -146,6 +147,9 @@ protected function profileUrl(): Attribute ? rtrim((string) data_get($this->meta, 'instance'), '/')."/@{$username}" : null, SocialPlatform::Telegram => $username ? "https://t.me/{$username}" : null, + SocialPlatform::GoogleBusiness => data_get($this->meta, 'location_id') + ? GoogleBusinessResourceName::dashboardUrl((string) data_get($this->meta, 'location_id')) + : null, default => null, }; }, diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2f18e9068..350b4b4e5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -188,6 +188,13 @@ protected function configureSocialite(): void return Socialite::buildProvider(GoogleProvider::class, $config); }); + // Google Business Profile — dedicated app, separate from 'google' (YouTube). + Socialite::extend('google-business', function ($app) { + $config = $app['config']['services.google-business']; + + return Socialite::buildProvider(GoogleProvider::class, $config); + }); + // Instagram Business Login Socialite::extend('instagram', function ($app) { $config = $app['config']['services.instagram']; diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index 3370a4a0c..35ed77b94 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -354,6 +354,12 @@ private function getImageConfig(Platform $platform): array 'format' => 'image/jpeg', 'quality' => 100, ], + Platform::GoogleBusiness => [ + 'max_width' => 2048, + 'max_size' => 5 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 100, + ], }; } } diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 954e83298..d8fb165e7 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -8,6 +8,8 @@ use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\BlueskyPublishException; use App\Exceptions\Social\DiscordPublishException; +use App\Exceptions\Social\ErrorCategory; +use App\Exceptions\Social\GoogleBusinessPublishException; use App\Exceptions\Social\LinkedInPublishException; use App\Exceptions\Social\MastodonPublishException; use App\Exceptions\Social\PinterestPublishException; @@ -115,6 +117,7 @@ private function callVerifyEndpoint(SocialAccount $account): bool Platform::Mastodon => $this->verifyMastodon($account), Platform::Telegram => $this->verifyTelegram($account), Platform::Discord => $this->verifyDiscord($account), + Platform::GoogleBusiness => $this->verifyGoogleBusiness($account), }; } @@ -148,6 +151,7 @@ public function refreshToken(SocialAccount $account): void Platform::Pinterest => $this->refreshPinterestToken($account), Platform::Threads => $this->refreshThreadsToken($account), Platform::Instagram => $this->refreshInstagramToken($account), + Platform::GoogleBusiness => $this->refreshGoogleBusinessToken($account), // Facebook / InstagramFacebook use Page tokens that don't expire. // Mastodon tokens don't expire either. default => null, @@ -375,6 +379,30 @@ private function refreshInstagramToken(SocialAccount $account): void $account->refresh(); } + private function refreshGoogleBusinessToken(SocialAccount $account): void + { + if (! $account->refresh_token) { + throw new TokenExpiredException('No refresh token available for Google Business Profile account'); + } + + $response = TokenRefreshClient::for(Platform::GoogleBusiness)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.google_business.oauth_api').'/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.google-business.client_id'), + 'client_secret' => config('services.google-business.client_secret'), + ])); + + $data = $response->json(); + + $account->update([ + 'access_token' => data_get($data, 'access_token'), + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + ]); + + $account->refresh(); + } + private function verifyLinkedIn(SocialAccount $account): bool { $response = Http::withToken($account->access_token) @@ -641,4 +669,34 @@ private function verifyMastodon(SocialAccount $account): bool $response->status(), ); } + + private function verifyGoogleBusiness(SocialAccount $account): bool + { + $locationName = (string) data_get($account->meta, 'location_name'); + + if (blank($locationName)) { + throw new GoogleBusinessPublishException( + userMessage: 'This Google Business Profile account has no location configured. Please reconnect it.', + category: ErrorCategory::Permission, + ); + } + + $response = Http::withToken($account->access_token) + ->get(config('trypost.platforms.google_business.business_information_api')."/{$locationName}", [ + 'readMask' => 'name', + ]); + + if (GoogleBusinessPublishException::isConfirmedDeadToken($response)) { + throw new TokenExpiredException('Google Business Profile access token is invalid or expired'); + } + + if ($response->successful()) { + return true; + } + + throw new PlatformUnavailableException( + "{$account->platform->label()} verify failed ({$response->status()}).", + $response->status(), + ); + } } diff --git a/app/Services/Social/GoogleBusinessAnalytics.php b/app/Services/Social/GoogleBusinessAnalytics.php new file mode 100644 index 000000000..a2a037f66 --- /dev/null +++ b/app/Services/Social/GoogleBusinessAnalytics.php @@ -0,0 +1,113 @@ + Google metric enum => translation key. */ + private const METRICS = [ + 'WEBSITE_CLICKS' => 'analytics.metrics.website_clicks', + 'CALL_CLICKS' => 'analytics.metrics.call_clicks', + 'BUSINESS_DIRECTION_REQUESTS' => 'analytics.metrics.direction_requests', + 'BUSINESS_IMPRESSIONS_DESKTOP_MAPS' => 'analytics.metrics.desktop_map_impressions', + 'BUSINESS_IMPRESSIONS_MOBILE_MAPS' => 'analytics.metrics.mobile_map_impressions', + ]; + + private string $baseUrl; + + public function __construct() + { + $this->baseUrl = config('trypost.platforms.google_business.performance_api'); + } + + public function getMetrics(SocialAccount $account, ?CarbonInterface $since = null, ?CarbonInterface $until = null): array + { + $since ??= now()->subDays(7); + $until ??= now(); + + $cacheKey = "analytics:google_business:{$account->id}:{$since->format('Y-m-d')}:{$until->format('Y-m-d')}"; + $cacheTtl = app()->isProduction() ? 3600 : 1; + + return Cache::remember($cacheKey, $cacheTtl, fn () => $this->fetchMetricsFromApi($account, $since, $until)); + } + + private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array + { + if ($account->needsProactiveTokenRefresh()) { + app(ConnectionVerifier::class)->refreshToken($account); + } + + $locationName = (string) data_get($account->meta, 'location_name'); + + if (blank($locationName)) { + return []; + } + + $response = $this->socialHttp()->withToken($account->access_token) + ->get("{$this->baseUrl}/{$locationName}:fetchMultiDailyMetricsTimeSeries?{$this->buildQuery($since, $until)}"); + + if ($response->failed()) { + Log::warning('Google Business Profile analytics fetch failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return []; + } + + $series = data_get($response->json(), 'multiDailyMetricTimeSeries.0.dailyMetricTimeSeries', []); + + $totals = collect(self::METRICS)->mapWithKeys(fn ($labelKey, $metric) => [$metric => 0])->all(); + + foreach ($series as $entry) { + $metric = data_get($entry, 'dailyMetric'); + + if (! array_key_exists($metric, $totals)) { + continue; + } + + $values = collect(data_get($entry, 'timeSeries.datedValues', [])) + ->sum(fn ($value) => (int) data_get($value, 'value', 0)); + + $totals[$metric] = $values; + } + + return collect(self::METRICS) + ->map(fn (string $labelKey, string $metric) => ['label' => __($labelKey), 'value' => $totals[$metric]]) + ->values() + ->all(); + } + + /** + * Google expects `dailyMetrics` as repeated scalar params, which + * `http_build_query` (and therefore the HTTP client's array query support) + * would encode as `dailyMetrics[0]=...` instead. + */ + private function buildQuery(CarbonInterface $since, CarbonInterface $until): string + { + $metrics = implode('&', array_map( + fn (string $metric): string => 'dailyMetrics='.urlencode($metric), + array_keys(self::METRICS), + )); + + $range = http_build_query([ + 'dailyRange.start_date.year' => $since->format('Y'), + 'dailyRange.start_date.month' => $since->format('n'), + 'dailyRange.start_date.day' => $since->format('j'), + 'dailyRange.end_date.year' => $until->format('Y'), + 'dailyRange.end_date.month' => $until->format('n'), + 'dailyRange.end_date.day' => $until->format('j'), + ]); + + return "{$metrics}&{$range}"; + } +} diff --git a/app/Services/Social/GoogleBusinessPublisher.php b/app/Services/Social/GoogleBusinessPublisher.php new file mode 100644 index 000000000..fbd999b20 --- /dev/null +++ b/app/Services/Social/GoogleBusinessPublisher.php @@ -0,0 +1,318 @@ +accountManagementUrl = config('trypost.platforms.google_business.account_management_api'); + $this->businessInformationUrl = config('trypost.platforms.google_business.business_information_api'); + $this->localPostsUrl = config('trypost.platforms.google_business.local_posts_api'); + } + + public function publish(PostPlatform $postPlatform): array + { + $this->validateContentLength($postPlatform); + + $account = $postPlatform->socialAccount; + + if ($account->needsProactiveTokenRefresh()) { + app(ConnectionVerifier::class)->refreshToken($account); + } + + $content = $postPlatform->post->content + ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, Platform::GoogleBusiness) + : ''; + + $locationId = (string) data_get($account->meta, 'location_id'); + + if (blank($locationId)) { + throw new GoogleBusinessPublishException( + userMessage: 'This Google Business Profile account has no location configured. Please reconnect it.', + category: ErrorCategory::Permission, + ); + } + + $payload = $this->buildPayload($postPlatform, $content); + + $response = $this->socialHttp()->withToken($account->access_token) + ->post("{$this->localPostsUrl}/{$locationId}/localPosts", $payload); + + if ($response->failed()) { + Log::error('Google Business Profile post creation failed', [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); + } + + $postName = (string) data_get($response->json(), 'name'); + + return [ + 'id' => $postName, + 'url' => GoogleBusinessResourceName::dashboardUrl($locationId), + ]; + } + + /** + * `id` is the full `accounts/{id}/locations/{id}` name the v4 Local Posts API + * needs as its parent; `location_name` is the short `locations/{id}` name the + * v1 Business Information and Performance APIs expect. + * + * @return list + */ + public function fetchLocations(string $accessToken): array + { + $locations = []; + + foreach ($this->fetchAccounts($accessToken) as $accountName) { + array_push($locations, ...$this->fetchLocationsForAccount($accessToken, $accountName)); + } + + return $locations; + } + + /** + * @return array The Local Post request body. + */ + private function buildPayload(PostPlatform $postPlatform, string $content): array + { + $languageCode = $postPlatform->post->workspace->content_language ?? ContentLanguage::DEFAULT->value; + $topicType = (string) (data_get($postPlatform->meta, 'topic_type') ?? 'STANDARD'); + + $payload = [ + 'languageCode' => $languageCode, + 'summary' => $content, + 'topicType' => $topicType, + ]; + + $callToActionType = data_get($postPlatform->meta, 'call_to_action.action_type'); + + if (filled($callToActionType) && $callToActionType !== 'NONE') { + $callToAction = ['actionType' => $callToActionType]; + + if ($callToActionType !== 'CALL') { + $callToAction['url'] = data_get($postPlatform->meta, 'call_to_action.url'); + } + + $payload['callToAction'] = $callToAction; + } + + $media = $postPlatform->post->mediaItems->first(fn ($item) => $item->isImage()); + + if ($media) { + $payload['media'] = [[ + 'mediaFormat' => 'PHOTO', + 'sourceUrl' => $media->url, + ]]; + } + + if (in_array($topicType, PostPlatformMetaRules::GOOGLE_BUSINESS_EVENT_TOPIC_TYPES, true)) { + $payload['event'] = $this->buildEvent($postPlatform); + } + + if ($topicType === 'OFFER') { + $offer = $this->buildOffer($postPlatform); + + if ($offer !== []) { + $payload['offer'] = $offer; + } + } + + return $payload; + } + + /** + * The v4 Local Posts API requires `event` for both the EVENT and OFFER topic + * types, so both read the same `meta.event.*` fields. + */ + private function buildEvent(PostPlatform $postPlatform): array + { + $title = (string) data_get($postPlatform->meta, 'event.title'); + + if (blank($title)) { + throw new GoogleBusinessPublishException( + userMessage: 'This Google Business Profile post needs an event title. Please add one and try again.', + category: ErrorCategory::ContentPolicy, + ); + } + + $startDate = (string) data_get($postPlatform->meta, 'event.start_date'); + $endDate = (string) data_get($postPlatform->meta, 'event.end_date'); + + if (blank($startDate) || blank($endDate)) { + throw new GoogleBusinessPublishException( + userMessage: 'This Google Business Profile post needs an event start and end date. Please add them and try again.', + category: ErrorCategory::ContentPolicy, + ); + } + + $schedule = [ + 'startDate' => $this->formatDate($startDate), + 'endDate' => $this->formatDate($endDate), + ]; + + if (filled(data_get($postPlatform->meta, 'event.start_time'))) { + $schedule['startTime'] = $this->formatTime((string) data_get($postPlatform->meta, 'event.start_time')); + } + + if (filled(data_get($postPlatform->meta, 'event.end_time'))) { + $schedule['endTime'] = $this->formatTime((string) data_get($postPlatform->meta, 'event.end_time')); + } + + return [ + 'title' => $title, + 'schedule' => $schedule, + ]; + } + + private function buildOffer(PostPlatform $postPlatform): array + { + return array_filter([ + 'couponCode' => data_get($postPlatform->meta, 'offer.coupon_code'), + 'redeemOnlineUrl' => data_get($postPlatform->meta, 'offer.redeem_online_url'), + 'termsConditions' => data_get($postPlatform->meta, 'offer.terms_conditions'), + ], fn ($value) => filled($value)); + } + + /** + * @return array{year: int, month: int, day: int} + */ + private function formatDate(string $date): array + { + $carbon = CarbonImmutable::parse($date); + + return ['year' => (int) $carbon->format('Y'), 'month' => (int) $carbon->format('n'), 'day' => (int) $carbon->format('j')]; + } + + /** + * @return array{hours: int, minutes: int, seconds: int, nanos: int} + */ + private function formatTime(string $time): array + { + $carbon = CarbonImmutable::parse($time); + + return ['hours' => (int) $carbon->format('G'), 'minutes' => (int) $carbon->format('i'), 'seconds' => 0, 'nanos' => 0]; + } + + /** + * @return list Full "accounts/{id}" resource names. + */ + private function fetchAccounts(string $accessToken): array + { + $accounts = []; + $pageToken = null; + + do { + $response = $this->socialHttp()->withToken($accessToken) + ->get("{$this->accountManagementUrl}/accounts", array_filter([ + 'pageSize' => 100, + 'pageToken' => $pageToken, + ])); + + if ($response->failed()) { + Log::error('Google Business Profile accounts fetch failed', [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); + } + + $data = $response->json() ?? []; + + foreach (data_get($data, 'accounts', []) as $account) { + $accounts[] = (string) data_get($account, 'name'); + } + + $pageToken = data_get($data, 'nextPageToken'); + } while (filled($pageToken)); + + return $accounts; + } + + /** + * @return list + */ + private function fetchLocationsForAccount(string $accessToken, string $accountName): array + { + $locations = []; + $pageToken = null; + + do { + $response = $this->socialHttp()->withToken($accessToken) + ->get("{$this->businessInformationUrl}/{$accountName}/locations", array_filter([ + 'readMask' => 'name,title,storefrontAddress,metadata', + 'pageSize' => 100, + 'pageToken' => $pageToken, + ])); + + if ($response->failed()) { + Log::error('Google Business Profile locations fetch failed', [ + 'account' => $accountName, + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); + } + + $data = $response->json() ?? []; + + foreach (data_get($data, 'locations', []) as $location) { + $shortName = (string) data_get($location, 'name'); + + $locations[] = [ + 'id' => GoogleBusinessResourceName::toFullLocationName($accountName, $shortName), + 'account_name' => $accountName, + 'location_name' => $shortName, + 'title' => (string) data_get($location, 'title'), + 'address' => $this->formatAddress(data_get($location, 'storefrontAddress')), + ]; + } + + $pageToken = data_get($data, 'nextPageToken'); + } while (filled($pageToken)); + + return $locations; + } + + private function formatAddress(?array $storefrontAddress): ?string + { + if (! $storefrontAddress) { + return null; + } + + $lines = (array) data_get($storefrontAddress, 'addressLines', []); + $locality = data_get($storefrontAddress, 'locality'); + $parts = array_filter([implode(' ', $lines), $locality]); + + return $parts === [] ? null : implode(', ', $parts); + } + + private function handleApiError(Response $response): never + { + throw GoogleBusinessPublishException::fromApiResponse($response); + } +} diff --git a/app/Support/GoogleBusinessResourceName.php b/app/Support/GoogleBusinessResourceName.php new file mode 100644 index 000000000..f2d19abfd --- /dev/null +++ b/app/Support/GoogleBusinessResourceName.php @@ -0,0 +1,40 @@ + + */ + public const GOOGLE_BUSINESS_EVENT_TOPIC_TYPES = ['EVENT', 'OFFER']; + /** * Validation rules for `platforms.*.meta` and all its per-platform sub-keys. * Spread into a FormRequest/MCP tool rule set as the complete meta contract. @@ -76,6 +84,22 @@ public static function rules(): array 'platforms.*.meta.embeds.*.url' => ['sometimes', 'nullable', 'url'], 'platforms.*.meta.embeds.*.image' => ['sometimes', 'nullable', 'url'], 'platforms.*.meta.embeds.*.color' => ['sometimes', 'nullable', 'string', 'regex:/^#?[0-9A-Fa-f]{6}$/'], + + // Google Business Profile + 'platforms.*.meta.topic_type' => ['sometimes', 'nullable', 'string', Rule::in(['STANDARD', 'EVENT', 'OFFER'])], + 'platforms.*.meta.call_to_action' => ['sometimes', 'nullable', 'array'], + 'platforms.*.meta.call_to_action.action_type' => ['sometimes', 'nullable', 'string', Rule::in(['NONE', 'BOOK', 'ORDER', 'SHOP', 'LEARN_MORE', 'SIGN_UP', 'GET_OFFER', 'CALL'])], + 'platforms.*.meta.call_to_action.url' => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], + 'platforms.*.meta.event' => ['sometimes', 'nullable', 'array'], + 'platforms.*.meta.event.title' => ['sometimes', 'nullable', 'string', 'max:100'], + 'platforms.*.meta.event.start_date' => ['sometimes', 'nullable', 'date'], + 'platforms.*.meta.event.end_date' => ['sometimes', 'nullable', 'date', 'after_or_equal:platforms.*.meta.event.start_date'], + 'platforms.*.meta.event.start_time' => ['sometimes', 'nullable', 'date_format:H:i'], + 'platforms.*.meta.event.end_time' => ['sometimes', 'nullable', 'date_format:H:i'], + 'platforms.*.meta.offer' => ['sometimes', 'nullable', 'array'], + 'platforms.*.meta.offer.coupon_code' => ['sometimes', 'nullable', 'string', 'max:58'], + 'platforms.*.meta.offer.redeem_online_url' => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], + 'platforms.*.meta.offer.terms_conditions' => ['sometimes', 'nullable', 'string', 'max:5000'], ]; } @@ -103,6 +127,8 @@ public static function attributes(): array return [ 'platforms.*.meta.title' => __('posts.form.pinterest.title'), 'platforms.*.meta.link' => __('posts.form.pinterest.link'), + 'platforms.*.meta.event.title' => __('posts.form.google_business.event_title'), + 'platforms.*.meta.call_to_action.url' => __('posts.form.google_business.cta_url'), ]; } @@ -162,10 +188,22 @@ public static function assertStoredPostPublishable(Post $post): void */ private static function requiredMetaViolation(?Platform $platform, mixed $meta): ?array { + $needsGoogleBusinessEvent = $platform === Platform::GoogleBusiness + && in_array(data_get($meta, 'topic_type') ?? 'STANDARD', self::GOOGLE_BUSINESS_EVENT_TOPIC_TYPES, true); + return match (true) { $platform === Platform::TikTok && blank(data_get($meta, 'privacy_level')) => ['privacy_level', trans('posts.form.tiktok.privacy_required')], $platform === Platform::Pinterest && blank(data_get($meta, 'board_id')) => ['board_id', trans('posts.form.pinterest.board_required')], $platform === Platform::Discord && blank(data_get($meta, 'channel_id')) => ['channel_id', trans('posts.form.discord.channel_required')], + $needsGoogleBusinessEvent + && blank(data_get($meta, 'event.title')) => ['event.title', trans('posts.form.google_business.event_title_required')], + $needsGoogleBusinessEvent + && blank(data_get($meta, 'event.start_date')) => ['event.start_date', trans('posts.form.google_business.event_start_date_required')], + $needsGoogleBusinessEvent + && blank(data_get($meta, 'event.end_date')) => ['event.end_date', trans('posts.form.google_business.event_end_date_required')], + $platform === Platform::GoogleBusiness + && ! in_array(data_get($meta, 'call_to_action.action_type') ?? 'NONE', ['NONE', 'CALL'], true) + && blank(data_get($meta, 'call_to_action.url')) => ['call_to_action.url', trans('posts.form.google_business.cta_url_required')], default => null, }; } diff --git a/config/services.php b/config/services.php index 4b5d5690f..94f3e58b0 100644 --- a/config/services.php +++ b/config/services.php @@ -117,6 +117,15 @@ 'redirect' => env('DISCORD_CLIENT_REDIRECT'), ], + // Google Business Profile — dedicated OAuth app, isolated from 'google' + // (YouTube) so adding the sensitive business.manage scope never triggers + // Google to re-review the YouTube app's already-verified scope set. + 'google-business' => [ + 'client_id' => env('GOOGLE_BUSINESS_CLIENT_ID'), + 'client_secret' => env('GOOGLE_BUSINESS_CLIENT_SECRET'), + 'redirect' => env('GOOGLE_BUSINESS_CLIENT_REDIRECT'), + ], + 'gtm' => [ 'id' => env('GTM_ID'), ], diff --git a/config/trypost.php b/config/trypost.php index be8f0a55d..a15534550 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -220,6 +220,19 @@ 'permissions' => env('DISCORD_PERMISSIONS', '248832'), 'scopes' => array_values(array_filter(array_map('trim', explode(',', (string) env('DISCORD_SCOPES', 'bot,identify,guilds'))))), ], + 'google_business' => [ + 'enabled' => env('GOOGLE_BUSINESS_ENABLED', true), + // Account Management API — lists the Business accounts a user administers. + 'account_management_api' => env('GOOGLE_BUSINESS_ACCOUNT_MANAGEMENT_API', 'https://mybusinessaccountmanagement.googleapis.com/v1'), + // Business Information API — lists locations under an account. + 'business_information_api' => env('GOOGLE_BUSINESS_BUSINESS_INFORMATION_API', 'https://mybusinessbusinessinformation.googleapis.com/v1'), + // Legacy but still-active v4 API — the only home for Local Post create/update/delete. + 'local_posts_api' => env('GOOGLE_BUSINESS_LOCAL_POSTS_API', 'https://mybusiness.googleapis.com/v4'), + // Business Profile Performance API — location-level analytics. + 'performance_api' => env('GOOGLE_BUSINESS_PERFORMANCE_API', 'https://businessprofileperformance.googleapis.com/v1'), + // OAuth token endpoint, same host Google uses for every OAuth2 client. + 'oauth_api' => env('GOOGLE_BUSINESS_OAUTH_API', 'https://oauth2.googleapis.com'), + ], ], ]; diff --git a/database/factories/PostPlatformFactory.php b/database/factories/PostPlatformFactory.php index cb39e970f..7312e6ea8 100644 --- a/database/factories/PostPlatformFactory.php +++ b/database/factories/PostPlatformFactory.php @@ -131,6 +131,14 @@ public function pinterest(): static ]); } + public function googleBusiness(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::GoogleBusiness, + 'content_type' => ContentType::GoogleBusinessPost, + ]); + } + public function pinterestVideoPin(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index 927aba12a..3087f9fb9 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -109,6 +109,20 @@ public function pinterest(): static ]); } + public function googleBusiness(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::GoogleBusiness, + 'scopes' => Platform::GoogleBusiness->requiredPublishScopes(), + 'meta' => [ + 'location_id' => 'accounts/123456789/locations/987654321', + 'account_name' => 'accounts/123456789', + 'location_name' => 'locations/987654321', + 'google_user_id' => 'google-user-123', + ], + ]); + } + public function bluesky(): static { return $this->state(fn (array $attributes) => [ diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index 46971781f..a323eb9e2 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'اربط حسابك على Mastodon', 'telegram' => 'اربط قناة أو مجموعة على Telegram', 'discord' => 'اربط خادم Discord', + 'google_business' => 'اربط موقع Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'لم يتم العثور على صفحات Facebook مرتبطة بحسابات Instagram.', 'no_youtube_channels' => 'لم يتم العثور على قنوات YouTube. يرجى إنشاء قناة أولًا.', 'not_linkedin_admin' => 'أنت لست مشرفًا على أي صفحة LinkedIn.', + 'no_google_business_locations' => 'لم يتم العثور على مواقع Google Business Profile. يرجى التحقق من نشاطك التجاري أولاً.', + 'location_not_found' => 'الموقع غير موجود.', + 'error_connecting_location' => 'خطأ في ربط الموقع. يرجى المحاولة مرة أخرى.', + ], + + 'google_business' => [ + 'title' => 'اختر موقع النشاط التجاري', + 'description' => 'اختر الموقع الذي تريد ربطه', + 'no_locations' => 'لم يتم العثور على مواقع', + 'no_locations_description' => 'أنت لست مديرًا لأي موقع تم التحقق منه في Google Business Profile.', + 'choose' => 'اختيار', ], ]; diff --git a/lang/ar/analytics.php b/lang/ar/analytics.php index 025526c5f..ade67406c 100644 --- a/lang/ar/analytics.php +++ b/lang/ar/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'مشاهدات الفيديو', 'videos' => 'مقاطع الفيديو', 'views' => 'المشاهدات', + 'website_clicks' => 'نقرات الموقع الإلكتروني', + 'call_clicks' => 'نقرات الاتصال', + 'direction_requests' => 'طلبات الاتجاهات', + 'desktop_map_impressions' => 'مرات ظهور الخريطة على سطح المكتب', + 'mobile_map_impressions' => 'مرات ظهور الخريطة على الجوال', ], ]; diff --git a/lang/ar/posts.php b/lang/ar/posts.php index c61611e59..80609ca27 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -186,6 +186,42 @@ 'embed_image' => 'رابط الصورة', 'embed_color' => 'اللون', ], + 'google_business' => [ + 'settings' => 'إعدادات ملف Google Business Profile', + 'posting_to' => 'النشر إلى', + 'topic_type_label' => 'نوع المنشور', + 'topic_type' => [ + 'standard' => 'تحديث', + 'event' => 'حدث', + 'offer' => 'عرض', + ], + 'cta_label' => 'زر', + 'cta_none' => 'بدون زر', + 'cta' => [ + 'book' => 'احجز', + 'order' => 'اطلب عبر الإنترنت', + 'shop' => 'اشتر', + 'learn_more' => 'تعرف على المزيد', + 'sign_up' => 'اشترك', + 'get_offer' => 'احصل على العرض', + 'call' => 'اتصل الآن', + ], + 'cta_url' => 'رابط الزر', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'أدخل رابطًا لهذا الزر، أو اختر "بدون زر".', + 'event_title' => 'عنوان الحدث', + 'event_title_placeholder' => 'عرض الصيف', + 'event_title_required' => 'أدخل عنوان الحدث.', + 'event_start_date' => 'تاريخ البدء', + 'event_start_date_required' => 'أدخل تاريخ البدء.', + 'event_end_date' => 'تاريخ الانتهاء', + 'event_end_date_required' => 'أدخل تاريخ الانتهاء.', + 'event_start_time' => 'وقت البدء', + 'event_end_time' => 'وقت الانتهاء', + 'offer_coupon_code' => 'رمز القسيمة', + 'offer_redeem_url' => 'رابط الاسترداد عبر الإنترنت', + 'offer_terms' => 'الشروط والأحكام', + ], 'warnings' => [ 'no_variant' => 'اختر نوع منشور للمتابعة.', 'requires_media' => 'يتطلب هذا النوع من المنشورات صورة أو فيديو واحدًا على الأقل.', @@ -550,6 +586,10 @@ 'label' => 'رسالة', 'description' => 'رسالة إلى قناة Discord مع وسائط وتضمينات اختيارية', ], + 'google_business_post' => [ + 'label' => 'منشور', + 'description' => 'يظهر على ملفك التجاري في البحث والخرائط', + ], ], 'platforms' => [ diff --git a/lang/de/accounts.php b/lang/de/accounts.php index efb4801bb..cd4fbae8f 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -31,6 +31,7 @@ 'mastodon' => 'Verbinde dein Mastodon-Konto', 'telegram' => 'Verbinde einen Telegram-Kanal oder eine Telegram-Gruppe', 'discord' => 'Verbinde einen Discord-Server', + 'google_business' => 'Verbinde einen Google Unternehmensprofil-Standort', ], 'disconnect_modal' => [ @@ -155,5 +156,16 @@ 'no_facebook_instagram_pages' => 'Keine Facebook-Seiten mit verknüpften Instagram-Konten gefunden.', 'no_youtube_channels' => 'Keine YouTube-Kanäle gefunden. Bitte erstelle zuerst einen Kanal.', 'not_linkedin_admin' => 'Du bist kein Administrator einer LinkedIn-Seite.', + 'no_google_business_locations' => 'Keine Google Unternehmensprofil-Standorte gefunden. Bestätige zuerst dein Unternehmen.', + 'location_not_found' => 'Standort nicht gefunden.', + 'error_connecting_location' => 'Fehler beim Verbinden des Standorts. Bitte versuche es erneut.', + ], + + 'google_business' => [ + 'title' => 'Standort auswählen', + 'description' => 'Wähle aus, welchen Standort du verbinden möchtest', + 'no_locations' => 'Keine Standorte gefunden', + 'no_locations_description' => 'Du bist kein Manager eines verifizierten Google Unternehmensprofil-Standorts.', + 'choose' => 'Auswählen', ], ]; diff --git a/lang/de/analytics.php b/lang/de/analytics.php index 57e97d26f..c79c8e57b 100644 --- a/lang/de/analytics.php +++ b/lang/de/analytics.php @@ -53,5 +53,10 @@ 'video_views' => 'Videoaufrufe', 'videos' => 'Videos', 'views' => 'Aufrufe', + 'website_clicks' => 'Website-Klicks', + 'call_clicks' => 'Anruf-Klicks', + 'direction_requests' => 'Routenanfragen', + 'desktop_map_impressions' => 'Kartenimpressionen auf dem Desktop', + 'mobile_map_impressions' => 'Kartenimpressionen auf Mobilgeräten', ], ]; diff --git a/lang/de/posts.php b/lang/de/posts.php index bd79bfa3e..440b8c083 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -188,6 +188,42 @@ 'embed_image' => 'Bild-URL', 'embed_color' => 'Farbe', ], + 'google_business' => [ + 'settings' => 'Google Business Profile-Einstellungen', + 'posting_to' => 'Veröffentlichen auf', + 'topic_type_label' => 'Beitragstyp', + 'topic_type' => [ + 'standard' => 'Aktualisierung', + 'event' => 'Veranstaltung', + 'offer' => 'Angebot', + ], + 'cta_label' => 'Schaltfläche', + 'cta_none' => 'Keine Schaltfläche', + 'cta' => [ + 'book' => 'Buchen', + 'order' => 'Online bestellen', + 'shop' => 'Kaufen', + 'learn_more' => 'Mehr erfahren', + 'sign_up' => 'Anmelden', + 'get_offer' => 'Angebot nutzen', + 'call' => 'Jetzt anrufen', + ], + 'cta_url' => 'Schaltflächenlink', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Gib einen Link für diese Schaltfläche ein oder wähle "Keine Schaltfläche".', + 'event_title' => 'Veranstaltungstitel', + 'event_title_placeholder' => 'Sommerverkauf', + 'event_title_required' => 'Gib einen Veranstaltungstitel ein.', + 'event_start_date' => 'Startdatum', + 'event_start_date_required' => 'Gib ein Startdatum ein.', + 'event_end_date' => 'Enddatum', + 'event_end_date_required' => 'Gib ein Enddatum ein.', + 'event_start_time' => 'Startzeit', + 'event_end_time' => 'Endzeit', + 'offer_coupon_code' => 'Gutscheincode', + 'offer_redeem_url' => 'Online-Einlösungslink', + 'offer_terms' => 'Geschäftsbedingungen', + ], 'warnings' => [ 'no_variant' => 'Wähle einen Beitragstyp, um fortzufahren.', 'requires_media' => 'Dieser Beitragstyp erfordert mindestens ein Bild oder Video.', @@ -552,6 +588,10 @@ 'label' => 'Nachricht', 'description' => 'Nachricht an einen Discord-Kanal mit optionalen Medien & Embeds', ], + 'google_business_post' => [ + 'label' => 'Beitrag', + 'description' => 'Wird in deinem Geschäftsprofil in Suche und Karten angezeigt', + ], ], 'platforms' => [ diff --git a/lang/el/accounts.php b/lang/el/accounts.php index e94adbd11..2626966fe 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Συνδέστε τον λογαριασμό σας Mastodon', 'telegram' => 'Συνδέστε ένα κανάλι ή ομάδα Telegram', 'discord' => 'Συνδέστε έναν διακομιστή Discord', + 'google_business' => 'Συνδέστε μια τοποθεσία Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Δεν βρέθηκαν σελίδες Facebook με συνδεδεμένους λογαριασμούς Instagram.', 'no_youtube_channels' => 'Δεν βρέθηκαν κανάλια YouTube. Παρακαλούμε δημιουργήστε πρώτα ένα κανάλι.', 'not_linkedin_admin' => 'Δεν είστε διαχειριστής καμίας σελίδας LinkedIn.', + 'no_google_business_locations' => 'Δεν βρέθηκαν τοποθεσίες Google Business Profile. Επαληθεύστε πρώτα την επιχείρησή σας.', + 'location_not_found' => 'Η τοποθεσία δεν βρέθηκε.', + 'error_connecting_location' => 'Σφάλμα κατά τη σύνδεση της τοποθεσίας. Δοκιμάστε ξανά.', + ], + + 'google_business' => [ + 'title' => 'Επιλογή Τοποθεσίας Επιχείρησης', + 'description' => 'Επιλέξτε ποια τοποθεσία θέλετε να συνδέσετε', + 'no_locations' => 'Δεν βρέθηκαν τοποθεσίες', + 'no_locations_description' => 'Δεν είστε διαχειριστής καμίας επαληθευμένης τοποθεσίας Google Business Profile.', + 'choose' => 'Επιλογή', ], ]; diff --git a/lang/el/analytics.php b/lang/el/analytics.php index 779ecc226..70e42093a 100644 --- a/lang/el/analytics.php +++ b/lang/el/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Προβολές βίντεο', 'videos' => 'Βίντεο', 'views' => 'Προβολές', + 'website_clicks' => 'Κλικ ιστοσελίδας', + 'call_clicks' => 'Κλικ κλήσης', + 'direction_requests' => 'Αιτήματα κατεύθυνσης', + 'desktop_map_impressions' => 'Εμφανίσεις χάρτη σε υπολογιστή', + 'mobile_map_impressions' => 'Εμφανίσεις χάρτη σε κινητό', ], ]; diff --git a/lang/el/posts.php b/lang/el/posts.php index 04d8992e1..c86b79983 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -186,6 +186,42 @@ 'embed_image' => 'URL εικόνας', 'embed_color' => 'Χρώμα', ], + 'google_business' => [ + 'settings' => 'Ρυθμίσεις Google Business Profile', + 'posting_to' => 'Δημοσίευση σε', + 'topic_type_label' => 'Τύπος δημοσίευσης', + 'topic_type' => [ + 'standard' => 'Ενημέρωση', + 'event' => 'Εκδήλωση', + 'offer' => 'Προσφορά', + ], + 'cta_label' => 'Κουμπί', + 'cta_none' => 'Χωρίς κουμπί', + 'cta' => [ + 'book' => 'Κράτηση', + 'order' => 'Παραγγελία online', + 'shop' => 'Αγορά', + 'learn_more' => 'Μάθετε περισσότερα', + 'sign_up' => 'Εγγραφή', + 'get_offer' => 'Λάβετε προσφορά', + 'call' => 'Καλέστε τώρα', + ], + 'cta_url' => 'Σύνδεσμος κουμπιού', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Εισάγετε έναν σύνδεσμο για αυτό το κουμπί ή επιλέξτε "Χωρίς κουμπί".', + 'event_title' => 'Τίτλος εκδήλωσης', + 'event_title_placeholder' => 'Θερινή έκπτωση', + 'event_title_required' => 'Εισάγετε τίτλο εκδήλωσης.', + 'event_start_date' => 'Ημερομηνία έναρξης', + 'event_start_date_required' => 'Εισάγετε ημερομηνία έναρξης.', + 'event_end_date' => 'Ημερομηνία λήξης', + 'event_end_date_required' => 'Εισάγετε ημερομηνία λήξης.', + 'event_start_time' => 'Ώρα έναρξης', + 'event_end_time' => 'Ώρα λήξης', + 'offer_coupon_code' => 'Κωδικός κουπονιού', + 'offer_redeem_url' => 'Σύνδεσμος εξαργύρωσης online', + 'offer_terms' => 'Όροι και προϋποθέσεις', + ], 'warnings' => [ 'no_variant' => 'Επιλέξτε έναν τύπο δημοσίευσης για να συνεχίσετε.', 'requires_media' => 'Αυτός ο τύπος δημοσίευσης απαιτεί τουλάχιστον μία εικόνα ή βίντεο.', @@ -550,6 +586,10 @@ 'label' => 'Μήνυμα', 'description' => 'Μήνυμα σε κανάλι Discord με προαιρετικά πολυμέσα και embeds', ], + 'google_business_post' => [ + 'label' => 'Δημοσίευση', + 'description' => 'Εμφανίζεται στο Επιχειρηματικό σας Προφίλ στην Αναζήτηση και τους Χάρτες', + ], ], 'platforms' => [ diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 6ab145525..fecb6d455 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Connect your Mastodon account', 'telegram' => 'Connect a Telegram channel or group', 'discord' => 'Connect a Discord server', + 'google_business' => 'Connect a Google Business Profile location', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'No Facebook Pages with linked Instagram accounts found.', 'no_youtube_channels' => 'No YouTube channels found. Please create a channel first.', 'not_linkedin_admin' => 'You are not an administrator of any LinkedIn page.', + 'no_google_business_locations' => 'No Google Business Profile locations found. Verify your business first.', + 'location_not_found' => 'Location not found.', + 'error_connecting_location' => 'Error connecting location. Please try again.', + ], + + 'google_business' => [ + 'title' => 'Select Business Location', + 'description' => 'Choose which location you want to connect', + 'no_locations' => 'No locations found', + 'no_locations_description' => 'You are not a manager of any verified Google Business Profile location.', + 'choose' => 'Choose', ], ]; diff --git a/lang/en/analytics.php b/lang/en/analytics.php index 99c8190c2..49f8929d1 100644 --- a/lang/en/analytics.php +++ b/lang/en/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Video Views', 'videos' => 'Videos', 'views' => 'Views', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/lang/en/posts.php b/lang/en/posts.php index 4ce09e420..301a703bc 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -186,6 +186,42 @@ 'embed_image' => 'Image URL', 'embed_color' => 'Color', ], + 'google_business' => [ + 'settings' => 'Google Business Profile Settings', + 'posting_to' => 'Posting to', + 'topic_type_label' => 'Post type', + 'topic_type' => [ + 'standard' => 'Update', + 'event' => 'Event', + 'offer' => 'Offer', + ], + 'cta_label' => 'Button', + 'cta_none' => 'No button', + 'cta' => [ + 'book' => 'Book', + 'order' => 'Order online', + 'shop' => 'Buy', + 'learn_more' => 'Learn more', + 'sign_up' => 'Sign up', + 'get_offer' => 'Redeem offer', + 'call' => 'Call now', + ], + 'cta_url' => 'Button link', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Enter a link for this button, or choose "No button".', + 'event_title' => 'Event title', + 'event_title_placeholder' => 'Summer sale', + 'event_title_required' => 'Enter an event title.', + 'event_start_date' => 'Start date', + 'event_start_date_required' => 'Enter an event start date.', + 'event_end_date' => 'End date', + 'event_end_date_required' => 'Enter an event end date.', + 'event_start_time' => 'Start time', + 'event_end_time' => 'End time', + 'offer_coupon_code' => 'Coupon code', + 'offer_redeem_url' => 'Redeem online link', + 'offer_terms' => 'Terms & conditions', + ], 'warnings' => [ 'no_variant' => 'Pick a post type to continue.', 'requires_media' => 'This post type requires at least one image or video.', @@ -550,6 +586,10 @@ 'label' => 'Message', 'description' => 'Message to a Discord channel with optional media & embeds', ], + 'google_business_post' => [ + 'label' => 'Post', + 'description' => 'Appears on your Business Profile in Search and Maps', + ], ], 'platforms' => [ diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 38a93d016..1374e0fb4 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Conecta tu cuenta de Mastodon', 'telegram' => 'Conecta un canal o grupo de Telegram', 'discord' => 'Conecta un servidor de Discord', + 'google_business' => 'Conecta una ubicación de Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'No se encontraron páginas de Facebook con cuentas de Instagram vinculadas.', 'no_youtube_channels' => 'No se encontraron canales de YouTube. Crea un canal primero.', 'not_linkedin_admin' => 'No eres administrador de ninguna página de LinkedIn.', + 'no_google_business_locations' => 'No se encontraron ubicaciones de Google Business Profile. Verifica tu negocio primero.', + 'location_not_found' => 'Ubicación no encontrada.', + 'error_connecting_location' => 'Error al conectar la ubicación. Por favor, inténtalo de nuevo.', + ], + + 'google_business' => [ + 'title' => 'Seleccionar Ubicación del Negocio', + 'description' => 'Elige qué ubicación quieres conectar', + 'no_locations' => 'No se encontraron ubicaciones', + 'no_locations_description' => 'No eres administrador de ninguna ubicación verificada de Google Business Profile.', + 'choose' => 'Elegir', ], ]; diff --git a/lang/es/analytics.php b/lang/es/analytics.php index da4d2a406..e1c887418 100644 --- a/lang/es/analytics.php +++ b/lang/es/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Vistas de Vídeo', 'videos' => 'Vídeos', 'views' => 'Vistas', + 'website_clicks' => 'Clics de Sitio Web', + 'call_clicks' => 'Clics de Llamada', + 'direction_requests' => 'Solicitudes de Dirección', + 'desktop_map_impressions' => 'Impresiones de Mapa de Escritorio', + 'mobile_map_impressions' => 'Impresiones de Mapa Móvil', ], ]; diff --git a/lang/es/posts.php b/lang/es/posts.php index 957d4d1a3..4e8826265 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -186,6 +186,42 @@ 'embed_image' => 'URL de la imagen', 'embed_color' => 'Color', ], + 'google_business' => [ + 'settings' => 'Configuración de Google Business Profile', + 'posting_to' => 'Publicando en', + 'topic_type_label' => 'Tipo de publicación', + 'topic_type' => [ + 'standard' => 'Actualización', + 'event' => 'Evento', + 'offer' => 'Oferta', + ], + 'cta_label' => 'Botón', + 'cta_none' => 'Sin botón', + 'cta' => [ + 'book' => 'Reservar', + 'order' => 'Pedir online', + 'shop' => 'Comprar', + 'learn_more' => 'Más información', + 'sign_up' => 'Registrarse', + 'get_offer' => 'Canjear oferta', + 'call' => 'Llamar ahora', + ], + 'cta_url' => 'Enlace del botón', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Ingresa un enlace para este botón, o elige "Sin botón".', + 'event_title' => 'Título del evento', + 'event_title_placeholder' => 'Venta de verano', + 'event_title_required' => 'Ingresa un título de evento.', + 'event_start_date' => 'Fecha de inicio', + 'event_start_date_required' => 'Ingresa una fecha de inicio.', + 'event_end_date' => 'Fecha de finalización', + 'event_end_date_required' => 'Ingresa una fecha de finalización.', + 'event_start_time' => 'Hora de inicio', + 'event_end_time' => 'Hora de finalización', + 'offer_coupon_code' => 'Código de cupón', + 'offer_redeem_url' => 'Enlace para canjear online', + 'offer_terms' => 'Términos y condiciones', + ], 'warnings' => [ 'no_variant' => 'Elige un tipo de publicación para continuar.', 'requires_media' => 'Este tipo requiere al menos una imagen o video.', @@ -550,6 +586,10 @@ 'label' => 'Mensaje', 'description' => 'Mensaje a un canal de Discord con multimedia y embeds opcionales', ], + 'google_business_post' => [ + 'label' => 'Publicación', + 'description' => 'Aparece en tu Perfil Empresarial en Búsqueda y Mapas', + ], ], 'platforms' => [ diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index 8e37fc69b..1e464d374 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Connectez votre compte Mastodon', 'telegram' => 'Connectez un canal ou un groupe Telegram', 'discord' => 'Connectez un serveur Discord', + 'google_business' => 'Connectez un établissement Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Aucune page Facebook associée à un compte Instagram trouvée.', 'no_youtube_channels' => 'Aucune chaîne YouTube trouvée. Veuillez d\'abord créer une chaîne.', 'not_linkedin_admin' => 'Vous n\'êtes administrateur d\'aucune page LinkedIn.', + 'no_google_business_locations' => 'Aucun établissement Google Business Profile trouvé. Vérifiez d\'abord votre établissement.', + 'location_not_found' => 'Établissement introuvable.', + 'error_connecting_location' => 'Erreur lors de la connexion de l\'établissement. Veuillez réessayer.', + ], + + 'google_business' => [ + 'title' => 'Sélectionner l\'établissement', + 'description' => 'Choisissez l\'établissement que vous souhaitez connecter', + 'no_locations' => 'Aucun établissement trouvé', + 'no_locations_description' => 'Vous n\'êtes gestionnaire d\'aucun établissement Google Business Profile vérifié.', + 'choose' => 'Choisir', ], ]; diff --git a/lang/fr/analytics.php b/lang/fr/analytics.php index c26d20b15..1eaa169e0 100644 --- a/lang/fr/analytics.php +++ b/lang/fr/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Vues de la vidéo', 'videos' => 'Vidéos', 'views' => 'Vues', + 'website_clicks' => 'Clics sur le site web', + 'call_clicks' => 'Clics d\'appel', + 'direction_requests' => 'Demandes d\'itinéraire', + 'desktop_map_impressions' => 'Impressions de carte sur ordinateur', + 'mobile_map_impressions' => 'Impressions de carte sur mobile', ], ]; diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 1983643a7..055486276 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -186,6 +186,42 @@ 'embed_image' => 'URL de l\'image', 'embed_color' => 'Couleur', ], + 'google_business' => [ + 'settings' => 'Paramètres de Google Business Profile', + 'posting_to' => 'Publier sur', + 'topic_type_label' => 'Type de publication', + 'topic_type' => [ + 'standard' => 'Mise à jour', + 'event' => 'Événement', + 'offer' => 'Offre', + ], + 'cta_label' => 'Bouton', + 'cta_none' => 'Pas de bouton', + 'cta' => [ + 'book' => 'Réserver', + 'order' => 'Commander en ligne', + 'shop' => 'Acheter', + 'learn_more' => 'En savoir plus', + 'sign_up' => 'S\'inscrire', + 'get_offer' => 'Utiliser l\'offre', + 'call' => 'Appelez maintenant', + ], + 'cta_url' => 'Lien du bouton', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Entrez un lien pour ce bouton, ou choisissez "Pas de bouton".', + 'event_title' => 'Titre de l\'événement', + 'event_title_placeholder' => 'Vente d\'été', + 'event_title_required' => 'Entrez un titre d\'événement.', + 'event_start_date' => 'Date de début', + 'event_start_date_required' => 'Entrez une date de début.', + 'event_end_date' => 'Date de fin', + 'event_end_date_required' => 'Entrez une date de fin.', + 'event_start_time' => 'Heure de début', + 'event_end_time' => 'Heure de fin', + 'offer_coupon_code' => 'Code de coupon', + 'offer_redeem_url' => 'Lien de rachat en ligne', + 'offer_terms' => 'Conditions et termes', + ], 'warnings' => [ 'no_variant' => 'Choisissez un type de publication pour continuer.', 'requires_media' => 'Ce type de publication nécessite au moins une image ou une vidéo.', @@ -550,6 +586,10 @@ 'label' => 'Message', 'description' => 'Message vers un salon Discord avec médias et embeds facultatifs', ], + 'google_business_post' => [ + 'label' => 'Publication', + 'description' => 'Apparaît dans votre Profil Entreprise dans la Recherche et Cartes', + ], ], 'platforms' => [ diff --git a/lang/it/accounts.php b/lang/it/accounts.php index b362705a1..e1f112085 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Collega il tuo account Mastodon', 'telegram' => 'Collega un canale o gruppo Telegram', 'discord' => 'Collega un server Discord', + 'google_business' => 'Collega una sede di Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Nessuna pagina Facebook con account Instagram collegati trovata.', 'no_youtube_channels' => 'Nessun canale YouTube trovato. Crea prima un canale.', 'not_linkedin_admin' => 'Non sei amministratore di alcuna pagina LinkedIn.', + 'no_google_business_locations' => 'Nessuna sede di Google Business Profile trovata. Verifica prima la tua attività.', + 'location_not_found' => 'Sede non trovata.', + 'error_connecting_location' => 'Errore nella connessione della sede. Riprova.', + ], + + 'google_business' => [ + 'title' => 'Seleziona Sede Attività', + 'description' => 'Scegli quale sede vuoi collegare', + 'no_locations' => 'Nessuna sede trovata', + 'no_locations_description' => 'Non sei gestore di nessuna sede verificata di Google Business Profile.', + 'choose' => 'Scegli', ], ]; diff --git a/lang/it/analytics.php b/lang/it/analytics.php index 2bce4a934..5333d62af 100644 --- a/lang/it/analytics.php +++ b/lang/it/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Visualizzazioni video', 'videos' => 'Video', 'views' => 'Visualizzazioni', + 'website_clicks' => 'Clic sul sito web', + 'call_clicks' => 'Clic sulla chiamata', + 'direction_requests' => 'Richieste di indicazioni', + 'desktop_map_impressions' => 'Impressioni della mappa su desktop', + 'mobile_map_impressions' => 'Impressioni della mappa mobile', ], ]; diff --git a/lang/it/posts.php b/lang/it/posts.php index 6460e6cb2..8bf1e46d8 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -186,6 +186,42 @@ 'embed_image' => 'URL immagine', 'embed_color' => 'Colore', ], + 'google_business' => [ + 'settings' => 'Impostazioni Google Business Profile', + 'posting_to' => 'Pubblicazione su', + 'topic_type_label' => 'Tipo di post', + 'topic_type' => [ + 'standard' => 'Aggiornamento', + 'event' => 'Evento', + 'offer' => 'Offerta', + ], + 'cta_label' => 'Pulsante', + 'cta_none' => 'Nessun pulsante', + 'cta' => [ + 'book' => 'Prenota', + 'order' => 'Ordina online', + 'shop' => 'Acquista', + 'learn_more' => 'Scopri di più', + 'sign_up' => 'Iscriviti', + 'get_offer' => 'Riscatta offerta', + 'call' => 'Chiama ora', + ], + 'cta_url' => 'Link del pulsante', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Inserisci un link per questo pulsante, oppure scegli "Nessun pulsante".', + 'event_title' => 'Titolo evento', + 'event_title_placeholder' => 'Saldi estivi', + 'event_title_required' => 'Inserisci un titolo evento.', + 'event_start_date' => 'Data inizio', + 'event_start_date_required' => 'Inserisci una data inizio.', + 'event_end_date' => 'Data fine', + 'event_end_date_required' => 'Inserisci una data fine.', + 'event_start_time' => 'Ora inizio', + 'event_end_time' => 'Ora fine', + 'offer_coupon_code' => 'Codice coupon', + 'offer_redeem_url' => 'Link riscatto online', + 'offer_terms' => 'Termini e condizioni', + ], 'warnings' => [ 'no_variant' => 'Scegli un tipo di post per continuare.', 'requires_media' => 'Questo tipo di post richiede almeno un\'immagine o un video.', @@ -550,6 +586,10 @@ 'label' => 'Messaggio', 'description' => 'Messaggio a un canale Discord con media ed embed facoltativi', ], + 'google_business_post' => [ + 'label' => 'Pubblicazione', + 'description' => 'Appare nel tuo Profilo Aziendale in Ricerca e Mappe', + ], ], 'platforms' => [ diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index 4ca6e41df..de35ba2ed 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Mastodon アカウントを接続', 'telegram' => 'Telegram チャンネルまたはグループを接続', 'discord' => 'Discord サーバーを接続', + 'google_business' => 'Google ビジネス プロフィールの店舗を接続', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Instagram アカウントが連携された Facebook ページが見つかりません。', 'no_youtube_channels' => 'YouTube チャンネルが見つかりません。先にチャンネルを作成してください。', 'not_linkedin_admin' => 'あなたは管理者となっている LinkedIn ページがありません。', + 'no_google_business_locations' => 'Google ビジネス プロフィールの店舗が見つかりません。まずビジネスを確認してください。', + 'location_not_found' => '店舗が見つかりません。', + 'error_connecting_location' => '店舗の接続中にエラーが発生しました。もう一度お試しください。', + ], + + 'google_business' => [ + 'title' => 'ビジネス店舗を選択', + 'description' => '接続する店舗を選択してください', + 'no_locations' => '店舗が見つかりません', + 'no_locations_description' => '確認済みの Google ビジネス プロフィール店舗の管理者ではありません。', + 'choose' => '選択', ], ]; diff --git a/lang/ja/analytics.php b/lang/ja/analytics.php index 22f015157..ca4837853 100644 --- a/lang/ja/analytics.php +++ b/lang/ja/analytics.php @@ -51,5 +51,10 @@ 'video_views' => '動画再生数', 'videos' => '動画', 'views' => '再生数', + 'website_clicks' => 'ウェブサイトクリック', + 'call_clicks' => '通話クリック', + 'direction_requests' => '経路リクエスト', + 'desktop_map_impressions' => 'デスクトップ地図インプレッション', + 'mobile_map_impressions' => 'モバイル地図インプレッション', ], ]; diff --git a/lang/ja/posts.php b/lang/ja/posts.php index cdae132c2..85e5980e7 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -186,6 +186,42 @@ 'embed_image' => '画像 URL', 'embed_color' => '色', ], + 'google_business' => [ + 'settings' => 'Google Business Profile 設定', + 'posting_to' => '投稿先', + 'topic_type_label' => '投稿タイプ', + 'topic_type' => [ + 'standard' => '更新', + 'event' => 'イベント', + 'offer' => 'オファー', + ], + 'cta_label' => 'ボタン', + 'cta_none' => 'ボタンなし', + 'cta' => [ + 'book' => '予約', + 'order' => 'オンラインで注文', + 'shop' => '購入', + 'learn_more' => 'もっと詳しく', + 'sign_up' => '登録', + 'get_offer' => 'オファーを利用', + 'call' => '今すぐ電話', + ], + 'cta_url' => 'ボタンリンク', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'このボタンのリンクを入力するか、「ボタンなし」を選択してください。', + 'event_title' => 'イベントタイトル', + 'event_title_placeholder' => '夏のセール', + 'event_title_required' => 'イベントタイトルを入力してください。', + 'event_start_date' => '開始日', + 'event_start_date_required' => '開始日を入力してください。', + 'event_end_date' => '終了日', + 'event_end_date_required' => '終了日を入力してください。', + 'event_start_time' => '開始時刻', + 'event_end_time' => '終了時刻', + 'offer_coupon_code' => 'クーポンコード', + 'offer_redeem_url' => 'オンライン引き換えリンク', + 'offer_terms' => '利用規約', + ], 'warnings' => [ 'no_variant' => '続けるには投稿タイプを選択してください。', 'requires_media' => 'この投稿タイプには少なくとも 1 つの画像または動画が必要です。', @@ -550,6 +586,10 @@ 'label' => 'メッセージ', 'description' => 'メディアと埋め込み(任意)付きの Discord チャンネルへのメッセージ', ], + 'google_business_post' => [ + 'label' => '投稿', + 'description' => 'ビジネス プロフィールに検索とマップで表示されます', + ], ], 'platforms' => [ diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index 89ad4b751..3a264488c 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Mastodon 계정을 연결하세요', 'telegram' => 'Telegram 채널 또는 그룹을 연결하세요', 'discord' => 'Discord 서버를 연결하세요', + 'google_business' => 'Google 비즈니스 프로필 위치를 연결하세요', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Instagram 계정이 연결된 Facebook 페이지를 찾을 수 없습니다.', 'no_youtube_channels' => 'YouTube 채널을 찾을 수 없습니다. 먼저 채널을 만드세요.', 'not_linkedin_admin' => '관리자로 있는 LinkedIn 페이지가 없습니다.', + 'no_google_business_locations' => 'Google 비즈니스 프로필 위치를 찾을 수 없습니다. 먼저 비즈니스를 인증하세요.', + 'location_not_found' => '위치를 찾을 수 없습니다.', + 'error_connecting_location' => '위치 연결 중 오류가 발생했습니다. 다시 시도해 주세요.', + ], + + 'google_business' => [ + 'title' => '비즈니스 위치 선택', + 'description' => '연결할 위치를 선택하세요', + 'no_locations' => '위치를 찾을 수 없습니다', + 'no_locations_description' => '인증된 Google 비즈니스 프로필 위치의 관리자가 아닙니다.', + 'choose' => '선택', ], ]; diff --git a/lang/ko/analytics.php b/lang/ko/analytics.php index 946ba066c..fbe72aa2d 100644 --- a/lang/ko/analytics.php +++ b/lang/ko/analytics.php @@ -51,5 +51,10 @@ 'video_views' => '동영상 조회수', 'videos' => '동영상', 'views' => '조회수', + 'website_clicks' => '웹사이트 클릭', + 'call_clicks' => '통화 클릭', + 'direction_requests' => '길찾기 요청', + 'desktop_map_impressions' => '데스크톱 지도 노출수', + 'mobile_map_impressions' => '모바일 지도 노출수', ], ]; diff --git a/lang/ko/posts.php b/lang/ko/posts.php index ee4a20fa7..c15a35841 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -186,6 +186,42 @@ 'embed_image' => '이미지 URL', 'embed_color' => '색상', ], + 'google_business' => [ + 'settings' => 'Google Business Profile 설정', + 'posting_to' => '게시 대상', + 'topic_type_label' => '게시물 유형', + 'topic_type' => [ + 'standard' => '업데이트', + 'event' => '이벤트', + 'offer' => '오퍼', + ], + 'cta_label' => '버튼', + 'cta_none' => '버튼 없음', + 'cta' => [ + 'book' => '예약', + 'order' => '온라인 주문', + 'shop' => '구매', + 'learn_more' => '자세히 알아보기', + 'sign_up' => '가입', + 'get_offer' => '오퍼 받기', + 'call' => '지금 전화', + ], + 'cta_url' => '버튼 링크', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => '이 버튼의 링크를 입력하거나 "버튼 없음"을 선택하세요.', + 'event_title' => '이벤트 제목', + 'event_title_placeholder' => '여름 세일', + 'event_title_required' => '이벤트 제목을 입력하세요.', + 'event_start_date' => '시작 날짜', + 'event_start_date_required' => '시작 날짜를 입력하세요.', + 'event_end_date' => '종료 날짜', + 'event_end_date_required' => '종료 날짜를 입력하세요.', + 'event_start_time' => '시작 시간', + 'event_end_time' => '종료 시간', + 'offer_coupon_code' => '쿠폰 코드', + 'offer_redeem_url' => '온라인 사용 링크', + 'offer_terms' => '약관', + ], 'warnings' => [ 'no_variant' => '계속하려면 게시물 유형을 선택하세요.', 'requires_media' => '이 게시물 유형에는 이미지 또는 동영상이 하나 이상 필요합니다.', @@ -550,6 +586,10 @@ 'label' => '메시지', 'description' => '선택적 미디어 및 임베드가 있는 Discord 채널 메시지', ], + 'google_business_post' => [ + 'label' => '게시물', + 'description' => '비즈니스 프로필에 검색 및 지도에 표시됩니다', + ], ], 'platforms' => [ diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index c5c8f45e3..292284a53 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Koppel je Mastodon-account', 'telegram' => 'Koppel een Telegram-kanaal of -groep', 'discord' => 'Koppel een Discord-server', + 'google_business' => 'Koppel een Google Bedrijfsprofiel-locatie', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Geen Facebook-pagina\'s met gekoppelde Instagram-accounts gevonden.', 'no_youtube_channels' => 'Geen YouTube-kanalen gevonden. Maak eerst een kanaal aan.', 'not_linkedin_admin' => 'Je bent geen beheerder van een LinkedIn-pagina.', + 'no_google_business_locations' => 'Geen Google Bedrijfsprofiel-locaties gevonden. Verifieer eerst je bedrijf.', + 'location_not_found' => 'Locatie niet gevonden.', + 'error_connecting_location' => 'Fout bij het koppelen van de locatie. Probeer het opnieuw.', + ], + + 'google_business' => [ + 'title' => 'Selecteer Bedrijfslocatie', + 'description' => 'Kies welke locatie je wilt koppelen', + 'no_locations' => 'Geen locaties gevonden', + 'no_locations_description' => 'Je bent geen beheerder van een geverifieerde Google Bedrijfsprofiel-locatie.', + 'choose' => 'Kiezen', ], ]; diff --git a/lang/nl/analytics.php b/lang/nl/analytics.php index e62378ec1..5225b21d6 100644 --- a/lang/nl/analytics.php +++ b/lang/nl/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Videoweergaven', 'videos' => 'Video\'s', 'views' => 'Weergaven', + 'website_clicks' => 'Websiteklikken', + 'call_clicks' => 'Oproepklikken', + 'direction_requests' => 'Routeaanvragen', + 'desktop_map_impressions' => 'Kaartweergaven op desktop', + 'mobile_map_impressions' => 'Kaartweergaven op mobiel', ], ]; diff --git a/lang/nl/posts.php b/lang/nl/posts.php index d9ef79626..57b3756a4 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -186,6 +186,42 @@ 'embed_image' => 'Afbeeldings-URL', 'embed_color' => 'Kleur', ], + 'google_business' => [ + 'settings' => 'Google Business Profile-instellingen', + 'posting_to' => 'Posten naar', + 'topic_type_label' => 'Posttype', + 'topic_type' => [ + 'standard' => 'Update', + 'event' => 'Evenement', + 'offer' => 'Aanbod', + ], + 'cta_label' => 'Knop', + 'cta_none' => 'Geen knop', + 'cta' => [ + 'book' => 'Boeken', + 'order' => 'Online bestellen', + 'shop' => 'Kopen', + 'learn_more' => 'Meer informatie', + 'sign_up' => 'Aanmelden', + 'get_offer' => 'Aanbod gebruiken', + 'call' => 'Nu bellen', + ], + 'cta_url' => 'Koppelinformatie voor knop', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Voer een link in voor deze knop, of kies "Geen knop".', + 'event_title' => 'Evenementtitel', + 'event_title_placeholder' => 'Zomeruitverkoop', + 'event_title_required' => 'Voer een evenementtitel in.', + 'event_start_date' => 'Startdatum', + 'event_start_date_required' => 'Voer een startdatum in.', + 'event_end_date' => 'Einddatum', + 'event_end_date_required' => 'Voer een einddatum in.', + 'event_start_time' => 'Starttijd', + 'event_end_time' => 'Eindtijd', + 'offer_coupon_code' => 'Couponcode', + 'offer_redeem_url' => 'Online inwisselingskoppeling', + 'offer_terms' => 'Voorwaarden', + ], 'warnings' => [ 'no_variant' => 'Kies een posttype om door te gaan.', 'requires_media' => 'Dit posttype vereist ten minste één afbeelding of video.', @@ -550,6 +586,10 @@ 'label' => 'Bericht', 'description' => 'Bericht naar een Discord-kanaal met optionele media en embeds', ], + 'google_business_post' => [ + 'label' => 'Bericht', + 'description' => 'Wordt weergegeven in je Bedrijfsprofiel in Zoeken en Kaarten', + ], ], 'platforms' => [ diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index 0828de36e..90ed8230c 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Połącz swoje konto Mastodon', 'telegram' => 'Połącz kanał lub grupę na Telegramie', 'discord' => 'Połącz serwer Discord', + 'google_business' => 'Połącz lokalizację Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Nie znaleziono stron na Facebooku z powiązanymi kontami Instagram.', 'no_youtube_channels' => 'Nie znaleziono kanałów YouTube. Najpierw utwórz kanał.', 'not_linkedin_admin' => 'Nie jesteś administratorem żadnej strony LinkedIn.', + 'no_google_business_locations' => 'Nie znaleziono lokalizacji Google Business Profile. Najpierw zweryfikuj swoją firmę.', + 'location_not_found' => 'Nie znaleziono lokalizacji.', + 'error_connecting_location' => 'Błąd podczas łączenia lokalizacji. Spróbuj ponownie.', + ], + + 'google_business' => [ + 'title' => 'Wybierz lokalizację firmy', + 'description' => 'Wybierz, którą lokalizację chcesz połączyć', + 'no_locations' => 'Nie znaleziono lokalizacji', + 'no_locations_description' => 'Nie jesteś menedżerem żadnej zweryfikowanej lokalizacji Google Business Profile.', + 'choose' => 'Wybierz', ], ]; diff --git a/lang/pl/analytics.php b/lang/pl/analytics.php index 19def1bd9..872c516d2 100644 --- a/lang/pl/analytics.php +++ b/lang/pl/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Wyświetlenia wideo', 'videos' => 'Filmy', 'views' => 'Wyświetlenia', + 'website_clicks' => 'Kliknięcia witryny', + 'call_clicks' => 'Kliknięcia połączeń', + 'direction_requests' => 'Żądania tras', + 'desktop_map_impressions' => 'Wyświetlenia map na komputerze', + 'mobile_map_impressions' => 'Wyświetlenia map na urządzeniu mobilnym', ], ]; diff --git a/lang/pl/posts.php b/lang/pl/posts.php index 3f9732a8e..9eb92b9e4 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -186,6 +186,42 @@ 'embed_image' => 'Adres URL obrazu', 'embed_color' => 'Kolor', ], + 'google_business' => [ + 'settings' => 'Ustawienia Google Business Profile', + 'posting_to' => 'Publikowanie na', + 'topic_type_label' => 'Typ posta', + 'topic_type' => [ + 'standard' => 'Aktualizacja', + 'event' => 'Wydarzenie', + 'offer' => 'Oferta', + ], + 'cta_label' => 'Przycisk', + 'cta_none' => 'Bez przycisku', + 'cta' => [ + 'book' => 'Zarezerwuj', + 'order' => 'Zamów online', + 'shop' => 'Kup', + 'learn_more' => 'Dowiedz się więcej', + 'sign_up' => 'Zarejestruj się', + 'get_offer' => 'Uzyskaj ofertę', + 'call' => 'Zadzwoń teraz', + ], + 'cta_url' => 'Link przycisku', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Wpisz link dla tego przycisku lub wybierz "Bez przycisku".', + 'event_title' => 'Tytuł wydarzenia', + 'event_title_placeholder' => 'Letnia wyprzedaż', + 'event_title_required' => 'Wpisz tytuł wydarzenia.', + 'event_start_date' => 'Data rozpoczęcia', + 'event_start_date_required' => 'Wpisz datę rozpoczęcia.', + 'event_end_date' => 'Data zakończenia', + 'event_end_date_required' => 'Wpisz datę zakończenia.', + 'event_start_time' => 'Czas rozpoczęcia', + 'event_end_time' => 'Czas zakończenia', + 'offer_coupon_code' => 'Kod kuponu', + 'offer_redeem_url' => 'Link do realizacji online', + 'offer_terms' => 'Warunki i postanowienia', + ], 'warnings' => [ 'no_variant' => 'Wybierz typ posta, aby kontynuować.', 'requires_media' => 'Ten typ posta wymaga co najmniej jednego obrazu lub filmu.', @@ -550,6 +586,10 @@ 'label' => 'Wiadomość', 'description' => 'Wiadomość na kanale Discord z opcjonalnymi multimediami i osadzeniami', ], + 'google_business_post' => [ + 'label' => 'Post', + 'description' => 'Pojawia się w twoim Profilu Biznesowym w Wyszukiwaniu i Mapach', + ], ], 'platforms' => [ diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index 4531dcdd1..c1b159f13 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Conecte sua conta do Mastodon', 'telegram' => 'Conecte um canal ou grupo do Telegram', 'discord' => 'Conecte um servidor do Discord', + 'google_business' => 'Conecte um local do Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Nenhuma página do Facebook com conta do Instagram vinculada foi encontrada.', 'no_youtube_channels' => 'Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.', 'not_linkedin_admin' => 'Você não é administrador de nenhuma página do LinkedIn.', + 'no_google_business_locations' => 'Nenhum local do Google Business Profile encontrado. Verifique sua empresa primeiro.', + 'location_not_found' => 'Local não encontrado.', + 'error_connecting_location' => 'Erro ao conectar local. Por favor, tente novamente.', + ], + + 'google_business' => [ + 'title' => 'Selecionar Local Comercial', + 'description' => 'Escolha qual local você deseja conectar', + 'no_locations' => 'Nenhum local encontrado', + 'no_locations_description' => 'Você não é gerente de nenhum local verificado do Google Business Profile.', + 'choose' => 'Escolher', ], ]; diff --git a/lang/pt-BR/analytics.php b/lang/pt-BR/analytics.php index bad4c550d..c3a3e05d2 100644 --- a/lang/pt-BR/analytics.php +++ b/lang/pt-BR/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Visualizações de Vídeo', 'videos' => 'Vídeos', 'views' => 'Visualizações', + 'website_clicks' => 'Cliques no Site', + 'call_clicks' => 'Cliques de Chamada', + 'direction_requests' => 'Solicitações de Direções', + 'desktop_map_impressions' => 'Impressões de Mapa no Desktop', + 'mobile_map_impressions' => 'Impressões de Mapa no Celular', ], ]; diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index a13828d37..177a90fad 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -186,6 +186,42 @@ 'embed_image' => 'URL da imagem', 'embed_color' => 'Cor', ], + 'google_business' => [ + 'settings' => 'Configurações do Google Business Profile', + 'posting_to' => 'Publicando em', + 'topic_type_label' => 'Tipo de publicação', + 'topic_type' => [ + 'standard' => 'Atualização', + 'event' => 'Evento', + 'offer' => 'Oferta', + ], + 'cta_label' => 'Botão', + 'cta_none' => 'Sem botão', + 'cta' => [ + 'book' => 'Reservar', + 'order' => 'Pedir online', + 'shop' => 'Comprar', + 'learn_more' => 'Saiba mais', + 'sign_up' => 'Inscreva-se', + 'get_offer' => 'Usar oferta', + 'call' => 'Ligar agora', + ], + 'cta_url' => 'Link do botão', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Insira um link para este botão, ou escolha "Sem botão".', + 'event_title' => 'Título do evento', + 'event_title_placeholder' => 'Venda de verão', + 'event_title_required' => 'Insira um título de evento.', + 'event_start_date' => 'Data de início', + 'event_start_date_required' => 'Insira uma data de início.', + 'event_end_date' => 'Data de término', + 'event_end_date_required' => 'Insira uma data de término.', + 'event_start_time' => 'Hora de início', + 'event_end_time' => 'Hora de término', + 'offer_coupon_code' => 'Código do cupom', + 'offer_redeem_url' => 'Link para resgatar online', + 'offer_terms' => 'Termos e condições', + ], 'warnings' => [ 'no_variant' => 'Escolha um tipo de publicação para continuar.', 'requires_media' => 'Este tipo exige pelo menos uma imagem ou vídeo.', @@ -550,6 +586,10 @@ 'label' => 'Mensagem', 'description' => 'Mensagem para um canal do Discord com mídia e embeds opcionais', ], + 'google_business_post' => [ + 'label' => 'Publicação', + 'description' => 'Aparece no seu Perfil Empresarial em Pesquisa e Mapas', + ], ], 'platforms' => [ diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index 384a0c430..c90ef729e 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Подключите аккаунт Mastodon', 'telegram' => 'Подключите канал или группу Telegram', 'discord' => 'Подключите сервер Discord', + 'google_business' => 'Подключите местоположение Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Не найдено страниц Facebook со связанными аккаунтами Instagram.', 'no_youtube_channels' => 'Каналы YouTube не найдены. Сначала создайте канал.', 'not_linkedin_admin' => 'Вы не являетесь администратором ни одной страницы LinkedIn.', + 'no_google_business_locations' => 'Местоположения Google Business Profile не найдены. Сначала подтвердите свою компанию.', + 'location_not_found' => 'Местоположение не найдено.', + 'error_connecting_location' => 'Ошибка при подключении местоположения. Пожалуйста, попробуйте снова.', + ], + + 'google_business' => [ + 'title' => 'Выберите местоположение компании', + 'description' => 'Выберите местоположение, которое хотите подключить', + 'no_locations' => 'Местоположения не найдены', + 'no_locations_description' => 'Вы не являетесь менеджером ни одного подтверждённого местоположения Google Business Profile.', + 'choose' => 'Выбрать', ], ]; diff --git a/lang/ru/analytics.php b/lang/ru/analytics.php index a0c767540..08b4b4707 100644 --- a/lang/ru/analytics.php +++ b/lang/ru/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Просмотры видео', 'videos' => 'Видео', 'views' => 'Просмотры', + 'website_clicks' => 'Клики по веб-сайту', + 'call_clicks' => 'Клики на звонок', + 'direction_requests' => 'Запросы маршрутов', + 'desktop_map_impressions' => 'Показы карты на ПК', + 'mobile_map_impressions' => 'Показы карты на мобильном', ], ]; diff --git a/lang/ru/posts.php b/lang/ru/posts.php index 9787313b2..8fc4737fd 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -186,6 +186,42 @@ 'embed_image' => 'URL изображения', 'embed_color' => 'Цвет', ], + 'google_business' => [ + 'settings' => 'Настройки Google Business Profile', + 'posting_to' => 'Публикация в', + 'topic_type_label' => 'Тип поста', + 'topic_type' => [ + 'standard' => 'Обновление', + 'event' => 'Событие', + 'offer' => 'Предложение', + ], + 'cta_label' => 'Кнопка', + 'cta_none' => 'Без кнопки', + 'cta' => [ + 'book' => 'Забронировать', + 'order' => 'Заказать онлайн', + 'shop' => 'Купить', + 'learn_more' => 'Узнать больше', + 'sign_up' => 'Зарегистрироваться', + 'get_offer' => 'Получить предложение', + 'call' => 'Позвонить сейчас', + ], + 'cta_url' => 'Ссылка кнопки', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Введите ссылку для этой кнопки или выберите "Без кнопки".', + 'event_title' => 'Название события', + 'event_title_placeholder' => 'Летняя распродажа', + 'event_title_required' => 'Введите название события.', + 'event_start_date' => 'Дата начала', + 'event_start_date_required' => 'Введите дату начала.', + 'event_end_date' => 'Дата окончания', + 'event_end_date_required' => 'Введите дату окончания.', + 'event_start_time' => 'Время начала', + 'event_end_time' => 'Время окончания', + 'offer_coupon_code' => 'Код купона', + 'offer_redeem_url' => 'Ссылка на удаление в Интернете', + 'offer_terms' => 'Условия использования', + ], 'warnings' => [ 'no_variant' => 'Выберите тип поста, чтобы продолжить.', 'requires_media' => 'Этот тип поста требует хотя бы одно изображение или видео.', @@ -550,6 +586,10 @@ 'label' => 'Сообщение', 'description' => 'Сообщение в канал Discord с опциональным медиа и встраиваниями', ], + 'google_business_post' => [ + 'label' => 'Пост', + 'description' => 'Отображается в вашем Профиле компании в Поиске и Картах', + ], ], 'platforms' => [ diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index e65d7b887..ac46b5ebb 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -31,6 +31,7 @@ 'mastodon' => 'Mastodon hesabınızı bağlayın', 'telegram' => 'Bir Telegram kanalı veya grubu bağlayın', 'discord' => 'Bir Discord sunucusu bağlayın', + 'google_business' => 'Bir Google İşletme Profili konumu bağlayın', ], 'disconnect_modal' => [ @@ -155,5 +156,16 @@ 'no_facebook_instagram_pages' => 'Bağlı Instagram hesabı olan Facebook Sayfası bulunamadı.', 'no_youtube_channels' => 'YouTube kanalı bulunamadı. Lütfen önce bir kanal oluşturun.', 'not_linkedin_admin' => 'Hiçbir LinkedIn sayfasının yöneticisi değilsiniz.', + 'no_google_business_locations' => 'Google İşletme Profili konumu bulunamadı. Önce işletmenizi doğrulayın.', + 'location_not_found' => 'Konum bulunamadı.', + 'error_connecting_location' => 'Konum bağlanırken hata oluştu. Lütfen tekrar deneyin.', + ], + + 'google_business' => [ + 'title' => 'İşletme Konumu Seç', + 'description' => 'Bağlamak istediğiniz konumu seçin', + 'no_locations' => 'Konum bulunamadı', + 'no_locations_description' => 'Doğrulanmış herhangi bir Google İşletme Profili konumunun yöneticisi değilsiniz.', + 'choose' => 'Seç', ], ]; diff --git a/lang/tr/analytics.php b/lang/tr/analytics.php index daafd3b3b..7eb3d76ec 100644 --- a/lang/tr/analytics.php +++ b/lang/tr/analytics.php @@ -53,5 +53,10 @@ 'video_views' => 'Video Görüntülemeleri', 'videos' => 'Videolar', 'views' => 'Görüntülemeler', + 'website_clicks' => 'Web Sitesi Tıklamaları', + 'call_clicks' => 'Arama Tıklamaları', + 'direction_requests' => 'Rota Talepleri', + 'desktop_map_impressions' => 'Masaüstü Harita Gösterimleri', + 'mobile_map_impressions' => 'Mobil Harita Gösterimleri', ], ]; diff --git a/lang/tr/posts.php b/lang/tr/posts.php index 5cb220c56..ed2e45d3d 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -188,6 +188,42 @@ 'embed_image' => 'Görsel URL\'si', 'embed_color' => 'Renk', ], + 'google_business' => [ + 'settings' => 'Google Business Profile Ayarları', + 'posting_to' => 'Şuraya paylaşılıyor', + 'topic_type_label' => 'Gönderi türü', + 'topic_type' => [ + 'standard' => 'Güncelleme', + 'event' => 'Etkinlik', + 'offer' => 'Teklif', + ], + 'cta_label' => 'Düğme', + 'cta_none' => 'Düğme yok', + 'cta' => [ + 'book' => 'Rezervasyon', + 'order' => 'Çevrimiçi sipariş', + 'shop' => 'Satın al', + 'learn_more' => 'Daha fazla bilgi', + 'sign_up' => 'Kaydol', + 'get_offer' => 'Teklifi al', + 'call' => 'Hemen ara', + ], + 'cta_url' => 'Düğme bağlantısı', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Bu düğme için bir bağlantı girin veya "Düğme yok" seçeneğini seçin.', + 'event_title' => 'Etkinlik başlığı', + 'event_title_placeholder' => 'Yaz indirimi', + 'event_title_required' => 'Bir etkinlik başlığı girin.', + 'event_start_date' => 'Başlangıç tarihi', + 'event_start_date_required' => 'Başlangıç tarihini girin.', + 'event_end_date' => 'Bitiş tarihi', + 'event_end_date_required' => 'Bitiş tarihini girin.', + 'event_start_time' => 'Başlangıç saati', + 'event_end_time' => 'Bitiş saati', + 'offer_coupon_code' => 'Kupon kodu', + 'offer_redeem_url' => 'Çevrimiçi kullanım bağlantısı', + 'offer_terms' => 'Şartlar ve koşullar', + ], 'warnings' => [ 'no_variant' => 'Devam etmek için bir gönderi türü seçin.', 'requires_media' => 'Bu gönderi türü en az bir görsel veya video gerektirir.', @@ -552,6 +588,10 @@ 'label' => 'Mesaj', 'description' => 'İsteğe bağlı medya ve yerleştirmeler içeren Discord kanalına mesaj', ], + 'google_business_post' => [ + 'label' => 'Gönderi', + 'description' => 'İşletme Profilinde Arama ve Haritalar\'da görünür', + ], ], 'platforms' => [ diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index ae1a521f4..b9409e0e9 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Підключіть акаунт Mastodon', 'telegram' => 'Підключіть канал або групу Telegram', 'discord' => 'Підключіть сервер Discord', + 'google_business' => 'Підключіть місцезнаходження Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Не знайдено сторінок Facebook із підключеними акаунтами Instagram.', 'no_youtube_channels' => 'Каналів YouTube не знайдено. Спочатку створіть канал.', 'not_linkedin_admin' => 'Ви не є адміністратором жодної сторінки LinkedIn.', + 'no_google_business_locations' => 'Місцезнаходження Google Business Profile не знайдено. Спочатку підтвердьте свій бізнес.', + 'location_not_found' => 'Місцезнаходження не знайдено.', + 'error_connecting_location' => 'Помилка під час підключення місцезнаходження. Спробуйте ще раз.', + ], + + 'google_business' => [ + 'title' => 'Виберіть місцезнаходження бізнесу', + 'description' => 'Виберіть місцезнаходження, яке хочете підключити', + 'no_locations' => 'Місцезнаходження не знайдено', + 'no_locations_description' => 'Ви не є менеджером жодного підтвердженого місцезнаходження Google Business Profile.', + 'choose' => 'Вибрати', ], ]; diff --git a/lang/uk/analytics.php b/lang/uk/analytics.php index 1a4891b22..ef35d95b0 100644 --- a/lang/uk/analytics.php +++ b/lang/uk/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Перегляди відео', 'videos' => 'Відео', 'views' => 'Перегляди', + 'website_clicks' => 'Кліки по веб-сайту', + 'call_clicks' => 'Кліки на дзвінок', + 'direction_requests' => 'Запити маршрутів', + 'desktop_map_impressions' => 'Покази карти на комп\'ютері', + 'mobile_map_impressions' => 'Покази карти на мобільному', ], ]; diff --git a/lang/uk/posts.php b/lang/uk/posts.php index 8dc88b8c1..87cb236f6 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -186,6 +186,42 @@ 'embed_image' => 'URL зображення', 'embed_color' => 'Колір', ], + 'google_business' => [ + 'settings' => 'Налаштування Google Business Profile', + 'posting_to' => 'Публікація в', + 'topic_type_label' => 'Тип посту', + 'topic_type' => [ + 'standard' => 'Оновлення', + 'event' => 'Подія', + 'offer' => 'Пропозиція', + ], + 'cta_label' => 'Кнопка', + 'cta_none' => 'Без кнопки', + 'cta' => [ + 'book' => 'Забронювати', + 'order' => 'Замовити онлайн', + 'shop' => 'Купити', + 'learn_more' => 'Дізнатись більше', + 'sign_up' => 'Зареєструватися', + 'get_offer' => 'Отримати пропозицію', + 'call' => 'Позвонити зараз', + ], + 'cta_url' => 'Посилання кнопки', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Введіть посилання для цієї кнопки або виберіть "Без кнопки".', + 'event_title' => 'Назва події', + 'event_title_placeholder' => 'Літній розпродаж', + 'event_title_required' => 'Введіть назву події.', + 'event_start_date' => 'Дата початку', + 'event_start_date_required' => 'Введіть дату початку.', + 'event_end_date' => 'Дата завершення', + 'event_end_date_required' => 'Введіть дату завершення.', + 'event_start_time' => 'Час початку', + 'event_end_time' => 'Час завершення', + 'offer_coupon_code' => 'Код купона', + 'offer_redeem_url' => 'Посилання на реалізацію онлайн', + 'offer_terms' => 'Умови та положення', + ], 'warnings' => [ 'no_variant' => 'Виберіть тип поста, щоб продовжити.', 'requires_media' => 'Цей тип поста потребує принаймні одного зображення або відео.', @@ -550,6 +586,10 @@ 'label' => 'Повідомлення', 'description' => 'Повідомлення в канал Discord із необов’язковим медіа та вбудовуваннями', ], + 'google_business_post' => [ + 'label' => 'Публікація', + 'description' => 'Відображається у вашому Профілі компанії в Пошуку та Картах', + ], ], 'platforms' => [ diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index af05c84ea..6b3ad81b0 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => '连接你的 Mastodon 账号', 'telegram' => '连接一个 Telegram 频道或群组', 'discord' => '连接一个 Discord 服务器', + 'google_business' => '连接一个 Google 商家资料位置', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => '未找到关联了 Instagram 账号的 Facebook 主页。', 'no_youtube_channels' => '未找到 YouTube 频道,请先创建一个频道。', 'not_linkedin_admin' => '你不是任何 LinkedIn 页面的管理员。', + 'no_google_business_locations' => '未找到 Google 商家资料位置。请先验证您的企业。', + 'location_not_found' => '未找到该位置。', + 'error_connecting_location' => '连接位置时出错。请重试。', + ], + + 'google_business' => [ + 'title' => '选择商家位置', + 'description' => '选择您要连接的位置', + 'no_locations' => '未找到位置', + 'no_locations_description' => '您不是任何已验证的 Google 商家资料位置的管理者。', + 'choose' => '选择', ], ]; diff --git a/lang/zh/analytics.php b/lang/zh/analytics.php index d9e81278d..8a150b21f 100644 --- a/lang/zh/analytics.php +++ b/lang/zh/analytics.php @@ -51,5 +51,10 @@ 'video_views' => '视频观看量', 'videos' => '视频', 'views' => '观看量', + 'website_clicks' => '网站点击', + 'call_clicks' => '通话点击', + 'direction_requests' => '方向请求', + 'desktop_map_impressions' => '桌面地图展示量', + 'mobile_map_impressions' => '移动地图展示量', ], ]; diff --git a/lang/zh/posts.php b/lang/zh/posts.php index 449ed49e7..2f4a8a711 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -186,6 +186,42 @@ 'embed_image' => '图片 URL', 'embed_color' => '颜色', ], + 'google_business' => [ + 'settings' => 'Google Business Profile 设置', + 'posting_to' => '发布到', + 'topic_type_label' => '帖子类型', + 'topic_type' => [ + 'standard' => '更新', + 'event' => '活动', + 'offer' => '优惠', + ], + 'cta_label' => '按钮', + 'cta_none' => '无按钮', + 'cta' => [ + 'book' => '预订', + 'order' => '在线订购', + 'shop' => '购买', + 'learn_more' => '了解详情', + 'sign_up' => '注册', + 'get_offer' => '获取优惠', + 'call' => '立即致电', + ], + 'cta_url' => '按钮链接', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => '请输入此按钮的链接,或选择"无按钮"。', + 'event_title' => '活动标题', + 'event_title_placeholder' => '夏季促销', + 'event_title_required' => '请输入活动标题。', + 'event_start_date' => '开始日期', + 'event_start_date_required' => '请输入开始日期。', + 'event_end_date' => '结束日期', + 'event_end_date_required' => '请输入结束日期。', + 'event_start_time' => '开始时间', + 'event_end_time' => '结束时间', + 'offer_coupon_code' => '优惠券代码', + 'offer_redeem_url' => '在线兑换链接', + 'offer_terms' => '条款和条件', + ], 'warnings' => [ 'no_variant' => '请选择一个帖子类型以继续。', 'requires_media' => '此帖子类型至少需要一张图片或一个视频。', @@ -550,6 +586,10 @@ 'label' => '消息', 'description' => '发送到 Discord 频道的消息,可附带媒体和嵌入内容', ], + 'google_business_post' => [ + 'label' => '帖子', + 'description' => '在搜索和地图中显示在您的商业资料中', + ], ], 'platforms' => [ diff --git a/public/images/accounts/google_business.png b/public/images/accounts/google_business.png new file mode 100644 index 000000000..2ff782a9e Binary files /dev/null and b/public/images/accounts/google_business.png differ diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue index ce3d00e87..de642c52e 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -4,6 +4,7 @@ import { computed } from 'vue'; import DiscordSettings from '@/components/posts/editor/DiscordSettings.vue'; import FacebookSettings from '@/components/posts/editor/FacebookSettings.vue'; +import GoogleBusinessSettings from '@/components/posts/editor/GoogleBusinessSettings.vue'; import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue'; import LinkedInSettings from '@/components/posts/editor/LinkedInSettings.vue'; import PinterestSettings from '@/components/posts/editor/PinterestSettings.vue'; @@ -39,6 +40,9 @@ const emit = defineEmits<{ const isSelected = (id: string): boolean => props.selectedIds.includes(id); +// Order matches the `platforms` array the editor submits (both filter the same +// post_platforms list by the same selection), so a settings panel's position +// here is the `platforms.{index}.*` index its backend errors are keyed by. const selectedChannels = computed(() => props.channels.filter((channel) => isSelected(channel.id))); @@ -110,7 +114,7 @@ const selectedChannels = computed(() => props.channels.filter((channel) => isSel - diff --git a/resources/js/components/accounts/NetworkConnectGrid.vue b/resources/js/components/accounts/NetworkConnectGrid.vue index 314beabd4..861b17b6c 100644 --- a/resources/js/components/accounts/NetworkConnectGrid.vue +++ b/resources/js/components/accounts/NetworkConnectGrid.vue @@ -121,6 +121,11 @@ const platformTheme: Record< rotate: 'rotate-1', image: '/images/accounts/discord.png', }, + google_business: { + bg: 'bg-blue-100', + rotate: 'rotate-2', + image: '/images/accounts/google_business.png', + }, }; const themeFor = (value: string) => diff --git a/resources/js/components/analytics/GoogleBusinessAnalytics.vue b/resources/js/components/analytics/GoogleBusinessAnalytics.vue new file mode 100644 index 000000000..943e0480f --- /dev/null +++ b/resources/js/components/analytics/GoogleBusinessAnalytics.vue @@ -0,0 +1,61 @@ + + + diff --git a/resources/js/components/automations/config/GenerateNodeConfig.vue b/resources/js/components/automations/config/GenerateNodeConfig.vue index b3311ed63..a5ca08d71 100644 --- a/resources/js/components/automations/config/GenerateNodeConfig.vue +++ b/resources/js/components/automations/config/GenerateNodeConfig.vue @@ -127,6 +127,8 @@ const defaultContentTypeFor = (platform: string): string => { return ContentType.BlueskyPost; case Platform.Mastodon: return ContentType.MastodonPost; + case Platform.GoogleBusiness: + return ContentType.GoogleBusinessPost; default: return ''; } diff --git a/resources/js/components/posts/editor/GoogleBusinessSettings.vue b/resources/js/components/posts/editor/GoogleBusinessSettings.vue new file mode 100644 index 000000000..9a16fc025 --- /dev/null +++ b/resources/js/components/posts/editor/GoogleBusinessSettings.vue @@ -0,0 +1,217 @@ + + +