diff --git a/.env.example b/.env.example index 8b76d274e..b27efe238 100644 --- a/.env.example +++ b/.env.example @@ -157,6 +157,12 @@ GOOGLE_AUTH_ENABLED=false GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_REDIRECT="${APP_URL}/accounts/youtube/callback" + +# Google Business Profile (https://developers.google.com/my-business) +GOOGLE_BUSINESS_PROFILE_CLIENT_ID= +GOOGLE_BUSINESS_PROFILE_CLIENT_SECRET= +GOOGLE_BUSINESS_PROFILE_CLIENT_REDIRECT="${APP_URL}/accounts/google-business-profile/callback" +GOOGLE_BUSINESS_PROFILE_ENABLED=true GOOGLE_AUTH_CALLBACK="${APP_URL}/auth/google/callback" # GitHub (https://github.com/settings/developers) diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index a0f439e89..5616d2959 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -6,11 +6,13 @@ use App\Enums\Post\CreatedVia; use App\Enums\Post\Status as PostStatus; +use App\Enums\SocialAccount\Platform; use App\Models\Post; use App\Models\User; use App\Models\Workspace; use Carbon\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; class CreatePost { @@ -35,7 +37,7 @@ class CreatePost * date?: ?string, * scheduled_at?: ?string, * created_via?: ?CreatedVia, - * platforms?: array}>, + * platforms?: array}>, * label_ids?: array * } $data */ @@ -62,6 +64,27 @@ public static function execute(Workspace $workspace, User $user, array $data): P } $updates = ['enabled' => true]; + $googleBusinessProfileLocationId = data_get($platformData, 'google_business_profile_location_id'); + $account = $workspace->socialAccounts()->find($accountId); + + if ($account?->platform === Platform::GoogleBusinessProfile) { + if (! $googleBusinessProfileLocationId) { + throw ValidationException::withMessages([ + 'platforms' => 'Choose a specific Google Business Profile location.', + ]); + } + + $validLocation = $account->googleBusinessProfileLocations() + ->where('is_selected', true) + ->whereKey($googleBusinessProfileLocationId) + ->exists(); + + if (! $validLocation) { + throw ValidationException::withMessages([ + 'platforms' => 'Choose a currently connected Google Business Profile location.', + ]); + } + } if ($contentType = data_get($platformData, 'content_type')) { $updates['content_type'] = $contentType; @@ -71,6 +94,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P if (is_array($meta) && $meta !== []) { $existing = $post->postPlatforms() ->where('social_account_id', $accountId) + ->when($googleBusinessProfileLocationId, fn ($query) => $query->where('google_business_profile_location_id', $googleBusinessProfileLocationId)) ->first(); if ($existing) { @@ -83,6 +107,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P $post->postPlatforms() ->where('social_account_id', $accountId) + ->when($googleBusinessProfileLocationId, fn ($query) => $query->where('google_business_profile_location_id', $googleBusinessProfileLocationId)) ->update($updates); } diff --git a/app/Actions/Post/DeletePost.php b/app/Actions/Post/DeletePost.php index e907acc9b..9e0f7f4cd 100644 --- a/app/Actions/Post/DeletePost.php +++ b/app/Actions/Post/DeletePost.php @@ -6,15 +6,30 @@ use App\Events\PostDeleted; use App\Models\Post; +use App\Support\PostStatusRules; +use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; class DeletePost { public static function execute(Post $post): void { - $postId = $post->id; - $workspaceId = $post->workspace_id; + [$postId, $workspaceId] = DB::transaction(function () use ($post): array { + $fresh = Post::query()->lockForUpdate()->findOrFail($post->getKey()); - $post->delete(); + if (PostStatusRules::blocksDeletion($fresh)) { + throw ValidationException::withMessages([ + 'post' => PostStatusRules::deleteBlockedMessage(), + ]); + } + + $postId = $fresh->id; + $workspaceId = $fresh->workspace_id; + + $fresh->delete(); + + return [$postId, $workspaceId]; + }); PostDeleted::dispatch($postId, $workspaceId); } diff --git a/app/Actions/Post/DuplicatePost.php b/app/Actions/Post/DuplicatePost.php index 3ce6dc1da..f9bc347a6 100644 --- a/app/Actions/Post/DuplicatePost.php +++ b/app/Actions/Post/DuplicatePost.php @@ -6,6 +6,7 @@ use App\Enums\Post\CreatedVia; use App\Enums\Post\Status as PostStatus; +use App\Enums\PostPlatform\ContentType; use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Models\Post; use App\Models\User; @@ -36,26 +37,42 @@ public static function execute(Post $original, User $user): Post ]); $platforms = $original->postPlatforms() + ->with('googleBusinessProfileLocation') ->whereHas('socialAccount') ->get(); foreach ($platforms as $platform) { + $locationDisconnected = $platform->google_business_profile_location_id + && ! $platform->googleBusinessProfileLocation?->is_selected; + $contentType = $platform->content_type->isAuthorable() + ? $platform->content_type + : ContentType::defaultFor($platform->platform); + $meta = $platform->meta; + + if (! $platform->content_type->isAuthorable()) { + unset($meta['alert_type']); + } + $copy->postPlatforms()->create([ 'social_account_id' => $platform->social_account_id, + 'google_business_profile_location_id' => $platform->google_business_profile_location_id, 'platform' => $platform->platform, 'platform_name' => $platform->platform_name, 'platform_username' => $platform->platform_username, 'platform_avatar' => $platform->getRawOriginal('platform_avatar'), - 'content_type' => $platform->content_type, - 'enabled' => $platform->enabled, - 'meta' => $platform->meta, + 'content_type' => $contentType, + 'enabled' => $platform->enabled && ! $locationDisconnected, + 'meta' => $meta, // Always reset platform-level status — never carry // published/failed/publishing into the new draft. 'status' => PostPlatformStatus::Pending, 'platform_post_id' => null, 'platform_url' => null, - 'error_message' => null, - 'error_context' => null, + 'error_message' => $locationDisconnected ? __('posts.errors.gbp_location_disconnected') : null, + 'error_context' => $locationDisconnected ? [ + 'category' => 'connection_action_required', + 'reason' => 'gbp_location_disconnected', + ] : null, 'published_at' => null, ]); } diff --git a/app/Actions/Post/SyncPostPlatforms.php b/app/Actions/Post/SyncPostPlatforms.php index b0709fdbf..759bbb961 100644 --- a/app/Actions/Post/SyncPostPlatforms.php +++ b/app/Actions/Post/SyncPostPlatforms.php @@ -6,6 +6,7 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\PostPlatform\Status as PostPlatformStatus; +use App\Enums\SocialAccount\Platform; use App\Models\Post; class SyncPostPlatforms @@ -20,14 +21,44 @@ public static function execute(Post $post): void { $workspace = $post->workspace; - $existingAccountIds = $post->postPlatforms()->pluck('social_account_id')->filter(); + $existingAccountIds = $post->postPlatforms() + ->whereNull('google_business_profile_location_id') + ->pluck('social_account_id') + ->filter(); + + $existingGoogleLocationIds = $post->postPlatforms() + ->whereNotNull('google_business_profile_location_id') + ->pluck('google_business_profile_location_id') + ->filter(); $missingAccounts = $workspace->socialAccounts() ->active() - ->whereNotIn('id', $existingAccountIds) + ->with(['googleBusinessProfileLocations' => fn ($query) => $query->where('is_selected', true)]) ->get(); foreach ($missingAccounts as $account) { + if ($account->platform === Platform::GoogleBusinessProfile) { + foreach ($account->googleBusinessProfileLocations->whereNotIn('id', $existingGoogleLocationIds) as $location) { + $post->postPlatforms()->create([ + 'social_account_id' => $account->id, + 'google_business_profile_location_id' => $location->id, + 'platform' => $account->platform->value, + 'platform_name' => $location->title, + 'platform_username' => $location->store_code, + 'platform_avatar' => $account->getRawOriginal('avatar_url'), + 'content_type' => ContentType::defaultFor($account->platform), + 'status' => PostPlatformStatus::Pending, + 'enabled' => false, + ]); + } + + continue; + } + + if ($existingAccountIds->contains($account->id)) { + continue; + } + $post->postPlatforms()->create([ 'social_account_id' => $account->id, 'platform' => $account->platform->value, diff --git a/app/Actions/SocialAccount/ManageGoogleBusinessProfileLocationTargets.php b/app/Actions/SocialAccount/ManageGoogleBusinessProfileLocationTargets.php new file mode 100644 index 000000000..055c31bb8 --- /dev/null +++ b/app/Actions/SocialAccount/ManageGoogleBusinessProfileLocationTargets.php @@ -0,0 +1,87 @@ +|array $locationIds */ + public function disable(Collection|array $locationIds): int + { + $ids = collect($locationIds)->values(); + + if ($ids->isEmpty()) { + return 0; + } + + $targets = PostPlatform::query() + ->whereIn('google_business_profile_location_id', $ids) + ->where('status', PostPlatformStatus::Pending) + ->where('enabled', true) + ->whereHas('post', fn ($query) => $query->whereIn('status', [PostStatus::Draft, PostStatus::Scheduled])) + ->lockForUpdate() + ->get(); + + $affectedPostIds = $targets->pluck('post_id')->unique(); + + foreach ($targets as $target) { + $target->update([ + 'enabled' => false, + 'error_message' => __('posts.errors.gbp_location_disconnected'), + 'error_context' => [ + ...(is_array($target->error_context) ? $target->error_context : []), + 'category' => 'connection_action_required', + 'reason' => self::REASON, + 'disconnected_at' => now()->toIso8601String(), + ], + ]); + } + + // A scheduled post is one publish unit. If any destination is removed, + // stop the whole schedule so it cannot partially publish elsewhere. + Post::query() + ->whereIn('id', $affectedPostIds) + ->where('status', PostStatus::Scheduled) + ->update(['status' => PostStatus::Draft, 'scheduled_at' => null]); + + return $targets->count(); + } + + /** @param Collection|array $locationIds */ + public function restore(Collection|array $locationIds): int + { + $ids = collect($locationIds)->values(); + + if ($ids->isEmpty()) { + return 0; + } + + $targets = PostPlatform::query() + ->whereIn('google_business_profile_location_id', $ids) + ->where('status', PostPlatformStatus::Pending) + ->where('enabled', false) + ->whereHas('post', fn ($query) => $query->where('status', PostStatus::Draft)) + ->lockForUpdate() + ->get() + ->filter(fn (PostPlatform $target): bool => data_get($target->error_context, 'reason') === self::REASON); + + foreach ($targets as $target) { + $target->update([ + 'enabled' => true, + 'error_message' => null, + 'error_context' => null, + ]); + } + + return $targets->count(); + } +} diff --git a/app/Console/Commands/ReconcileGoogleBusinessProfilePosts.php b/app/Console/Commands/ReconcileGoogleBusinessProfilePosts.php new file mode 100644 index 000000000..e044c0abf --- /dev/null +++ b/app/Console/Commands/ReconcileGoogleBusinessProfilePosts.php @@ -0,0 +1,32 @@ +where('platform', Platform::GoogleBusinessProfile) + ->whereIn('status', [Status::Submitted, Status::PendingReview]) + ->whereNotNull('platform_post_id') + ->oldest('last_reconciled_at') + ->chunkById(100, function ($postPlatforms): void { + foreach ($postPlatforms as $postPlatform) { + ReconcileGoogleBusinessProfilePost::dispatch($postPlatform); + } + }); + } +} diff --git a/app/Console/Commands/RecoverStuckPosts.php b/app/Console/Commands/RecoverStuckPosts.php index a8f74498d..93968be49 100644 --- a/app/Console/Commands/RecoverStuckPosts.php +++ b/app/Console/Commands/RecoverStuckPosts.php @@ -59,7 +59,13 @@ public function handle(): void // fresh updated_at — do not finalize the post while that work is still live. $stillActive = $post->postPlatforms() ->enabled() - ->whereIn('status', [PlatformStatus::Publishing, PlatformStatus::Pending, PlatformStatus::Retrying]) + ->whereIn('status', [ + PlatformStatus::Publishing, + PlatformStatus::Pending, + PlatformStatus::Retrying, + PlatformStatus::Submitted, + PlatformStatus::PendingReview, + ]) ->exists(); if ($stillActive) { @@ -70,7 +76,9 @@ public function handle(): void $total = $enabledPlatforms->count(); $publishedCount = $enabledPlatforms->where('status', PlatformStatus::Published)->count(); - if ($publishedCount === $total) { + if ($total === 0) { + $post->markAsFailed(); + } elseif ($publishedCount === $total) { $post->markAsPublished(); } elseif ($publishedCount > 0) { $post->markAsPartiallyPublished(); diff --git a/app/Console/Commands/RetryFailedPost.php b/app/Console/Commands/RetryFailedPost.php index 6bc7e4ae2..f03b40619 100644 --- a/app/Console/Commands/RetryFailedPost.php +++ b/app/Console/Commands/RetryFailedPost.php @@ -11,6 +11,7 @@ use App\Jobs\PublishToSocialPlatform; use App\Models\Post; use App\Models\PostPlatform; +use App\Support\Social\GoogleBusinessProfileMediaDerivativeCleaner; use App\Support\Social\PublishCheckpoint; use App\Support\Social\TikTokPhotoDerivativeCleaner; use Illuminate\Console\Command; @@ -18,6 +19,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Storage; class RetryFailedPost extends Command { @@ -28,6 +30,7 @@ class RetryFailedPost extends Command public function __construct( private readonly TikTokPhotoDerivativeCleaner $tiktokPhotoDerivativeCleaner, + private readonly GoogleBusinessProfileMediaDerivativeCleaner $googleBusinessProfileMediaDerivativeCleaner, ) { parent::__construct(); } @@ -86,6 +89,11 @@ public function handle(): int $this->tiktokPhotoDerivativeCleaner->cleanup($entry['original_error_context'], $entry['id']); } + if ($entry['platform'] === SocialPlatform::GoogleBusinessProfile + && PublishCheckpoint::googleBusinessProfileDerivativePath($entry['error_context']) === null) { + $this->googleBusinessProfileMediaDerivativeCleaner->cleanup($entry['original_error_context'], $entry['id']); + } + $postPlatform = PostPlatform::query()->findOrFail($entry['id']); PublishToSocialPlatform::dispatch($postPlatform); } @@ -183,6 +191,7 @@ private function resumableContext(?array $context): ?array $kept = []; $publishId = PublishCheckpoint::tiktokPublishId($context); $workflow = PublishCheckpoint::instagramWorkflow($context); + $googleDerivativePath = PublishCheckpoint::googleBusinessProfileDerivativePath($context); if ($publishId !== null) { $kept[PublishCheckpoint::TIKTOK_PUBLISH_ID] = $publishId; @@ -197,6 +206,11 @@ private function resumableContext(?array $context): ?array $kept[PublishCheckpoint::INSTAGRAM_WORKFLOW] = $workflow; } + if ($this->googleBusinessProfileMediaDerivativeCleaner->isManagedDerivativePath($googleDerivativePath) + && Storage::exists($googleDerivativePath)) { + $kept[PublishCheckpoint::GOOGLE_BUSINESS_PROFILE_DERIVATIVE_PATH] = $googleDerivativePath; + } + return $kept === [] ? null : $kept; } } diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 0d56e74cd..fd3f36250 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -33,6 +33,12 @@ enum ContentType: string // YouTube case YouTubeShort = 'youtube_short'; + // Google Business Profile local posts + case GoogleBusinessProfileStandard = 'google_business_profile_standard'; + case GoogleBusinessProfileEvent = 'google_business_profile_event'; + case GoogleBusinessProfileOffer = 'google_business_profile_offer'; + case GoogleBusinessProfileAlert = 'google_business_profile_alert'; + // X (Twitter) case XPost = 'x_post'; @@ -75,6 +81,10 @@ public function label(): string self::TikTokVideo => 'Video', self::TikTokPhoto => 'Photo carousel', self::YouTubeShort => 'Short', + self::GoogleBusinessProfileStandard => 'Update', + self::GoogleBusinessProfileEvent => 'Event', + self::GoogleBusinessProfileOffer => 'Offer', + self::GoogleBusinessProfileAlert => 'Alert', self::XPost => 'Post', self::ThreadsPost => 'Post', self::PinterestPin => 'Pin', @@ -101,6 +111,10 @@ public function platform(): SocialPlatform self::FacebookPost, self::FacebookReel, self::FacebookStory => SocialPlatform::Facebook, self::TikTokVideo, self::TikTokPhoto => SocialPlatform::TikTok, self::YouTubeShort => SocialPlatform::YouTube, + self::GoogleBusinessProfileStandard, + self::GoogleBusinessProfileEvent, + self::GoogleBusinessProfileOffer, + self::GoogleBusinessProfileAlert => SocialPlatform::GoogleBusinessProfile, self::XPost => SocialPlatform::X, self::ThreadsPost => SocialPlatform::Threads, self::PinterestPin, self::PinterestVideoPin, self::PinterestCarousel => SocialPlatform::Pinterest, @@ -131,6 +145,10 @@ public function aiImageDimensions(): array self::XPost, self::BlueskyPost, self::MastodonPost => ['width' => 1080, 'height' => 1080], + self::GoogleBusinessProfileStandard, + self::GoogleBusinessProfileEvent, + self::GoogleBusinessProfileOffer, + self::GoogleBusinessProfileAlert => ['width' => 1200, 'height' => 900], // Stories 9:16 (Instagram + Facebook) self::InstagramStory, @@ -169,6 +187,10 @@ public function maxMediaCount(): int self::TikTokVideo => 1, self::TikTokPhoto => 35, self::YouTubeShort => 1, + self::GoogleBusinessProfileStandard, + self::GoogleBusinessProfileEvent, + self::GoogleBusinessProfileOffer, + self::GoogleBusinessProfileAlert => 1, self::XPost => 4, self::ThreadsPost => 10, self::PinterestPin, self::PinterestVideoPin => 1, @@ -450,6 +472,10 @@ public function supportsVideo(): bool self::TikTokVideo => true, self::TikTokPhoto => false, self::YouTubeShort => true, + self::GoogleBusinessProfileStandard, + self::GoogleBusinessProfileEvent, + self::GoogleBusinessProfileOffer, + self::GoogleBusinessProfileAlert => true, self::XPost => true, self::ThreadsPost => true, self::PinterestVideoPin => true, @@ -469,6 +495,10 @@ public function supportsImage(): bool self::TikTokVideo => false, self::TikTokPhoto => true, self::YouTubeShort => false, + self::GoogleBusinessProfileStandard, + self::GoogleBusinessProfileEvent, + self::GoogleBusinessProfileOffer, + self::GoogleBusinessProfileAlert => true, self::PinterestVideoPin => false, default => true, }; @@ -526,6 +556,10 @@ public function requiresMedia(): bool self::TelegramPost => false, self::FacebookPost => false, self::DiscordMessage => false, + self::GoogleBusinessProfileStandard, + self::GoogleBusinessProfileEvent, + self::GoogleBusinessProfileOffer, + self::GoogleBusinessProfileAlert => false, default => true, }; } @@ -550,6 +584,7 @@ public static function aiSupported(): array self::FacebookPost, self::PinterestPin, self::PinterestCarousel, + self::GoogleBusinessProfileStandard, ]; } @@ -586,10 +621,19 @@ public static function forPlatform(SocialPlatform $platform): array return array_filter( self::cases(), - fn (self $type) => $type->platform() === $effective + fn (self $type) => $type->platform() === $effective && $type->isAuthorable() ); } + /** + * Whether providers currently allow creating new posts of this type. + * Kept separate from enum membership so historical posts still render. + */ + public function isAuthorable(): bool + { + return $this !== self::GoogleBusinessProfileAlert; + } + /** * Get the default content type for a platform. */ @@ -602,6 +646,7 @@ public static function defaultFor(SocialPlatform $platform): self SocialPlatform::Facebook => self::FacebookPost, SocialPlatform::TikTok => self::TikTokVideo, SocialPlatform::YouTube => self::YouTubeShort, + SocialPlatform::GoogleBusinessProfile => self::GoogleBusinessProfileStandard, SocialPlatform::X => self::XPost, SocialPlatform::Threads => self::ThreadsPost, SocialPlatform::Pinterest => self::PinterestPin, diff --git a/app/Enums/PostPlatform/Status.php b/app/Enums/PostPlatform/Status.php index 354c683bb..8bec83221 100644 --- a/app/Enums/PostPlatform/Status.php +++ b/app/Enums/PostPlatform/Status.php @@ -8,7 +8,10 @@ enum Status: string { case Pending = 'pending'; case Publishing = 'publishing'; + case Submitted = 'submitted'; + case PendingReview = 'pending_review'; case Retrying = 'retrying'; case Published = 'published'; case Failed = 'failed'; + case Rejected = 'rejected'; } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 1d9d207c8..8dc4c96c7 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -13,6 +13,7 @@ enum Platform: string case X = 'x'; case TikTok = 'tiktok'; case YouTube = 'youtube'; + case GoogleBusinessProfile = 'google-business-profile'; case Facebook = 'facebook'; case Instagram = 'instagram'; case InstagramFacebook = 'instagram-facebook'; @@ -60,6 +61,7 @@ public function label(): string self::X => 'X', self::TikTok => 'TikTok', self::YouTube => 'YouTube Shorts', + self::GoogleBusinessProfile => 'Google Business Profile', self::Facebook => 'Facebook Page', self::Instagram => 'Instagram', self::InstagramFacebook => 'Instagram (Facebook Business)', @@ -79,6 +81,7 @@ public function color(): string self::X => '#000000', self::TikTok => '#000000', self::YouTube => '#FF0000', + self::GoogleBusinessProfile => '#4285F4', self::Facebook => '#1877F2', self::Instagram => '#E4405F', self::InstagramFacebook => '#E4405F', @@ -98,6 +101,7 @@ public function allowedMediaTypes(): array self::X => [MediaType::Image, MediaType::Video], self::TikTok => [MediaType::Video], self::YouTube => [MediaType::Video], + self::GoogleBusinessProfile => [MediaType::Image, MediaType::Video], self::Facebook => [MediaType::Image, MediaType::Video], self::Instagram, self::InstagramFacebook => [MediaType::Image, MediaType::Video], self::Threads => [MediaType::Image, MediaType::Video], @@ -116,6 +120,7 @@ public function maxImages(): int self::X => 4, self::TikTok => 0, self::YouTube => 0, + self::GoogleBusinessProfile => 1, self::Facebook => 10, self::Instagram, self::InstagramFacebook => 10, self::Threads => 10, @@ -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::GoogleBusinessProfile, self::Telegram => null, }; } @@ -189,6 +194,7 @@ public function maxContentLength(): int self::X => 280, self::TikTok => 2200, self::YouTube => 100, + self::GoogleBusinessProfile => 1500, self::Facebook => 10000, self::Instagram, self::InstagramFacebook => 2200, self::Threads => 500, @@ -238,6 +244,7 @@ public function recommendedAiContentLength(): int // YouTube Shorts — fits within the 100-char title (with " #Shorts" // suffix taking 8 chars) so the same string works as title + desc self::YouTube => 80, + self::GoogleBusinessProfile => 300, // Telegram channel posts — short announcements read best self::Telegram => 400, // Discord — conversational community posts read best when concise @@ -256,6 +263,7 @@ public function requiredPublishScopes(): array self::Facebook => ['pages_manage_posts'], self::TikTok => ['video.publish'], self::YouTube => ['https://www.googleapis.com/auth/youtube.upload'], + self::GoogleBusinessProfile => ['https://www.googleapis.com/auth/business.manage'], self::LinkedIn => ['w_member_social'], self::LinkedInPage => ['w_organization_social'], self::X => ['tweet.write'], @@ -275,6 +283,7 @@ public function supportsTextOnly(): bool self::X => true, self::TikTok => false, self::YouTube => false, + self::GoogleBusinessProfile => true, self::Facebook => true, self::Instagram, self::InstagramFacebook => false, self::Threads => true, @@ -323,7 +332,7 @@ public function hasTokenRefreshFlow(): bool { return match ($this) { self::LinkedIn, self::LinkedInPage, self::X, self::Bluesky, - self::YouTube, self::TikTok, self::Pinterest, + self::YouTube, self::GoogleBusinessProfile, self::TikTok, self::Pinterest, self::Threads, self::Instagram => true, default => false, }; diff --git a/app/Exceptions/Social/GoogleBusinessProfilePublishException.php b/app/Exceptions/Social/GoogleBusinessProfilePublishException.php new file mode 100644 index 000000000..a16d5f761 --- /dev/null +++ b/app/Exceptions/Social/GoogleBusinessProfilePublishException.php @@ -0,0 +1,56 @@ +json(), 'error.message', 'Google Business Profile rejected the post.'); + $code = (string) data_get($response->json(), 'error.status', $response->status()); + + if ($response->status() === 401) { + throw new TokenExpiredException($message, platformErrorCode: $code); + } + + if ($response->status() === 429 || $response->serverError()) { + $redactedBody = TokenRedactor::redact($response->body()); + + throw new PlatformUnavailableException( + "Google Business Profile API temporarily failed ({$response->status()}).", + $response->status(), + array_filter([ + 'platform_error_code' => $code, + 'google_error_status' => data_get($response->json(), 'error.status'), + 'google_error_message' => $message, + 'raw_response' => $redactedBody === null ? null : mb_substr($redactedBody, 0, 2000), + ], fn (mixed $value): bool => $value !== null && $value !== ''), + ); + } + + $category = match (true) { + $response->status() === 403 => ErrorCategory::Permission, + default => ErrorCategory::ContentPolicy, + }; + + return new static( + userMessage: $message, + category: $category, + platformErrorCode: $code, + rawResponse: $response->body(), + ); + } + + public function platform(): string + { + return 'google-business-profile'; + } +} diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php index 8376afe8f..768d6ac8b 100644 --- a/app/Http/Controllers/App/AnalyticsController.php +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -6,9 +6,13 @@ use App\Enums\SocialAccount\Platform; use App\Exceptions\PlatformUnavailableException; +use App\Exceptions\Social\GoogleBusinessProfilePublishException; +use App\Exceptions\TokenExpiredException; use App\Http\Controllers\Controller; +use App\Models\GoogleBusinessProfileLocation; use App\Models\SocialAccount; use App\Services\Social\FacebookAnalytics; +use App\Services\Social\GoogleBusinessProfile\GoogleBusinessProfileAnalytics; use App\Services\Social\InstagramAnalytics; use App\Services\Social\LinkedInPageAnalytics; use App\Services\Social\PinterestAnalytics; @@ -37,6 +41,7 @@ class AnalyticsController extends Controller Platform::LinkedInPage, Platform::Pinterest, Platform::YouTube, + Platform::GoogleBusinessProfile, Platform::Telegram, ]; @@ -50,13 +55,33 @@ public function index(Request $request): Response ->where('is_active', true) ->whereIn('platform', self::SUPPORTED_PLATFORMS) ->get() - ->map(fn (SocialAccount $account) => [ - 'id' => $account->id, - 'platform' => $account->platform->value, - 'username' => $account->username, - 'display_label' => $account->display_label, - 'avatar_url' => $account->avatar_url, - ]); + ->flatMap(function (SocialAccount $account): array { + if ($account->platform === Platform::GoogleBusinessProfile) { + return $account->googleBusinessProfileLocations() + ->where('is_selected', true) + ->orderBy('title') + ->get() + ->map(fn (GoogleBusinessProfileLocation $location): array => [ + 'id' => 'gbp-location-'.$location->id, + 'account_id' => $account->id, + 'location_id' => $location->id, + 'platform' => $account->platform->value, + 'username' => null, + 'display_label' => $location->title, + 'avatar_url' => $account->avatar_url, + ])->all(); + } + + return [[ + 'id' => $account->id, + 'account_id' => $account->id, + 'location_id' => null, + 'platform' => $account->platform->value, + 'username' => $account->username, + 'display_label' => $account->display_label, + 'avatar_url' => $account->avatar_url, + ]]; + })->values(); return Inertia::render('analytics/Index', [ 'accounts' => $accounts, @@ -74,7 +99,14 @@ public function show(Request $request, SocialAccount $account): JsonResponse $since = $request->has('since') ? Carbon::parse($request->input('since')) : null; $until = $request->has('until') ? Carbon::parse($request->input('until')) : null; - $metrics = $this->metricsFor($account, $since, $until); + $location = null; + if ($account->platform === Platform::GoogleBusinessProfile && $request->filled('location_id')) { + $location = $account->googleBusinessProfileLocations() + ->where('is_selected', true) + ->findOrFail($request->string('location_id')->toString()); + } + + $metrics = $this->metricsFor($account, $since, $until, $location); return response()->json(['metrics' => $metrics]); } @@ -86,8 +118,12 @@ public function show(Request $request, SocialAccount $account): JsonResponse * * @return array */ - private function metricsFor(SocialAccount $account, ?Carbon $since, ?Carbon $until): array - { + private function metricsFor( + SocialAccount $account, + ?Carbon $since, + ?Carbon $until, + ?GoogleBusinessProfileLocation $googleBusinessProfileLocation = null, + ): array { try { return match ($account->platform) { Platform::TikTok => app(TikTokAnalytics::class)->getMetrics($account), @@ -98,10 +134,11 @@ private function metricsFor(SocialAccount $account, ?Carbon $since, ?Carbon $unt Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->getMetrics($account, $since, $until), Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until), Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until), + Platform::GoogleBusinessProfile => app(GoogleBusinessProfileAnalytics::class)->getMetrics($account, $since, $until, $googleBusinessProfileLocation), Platform::Telegram => app(TelegramAnalytics::class)->getMetrics($account), default => [], }; - } catch (PlatformUnavailableException|ConnectionException $e) { + } catch (GoogleBusinessProfilePublishException|PlatformUnavailableException|TokenExpiredException|ConnectionException $e) { report($e); return []; diff --git a/app/Http/Controllers/App/PostAiCreateController.php b/app/Http/Controllers/App/PostAiCreateController.php index 54d5917b1..6fb3ada19 100644 --- a/app/Http/Controllers/App/PostAiCreateController.php +++ b/app/Http/Controllers/App/PostAiCreateController.php @@ -32,6 +32,7 @@ public function start(StartPostCreationRequest $request): JsonResponse if ($socialAccountId) { $owned = SocialAccount::where('id', $socialAccountId) ->where('workspace_id', $workspace->id) + ->where('is_active', true) ->exists(); if (! $owned) { @@ -47,6 +48,7 @@ public function start(StartPostCreationRequest $request): JsonResponse workspaceId: $workspace->id, format: $request->string('format')->toString(), socialAccountId: $socialAccountId, + googleBusinessProfileLocationId: $request->input('google_business_profile_location_id'), imageCount: (int) $request->input('image_count', 0), prompt: $request->string('prompt')->toString(), date: $request->input('date'), @@ -69,6 +71,7 @@ public function loading(Request $request, string $creationId): InertiaResponse 'format' => (string) $request->query('format', ''), 'prompt' => (string) $request->query('prompt', ''), 'socialAccountId' => $request->query('social_account_id') ?: null, + 'googleBusinessProfileLocationId' => $request->query('google_business_profile_location_id') ?: null, 'date' => $request->query('date') ?: null, 'template' => (string) $request->query('template', 'image_card'), 'applyBrandVisuals' => $request->boolean('apply_brand_visuals', true), diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index 9b0702eb8..2da4d0453 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -47,7 +47,7 @@ public function index(Request $request, ?string $status = null): Response|Redire $this->authorize('view', $workspace); $query = $workspace->posts() - ->with(['postPlatforms' => fn ($query) => $query->enabled()->with('socialAccount'), 'user', 'labels']); + ->with(['postPlatforms' => fn ($query) => $query->enabled()->with(['socialAccount', 'googleBusinessProfileLocation']), 'user', 'labels']); if ($status) { $query = match ($status) { @@ -124,7 +124,7 @@ public function calendar(Request $request): Response|RedirectResponse }; $posts = $workspace->posts() - ->with(['postPlatforms' => fn ($query) => $query->enabled()->with('socialAccount')]) + ->with(['postPlatforms' => fn ($query) => $query->enabled()->with(['socialAccount', 'googleBusinessProfileLocation'])]) ->whereBetween('scheduled_at', [$rangeStart->copy()->utc(), $rangeEnd->copy()->utc()]) ->orderBy('scheduled_at') ->get() @@ -222,7 +222,7 @@ public function show(Request $request, Post $post): Response|RedirectResponse return redirect()->route('app.posts.edit', $post); } - $post->load(['postPlatforms.socialAccount', 'labels']); + $post->load(['postPlatforms.socialAccount', 'postPlatforms.googleBusinessProfileLocation', 'labels']); return Inertia::render('posts/Show', [ 'workspace' => $workspace, @@ -248,7 +248,7 @@ public function edit(Request $request, Post $post): Response|RedirectResponse SyncPostPlatforms::execute($post); } - $post->load(['postPlatforms.socialAccount', 'labels']); + $post->load(['postPlatforms.socialAccount', 'postPlatforms.googleBusinessProfileLocation', 'labels']); $socialAccounts = $workspace->socialAccounts()->active()->get(); $labels = $workspace->labels; $signatures = $workspace->signatures; @@ -338,7 +338,7 @@ public function destroy(Request $request, Post $post): RedirectResponse $this->authorize('delete', $post); if (PostStatusRules::blocksDeletion($post)) { - session()->flash('flash.banner', __('posts.flash.cannot_delete_published')); + session()->flash('flash.banner', PostStatusRules::deleteBlockedMessage()); session()->flash('flash.bannerStyle', 'danger'); return back(); diff --git a/app/Http/Controllers/Auth/GoogleBusinessProfileController.php b/app/Http/Controllers/Auth/GoogleBusinessProfileController.php new file mode 100644 index 000000000..f3fad6bf4 --- /dev/null +++ b/app/Http/Controllers/Auth/GoogleBusinessProfileController.php @@ -0,0 +1,263 @@ +ensurePlatformEnabled(); + $workspace = $request->user()->currentWorkspace; + $this->authorize('manageAccounts', $workspace); + + session([ + 'social_connect_workspace' => $workspace->id, + 'social_reconnect_id' => null, + 'google_business_profile_account_id' => null, + ]); + + return Inertia::location($this->provider() + ->scopes($this->scopes) + ->with([ + 'access_type' => 'offline', + 'prompt' => 'consent', + 'include_granted_scopes' => 'true', + ]) + ->redirect() + ->getTargetUrl()); + } + + public function callback(Request $request, GoogleBusinessProfileApi $api): InertiaResponse|RedirectResponse + { + if ($request->filled('error')) { + return $this->popupCallback(false, __('accounts.popup_callback.failed_to_authenticate'), $this->platform->value); + } + + $workspace = $this->workspaceFromSession($request); + if (! $workspace) { + return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); + } + + try { + $googleUser = $this->provider()->user(); + $accounts = $api->accounts($googleUser->token); + $locations = collect($accounts) + ->flatMap(fn (array $account): array => collect($api->locations($googleUser->token, (string) data_get($account, 'name'))) + ->map(fn (array $location): array => [...$location, 'googleAccountName' => data_get($account, 'name')]) + ->all()) + ->values(); + + if ($locations->isEmpty()) { + return $this->popupCallback(false, __('accounts.popup_callback.no_google_business_profile_locations'), $this->platform->value); + } + + $socialAccount = DB::transaction(function () use ($workspace, $googleUser, $locations): SocialAccount { + $socialAccount = $workspace->socialAccounts()->firstOrNew([ + 'platform' => $this->platform->value, + 'platform_user_id' => $googleUser->getId(), + ]); + $socialAccount->fill([ + 'username' => $googleUser->getEmail(), + 'display_name' => $googleUser->getName() ?: $googleUser->getEmail(), + 'access_token' => $googleUser->token, + 'token_expires_at' => $googleUser->expiresIn ? now()->addSeconds($googleUser->expiresIn) : null, + 'scopes' => $this->scopes, + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + 'meta' => ['google_user_id' => $googleUser->getId()], + ]); + + // Google may omit the refresh token on a repeat authorization. + // Keep the previously issued token instead of making the account + // unable to refresh as soon as the new access token expires. + if (filled($googleUser->refreshToken)) { + $socialAccount->refresh_token = $googleUser->refreshToken; + } + $socialAccount->save(); + + $seen = []; + foreach ($locations as $location) { + $name = (string) data_get($location, 'name'); + $seen[] = $name; + $socialAccount->googleBusinessProfileLocations()->updateOrCreate( + ['google_location_name' => $name], + [ + 'google_account_name' => data_get($location, 'googleAccountName'), + 'title' => data_get($location, 'title'), + 'store_code' => data_get($location, 'storeCode'), + 'timezone' => data_get($location, 'metadata.timezone'), + 'maps_uri' => data_get($location, 'metadata.mapsUri'), + 'website_uri' => data_get($location, 'websiteUri'), + 'phone_number' => data_get($location, 'phoneNumbers.primaryPhone'), + 'storefront_address' => data_get($location, 'storefrontAddress'), + 'metadata' => data_get($location, 'metadata'), + 'is_verified' => (bool) data_get($location, 'metadata.hasVoiceOfMerchant', false), + 'last_synced_at' => now(), + ], + ); + } + + $socialAccount->googleBusinessProfileLocations() + ->whereNotIn('google_location_name', $seen) + ->update(['is_selected' => false]); + + return $socialAccount; + }); + + session(['google_business_profile_account_id' => $socialAccount->id]); + + return redirect()->route('app.social.google-business-profile.select-locations'); + } catch (NetworkAlreadyConnectedException) { + return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); + } catch (\Throwable $e) { + Log::error('Google Business Profile OAuth error', ['error' => $e->getMessage()]); + + return $this->popupCallback(false, __('accounts.popup_callback.error_connecting_google_business_profile'), $this->platform->value); + } + } + + public function selectLocations(Request $request): InertiaResponse + { + $account = $this->accountFromSession($request); + if (! $account) { + return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + } + + return Inertia::render('accounts/GoogleBusinessProfileLocationSelect', [ + 'locations' => $account->googleBusinessProfileLocations() + ->orderBy('title') + ->get(['id', 'title', 'store_code', 'storefront_address', 'maps_uri', 'is_selected']), + ]); + } + + public function select(Request $request, ManageGoogleBusinessProfileLocationTargets $targets): InertiaResponse + { + $account = $this->accountFromSession($request); + if (! $account) { + return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + } + + $validated = $request->validate([ + 'location_ids' => ['required', 'array', 'min:1'], + 'location_ids.*' => [ + 'uuid', + Rule::exists('google_business_profile_locations', 'id')->where('social_account_id', $account->id), + ], + ]); + + DB::transaction(function () use ($account, $validated, $targets): void { + $deselectedLocationIds = $account->googleBusinessProfileLocations() + ->whereNotIn('id', $validated['location_ids']) + ->pluck('id'); + $newlySelectedLocationIds = $account->googleBusinessProfileLocations() + ->whereIn('id', $validated['location_ids']) + ->where('is_selected', false) + ->pluck('id'); + + $targets->disable($deselectedLocationIds); + + $account->googleBusinessProfileLocations()->update(['is_selected' => false]); + $account->googleBusinessProfileLocations() + ->whereIn('id', $validated['location_ids']) + ->update(['is_selected' => true]); + + $targets->restore($newlySelectedLocationIds); + }); + + session()->forget(['google_business_profile_account_id', 'social_connect_workspace', 'social_reconnect_id']); + + return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + } + + public function disconnectLocation( + Request $request, + GoogleBusinessProfileLocation $location, + ManageGoogleBusinessProfileLocationTargets $targets, + ): RedirectResponse { + $workspace = $request->user()->currentWorkspace; + $this->authorize('manageAccounts', $workspace); + + $location->load('socialAccount'); + + if ($location->socialAccount?->workspace_id !== $workspace->id + || $location->socialAccount?->platform !== $this->platform) { + abort(404); + } + + DB::transaction(function () use ($location, $targets): void { + $targets->disable([$location->id]); + $location->update(['is_selected' => false]); + + $account = $location->socialAccount; + if ($account && ! $account->googleBusinessProfileLocations()->where('is_selected', true)->exists()) { + $account->update([ + 'access_token' => '', + 'refresh_token' => null, + 'token_expires_at' => null, + 'status' => Status::Disconnected, + 'disconnected_at' => now(), + ]); + } + }); + + session()->flash('flash.banner', __('accounts.flash.google_business_profile_location_disconnected', [ + 'location' => $location->title, + ])); + session()->flash('flash.bannerStyle', 'success'); + + return back(); + } + + private function provider(): GoogleProvider + { + /** @var GoogleProvider $provider */ + $provider = Socialite::buildProvider(GoogleProvider::class, config('services.google-business-profile')); + + return $provider; + } + + private function workspaceFromSession(Request $request): ?Workspace + { + $workspace = Workspace::find(session('social_connect_workspace')); + + return $workspace && $request->user()->can('manageAccounts', $workspace) ? $workspace : null; + } + + private function accountFromSession(Request $request): ?SocialAccount + { + $workspace = $this->workspaceFromSession($request); + if (! $workspace) { + return null; + } + + return $workspace->socialAccounts() + ->where('platform', $this->platform) + ->find(session('google_business_profile_account_id')); + } +} diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index a082b0c9e..6effaaa7f 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -64,6 +64,13 @@ public function disconnect(Request $request, SocialAccount $account): RedirectRe abort(403); } + if ($account->platform === SocialPlatform::GoogleBusinessProfile) { + session()->flash('flash.banner', __('accounts.flash.disconnect_google_business_profile_locations_individually')); + session()->flash('flash.bannerStyle', 'danger'); + + return back(); + } + // Drop pending platform rows from drafts/scheduled posts so the account // disappears cleanly from their UI. Published/failed rows survive via the // FK's nullOnDelete cascade and keep their snapshot fields for history. @@ -291,6 +298,7 @@ protected function popupCallback(bool $success, string $message, ?string $platfo 'success' => $success, 'message' => $message, 'platform' => $platform, + 'fallbackUrl' => route('app.accounts'), 'onboardingProgress' => false, ]); } diff --git a/app/Http/Requests/Api/Post/StorePostRequest.php b/app/Http/Requests/Api/Post/StorePostRequest.php index 8f19cb3ba..ff0613eea 100644 --- a/app/Http/Requests/Api/Post/StorePostRequest.php +++ b/app/Http/Requests/Api/Post/StorePostRequest.php @@ -14,6 +14,7 @@ use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class StorePostRequest extends FormRequest { @@ -45,6 +46,12 @@ public function rules(): array ->where('workspace_id', $workspaceId) ->where('is_active', true), ], + 'platforms.*.google_business_profile_location_id' => [ + 'sometimes', + 'nullable', + 'uuid', + Rule::exists('google_business_profile_locations', 'id'), + ], 'platforms.*.content_type' => [ 'required', 'string', @@ -77,6 +84,51 @@ public function attributes(): array return PostPlatformMetaRules::attributes(); } + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $platforms = (array) $this->input('platforms', []); + $accounts = SocialAccount::query() + ->where('workspace_id', $this->user()->currentWorkspace->id) + ->whereIn('id', collect($platforms)->pluck('social_account_id')->filter()) + ->get() + ->keyBy('id'); + + foreach ($platforms as $index => $platform) { + $account = $accounts->get(data_get($platform, 'social_account_id')); + if ($account?->platform !== Platform::GoogleBusinessProfile) { + if (filled(data_get($platform, 'google_business_profile_location_id'))) { + $validator->errors()->add( + "platforms.{$index}.google_business_profile_location_id", + 'A Google Business Profile location can only be used with a Google Business Profile connection.', + ); + } + + continue; + } + + $locationId = data_get($platform, 'google_business_profile_location_id'); + $valid = filled($locationId) && $account->googleBusinessProfileLocations() + ->whereKey($locationId) + ->where('is_selected', true) + ->exists(); + + if (! $valid) { + $validator->errors()->add( + "platforms.{$index}.google_business_profile_location_id", + 'Choose a selected Google Business Profile location managed by this connection.', + ); + } + } + + PostPlatformMetaRules::addGoogleBusinessProfileErrors( + $validator, + $platforms, + fn ($platform) => ContentType::tryFrom((string) data_get($platform, 'content_type')), + ); + }); + } + /** * @return Collection */ diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 2796b80f4..a96e78fe3 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -83,7 +83,28 @@ public function attributes(): array public function withValidator(Validator $validator): void { $validator->after(function (Validator $validator): void { - if (! in_array($this->input('status'), [Status::Scheduled->value, Status::Publishing->value], true)) { + $submittedPlatforms = (array) $this->input('platforms', []); + $isPublishing = in_array($this->input('status'), [Status::Scheduled->value, Status::Publishing->value], true); + + if ($this->has('platforms')) { + foreach (PostPlatformMetaRules::googleBusinessProfileLocationErrorsForUpdate($this->route('post'), $submittedPlatforms) as $field => $message) { + $validator->errors()->add($field, $message); + } + } + + if ($this->has('platforms') || $isPublishing) { + $effectivePayloads = PostPlatformMetaRules::effectivePayloadsForUpdate( + $this->route('post'), + $this->has('platforms') ? $submittedPlatforms : null, + ); + PostPlatformMetaRules::addGoogleBusinessProfileErrors( + $validator, + $effectivePayloads, + fn ($platform) => ContentType::tryFrom((string) data_get($platform, 'content_type')), + ); + } + + if (! $isPublishing) { return; } diff --git a/app/Http/Requests/App/Ai/StartPostCreationRequest.php b/app/Http/Requests/App/Ai/StartPostCreationRequest.php index 10b33527c..4c12680fb 100644 --- a/app/Http/Requests/App/Ai/StartPostCreationRequest.php +++ b/app/Http/Requests/App/Ai/StartPostCreationRequest.php @@ -6,6 +6,7 @@ use App\Enums\Ai\ContentStyle; use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; use App\Support\AiPromptRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -34,6 +35,7 @@ public function rules(): array Rule::in($allowedFormats), ], 'social_account_id' => ['nullable', 'uuid'], + 'google_business_profile_location_id' => ['nullable', 'uuid'], 'image_count' => ['nullable', 'integer', 'min:0', 'max:10'], 'prompt' => AiPromptRules::wizardPromptRule(), 'date' => ['nullable', 'date_format:Y-m-d'], @@ -51,6 +53,64 @@ public function withValidator(Validator $validator): void if ($style->needsAccount() && blank($this->input('social_account_id'))) { $validator->errors()->add('social_account_id', trans('validation.required', ['attribute' => 'social account'])); } + + $isGoogleBusinessProfile = $this->input('format') === ContentType::GoogleBusinessProfileStandard->value; + $socialAccountId = $this->input('social_account_id'); + $locationId = $this->input('google_business_profile_location_id'); + + if ($validator->errors()->hasAny([ + 'format', + 'social_account_id', + 'google_business_profile_location_id', + ])) { + return; + } + + if (! $isGoogleBusinessProfile) { + if (filled($locationId)) { + $validator->errors()->add( + 'google_business_profile_location_id', + 'A Google Business Profile location can only be used with a Google Business Profile format.', + ); + } + + return; + } + + if (blank($socialAccountId)) { + $validator->errors()->add( + 'social_account_id', + trans('validation.required', ['attribute' => 'social account']), + ); + } + + if (blank($locationId)) { + $validator->errors()->add( + 'google_business_profile_location_id', + 'Choose a Google Business Profile location.', + ); + + return; + } + + $account = $this->user()?->currentWorkspace + ?->socialAccounts() + ?->active() + ?->whereKey($socialAccountId) + ?->first(); + + $validLocation = $account?->platform === Platform::GoogleBusinessProfile + && $account->googleBusinessProfileLocations() + ->whereKey($locationId) + ->where('is_selected', true) + ->exists(); + + if (! $validLocation) { + $validator->errors()->add( + 'google_business_profile_location_id', + 'Choose a selected Google Business Profile location managed by this connection.', + ); + } }); } } diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index 83647284d..650b410ad 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -80,6 +80,25 @@ public function attributes(): array public function withValidator(Validator $validator): void { $validator->after(function (Validator $validator): void { + $submittedPlatforms = (array) $this->input('platforms', []); + if ($this->has('platforms')) { + foreach (PostPlatformMetaRules::googleBusinessProfileLocationErrorsForUpdate($this->route('post'), $submittedPlatforms) as $field => $message) { + $validator->errors()->add($field, $message); + } + } + + if ($this->has('platforms') || $this->isPublishingOrScheduling()) { + $effectivePayloads = PostPlatformMetaRules::effectivePayloadsForUpdate( + $this->route('post'), + $this->has('platforms') ? $submittedPlatforms : null, + ); + PostPlatformMetaRules::addGoogleBusinessProfileErrors( + $validator, + $effectivePayloads, + fn ($platform) => ContentType::tryFrom((string) data_get($platform, 'content_type')), + ); + } + if (! $this->isPublishingOrScheduling()) { return; } diff --git a/app/Http/Resources/Api/PostPlatformResource.php b/app/Http/Resources/Api/PostPlatformResource.php index 3995c87c6..f98d1b221 100644 --- a/app/Http/Resources/Api/PostPlatformResource.php +++ b/app/Http/Resources/Api/PostPlatformResource.php @@ -23,7 +23,11 @@ public function toArray(Request $request): array 'enabled' => $this->enabled, 'platform_url' => $this->platform_url, 'published_at' => $this->published_at?->format('Y-m-d H:i:s'), + 'submitted_at' => $this->submitted_at?->format('Y-m-d H:i:s'), + 'last_reconciled_at' => $this->last_reconciled_at?->format('Y-m-d H:i:s'), + 'google_business_profile_location_id' => $this->google_business_profile_location_id, 'error_message' => $this->error_message, + 'connection_issue_code' => $this->connection_issue_code, // Display fields fall back to snapshots (platform_name/username/avatar) // so deleted accounts still render correctly in the post history. 'display_name' => $this->display_name, diff --git a/app/Http/Resources/Api/SocialAccountResource.php b/app/Http/Resources/Api/SocialAccountResource.php index 5fd2880fc..82ed432cb 100644 --- a/app/Http/Resources/Api/SocialAccountResource.php +++ b/app/Http/Resources/Api/SocialAccountResource.php @@ -4,6 +4,7 @@ namespace App\Http\Resources\Api; +use App\Enums\SocialAccount\Platform; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -21,6 +22,13 @@ public function toArray(Request $request): array 'username' => $this->username, 'is_active' => $this->is_active, 'status' => $this->status?->value, + 'google_business_profile_locations' => $this->when( + $this->platform === Platform::GoogleBusinessProfile, + fn () => $this->googleBusinessProfileLocations() + ->where('is_selected', true) + ->orderBy('title') + ->get(['id', 'title', 'store_code', 'maps_uri', 'is_verified']), + ), ]; } } diff --git a/app/Http/Resources/App/SocialAccountResource.php b/app/Http/Resources/App/SocialAccountResource.php index ee476b448..2605220ca 100644 --- a/app/Http/Resources/App/SocialAccountResource.php +++ b/app/Http/Resources/App/SocialAccountResource.php @@ -4,6 +4,7 @@ namespace App\Http\Resources\App; +use App\Enums\SocialAccount\Platform; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -30,6 +31,13 @@ public function toArray(Request $request): array 'error_message' => $this->error_message, 'last_used_at' => $this->last_used_at, 'created_at' => $this->created_at, + 'google_business_profile_locations' => $this->when( + $this->platform === Platform::GoogleBusinessProfile, + fn () => $this->googleBusinessProfileLocations() + ->where('is_selected', true) + ->orderBy('title') + ->get(['id', 'title', 'store_code', 'storefront_address', 'maps_uri', 'is_verified']), + ), ]; } } diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index eeeba767b..8a5d414df 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -16,6 +16,7 @@ use App\Enums\Notification\Type as NotificationType; use App\Enums\Post\CreatedVia; use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; use App\Events\Ai\PostCreationReady; use App\Jobs\SendNotification; use App\Models\Post; @@ -48,6 +49,7 @@ public function __construct( public ?string $date = null, public string $template = 'image_card', public bool $applyBrandVisuals = true, + public ?string $googleBusinessProfileLocationId = null, ) { $this->onQueue('ai'); } @@ -200,31 +202,35 @@ private function createPostFromGenerated(Workspace $workspace, GeneratedPost $ge { $user = User::findOrFail($this->userId); - $post = CreatePost::execute($workspace, $user, [ + $platforms = []; + + if ($generated->contentType && $socialAccount) { + $contentType = $socialAccount->platform === Platform::GoogleBusinessProfile + ? ContentType::GoogleBusinessProfileStandard + : $generated->contentType; + $aspectRatio = $this->aspectRatioFor($contentType); + $platform = [ + 'social_account_id' => $socialAccount->id, + 'content_type' => $contentType->value, + 'meta' => array_filter([ + 'aspect_ratio' => $aspectRatio, + ], fn (mixed $value): bool => $value !== null), + ]; + + if ($socialAccount->platform === Platform::GoogleBusinessProfile) { + $platform['google_business_profile_location_id'] = $this->googleBusinessProfileLocationId; + } + + $platforms[] = $platform; + } + + return CreatePost::execute($workspace, $user, [ 'content' => $generated->content, 'media' => $generated->media, 'date' => $this->date, 'created_via' => CreatedVia::Web, + 'platforms' => $platforms, ]); - - if ($generated->contentType && $socialAccount) { - $aspectRatio = $this->aspectRatioFor($generated->contentType); - - $post->postPlatforms() - ->where('social_account_id', $socialAccount->id) - ->each(function ($platform) use ($aspectRatio, $generated): void { - $meta = $platform->meta ?? []; - if ($aspectRatio !== null) { - $meta['aspect_ratio'] = $aspectRatio; - } - $platform->meta = $meta; - $platform->content_type = $generated->contentType->value; - $platform->enabled = true; - $platform->save(); - }); - } - - return $post; } private function notifyReady(Workspace $workspace, Post $post): void diff --git a/app/Jobs/PublishPost.php b/app/Jobs/PublishPost.php index d2ad9a186..377e0f31b 100644 --- a/app/Jobs/PublishPost.php +++ b/app/Jobs/PublishPost.php @@ -4,24 +4,59 @@ namespace App\Jobs; +use App\Enums\Post\Status as PostStatus; +use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Models\Post; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; -class PublishPost implements ShouldQueue +class PublishPost implements ShouldBeUnique, ShouldQueue { use Queueable; public int $tries = 1; + public int $uniqueFor = 900; + public function __construct(public Post $post) {} + public function uniqueId(): string + { + return $this->post->id; + } + public function handle(): void { - $this->post->markAsPublishing(); + $targets = DB::transaction(function () { + $post = Post::query()->lockForUpdate()->findOrFail($this->post->id); + if (! in_array($post->status, [PostStatus::Scheduled, PostStatus::Publishing], true)) { + return collect(); + } + + $enabledTargets = $post->postPlatforms()->enabled()->lockForUpdate()->get(); + if ($enabledTargets->isEmpty()) { + $post->markAsFailed(); + Log::warning('PublishPost stopped because the post has no enabled targets', ['post_id' => $post->id]); + } + + $targets = $enabledTargets->where('status', PostPlatformStatus::Pending); + if ($targets->isNotEmpty()) { + $post->markAsPublishing(); + } + + $this->post = $post; + + return $targets; + }); + + if ($targets->isEmpty()) { + return; + } - foreach ($this->post->postPlatforms()->enabled()->get() as $postPlatform) { + foreach ($targets as $postPlatform) { PublishToSocialPlatform::dispatch($postPlatform); } } diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 106a9f92e..7a8db2b12 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -4,8 +4,6 @@ namespace App\Jobs; -use App\Enums\Notification\Channel; -use App\Enums\Notification\Type; use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; @@ -14,14 +12,13 @@ use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\SocialPublishException; use App\Exceptions\TokenExpiredException; -use App\Mail\PostPublished; -use App\Mail\PostPublishFailed; -use App\Models\Post; use App\Models\PostPlatform; +use App\Services\Post\PostPublicationFinalizer; use App\Services\Social\BlueskyPublisher; use App\Services\Social\ConnectionVerifier; use App\Services\Social\Discord\DiscordPublisher; use App\Services\Social\FacebookPublisher; +use App\Services\Social\GoogleBusinessProfile\GoogleBusinessProfilePublisher; use App\Services\Social\InstagramPublisher; use App\Services\Social\LinkedInPagePublisher; use App\Services\Social\LinkedInPublisher; @@ -32,6 +29,8 @@ use App\Services\Social\TikTokPublisher; use App\Services\Social\XPublisher; use App\Services\Social\YouTubePublisher; +use App\Support\Social\GoogleBusinessProfileMediaDerivativeCleaner; +use App\Support\Social\PublishCheckpoint; use App\Support\Social\TikTokPhotoDerivativeCleaner; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; @@ -90,6 +89,14 @@ public function handle(): void return; } + if (in_array($this->postPlatform->status, [PostPlatformStatus::Submitted, PostPlatformStatus::PendingReview], true)) { + return; + } + + if (! $this->postPlatform->enabled) { + return; + } + if (! $this->postPlatform->socialAccount->is_active) { $this->failAndFinalize(__('posts.errors.account_inactive')); @@ -124,7 +131,11 @@ public function handle(): void try { $publisher = $this->getPublisher(); $result = $publisher->publish($this->postPlatform); - $this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url')); + if ($this->postPlatform->platform === SocialPlatform::GoogleBusinessProfile) { + $this->recordGoogleBusinessProfileSubmission($result); + } else { + $this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url')); + } break; } catch (PlatformUnavailableException $e) { $this->rescheduleForRetry($e); @@ -254,9 +265,15 @@ private function rescheduleForRetry(PlatformUnavailableException $e): void ...$context, ]); + $terminalContext = [ + ...$context, + 'retries_exhausted' => true, + 'failed_at' => now()->toIso8601String(), + ]; + $this->markPlatformAsFailed( __('posts.errors.platform_unavailable_exhausted'), - [...$context, 'failed_at' => now()->toIso8601String()], + $terminalContext, ); return; @@ -267,8 +284,8 @@ private function rescheduleForRetry(PlatformUnavailableException $e): void Log::warning('Publish rescheduled: platform unavailable', [ 'post_platform_id' => $this->postPlatform->id, 'platform' => $this->postPlatform->platform->value, - 'next_attempt_at' => $nextAttemptAt->toIso8601String(), ...$context, + 'next_attempt_at' => $nextAttemptAt->toIso8601String(), ]); $this->postPlatform->update([ @@ -300,6 +317,16 @@ private function markPlatformAsFailed(string $message, ?array $context = null): $failureContext = [...$previousContext, ...($context ?? [])]; + if ($this->postPlatform->platform === SocialPlatform::GoogleBusinessProfile + && (ErrorCategory::tryFromContext($failureContext)?->isResumable() !== true + || data_get($failureContext, 'retries_exhausted') === true)) { + app(GoogleBusinessProfileMediaDerivativeCleaner::class)->cleanup( + $failureContext, + $this->postPlatform->id, + ); + unset($failureContext[PublishCheckpoint::GOOGLE_BUSINESS_PROFILE_DERIVATIVE_PATH]); + } + $this->postPlatform->markAsFailed($message, $failureContext === [] ? null : $failureContext); } @@ -318,6 +345,7 @@ private function isTerminal(): bool return in_array($this->postPlatform->status, [ PostPlatformStatus::Published, PostPlatformStatus::Failed, + PostPlatformStatus::Rejected, ], true); } @@ -337,7 +365,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|GoogleBusinessProfilePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher { return match ($this->postPlatform->platform) { SocialPlatform::LinkedIn => app(LinkedInPublisher::class), @@ -345,6 +373,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis SocialPlatform::X => app(XPublisher::class), SocialPlatform::TikTok => app(TikTokPublisher::class), SocialPlatform::YouTube => app(YouTubePublisher::class), + SocialPlatform::GoogleBusinessProfile => app(GoogleBusinessProfilePublisher::class), SocialPlatform::Facebook => app(FacebookPublisher::class), SocialPlatform::Instagram, SocialPlatform::InstagramFacebook => app(InstagramPublisher::class), SocialPlatform::Threads => app(ThreadsPublisher::class), @@ -358,33 +387,46 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis private function updatePostStatus(): void { - $post = $this->postPlatform->post->fresh(); - $enabledPlatforms = $post->postPlatforms->where('enabled', true); + app(PostPublicationFinalizer::class)->finalize($this->postPlatform); + } + + /** @param array $result */ + private function recordGoogleBusinessProfileSubmission(array $result): void + { + $state = (string) data_get($result, 'provider_state', 'PROCESSING'); + $derivativePath = data_get($result, 'derivative_path'); + $cleaner = app(GoogleBusinessProfileMediaDerivativeCleaner::class); - $total = $enabledPlatforms->count(); - $publishedCount = $enabledPlatforms->where('status', PostPlatformStatus::Published)->count(); - $failedCount = $enabledPlatforms->where('status', PostPlatformStatus::Failed)->count(); - $finishedCount = $publishedCount + $failedCount; + if (in_array($state, ['LIVE', 'RECURRING'], true)) { + $this->postPlatform->markAsPublished((string) data_get($result, 'id'), data_get($result, 'url')); + $cleaner->cleanupPath(is_string($derivativePath) ? $derivativePath : null, $this->postPlatform->id); - // Only update post status when all platforms have finished - if ($finishedCount < $total) { return; } - if ($publishedCount === $total) { - $post->markAsPublished(); - $this->notify($post, PostPlatformStatus::Published); + if ($state === 'REJECTED') { + $this->postPlatform->markAsRejected('Google rejected this post during review.', [ + 'provider_state' => $state, + 'reconciled_at' => now()->toIso8601String(), + ]); + $cleaner->cleanupPath(is_string($derivativePath) ? $derivativePath : null, $this->postPlatform->id); return; } - if ($publishedCount > 0) { - $post->markAsPartiallyPublished(); - } else { - $post->markAsFailed(); - } - - $this->notify($post, PostPlatformStatus::Failed); + $status = $state === 'PROCESSING' + ? PostPlatformStatus::PendingReview + : PostPlatformStatus::Submitted; + + $this->postPlatform->markAsSubmitted( + (string) data_get($result, 'id'), + data_get($result, 'url'), + $status, + array_filter([ + 'provider_state' => $state, + PublishCheckpoint::GOOGLE_BUSINESS_PROFILE_DERIVATIVE_PATH => is_string($derivativePath) ? $derivativePath : null, + ], fn (mixed $value): bool => $value !== null && $value !== ''), + ); } public function failed(?Throwable $exception): void @@ -411,33 +453,4 @@ public function failed(?Throwable $exception): void $this->updatePostStatus(); $this->broadcastStatus(); } - - private function notify(Post $post, PostPlatformStatus $status): void - { - $owner = $post->workspace->owner; - - if (! $owner) { - return; - } - - $successful = $status === PostPlatformStatus::Published; - $platforms = $post->postPlatforms() - ->with('socialAccount') - ->enabled() - ->where('status', $status) - ->get() - ->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')') - ->implode(', '); - - SendNotification::dispatch( - user: $owner, - workspaceId: $post->workspace_id, - type: $successful ? Type::PostPublished : Type::PostFailed, - channel: Channel::Both, - title: $successful ? 'Post published successfully' : 'Post failed to publish', - body: $successful ? $platforms : "Failed on: {$platforms}", - data: ['post_id' => $post->id], - mailable: $successful ? new PostPublished($post) : new PostPublishFailed($post), - ); - } } diff --git a/app/Jobs/ReconcileGoogleBusinessProfilePost.php b/app/Jobs/ReconcileGoogleBusinessProfilePost.php new file mode 100644 index 000000000..eeba28614 --- /dev/null +++ b/app/Jobs/ReconcileGoogleBusinessProfilePost.php @@ -0,0 +1,135 @@ + */ + public array $backoff = [60, 300, 900, 1800]; + + public function __construct(public PostPlatform $postPlatform) + { + $this->onQueue($postPlatform->platform->queue()); + } + + public function uniqueId(): string + { + return $this->postPlatform->id; + } + + public function handle(GoogleBusinessProfileApi $api, ConnectionVerifier $verifier): void + { + $this->postPlatform->refresh()->load(['socialAccount', 'post.postPlatforms']); + + if (! in_array($this->postPlatform->status, [Status::Submitted, Status::PendingReview], true) + || blank($this->postPlatform->platform_post_id)) { + return; + } + + $account = $this->postPlatform->socialAccount; + $derivativePath = PublishCheckpoint::googleBusinessProfileDerivativePath($this->postPlatform->error_context); + if ($account->needsProactiveTokenRefresh()) { + $verifier->refreshToken($account); + } + + $remote = $api->localPost($account, $this->postPlatform->platform_post_id); + $state = (string) data_get($remote, 'state', 'PROCESSING'); + + if (in_array($state, ['LIVE', 'RECURRING'], true)) { + $this->postPlatform->markAsPublished( + $this->postPlatform->platform_post_id, + data_get($remote, 'searchUrl', $this->postPlatform->platform_url), + ); + app(GoogleBusinessProfileMediaDerivativeCleaner::class)->cleanupPath($derivativePath, $this->postPlatform->id); + } elseif ($state === 'REJECTED') { + $this->postPlatform->markAsRejected( + 'Google rejected this post during review.', + ['provider_state' => $state, 'remote' => $this->safeRemoteContext($remote)], + ); + app(GoogleBusinessProfileMediaDerivativeCleaner::class)->cleanupPath($derivativePath, $this->postPlatform->id); + } else { + $this->postPlatform->update([ + 'status' => $state === 'PROCESSING' ? Status::PendingReview : Status::Submitted, + 'platform_url' => data_get($remote, 'searchUrl', $this->postPlatform->platform_url), + 'last_reconciled_at' => now(), + 'error_context' => array_filter([ + 'provider_state' => $state, + PublishCheckpoint::GOOGLE_BUSINESS_PROFILE_DERIVATIVE_PATH => $derivativePath, + ], fn (mixed $value): bool => $value !== null && $value !== ''), + ]); + } + + if (in_array($state, ['LIVE', 'RECURRING'], true)) { + $this->postPlatform->update(['last_reconciled_at' => now()]); + } + + app(PostPublicationFinalizer::class)->finalize($this->postPlatform); + PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); + } + + public function failed(?Throwable $exception): void + { + $this->postPlatform->refresh(); + + if (! in_array($this->postPlatform->status, [Status::Submitted, Status::PendingReview], true)) { + return; + } + + $derivativePath = PublishCheckpoint::googleBusinessProfileDerivativePath($this->postPlatform->error_context); + + $this->postPlatform->update([ + 'status' => Status::Failed, + 'error_message' => 'Google Business Profile post status could not be confirmed after several attempts.', + 'error_context' => [ + 'category' => 'reconciliation_failed', + 'failed_at' => now()->toIso8601String(), + ], + 'last_reconciled_at' => now(), + ]); + + app(GoogleBusinessProfileMediaDerivativeCleaner::class)->cleanupPath($derivativePath, $this->postPlatform->id); + + app(PostPublicationFinalizer::class)->finalize($this->postPlatform); + PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); + + if ($exception) { + report($exception); + } + } + + /** @param array $remote + * @return array + */ + private function safeRemoteContext(array $remote): array + { + return array_filter([ + 'name' => data_get($remote, 'name'), + 'state' => data_get($remote, 'state'), + 'topic_type' => data_get($remote, 'topicType'), + 'update_time' => data_get($remote, 'updateTime'), + ]); + } +} diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index a90e40305..5b3990d28 100644 --- a/app/Mcp/Tools/Post/CreatePostTool.php +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -7,13 +7,16 @@ use App\Actions\Post\CreatePost; use App\Enums\Post\CreatedVia; use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; use App\Http\Resources\Api\PostResource; use App\Mcp\Concerns\AuthorizesMcpTool; +use App\Models\SocialAccount; use App\Models\Workspace; use App\Rules\ContentTypeMatchesPlatform; use App\Support\PostPlatformMetaRules; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -51,6 +54,7 @@ public function handle(Request $request): Response|ResponseFactory ->where('workspace_id', $workspace->id) ->where('is_active', true), ], + 'platforms.*.google_business_profile_location_id' => ['sometimes', 'nullable', 'uuid', Rule::exists('google_business_profile_locations', 'id')], 'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPlatform], ...PostPlatformMetaRules::rules(), ], @@ -60,6 +64,36 @@ public function handle(Request $request): Response|ResponseFactory $validated['created_via'] = CreatedVia::Mcp; + $accounts = SocialAccount::query() + ->where('workspace_id', $workspace->id) + ->whereIn('id', collect($validated['platforms'] ?? [])->pluck('social_account_id')->filter()) + ->get() + ->keyBy('id'); + $locationErrors = []; + foreach (($validated['platforms'] ?? []) as $index => $platform) { + $account = $accounts->get(data_get($platform, 'social_account_id')); + if ($account?->platform !== Platform::GoogleBusinessProfile) { + if (filled(data_get($platform, 'google_business_profile_location_id'))) { + $locationErrors["platforms.{$index}.google_business_profile_location_id"] = 'A Google Business Profile location can only be used with a Google Business Profile connection.'; + } + + continue; + } + + $locationId = data_get($platform, 'google_business_profile_location_id'); + if (blank($locationId) || ! $account->googleBusinessProfileLocations()->whereKey($locationId)->where('is_selected', true)->exists()) { + $locationErrors["platforms.{$index}.google_business_profile_location_id"] = 'Choose a selected Google Business Profile location managed by this connection.'; + } + } + if ($locationErrors !== []) { + throw ValidationException::withMessages($locationErrors); + } + + PostPlatformMetaRules::assertGoogleBusinessProfilePayloads( + $validated['platforms'] ?? [], + fn ($platform) => ContentType::tryFrom((string) data_get($platform, 'content_type')), + ); + $post = CreatePost::execute($workspace, $request->user(), $validated); $post->load(['postPlatforms.socialAccount', 'labels']); @@ -78,8 +112,9 @@ public function schema(JsonSchema $schema): array 'platforms' => $schema->array() ->items($schema->object(fn ($p) => [ 'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'), + 'google_business_profile_location_id' => $p->string()->description('Required for Google Business Profile: UUID of the selected managed location returned by the social-account listing.'), 'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'), - 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), + 'meta' => $p->object()->description('Per-platform metadata. Google Business Profile: CTA fields (cta_action_type, cta_url); Event/Offer require event_title, event_start_at, event_end_at; Offer supports offer_coupon_code, offer_redeem_url, offer_terms; Alert requires alert_type=COVID_19; recurrence supports daily, weekly, or monthly fields. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), ])) ->description('Platforms to publish on. Accounts not listed remain available but disabled.'), ]; diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index 3f0ae0e62..4867f83e2 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -77,6 +77,24 @@ public function handle(Request $request): Response|ResponseFactory // here, or stored) against the post's stored media — the tool can't change // media, so a misconfigured post can't be scheduled even without resubmitting // content_type. Mirrors the public API's withValidator check. + $effectivePayloads = PostPlatformMetaRules::effectivePayloadsForUpdate( + $post, + array_key_exists('platforms', $validated) ? $validated['platforms'] : null, + ); + if (array_key_exists('platforms', $validated)) { + $locationErrors = PostPlatformMetaRules::googleBusinessProfileLocationErrorsForUpdate($post, $validated['platforms']); + if ($locationErrors !== []) { + throw ValidationException::withMessages($locationErrors); + } + } + + if (array_key_exists('platforms', $validated) || $status === Status::Scheduled->value) { + PostPlatformMetaRules::assertGoogleBusinessProfilePayloads( + $effectivePayloads, + fn ($platform) => ContentType::tryFrom((string) data_get($platform, 'content_type')), + ); + } + if ($status === Status::Scheduled->value) { $errors = ContentTypeCompatibleWithMedia::errorsFor( ContentTypeCompatibleWithMedia::entriesForUpdate($post, data_get($validated, 'platforms')), diff --git a/app/Models/GoogleBusinessProfileLocation.php b/app/Models/GoogleBusinessProfileLocation.php new file mode 100644 index 000000000..be23cbd67 --- /dev/null +++ b/app/Models/GoogleBusinessProfileLocation.php @@ -0,0 +1,56 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'social_account_id', + 'google_account_name', + 'google_location_name', + 'title', + 'store_code', + 'timezone', + 'maps_uri', + 'website_uri', + 'phone_number', + 'storefront_address', + 'metadata', + 'is_selected', + 'is_verified', + 'last_synced_at', + ]; + + protected function casts(): array + { + return [ + 'storefront_address' => 'array', + 'metadata' => 'array', + 'is_selected' => 'boolean', + 'is_verified' => 'boolean', + 'last_synced_at' => 'datetime', + ]; + } + + public function socialAccount(): BelongsTo + { + return $this->belongsTo(SocialAccount::class); + } + + public function postPlatforms(): HasMany + { + return $this->hasMany(PostPlatform::class); + } +} diff --git a/app/Models/PostPlatform.php b/app/Models/PostPlatform.php index 35f0f0dda..c9556313b 100644 --- a/app/Models/PostPlatform.php +++ b/app/Models/PostPlatform.php @@ -20,9 +20,17 @@ class PostPlatform extends Model /** @use HasFactory */ use HasFactory, HasUuids; + protected $appends = [ + 'display_name', + 'display_username', + 'display_avatar', + 'connection_issue_code', + ]; + protected $fillable = [ 'post_id', 'social_account_id', + 'google_business_profile_location_id', 'enabled', 'platform', 'platform_name', @@ -35,6 +43,8 @@ class PostPlatform extends Model 'error_message', 'error_context', 'published_at', + 'submitted_at', + 'last_reconciled_at', 'meta', 'connection_warning_sent_at', ]; @@ -47,6 +57,8 @@ protected function casts(): array 'content_type' => ContentType::class, 'status' => Status::class, 'published_at' => 'datetime', + 'submitted_at' => 'datetime', + 'last_reconciled_at' => 'datetime', 'meta' => 'array', 'error_context' => 'array', 'connection_warning_sent_at' => 'datetime', @@ -63,6 +75,11 @@ public function socialAccount(): BelongsTo return $this->belongsTo(SocialAccount::class); } + public function googleBusinessProfileLocation(): BelongsTo + { + return $this->belongsTo(GoogleBusinessProfileLocation::class); + } + /** * Only platforms still enabled for publishing — disabled ones are * excluded from PublishPost, so anything else that mirrors publish @@ -78,6 +95,12 @@ public function scopeEnabled(Builder $query): Builder */ public function getDisplayNameAttribute(): string { + if ($this->platform === SocialPlatform::GoogleBusinessProfile) { + return $this->platform_name + ?? $this->googleBusinessProfileLocation?->title + ?? $this->platform->label(); + } + return $this->socialAccount?->accountDisplayName() ?? $this->platform_name ?? $this->platform->label(); } @@ -86,6 +109,10 @@ public function getDisplayNameAttribute(): string */ public function getDisplayUsernameAttribute(): ?string { + if ($this->platform === SocialPlatform::GoogleBusinessProfile) { + return $this->platform_username ?? $this->googleBusinessProfileLocation?->store_code; + } + return $this->socialAccount?->username ?? $this->platform_username; } @@ -101,6 +128,36 @@ public function getDisplayAvatarAttribute(): ?string return $this->platform_avatar ? Storage::url($this->platform_avatar) : null; } + public function getConnectionIssueCodeAttribute(): ?string + { + if (in_array($this->status, [Status::Published, Status::Failed, Status::Rejected], true)) { + return null; + } + + if ($this->platform === SocialPlatform::GoogleBusinessProfile + && (! $this->googleBusinessProfileLocation || ! $this->googleBusinessProfileLocation->is_selected)) { + return 'gbp_location_disconnected'; + } + + if (! $this->socialAccount) { + return 'account_disconnected'; + } + + if (! $this->socialAccount->is_active) { + return 'account_inactive'; + } + + if ($this->socialAccount->status === \App\Enums\SocialAccount\Status::TokenExpired) { + return 'account_token_expired'; + } + + if ($this->socialAccount->status !== \App\Enums\SocialAccount\Status::Connected) { + return 'account_disconnected'; + } + + return null; + } + public function markAsPublishing(): void { $this->update(['status' => Status::Publishing]); @@ -122,6 +179,33 @@ public function markAsPublished(string $platformPostId, ?string $platformUrl = n $this->socialAccount?->update(['last_used_at' => $now]); } + /** @param array|null $errorContext */ + public function markAsSubmitted( + string $platformPostId, + ?string $platformUrl = null, + Status $status = Status::Submitted, + ?array $errorContext = null, + ): void { + $this->update([ + 'status' => $status, + 'platform_post_id' => $platformPostId, + 'platform_url' => $platformUrl, + 'submitted_at' => now(), + 'error_message' => null, + 'error_context' => $errorContext === [] ? null : $errorContext, + ]); + } + + public function markAsRejected(string $message, ?array $context = null): void + { + $this->update([ + 'status' => Status::Rejected, + 'error_message' => $message, + 'error_context' => $context, + 'last_reconciled_at' => now(), + ]); + } + public function markAsFailed(string $errorMessage, ?array $errorContext = null): void { $this->update([ diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 6059bb3ed..e90a1b23e 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -223,6 +223,11 @@ public function postPlatforms(): HasMany return $this->hasMany(PostPlatform::class); } + public function googleBusinessProfileLocations(): HasMany + { + return $this->hasMany(GoogleBusinessProfileLocation::class); + } + protected function isTokenExpired(): Attribute { return Attribute::make( diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 127191488..44e1e329b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -13,6 +13,7 @@ use App\Models\AutomationNodeState; use App\Models\AutomationRun; use App\Models\AutomationTriggerItem; +use App\Models\GoogleBusinessProfileLocation; use App\Models\Invite; use App\Models\Media; use App\Models\Notification; @@ -103,6 +104,7 @@ protected function configureMorphMap(): void 'automationNodeState' => AutomationNodeState::class, 'automationRun' => AutomationRun::class, 'automationTriggerItem' => AutomationTriggerItem::class, + 'googleBusinessProfileLocation' => GoogleBusinessProfileLocation::class, 'invite' => Invite::class, 'media' => Media::class, 'notification' => Notification::class, diff --git a/app/Rules/ContentTypeMatchesPlatform.php b/app/Rules/ContentTypeMatchesPlatform.php index fd0da853d..bb45b3ca5 100644 --- a/app/Rules/ContentTypeMatchesPlatform.php +++ b/app/Rules/ContentTypeMatchesPlatform.php @@ -51,6 +51,12 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } + if (! $contentType->isAuthorable()) { + $fail(sprintf('content_type "%s" is no longer available for new posts.', $contentType->value)); + + return; + } + if (! in_array($account->platform, $contentType->compatiblePlatforms(), true)) { $fail(sprintf( 'content_type "%s" is not compatible with the %s account.', diff --git a/app/Rules/ContentTypeMatchesPostPlatform.php b/app/Rules/ContentTypeMatchesPostPlatform.php index b1fe924af..9de032425 100644 --- a/app/Rules/ContentTypeMatchesPostPlatform.php +++ b/app/Rules/ContentTypeMatchesPostPlatform.php @@ -47,6 +47,12 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } + if (! $contentType->isAuthorable()) { + $fail(sprintf('content_type "%s" is no longer available for new posts.', $contentType->value)); + + return; + } + if (! in_array($postPlatform->socialAccount->platform, $contentType->compatiblePlatforms(), true)) { $fail(sprintf( 'content_type "%s" is not compatible with the %s account.', diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index 3370a4a0c..db0bc25b1 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -342,6 +342,12 @@ private function getImageConfig(Platform $platform): array 'format' => 'image/jpeg', 'quality' => 100, ], + Platform::GoogleBusinessProfile => [ + 'max_width' => 1200, + 'max_size' => 10 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 100, + ], Platform::Telegram => [ 'max_width' => 2048, 'max_size' => 10 * 1024 * 1024, diff --git a/app/Services/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php index b68e3305b..9b1aac7d5 100644 --- a/app/Services/Post/PostMetricsFetcher.php +++ b/app/Services/Post/PostMetricsFetcher.php @@ -10,6 +10,7 @@ use App\Services\Social\BlueskyAnalytics; use App\Services\Social\Discord\DiscordAnalytics; use App\Services\Social\FacebookAnalytics; +use App\Services\Social\GoogleBusinessProfile\GoogleBusinessProfileAnalytics; use App\Services\Social\InstagramAnalytics; use App\Services\Social\LinkedInPageAnalytics; use App\Services\Social\MastodonAnalytics; @@ -73,6 +74,7 @@ public function forPlatform(PostPlatform $postPlatform): array Platform::Threads => app(ThreadsAnalytics::class)->fetchPostMetrics($postPlatform), Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->fetchPostMetrics($postPlatform), Platform::YouTube => app(YouTubeAnalytics::class)->fetchPostMetrics($postPlatform), + Platform::GoogleBusinessProfile => app(GoogleBusinessProfileAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Pinterest => app(PinterestAnalytics::class)->fetchPostMetrics($postPlatform), default => ['unsupported' => true, 'reason' => 'platform_not_supported'], }); diff --git a/app/Services/Post/PostPublicationFinalizer.php b/app/Services/Post/PostPublicationFinalizer.php new file mode 100644 index 000000000..85b6e93fd --- /dev/null +++ b/app/Services/Post/PostPublicationFinalizer.php @@ -0,0 +1,100 @@ +lockForUpdate()->findOrFail($postPlatform->post_id); + $enabled = $post->postPlatforms()->enabled()->get(); + $total = $enabled->count(); + + if ($total === 0) { + return null; + } + + $published = $enabled->where('status', PostPlatformStatus::Published)->count(); + $failed = $enabled->whereIn('status', [PostPlatformStatus::Failed, PostPlatformStatus::Rejected])->count(); + + if (($published + $failed) < $total) { + return null; + } + + $targetStatus = match (true) { + $published === $total => PostStatus::Published, + $published > 0 => PostStatus::PartiallyPublished, + default => PostStatus::Failed, + }; + + if ($post->status === $targetStatus) { + return null; + } + + match ($targetStatus) { + PostStatus::Published => $post->markAsPublished(), + PostStatus::PartiallyPublished => $post->markAsPartiallyPublished(), + PostStatus::Failed => $post->markAsFailed(), + default => null, + }; + + return [$post->fresh(), $targetStatus === PostStatus::Published]; + }); + + if ($result === null) { + return; + } + + [$post, $successful] = $result; + $this->notify($post, $successful); + } + + private function notify(Post $post, bool $successful): void + { + $owner = $post->workspace->owner; + + if (! $owner) { + return; + } + + $statuses = $successful + ? [PostPlatformStatus::Published] + : [PostPlatformStatus::Failed, PostPlatformStatus::Rejected]; + $platforms = $post->postPlatforms() + ->with('socialAccount') + ->enabled() + ->whereIn('status', $statuses) + ->get() + ->map(function (PostPlatform $target): string { + $username = filled($target->display_username) ? " (@{$target->display_username})" : ''; + + return $target->display_name.$username; + }) + ->implode(', '); + + SendNotification::dispatch( + user: $owner, + workspaceId: $post->workspace_id, + type: $successful ? Type::PostPublished : Type::PostFailed, + channel: Channel::Both, + title: $successful ? 'Post published successfully' : 'Post failed to publish', + body: $successful ? $platforms : "Failed on: {$platforms}", + data: ['post_id' => $post->id], + mailable: $successful ? new PostPublished($post) : new PostPublishFailed($post), + ); + } +} diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 7c38c91f0..e327ddd8f 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -179,6 +179,7 @@ private function callVerifyEndpoint(SocialAccount $account): bool Platform::Threads => $this->verifyThreads($account), Platform::TikTok => $this->verifyTikTok($account), Platform::YouTube => $this->verifyYouTube($account), + Platform::GoogleBusinessProfile => $this->verifyGoogleBusinessProfile($account), Platform::Pinterest => $this->verifyPinterest($account), Platform::Bluesky => $this->verifyBluesky($account), Platform::Mastodon => $this->verifyMastodon($account), @@ -231,6 +232,7 @@ public function refreshToken(SocialAccount $account): bool Platform::X => $this->refreshXToken($account), Platform::Bluesky => $this->refreshBlueskyToken($account), Platform::YouTube => $this->refreshYouTubeToken($account), + Platform::GoogleBusinessProfile => $this->refreshGoogleBusinessProfileToken($account), Platform::TikTok => $this->refreshTikTokToken($account), Platform::Pinterest => $this->refreshPinterestToken($account), Platform::Threads => $this->refreshThreadsToken($account), @@ -364,6 +366,30 @@ private function refreshYouTubeToken(SocialAccount $account): void $account->refresh(); } + private function refreshGoogleBusinessProfileToken(SocialAccount $account): void + { + if (! $account->refresh_token) { + throw new TokenExpiredException('No refresh token available for Google Business Profile account'); + } + + $response = TokenRefreshClient::for(Platform::GoogleBusinessProfile)->send(fn () => $this->refreshHttp()->asForm() + ->post(config('trypost.platforms.google-business-profile.oauth_api').'/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.google-business-profile.client_id'), + 'client_secret' => config('services.google-business-profile.client_secret'), + ])); + + $data = $response->json(); + + $account->update([ + 'access_token' => $this->tokenFrom($data, $account->platform), + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + ]); + + $account->refresh(); + } + private function refreshTikTokToken(SocialAccount $account): void { if (! $account->refresh_token) { @@ -626,6 +652,25 @@ private function verifyYouTube(SocialAccount $account): bool ); } + private function verifyGoogleBusinessProfile(SocialAccount $account): bool + { + $response = Http::withToken($account->access_token) + ->get('https://mybusinessaccountmanagement.googleapis.com/v1/accounts', ['pageSize' => 1]); + + if (in_array($response->status(), [401, 403], true)) { + throw new TokenExpiredException('Google Business Profile access token is invalid, expired, or missing the required permission'); + } + + if ($response->successful()) { + return true; + } + + throw new PlatformUnavailableException( + "{$account->platform->label()} verify failed ({$response->status()}).", + $response->status(), + ); + } + private function verifyPinterest(SocialAccount $account): bool { $response = Http::withToken($account->access_token) diff --git a/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfileAnalytics.php b/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfileAnalytics.php new file mode 100644 index 000000000..fd80be988 --- /dev/null +++ b/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfileAnalytics.php @@ -0,0 +1,157 @@ + */ + public function getMetrics( + SocialAccount $account, + ?CarbonInterface $since = null, + ?CarbonInterface $until = null, + ?GoogleBusinessProfileLocation $location = null, + ): array { + $this->refreshTokenIfNeeded($account); + + $since ??= now()->subDays(30); + $until ??= now(); + $totals = array_fill_keys(self::DAILY_METRICS, 0); + /** @var array $keywords */ + $keywords = []; + + $locations = $location + ? collect([$location]) + : $account->googleBusinessProfileLocations()->where('is_selected', true)->get(); + + foreach ($locations as $location) { + $response = $this->api->performance($location, [ + 'dailyMetrics' => self::DAILY_METRICS, + 'dailyRange.start_date.year' => $since->year, + 'dailyRange.start_date.month' => $since->month, + 'dailyRange.start_date.day' => $since->day, + 'dailyRange.end_date.year' => $until->year, + 'dailyRange.end_date.month' => $until->month, + 'dailyRange.end_date.day' => $until->day, + ]); + + foreach ((array) data_get($response, 'multiDailyMetricTimeSeries', []) as $seriesGroup) { + foreach ((array) data_get($seriesGroup, 'dailyMetricTimeSeries', []) as $series) { + $metric = (string) data_get($series, 'dailyMetric'); + $totals[$metric] = ($totals[$metric] ?? 0) + collect(data_get($series, 'timeSeries.datedValues', [])) + ->sum(fn (array $point): int => (int) data_get($point, 'value', 0)); + } + } + + foreach ($this->api->searchKeywords($location, [ + 'monthlyRange.start_month.year' => $since->year, + 'monthlyRange.start_month.month' => $since->month, + 'monthlyRange.end_month.year' => $until->year, + 'monthlyRange.end_month.month' => $until->month, + ]) as $keyword) { + $term = (string) data_get($keyword, 'searchKeyword'); + $value = data_get($keyword, 'insightsValue.value'); + $threshold = data_get($keyword, 'insightsValue.threshold'); + $keywords[$term] ??= ['value' => 0, 'estimated' => false]; + $keywords[$term]['value'] += (int) ($value ?? $threshold ?? 0); + $keywords[$term]['estimated'] = $keywords[$term]['estimated'] || $threshold !== null; + } + } + + uasort($keywords, fn (array $left, array $right): int => $right['value'] <=> $left['value']); + + return [ + ['label' => 'Profile impressions', 'value' => $totals['BUSINESS_IMPRESSIONS_DESKTOP_MAPS'] + $totals['BUSINESS_IMPRESSIONS_DESKTOP_SEARCH'] + $totals['BUSINESS_IMPRESSIONS_MOBILE_MAPS'] + $totals['BUSINESS_IMPRESSIONS_MOBILE_SEARCH']], + ['label' => 'Calls', 'value' => $totals['CALL_CLICKS']], + ['label' => 'Website clicks', 'value' => $totals['WEBSITE_CLICKS']], + ['label' => 'Direction requests', 'value' => $totals['BUSINESS_DIRECTION_REQUESTS']], + ['label' => 'Conversations', 'value' => $totals['BUSINESS_CONVERSATIONS']], + ['label' => 'Bookings', 'value' => $totals['BUSINESS_BOOKINGS']], + ['label' => 'Food orders', 'value' => $totals['BUSINESS_FOOD_ORDERS']], + ['label' => 'Menu clicks', 'value' => $totals['BUSINESS_FOOD_MENU_CLICKS']], + ...collect($keywords)->take(5)->map( + fn (array $keyword, string $term): array => [ + 'label' => "Search: {$term}", + 'value' => $keyword['estimated'] ? '<'.$keyword['value'] : $keyword['value'], + ], + )->values()->all(), + ]; + } + + /** @return array|array{unsupported: true, reason: string} */ + public function fetchPostMetrics(PostPlatform $postPlatform): array + { + $account = $postPlatform->socialAccount; + $location = $postPlatform->googleBusinessProfileLocation; + if (! $account || ! $location || ! $postPlatform->platform_post_id) { + return ['unsupported' => true, 'reason' => 'missing_account_or_location']; + } + + $this->refreshTokenIfNeeded($account); + + try { + $response = $this->api->localPostInsights( + $location, + $postPlatform->platform_post_id, + ); + } catch (GoogleBusinessProfilePublishException $exception) { + if ($this->isLocalPostInsightsCapabilityUnavailable($exception)) { + return ['unsupported' => true, 'reason' => 'provider_capability_unavailable']; + } + + throw $exception; + } + $metricValues = (array) data_get($response, 'localPostMetrics.0.metricValues', []); + $metrics = collect($metricValues)->mapWithKeys(fn (array $metric): array => [ + data_get($metric, 'metric') => (int) data_get($metric, 'totalValue.value', 0), + ]); + + return [ + ['label' => 'Views in Google Search', 'value' => (int) $metrics->get('LOCAL_POST_VIEWS_SEARCH', 0)], + ['label' => 'Call-to-action clicks', 'value' => (int) $metrics->get('LOCAL_POST_ACTIONS_CALL_TO_ACTION', 0)], + ]; + } + + private function isLocalPostInsightsCapabilityUnavailable( + GoogleBusinessProfilePublishException $exception, + ): bool { + $errorCode = strtolower((string) $exception->platformErrorCode); + $response = strtolower($exception->userMessage.' '.($exception->rawResponse ?? '')); + + return in_array($errorCode, ['404', 'not_found'], true) + && str_contains($response, 'localposts:reportinsights') + && str_contains($response, 'requested url') + && str_contains($response, 'was not found'); + } + + private function refreshTokenIfNeeded(SocialAccount $account): void + { + if ($account->needsProactiveTokenRefresh()) { + app(ConnectionVerifier::class)->refreshToken($account); + } + } +} diff --git a/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfileApi.php b/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfileApi.php new file mode 100644 index 000000000..ba120628f --- /dev/null +++ b/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfileApi.php @@ -0,0 +1,175 @@ +> */ + public function accounts(string $accessToken): array + { + return $this->collectPages( + fn (?string $pageToken): Response => $this->http($accessToken)->get(self::ACCOUNT_MANAGEMENT_API.'/accounts', array_filter([ + 'pageSize' => 20, + 'pageToken' => $pageToken, + ])), + 'accounts', + ); + } + + /** @return list> */ + public function locations(string $accessToken, string $accountName): array + { + return $this->collectPages( + fn (?string $pageToken): Response => $this->http($accessToken)->get(self::BUSINESS_INFORMATION_API."/{$accountName}/locations", array_filter([ + 'pageSize' => 100, + 'pageToken' => $pageToken, + 'readMask' => 'name,title,storeCode,storefrontAddress,websiteUri,phoneNumbers,metadata', + ])), + 'locations', + ); + } + + /** @param array $payload + * @return array + */ + public function createLocalPost(GoogleBusinessProfileLocation $location, array $payload): array + { + $parent = $location->google_account_name.'/'.$location->google_location_name; + $response = $this->request(fn () => $this->http($location->socialAccount->access_token) + ->post(self::LOCAL_POSTS_API."/{$parent}/localPosts", $payload)); + + if ($response->failed()) { + throw GoogleBusinessProfilePublishException::fromApiResponse($response); + } + + return $response->json() ?? []; + } + + /** @return array */ + public function localPost(SocialAccount $account, string $localPostName): array + { + $response = $this->request(fn () => $this->http($account->access_token) + ->get(self::LOCAL_POSTS_API.'/'.ltrim($localPostName, '/'))); + + if ($response->failed()) { + throw GoogleBusinessProfilePublishException::fromApiResponse($response); + } + + return $response->json() ?? []; + } + + /** @return array */ + public function localPostInsights(GoogleBusinessProfileLocation $location, string $localPostName): array + { + $parent = $location->google_account_name.'/'.$location->google_location_name; + $response = $this->request(fn () => $this->http($location->socialAccount->access_token) + ->post(self::LOCAL_POSTS_API."/{$parent}/localPosts:reportInsights", [ + 'localPostNames' => [$localPostName], + 'basicRequest' => [ + 'metricRequests' => [ + ['metric' => 'LOCAL_POST_VIEWS_SEARCH', 'options' => ['AGGREGATED_TOTAL']], + ['metric' => 'LOCAL_POST_ACTIONS_CALL_TO_ACTION', 'options' => ['AGGREGATED_TOTAL']], + ], + ], + ])); + + if ($response->failed()) { + throw GoogleBusinessProfilePublishException::fromApiResponse($response); + } + + return $response->json() ?? []; + } + + /** @param array $query + * @return array + */ + public function performance(GoogleBusinessProfileLocation $location, array $query): array + { + $metrics = (array) ($query['dailyMetrics'] ?? []); + unset($query['dailyMetrics']); + $queryString = collect($metrics) + ->map(fn (string $metric): string => 'dailyMetrics='.rawurlencode($metric)) + ->push(http_build_query($query)) + ->filter() + ->implode('&'); + + $response = $this->request(fn () => $this->http($location->socialAccount->access_token) + ->get(self::PERFORMANCE_API.'/'.ltrim($location->google_location_name, '/').':fetchMultiDailyMetricsTimeSeries?'.$queryString)); + + if ($response->failed()) { + throw GoogleBusinessProfilePublishException::fromApiResponse($response); + } + + return $response->json() ?? []; + } + + /** @return list> */ + public function searchKeywords(GoogleBusinessProfileLocation $location, array $query): array + { + return $this->collectPages( + fn (?string $pageToken): Response => $this->http($location->socialAccount->access_token) + ->get(self::PERFORMANCE_API.'/'.ltrim($location->google_location_name, '/').'/searchkeywords/impressions/monthly', [ + ...$query, + 'pageSize' => 100, + 'pageToken' => $pageToken, + ]), + 'searchKeywordsCounts', + ); + } + + private function http(string $accessToken): PendingRequest + { + return Http::acceptJson()->asJson()->withToken($accessToken)->timeout(30)->connectTimeout(10); + } + + /** @param callable(?string): Response $request + * @return list> + */ + private function collectPages(callable $request, string $key): array + { + $items = []; + $pageToken = null; + + do { + $response = $this->request(fn () => $request($pageToken)); + + if ($response->failed()) { + throw GoogleBusinessProfilePublishException::fromApiResponse($response); + } + + $items = [...$items, ...($response->json($key) ?? [])]; + $pageToken = $response->json('nextPageToken'); + } while (filled($pageToken)); + + return $items; + } + + /** @param callable(): Response $request */ + private function request(callable $request): Response + { + try { + return $request(); + } catch (ConnectionException $e) { + throw new PlatformUnavailableException('Google Business Profile API is unavailable: '.$e->getMessage()); + } + } +} diff --git a/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfilePublisher.php b/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfilePublisher.php new file mode 100644 index 000000000..f153fa830 --- /dev/null +++ b/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfilePublisher.php @@ -0,0 +1,357 @@ + */ + public function publish(PostPlatform $postPlatform): array + { + if (! $postPlatform->content_type->isAuthorable()) { + throw new GoogleBusinessProfilePublishException( + userMessage: 'Google Business Profile alerts are no longer available for new posts.', + category: ErrorCategory::ContentPolicy, + ); + } + + $location = $postPlatform->googleBusinessProfileLocation; + + if (! $location || ! $location->is_selected || $location->social_account_id !== $postPlatform->social_account_id) { + throw new GoogleBusinessProfilePublishException( + userMessage: 'Choose a valid Google Business Profile location before publishing.', + category: ErrorCategory::Permission, + ); + } + + $account = $postPlatform->socialAccount; + if ($account->needsProactiveTokenRefresh()) { + app(ConnectionVerifier::class)->refreshToken($account); + } + + [$payload, $derivativePath] = $this->preparePayload($postPlatform); + + try { + $result = $this->api->createLocalPost($location, $payload); + } catch (PlatformUnavailableException $e) { + if ($derivativePath !== null) { + $e->context[PublishCheckpoint::GOOGLE_BUSINESS_PROFILE_DERIVATIVE_PATH] = $derivativePath; + } + + throw $e; + } catch (Throwable $e) { + $this->derivativeCleaner->cleanupPath($derivativePath, $postPlatform->id); + + throw $e; + } + + $platformPostId = data_get($result, 'name'); + if (! is_string($platformPostId) || $platformPostId === '') { + $this->derivativeCleaner->cleanupPath($derivativePath, $postPlatform->id); + + throw new GoogleBusinessProfilePublishException( + userMessage: 'Google Business Profile did not confirm the created post.', + category: ErrorCategory::ServerError, + ); + } + + $platformUrl = data_get($result, 'searchUrl'); + $state = (string) data_get($result, 'state', 'PROCESSING'); + $context = array_filter([ + 'provider_state' => $state, + PublishCheckpoint::GOOGLE_BUSINESS_PROFILE_DERIVATIVE_PATH => $derivativePath, + ], fn (mixed $value): bool => $value !== null && $value !== ''); + + if (in_array($state, ['LIVE', 'RECURRING'], true)) { + $postPlatform->markAsPublished($platformPostId, $platformUrl); + $this->derivativeCleaner->cleanupPath($derivativePath, $postPlatform->id); + } elseif ($state === 'REJECTED') { + $postPlatform->markAsRejected('Google rejected this post during review.', ['provider_state' => $state]); + $this->derivativeCleaner->cleanupPath($derivativePath, $postPlatform->id); + } else { + $postPlatform->markAsSubmitted( + $platformPostId, + $platformUrl, + $state === 'PROCESSING' ? Status::PendingReview : Status::Submitted, + $context, + ); + } + + return [ + 'id' => $platformPostId, + 'url' => $platformUrl, + 'provider_state' => $state, + 'derivative_path' => $derivativePath, + ]; + } + + /** @return array */ + public function payload(PostPlatform $postPlatform): array + { + $meta = $postPlatform->meta ?? []; + $payload = array_filter([ + 'languageCode' => data_get($meta, 'language_code'), + 'summary' => $postPlatform->post->content, + 'topicType' => $this->topicType($postPlatform->content_type), + 'callToAction' => $this->callToAction($meta), + 'event' => $this->event($postPlatform->content_type, $meta), + 'offer' => $postPlatform->content_type === ContentType::GoogleBusinessProfileOffer ? array_filter([ + 'couponCode' => data_get($meta, 'offer_coupon_code'), + 'redeemOnlineUrl' => data_get($meta, 'offer_redeem_url'), + 'termsConditions' => data_get($meta, 'offer_terms'), + ], fn (mixed $value): bool => filled($value)) : null, + 'alertType' => $postPlatform->content_type === ContentType::GoogleBusinessProfileAlert + ? data_get($meta, 'alert_type') + : null, + ], fn (mixed $value): bool => $value !== null && $value !== [] && $value !== ''); + + if ($media = $postPlatform->post->mediaItems->first()) { + $payload['media'] = [['sourceUrl' => $media->url]]; + } + + return $payload; + } + + /** @return array{0: array, 1: string|null} */ + private function preparePayload(PostPlatform $postPlatform): array + { + $payload = $this->payload($postPlatform); + + $derivativePath = null; + $media = $postPlatform->post->mediaItems->first(); + if ($media?->isImage()) { + [$sourceUrl, $derivativePath] = $this->resolveImageDerivative($media, $postPlatform); + data_set($payload, 'media.0.sourceUrl', $sourceUrl); + } + + return [$payload, $derivativePath]; + } + + /** @return array{0: string, 1: string} */ + private function resolveImageDerivative(MediaItem $media, PostPlatform $postPlatform): array + { + $existingPath = PublishCheckpoint::googleBusinessProfileDerivativePath($postPlatform->error_context); + if ($this->derivativeCleaner->isManagedDerivativePath($existingPath)) { + try { + if (Storage::exists($existingPath)) { + return [$this->absoluteStorageUrl($existingPath), $existingPath]; + } + } catch (Throwable) { + throw new PlatformUnavailableException('Media storage temporarily failed while reusing the Google Business Profile image derivative.'); + } + } + + $input = tempnam(sys_get_temp_dir(), 'gbp_input_'); + if ($input === false) { + throw new GoogleBusinessProfilePublishException( + userMessage: 'Failed to prepare image for Google Business Profile.', + category: ErrorCategory::ServerError, + ); + } + + $optimized = null; + $derivativePath = null; + + try { + try { + $sourceExists = $media->path !== '' && Storage::exists($media->path); + } catch (Throwable) { + throw new PlatformUnavailableException('Media storage temporarily failed while preparing the Google Business Profile image.'); + } + + if (! $sourceExists) { + throw new GoogleBusinessProfilePublishException( + userMessage: 'The image for this Google Business Profile post is no longer available.', + category: ErrorCategory::ServerError, + ); + } + + $maxSourceBytes = (int) config('trypost.media.max_size_mb.image', 10) * 1024 * 1024; + try { + $sourceSize = Storage::size($media->path); + $source = Storage::get($media->path); + } catch (Throwable) { + throw new PlatformUnavailableException('Media storage temporarily failed while preparing the Google Business Profile image.'); + } + + if ($sourceSize <= 0 || $sourceSize > $maxSourceBytes) { + throw new GoogleBusinessProfilePublishException( + userMessage: 'The image is too large for Google Business Profile.', + category: ErrorCategory::MediaFormat, + ); + } + + if (strlen($source) !== $sourceSize || file_put_contents($input, $source) === false) { + throw new PlatformUnavailableException('Media storage temporarily failed while preparing the Google Business Profile image.'); + } + + $optimized = $this->mediaOptimizer->optimizeImage($input, Platform::GoogleBusinessProfile); + if (mime_content_type($optimized) !== 'image/jpeg') { + throw new GoogleBusinessProfilePublishException( + userMessage: 'Failed to prepare image for Google Business Profile.', + category: ErrorCategory::MediaFormat, + ); + } + + $derivativePath = GoogleBusinessProfileMediaDerivativeCleaner::DIRECTORY.'/'.Str::uuid()->toString().'.jpg'; + try { + $stored = Storage::put($derivativePath, file_get_contents($optimized)); + } catch (Throwable) { + throw new PlatformUnavailableException('Media storage temporarily failed while preparing the Google Business Profile image.'); + } + + if (! $stored) { + throw new PlatformUnavailableException('Media storage temporarily failed while preparing the Google Business Profile image.'); + } + + return [$this->absoluteStorageUrl($derivativePath), $derivativePath]; + } catch (GoogleBusinessProfilePublishException|PlatformUnavailableException $e) { + $this->derivativeCleaner->cleanupPath($derivativePath, $postPlatform->id); + + throw $e; + } catch (Throwable $e) { + $this->derivativeCleaner->cleanupPath($derivativePath, $postPlatform->id); + + throw new GoogleBusinessProfilePublishException( + userMessage: 'Failed to prepare image for Google Business Profile.', + category: ErrorCategory::ServerError, + ); + } finally { + @unlink($input); + if ($optimized !== null) { + @unlink($optimized); + } + } + } + + private function absoluteStorageUrl(string $path): string + { + $storageUrl = Storage::url($path); + + return Str::startsWith($storageUrl, ['http://', 'https://']) ? $storageUrl : url($storageUrl); + } + + /** @param array $meta + * @return array|null + */ + private function callToAction(array $meta): ?array + { + $type = data_get($meta, 'cta_action_type'); + if (blank($type)) { + return null; + } + + return array_filter([ + 'actionType' => $type, + 'url' => $type === 'CALL' ? null : data_get($meta, 'cta_url'), + ], fn (mixed $value): bool => filled($value)); + } + + /** @param array $meta + * @return array|null + */ + private function event(ContentType $contentType, array $meta): ?array + { + if (! in_array($contentType, [ContentType::GoogleBusinessProfileEvent, ContentType::GoogleBusinessProfileOffer], true)) { + return null; + } + + $start = CarbonImmutable::parse((string) data_get($meta, 'event_start_at')); + $end = CarbonImmutable::parse((string) data_get($meta, 'event_end_at')); + + return array_filter([ + 'title' => data_get($meta, 'event_title'), + 'schedule' => [ + 'startDate' => $this->date($start), + 'startTime' => $this->time($start), + 'endDate' => $this->date($end), + 'endTime' => $this->time($end), + ], + 'recurrenceInfo' => $this->recurrence($meta), + ], fn (mixed $value): bool => $value !== null && $value !== []); + } + + /** @param array $meta + * @return array|null + */ + private function recurrence(array $meta): ?array + { + $pattern = data_get($meta, 'recurrence_pattern'); + if (blank($pattern)) { + return null; + } + + $key = match ($pattern) { + 'daily' => 'dailyPattern', + 'weekly' => 'weeklyPattern', + 'monthly' => 'monthlyPattern', + default => null, + }; + if ($key === null) { + return null; + } + + $patternPayload = match ($pattern) { + 'daily' => [], + 'weekly' => ['daysOfWeek' => data_get($meta, 'recurrence_days_of_week', [])], + 'monthly' => array_filter([ + 'dayOfMonth' => data_get($meta, 'recurrence_day_of_month'), + 'dayOfWeekOccurrence' => data_get($meta, 'recurrence_day_of_week_occurrence'), + ], fn (mixed $value): bool => filled($value)), + }; + + return array_filter([ + 'seriesEndTime' => filled(data_get($meta, 'recurrence_series_end_at')) + ? CarbonImmutable::parse((string) data_get($meta, 'recurrence_series_end_at'))->toRfc3339String() + : null, + $key => (object) $patternPayload, + ], fn (mixed $value): bool => $value !== null); + } + + /** @return array{year: int, month: int, day: int} */ + private function date(CarbonImmutable $date): array + { + return ['year' => $date->year, 'month' => $date->month, 'day' => $date->day]; + } + + /** @return array{hours: int, minutes: int, seconds: int, nanos: int} */ + private function time(CarbonImmutable $date): array + { + return ['hours' => $date->hour, 'minutes' => $date->minute, 'seconds' => $date->second, 'nanos' => 0]; + } + + private function topicType(ContentType $contentType): string + { + return match ($contentType) { + ContentType::GoogleBusinessProfileStandard => 'STANDARD', + ContentType::GoogleBusinessProfileEvent => 'EVENT', + ContentType::GoogleBusinessProfileOffer => 'OFFER', + ContentType::GoogleBusinessProfileAlert => 'ALERT', + default => throw new \LogicException('Unsupported Google Business Profile content type.'), + }; + } +} diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index 15cc71046..c251adbbc 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -5,6 +5,7 @@ namespace App\Support; use App\Enums\PostPlatform\AspectRatio; +use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Models\Post; use Illuminate\Validation\Rule; @@ -31,6 +32,45 @@ class PostPlatformMetaRules 'SELF_ONLY', ]; + public const GOOGLE_BUSINESS_PROFILE_CTA_TYPES = ['BOOK', 'ORDER', 'SHOP', 'LEARN_MORE', 'SIGN_UP', 'CALL']; + + public const GOOGLE_BUSINESS_PROFILE_RECURRENCE_PATTERNS = ['daily', 'weekly', 'monthly']; + + public const GOOGLE_BUSINESS_PROFILE_DAYS_OF_WEEK = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY']; + + public const GOOGLE_BUSINESS_PROFILE_WEEK_OCCURRENCES = ['FIRST', 'SECOND', 'THIRD', 'FOURTH', 'FIFTH', 'LAST']; + + /** + * Resolve an update exactly as UpdatePost will: submitted rows become the + * enabled set and merge their meta patches; omitted rows keep stored state. + * + * @param array|null $submittedPlatforms + * @return list}> + */ + public static function effectivePayloadsForUpdate(Post $post, ?array $submittedPlatforms): array + { + $stored = $post->postPlatforms()->get()->keyBy('id'); + + if ($submittedPlatforms === null) { + return $stored->where('enabled', true)->map(fn ($postPlatform): array => [ + 'content_type' => $postPlatform->content_type->value, + 'meta' => $postPlatform->meta ?? [], + ])->values()->all(); + } + + return collect($submittedPlatforms)->map(function ($platform) use ($stored): array { + $postPlatform = $stored->get(data_get($platform, 'id')); + + return [ + 'content_type' => (string) (data_get($platform, 'content_type') ?? $postPlatform?->content_type?->value ?? ''), + 'meta' => array_filter( + array_merge($postPlatform?->meta ?? [], (array) data_get($platform, 'meta', [])), + fn (mixed $value): bool => $value !== null, + ), + ]; + })->values()->all(); + } + /** * 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. @@ -64,6 +104,24 @@ public static function rules(): array 'platforms.*.meta.title' => ['sometimes', 'nullable', 'string', 'max:100'], 'platforms.*.meta.link' => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], + // Google Business Profile local posts + 'platforms.*.meta.language_code' => ['sometimes', 'nullable', 'string', 'max:35'], + 'platforms.*.meta.cta_action_type' => ['sometimes', 'nullable', 'string', Rule::in(self::GOOGLE_BUSINESS_PROFILE_CTA_TYPES)], + 'platforms.*.meta.cta_url' => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], + 'platforms.*.meta.event_title' => ['sometimes', 'nullable', 'string', 'max:255'], + 'platforms.*.meta.event_start_at' => ['sometimes', 'nullable', 'date'], + 'platforms.*.meta.event_end_at' => ['sometimes', 'nullable', 'date'], + 'platforms.*.meta.offer_coupon_code' => ['sometimes', 'nullable', 'string', 'max:255'], + 'platforms.*.meta.offer_redeem_url' => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], + 'platforms.*.meta.offer_terms' => ['sometimes', 'nullable', 'string', 'max:5000'], + 'platforms.*.meta.alert_type' => ['sometimes', 'nullable', 'string', Rule::in(['COVID_19'])], + 'platforms.*.meta.recurrence_pattern' => ['sometimes', 'nullable', 'string', Rule::in(self::GOOGLE_BUSINESS_PROFILE_RECURRENCE_PATTERNS)], + 'platforms.*.meta.recurrence_series_end_at' => ['sometimes', 'nullable', 'date'], + 'platforms.*.meta.recurrence_days_of_week' => ['sometimes', 'nullable', 'array', 'min:1'], + 'platforms.*.meta.recurrence_days_of_week.*' => ['string', Rule::in(self::GOOGLE_BUSINESS_PROFILE_DAYS_OF_WEEK)], + 'platforms.*.meta.recurrence_day_of_month' => ['sometimes', 'nullable', 'integer', 'between:1,31'], + 'platforms.*.meta.recurrence_day_of_week_occurrence' => ['sometimes', 'nullable', 'string', Rule::in(self::GOOGLE_BUSINESS_PROFILE_WEEK_OCCURRENCES)], + // Discord 'platforms.*.meta.channel_id' => ['sometimes', 'nullable', 'string'], 'platforms.*.meta.channel_name' => ['sometimes', 'nullable', 'string'], @@ -141,12 +199,71 @@ public static function assertStoredPostPublishable(Post $post): void $errors = []; foreach ($post->postPlatforms()->enabled()->get()->values() as $index => $postPlatform) { + if ($postPlatform->content_type && ! $postPlatform->content_type->isAuthorable()) { + $errors["platforms.{$index}.content_type"] = 'Google Business Profile alerts are no longer available for new posts.'; + } + $violation = self::requiredMetaViolation($postPlatform->platform, $postPlatform->meta); if ($violation !== null) { [$field, $message] = $violation; $errors["platforms.{$index}.meta.{$field}"] = $message; } + + $errors = [...$errors, ...self::googleBusinessProfileErrorsFor( + $postPlatform->content_type, + $postPlatform->meta ?? [], + "platforms.{$index}.meta", + )]; + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } + + /** + * @param array $platforms + * @param callable(mixed, int): ?ContentType $resolveContentType + */ + public static function addGoogleBusinessProfileErrors(Validator $validator, array $platforms, callable $resolveContentType): void + { + foreach ($platforms as $index => $platform) { + $contentType = $resolveContentType($platform, $index); + if ($contentType && ! $contentType->isAuthorable()) { + $validator->errors()->add("platforms.{$index}.content_type", 'Google Business Profile alerts are no longer available for new posts.'); + } + + foreach (self::googleBusinessProfileErrorsFor( + $contentType, + (array) data_get($platform, 'meta', []), + "platforms.{$index}.meta", + ) as $field => $message) { + $validator->errors()->add($field, $message); + } + } + } + + /** + * @param array $platforms + * @param callable(mixed, int): ?ContentType $resolveContentType + * + * @throws ValidationException + */ + public static function assertGoogleBusinessProfilePayloads(array $platforms, callable $resolveContentType): void + { + $errors = []; + foreach ($platforms as $index => $platform) { + $contentType = $resolveContentType($platform, $index); + if ($contentType && ! $contentType->isAuthorable()) { + $errors["platforms.{$index}.content_type"] = 'Google Business Profile alerts are no longer available for new posts.'; + } + + $errors = [...$errors, ...self::googleBusinessProfileErrorsFor( + $contentType, + (array) data_get($platform, 'meta', []), + "platforms.{$index}.meta", + )]; } if ($errors !== []) { @@ -154,6 +271,86 @@ public static function assertStoredPostPublishable(Post $post): void } } + /** + * @param array $platforms + * @return array + */ + public static function googleBusinessProfileLocationErrorsForUpdate(Post $post, array $platforms): array + { + $targets = $post->postPlatforms() + ->with('googleBusinessProfileLocation') + ->whereIn('id', collect($platforms)->pluck('id')->filter()) + ->get() + ->keyBy('id'); + $errors = []; + + foreach ($platforms as $index => $platform) { + $target = $targets->get(data_get($platform, 'id')); + if ($target?->platform !== Platform::GoogleBusinessProfile) { + continue; + } + + $location = $target->googleBusinessProfileLocation; + if (! $location || ! $location->is_selected || $location->social_account_id !== $target->social_account_id) { + $errors["platforms.{$index}.id"] = 'Choose a currently selected Google Business Profile location.'; + } + } + + return $errors; + } + + /** @param array $meta + * @return array + */ + private static function googleBusinessProfileErrorsFor(?ContentType $contentType, array $meta, string $prefix): array + { + $gbpTypes = [ + ContentType::GoogleBusinessProfileStandard, + ContentType::GoogleBusinessProfileEvent, + ContentType::GoogleBusinessProfileOffer, + ContentType::GoogleBusinessProfileAlert, + ]; + if (! in_array($contentType, $gbpTypes, true)) { + return []; + } + + $errors = []; + if (filled(data_get($meta, 'cta_action_type')) + && data_get($meta, 'cta_action_type') !== 'CALL' + && blank(data_get($meta, 'cta_url'))) { + $errors["{$prefix}.cta_url"] = 'A destination URL is required for this call to action.'; + } + + if (in_array($contentType, [ContentType::GoogleBusinessProfileEvent, ContentType::GoogleBusinessProfileOffer], true)) { + foreach (['event_title' => 'Event title', 'event_start_at' => 'Event start', 'event_end_at' => 'Event end'] as $field => $label) { + if (blank(data_get($meta, $field))) { + $errors["{$prefix}.{$field}"] = "{$label} is required for this Google Business Profile post type."; + } + } + + if (filled(data_get($meta, 'event_start_at')) && filled(data_get($meta, 'event_end_at')) + && strtotime((string) data_get($meta, 'event_end_at')) <= strtotime((string) data_get($meta, 'event_start_at'))) { + $errors["{$prefix}.event_end_at"] = 'Event end must be after event start.'; + } + } + + if (data_get($meta, 'recurrence_pattern') === 'weekly' && blank(data_get($meta, 'recurrence_days_of_week'))) { + $errors["{$prefix}.recurrence_days_of_week"] = 'Choose at least one weekday for weekly recurrence.'; + } + + if (data_get($meta, 'recurrence_pattern') === 'monthly') { + $monthlyOptions = collect([ + data_get($meta, 'recurrence_day_of_month'), + data_get($meta, 'recurrence_day_of_week_occurrence'), + ])->filter(fn (mixed $value): bool => filled($value))->count(); + if ($monthlyOptions !== 1) { + $errors["{$prefix}.recurrence_day_of_month"] = 'Choose either a day of month or a weekday occurrence for monthly recurrence.'; + } + } + + return $errors; + } + /** * The missing required meta field for a platform about to publish, or null when * nothing is missing. Single source of "what each platform requires to publish". diff --git a/app/Support/PostStatusRules.php b/app/Support/PostStatusRules.php index 9e16ca818..da16bf8ac 100644 --- a/app/Support/PostStatusRules.php +++ b/app/Support/PostStatusRules.php @@ -14,7 +14,9 @@ */ class PostStatusRules { - private const EDIT_BLOCKED_MESSAGE_KEY = 'posts.cannot_edit_finalized'; + private const EDIT_BLOCKED_MESSAGE_KEY = 'posts.flash.cannot_edit_finalized'; + + private const DELETE_BLOCKED_MESSAGE_KEY = 'posts.flash.cannot_delete_published'; /** * Statuses where the post can no longer be edited. @@ -54,6 +56,11 @@ public static function editBlockedMessage(): string return __(self::EDIT_BLOCKED_MESSAGE_KEY); } + public static function deleteBlockedMessage(): string + { + return __(self::DELETE_BLOCKED_MESSAGE_KEY); + } + /** * True when status is scheduled and the post has no future schedule to reuse. */ diff --git a/app/Support/Social/GoogleBusinessProfileMediaDerivativeCleaner.php b/app/Support/Social/GoogleBusinessProfileMediaDerivativeCleaner.php new file mode 100644 index 000000000..aaa760ea9 --- /dev/null +++ b/app/Support/Social/GoogleBusinessProfileMediaDerivativeCleaner.php @@ -0,0 +1,45 @@ +|null $context */ + public function cleanup(?array $context, ?string $postPlatformId = null): void + { + $this->cleanupPath(PublishCheckpoint::googleBusinessProfileDerivativePath($context), $postPlatformId); + } + + public function cleanupPath(?string $path, ?string $postPlatformId = null): void + { + if (! $this->isManagedDerivativePath($path)) { + return; + } + + try { + Storage::delete($path); + } catch (Throwable $e) { + Log::warning('Failed to prune Google Business Profile media derivative', [ + 'post_platform_id' => $postPlatformId, + 'error' => $e->getMessage(), + ]); + } + } + + public function isManagedDerivativePath(mixed $path): bool + { + return is_string($path) + && dirname($path) === self::DIRECTORY + && in_array(strtolower(pathinfo($path, PATHINFO_EXTENSION)), ['jpg', 'jpeg', 'png'], true) + && Str::isUuid(pathinfo($path, PATHINFO_FILENAME)); + } +} diff --git a/app/Support/Social/PublishCheckpoint.php b/app/Support/Social/PublishCheckpoint.php index 6862d77e9..6333af88e 100644 --- a/app/Support/Social/PublishCheckpoint.php +++ b/app/Support/Social/PublishCheckpoint.php @@ -17,6 +17,8 @@ final class PublishCheckpoint public const string INSTAGRAM_WORKFLOW = 'instagram_workflow'; + public const string GOOGLE_BUSINESS_PROFILE_DERIVATIVE_PATH = 'google_business_profile_derivative_path'; + /** * @param array|null $context */ @@ -48,4 +50,14 @@ public static function instagramWorkflow(?array $context): ?array return is_array($workflow) && $workflow !== [] ? $workflow : null; } + + /** + * @param array|null $context + */ + public static function googleBusinessProfileDerivativePath(?array $context): ?string + { + $path = data_get($context, self::GOOGLE_BUSINESS_PROFILE_DERIVATIVE_PATH); + + return is_string($path) && $path !== '' ? $path : null; + } } diff --git a/config/services.php b/config/services.php index 4b5d5690f..d2ff38bdf 100644 --- a/config/services.php +++ b/config/services.php @@ -68,6 +68,14 @@ 'redirect' => env('GOOGLE_CLIENT_REDIRECT'), ], + // Google OAuth used for Business Profile management. A separate client is + // supported so YouTube and GBP can have independent consent screens. + 'google-business-profile' => [ + 'client_id' => env('GOOGLE_BUSINESS_PROFILE_CLIENT_ID'), + 'client_secret' => env('GOOGLE_BUSINESS_PROFILE_CLIENT_SECRET'), + 'redirect' => env('GOOGLE_BUSINESS_PROFILE_CLIENT_REDIRECT'), + ], + // Google OAuth (used for login/signup) 'google-auth' => [ 'client_id' => env('GOOGLE_CLIENT_ID'), diff --git a/config/trypost.php b/config/trypost.php index f9d038e4d..76b35d21f 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -182,6 +182,10 @@ 'analytics_api' => env('YOUTUBE_ANALYTICS_API', 'https://youtubeanalytics.googleapis.com/v2'), 'oauth_api' => env('YOUTUBE_OAUTH_API', 'https://oauth2.googleapis.com'), ], + 'google-business-profile' => [ + 'enabled' => env('GOOGLE_BUSINESS_PROFILE_ENABLED', true), + 'oauth_api' => env('GOOGLE_BUSINESS_PROFILE_OAUTH_API', 'https://oauth2.googleapis.com'), + ], 'facebook' => [ 'enabled' => env('FACEBOOK_ENABLED', true), 'graph_api' => env('FACEBOOK_GRAPH_API', 'https://graph.facebook.com/v25.0'), diff --git a/database/factories/GoogleBusinessProfileLocationFactory.php b/database/factories/GoogleBusinessProfileLocationFactory.php new file mode 100644 index 000000000..e4f190eb4 --- /dev/null +++ b/database/factories/GoogleBusinessProfileLocationFactory.php @@ -0,0 +1,41 @@ + */ +class GoogleBusinessProfileLocationFactory extends Factory +{ + protected $model = GoogleBusinessProfileLocation::class; + + public function definition(): array + { + $locationId = fake()->unique()->numerify('###############'); + + return [ + 'social_account_id' => SocialAccount::factory()->state([ + 'platform' => Platform::GoogleBusinessProfile, + 'scopes' => ['https://www.googleapis.com/auth/business.manage'], + ]), + 'google_account_name' => 'accounts/'.fake()->numerify('##########'), + 'google_location_name' => 'locations/'.$locationId, + 'title' => fake()->company(), + 'store_code' => fake()->optional()->bothify('STORE-###'), + 'timezone' => fake()->timezone(), + 'maps_uri' => 'https://maps.google.com/?cid='.$locationId, + 'website_uri' => fake()->url(), + 'phone_number' => fake()->phoneNumber(), + 'storefront_address' => ['locality' => fake()->city(), 'regionCode' => 'US'], + 'metadata' => [], + 'is_selected' => true, + 'is_verified' => true, + 'last_synced_at' => now(), + ]; + } +} diff --git a/database/factories/PostPlatformFactory.php b/database/factories/PostPlatformFactory.php index cb39e970f..90066bb5d 100644 --- a/database/factories/PostPlatformFactory.php +++ b/database/factories/PostPlatformFactory.php @@ -123,6 +123,14 @@ public function youtube(): static ]); } + public function googleBusinessProfile(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::GoogleBusinessProfile, + 'content_type' => ContentType::GoogleBusinessProfileStandard, + ]); + } + public function pinterest(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index 927aba12a..a3c31df25 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -77,6 +77,15 @@ public function youtube(): static ]); } + public function googleBusinessProfile(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::GoogleBusinessProfile, + 'scopes' => Platform::GoogleBusinessProfile->requiredPublishScopes(), + 'meta' => ['google_user_id' => 'google-user-123'], + ]); + } + public function facebook(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/migrations/2026_08_25_120000_create_google_business_profile_locations_table.php b/database/migrations/2026_08_25_120000_create_google_business_profile_locations_table.php new file mode 100644 index 000000000..2f8178ccf --- /dev/null +++ b/database/migrations/2026_08_25_120000_create_google_business_profile_locations_table.php @@ -0,0 +1,40 @@ +uuid('id')->primary(); + $table->foreignUuid('social_account_id')->constrained()->cascadeOnDelete(); + $table->string('google_account_name'); + $table->string('google_location_name'); + $table->string('title'); + $table->string('store_code')->nullable(); + $table->string('timezone')->nullable(); + $table->text('maps_uri')->nullable(); + $table->text('website_uri')->nullable(); + $table->string('phone_number')->nullable(); + $table->json('storefront_address')->nullable(); + $table->json('metadata')->nullable(); + $table->boolean('is_selected')->default(false); + $table->boolean('is_verified')->default(false); + $table->timestamp('last_synced_at')->nullable(); + $table->timestamps(); + + $table->unique(['social_account_id', 'google_location_name'], 'gbp_locations_account_location_unique'); + $table->index(['social_account_id', 'is_selected']); + }); + } + + public function down(): void + { + Schema::dropIfExists('google_business_profile_locations'); + } +}; diff --git a/database/migrations/2026_08_25_120001_add_google_business_profile_fields_to_post_platforms_table.php b/database/migrations/2026_08_25_120001_add_google_business_profile_fields_to_post_platforms_table.php new file mode 100644 index 000000000..658b943a1 --- /dev/null +++ b/database/migrations/2026_08_25_120001_add_google_business_profile_fields_to_post_platforms_table.php @@ -0,0 +1,33 @@ +foreignUuid('google_business_profile_location_id') + ->nullable() + ->after('social_account_id') + ->constrained('google_business_profile_locations') + ->nullOnDelete(); + $table->timestamp('submitted_at')->nullable()->after('published_at'); + $table->timestamp('last_reconciled_at')->nullable()->after('submitted_at'); + $table->index(['platform', 'status', 'last_reconciled_at'], 'post_platforms_reconciliation_index'); + }); + } + + public function down(): void + { + Schema::table('post_platforms', function (Blueprint $table) { + $table->dropConstrainedForeignId('google_business_profile_location_id'); + $table->dropIndex('post_platforms_reconciliation_index'); + $table->dropColumn(['submitted_at', 'last_reconciled_at']); + }); + } +}; diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index 500991a2d..29d201b26 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'اربط حسابك على Mastodon', 'telegram' => 'اربط قناة أو مجموعة على Telegram', 'discord' => 'اربط خادم Discord', + 'google-business-profile' => 'اربط موقعًا واحدًا أو أكثر للملف التجاري على Google', ], 'disconnect_modal' => [ @@ -90,6 +91,21 @@ 'choose' => 'اختيار', ], + 'google_business_profile' => [ + 'title' => 'حدّد مواقع الملف التجاري على Google', + 'description' => 'اختر جميع المواقع التي تريد النشر فيها وعرض تحليلاتها في TryPost.', + 'no_locations' => 'لم يتم العثور على مواقع', + 'no_locations_description' => 'لا يدير حساب Google هذا أي مواقع لملف تجاري.', + 'store_code' => 'رمز المتجر: :code', + 'save' => 'ربط المواقع المحددة', + 'saving' => 'جارٍ الاتصال...', + 'manage_locations' => 'Manage locations', + 'no_selected_locations' => 'No locations selected', + 'location_count' => ':count locations connected', + 'disconnect_title' => 'Disconnect Google Business Profile location', + 'disconnect_description' => 'This location will stop publishing. Pending draft and scheduled targets are kept, and reconnecting the location restores them as drafts for review.', + ], + 'instagram_facebook' => [ 'title' => 'اختيار حساب Instagram', 'description' => 'اختر حساب Instagram الذي تريد ربطه', @@ -131,6 +147,8 @@ 'deactivated' => 'تم إلغاء تفعيل الحساب!', 'already_connected' => 'هذه المنصة متصلة بالفعل.', 'no_youtube_channels' => 'لم يتم العثور على قنوات YouTube. يرجى إنشاء قناة أولًا.', + 'google_business_profile_location_disconnected' => ':location was disconnected. Its pending draft and scheduled targets are preserved and can be restored by reconnecting the location.', + 'disconnect_google_business_profile_locations_individually' => 'Disconnect Google Business Profile locations individually so drafts and history stay attached to the correct business.', ], 'popup_callback' => [ @@ -138,6 +156,7 @@ 'title_error' => 'خطأ', 'closing' => 'ستُغلق هذه النافذة تلقائيًا...', 'manual_close' => 'يمكنك إغلاق هذه النافذة.', + 'return_to_accounts' => 'Return to Social Accounts', 'popup_blocked' => 'تعذر فتح نافذة الاتصال. يرجى السماح بالنوافذ المنبثقة والمحاولة مرة أخرى.', 'connected' => 'تم ربط الحساب!', 'reconnected' => 'تمت إعادة ربط الحساب!', @@ -162,5 +181,7 @@ 'no_facebook_instagram_pages' => 'لم يتم العثور على صفحات Facebook مرتبطة بحسابات Instagram.', 'no_youtube_channels' => 'لم يتم العثور على قنوات YouTube. يرجى إنشاء قناة أولًا.', 'not_linkedin_admin' => 'أنت لست مشرفًا على أي صفحة LinkedIn.', + 'no_google_business_profile_locations' => 'لم يتم العثور على مواقع للملف التجاري على Google لهذا الحساب.', + 'error_connecting_google_business_profile' => 'تعذّر ربط الملف التجاري على Google. يُرجى المحاولة مرة أخرى.', ], ]; diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 77c703250..f9abe6f3a 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -212,8 +212,11 @@ 'scheduled' => 'مجدول', 'publishing' => 'قيد النشر', 'retrying' => 'إعادة المحاولة', + 'submitted' => 'تم الإرسال إلى Google', + 'pending_review' => 'بانتظار مراجعة Google', 'published' => 'منشور', 'partially_published' => 'منشور جزئيًا', + 'rejected' => 'مرفوض', 'failed' => 'فشل', ], @@ -420,7 +423,10 @@ 'published' => 'منشور', 'publishing' => 'جارٍ النشر...', 'retrying' => 'جارٍ إعادة المحاولة...', + 'submitted' => 'تم الإرسال إلى Google', + 'pending_review' => 'بانتظار مراجعة Google', 'failed' => 'فشل', + 'rejected' => 'مرفوض', ], 'delete_modal' => [ @@ -514,6 +520,22 @@ 'label' => 'مقطع قصير', 'description' => 'فيديو عمودي حتى 3 دقائق', ], + 'google_business_profile_standard' => [ + 'label' => 'تحديث', + 'description' => 'تحديث للنشاط التجاري مع وسائط وعبارة تحث على اتخاذ إجراء اختياريًا', + ], + 'google_business_profile_event' => [ + 'label' => 'فعالية', + 'description' => 'فعالية محددة الوقت أو متكررة مع وسائط وعبارة تحث على اتخاذ إجراء اختياريًا', + ], + 'google_business_profile_offer' => [ + 'label' => 'عرض', + 'description' => 'عرض محدد الوقت أو متكرر مع قسيمة وتفاصيل الاسترداد', + ], + 'google_business_profile_alert' => [ + 'label' => 'تنبيه', + 'description' => 'تنبيه عالي الأولوية عندما تتيح Google إمكانية إنشائه', + ], 'x_post' => [ 'label' => 'منشور', 'description' => 'تغريدة بنص ووسائط', @@ -557,7 +579,9 @@ 'linkedin-page' => 'LinkedIn Page', 'x' => 'X', 'tiktok' => 'TikTok', + 'facebook' => 'Facebook Page', 'youtube' => 'YouTube Shorts', + 'google-business-profile' => 'الملف التجاري على Google', 'facebook' => 'Facebook Page', 'instagram' => 'Instagram', 'threads' => 'Threads', @@ -579,6 +603,7 @@ 'account_disconnected' => 'الحساب الاجتماعي مفصول', 'account_inactive' => 'الحساب الاجتماعي مُعطَّل', 'account_token_expired' => 'انتهت جلسة الحساب الاجتماعي — يرجى إعادة الربط', + 'gbp_location_disconnected' => 'Google Business Profile location is disconnected — reconnect it to restore this target', 'platform_unavailable' => 'المنصة غير متاحة مؤقتًا. سنعيد المحاولة قريبًا.', 'platform_unavailable_exhausted' => 'ظلت المنصة غير متاحة بعد عدة محاولات. يرجى المحاولة لاحقًا.', 'publishing_timed_out' => 'انتهت مهلة النشر. يرجى المحاولة مرة أخرى.', diff --git a/lang/de/accounts.php b/lang/de/accounts.php index f9574718d..f8bf334b2 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -32,6 +32,7 @@ 'mastodon' => 'Verbinde dein Mastodon-Konto', 'telegram' => 'Verbinde einen Telegram-Kanal oder eine Telegram-Gruppe', 'discord' => 'Verbinde einen Discord-Server', + 'google-business-profile' => 'Verbinde einen oder mehrere Google-Unternehmensprofil-Standorte', ], 'disconnect_modal' => [ @@ -92,6 +93,21 @@ 'choose' => 'Auswählen', ], + 'google_business_profile' => [ + 'title' => 'Google-Unternehmensprofil-Standorte auswählen', + 'description' => 'Wähle alle Standorte aus, auf denen du in TryPost veröffentlichen und Analysen ansehen möchtest.', + 'no_locations' => 'Keine Standorte gefunden', + 'no_locations_description' => 'Dieses Google-Konto verwaltet keine Unternehmensprofil-Standorte.', + 'store_code' => 'Geschäftscode: :code', + 'save' => 'Ausgewählte Standorte verbinden', + 'saving' => 'Wird verbunden...', + 'manage_locations' => 'Manage locations', + 'no_selected_locations' => 'No locations selected', + 'location_count' => ':count locations connected', + 'disconnect_title' => 'Disconnect Google Business Profile location', + 'disconnect_description' => 'This location will stop publishing. Pending draft and scheduled targets are kept, and reconnecting the location restores them as drafts for review.', + ], + 'instagram_facebook' => [ 'title' => 'Instagram-Konto auswählen', 'description' => 'Wähle das Instagram-Konto aus, das du verbinden möchtest', @@ -133,6 +149,8 @@ 'deactivated' => 'Konto deaktiviert!', 'already_connected' => 'Diese Plattform ist bereits verbunden.', 'no_youtube_channels' => 'Keine YouTube-Kanäle gefunden. Bitte erstelle zuerst einen Kanal.', + 'google_business_profile_location_disconnected' => ':location was disconnected. Its pending draft and scheduled targets are preserved and can be restored by reconnecting the location.', + 'disconnect_google_business_profile_locations_individually' => 'Disconnect Google Business Profile locations individually so drafts and history stay attached to the correct business.', ], 'popup_callback' => [ @@ -140,6 +158,7 @@ 'title_error' => 'Fehler', 'closing' => 'Dieses Fenster wird automatisch geschlossen...', 'manual_close' => 'Du kannst dieses Fenster schließen.', + 'return_to_accounts' => 'Return to Social Accounts', 'popup_blocked' => 'Das Verbindungsfenster konnte nicht geöffnet werden. Bitte erlaube Pop-ups und versuche es erneut.', 'connected' => 'Konto verbunden!', 'reconnected' => 'Konto erneut verbunden!', @@ -164,5 +183,7 @@ '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_profile_locations' => 'Für dieses Google-Konto wurden keine Google-Unternehmensprofil-Standorte gefunden.', + 'error_connecting_google_business_profile' => 'Google Unternehmensprofil konnte nicht verbunden werden. Versuche es erneut.', ], ]; diff --git a/lang/de/posts.php b/lang/de/posts.php index a082a28d3..d6bca03f9 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -214,8 +214,11 @@ 'scheduled' => 'Geplant', 'publishing' => 'Wird veröffentlicht', 'retrying' => 'Erneuter Versuch', + 'submitted' => 'An Google übermittelt', + 'pending_review' => 'Google-Prüfung ausstehend', 'published' => 'Veröffentlicht', 'partially_published' => 'Teilweise veröffentlicht', + 'rejected' => 'Abgelehnt', 'failed' => 'Fehlgeschlagen', ], @@ -422,7 +425,10 @@ 'published' => 'Veröffentlicht', 'publishing' => 'Wird veröffentlicht...', 'retrying' => 'Erneuter Versuch...', + 'submitted' => 'An Google übermittelt', + 'pending_review' => 'Google-Prüfung ausstehend', 'failed' => 'Fehlgeschlagen', + 'rejected' => 'Abgelehnt', ], 'delete_modal' => [ @@ -516,6 +522,22 @@ 'label' => 'Short', 'description' => 'Vertikales Video bis zu 3 Minuten', ], + 'google_business_profile_standard' => [ + 'label' => 'Neuigkeit', + 'description' => 'Unternehmensneuigkeit mit optionalen Medien und Handlungsaufforderung', + ], + 'google_business_profile_event' => [ + 'label' => 'Veranstaltung', + 'description' => 'Zeitlich begrenzte oder wiederkehrende Veranstaltung mit optionalen Medien und Handlungsaufforderung', + ], + 'google_business_profile_offer' => [ + 'label' => 'Angebot', + 'description' => 'Zeitlich begrenztes oder wiederkehrendes Angebot mit Gutschein und Einlösedetails', + ], + 'google_business_profile_alert' => [ + 'label' => 'Warnung', + 'description' => 'Wichtige Warnung, sofern Google die Erstellung zulässt', + ], 'x_post' => [ 'label' => 'Beitrag', 'description' => 'Tweet mit Text und Medien', @@ -559,7 +581,9 @@ 'linkedin-page' => 'LinkedIn-Seite', 'x' => 'X', 'tiktok' => 'TikTok', + 'facebook' => 'Facebook-Seite', 'youtube' => 'YouTube Shorts', + 'google-business-profile' => 'Google Unternehmensprofil', 'facebook' => 'Facebook-Seite', 'instagram' => 'Instagram', 'threads' => 'Threads', @@ -581,6 +605,7 @@ 'account_disconnected' => 'Social-Media-Konto ist getrennt', 'account_inactive' => 'Social-Media-Konto ist deaktiviert', 'account_token_expired' => 'Sitzung des Social-Media-Kontos abgelaufen – bitte erneut verbinden', + 'gbp_location_disconnected' => 'Google Business Profile location is disconnected — reconnect it to restore this target', 'platform_unavailable' => 'Die Plattform ist vorübergehend nicht verfügbar. Wir versuchen es in Kürze erneut.', 'platform_unavailable_exhausted' => 'Die Plattform blieb nach mehreren Versuchen nicht verfügbar. Bitte später erneut versuchen.', 'publishing_timed_out' => 'Die Veröffentlichung ist abgelaufen. Bitte erneut versuchen.', diff --git a/lang/el/accounts.php b/lang/el/accounts.php index 2b2fffaf7..6126178e3 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Συνδέστε τον λογαριασμό σας Mastodon', 'telegram' => 'Συνδέστε ένα κανάλι ή ομάδα Telegram', 'discord' => 'Συνδέστε έναν διακομιστή Discord', + 'google-business-profile' => 'Συνδέστε μία ή περισσότερες τοποθεσίες Επιχειρηματικού προφίλ Google', ], 'disconnect_modal' => [ @@ -90,6 +91,21 @@ 'choose' => 'Επιλογή', ], + 'google_business_profile' => [ + 'title' => 'Επιλέξτε τοποθεσίες Επιχειρηματικού προφίλ Google', + 'description' => 'Επιλέξτε όλες τις τοποθεσίες όπου θέλετε να δημοσιεύετε και να βλέπετε αναλυτικά στοιχεία στο TryPost.', + 'no_locations' => 'Δεν βρέθηκαν τοποθεσίες', + 'no_locations_description' => 'Αυτός ο λογαριασμός Google δεν διαχειρίζεται τοποθεσίες Επιχειρηματικού προφίλ.', + 'store_code' => 'Κωδικός καταστήματος: :code', + 'save' => 'Σύνδεση επιλεγμένων τοποθεσιών', + 'saving' => 'Γίνεται σύνδεση...', + 'manage_locations' => 'Manage locations', + 'no_selected_locations' => 'No locations selected', + 'location_count' => ':count locations connected', + 'disconnect_title' => 'Disconnect Google Business Profile location', + 'disconnect_description' => 'This location will stop publishing. Pending draft and scheduled targets are kept, and reconnecting the location restores them as drafts for review.', + ], + 'instagram_facebook' => [ 'title' => 'Επιλογή λογαριασμού Instagram', 'description' => 'Επιλέξτε ποιον λογαριασμό Instagram θέλετε να συνδέσετε', @@ -131,6 +147,8 @@ 'deactivated' => 'Ο λογαριασμός απενεργοποιήθηκε!', 'already_connected' => 'Αυτή η πλατφόρμα είναι ήδη συνδεδεμένη.', 'no_youtube_channels' => 'Δεν βρέθηκαν κανάλια YouTube. Παρακαλούμε δημιουργήστε πρώτα ένα κανάλι.', + 'google_business_profile_location_disconnected' => ':location was disconnected. Its pending draft and scheduled targets are preserved and can be restored by reconnecting the location.', + 'disconnect_google_business_profile_locations_individually' => 'Disconnect Google Business Profile locations individually so drafts and history stay attached to the correct business.', ], 'popup_callback' => [ @@ -138,6 +156,7 @@ 'title_error' => 'Σφάλμα', 'closing' => 'Αυτό το παράθυρο θα κλείσει αυτόματα...', 'manual_close' => 'Μπορείτε να κλείσετε αυτό το παράθυρο.', + 'return_to_accounts' => 'Return to Social Accounts', 'popup_blocked' => 'Δεν ήταν δυνατό το άνοιγμα του παραθύρου σύνδεσης. Παρακαλούμε επιτρέψτε τα αναδυόμενα παράθυρα και δοκιμάστε ξανά.', 'connected' => 'Ο λογαριασμός συνδέθηκε!', 'reconnected' => 'Ο λογαριασμός επανασυνδέθηκε!', @@ -162,5 +181,7 @@ 'no_facebook_instagram_pages' => 'Δεν βρέθηκαν σελίδες Facebook με συνδεδεμένους λογαριασμούς Instagram.', 'no_youtube_channels' => 'Δεν βρέθηκαν κανάλια YouTube. Παρακαλούμε δημιουργήστε πρώτα ένα κανάλι.', 'not_linkedin_admin' => 'Δεν είστε διαχειριστής καμίας σελίδας LinkedIn.', + 'no_google_business_profile_locations' => 'Δεν βρέθηκαν τοποθεσίες Επιχειρηματικού προφίλ Google για αυτόν τον λογαριασμό.', + 'error_connecting_google_business_profile' => 'Δεν ήταν δυνατή η σύνδεση του Επιχειρηματικού προφίλ Google. Δοκιμάστε ξανά.', ], ]; diff --git a/lang/el/posts.php b/lang/el/posts.php index a4592be22..5a4abd54f 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -212,8 +212,11 @@ 'scheduled' => 'Προγραμματισμένη', 'publishing' => 'Δημοσιεύεται', 'retrying' => 'Επανάληψη', + 'submitted' => 'Υποβλήθηκε στην Google', + 'pending_review' => 'Αναμονή ελέγχου από την Google', 'published' => 'Δημοσιεύτηκε', 'partially_published' => 'Δημοσιεύτηκε εν μέρει', + 'rejected' => 'Απορρίφθηκε', 'failed' => 'Απέτυχε', ], @@ -420,7 +423,10 @@ 'published' => 'Δημοσιεύτηκε', 'publishing' => 'Δημοσίευση...', 'retrying' => 'Επανάληψη...', + 'submitted' => 'Υποβλήθηκε στην Google', + 'pending_review' => 'Αναμονή ελέγχου από την Google', 'failed' => 'Απέτυχε', + 'rejected' => 'Απορρίφθηκε', ], 'delete_modal' => [ @@ -514,6 +520,22 @@ 'label' => 'Short', 'description' => 'Κατακόρυφο βίντεο έως 3 λεπτά', ], + 'google_business_profile_standard' => [ + 'label' => 'Ενημέρωση', + 'description' => 'Ενημέρωση επιχείρησης με προαιρετικά πολυμέσα και παρότρυνση για δράση', + ], + 'google_business_profile_event' => [ + 'label' => 'Εκδήλωση', + 'description' => 'Χρονικά καθορισμένη ή επαναλαμβανόμενη εκδήλωση με προαιρετικά πολυμέσα και παρότρυνση για δράση', + ], + 'google_business_profile_offer' => [ + 'label' => 'Προσφορά', + 'description' => 'Χρονικά καθορισμένη ή επαναλαμβανόμενη προσφορά με κουπόνι και λεπτομέρειες εξαργύρωσης', + ], + 'google_business_profile_alert' => [ + 'label' => 'Ειδοποίηση', + 'description' => 'Ειδοποίηση υψηλής προτεραιότητας όταν η Google επιτρέπει τη σύνταξή της', + ], 'x_post' => [ 'label' => 'Δημοσίευση', 'description' => 'Tweet με κείμενο και πολυμέσα', @@ -557,7 +579,9 @@ 'linkedin-page' => 'Σελίδα LinkedIn', 'x' => 'X', 'tiktok' => 'TikTok', + 'facebook' => 'Σελίδα Facebook', 'youtube' => 'YouTube Shorts', + 'google-business-profile' => 'Επιχειρηματικό προφίλ Google', 'facebook' => 'Σελίδα Facebook', 'instagram' => 'Instagram', 'threads' => 'Threads', @@ -579,6 +603,7 @@ 'account_disconnected' => 'Ο λογαριασμός κοινωνικού δικτύου έχει αποσυνδεθεί', 'account_inactive' => 'Ο λογαριασμός κοινωνικού δικτύου έχει απενεργοποιηθεί', 'account_token_expired' => 'Η συνεδρία του λογαριασμού κοινωνικού δικτύου έληξε — επανασυνδεθείτε', + 'gbp_location_disconnected' => 'Google Business Profile location is disconnected — reconnect it to restore this target', 'platform_unavailable' => 'Η πλατφόρμα είναι προσωρινά μη διαθέσιμη. Θα δοκιμάσουμε ξανά σύντομα.', 'platform_unavailable_exhausted' => 'Η πλατφόρμα παρέμεινε μη διαθέσιμη μετά από αρκετές προσπάθειες. Δοκιμάστε ξανά αργότερα.', 'publishing_timed_out' => 'Η δημοσίευση έληξε. Δοκιμάστε ξανά.', diff --git a/lang/en/accounts.php b/lang/en/accounts.php index ce62dbfdb..ef905050b 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -21,6 +21,7 @@ 'x' => 'Connect your X (Twitter) account', 'tiktok' => 'Connect your TikTok account', 'youtube' => 'Connect a YouTube channel', + 'google-business-profile' => 'Connect one or more Google Business Profile locations', 'facebook' => 'Connect a Facebook page', 'instagram' => 'Connect via Instagram Login or Facebook Pages', 'instagram-facebook' => 'Connect Instagram via Facebook page', @@ -90,6 +91,21 @@ 'choose' => 'Choose', ], + 'google_business_profile' => [ + 'title' => 'Select Google Business Profile locations', + 'description' => 'Choose every location you want to publish to and analyze in TryPost.', + 'no_locations' => 'No locations found', + 'no_locations_description' => 'This Google account does not manage any Business Profile locations.', + 'store_code' => 'Store code: :code', + 'save' => 'Connect selected locations', + 'saving' => 'Connecting...', + 'manage_locations' => 'Manage locations', + 'no_selected_locations' => 'No locations selected', + 'location_count' => ':count locations connected', + 'disconnect_title' => 'Disconnect Google Business Profile location', + 'disconnect_description' => 'This location will stop publishing. Pending draft and scheduled targets are kept, and reconnecting the location restores them as drafts for review.', + ], + 'instagram_facebook' => [ 'title' => 'Select Instagram Account', 'description' => 'Choose which Instagram account you want to connect', @@ -131,6 +147,8 @@ 'deactivated' => 'Account deactivated!', 'already_connected' => 'This platform is already connected.', 'no_youtube_channels' => 'No YouTube channels found. Please create a channel first.', + 'google_business_profile_location_disconnected' => ':location was disconnected. Its pending draft and scheduled targets are preserved and can be restored by reconnecting the location.', + 'disconnect_google_business_profile_locations_individually' => 'Disconnect Google Business Profile locations individually so drafts and history stay attached to the correct business.', ], 'popup_callback' => [ @@ -138,6 +156,7 @@ 'title_error' => 'Error', 'closing' => 'This window will close automatically...', 'manual_close' => 'You can close this window.', + 'return_to_accounts' => 'Return to Social Accounts', 'popup_blocked' => 'Could not open the connection window. Please allow popups and try again.', 'connected' => 'Account connected!', 'reconnected' => 'Account reconnected!', @@ -146,6 +165,8 @@ 'wrong_account' => 'That is a different account. Authorize the one you are reconnecting.', 'all_connected' => 'Every account on this login is already connected.', 'busy' => 'Another connection is still finishing. Please try again in a moment.', + 'no_google_business_profile_locations' => 'No Google Business Profile locations were found for this Google account.', + 'error_connecting_google_business_profile' => 'Could not connect Google Business Profile. Please try again.', 'error_connecting_page' => 'Error connecting page. Please try again.', 'error_connecting_channel' => 'Error connecting channel. Please try again.', 'session_expired' => 'Session expired. Please try again.', diff --git a/lang/en/posts.php b/lang/en/posts.php index ffdf6cecf..9ea6a1aef 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -212,8 +212,11 @@ 'scheduled' => 'Scheduled', 'publishing' => 'Publishing', 'retrying' => 'Retrying', + 'submitted' => 'Submitted to Google', + 'pending_review' => 'Pending Google review', 'published' => 'Published', 'partially_published' => 'Partially Published', + 'rejected' => 'Rejected', 'failed' => 'Failed', ], @@ -420,7 +423,10 @@ 'published' => 'Published', 'publishing' => 'Publishing...', 'retrying' => 'Retrying...', + 'submitted' => 'Submitted to Google', + 'pending_review' => 'Pending Google review', 'failed' => 'Failed', + 'rejected' => 'Rejected', ], 'delete_modal' => [ @@ -514,6 +520,22 @@ 'label' => 'Short', 'description' => 'Vertical video up to 3 minutes', ], + 'google_business_profile_standard' => [ + 'label' => 'Update', + 'description' => 'Business update with optional media and call to action', + ], + 'google_business_profile_event' => [ + 'label' => 'Event', + 'description' => 'Timed or recurring event with optional media and call to action', + ], + 'google_business_profile_offer' => [ + 'label' => 'Offer', + 'description' => 'Timed or recurring offer with coupon and redemption details', + ], + 'google_business_profile_alert' => [ + 'label' => 'Alert', + 'description' => 'High-priority alert where Google makes authoring available', + ], 'x_post' => [ 'label' => 'Post', 'description' => 'Tweet with text and media', @@ -558,6 +580,7 @@ 'x' => 'X', 'tiktok' => 'TikTok', 'youtube' => 'YouTube Shorts', + 'google-business-profile' => 'Google Business Profile', 'facebook' => 'Facebook Page', 'instagram' => 'Instagram', 'threads' => 'Threads', @@ -579,6 +602,7 @@ 'account_disconnected' => 'Social account is disconnected', 'account_inactive' => 'Social account is deactivated', 'account_token_expired' => 'Social account session expired — please reconnect', + 'gbp_location_disconnected' => 'Google Business Profile location is disconnected — reconnect it to restore this target', 'platform_unavailable' => 'The platform is temporarily unavailable. We\'ll retry shortly.', 'platform_unavailable_exhausted' => 'The platform stayed unavailable after several retries. Please try again later.', 'publishing_timed_out' => 'Publishing timed out. Please try again.', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index f1f273393..7e8a80162 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Conecta tu cuenta de Mastodon', 'telegram' => 'Conecta un canal o grupo de Telegram', 'discord' => 'Conecta un servidor de Discord', + 'google-business-profile' => 'Conecta una o más ubicaciones de Perfil de Empresa de Google', ], 'disconnect_modal' => [ @@ -90,6 +91,21 @@ 'choose' => 'Elegir', ], + 'google_business_profile' => [ + 'title' => 'Selecciona ubicaciones de Perfil de Empresa de Google', + 'description' => 'Elige todas las ubicaciones en las que quieras publicar y analizar en TryPost.', + 'no_locations' => 'No se encontraron ubicaciones', + 'no_locations_description' => 'Esta cuenta de Google no administra ninguna ubicación de Perfil de Empresa.', + 'store_code' => 'Código de tienda: :code', + 'save' => 'Conectar ubicaciones seleccionadas', + 'saving' => 'Conectando...', + 'manage_locations' => 'Manage locations', + 'no_selected_locations' => 'No locations selected', + 'location_count' => ':count locations connected', + 'disconnect_title' => 'Disconnect Google Business Profile location', + 'disconnect_description' => 'This location will stop publishing. Pending draft and scheduled targets are kept, and reconnecting the location restores them as drafts for review.', + ], + 'instagram_facebook' => [ 'title' => 'Seleccionar cuenta de Instagram', 'description' => 'Elige qué cuenta de Instagram deseas conectar', @@ -131,6 +147,8 @@ 'deactivated' => '¡Cuenta desactivada!', 'already_connected' => 'Esta plataforma ya está conectada.', 'no_youtube_channels' => 'No se encontraron canales de YouTube. Crea un canal primero.', + 'google_business_profile_location_disconnected' => ':location was disconnected. Its pending draft and scheduled targets are preserved and can be restored by reconnecting the location.', + 'disconnect_google_business_profile_locations_individually' => 'Disconnect Google Business Profile locations individually so drafts and history stay attached to the correct business.', ], 'popup_callback' => [ @@ -138,6 +156,7 @@ 'title_error' => 'Error', 'closing' => 'Esta ventana se cerrará automáticamente...', 'manual_close' => 'Puedes cerrar esta ventana.', + 'return_to_accounts' => 'Return to Social Accounts', 'popup_blocked' => 'No se pudo abrir la ventana de conexión. Permite las ventanas emergentes e inténtalo de nuevo.', 'connected' => '¡Cuenta conectada!', 'reconnected' => '¡Cuenta reconectada!', @@ -162,5 +181,7 @@ '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_profile_locations' => 'No se encontraron ubicaciones de Perfil de Empresa de Google para esta cuenta de Google.', + 'error_connecting_google_business_profile' => 'No se pudo conectar el Perfil de Empresa de Google. Inténtalo de nuevo.', ], ]; diff --git a/lang/es/posts.php b/lang/es/posts.php index 566e78ecc..0243f1a2d 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -212,8 +212,11 @@ 'scheduled' => 'Programado', 'publishing' => 'Publicando', 'retrying' => 'Reintentando', + 'submitted' => 'Enviado a Google', + 'pending_review' => 'Pendiente de revisión de Google', 'published' => 'Publicado', 'partially_published' => 'Parcialmente publicado', + 'rejected' => 'Rechazado', 'failed' => 'Fallido', ], @@ -420,7 +423,10 @@ 'published' => 'Publicado', 'publishing' => 'Publicando...', 'retrying' => 'Reintentando...', + 'submitted' => 'Enviado a Google', + 'pending_review' => 'Pendiente de revisión de Google', 'failed' => 'Fallido', + 'rejected' => 'Rechazado', ], 'delete_modal' => [ @@ -514,6 +520,22 @@ 'label' => 'Short', 'description' => 'Video vertical de hasta 3 minutos', ], + 'google_business_profile_standard' => [ + 'label' => 'Actualización', + 'description' => 'Actualización del negocio con contenido multimedia y llamada a la acción opcionales', + ], + 'google_business_profile_event' => [ + 'label' => 'Evento', + 'description' => 'Evento programado o recurrente con contenido multimedia y llamada a la acción opcionales', + ], + 'google_business_profile_offer' => [ + 'label' => 'Oferta', + 'description' => 'Oferta programada o recurrente con cupón y detalles de canje', + ], + 'google_business_profile_alert' => [ + 'label' => 'Alerta', + 'description' => 'Alerta de alta prioridad cuando Google permite su creación', + ], 'x_post' => [ 'label' => 'Post', 'description' => 'Tweet con texto y multimedia', @@ -557,7 +579,9 @@ 'linkedin-page' => 'Página de LinkedIn', 'x' => 'X', 'tiktok' => 'TikTok', + 'facebook' => 'Página de Facebook', 'youtube' => 'YouTube Shorts', + 'google-business-profile' => 'Perfil de Empresa de Google', 'facebook' => 'Página de Facebook', 'instagram' => 'Instagram', 'threads' => 'Threads', @@ -579,6 +603,7 @@ 'account_disconnected' => 'Cuenta social desconectada', 'account_inactive' => 'Cuenta social desactivada', 'account_token_expired' => 'Sesión de la cuenta social expirada — reconecta la cuenta', + 'gbp_location_disconnected' => 'Google Business Profile location is disconnected — reconnect it to restore this target', 'platform_unavailable' => 'La plataforma no está disponible temporalmente. Reintentaremos en breve.', 'platform_unavailable_exhausted' => 'La plataforma siguió sin estar disponible tras varios reintentos. Inténtalo de nuevo más tarde.', 'publishing_timed_out' => 'La publicación agotó el tiempo de espera. Inténtalo de nuevo.', diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index 7ba02b5fe..303944c0c 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Connectez votre compte Mastodon', 'telegram' => 'Connectez un canal ou un groupe Telegram', 'discord' => 'Connectez un serveur Discord', + 'google-business-profile' => 'Connectez un ou plusieurs établissements Google Business Profile', ], 'disconnect_modal' => [ @@ -90,6 +91,21 @@ 'choose' => 'Choisir', ], + 'google_business_profile' => [ + 'title' => 'Sélectionnez les établissements Google Business Profile', + 'description' => 'Choisissez tous les établissements sur lesquels publier et analyser dans TryPost.', + 'no_locations' => 'Aucun établissement trouvé', + 'no_locations_description' => 'Ce compte Google ne gère aucun établissement Business Profile.', + 'store_code' => 'Code établissement : :code', + 'save' => 'Connecter les établissements sélectionnés', + 'saving' => 'Connexion...', + 'manage_locations' => 'Manage locations', + 'no_selected_locations' => 'No locations selected', + 'location_count' => ':count locations connected', + 'disconnect_title' => 'Disconnect Google Business Profile location', + 'disconnect_description' => 'This location will stop publishing. Pending draft and scheduled targets are kept, and reconnecting the location restores them as drafts for review.', + ], + 'instagram_facebook' => [ 'title' => 'Sélectionner un compte Instagram', 'description' => 'Choisissez le compte Instagram que vous souhaitez connecter', @@ -131,6 +147,8 @@ 'deactivated' => 'Compte désactivé !', 'already_connected' => 'Cette plateforme est déjà connectée.', 'no_youtube_channels' => 'Aucune chaîne YouTube trouvée. Veuillez d\'abord créer une chaîne.', + 'google_business_profile_location_disconnected' => ':location was disconnected. Its pending draft and scheduled targets are preserved and can be restored by reconnecting the location.', + 'disconnect_google_business_profile_locations_individually' => 'Disconnect Google Business Profile locations individually so drafts and history stay attached to the correct business.', ], 'popup_callback' => [ @@ -138,6 +156,7 @@ 'title_error' => 'Erreur', 'closing' => 'Cette fenêtre se fermera automatiquement...', 'manual_close' => 'Vous pouvez fermer cette fenêtre.', + 'return_to_accounts' => 'Return to Social Accounts', 'popup_blocked' => 'Impossible d\'ouvrir la fenêtre de connexion. Veuillez autoriser les pop-ups et réessayer.', 'connected' => 'Compte connecté !', 'reconnected' => 'Compte reconnecté !', @@ -162,5 +181,7 @@ '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_profile_locations' => 'Aucun établissement Google Business Profile n’a été trouvé pour ce compte Google.', + 'error_connecting_google_business_profile' => 'Impossible de connecter Google Business Profile. Réessayez.', ], ]; diff --git a/lang/fr/posts.php b/lang/fr/posts.php index b246551f2..608f7aa5e 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -212,8 +212,11 @@ 'scheduled' => 'Programmée', 'publishing' => 'Publication en cours', 'retrying' => 'Nouvelle tentative', + 'submitted' => 'Envoyée à Google', + 'pending_review' => 'En attente de validation Google', 'published' => 'Publiée', 'partially_published' => 'Partiellement publiée', + 'rejected' => 'Rejetée', 'failed' => 'Échec', ], @@ -420,7 +423,10 @@ 'published' => 'Publiée', 'publishing' => 'Publication en cours...', 'retrying' => 'Nouvelle tentative...', + 'submitted' => 'Envoyé à Google', + 'pending_review' => 'En attente de validation Google', 'failed' => 'Échec', + 'rejected' => 'Rejeté', ], 'delete_modal' => [ @@ -514,6 +520,22 @@ 'label' => 'Short', 'description' => 'Vidéo verticale jusqu\'à 3 minutes', ], + 'google_business_profile_standard' => [ + 'label' => 'Actualité', + 'description' => 'Actualité de l’établissement avec média et appel à l’action facultatifs', + ], + 'google_business_profile_event' => [ + 'label' => 'Événement', + 'description' => 'Événement ponctuel ou récurrent avec média et appel à l’action facultatifs', + ], + 'google_business_profile_offer' => [ + 'label' => 'Offre', + 'description' => 'Offre ponctuelle ou récurrente avec coupon et modalités d’utilisation', + ], + 'google_business_profile_alert' => [ + 'label' => 'Alerte', + 'description' => 'Alerte prioritaire lorsque Google autorise sa création', + ], 'x_post' => [ 'label' => 'Publication', 'description' => 'Tweet avec texte et médias', @@ -557,7 +579,9 @@ 'linkedin-page' => 'Page LinkedIn', 'x' => 'X', 'tiktok' => 'TikTok', + 'facebook' => 'Page Facebook', 'youtube' => 'YouTube Shorts', + 'google-business-profile' => 'Google Business Profile', 'facebook' => 'Page Facebook', 'instagram' => 'Instagram', 'threads' => 'Threads', @@ -579,6 +603,7 @@ 'account_disconnected' => 'Le compte social est déconnecté', 'account_inactive' => 'Le compte social est désactivé', 'account_token_expired' => 'La session du compte social a expiré — veuillez reconnecter', + 'gbp_location_disconnected' => 'Google Business Profile location is disconnected — reconnect it to restore this target', 'platform_unavailable' => 'La plateforme est temporairement indisponible. Nouvelle tentative sous peu.', 'platform_unavailable_exhausted' => 'La plateforme est restée indisponible après plusieurs tentatives. Réessayez plus tard.', 'publishing_timed_out' => 'La publication a expiré. Veuillez réessayer.', diff --git a/lang/it/accounts.php b/lang/it/accounts.php index 20d2deb5f..ce24b6dc8 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Collega il tuo account Mastodon', 'telegram' => 'Collega un canale o gruppo Telegram', 'discord' => 'Collega un server Discord', + 'google-business-profile' => 'Collega una o più sedi del Profilo dell’attività su Google', ], 'disconnect_modal' => [ @@ -90,6 +91,21 @@ 'choose' => 'Scegli', ], + 'google_business_profile' => [ + 'title' => 'Seleziona le sedi del Profilo dell’attività su Google', + 'description' => 'Scegli tutte le sedi in cui vuoi pubblicare e visualizzare le analisi in TryPost.', + 'no_locations' => 'Nessuna sede trovata', + 'no_locations_description' => 'Questo account Google non gestisce sedi del Profilo dell’attività.', + 'store_code' => 'Codice negozio: :code', + 'save' => 'Collega le sedi selezionate', + 'saving' => 'Connessione...', + 'manage_locations' => 'Manage locations', + 'no_selected_locations' => 'No locations selected', + 'location_count' => ':count locations connected', + 'disconnect_title' => 'Disconnect Google Business Profile location', + 'disconnect_description' => 'This location will stop publishing. Pending draft and scheduled targets are kept, and reconnecting the location restores them as drafts for review.', + ], + 'instagram_facebook' => [ 'title' => 'Seleziona account Instagram', 'description' => 'Scegli quale account Instagram vuoi collegare', @@ -131,6 +147,8 @@ 'deactivated' => 'Account disattivato!', 'already_connected' => 'Questa piattaforma è già collegata.', 'no_youtube_channels' => 'Nessun canale YouTube trovato. Crea prima un canale.', + 'google_business_profile_location_disconnected' => ':location was disconnected. Its pending draft and scheduled targets are preserved and can be restored by reconnecting the location.', + 'disconnect_google_business_profile_locations_individually' => 'Disconnect Google Business Profile locations individually so drafts and history stay attached to the correct business.', ], 'popup_callback' => [ @@ -138,6 +156,7 @@ 'title_error' => 'Errore', 'closing' => 'Questa finestra si chiuderà automaticamente...', 'manual_close' => 'Puoi chiudere questa finestra.', + 'return_to_accounts' => 'Return to Social Accounts', 'popup_blocked' => 'Impossibile aprire la finestra di collegamento. Consenti i popup e riprova.', 'connected' => 'Account collegato!', 'reconnected' => 'Account ricollegato!', @@ -162,5 +181,7 @@ '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_profile_locations' => 'Non sono state trovate sedi del Profilo dell’attività su Google per questo account.', + 'error_connecting_google_business_profile' => 'Impossibile collegare il Profilo dell’attività su Google. Riprova.', ], ]; diff --git a/lang/it/posts.php b/lang/it/posts.php index 8bb0645e9..7d8fc3401 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -212,8 +212,11 @@ 'scheduled' => 'Programmato', 'publishing' => 'In pubblicazione', 'retrying' => 'Nuovo tentativo', + 'submitted' => 'Inviato a Google', + 'pending_review' => 'In attesa di revisione da Google', 'published' => 'Pubblicato', 'partially_published' => 'Pubblicato parzialmente', + 'rejected' => 'Rifiutato', 'failed' => 'Non riuscito', ], @@ -420,7 +423,10 @@ 'published' => 'Pubblicato', 'publishing' => 'Pubblicazione in corso...', 'retrying' => 'Nuovo tentativo...', + 'submitted' => 'Inviato a Google', + 'pending_review' => 'In attesa di revisione da Google', 'failed' => 'Non riuscito', + 'rejected' => 'Rifiutato', ], 'delete_modal' => [ @@ -514,6 +520,22 @@ 'label' => 'Short', 'description' => 'Video verticale fino a 3 minuti', ], + 'google_business_profile_standard' => [ + 'label' => 'Aggiornamento', + 'description' => 'Aggiornamento dell’attività con contenuti multimediali e invito all’azione facoltativi', + ], + 'google_business_profile_event' => [ + 'label' => 'Evento', + 'description' => 'Evento a tempo o ricorrente con contenuti multimediali e invito all’azione facoltativi', + ], + 'google_business_profile_offer' => [ + 'label' => 'Offerta', + 'description' => 'Offerta a tempo o ricorrente con coupon e dettagli di riscatto', + ], + 'google_business_profile_alert' => [ + 'label' => 'Avviso', + 'description' => 'Avviso ad alta priorità quando Google ne consente la creazione', + ], 'x_post' => [ 'label' => 'Post', 'description' => 'Tweet con testo e media', @@ -557,7 +579,9 @@ 'linkedin-page' => 'Pagina LinkedIn', 'x' => 'X', 'tiktok' => 'TikTok', + 'facebook' => 'Pagina Facebook', 'youtube' => 'YouTube Shorts', + 'google-business-profile' => 'Profilo dell’attività su Google', 'facebook' => 'Pagina Facebook', 'instagram' => 'Instagram', 'threads' => 'Threads', @@ -579,6 +603,7 @@ 'account_disconnected' => 'L\'account social è scollegato', 'account_inactive' => 'L\'account social è disattivato', 'account_token_expired' => 'Sessione dell\'account social scaduta — ricollegalo', + 'gbp_location_disconnected' => 'Google Business Profile location is disconnected — reconnect it to restore this target', 'platform_unavailable' => 'La piattaforma è temporaneamente non disponibile. Riproveremo a breve.', 'platform_unavailable_exhausted' => 'La piattaforma è rimasta non disponibile dopo diversi tentativi. Riprova più tardi.', 'publishing_timed_out' => 'Pubblicazione scaduta. Riprova.', diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index 23f151420..b07d828cc 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Mastodon アカウントを接続', 'telegram' => 'Telegram チャンネルまたはグループを接続', 'discord' => 'Discord サーバーを接続', + 'google-business-profile' => '1 つ以上の Google ビジネス プロフィールの店舗を接続', ], 'disconnect_modal' => [ @@ -90,6 +91,21 @@ 'choose' => '選択', ], + 'google_business_profile' => [ + 'title' => 'Google ビジネス プロフィールの店舗を選択', + 'description' => 'TryPost で投稿と分析を行うすべての店舗を選択してください。', + 'no_locations' => '店舗が見つかりません', + 'no_locations_description' => 'この Google アカウントが管理するビジネス プロフィールの店舗はありません。', + 'store_code' => '店舗コード: :code', + 'save' => '選択した店舗を接続', + 'saving' => '接続中...', + 'manage_locations' => 'Manage locations', + 'no_selected_locations' => 'No locations selected', + 'location_count' => ':count locations connected', + 'disconnect_title' => 'Disconnect Google Business Profile location', + 'disconnect_description' => 'This location will stop publishing. Pending draft and scheduled targets are kept, and reconnecting the location restores them as drafts for review.', + ], + 'instagram_facebook' => [ 'title' => 'Instagram アカウントを選択', 'description' => '接続する Instagram アカウントを選択してください', @@ -131,6 +147,8 @@ 'deactivated' => 'アカウントを無効化しました!', 'already_connected' => 'このプラットフォームはすでに接続されています。', 'no_youtube_channels' => 'YouTube チャンネルが見つかりません。先にチャンネルを作成してください。', + 'google_business_profile_location_disconnected' => ':location was disconnected. Its pending draft and scheduled targets are preserved and can be restored by reconnecting the location.', + 'disconnect_google_business_profile_locations_individually' => 'Disconnect Google Business Profile locations individually so drafts and history stay attached to the correct business.', ], 'popup_callback' => [ @@ -138,6 +156,7 @@ 'title_error' => 'エラー', 'closing' => 'このウィンドウは自動的に閉じます...', 'manual_close' => 'このウィンドウを閉じても構いません。', + 'return_to_accounts' => 'Return to Social Accounts', 'popup_blocked' => '接続ウィンドウを開けませんでした。ポップアップを許可してもう一度お試しください。', 'connected' => 'アカウントを接続しました!', 'reconnected' => 'アカウントを再接続しました!', @@ -162,5 +181,7 @@ 'no_facebook_instagram_pages' => 'Instagram アカウントが連携された Facebook ページが見つかりません。', 'no_youtube_channels' => 'YouTube チャンネルが見つかりません。先にチャンネルを作成してください。', 'not_linkedin_admin' => 'あなたは管理者となっている LinkedIn ページがありません。', + 'no_google_business_profile_locations' => 'この Google アカウントの Google ビジネス プロフィール店舗が見つかりませんでした。', + 'error_connecting_google_business_profile' => 'Google ビジネス プロフィールに接続できませんでした。もう一度お試しください。', ], ]; diff --git a/lang/ja/posts.php b/lang/ja/posts.php index f0092fb3f..778c040f4 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -212,8 +212,11 @@ 'scheduled' => '予約済み', 'publishing' => '公開中', 'retrying' => '再試行中', + 'submitted' => 'Google に送信済み', + 'pending_review' => 'Google の審査待ち', 'published' => '公開済み', 'partially_published' => '一部公開済み', + 'rejected' => '却下済み', 'failed' => '失敗', ], @@ -420,7 +423,10 @@ 'published' => '公開済み', 'publishing' => '公開中...', 'retrying' => '再試行中...', + 'submitted' => 'Google に送信済み', + 'pending_review' => 'Google の審査待ち', 'failed' => '失敗', + 'rejected' => '却下済み', ], 'delete_modal' => [ @@ -514,6 +520,22 @@ 'label' => 'ショート', 'description' => '最大 3 分の縦型動画', ], + 'google_business_profile_standard' => [ + 'label' => '最新情報', + 'description' => 'メディアと行動を促すフレーズを任意で追加できるビジネス最新情報', + ], + 'google_business_profile_event' => [ + 'label' => 'イベント', + 'description' => 'メディアと行動を促すフレーズを任意で追加できる期間限定または定期イベント', + ], + 'google_business_profile_offer' => [ + 'label' => '特典', + 'description' => 'クーポンと利用情報を含む期間限定または定期特典', + ], + 'google_business_profile_alert' => [ + 'label' => 'アラート', + 'description' => 'Google で作成が許可されている場合の優先度の高いアラート', + ], 'x_post' => [ 'label' => '投稿', 'description' => 'テキストとメディア付きのツイート', @@ -557,7 +579,9 @@ 'linkedin-page' => 'LinkedIn ページ', 'x' => 'X', 'tiktok' => 'TikTok', + 'facebook' => 'Facebook ページ', 'youtube' => 'YouTube ショート', + 'google-business-profile' => 'Google ビジネス プロフィール', 'facebook' => 'Facebook ページ', 'instagram' => 'Instagram', 'threads' => 'Threads', @@ -579,6 +603,7 @@ 'account_disconnected' => 'ソーシャルアカウントの接続が解除されています', 'account_inactive' => 'ソーシャルアカウントが無効化されています', 'account_token_expired' => 'ソーシャルアカウントのセッションの有効期限が切れました — 再接続してください', + 'gbp_location_disconnected' => 'Google Business Profile location is disconnected — reconnect it to restore this target', 'platform_unavailable' => 'プラットフォームが一時的に利用できません。まもなく再試行します。', 'platform_unavailable_exhausted' => '何度か再試行しましたがプラットフォームが利用できませんでした。後でもう一度お試しください。', 'publishing_timed_out' => '公開がタイムアウトしました。もう一度お試しください。', diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index 48c2c06cc..b9a4a59d3 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Mastodon 계정을 연결하세요', 'telegram' => 'Telegram 채널 또는 그룹을 연결하세요', 'discord' => 'Discord 서버를 연결하세요', + 'google-business-profile' => '하나 이상의 Google 비즈니스 프로필 위치 연결', ], 'disconnect_modal' => [ @@ -90,6 +91,21 @@ 'choose' => '선택', ], + 'google_business_profile' => [ + 'title' => 'Google 비즈니스 프로필 위치 선택', + 'description' => 'TryPost에서 게시하고 분석할 모든 위치를 선택하세요.', + 'no_locations' => '위치를 찾을 수 없음', + 'no_locations_description' => '이 Google 계정은 비즈니스 프로필 위치를 관리하지 않습니다.', + 'store_code' => '매장 코드: :code', + 'save' => '선택한 위치 연결', + 'saving' => '연결 중...', + 'manage_locations' => 'Manage locations', + 'no_selected_locations' => 'No locations selected', + 'location_count' => ':count locations connected', + 'disconnect_title' => 'Disconnect Google Business Profile location', + 'disconnect_description' => 'This location will stop publishing. Pending draft and scheduled targets are kept, and reconnecting the location restores them as drafts for review.', + ], + 'instagram_facebook' => [ 'title' => 'Instagram 계정 선택', 'description' => '연결할 Instagram 계정을 선택하세요', @@ -131,6 +147,8 @@ 'deactivated' => '계정이 비활성화되었습니다!', 'already_connected' => '이 플랫폼은 이미 연결되어 있습니다.', 'no_youtube_channels' => 'YouTube 채널을 찾을 수 없습니다. 먼저 채널을 만드세요.', + 'google_business_profile_location_disconnected' => ':location was disconnected. Its pending draft and scheduled targets are preserved and can be restored by reconnecting the location.', + 'disconnect_google_business_profile_locations_individually' => 'Disconnect Google Business Profile locations individually so drafts and history stay attached to the correct business.', ], 'popup_callback' => [ @@ -138,6 +156,7 @@ 'title_error' => '오류', 'closing' => '이 창은 자동으로 닫힙니다...', 'manual_close' => '이 창을 닫아도 됩니다.', + 'return_to_accounts' => 'Return to Social Accounts', 'popup_blocked' => '연결 창을 열 수 없습니다. 팝업을 허용한 후 다시 시도하세요.', 'connected' => '계정이 연결되었습니다!', 'reconnected' => '계정이 다시 연결되었습니다!', @@ -162,5 +181,7 @@ 'no_facebook_instagram_pages' => 'Instagram 계정이 연결된 Facebook 페이지를 찾을 수 없습니다.', 'no_youtube_channels' => 'YouTube 채널을 찾을 수 없습니다. 먼저 채널을 만드세요.', 'not_linkedin_admin' => '관리자로 있는 LinkedIn 페이지가 없습니다.', + 'no_google_business_profile_locations' => '이 Google 계정에서 Google 비즈니스 프로필 위치를 찾을 수 없습니다.', + 'error_connecting_google_business_profile' => 'Google 비즈니스 프로필을 연결할 수 없습니다. 다시 시도하세요.', ], ]; diff --git a/lang/ko/posts.php b/lang/ko/posts.php index 2ec32c9a4..5806e303d 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -212,8 +212,11 @@ 'scheduled' => '예약됨', 'publishing' => '게시 중', 'retrying' => '재시도 중', + 'submitted' => 'Google에 제출됨', + 'pending_review' => 'Google 검토 대기 중', 'published' => '게시됨', 'partially_published' => '부분 게시됨', + 'rejected' => '거부됨', 'failed' => '실패', ], @@ -420,7 +423,10 @@ 'published' => '게시됨', 'publishing' => '게시 중...', 'retrying' => '재시도 중...', + 'submitted' => 'Google에 제출됨', + 'pending_review' => 'Google 검토 대기 중', 'failed' => '실패', + 'rejected' => '거부됨', ], 'delete_modal' => [ @@ -514,6 +520,22 @@ 'label' => 'Short', 'description' => '최대 3분 세로 동영상', ], + 'google_business_profile_standard' => [ + 'label' => '소식', + 'description' => '미디어와 클릭 유도 문구를 선택적으로 포함하는 비즈니스 소식', + ], + 'google_business_profile_event' => [ + 'label' => '이벤트', + 'description' => '미디어와 클릭 유도 문구를 선택적으로 포함하는 기간 지정 또는 반복 이벤트', + ], + 'google_business_profile_offer' => [ + 'label' => '혜택', + 'description' => '쿠폰과 사용 세부정보가 포함된 기간 지정 또는 반복 혜택', + ], + 'google_business_profile_alert' => [ + 'label' => '알림', + 'description' => 'Google에서 작성 기능을 제공하는 경우의 우선순위가 높은 알림', + ], 'x_post' => [ 'label' => '게시물', 'description' => '텍스트와 미디어가 있는 트윗', @@ -557,7 +579,9 @@ 'linkedin-page' => 'LinkedIn 페이지', 'x' => 'X', 'tiktok' => 'TikTok', + 'facebook' => 'Facebook 페이지', 'youtube' => 'YouTube Shorts', + 'google-business-profile' => 'Google 비즈니스 프로필', 'facebook' => 'Facebook 페이지', 'instagram' => 'Instagram', 'threads' => 'Threads', @@ -579,6 +603,7 @@ 'account_disconnected' => '소셜 계정 연결이 해제되었습니다', 'account_inactive' => '소셜 계정이 비활성화되었습니다', 'account_token_expired' => '소셜 계정 세션이 만료되었습니다 — 재연결하세요', + 'gbp_location_disconnected' => 'Google Business Profile location is disconnected — reconnect it to restore this target', 'platform_unavailable' => '플랫폼을 일시적으로 사용할 수 없습니다. 곧 다시 시도합니다.', 'platform_unavailable_exhausted' => '여러 번 재시도했지만 플랫폼을 사용할 수 없었습니다. 나중에 다시 시도하세요.', 'publishing_timed_out' => '게시에 시간이 초과되었습니다. 다시 시도하세요.', diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index 835ec17d1..5fbbccc57 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Koppel je Mastodon-account', 'telegram' => 'Koppel een Telegram-kanaal of -groep', 'discord' => 'Koppel een Discord-server', + 'google-business-profile' => 'Verbind een of meer Google-bedrijfsprofiellocaties', ], 'disconnect_modal' => [ @@ -90,6 +91,21 @@ 'choose' => 'Kiezen', ], + 'google_business_profile' => [ + 'title' => 'Google-bedrijfsprofiellocaties selecteren', + 'description' => 'Kies alle locaties waarop je via TryPost wilt publiceren en analyses wilt bekijken.', + 'no_locations' => 'Geen locaties gevonden', + 'no_locations_description' => 'Dit Google-account beheert geen bedrijfsprofiellocaties.', + 'store_code' => 'Winkelcode: :code', + 'save' => 'Geselecteerde locaties verbinden', + 'saving' => 'Verbinden...', + 'manage_locations' => 'Manage locations', + 'no_selected_locations' => 'No locations selected', + 'location_count' => ':count locations connected', + 'disconnect_title' => 'Disconnect Google Business Profile location', + 'disconnect_description' => 'This location will stop publishing. Pending draft and scheduled targets are kept, and reconnecting the location restores them as drafts for review.', + ], + 'instagram_facebook' => [ 'title' => 'Instagram-account selecteren', 'description' => 'Kies welk Instagram-account je wilt koppelen', @@ -131,6 +147,8 @@ 'deactivated' => 'Account gedeactiveerd!', 'already_connected' => 'Dit platform is al gekoppeld.', 'no_youtube_channels' => 'Geen YouTube-kanalen gevonden. Maak eerst een kanaal aan.', + 'google_business_profile_location_disconnected' => ':location was disconnected. Its pending draft and scheduled targets are preserved and can be restored by reconnecting the location.', + 'disconnect_google_business_profile_locations_individually' => 'Disconnect Google Business Profile locations individually so drafts and history stay attached to the correct business.', ], 'popup_callback' => [ @@ -138,6 +156,7 @@ 'title_error' => 'Fout', 'closing' => 'Dit venster wordt automatisch gesloten...', 'manual_close' => 'Je kunt dit venster sluiten.', + 'return_to_accounts' => 'Return to Social Accounts', 'popup_blocked' => 'Kon het koppelvenster niet openen. Sta pop-ups toe en probeer het opnieuw.', 'connected' => 'Account gekoppeld!', 'reconnected' => 'Account opnieuw gekoppeld!', @@ -162,5 +181,7 @@ '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_profile_locations' => 'Er zijn geen Google-bedrijfsprofiellocaties gevonden voor dit Google-account.', + 'error_connecting_google_business_profile' => 'Google Bedrijfsprofiel kon niet worden verbonden. Probeer het opnieuw.', ], ]; diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 2e06df707..dc43d0c11 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -212,8 +212,11 @@ 'scheduled' => 'Gepland', 'publishing' => 'Publiceren', 'retrying' => 'Opnieuw proberen', + 'submitted' => 'Ingediend bij Google', + 'pending_review' => 'In afwachting van Google-beoordeling', 'published' => 'Gepubliceerd', 'partially_published' => 'Gedeeltelijk gepubliceerd', + 'rejected' => 'Afgewezen', 'failed' => 'Mislukt', ], @@ -420,7 +423,10 @@ 'published' => 'Gepubliceerd', 'publishing' => 'Publiceren...', 'retrying' => 'Opnieuw proberen...', + 'submitted' => 'Ingediend bij Google', + 'pending_review' => 'In afwachting van Google-beoordeling', 'failed' => 'Mislukt', + 'rejected' => 'Afgewezen', ], 'delete_modal' => [ @@ -514,6 +520,22 @@ 'label' => 'Short', 'description' => 'Verticale video tot 3 minuten', ], + 'google_business_profile_standard' => [ + 'label' => 'Update', + 'description' => 'Bedrijfsupdate met optionele media en actieknop', + ], + 'google_business_profile_event' => [ + 'label' => 'Evenement', + 'description' => 'Tijdgebonden of terugkerend evenement met optionele media en actieknop', + ], + 'google_business_profile_offer' => [ + 'label' => 'Aanbieding', + 'description' => 'Tijdgebonden of terugkerende aanbieding met coupon- en inwisselgegevens', + ], + 'google_business_profile_alert' => [ + 'label' => 'Waarschuwing', + 'description' => 'Belangrijke waarschuwing wanneer Google het opstellen beschikbaar stelt', + ], 'x_post' => [ 'label' => 'Post', 'description' => 'Tweet met tekst en media', @@ -557,7 +579,9 @@ 'linkedin-page' => 'LinkedIn-pagina', 'x' => 'X', 'tiktok' => 'TikTok', + 'facebook' => 'Facebook-pagina', 'youtube' => 'YouTube Shorts', + 'google-business-profile' => 'Google Bedrijfsprofiel', 'facebook' => 'Facebook-pagina', 'instagram' => 'Instagram', 'threads' => 'Threads', @@ -579,6 +603,7 @@ 'account_disconnected' => 'Social account is losgekoppeld', 'account_inactive' => 'Social account is gedeactiveerd', 'account_token_expired' => 'Sessie van social account verlopen — koppel opnieuw', + 'gbp_location_disconnected' => 'Google Business Profile location is disconnected — reconnect it to restore this target', 'platform_unavailable' => 'Het platform is tijdelijk niet beschikbaar. We proberen het zo opnieuw.', 'platform_unavailable_exhausted' => 'Het platform bleef na meerdere pogingen niet beschikbaar. Probeer het later opnieuw.', 'publishing_timed_out' => 'Publiceren is timed-out. Probeer het opnieuw.', diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index 0d5b98a82..ee3b8e1fb 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Połącz swoje konto Mastodon', 'telegram' => 'Połącz kanał lub grupę na Telegramie', 'discord' => 'Połącz serwer Discord', + 'google-business-profile' => 'Połącz co najmniej jedną lokalizację Profilu Firmy w Google', ], 'disconnect_modal' => [ @@ -90,6 +91,21 @@ 'choose' => 'Wybierz', ], + 'google_business_profile' => [ + 'title' => 'Wybierz lokalizacje Profilu Firmy w Google', + 'description' => 'Wybierz wszystkie lokalizacje, w których chcesz publikować i analizować wyniki w TryPost.', + 'no_locations' => 'Nie znaleziono lokalizacji', + 'no_locations_description' => 'To konto Google nie zarządza żadnymi lokalizacjami Profilu Firmy.', + 'store_code' => 'Kod sklepu: :code', + 'save' => 'Połącz wybrane lokalizacje', + 'saving' => 'Łączenie...', + 'manage_locations' => 'Manage locations', + 'no_selected_locations' => 'No locations selected', + 'location_count' => ':count locations connected', + 'disconnect_title' => 'Disconnect Google Business Profile location', + 'disconnect_description' => 'This location will stop publishing. Pending draft and scheduled targets are kept, and reconnecting the location restores them as drafts for review.', + ], + 'instagram_facebook' => [ 'title' => 'Wybierz konto Instagram', 'description' => 'Wybierz konto Instagram, które chcesz połączyć', @@ -131,6 +147,8 @@ 'deactivated' => 'Konto dezaktywowane!', 'already_connected' => 'Ta platforma jest już połączona.', 'no_youtube_channels' => 'Nie znaleziono kanałów YouTube. Najpierw utwórz kanał.', + 'google_business_profile_location_disconnected' => ':location was disconnected. Its pending draft and scheduled targets are preserved and can be restored by reconnecting the location.', + 'disconnect_google_business_profile_locations_individually' => 'Disconnect Google Business Profile locations individually so drafts and history stay attached to the correct business.', ], 'popup_callback' => [ @@ -138,6 +156,7 @@ 'title_error' => 'Błąd', 'closing' => 'To okno zamknie się automatycznie...', 'manual_close' => 'Możesz zamknąć to okno.', + 'return_to_accounts' => 'Return to Social Accounts', 'popup_blocked' => 'Nie udało się otworzyć okna połączenia. Zezwól na wyskakujące okienka i spróbuj ponownie.', 'connected' => 'Konto połączone!', 'reconnected' => 'Konto połączone ponownie!', @@ -162,5 +181,7 @@ '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_profile_locations' => 'Nie znaleziono lokalizacji Profilu Firmy w Google dla tego konta Google.', + 'error_connecting_google_business_profile' => 'Nie udało się połączyć Profilu Firmy w Google. Spróbuj ponownie.', ], ]; diff --git a/lang/pl/posts.php b/lang/pl/posts.php index d47721c06..2d7624945 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -212,8 +212,11 @@ 'scheduled' => 'Zaplanowany', 'publishing' => 'Publikowanie', 'retrying' => 'Ponawianie', + 'submitted' => 'Przesłano do Google', + 'pending_review' => 'Oczekuje na weryfikację Google', 'published' => 'Opublikowany', 'partially_published' => 'Częściowo opublikowany', + 'rejected' => 'Odrzucono', 'failed' => 'Nieudany', ], @@ -420,7 +423,10 @@ 'published' => 'Opublikowany', 'publishing' => 'Publikowanie...', 'retrying' => 'Ponawianie...', + 'submitted' => 'Przesłano do Google', + 'pending_review' => 'Oczekuje na weryfikację Google', 'failed' => 'Nieudany', + 'rejected' => 'Odrzucono', ], 'delete_modal' => [ @@ -514,6 +520,22 @@ 'label' => 'Short', 'description' => 'Pionowy film do 3 minut', ], + 'google_business_profile_standard' => [ + 'label' => 'Aktualność', + 'description' => 'Aktualność firmy z opcjonalnymi multimediami i wezwaniem do działania', + ], + 'google_business_profile_event' => [ + 'label' => 'Wydarzenie', + 'description' => 'Wydarzenie czasowe lub cykliczne z opcjonalnymi multimediami i wezwaniem do działania', + ], + 'google_business_profile_offer' => [ + 'label' => 'Oferta', + 'description' => 'Oferta czasowa lub cykliczna z kuponem i informacjami o realizacji', + ], + 'google_business_profile_alert' => [ + 'label' => 'Alert', + 'description' => 'Alert o wysokim priorytecie, gdy Google umożliwia jego utworzenie', + ], 'x_post' => [ 'label' => 'Post', 'description' => 'Tweet z tekstem i multimediami', @@ -557,7 +579,9 @@ 'linkedin-page' => 'Strona LinkedIn', 'x' => 'X', 'tiktok' => 'TikTok', + 'facebook' => 'Strona na Facebooku', 'youtube' => 'YouTube Shorts', + 'google-business-profile' => 'Profil Firmy w Google', 'facebook' => 'Strona na Facebooku', 'instagram' => 'Instagram', 'threads' => 'Threads', @@ -579,6 +603,7 @@ 'account_disconnected' => 'Konto społecznościowe jest rozłączone', 'account_inactive' => 'Konto społecznościowe jest dezaktywowane', 'account_token_expired' => 'Sesja konta społecznościowego wygasła — połącz ponownie', + 'gbp_location_disconnected' => 'Google Business Profile location is disconnected — reconnect it to restore this target', 'platform_unavailable' => 'Platforma jest tymczasowo niedostępna. Spróbujemy ponownie wkrótce.', 'platform_unavailable_exhausted' => 'Platforma pozostała niedostępna po kilku próbach. Spróbuj ponownie później.', 'publishing_timed_out' => 'Publikowanie przekroczyło limit czasu. Spróbuj ponownie.', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index cb95d308d..fb0b8bf3f 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Conecte sua conta do Mastodon', 'telegram' => 'Conecte um canal ou grupo do Telegram', 'discord' => 'Conecte um servidor do Discord', + 'google-business-profile' => 'Conecte um ou mais locais do Perfil da Empresa no Google', ], 'disconnect_modal' => [ @@ -90,6 +91,21 @@ 'choose' => 'Escolher', ], + 'google_business_profile' => [ + 'title' => 'Selecione os locais do Perfil da Empresa no Google', + 'description' => 'Escolha todos os locais em que você deseja publicar e analisar no TryPost.', + 'no_locations' => 'Nenhum local encontrado', + 'no_locations_description' => 'Esta Conta do Google não gerencia nenhum local do Perfil da Empresa.', + 'store_code' => 'Código da loja: :code', + 'save' => 'Conectar locais selecionados', + 'saving' => 'Conectando...', + 'manage_locations' => 'Manage locations', + 'no_selected_locations' => 'No locations selected', + 'location_count' => ':count locations connected', + 'disconnect_title' => 'Disconnect Google Business Profile location', + 'disconnect_description' => 'This location will stop publishing. Pending draft and scheduled targets are kept, and reconnecting the location restores them as drafts for review.', + ], + 'instagram_facebook' => [ 'title' => 'Selecionar Conta do Instagram', 'description' => 'Escolha qual conta do Instagram você deseja conectar', @@ -131,6 +147,8 @@ 'deactivated' => 'Conta desativada!', 'already_connected' => 'Esta plataforma já está conectada.', 'no_youtube_channels' => 'Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.', + 'google_business_profile_location_disconnected' => ':location was disconnected. Its pending draft and scheduled targets are preserved and can be restored by reconnecting the location.', + 'disconnect_google_business_profile_locations_individually' => 'Disconnect Google Business Profile locations individually so drafts and history stay attached to the correct business.', ], 'popup_callback' => [ @@ -138,6 +156,7 @@ 'title_error' => 'Erro', 'closing' => 'Esta janela será fechada automaticamente...', 'manual_close' => 'Você pode fechar esta janela.', + 'return_to_accounts' => 'Return to Social Accounts', 'popup_blocked' => 'Não foi possível abrir a janela de conexão. Permita pop-ups e tente novamente.', 'connected' => 'Conta conectada!', 'reconnected' => 'Conta reconectada!', @@ -162,5 +181,7 @@ '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_profile_locations' => 'Nenhum local do Perfil da Empresa no Google foi encontrado para esta Conta do Google.', + 'error_connecting_google_business_profile' => 'Não foi possível conectar o Perfil da Empresa no Google. Tente novamente.', ], ]; diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 3e4c0a0ec..d844fc854 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -212,8 +212,11 @@ 'scheduled' => 'Agendado', 'publishing' => 'Publicando', 'retrying' => 'Tentando novamente', + 'submitted' => 'Enviado ao Google', + 'pending_review' => 'Aguardando análise do Google', 'published' => 'Publicado', 'partially_published' => 'Parcialmente Publicado', + 'rejected' => 'Rejeitado', 'failed' => 'Falhou', ], @@ -420,7 +423,10 @@ 'published' => 'Publicado', 'publishing' => 'Publicando...', 'retrying' => 'Tentando novamente...', + 'submitted' => 'Enviado ao Google', + 'pending_review' => 'Aguardando análise do Google', 'failed' => 'Falhou', + 'rejected' => 'Rejeitado', ], 'delete_modal' => [ @@ -514,6 +520,22 @@ 'label' => 'Short', 'description' => 'Vídeo vertical de até 3 minutos', ], + 'google_business_profile_standard' => [ + 'label' => 'Atualização', + 'description' => 'Atualização da empresa com mídia e chamada para ação opcionais', + ], + 'google_business_profile_event' => [ + 'label' => 'Evento', + 'description' => 'Evento com horário definido ou recorrente, com mídia e chamada para ação opcionais', + ], + 'google_business_profile_offer' => [ + 'label' => 'Oferta', + 'description' => 'Oferta com horário definido ou recorrente, com cupom e detalhes de resgate', + ], + 'google_business_profile_alert' => [ + 'label' => 'Alerta', + 'description' => 'Alerta de alta prioridade quando o Google permite a criação', + ], 'x_post' => [ 'label' => 'Post', 'description' => 'Tweet com texto e mídia', @@ -557,7 +579,9 @@ 'linkedin-page' => 'Página do LinkedIn', 'x' => 'X', 'tiktok' => 'TikTok', + 'facebook' => 'Página do Facebook', 'youtube' => 'YouTube Shorts', + 'google-business-profile' => 'Perfil da Empresa no Google', 'facebook' => 'Página do Facebook', 'instagram' => 'Instagram', 'threads' => 'Threads', @@ -579,6 +603,7 @@ 'account_disconnected' => 'Conta social está desconectada', 'account_inactive' => 'Conta social está desativada', 'account_token_expired' => 'Sessão da conta social expirou — reconecte a conta', + 'gbp_location_disconnected' => 'Google Business Profile location is disconnected — reconnect it to restore this target', 'platform_unavailable' => 'A plataforma está temporariamente indisponível. Vamos tentar de novo em breve.', 'platform_unavailable_exhausted' => 'A plataforma continuou indisponível após várias tentativas. Tente de novo mais tarde.', 'publishing_timed_out' => 'A publicação excedeu o tempo limite. Tente novamente.', diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index 32023ef44..877a18087 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Подключите аккаунт Mastodon', 'telegram' => 'Подключите канал или группу Telegram', 'discord' => 'Подключите сервер Discord', + 'google-business-profile' => 'Подключите одну или несколько точек профиля компании в Google', ], 'disconnect_modal' => [ @@ -90,6 +91,21 @@ 'choose' => 'Выбрать', ], + 'google_business_profile' => [ + 'title' => 'Выберите точки профиля компании в Google', + 'description' => 'Выберите все точки, в которых хотите публиковать материалы и просматривать аналитику в TryPost.', + 'no_locations' => 'Точки не найдены', + 'no_locations_description' => 'Этот аккаунт Google не управляет точками профиля компании.', + 'store_code' => 'Код магазина: :code', + 'save' => 'Подключить выбранные точки', + 'saving' => 'Подключение...', + 'manage_locations' => 'Manage locations', + 'no_selected_locations' => 'No locations selected', + 'location_count' => ':count locations connected', + 'disconnect_title' => 'Disconnect Google Business Profile location', + 'disconnect_description' => 'This location will stop publishing. Pending draft and scheduled targets are kept, and reconnecting the location restores them as drafts for review.', + ], + 'instagram_facebook' => [ 'title' => 'Выберите аккаунт Instagram', 'description' => 'Выберите аккаунт Instagram для подключения', @@ -131,6 +147,8 @@ 'deactivated' => 'Аккаунт деактивирован!', 'already_connected' => 'Эта платформа уже подключена.', 'no_youtube_channels' => 'Каналы YouTube не найдены. Сначала создайте канал.', + 'google_business_profile_location_disconnected' => ':location was disconnected. Its pending draft and scheduled targets are preserved and can be restored by reconnecting the location.', + 'disconnect_google_business_profile_locations_individually' => 'Disconnect Google Business Profile locations individually so drafts and history stay attached to the correct business.', ], 'popup_callback' => [ @@ -138,6 +156,7 @@ 'title_error' => 'Ошибка', 'closing' => 'Это окно закроется автоматически...', 'manual_close' => 'Вы можете закрыть это окно.', + 'return_to_accounts' => 'Return to Social Accounts', 'popup_blocked' => 'Не удалось открыть окно подключения. Разрешите всплывающие окна и попробуйте ещё раз.', 'connected' => 'Аккаунт подключён!', 'reconnected' => 'Аккаунт переподключён!', @@ -162,5 +181,7 @@ 'no_facebook_instagram_pages' => 'Не найдено страниц Facebook со связанными аккаунтами Instagram.', 'no_youtube_channels' => 'Каналы YouTube не найдены. Сначала создайте канал.', 'not_linkedin_admin' => 'Вы не являетесь администратором ни одной страницы LinkedIn.', + 'no_google_business_profile_locations' => 'Для этого аккаунта Google не найдено точек профиля компании.', + 'error_connecting_google_business_profile' => 'Не удалось подключить профиль компании в Google. Повторите попытку.', ], ]; diff --git a/lang/ru/posts.php b/lang/ru/posts.php index 19439375f..fdfa743bb 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -212,8 +212,11 @@ 'scheduled' => 'Запланирован', 'publishing' => 'Публикуется', 'retrying' => 'Повторная попытка', + 'submitted' => 'Отправлено в Google', + 'pending_review' => 'Ожидает проверки Google', 'published' => 'Опубликован', 'partially_published' => 'Частично опубликован', + 'rejected' => 'Отклонено', 'failed' => 'Ошибка', ], @@ -420,7 +423,10 @@ 'published' => 'Опубликован', 'publishing' => 'Публикация...', 'retrying' => 'Повторная попытка...', + 'submitted' => 'Отправлено в Google', + 'pending_review' => 'Ожидает проверки Google', 'failed' => 'Ошибка', + 'rejected' => 'Отклонено', ], 'delete_modal' => [ @@ -514,6 +520,22 @@ 'label' => 'Short', 'description' => 'Вертикальное видео до 3 минут', ], + 'google_business_profile_standard' => [ + 'label' => 'Новость', + 'description' => 'Новость компании с необязательными медиафайлами и призывом к действию', + ], + 'google_business_profile_event' => [ + 'label' => 'Мероприятие', + 'description' => 'Ограниченное по времени или повторяющееся мероприятие с необязательными медиафайлами и призывом к действию', + ], + 'google_business_profile_offer' => [ + 'label' => 'Предложение', + 'description' => 'Ограниченное по времени или повторяющееся предложение с купоном и условиями использования', + ], + 'google_business_profile_alert' => [ + 'label' => 'Оповещение', + 'description' => 'Важное оповещение, когда Google разрешает его создание', + ], 'x_post' => [ 'label' => 'Пост', 'description' => 'Твит с текстом и медиа', @@ -557,7 +579,9 @@ 'linkedin-page' => 'Страница LinkedIn', 'x' => 'X', 'tiktok' => 'TikTok', + 'facebook' => 'Страница Facebook', 'youtube' => 'YouTube Shorts', + 'google-business-profile' => 'Профиль компании в Google', 'facebook' => 'Страница Facebook', 'instagram' => 'Instagram', 'threads' => 'Threads', @@ -579,6 +603,7 @@ 'account_disconnected' => 'Социальный аккаунт отключён', 'account_inactive' => 'Социальный аккаунт деактивирован', 'account_token_expired' => 'Сессия социального аккаунта истекла — переподключите', + 'gbp_location_disconnected' => 'Google Business Profile location is disconnected — reconnect it to restore this target', 'platform_unavailable' => 'Платформа временно недоступна. Мы повторим попытку вскоре.', 'platform_unavailable_exhausted' => 'Платформа оставалась недоступной после нескольких попыток. Попробуйте позже.', 'publishing_timed_out' => 'Публикация превысила время ожидания. Попробуйте снова.', diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index dafacd2ff..4fb4576c3 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -32,6 +32,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-profile' => 'Bir veya daha fazla Google İşletme Profili konumu bağlayın', ], 'disconnect_modal' => [ @@ -92,6 +93,21 @@ 'choose' => 'Seç', ], + 'google_business_profile' => [ + 'title' => 'Google İşletme Profili konumlarını seçin', + 'description' => 'TryPost’ta yayınlamak ve analiz etmek istediğiniz tüm konumları seçin.', + 'no_locations' => 'Konum bulunamadı', + 'no_locations_description' => 'Bu Google hesabı hiçbir İşletme Profili konumunu yönetmiyor.', + 'store_code' => 'Mağaza kodu: :code', + 'save' => 'Seçili konumları bağla', + 'saving' => 'Bağlanıyor...', + 'manage_locations' => 'Manage locations', + 'no_selected_locations' => 'No locations selected', + 'location_count' => ':count locations connected', + 'disconnect_title' => 'Disconnect Google Business Profile location', + 'disconnect_description' => 'This location will stop publishing. Pending draft and scheduled targets are kept, and reconnecting the location restores them as drafts for review.', + ], + 'instagram_facebook' => [ 'title' => 'Instagram Hesabı Seç', 'description' => 'Bağlamak istediğiniz Instagram hesabını seçin', @@ -133,6 +149,8 @@ 'deactivated' => 'Hesap devre dışı bırakıldı!', 'already_connected' => 'Bu platform zaten bağlı.', 'no_youtube_channels' => 'YouTube kanalı bulunamadı. Lütfen önce bir kanal oluşturun.', + 'google_business_profile_location_disconnected' => ':location was disconnected. Its pending draft and scheduled targets are preserved and can be restored by reconnecting the location.', + 'disconnect_google_business_profile_locations_individually' => 'Disconnect Google Business Profile locations individually so drafts and history stay attached to the correct business.', ], 'popup_callback' => [ @@ -140,6 +158,7 @@ 'title_error' => 'Hata', 'closing' => 'Bu pencere otomatik olarak kapanacak...', 'manual_close' => 'Bu pencereyi kapatabilirsiniz.', + 'return_to_accounts' => 'Return to Social Accounts', 'popup_blocked' => 'Bağlantı penceresi açılamadı. Lütfen açılır pencerelere izin verip tekrar deneyin.', 'connected' => 'Hesap bağlandı!', 'reconnected' => 'Hesap yeniden bağlandı!', @@ -164,5 +183,7 @@ '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_profile_locations' => 'Bu Google hesabı için Google İşletme Profili konumu bulunamadı.', + 'error_connecting_google_business_profile' => 'Google İşletme Profili bağlanamadı. Lütfen tekrar deneyin.', ], ]; diff --git a/lang/tr/posts.php b/lang/tr/posts.php index f19d6c8a1..5eca01b36 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -214,8 +214,11 @@ 'scheduled' => 'Zamanlandı', 'publishing' => 'Yayınlanıyor', 'retrying' => 'Yeniden deneniyor', + 'submitted' => 'Google’a gönderildi', + 'pending_review' => 'Google incelemesi bekleniyor', 'published' => 'Yayınlandı', 'partially_published' => 'Kısmen Yayınlandı', + 'rejected' => 'Reddedildi', 'failed' => 'Başarısız', ], @@ -422,7 +425,10 @@ 'published' => 'Yayınlandı', 'publishing' => 'Yayınlanıyor...', 'retrying' => 'Yeniden deneniyor...', + 'submitted' => 'Google’a gönderildi', + 'pending_review' => 'Google incelemesi bekleniyor', 'failed' => 'Başarısız', + 'rejected' => 'Reddedildi', ], 'delete_modal' => [ @@ -516,6 +522,22 @@ 'label' => 'Short', 'description' => '3 dakikaya kadar dikey video', ], + 'google_business_profile_standard' => [ + 'label' => 'Güncelleme', + 'description' => 'İsteğe bağlı medya ve harekete geçirici mesaj içeren işletme güncellemesi', + ], + 'google_business_profile_event' => [ + 'label' => 'Etkinlik', + 'description' => 'İsteğe bağlı medya ve harekete geçirici mesaj içeren zamanlanmış veya yinelenen etkinlik', + ], + 'google_business_profile_offer' => [ + 'label' => 'Teklif', + 'description' => 'Kupon ve kullanım ayrıntıları içeren zamanlanmış veya yinelenen teklif', + ], + 'google_business_profile_alert' => [ + 'label' => 'Uyarı', + 'description' => 'Google’ın yazmaya izin verdiği yüksek öncelikli uyarı', + ], 'x_post' => [ 'label' => 'Gönderi', 'description' => 'Metin ve medya içeren tweet', @@ -559,7 +581,9 @@ 'linkedin-page' => 'LinkedIn Sayfası', 'x' => 'X', 'tiktok' => 'TikTok', + 'facebook' => 'Facebook Sayfası', 'youtube' => 'YouTube Shorts', + 'google-business-profile' => 'Google İşletme Profili', 'facebook' => 'Facebook Sayfası', 'instagram' => 'Instagram', 'threads' => 'Threads', @@ -581,6 +605,7 @@ 'account_disconnected' => 'Sosyal hesabın bağlantısı kesildi', 'account_inactive' => 'Sosyal hesap devre dışı bırakıldı', 'account_token_expired' => 'Sosyal hesap oturumunun süresi doldu — lütfen yeniden bağlanın', + 'gbp_location_disconnected' => 'Google Business Profile location is disconnected — reconnect it to restore this target', 'platform_unavailable' => 'Platform geçici olarak kullanılamıyor. Kısa süre içinde yeniden deneyeceğiz.', 'platform_unavailable_exhausted' => 'Platform birkaç denemeden sonra kullanılamaz kaldı. Lütfen daha sonra tekrar deneyin.', 'publishing_timed_out' => 'Yayınlama zaman aşımına uğradı. Lütfen tekrar deneyin.', diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index 20b510076..1c2225395 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Підключіть акаунт Mastodon', 'telegram' => 'Підключіть канал або групу Telegram', 'discord' => 'Підключіть сервер Discord', + 'google-business-profile' => 'Підключіть одне або кілька місцеположень профілю компанії в Google', ], 'disconnect_modal' => [ @@ -90,6 +91,21 @@ 'choose' => 'Вибрати', ], + 'google_business_profile' => [ + 'title' => 'Виберіть місцеположення профілю компанії в Google', + 'description' => 'Виберіть усі місцеположення, де хочете публікувати дописи й переглядати аналітику в TryPost.', + 'no_locations' => 'Місцеположень не знайдено', + 'no_locations_description' => 'Цей обліковий запис Google не керує жодним місцеположенням профілю компанії.', + 'store_code' => 'Код магазину: :code', + 'save' => 'Підключити вибрані місцеположення', + 'saving' => 'Підключення...', + 'manage_locations' => 'Manage locations', + 'no_selected_locations' => 'No locations selected', + 'location_count' => ':count locations connected', + 'disconnect_title' => 'Disconnect Google Business Profile location', + 'disconnect_description' => 'This location will stop publishing. Pending draft and scheduled targets are kept, and reconnecting the location restores them as drafts for review.', + ], + 'instagram_facebook' => [ 'title' => 'Виберіть акаунт Instagram', 'description' => 'Виберіть акаунт Instagram, який хочете підключити', @@ -131,6 +147,8 @@ 'deactivated' => 'Акаунт деактивовано!', 'already_connected' => 'Ця платформа вже підключена.', 'no_youtube_channels' => 'Каналів YouTube не знайдено. Спочатку створіть канал.', + 'google_business_profile_location_disconnected' => ':location was disconnected. Its pending draft and scheduled targets are preserved and can be restored by reconnecting the location.', + 'disconnect_google_business_profile_locations_individually' => 'Disconnect Google Business Profile locations individually so drafts and history stay attached to the correct business.', ], 'popup_callback' => [ @@ -138,6 +156,7 @@ 'title_error' => 'Помилка', 'closing' => 'Це вікно закриється автоматично...', 'manual_close' => 'Ви можете закрити це вікно.', + 'return_to_accounts' => 'Return to Social Accounts', 'popup_blocked' => 'Не вдалося відкрити вікно підключення. Дозвольте спливаючі вікна і спробуйте ще раз.', 'connected' => 'Акаунт підключено!', 'reconnected' => 'Акаунт перепідключено!', @@ -162,5 +181,7 @@ 'no_facebook_instagram_pages' => 'Не знайдено сторінок Facebook із підключеними акаунтами Instagram.', 'no_youtube_channels' => 'Каналів YouTube не знайдено. Спочатку створіть канал.', 'not_linkedin_admin' => 'Ви не є адміністратором жодної сторінки LinkedIn.', + 'no_google_business_profile_locations' => 'Для цього облікового запису Google не знайдено місцеположень профілю компанії.', + 'error_connecting_google_business_profile' => 'Не вдалося підключити профіль компанії в Google. Спробуйте ще раз.', ], ]; diff --git a/lang/uk/posts.php b/lang/uk/posts.php index bcd9b2505..02841510e 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -212,8 +212,11 @@ 'scheduled' => 'Заплановано', 'publishing' => 'Публікується', 'retrying' => 'Повторна спроба', + 'submitted' => 'Надіслано в Google', + 'pending_review' => 'Очікує на перевірку Google', 'published' => 'Опубліковано', 'partially_published' => 'Частково опубліковано', + 'rejected' => 'Відхилено', 'failed' => 'Помилка', ], @@ -420,7 +423,10 @@ 'published' => 'Опубліковано', 'publishing' => 'Публікується...', 'retrying' => 'Повторна спроба...', + 'submitted' => 'Надіслано в Google', + 'pending_review' => 'Очікує на перевірку Google', 'failed' => 'Помилка', + 'rejected' => 'Відхилено', ], 'delete_modal' => [ @@ -514,6 +520,22 @@ 'label' => 'Short', 'description' => 'Вертикальне відео до 3 хвилин', ], + 'google_business_profile_standard' => [ + 'label' => 'Оновлення', + 'description' => 'Оновлення компанії з необов’язковими медіафайлами та закликом до дії', + ], + 'google_business_profile_event' => [ + 'label' => 'Подія', + 'description' => 'Подія з визначеним часом або повторенням, необов’язковими медіафайлами та закликом до дії', + ], + 'google_business_profile_offer' => [ + 'label' => 'Пропозиція', + 'description' => 'Пропозиція з визначеним часом або повторенням, купоном і деталями використання', + ], + 'google_business_profile_alert' => [ + 'label' => 'Сповіщення', + 'description' => 'Важливе сповіщення, коли Google дозволяє його створення', + ], 'x_post' => [ 'label' => 'Пост', 'description' => 'Твіт із текстом та медіа', @@ -557,7 +579,9 @@ 'linkedin-page' => 'Сторінка LinkedIn', 'x' => 'X', 'tiktok' => 'TikTok', + 'facebook' => 'Сторінка Facebook', 'youtube' => 'YouTube Shorts', + 'google-business-profile' => 'Профіль компанії в Google', 'facebook' => 'Сторінка Facebook', 'instagram' => 'Instagram', 'threads' => 'Threads', @@ -579,6 +603,7 @@ 'account_disconnected' => 'Соціальний акаунт відключено', 'account_inactive' => 'Соціальний акаунт деактивовано', 'account_token_expired' => 'Сесія соціального акаунта закінчилася — перепідключіть', + 'gbp_location_disconnected' => 'Google Business Profile location is disconnected — reconnect it to restore this target', 'platform_unavailable' => 'Платформа тимчасово недоступна. Ми спробуємо знову незабаром.', 'platform_unavailable_exhausted' => 'Платформа залишалася недоступною після кількох спроб. Спробуйте пізніше.', 'publishing_timed_out' => 'Публікація перевищила час очікування. Спробуйте ще раз.', diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index bae4ccdc5..4881bda64 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => '连接你的 Mastodon 账号', 'telegram' => '连接一个 Telegram 频道或群组', 'discord' => '连接一个 Discord 服务器', + 'google-business-profile' => '连接一个或多个 Google 商家资料地点', ], 'disconnect_modal' => [ @@ -90,6 +91,21 @@ 'choose' => '选择', ], + 'google_business_profile' => [ + 'title' => '选择 Google 商家资料地点', + 'description' => '选择要在 TryPost 中发布内容和查看分析数据的所有地点。', + 'no_locations' => '未找到地点', + 'no_locations_description' => '此 Google 账号未管理任何商家资料地点。', + 'store_code' => '门店代码::code', + 'save' => '连接所选地点', + 'saving' => '正在连接...', + 'manage_locations' => 'Manage locations', + 'no_selected_locations' => 'No locations selected', + 'location_count' => ':count locations connected', + 'disconnect_title' => 'Disconnect Google Business Profile location', + 'disconnect_description' => 'This location will stop publishing. Pending draft and scheduled targets are kept, and reconnecting the location restores them as drafts for review.', + ], + 'instagram_facebook' => [ 'title' => '选择 Instagram 账号', 'description' => '选择你要连接的 Instagram 账号', @@ -131,6 +147,8 @@ 'deactivated' => '账号已停用!', 'already_connected' => '此平台已连接。', 'no_youtube_channels' => '未找到 YouTube 频道,请先创建一个频道。', + 'google_business_profile_location_disconnected' => ':location was disconnected. Its pending draft and scheduled targets are preserved and can be restored by reconnecting the location.', + 'disconnect_google_business_profile_locations_individually' => 'Disconnect Google Business Profile locations individually so drafts and history stay attached to the correct business.', ], 'popup_callback' => [ @@ -138,6 +156,7 @@ 'title_error' => '错误', 'closing' => '此窗口将自动关闭…', 'manual_close' => '你可以关闭此窗口。', + 'return_to_accounts' => 'Return to Social Accounts', 'popup_blocked' => '无法打开连接窗口。请允许弹出窗口后重试。', 'connected' => '账号已连接!', 'reconnected' => '账号已重新连接!', @@ -162,5 +181,7 @@ 'no_facebook_instagram_pages' => '未找到关联了 Instagram 账号的 Facebook 主页。', 'no_youtube_channels' => '未找到 YouTube 频道,请先创建一个频道。', 'not_linkedin_admin' => '你不是任何 LinkedIn 页面的管理员。', + 'no_google_business_profile_locations' => '未找到此 Google 账号的 Google 商家资料地点。', + 'error_connecting_google_business_profile' => '无法连接 Google 商家资料,请重试。', ], ]; diff --git a/lang/zh/posts.php b/lang/zh/posts.php index af5dc2750..5683bf667 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -212,8 +212,11 @@ 'scheduled' => '已排期', 'publishing' => '发布中', 'retrying' => '重试中', + 'submitted' => '已提交至 Google', + 'pending_review' => '等待 Google 审核', 'published' => '已发布', 'partially_published' => '部分发布', + 'rejected' => '已拒绝', 'failed' => '已失败', ], @@ -420,7 +423,10 @@ 'published' => '已发布', 'publishing' => '发布中…', 'retrying' => '重试中…', + 'submitted' => '已提交至 Google', + 'pending_review' => '等待 Google 审核', 'failed' => '已失败', + 'rejected' => '已拒绝', ], 'delete_modal' => [ @@ -514,6 +520,22 @@ 'label' => 'Short', 'description' => '最长 3 分钟的竖版视频', ], + 'google_business_profile_standard' => [ + 'label' => '动态', + 'description' => '可选择添加媒体和号召性用语的商家动态', + ], + 'google_business_profile_event' => [ + 'label' => '活动', + 'description' => '可选择添加媒体和号召性用语的限时或周期性活动', + ], + 'google_business_profile_offer' => [ + 'label' => '优惠', + 'description' => '包含优惠券和兑换详情的限时或周期性优惠', + ], + 'google_business_profile_alert' => [ + 'label' => '提醒', + 'description' => 'Google 开放创建功能时可发布的高优先级提醒', + ], 'x_post' => [ 'label' => '帖子', 'description' => '带文字和媒体的推文', @@ -557,7 +579,9 @@ 'linkedin-page' => 'LinkedIn 页面', 'x' => 'X', 'tiktok' => 'TikTok', + 'facebook' => 'Facebook 主页', 'youtube' => 'YouTube Shorts', + 'google-business-profile' => 'Google 商家资料', 'facebook' => 'Facebook 主页', 'instagram' => 'Instagram', 'threads' => 'Threads', @@ -579,6 +603,7 @@ 'account_disconnected' => '社交账号已断开连接', 'account_inactive' => '社交账号已停用', 'account_token_expired' => '社交账号会话已过期——请重新连接', + 'gbp_location_disconnected' => 'Google Business Profile location is disconnected — reconnect it to restore this target', 'platform_unavailable' => '平台暂时不可用。我们稍后会重试。', 'platform_unavailable_exhausted' => '多次重试后平台仍不可用。请稍后再试。', 'publishing_timed_out' => '发布超时。请重试。', diff --git a/public/images/accounts/google-business-profile.svg b/public/images/accounts/google-business-profile.svg new file mode 100644 index 000000000..05317bc4d --- /dev/null +++ b/public/images/accounts/google-business-profile.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue index ce3d00e87..094fb3e0a 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -4,32 +4,44 @@ import { computed } from 'vue'; import DiscordSettings from '@/components/posts/editor/DiscordSettings.vue'; import FacebookSettings from '@/components/posts/editor/FacebookSettings.vue'; +import GoogleBusinessProfileSettings from '@/components/posts/editor/GoogleBusinessProfileSettings.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'; import TikTokSettings from '@/components/posts/editor/TikTokSettings.vue'; import { Avatar } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { + getPlatformLabel, + getPlatformLogo, +} from '@/composables/usePlatformLogo'; import type { Channel } from '@/types/channel'; import type { MediaItem } from '@/types/media'; import { Platform } from '@/types/platform'; import { PostPlatformStatus } from '@/types/post'; -const props = withDefaults(defineProps<{ - channels: Channel[]; - selectedIds: string[]; - media?: MediaItem[]; - videoDurationSec?: number | null; - disabled?: boolean; - previewOnly?: boolean; -}>(), { - media: () => [], - videoDurationSec: null, - disabled: false, - previewOnly: false, -}); +const props = withDefaults( + defineProps<{ + channels: Channel[]; + selectedIds: string[]; + media?: MediaItem[]; + videoDurationSec?: number | null; + disabled?: boolean; + previewOnly?: boolean; + }>(), + { + media: () => [], + videoDurationSec: null, + disabled: false, + previewOnly: false, + }, +); const emit = defineEmits<{ toggle: [id: string]; @@ -39,24 +51,39 @@ const emit = defineEmits<{ const isSelected = (id: string): boolean => props.selectedIds.includes(id); -const selectedChannels = computed(() => props.channels.filter((channel) => isSelected(channel.id))); +const selectedChannels = computed(() => + props.channels.filter((channel) => isSelected(channel.id)), +);