diff --git a/.ai/rules/auth.md b/.ai/rules/auth.md new file mode 100644 index 000000000..3180d9fd1 --- /dev/null +++ b/.ai/rules/auth.md @@ -0,0 +1,12 @@ +--- +paths: + - app/Http/Controllers/Auth/GoogleBusinessController.php +--- + +# Auth + +## Clear google_business_oauth on every terminal connect path +google_business_oauth holds access and refresh tokens until the user picks a location. Forget it on every terminal select() exit, including the generic Exception catch, and in ConnectPopupException::render() alongside social_connect_workspace. Leaving it after uploadFromUrl / fetchLocationPhoto / connectIdentity failures leaks tokens into the session. On reconnect, keep the existing refresh_token when Google omits a new one. + +## Forget google_business_oauth on every popupCallback +Override popupCallback to forgetOauthSession() so single-location success and every error path drop the token bag. Do not forget before the multi-location redirect to the picker — that is the only request that still needs it. select() finally and ConnectPopupException::render() also forget; a second forget is fine. diff --git a/.ai/rules/google-business.md b/.ai/rules/google-business.md new file mode 100644 index 000000000..c83ffef0f --- /dev/null +++ b/.ai/rules/google-business.md @@ -0,0 +1,30 @@ +--- +paths: + - 'app/Enums/GoogleBusiness/**' + - app/Jobs/PublishToSocialPlatform.php + - app/Jobs/ReconcileGoogleBusinessPost.php + - app/Console/Commands/ReconcileGoogleBusinessPosts.php + - app/Services/Social/GoogleBusinessPublisher.php + - app/Support/PostPlatformMetaRules.php + - app/Services/Social/GoogleBusinessAnalytics.php +--- + +# Google Business + +## GBP Local Posts v4 values are enums +LocalPost.state, topicType, and callToAction.actionType live on App\Enums\GoogleBusiness\{LocalPostState,TopicType,CtaAction}. Compare cases / helpers (isPendingReview, isLive, requiresEvent, allowsCallToAction, requiresUrl) — never raw PROCESSING/SCHEDULED/STANDARD/OFFER strings. Do not ship Google-deprecated Local Post values: no GET_OFFER CTA, no ALERT/COVID_19 topic, no PRODUCT topic, no localPosts.reportInsights. Missing state defaults to Processing; Unspecified (LOCAL_POST_STATE_UNSPECIFIED) is pending review, not published. Missing topic defaults to Standard. Rejected targets have no retry — the user must duplicate the post. The Show/Edit publishing overlay must use isActivelyPublishing (in-flight platforms only); pending_review keeps post.status=publishing and would hide the page for up to 24h. + +## GBP publish result uses tryFrom not fromApi +PublishToSocialPlatform::recordPublishResult must use LocalPostState::tryFrom, never fromApi. Other publishers omit `state`; fromApi(null) is Processing and would park LinkedIn/X/… in pending review. The GBP publisher always returns `$state->value` after fromApi, so the job only sees a known case. + +## GBP event title is 58 characters, coupon is not +LocalPost event.title is capped at TopicType::TITLE_MAX_LENGTH (58) — Google returns Must be at most 58 characters even though the v4 schema page omits the limit. Do not reuse 58 on offer.coupon_code / terms; those have no published cap. Mirror the title cap in resources/js/types/google-business.ts as GOOGLE_BUSINESS_EVENT_TITLE_MAX. requiredMetaViolation() must reject a stored title over 58 — MCP PublishPostTool never re-runs rules(). Do not apply the cap to leftover titles on STANDARD. + +## GBP reconcile refreshes on 401 +ReconcileGoogleBusinessPost must call ConnectionVerifier::verify() after a TokenExpiredException from fetchLocalPost and retry the GET once. If refresh also fails, mark the SocialAccount token-expired, then deferOrGiveUp. Do not park a 401 in pending_review for 24h while a refresh would settle it. RecoverStuckPosts times the 24h ceiling from submitted_at only — never created_at. + +## GBP analytics must not cache a failed fetch +GoogleBusinessAnalytics caches only a successful array. An HTTP failure or a missing location returns false and is not written to cache — a 500 must not blank the dashboard for an hour. Publish/verify/analytics require both location_id (v4) and location_name (v1) via GoogleBusinessResourceName::connectedLocation(). + +## Local Posts 401 after a live BI verify does not expire the account +retryAfterExpiredToken calls ConnectionVerifier::verify() then retries fetchRemote. markAsTokenExpired only when verify() itself throws TokenExpiredException. If BI verify succeeds and Local Posts still 401s, deferOrGiveUp without disconnecting — the token still works for the house verify. ReconcileGoogleBusinessPosts only dispatches enabled() pending_review rows. ReconcileGoogleBusinessPost::handle() giveUps immediately when socialAccount is null — disconnect nulls the FK and fetchRemote would TypeError until the 24h ceiling. diff --git a/.ai/rules/index.md b/.ai/rules/index.md new file mode 100644 index 000000000..5832afebe --- /dev/null +++ b/.ai/rules/index.md @@ -0,0 +1,13 @@ +# Project Rules Index + +Before planning or editing, find the row whose globs match the file's path and read that rule file. + +| Applies to | Rule file | +| --- | --- | +| app/Http/Controllers/Auth/GoogleBusinessController.php | .ai/rules/auth.md | +| app/Enums/GoogleBusiness/**, app/Jobs/PublishToSocialPlatform.php, app/Jobs/ReconcileGoogleBusinessPost.php, app/Console/Commands/ReconcileGoogleBusinessPosts.php, app/Services/Social/GoogleBusinessPublisher.php, app/Support/PostPlatformMetaRules.php, app/Services/Social/GoogleBusinessAnalytics.php | .ai/rules/google-business.md | +| app/Jobs/ReconcileGoogleBusinessPost.php, app/Console/Commands/RecoverStuckPosts.php | .ai/rules/jobs.md | +| app/Actions/Post/FinalizePostPublication.php, app/Jobs/PublishPost.php, app/Actions/Post/UpdatePost.php | .ai/rules/post.md | +| app/Enums/SocialAccount/Platform.php | .ai/rules/social-account.md | +| app/Services/Social/GoogleBusinessPublisher.php, app/Support/Social/GoogleBusinessDerivativeCleaner.php, app/Actions/Post/DeletePost.php, app/Actions/Post/UpdatePost.php, app/Support/Social/AbandonGoogleBusinessReview.php, app/Actions/Workspace/PurgeWorkspace.php, app/Http/Controllers/Auth/SocialController.php | .ai/rules/social.md | +| app/Support/PostPlatformMetaRules.php | .ai/rules/support.md | diff --git a/.ai/rules/jobs.md b/.ai/rules/jobs.md new file mode 100644 index 000000000..bcf2d94bb --- /dev/null +++ b/.ai/rules/jobs.md @@ -0,0 +1,10 @@ +--- +paths: + - app/Jobs/ReconcileGoogleBusinessPost.php + - app/Console/Commands/RecoverStuckPosts.php +--- + +# Jobs + +## GBP reconcile must settle permanent API errors +ReconcileGoogleBusinessPost must not rethrow GoogleBusinessPublishException. Permanent categories (NOT_FOUND, PERMISSION_DENIED, INVALID_ARGUMENT, Unknown) reject the target immediately and call FinalizePostPublication. Only ServerError, RateLimit, and ConnectionException defer until REVIEW_CEILING_HOURS. Throwing leaves last_reconciled_at stale, the 5-minute sweep re-dispatches, RecoverStuckPosts treats PendingReview as still-active, and the post sticks forever. failed() is the safety net if the worker dies mid-review — it must deferOrGiveUp (respect the 24h ceiling), never giveUp immediately. Job $timeout must exceed HasSocialHttpClient's 120s HTTP timeout. RecoverStuckPosts may only fail PendingReview after the same 24h ceiling timed from submitted_at — never created_at (a scheduled draft can be days old before it enters review) and never the 1h publishing timeout. Reconcile must refresh-and-retry on TokenExpiredException the same way PublishToSocialPlatform does; if verify() itself dies, mark the social account token-expired before deferring. If Business Information verify succeeds and Local Posts still 401s, defer without markAsTokenExpired — the token is still valid for the house verify. A 401 must not sit in review for 24h while a refresh would have settled it. JPEG derivatives stay on disk while LocalPostState is Processing/Scheduled so Google can fetch sourceUrl; settle(), RecoverStuckPosts' 1h publishing timeout, a disabled GBP target, and the expired-review path prune them. Reconcile must giveUp immediately when socialAccount is null (disconnect nulls the FK and would TypeError in fetchRemote). When RecoverStuckPosts finishes a post (no still-active targets), call FinalizePostPublication so the owner is notified — do not mark the post Failed/Published by hand. Never compare raw PROCESSING/SCHEDULED/LIVE strings — use App\Enums\GoogleBusiness\LocalPostState. diff --git a/.ai/rules/post.md b/.ai/rules/post.md new file mode 100644 index 000000000..4686f2c31 --- /dev/null +++ b/.ai/rules/post.md @@ -0,0 +1,20 @@ +--- +paths: + - app/Actions/Post/FinalizePostPublication.php + - app/Jobs/PublishPost.php + - app/Actions/Post/UpdatePost.php +--- + +# Post + +## FinalizePostPublication is the only post settler +handle() takes the Post, not a dummy PostPlatform. Every path that can finish the last enabled target must call it: PublishToSocialPlatform, ReconcileGoogleBusinessPost, RecoverStuckPosts, PublishPost::failed, and AbandonGoogleBusinessReview (disconnect / disable during pending_review). No enabled targets on a draft or scheduled post is a no-op — do not mark the post published. A Publishing post with no enabled targets is abandoned in-flight: mark it Failed so it does not sit non-editable forever. Do not mark the post Published / PartiallyPublished / Failed by hand outside Finalize. + +## In-app publish notice uses owner locale +SendNotification title/body are stored already-resolved. Resolve them through lang/*/notifications.php (post_published / post_failed) with $owner->preferredLocale() — the worker locale is English. Mailables stay untranslated at dispatch; Mail::to($owner) applies HasLocalePreference. Do not hardcode English title/body here. + +## Finalize is idempotent once the post is settled +handle() lockForUpdates the post and returns without notifying when status is already Published, PartiallyPublished, or Failed (Status::isSettled()). RecoverStuckPosts and ReconcileGoogleBusinessPost can both finish the last target at the 24h ceiling; the second call must not send a second email or toast. Dispatch SendNotification only after the transaction commits. + +## Target disabled is not account inactive +Abandoning a GBP pending_review because the post destination was unchecked uses posts.errors.target_disabled. posts.errors.account_inactive stays for PublishToSocialPlatform when social_accounts.is_active is false. Do not reuse the account copy on a switched-off target. diff --git a/.ai/rules/social-account.md b/.ai/rules/social-account.md new file mode 100644 index 000000000..2b28774d4 --- /dev/null +++ b/.ai/rules/social-account.md @@ -0,0 +1,9 @@ +--- +paths: + - app/Enums/SocialAccount/Platform.php +--- + +# Social Account + +## Adding a platform: grep for exhaustive Platform matches beyond the known touch-point list +Adding a new Platform enum case breaks any `match ($platform) { ... }` elsewhere in the codebase that enumerates every case with no `default` arm — these throw UnhandledMatchError only at runtime/test time, not statically. Known example found the hard way: `app/Services/Media/MediaOptimizer.php`'s per-platform image optimization settings match, which isn't part of the "usual" platform touch-point list (Platform enum, ContentType enum, config, PostPlatformMetaRules, publisher, controller, frontend registry). Before considering a new platform done, run the full test suite (`php artisan test --compact --parallel`) — a missing arm surfaces as a clean, unambiguous UnhandledMatchError failure, not a silent bug. diff --git a/.ai/rules/social.md b/.ai/rules/social.md new file mode 100644 index 000000000..f217abd86 --- /dev/null +++ b/.ai/rules/social.md @@ -0,0 +1,15 @@ +--- +paths: + - app/Services/Social/GoogleBusinessPublisher.php + - app/Support/Social/GoogleBusinessDerivativeCleaner.php + - app/Actions/Post/DeletePost.php + - app/Actions/Post/UpdatePost.php + - app/Support/Social/AbandonGoogleBusinessReview.php + - app/Actions/Workspace/PurgeWorkspace.php + - app/Http/Controllers/Auth/SocialController.php +--- + +# Social + +## GBP JPEG must outlive PROCESSING +Google fetches Local Post sourceUrl after create while LocalPostState is Processing/Scheduled/Unspecified. Keep the JPEG derivative on disk until reconcile settle() or RecoverStuckPosts fails the target. Deleting in publish() finally races PHOTO_FETCH_FAILED. Live/Rejected on the create response may prune immediately. Path is deterministic: google-business-derivatives/{postPlatformId}.jpg. Wire values live on App\Enums\GoogleBusiness\LocalPostState — never compare raw PROCESSING/SCHEDULED strings. RecoverStuckPosts must prune the JPEG on the 1h Publishing/Pending/Retrying timeout (the worker can die after writing the file and before PendingReview), on a disabled GBP target (reconcile and the 24h ceiling skip `enabled=false`), and again on the 24h review ceiling. UpdatePost mass-updates `enabled=false` (observers never fire): abandon any GBP still in pending_review that is not in the kept set, then prune leftover JPEGs after commit. Disconnect must abandon pending_review rows before the account delete (nullOnDelete would otherwise leave reconcile TypeErroring until the 24h ceiling). DeletePost and PurgeWorkspace must prune every Google Business PostPlatform JPEG before the row disappears — a user delete during pending_review or a workspace wipe would otherwise leak the file, and a DB cascade on post_platforms does not fire Eloquent observers. diff --git a/.ai/rules/support.md b/.ai/rules/support.md new file mode 100644 index 000000000..a26e69c8f --- /dev/null +++ b/.ai/rules/support.md @@ -0,0 +1,9 @@ +--- +paths: + - app/Support/PostPlatformMetaRules.php +--- + +# Support + +## Never use a cross-field Laravel rule (required_unless, required_if, etc.) for a single platform's conditional meta field +`rules()` is shared by every platform via `platforms.*.meta.*` wildcards. Rules like `required_unless`/`required_if` are Laravel "implicit" rules — they validate even when the field itself is absent from the request. Adding one scoped in spirit to a single platform (e.g. `call_to_action.url` required_unless action_type is NONE/CALL, meant only for Google Business Profile) breaks every OTHER platform's create/update through web, API, and MCP, because their requests never send that field at all and the implicit rule still fires. The correct pattern (already used by Pinterest's `board_id`, Discord's `channel_id`): keep the field's `rules()` entry unconditional (`sometimes|nullable|...`), and enforce "required" semantics only in `requiredMetaViolation()`'s `match(true)` block, which is evaluated per the resolved `Platform` of the row being checked. This bug shipped once (fixed in commit 8887b3f3) and broke Pinterest/Discord/TikTok post updates — the task reviewer that approved the original `rules()` entry didn't catch it because it only reviewed Google Business's own test coverage, not sibling platforms'. diff --git a/.env.example b/.env.example index b1ff91de7..b7812c65b 100644 --- a/.env.example +++ b/.env.example @@ -158,6 +158,12 @@ GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_REDIRECT="${APP_URL}/accounts/youtube/callback" GOOGLE_AUTH_CALLBACK="${APP_URL}/auth/google/callback" +# Google Business Profile (https://console.cloud.google.com) +# Dedicated OAuth app, isolated from YouTube +GOOGLE_BUSINESS_CLIENT_ID= +GOOGLE_BUSINESS_CLIENT_SECRET= +GOOGLE_BUSINESS_CLIENT_REDIRECT="${APP_URL}/accounts/google-business/callback" + # GitHub (https://github.com/settings/developers) # Used for GitHub login/signup GITHUB_AUTH_ENABLED=false @@ -283,6 +289,7 @@ NIGHTWATCH_TOKEN= # MASTODON_ENABLED=true # BLUESKY_ENABLED=true # TELEGRAM_ENABLED=true +# GOOGLE_BUSINESS_ENABLED=true # Media Services UNSPLASH_ACCESS_KEY= diff --git a/CLAUDE.md b/CLAUDE.md index b3bd7ebc0..9f1e51cb0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -575,6 +575,7 @@ Browser tests live in `tests/Browser` and run on `pestphp/pest-plugin-browser` d - **Bluesky / AT Protocol**: official lexicons — https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo ; HTTP API reference — https://docs.bsky.app - **Discord**: Webhook resource (used for our webhook-based publishing) — https://docs.discord.com/developers/resources/webhook - **Telegram**: Bot API — https://core.telegram.org/bots/api +- **Google Business Profile**: Business Information API, Account Management API, Business Profile Performance API — https://developers.google.com/my-business/reference/rest ; legacy but still-active Local Posts v4 API (the only endpoint for creating/updating/deleting Local Posts) — https://developers.google.com/my-business/reference/rest/v4/accounts.locations.localPosts ## TryPost.it Documentation diff --git a/app/Actions/Post/DeletePost.php b/app/Actions/Post/DeletePost.php index e907acc9b..8dee00d62 100644 --- a/app/Actions/Post/DeletePost.php +++ b/app/Actions/Post/DeletePost.php @@ -4,13 +4,20 @@ namespace App\Actions\Post; +use App\Enums\SocialAccount\Platform; use App\Events\PostDeleted; use App\Models\Post; +use App\Support\Social\GoogleBusinessDerivativeCleaner; class DeletePost { public static function execute(Post $post): void { + $post->postPlatforms() + ->where('platform', Platform::GoogleBusiness) + ->pluck('id') + ->each(fn (string $id) => app(GoogleBusinessDerivativeCleaner::class)->cleanup($id)); + $postId = $post->id; $workspaceId = $post->workspace_id; diff --git a/app/Actions/Post/FinalizePostPublication.php b/app/Actions/Post/FinalizePostPublication.php new file mode 100644 index 000000000..7073ee96f --- /dev/null +++ b/app/Actions/Post/FinalizePostPublication.php @@ -0,0 +1,117 @@ +}|null $outcome */ + $outcome = DB::transaction(function () use ($post): ?array { + $post = Post::query() + ->with(['workspace.owner', 'postPlatforms.socialAccount']) + ->whereKey($post->id) + ->lockForUpdate() + ->first(); + + if (! $post instanceof Post || $post->status->isSettled()) { + return null; + } + + $targets = $post->postPlatforms->where('enabled', true); + + if ($targets->isEmpty()) { + if ($post->status !== PostStatus::Publishing) { + return null; + } + + $post->markAsFailed(); + + return [ + 'post' => $post, + 'successful' => false, + 'platforms' => $post->postPlatforms->filter( + fn (PostPlatform $target): bool => $target->status->isFinished(), + ), + ]; + } + + $finished = $targets->filter(fn (PostPlatform $target): bool => $target->status->isFinished()); + $published = $finished->where('status', PostPlatformStatus::Published); + $failed = $finished->reject(fn (PostPlatform $target): bool => $target->status === PostPlatformStatus::Published); + + if ($finished->count() < $targets->count()) { + return null; + } + + $successful = $failed->isEmpty(); + + if ($successful) { + $post->markAsPublished(); + } elseif ($published->isNotEmpty()) { + $post->markAsPartiallyPublished(); + } else { + $post->markAsFailed(); + } + + return [ + 'post' => $post, + 'successful' => $successful, + 'platforms' => $successful ? $published : $failed, + ]; + }); + + if ($outcome === null) { + return; + } + + $this->notify($outcome['post'], $outcome['successful'], $outcome['platforms']); + } + + /** + * @param Collection $platforms + */ + private function notify(Post $post, bool $successful, Collection $platforms): void + { + $owner = $post->workspace->owner; + + if (! $owner) { + return; + } + + $type = $successful ? Type::PostPublished : Type::PostFailed; + $locale = $owner->preferredLocale(); + + SendNotification::dispatch( + user: $owner, + workspaceId: $post->workspace_id, + type: $type, + channel: Channel::Both, + title: __("notifications.{$type->value}.title", [], $locale), + body: __("notifications.{$type->value}.body", [ + 'platforms' => $platforms->map->notificationLabel()->implode(', '), + ], $locale), + data: ['post_id' => $post->id], + mailable: $successful ? new PostPublished($post) : new PostPublishFailed($post), + ); + } +} diff --git a/app/Actions/Post/UpdatePost.php b/app/Actions/Post/UpdatePost.php index 4f8de31c8..f3c87f625 100644 --- a/app/Actions/Post/UpdatePost.php +++ b/app/Actions/Post/UpdatePost.php @@ -6,10 +6,15 @@ use App\Enums\Post\Action as PostAction; use App\Enums\Post\Status as PostStatus; +use App\Enums\PostPlatform\Status as PlatformStatus; +use App\Enums\SocialAccount\Platform; use App\Jobs\PublishPost; use App\Models\Post; +use App\Models\PostPlatform; use App\Models\Workspace; use App\Support\PostStatusRules; +use App\Support\Social\AbandonGoogleBusinessReview; +use App\Support\Social\GoogleBusinessDerivativeCleaner; use Carbon\Carbon; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; @@ -69,6 +74,28 @@ public static function execute(Workspace $workspace, Post $post, array $data): a ->where('id', data_get($platformData, 'id')) ->update($updateData); } + + $post->postPlatforms() + ->disabled() + ->where('platform', Platform::GoogleBusiness) + ->where('status', PlatformStatus::PendingReview) + ->get() + ->each(fn (PostPlatform $platform) => AbandonGoogleBusinessReview::execute( + $platform, + __('posts.errors.target_disabled'), + ['category' => 'target_disabled'], + )); + + $disabledGoogleBusinessIds = $post->postPlatforms() + ->disabled() + ->where('platform', Platform::GoogleBusiness) + ->pluck('id'); + + DB::afterCommit(function () use ($disabledGoogleBusinessIds): void { + $disabledGoogleBusinessIds->each( + fn (string $id) => app(GoogleBusinessDerivativeCleaner::class)->cleanup($id), + ); + }); } if ($status === PostStatus::Publishing->value) { diff --git a/app/Actions/Workspace/PurgeWorkspace.php b/app/Actions/Workspace/PurgeWorkspace.php index 74eabf393..d93777518 100644 --- a/app/Actions/Workspace/PurgeWorkspace.php +++ b/app/Actions/Workspace/PurgeWorkspace.php @@ -5,7 +5,10 @@ namespace App\Actions\Workspace; use App\Actions\Media\DeleteWorkspaceMedia; +use App\Enums\SocialAccount\Platform; +use App\Models\PostPlatform; use App\Models\Workspace; +use App\Support\Social\GoogleBusinessDerivativeCleaner; class PurgeWorkspace { @@ -19,6 +22,12 @@ class PurgeWorkspace */ public static function execute(Workspace $workspace): array { + PostPlatform::query() + ->where('platform', Platform::GoogleBusiness) + ->whereIn('post_id', $workspace->posts()->select('id')) + ->pluck('id') + ->each(fn (string $id) => app(GoogleBusinessDerivativeCleaner::class)->cleanup($id)); + $mediaPaths = DeleteWorkspaceMedia::purgeRecords($workspace); $workspace->delete(); diff --git a/app/Console/Commands/ReconcileGoogleBusinessPosts.php b/app/Console/Commands/ReconcileGoogleBusinessPosts.php new file mode 100644 index 000000000..1e576643d --- /dev/null +++ b/app/Console/Commands/ReconcileGoogleBusinessPosts.php @@ -0,0 +1,41 @@ +enabled() + ->where('platform', Platform::GoogleBusiness) + ->where('status', Status::PendingReview) + ->whereNotNull('platform_post_id') + ->where(function ($query): void { + $query->whereNull('last_reconciled_at') + ->orWhere('last_reconciled_at', '<=', now()->subMinutes(self::RECHECK_AFTER_MINUTES)); + }) + ->each(fn (PostPlatform $postPlatform) => ReconcileGoogleBusinessPost::dispatch($postPlatform)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RecoverStuckPosts.php b/app/Console/Commands/RecoverStuckPosts.php index 0b4dc1bab..afe590207 100644 --- a/app/Console/Commands/RecoverStuckPosts.php +++ b/app/Console/Commands/RecoverStuckPosts.php @@ -4,11 +4,17 @@ namespace App\Console\Commands; +use App\Actions\Post\FinalizePostPublication; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\Status as PlatformStatus; +use App\Enums\SocialAccount\Platform; +use App\Events\PostPlatformStatusUpdated; use App\Exceptions\Social\ErrorCategory; +use App\Jobs\ReconcileGoogleBusinessPost; use App\Models\Post; use App\Models\PostPlatform; +use App\Support\Social\AbandonGoogleBusinessReview; +use App\Support\Social\GoogleBusinessDerivativeCleaner; use App\Support\Social\TikTokPhotoDerivativeCleaner; use Illuminate\Console\Command; @@ -20,18 +26,17 @@ class RecoverStuckPosts extends Command public function __construct( private readonly TikTokPhotoDerivativeCleaner $tiktokPhotoDerivativeCleaner, + private readonly GoogleBusinessDerivativeCleaner $googleBusinessDerivativeCleaner, ) { parent::__construct(); } public function handle(): void { - $count = 0; - Post::query() ->where('status', PostStatus::Publishing) ->where('updated_at', '<=', now()->subHour()) - ->each(function (Post $post) use (&$count) { + ->each(function (Post $post): void { $stalePlatforms = $post->postPlatforms() ->enabled() ->whereIn('status', [PlatformStatus::Publishing, PlatformStatus::Pending, PlatformStatus::Retrying]) @@ -43,6 +48,7 @@ public function handle(): void $postPlatform->error_context, $postPlatform->id, ); + $this->googleBusinessDerivativeCleaner->cleanup($postPlatform->id); $postPlatform->update([ 'status' => PlatformStatus::Failed, @@ -55,31 +61,83 @@ public function handle(): void ]); }); + $this->pruneDisabledGoogleBusinessDerivatives($post); + $this->failExpiredReviews($post); + // Delayed platform-unavailable retries keep the platform Retrying with a // 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::PendingReview, + ]) ->exists(); if ($stillActive) { return; } - $enabledPlatforms = $post->postPlatforms()->enabled()->get(); - $total = $enabledPlatforms->count(); - $publishedCount = $enabledPlatforms->where('status', PlatformStatus::Published)->count(); + app(FinalizePostPublication::class)->handle($post); + }); + } + + /** + * A switched-off GBP target is skipped by reconcile and the 24h ceiling. + * Abandon the review so the parent can settle, and prune the JPEG. + */ + private function pruneDisabledGoogleBusinessDerivatives(Post $post): void + { + $post->postPlatforms() + ->disabled() + ->where('platform', Platform::GoogleBusiness) + ->get() + ->each(function (PostPlatform $postPlatform): void { + if ($postPlatform->status === PlatformStatus::PendingReview) { + AbandonGoogleBusinessReview::execute( + $postPlatform, + __('posts.errors.target_disabled'), + ['category' => 'target_disabled'], + ); - if ($publishedCount === $total) { - $post->markAsPublished(); - } elseif ($publishedCount > 0) { - $post->markAsPartiallyPublished(); - } else { - $post->markAsFailed(); + return; } - $count++; + $this->googleBusinessDerivativeCleaner->cleanup($postPlatform->id); }); + } + /** + * PendingReview is supposed to last up to Google's review ceiling, timed + * from submitted_at only. A scheduled draft can be days old before it + * enters review — created_at must not trip the ceiling. Rows without + * submitted_at stay in review until reconcile or a later recover after + * markAsPendingReview writes the clock. + */ + private function failExpiredReviews(Post $post): void + { + $cutoff = now()->subHours(ReconcileGoogleBusinessPost::REVIEW_CEILING_HOURS); + + $post->postPlatforms() + ->enabled() + ->where('status', PlatformStatus::PendingReview) + ->where('submitted_at', '<=', $cutoff) + ->get() + ->each(function (PostPlatform $postPlatform): void { + $postPlatform->markAsRejected( + (string) $postPlatform->platform_post_id, + $postPlatform->platform_url, + __('posts.errors.review_unconfirmed'), + [ + ...($postPlatform->error_context ?? []), + 'category' => 'review_unconfirmed', + 'failed_at' => now()->toIso8601String(), + ], + ); + $this->googleBusinessDerivativeCleaner->cleanup($postPlatform->id); + PostPlatformStatusUpdated::dispatch($postPlatform->fresh()); + }); } } diff --git a/app/Enums/GoogleBusiness/CtaAction.php b/app/Enums/GoogleBusiness/CtaAction.php new file mode 100644 index 000000000..bfd0a78c6 --- /dev/null +++ b/app/Enums/GoogleBusiness/CtaAction.php @@ -0,0 +1,44 @@ + + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** CALL uses the location phone; NONE has no destination. */ + public function requiresUrl(): bool + { + return match ($this) { + self::None, self::Call => false, + default => true, + }; + } +} diff --git a/app/Enums/GoogleBusiness/LocalPostState.php b/app/Enums/GoogleBusiness/LocalPostState.php new file mode 100644 index 000000000..0f2343ebe --- /dev/null +++ b/app/Enums/GoogleBusiness/LocalPostState.php @@ -0,0 +1,51 @@ + true, + default => false, + }; + } + + public function isLive(): bool + { + return match ($this) { + self::Live, self::Recurring => true, + default => false, + }; + } + + public function isRejected(): bool + { + return $this === self::Rejected; + } +} diff --git a/app/Enums/GoogleBusiness/TopicType.php b/app/Enums/GoogleBusiness/TopicType.php new file mode 100644 index 000000000..a51c8957a --- /dev/null +++ b/app/Enums/GoogleBusiness/TopicType.php @@ -0,0 +1,53 @@ + + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** Google requires a LocalPost `event` object for EVENT and OFFER. */ + public function requiresEvent(): bool + { + return match ($this) { + self::Event, self::Offer => true, + self::Standard => false, + }; + } + + /** Official schema: callToAction is ignored for topic type OFFER. */ + public function allowsCallToAction(): bool + { + return $this !== self::Offer; + } +} diff --git a/app/Enums/Post/Status.php b/app/Enums/Post/Status.php index ee3933959..dd5a33f77 100644 --- a/app/Enums/Post/Status.php +++ b/app/Enums/Post/Status.php @@ -36,4 +36,13 @@ public function color(): string self::Failed => 'red', }; } + + /** Published, partially published, or failed — Finalize must not notify again. */ + public function isSettled(): bool + { + return match ($this) { + self::Published, self::PartiallyPublished, self::Failed => true, + default => false, + }; + } } diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 5b293a2c2..eba15e5d1 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -56,6 +56,9 @@ enum ContentType: string // Discord case DiscordMessage = 'discord_message'; + // Google Business Profile + case GoogleBusinessPost = 'google_business_post'; + /** * AI generation format for an Instagram carousel. Not a content type — * carousel posts are persisted as InstagramFeed. @@ -84,6 +87,7 @@ public function label(): string self::MastodonPost => 'Post', self::TelegramPost => 'Post', self::DiscordMessage => 'Message', + self::GoogleBusinessPost => 'Post', }; } @@ -108,6 +112,7 @@ public function platform(): SocialPlatform self::MastodonPost => SocialPlatform::Mastodon, self::TelegramPost => SocialPlatform::Telegram, self::DiscordMessage => SocialPlatform::Discord, + self::GoogleBusinessPost => SocialPlatform::GoogleBusiness, }; } @@ -177,6 +182,7 @@ public function maxMediaCount(): int self::MastodonPost => 4, self::TelegramPost => 10, self::DiscordMessage => 10, + self::GoogleBusinessPost => 1, }; } @@ -490,6 +496,7 @@ public function supportsVideo(): bool self::MastodonPost => true, self::TelegramPost => true, self::DiscordMessage => true, + self::GoogleBusinessPost => false, }; } @@ -558,6 +565,7 @@ public function requiresMedia(): bool self::TelegramPost => false, self::FacebookPost => false, self::DiscordMessage => false, + self::GoogleBusinessPost => false, default => true, }; } @@ -641,6 +649,7 @@ public static function defaultFor(SocialPlatform $platform): self SocialPlatform::Mastodon => self::MastodonPost, SocialPlatform::Telegram => self::TelegramPost, SocialPlatform::Discord => self::DiscordMessage, + SocialPlatform::GoogleBusiness => self::GoogleBusinessPost, }; } } diff --git a/app/Enums/PostPlatform/Status.php b/app/Enums/PostPlatform/Status.php index 354c683bb..3c6789504 100644 --- a/app/Enums/PostPlatform/Status.php +++ b/app/Enums/PostPlatform/Status.php @@ -9,6 +9,23 @@ enum Status: string case Pending = 'pending'; case Publishing = 'publishing'; case Retrying = 'retrying'; + case PendingReview = 'pending_review'; case Published = 'published'; case Failed = 'failed'; + case Rejected = 'rejected'; + + /** Published, failed, or rejected — counts toward settling the parent post. */ + public function isFinished(): bool + { + return match ($this) { + self::Published, self::Failed, self::Rejected => true, + default => false, + }; + } + + /** The publish job must not run again. Pending review waits on reconcile. */ + public function isClosed(): bool + { + return $this->isFinished() || $this === self::PendingReview; + } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 211bca1b3..c87258188 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -23,6 +23,7 @@ enum Platform: string case Mastodon = 'mastodon'; case Telegram = 'telegram'; case Discord = 'discord'; + case GoogleBusiness = 'google_business'; public function network(): string { @@ -61,6 +62,7 @@ public function label(): string self::Mastodon => 'Mastodon', self::Telegram => 'Telegram', self::Discord => 'Discord', + self::GoogleBusiness => 'Google Business Profile', }; } @@ -80,6 +82,7 @@ public function color(): string self::Mastodon => '#6364FF', self::Telegram => '#26A5E4', self::Discord => '#5865F2', + self::GoogleBusiness => '#4285F4', }; } @@ -98,6 +101,7 @@ public function allowedMediaTypes(): array self::Mastodon => [MediaType::Image, MediaType::Video], self::Telegram => [MediaType::Image, MediaType::Video], self::Discord => [MediaType::Image, MediaType::Video], + self::GoogleBusiness => [MediaType::Image], }; } @@ -116,6 +120,7 @@ public function maxImages(): int self::Mastodon => 4, self::Telegram => 10, self::Discord => 10, + self::GoogleBusiness => 1, }; } @@ -139,7 +144,7 @@ public function altTextMaxLength(): ?int self::Threads => 1000, self::Pinterest => 500, self::Discord => 1024, - self::TikTok, self::YouTube, self::Telegram => null, + self::TikTok, self::YouTube, self::Telegram, self::GoogleBusiness => null, }; } @@ -173,6 +178,7 @@ public function supportsAltText(): bool * - Mastodon: 500 default; instances may be higher (we stay conservative) * - Telegram: 4096 for a text message (media captions are capped at 1024, * handled in the publisher by sending long text as its own message) + * - Google Business Profile Local Post `summary`: 1500 */ public function maxContentLength(): int { @@ -189,6 +195,7 @@ public function maxContentLength(): int self::Mastodon => 500, self::Telegram => 4096, self::Discord => 2000, + self::GoogleBusiness => 1500, }; } @@ -234,6 +241,9 @@ public function recommendedAiContentLength(): int self::Telegram => 400, // Discord — conversational community posts read best when concise self::Discord => 280, + // Google Business Profile — image does most of the work, keep the + // summary tight and scannable + self::GoogleBusiness => 300, }; } @@ -257,6 +267,7 @@ public function requiredPublishScopes(): array self::Mastodon => ['write:statuses'], self::Telegram => [], self::Discord => [], + self::GoogleBusiness => ['https://www.googleapis.com/auth/business.manage'], }; } @@ -275,6 +286,7 @@ public function supportsTextOnly(): bool self::Mastodon => true, self::Telegram => true, self::Discord => true, + self::GoogleBusiness => true, }; } @@ -316,7 +328,7 @@ public function hasTokenRefreshFlow(): bool return match ($this) { self::LinkedIn, self::LinkedInPage, self::X, self::Bluesky, self::YouTube, self::TikTok, self::Pinterest, - self::Threads, self::Instagram => true, + self::Threads, self::Instagram, self::GoogleBusiness => true, default => false, }; } @@ -344,6 +356,7 @@ public static function accessTokenExtendingPlatformValues(): array * * - X: a 2-hour access token. * - Instagram / Threads: Meta's 60-day long-lived token. + * - Google Business Profile: standard Google OAuth2 1-hour access token. * * Networks that always return expires_in (LinkedIn, TikTok, YouTube, * Pinterest), whose refresh sets a fixed lifetime directly (Bluesky), or @@ -354,6 +367,7 @@ public function defaultTokenTtlSeconds(): ?int { return match ($this) { self::X => 7200, + self::GoogleBusiness => 3600, self::Instagram, self::Threads => 5184000, default => null, }; @@ -411,6 +425,7 @@ public function isEnabled(): bool self::Mastodon => 'MASTODON_ENABLED', self::Telegram => 'TELEGRAM_ENABLED', self::Discord => 'DISCORD_ENABLED', + self::GoogleBusiness => 'GOOGLE_BUSINESS_ENABLED', }, true), ); } diff --git a/app/Enums/Workspace/ContentLanguage.php b/app/Enums/Workspace/ContentLanguage.php index 190549af1..b3bf7cec9 100644 --- a/app/Enums/Workspace/ContentLanguage.php +++ b/app/Enums/Workspace/ContentLanguage.php @@ -95,6 +95,19 @@ public function direction(): string return $this === self::Arabic ? 'rtl' : 'ltr'; } + /** + * BCP 47 tag for outbound APIs that want a regional form. Workspace + * storage stays on the short codes (`en`, `zh`); only the wire format + * widens the ones Google treats as underspecified. + */ + public function bcp47(): string + { + return match ($this) { + self::Chinese => 'zh-CN', + default => $this->value, + }; + } + /** * Resolve a raw `` value (e.g. "pt-PT", "zh-Hans") to a supported * language by matching its primary subtag, or null if none is supported. diff --git a/app/Exceptions/Social/GoogleBusinessPublishException.php b/app/Exceptions/Social/GoogleBusinessPublishException.php new file mode 100644 index 000000000..9595046bd --- /dev/null +++ b/app/Exceptions/Social/GoogleBusinessPublishException.php @@ -0,0 +1,96 @@ +status(); + $reason = (string) data_get($response->json(), 'error.status', ''); + $message = (string) data_get($response->json(), 'error.message', ''); + $rawResponse = $response->body(); + + if (self::isConfirmedDeadToken($response)) { + throw new TokenExpiredException( + message: $message !== '' ? $message : __('posts.errors.google_business.token_expired'), + platformErrorCode: $reason !== '' ? $reason : (string) $status, + ); + } + + if ($reason === 'PERMISSION_DENIED') { + return new static( + userMessage: __('posts.errors.google_business.permission_denied'), + category: ErrorCategory::Permission, + platformErrorCode: $reason, + rawResponse: $rawResponse, + ); + } + + if ($reason === 'NOT_FOUND') { + return new static( + userMessage: __('posts.errors.google_business.not_found'), + category: ErrorCategory::ContentPolicy, + platformErrorCode: $reason, + rawResponse: $rawResponse, + ); + } + + if ($reason === 'INVALID_ARGUMENT') { + return new static( + userMessage: $message !== '' ? $message : __('posts.errors.google_business.invalid_content'), + category: ErrorCategory::ContentPolicy, + platformErrorCode: $reason, + rawResponse: $rawResponse, + ); + } + + if ($reason === 'RESOURCE_EXHAUSTED' || $status === 429) { + return new static( + userMessage: __('posts.errors.google_business.rate_limited'), + category: ErrorCategory::RateLimit, + platformErrorCode: $reason !== '' ? $reason : (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status >= 500) { + return new static( + userMessage: __('posts.errors.google_business.server_error'), + category: ErrorCategory::ServerError, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + return new static( + userMessage: __('posts.errors.google_business.rejected'), + category: ErrorCategory::Unknown, + platformErrorCode: $reason !== '' ? $reason : (string) $status, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'google_business'; + } + + /** + * Whether this response confirms the account's own access_token is dead + * (not merely a transient or content-specific failure). Shared with + * ConnectionVerifier so both the publish and verify paths agree on what + * a dead Google Business Profile token looks like. + */ + public static function isConfirmedDeadToken(Response $response): bool + { + return $response->status() === 401 + || data_get($response->json(), 'error.status') === 'UNAUTHENTICATED'; + } +} diff --git a/app/Exceptions/SocialAccount/ConnectPopupException.php b/app/Exceptions/SocialAccount/ConnectPopupException.php index 219e0aa83..b7af17ba1 100644 --- a/app/Exceptions/SocialAccount/ConnectPopupException.php +++ b/app/Exceptions/SocialAccount/ConnectPopupException.php @@ -31,7 +31,7 @@ public function __construct( public function render(Request $request): Response { - session()->forget(['social_connect_workspace', 'social_reconnect_id']); + session()->forget(['social_connect_workspace', 'social_reconnect_id', 'google_business_oauth']); return Inertia::render('accounts/PopupCallback', [ 'success' => false, diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php index 8376afe8f..a764e2796 100644 --- a/app/Http/Controllers/App/AnalyticsController.php +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Controller; use App\Models\SocialAccount; use App\Services\Social\FacebookAnalytics; +use App\Services\Social\GoogleBusinessAnalytics; use App\Services\Social\InstagramAnalytics; use App\Services\Social\LinkedInPageAnalytics; use App\Services\Social\PinterestAnalytics; @@ -38,6 +39,7 @@ class AnalyticsController extends Controller Platform::Pinterest, Platform::YouTube, Platform::Telegram, + Platform::GoogleBusiness, ]; public function index(Request $request): Response @@ -76,9 +78,32 @@ public function show(Request $request, SocialAccount $account): JsonResponse $metrics = $this->metricsFor($account, $since, $until); + // Google aggregates search keywords by month, so they cannot be folded + // into the daily metric cards and travel as their own list. + if ($account->platform === Platform::GoogleBusiness) { + return response()->json([ + 'metrics' => $metrics, + 'keywords' => $this->searchKeywordsFor($account, $since, $until), + ]); + } + return response()->json(['metrics' => $metrics]); } + /** + * @return array + */ + private function searchKeywordsFor(SocialAccount $account, ?Carbon $since, ?Carbon $until): array + { + try { + return app(GoogleBusinessAnalytics::class)->getSearchKeywords($account, $since, $until); + } catch (PlatformUnavailableException|ConnectionException $e) { + report($e); + + return []; + } + } + /** * An unreachable platform is not a server error — empty numbers beat a 500 * on a page the user just opened. Narrow on purpose: catching Throwable @@ -99,6 +124,7 @@ private function metricsFor(SocialAccount $account, ?Carbon $since, ?Carbon $unt Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until), Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until), Platform::Telegram => app(TelegramAnalytics::class)->getMetrics($account), + Platform::GoogleBusiness => app(GoogleBusinessAnalytics::class)->getMetrics($account, $since, $until), default => [], }; } catch (PlatformUnavailableException|ConnectionException $e) { diff --git a/app/Http/Controllers/Auth/GoogleBusinessController.php b/app/Http/Controllers/Auth/GoogleBusinessController.php new file mode 100644 index 000000000..76373e182 --- /dev/null +++ b/app/Http/Controllers/Auth/GoogleBusinessController.php @@ -0,0 +1,237 @@ +ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + $this->authorize('manageAccounts', $workspace); + + return $this->redirectToProvider($request, $this->driver, $this->scopes, [ + 'access_type' => 'offline', + 'prompt' => 'consent', + 'include_granted_scopes' => 'true', + ]); + } + + public function callback(Request $request): InertiaResponse|RedirectResponse + { + $workspace = $this->connectWorkspace($request); + $reconnect = $this->reconnectAccount($workspace); + + try { + $socialUser = Socialite::driver($this->driver)->user(); + $oauth = [ + 'access_token' => $socialUser->token, + 'refresh_token' => $socialUser->refreshToken, + 'expires_in' => $socialUser->expiresIn, + 'user_id' => $socialUser->getId(), + 'reconnect_id' => $reconnect?->id, + ]; + + $locations = $this->publisher->fetchLocations($socialUser->token); + + if (empty($locations)) { + return $this->popupCallback(false, __('accounts.popup_callback.no_google_business_locations'), $this->platform->value); + } + + $locations = $this->filterConnectableIdentities($workspace, $locations, 'id', $reconnect); + + if (empty($locations)) { + return $this->noConnectableIdentities($reconnect, 'location_not_found'); + } + + if (count($locations) === 1) { + $this->connectLocation($workspace, $locations[0], $oauth, $reconnect); + + return $this->connectedCallback($reconnect); + } + + session([self::OAUTH_SESSION => [...$oauth, 'locations' => $locations]]); + + return redirect()->route('app.social.google-business.select-location'); + } catch (NetworkAlreadyConnectedException $e) { + return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value); + } catch (Exception $e) { + Log::error('Google Business Profile OAuth Error', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value); + } + } + + public function selectLocation(Request $request): InertiaResponse + { + $oauth = $this->requireOauthSession(); + $workspace = $this->connectWorkspace($request); + $locations = data_get($oauth, 'locations', []); + + if (empty($locations)) { + $this->forgetOauthSession(); + + return $this->popupCallback(false, __('accounts.popup_callback.no_google_business_locations'), $this->platform->value); + } + + return Inertia::render('accounts/GoogleBusinessLocationSelect', [ + 'workspace' => $workspace, + 'locations' => $locations, + ]); + } + + protected function popupCallback(bool $success, string $message, ?string $platform = null): InertiaResponse + { + $this->forgetOauthSession(); + + return parent::popupCallback($success, $message, $platform); + } + + public function select(SelectGoogleBusinessLocationRequest $request): InertiaResponse + { + $oauth = $this->requireOauthSession(); + $workspace = $this->connectWorkspace($request); + + try { + $location = collect(data_get($oauth, 'locations')) + ->firstWhere('id', $request->validated('location_id')); + + if (! $location) { + return $this->popupCallback(false, __('accounts.popup_callback.location_not_found'), $this->platform->value); + } + + $reconnect = $this->reconnectAccount($workspace, data_get($oauth, 'reconnect_id')); + + $this->connectLocation($workspace, $location, $oauth, $reconnect); + + return $this->connectedCallback($reconnect); + } catch (NetworkAlreadyConnectedException $e) { + return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value); + } catch (Exception $e) { + Log::error('Google Business Profile location selection error', [ + 'error' => $e->getMessage(), + ]); + + return $this->popupCallback(false, __('accounts.popup_callback.error_connecting_location'), $this->platform->value); + } finally { + $this->forgetOauthSession(); + } + } + + /** + * @param array $location + * @param array $oauth + */ + private function connectLocation(Workspace $workspace, array $location, array $oauth, ?SocialAccount $reconnect = null): void + { + $location['photo'] = $this->publisher->fetchLocationPhoto( + (string) data_get($oauth, 'access_token'), + (string) data_get($location, 'id'), + ); + + $attributes = $this->locationAttributes($location, $oauth); + + if ($reconnect !== null && blank(data_get($attributes, 'refresh_token'))) { + $attributes['refresh_token'] = $reconnect->refresh_token; + } + + SocialAccount::connectIdentity( + $workspace, + $this->platform, + (string) data_get($location, 'id'), + [ + ...$attributes, + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + ], + $reconnect, + ); + } + + /** + * @param array $location + * @param array $oauth + * @return array + */ + private function locationAttributes(array $location, array $oauth): array + { + $title = data_get($location, 'title'); + + return [ + 'username' => $title, + 'display_name' => $title, + 'avatar_url' => uploadFromUrl(data_get($location, 'photo')), + 'access_token' => data_get($oauth, 'access_token'), + 'refresh_token' => data_get($oauth, 'refresh_token'), + 'token_expires_at' => data_get($oauth, 'expires_in') + ? now()->addSeconds((int) data_get($oauth, 'expires_in')) + : null, + 'scopes' => $this->scopes, + 'meta' => [ + 'location_id' => data_get($location, 'id'), + 'account_name' => data_get($location, 'account_name'), + 'location_name' => data_get($location, 'location_name'), + 'maps_uri' => data_get($location, 'maps_uri'), + 'google_user_id' => data_get($oauth, 'user_id'), + ], + ]; + } + + /** + * @return array + */ + private function requireOauthSession(): array + { + $oauth = session(self::OAUTH_SESSION); + + if (! is_array($oauth)) { + throw new ConnectPopupException('session_expired', $this->platform); + } + + return $oauth; + } + + private function forgetOauthSession(): void + { + session()->forget(self::OAUTH_SESSION); + } +} diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index 5dc880244..08932e324 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -13,9 +13,11 @@ use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Http\Controllers\Controller; use App\Http\Resources\App\SocialAccountResource; +use App\Models\PostPlatform; use App\Models\Repurpose; use App\Models\SocialAccount; use App\Models\Workspace; +use App\Support\Social\AbandonGoogleBusinessReview; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -67,6 +69,16 @@ public function disconnect(Request $request, SocialAccount $account): RedirectRe abort(403); } + $account->postPlatforms() + ->where('platform', SocialPlatform::GoogleBusiness) + ->where('status', PostPlatformStatus::PendingReview) + ->get() + ->each(fn (PostPlatform $platform) => AbandonGoogleBusinessReview::execute( + $platform, + __('posts.errors.account_disconnected'), + ['category' => 'account_disconnected'], + )); + // 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. diff --git a/app/Http/Requests/Auth/SelectGoogleBusinessLocationRequest.php b/app/Http/Requests/Auth/SelectGoogleBusinessLocationRequest.php new file mode 100644 index 000000000..2578b5d3b --- /dev/null +++ b/app/Http/Requests/Auth/SelectGoogleBusinessLocationRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'location_id' => ['required', 'string'], + ]; + } +} diff --git a/app/Jobs/PublishPost.php b/app/Jobs/PublishPost.php index d2ad9a186..0ac71b320 100644 --- a/app/Jobs/PublishPost.php +++ b/app/Jobs/PublishPost.php @@ -4,10 +4,12 @@ namespace App\Jobs; +use App\Actions\Post\FinalizePostPublication; use App\Models\Post; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Log; +use Throwable; class PublishPost implements ShouldQueue { @@ -26,13 +28,13 @@ public function handle(): void } } - public function failed(?\Throwable $exception): void + public function failed(?Throwable $exception): void { Log::error('PublishPost job failed', [ 'post_id' => $this->post->id, 'error' => $exception?->getMessage(), ]); - $this->post->markAsFailed(); + app(FinalizePostPublication::class)->handle($this->post); } } diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 68308e003..096463a89 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -4,9 +4,9 @@ namespace App\Jobs; +use App\Actions\Post\FinalizePostPublication; +use App\Enums\GoogleBusiness\LocalPostState; use App\Enums\Media\Type as MediaType; -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; @@ -15,14 +15,12 @@ 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\Social\BlueskyPublisher; use App\Services\Social\ConnectionVerifier; use App\Services\Social\Discord\DiscordPublisher; use App\Services\Social\FacebookPublisher; +use App\Services\Social\GoogleBusinessPublisher; use App\Services\Social\InstagramPublisher; use App\Services\Social\LinkedInPagePublisher; use App\Services\Social\LinkedInPublisher; @@ -33,6 +31,7 @@ use App\Services\Social\TikTokPublisher; use App\Services\Social\XPublisher; use App\Services\Social\YouTubePublisher; +use App\Support\Social\GoogleBusinessDerivativeCleaner; use App\Support\Social\TikTokPhotoDerivativeCleaner; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; @@ -87,7 +86,7 @@ public function handle(): void { $this->postPlatform->refresh(); - if ($this->isTerminal()) { + if ($this->postPlatform->status->isClosed()) { return; } @@ -125,7 +124,8 @@ public function handle(): void try { $publisher = $this->getPublisher(); $result = $publisher->publish($this->postPlatform); - $this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url')); + + $this->recordPublishResult($result); break; } catch (PlatformUnavailableException $e) { $this->rescheduleForRetry($e); @@ -153,40 +153,60 @@ public function handle(): void break; } catch (SocialPublishException $e) { $this->reportCaughtPublishFailure($e); - $this->markPlatformAsFailed($e->userMessage, [ + $this->markPlatformAsFailed($e->userMessage, $this->failureContext([ 'category' => $e->category->value, 'platform_error_code' => $e->platformErrorCode, - 'failed_at' => now()->toIso8601String(), - 'content_length' => mb_strlen($this->postPlatform->post->content ?? ''), - 'media_count' => count($this->postPlatform->post->media ?? []), 'raw_response' => $e->context()['raw_response'], - ]); + ])); break; } catch (Throwable $e) { $this->reportCaughtPublishFailure($e); - $this->markPlatformAsFailed($this->safeFailureMessage($e), [ + $this->markPlatformAsFailed($this->safeFailureMessage($e), $this->failureContext([ 'category' => ErrorCategory::Unknown->value, - 'failed_at' => now()->toIso8601String(), - 'content_length' => mb_strlen($this->postPlatform->post->content ?? ''), - 'media_count' => count($this->postPlatform->post->media ?? []), - ]); + ])); break; } } - // Always check and update post status after each platform finishes $this->updatePostStatus(); - - // Broadcast final status $this->broadcastStatus(); } - private function refreshAccountToken(): void + /** + * A publisher may answer with a provider-side state instead of a finished + * post. Anything it does not report is a plain success, which is every + * platform but Google Business Profile. + * + * @param array $result + */ + private function recordPublishResult(array $result): void { - $account = $this->postPlatform->socialAccount; + $platformPostId = (string) data_get($result, 'id'); + $platformUrl = data_get($result, 'url'); + + // tryFrom, not fromApi: every other publisher omits `state`. fromApi(null) + // is Processing, which would hold LinkedIn/X/… in pending review forever. + $state = LocalPostState::tryFrom((string) data_get($result, 'state')); + + match ($state) { + LocalPostState::Rejected => $this->postPlatform->markAsRejected( + $platformPostId, + $platformUrl, + __('posts.errors.rejected_in_review'), + ['provider_state' => $state->value], + ), + LocalPostState::Processing, + LocalPostState::Scheduled, + LocalPostState::Unspecified => $this->postPlatform->markAsPendingReview($platformPostId, $platformUrl), + LocalPostState::Live, + LocalPostState::Recurring, + null => $this->postPlatform->markAsPublished($platformPostId, $platformUrl), + }; + } - // Delegate to ConnectionVerifier which already has per-platform refresh logic - app(ConnectionVerifier::class)->verify($account); + private function refreshAccountToken(): void + { + app(ConnectionVerifier::class)->verify($this->postPlatform->socialAccount); } private function failForMissingScopes(): bool @@ -342,18 +362,34 @@ private function markPlatformAsFailed(string $message, ?array $context = null): { $previousContext = $this->postPlatform->error_context ?? []; - if ($this->postPlatform->platform === SocialPlatform::TikTok) { - app(TikTokPhotoDerivativeCleaner::class)->cleanupUnlessPublishInFlight( + match ($this->postPlatform->platform) { + SocialPlatform::TikTok => app(TikTokPhotoDerivativeCleaner::class)->cleanupUnlessPublishInFlight( $previousContext, $this->postPlatform->id, - ); - } + ), + SocialPlatform::GoogleBusiness => app(GoogleBusinessDerivativeCleaner::class)->cleanup($this->postPlatform->id), + default => null, + }; $failureContext = [...$previousContext, ...($context ?? [])]; $this->postPlatform->markAsFailed($message, $failureContext === [] ? null : $failureContext); } + /** + * @param array $extra + * @return array + */ + private function failureContext(array $extra = []): array + { + return [ + ...$extra, + 'failed_at' => now()->toIso8601String(), + 'content_length' => mb_strlen($this->postPlatform->post->content ?? ''), + 'media_count' => count($this->postPlatform->post->media ?? []), + ]; + } + /** * @param array|null $context */ @@ -364,14 +400,6 @@ private function failAndFinalize(string $message, ?array $context = null): void $this->broadcastStatus(); } - private function isTerminal(): bool - { - return in_array($this->postPlatform->status, [ - PostPlatformStatus::Published, - PostPlatformStatus::Failed, - ], true); - } - private function broadcastStatus(): void { PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); @@ -388,7 +416,7 @@ private function safeFailureMessage(Throwable $e): string : 'An unexpected error occurred while publishing. Please try again.'; } - private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher + private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher|GoogleBusinessPublisher { return match ($this->postPlatform->platform) { SocialPlatform::LinkedIn => app(LinkedInPublisher::class), @@ -404,38 +432,13 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis SocialPlatform::Mastodon => app(MastodonPublisher::class), SocialPlatform::Telegram => app(TelegramPublisher::class), SocialPlatform::Discord => app(DiscordPublisher::class), + SocialPlatform::GoogleBusiness => app(GoogleBusinessPublisher::class), }; } private function updatePostStatus(): void { - $post = $this->postPlatform->post->fresh(); - $enabledPlatforms = $post->postPlatforms->where('enabled', true); - - $total = $enabledPlatforms->count(); - $publishedCount = $enabledPlatforms->where('status', PostPlatformStatus::Published)->count(); - $failedCount = $enabledPlatforms->where('status', PostPlatformStatus::Failed)->count(); - $finishedCount = $publishedCount + $failedCount; - - // Only update post status when all platforms have finished - if ($finishedCount < $total) { - return; - } - - if ($publishedCount === $total) { - $post->markAsPublished(); - $this->notify($post, PostPlatformStatus::Published); - - return; - } - - if ($publishedCount > 0) { - $post->markAsPartiallyPublished(); - } else { - $post->markAsFailed(); - } - - $this->notify($post, PostPlatformStatus::Failed); + app(FinalizePostPublication::class)->handle($this->postPlatform->post); } public function failed(?Throwable $exception): void @@ -448,47 +451,16 @@ public function failed(?Throwable $exception): void $this->postPlatform->refresh(); - if ($this->isTerminal()) { + if ($this->postPlatform->status->isClosed()) { return; } - $this->markPlatformAsFailed( + $this->failAndFinalize( $exception ? $this->safeFailureMessage($exception) : 'Unknown error', [ 'category' => ErrorCategory::JobFailed->value, 'failed_at' => now()->toIso8601String(), - ] - ); - $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->notificationLabel()) - ->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/ReconcileGoogleBusinessPost.php b/app/Jobs/ReconcileGoogleBusinessPost.php new file mode 100644 index 000000000..6a57db1ac --- /dev/null +++ b/app/Jobs/ReconcileGoogleBusinessPost.php @@ -0,0 +1,258 @@ +onQueue($postPlatform->platform->queue()); + } + + public function uniqueId(): string + { + return $this->postPlatform->id; + } + + public function handle(): void + { + $this->postPlatform->refresh(); + + if ($this->postPlatform->status !== Status::PendingReview || blank($this->postPlatform->platform_post_id)) { + return; + } + + $account = $this->postPlatform->socialAccount; + + if (! $account instanceof SocialAccount) { + $this->giveUp(__('posts.errors.account_disconnected'), [ + 'category' => 'account_disconnected', + ]); + + return; + } + + try { + $remote = $this->fetchRemote($account); + } catch (TokenExpiredException) { + $remote = $this->retryAfterExpiredToken($account); + + if ($remote === null) { + return; + } + } catch (PlatformUnavailableException|ConnectionException $e) { + $this->deferOrGiveUp($e->getMessage()); + + return; + } catch (GoogleBusinessPublishException $e) { + $this->handlePublishException($e); + + return; + } + + $state = LocalPostState::fromApi(data_get($remote, 'state')); + $platformUrl = (string) (data_get($remote, 'searchUrl') ?: $this->postPlatform->platform_url); + + if ($state->isLive()) { + $this->postPlatform->markAsPublished((string) $this->postPlatform->platform_post_id, $platformUrl); + } elseif ($state->isRejected()) { + $this->postPlatform->markAsRejected( + (string) $this->postPlatform->platform_post_id, + $platformUrl, + __('posts.errors.rejected_in_review'), + ['provider_state' => $state->value], + ); + } elseif ($this->reviewExpired()) { + $this->postPlatform->markAsRejected( + (string) $this->postPlatform->platform_post_id, + $platformUrl, + __('posts.errors.review_unconfirmed'), + ['category' => 'review_unconfirmed', 'provider_state' => $state->value], + ); + } else { + $this->postPlatform->update([ + 'platform_url' => $platformUrl, + 'last_reconciled_at' => now(), + ]); + + return; + } + + $this->postPlatform->update(['last_reconciled_at' => now()]); + $this->settle(); + } + + public function failed(?Throwable $exception): void + { + $this->postPlatform->refresh(); + + if ($this->postPlatform->status !== Status::PendingReview) { + return; + } + + $this->deferOrGiveUp( + $exception instanceof GoogleBusinessPublishException + ? $exception->userMessage + : ($exception?->getMessage() ?: __('posts.errors.review_unconfirmed')), + ); + } + + /** + * @return array + */ + private function fetchRemote(SocialAccount $account): array + { + if ($account->needsProactiveTokenRefresh()) { + app(ConnectionVerifier::class)->refreshToken($account); + } + + return app(GoogleBusinessPublisher::class)->fetchLocalPost( + $account, + (string) $this->postPlatform->platform_post_id, + ); + } + + /** + * Publish retries a 401 through verify(); reconcile used to park the + * target for 24h even when a refresh would have unstuck it. + * + * @return array|null + */ + private function retryAfterExpiredToken(SocialAccount $account): ?array + { + try { + app(ConnectionVerifier::class)->verify($account); + $account->refresh(); + } catch (TokenExpiredException $e) { + $account->markAsTokenExpired($e->getMessage()); + $this->deferOrGiveUp($e->getMessage()); + + return null; + } catch (PlatformUnavailableException|ConnectionException $e) { + $this->deferOrGiveUp($e->getMessage()); + + return null; + } catch (GoogleBusinessPublishException $e) { + $this->handlePublishException($e); + + return null; + } + + try { + return $this->fetchRemote($account); + } catch (TokenExpiredException $e) { + $this->deferOrGiveUp($e->getMessage()); + + return null; + } catch (PlatformUnavailableException|ConnectionException $e) { + $this->deferOrGiveUp($e->getMessage()); + + return null; + } catch (GoogleBusinessPublishException $e) { + $this->handlePublishException($e); + + return null; + } + } + + private function handlePublishException(GoogleBusinessPublishException $e): void + { + if (in_array($e->category, [ErrorCategory::ServerError, ErrorCategory::RateLimit], true)) { + $this->deferOrGiveUp($e->userMessage); + + return; + } + + $this->giveUp($e->userMessage, [ + 'category' => $e->category->value, + 'platform_error_code' => $e->platformErrorCode, + ]); + } + + private function deferOrGiveUp(string $errorMessage): void + { + if ($this->reviewExpired()) { + $this->giveUp(__('posts.errors.review_unconfirmed'), [ + 'category' => 'review_unconfirmed', + 'detail' => $errorMessage, + ]); + + return; + } + + $this->postPlatform->update(['last_reconciled_at' => now()]); + } + + /** + * @param array $errorContext + */ + private function giveUp(string $errorMessage, array $errorContext = []): void + { + $this->postPlatform->markAsRejected( + (string) $this->postPlatform->platform_post_id, + $this->postPlatform->platform_url, + $errorMessage, + $errorContext, + ); + $this->postPlatform->update(['last_reconciled_at' => now()]); + $this->settle(); + } + + private function reviewExpired(): bool + { + $submittedAt = $this->postPlatform->submitted_at; + + return $submittedAt instanceof CarbonInterface + && $submittedAt->copy()->addHours(self::REVIEW_CEILING_HOURS)->isPast(); + } + + private function settle(): void + { + app(GoogleBusinessDerivativeCleaner::class)->cleanup($this->postPlatform->id); + app(FinalizePostPublication::class)->handle($this->postPlatform->post); + PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); + } +} diff --git a/app/Models/PostPlatform.php b/app/Models/PostPlatform.php index a7316211d..2e650a308 100644 --- a/app/Models/PostPlatform.php +++ b/app/Models/PostPlatform.php @@ -35,6 +35,8 @@ class PostPlatform extends Model 'error_message', 'error_context', 'published_at', + 'submitted_at', + 'last_reconciled_at', 'meta', 'connection_warning_sent_at', ]; @@ -47,6 +49,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', @@ -73,6 +77,11 @@ public function scopeEnabled(Builder $query): Builder return $query->where('post_platforms.enabled', true); } + public function scopeDisabled(Builder $query): Builder + { + return $query->where('post_platforms.enabled', false); + } + /** * Get display name, falling back to snapshot if account was deleted. */ @@ -141,6 +150,39 @@ public function markAsPublished(string $platformPostId, ?string $platformUrl = n $this->socialAccount?->update(['last_used_at' => $now]); } + /** + * The provider accepted the post but has not finished reviewing it. It is + * neither published nor failed until the review settles. + */ + public function markAsPendingReview(string $platformPostId, ?string $platformUrl = null): void + { + $this->update([ + 'status' => Status::PendingReview, + 'platform_post_id' => $platformPostId, + 'platform_url' => $platformUrl, + 'submitted_at' => $this->submitted_at ?? now(), + 'error_message' => null, + 'error_context' => null, + ]); + } + + /** + * The provider accepted the post and then refused it in review. Unlike a + * failure, the remote row exists, so its id and URL are kept for support. + * + * @param array|null $errorContext + */ + public function markAsRejected(string $platformPostId, ?string $platformUrl, string $errorMessage, ?array $errorContext = null): void + { + $this->update([ + 'status' => Status::Rejected, + 'platform_post_id' => $platformPostId, + 'platform_url' => $platformUrl, + 'error_message' => $errorMessage, + 'error_context' => $errorContext, + ]); + } + public function markAsFailed(string $errorMessage, ?array $errorContext = null): void { $this->update([ diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index d9a4697bf..3e86b581e 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -14,6 +14,7 @@ use App\Jobs\SendNotification; use App\Mail\AccountDisconnected; use App\Observers\SocialAccountObserver; +use App\Support\GoogleBusinessResourceName; use Database\Factories\SocialAccountFactory; use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Database\Eloquent\Attributes\ObservedBy; @@ -274,6 +275,11 @@ protected function profileUrl(): Attribute ? rtrim((string) data_get($this->meta, 'instance'), '/')."/@{$username}" : null, SocialPlatform::Telegram => $username ? "https://t.me/{$username}" : null, + SocialPlatform::GoogleBusiness => filled(data_get($this->meta, 'maps_uri')) + ? (string) data_get($this->meta, 'maps_uri') + : (filled(data_get($this->meta, 'location_id')) + ? GoogleBusinessResourceName::dashboardUrl((string) data_get($this->meta, 'location_id')) + : null), default => null, }; }, diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f4071b958..040f423b7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -183,6 +183,13 @@ protected function configureSocialite(): void return Socialite::buildProvider(GoogleProvider::class, $config); }); + // Google Business Profile — dedicated app, separate from 'google' (YouTube). + Socialite::extend('google-business', function ($app) { + $config = $app['config']['services.google-business']; + + return Socialite::buildProvider(GoogleProvider::class, $config); + }); + // Instagram Business Login Socialite::extend('instagram', function ($app) { $config = $app['config']['services.instagram']; diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index 3370a4a0c..35ed77b94 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -354,6 +354,12 @@ private function getImageConfig(Platform $platform): array 'format' => 'image/jpeg', 'quality' => 100, ], + Platform::GoogleBusiness => [ + 'max_width' => 2048, + 'max_size' => 5 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 100, + ], }; } } diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 7c38c91f0..e0ef33965 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -8,6 +8,7 @@ use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\BlueskyPublishException; use App\Exceptions\Social\DiscordPublishException; +use App\Exceptions\Social\GoogleBusinessPublishException; use App\Exceptions\Social\LinkedInPublishException; use App\Exceptions\Social\MastodonPublishException; use App\Exceptions\Social\PinterestPublishException; @@ -20,6 +21,7 @@ use App\Services\Social\Discord\DiscordClient; use App\Services\Social\Meta\GraphError; use App\Services\Social\Telegram\TelegramApi; +use App\Support\GoogleBusinessResourceName; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -52,6 +54,8 @@ class ConnectionVerifier */ public function verify(SocialAccount $account): bool { + $this->assertConnectionConfigured($account); + // Hard-expired tokens cannot make API calls — refresh is mandatory. // For tokens that are still valid OR only "expiring soon", try the // verify endpoint FIRST with the current access_token. This avoids @@ -152,6 +156,20 @@ private function refreshHttp(): PendingRequest ->connectTimeout(self::REFRESH_CONNECT_TIMEOUT_SECONDS); } + /** + * Connections that cannot be verified until the user reconnects — throw + * before the refresh ladder so a missing location is not mistaken for a + * dead access token. + * + * @throws TokenExpiredException + */ + private function assertConnectionConfigured(SocialAccount $account): void + { + if ($account->platform === Platform::GoogleBusiness && GoogleBusinessResourceName::connectedLocation($account->meta) === null) { + throw new TokenExpiredException(__('posts.errors.google_business.no_location')); + } + } + /** * Check the stored access token as it is, skipping the refresh-and-retry * ladder verify() runs — which would re-send a refresh_token the provider @@ -184,6 +202,7 @@ private function callVerifyEndpoint(SocialAccount $account): bool Platform::Mastodon => $this->verifyMastodon($account), Platform::Telegram => $this->verifyTelegram($account), Platform::Discord => $this->verifyDiscord($account), + Platform::GoogleBusiness => $this->verifyGoogleBusiness($account), }; } @@ -235,6 +254,7 @@ public function refreshToken(SocialAccount $account): bool Platform::Pinterest => $this->refreshPinterestToken($account), Platform::Threads => $this->refreshThreadsToken($account), Platform::Instagram => $this->refreshInstagramToken($account), + Platform::GoogleBusiness => $this->refreshGoogleBusinessToken($account), }; return true; @@ -461,6 +481,31 @@ private function refreshInstagramToken(SocialAccount $account): void $account->refresh(); } + private function refreshGoogleBusinessToken(SocialAccount $account): void + { + if (! $account->refresh_token) { + throw new TokenExpiredException(__('posts.errors.google_business.no_refresh_token')); + } + + $response = TokenRefreshClient::for(Platform::GoogleBusiness)->send(fn () => $this->refreshHttp()->asForm() + ->post(config('trypost.platforms.google_business.oauth_api').'/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.google-business.client_id'), + 'client_secret' => config('services.google-business.client_secret'), + ])); + + $data = $response->json(); + + $account->update([ + 'access_token' => $this->tokenFrom($data, $account->platform), + 'refresh_token' => $this->rotatedTokenFrom($data, 'refresh_token', (string) $account->refresh_token), + 'token_expires_at' => now()->addSeconds((int) (data_get($data, 'expires_in') ?: $account->platform->defaultTokenTtlSeconds())), + ]); + + $account->refresh(); + } + private function verifyLinkedIn(SocialAccount $account): bool { $response = Http::withToken($account->access_token) @@ -727,4 +772,33 @@ private function verifyMastodon(SocialAccount $account): bool $response->status(), ); } + + private function verifyGoogleBusiness(SocialAccount $account): bool + { + $location = GoogleBusinessResourceName::connectedLocation($account->meta); + + if ($location === null) { + throw new TokenExpiredException(__('posts.errors.google_business.no_location')); + } + + $locationName = $location['name']; + + $response = Http::withToken($account->access_token) + ->get(config('trypost.platforms.google_business.business_information_api')."/{$locationName}", [ + 'readMask' => 'name', + ]); + + if (GoogleBusinessPublishException::isConfirmedDeadToken($response)) { + throw new TokenExpiredException(__('posts.errors.google_business.token_expired')); + } + + if ($response->successful()) { + return true; + } + + throw new PlatformUnavailableException( + "{$account->platform->label()} verify failed ({$response->status()}).", + $response->status(), + ); + } } diff --git a/app/Services/Social/GoogleBusinessAnalytics.php b/app/Services/Social/GoogleBusinessAnalytics.php new file mode 100644 index 000000000..e3cd5e1c1 --- /dev/null +++ b/app/Services/Social/GoogleBusinessAnalytics.php @@ -0,0 +1,240 @@ + 'analytics.metrics.desktop_search_impressions', + 'BUSINESS_IMPRESSIONS_MOBILE_SEARCH' => 'analytics.metrics.mobile_search_impressions', + 'BUSINESS_IMPRESSIONS_DESKTOP_MAPS' => 'analytics.metrics.desktop_map_impressions', + 'BUSINESS_IMPRESSIONS_MOBILE_MAPS' => 'analytics.metrics.mobile_map_impressions', + 'WEBSITE_CLICKS' => 'analytics.metrics.website_clicks', + 'CALL_CLICKS' => 'analytics.metrics.call_clicks', + 'BUSINESS_DIRECTION_REQUESTS' => 'analytics.metrics.direction_requests', + 'BUSINESS_CONVERSATIONS' => 'analytics.metrics.conversations', + ]; + + /** + * Bookings and food metrics stay empty for most business types. Hide a + * zero so a dentist is not staring at three permanent empty cards. + */ + private const CONDITIONAL_METRICS = [ + 'BUSINESS_BOOKINGS' => 'analytics.metrics.bookings', + 'BUSINESS_FOOD_ORDERS' => 'analytics.metrics.food_orders', + 'BUSINESS_FOOD_MENU_CLICKS' => 'analytics.metrics.food_menu_clicks', + ]; + + private string $baseUrl; + + public function __construct() + { + $this->baseUrl = config('trypost.platforms.google_business.performance_api'); + } + + public function getMetrics(SocialAccount $account, ?CarbonInterface $since = null, ?CarbonInterface $until = null): array + { + $since ??= now()->subDays(7); + $until ??= now(); + + return $this->rememberSuccessful( + "analytics:google_business:{$account->id}:{$since->format('Y-m-d')}:{$until->format('Y-m-d')}", + fn (): array|false => $this->fetchMetricsFromApi($account, $since, $until), + ); + } + + /** + * Google only aggregates search keywords by month, so a day-level range + * is widened to the months it touches — the panel labels the period it got. + * + * @return list + */ + public function getSearchKeywords(SocialAccount $account, ?CarbonInterface $since = null, ?CarbonInterface $until = null): array + { + $since ??= now()->subMonth(); + $until ??= now(); + + return $this->rememberSuccessful( + "analytics:google_business:keywords:{$account->id}:{$since->format('Y-m')}:{$until->format('Y-m')}", + fn (): array|false => $this->fetchSearchKeywordsFromApi($account, $since, $until), + ); + } + + /** + * @param callable(): array|false $callback + */ + private function rememberSuccessful(string $key, callable $callback): array + { + $cached = Cache::get($key); + + if (is_array($cached)) { + return $cached; + } + + $value = $callback(); + + if ($value === false) { + return []; + } + + Cache::put($key, $value, app()->isProduction() ? 3600 : 1); + + return $value; + } + + private function location(SocialAccount $account): ?string + { + $location = GoogleBusinessResourceName::connectedLocation($account->meta); + + if ($location === null) { + return null; + } + + if ($account->needsProactiveTokenRefresh()) { + app(ConnectionVerifier::class)->refreshToken($account); + } + + return $location['name']; + } + + /** + * @return list|false + */ + private function fetchSearchKeywordsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array|false + { + $locationName = $this->location($account); + + if ($locationName === null) { + return false; + } + + $keywords = []; + $pageToken = null; + + do { + $response = $this->socialHttp()->withToken($account->access_token) + ->get("{$this->baseUrl}/{$locationName}/searchkeywords/impressions/monthly", array_filter([ + 'monthlyRange.start_month.year' => (int) $since->format('Y'), + 'monthlyRange.start_month.month' => (int) $since->format('n'), + 'monthlyRange.end_month.year' => (int) $until->format('Y'), + 'monthlyRange.end_month.month' => (int) $until->format('n'), + 'pageSize' => 100, + 'pageToken' => $pageToken, + ])); + + if ($response->failed()) { + Log::warning('Google Business Profile search keywords fetch failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return false; + } + + $payload = $response->json(); + + foreach (data_get($payload, 'searchKeywordsCounts', []) as $entry) { + $threshold = data_get($entry, 'insightsValue.threshold'); + + $keywords[] = [ + 'keyword' => (string) data_get($entry, 'searchKeyword'), + // Google withholds the count for low-volume terms and sends + // the floor instead. The estimated flag is what keeps that + // floor from being shown as a real count. + 'value' => (int) (data_get($entry, 'insightsValue.value') ?? $threshold ?? 0), + 'estimated' => $threshold !== null, + ]; + } + + $pageToken = data_get($payload, 'nextPageToken'); + } while (filled($pageToken)); + + return $keywords; + } + + /** + * @return list|false + */ + private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array|false + { + $locationName = $this->location($account); + + if ($locationName === null) { + return false; + } + + $response = $this->socialHttp()->withToken($account->access_token) + ->get("{$this->baseUrl}/{$locationName}:fetchMultiDailyMetricsTimeSeries?{$this->buildQuery($since, $until)}"); + + if ($response->failed()) { + Log::warning('Google Business Profile analytics fetch failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return false; + } + + $labels = self::METRICS + self::CONDITIONAL_METRICS; + $totals = array_fill_keys(array_keys($labels), 0); + + foreach (data_get($response->json(), 'multiDailyMetricTimeSeries.0.dailyMetricTimeSeries', []) as $entry) { + $metric = data_get($entry, 'dailyMetric'); + + if (! array_key_exists($metric, $totals)) { + continue; + } + + $totals[$metric] = collect(data_get($entry, 'timeSeries.datedValues', [])) + ->sum(fn ($value) => (int) data_get($value, 'value', 0)); + } + + $metrics = []; + + foreach ($labels as $metric => $labelKey) { + $value = $totals[$metric]; + + if (isset(self::CONDITIONAL_METRICS[$metric]) && $value === 0) { + continue; + } + + $metrics[] = ['label' => __($labelKey), 'value' => $value]; + } + + return $metrics; + } + + /** + * Google expects `dailyMetrics` as repeated scalar params, which + * `http_build_query` (and therefore the HTTP client's array query support) + * would encode as `dailyMetrics[0]=...` instead. + */ + private function buildQuery(CarbonInterface $since, CarbonInterface $until): string + { + $metrics = implode('&', array_map( + fn (string $metric): string => 'dailyMetrics='.urlencode($metric), + array_keys(self::METRICS + self::CONDITIONAL_METRICS), + )); + + $range = http_build_query([ + 'dailyRange.start_date.year' => $since->format('Y'), + 'dailyRange.start_date.month' => $since->format('n'), + 'dailyRange.start_date.day' => $since->format('j'), + 'dailyRange.end_date.year' => $until->format('Y'), + 'dailyRange.end_date.month' => $until->format('n'), + 'dailyRange.end_date.day' => $until->format('j'), + ]); + + return "{$metrics}&{$range}"; + } +} diff --git a/app/Services/Social/GoogleBusinessPublisher.php b/app/Services/Social/GoogleBusinessPublisher.php new file mode 100644 index 000000000..c03cc3802 --- /dev/null +++ b/app/Services/Social/GoogleBusinessPublisher.php @@ -0,0 +1,442 @@ +validateContentLength($postPlatform); + + $account = $postPlatform->socialAccount; + + if ($account->needsProactiveTokenRefresh()) { + app(ConnectionVerifier::class)->refreshToken($account); + } + + $location = GoogleBusinessResourceName::connectedLocation($account->meta); + $locationId = $this->required( + $location['id'] ?? null, + __('posts.errors.google_business.no_location'), + ErrorCategory::Permission, + ); + $created = $this->post( + $account->access_token, + $this->url('local_posts_api', "/{$locationId}/localPosts"), + $this->payload($postPlatform), + 'Google Business Profile post creation failed', + ); + $state = LocalPostState::fromApi(data_get($created, 'state')); + + return [ + 'id' => (string) data_get($created, 'name'), + 'url' => (string) (data_get($created, 'searchUrl') ?: GoogleBusinessResourceName::dashboardUrl($locationId)), + 'state' => $state->value, + ]; + } finally { + if (! $state?->isPendingReview()) { + app(GoogleBusinessDerivativeCleaner::class)->cleanup($postPlatform->id); + } + } + } + + /** + * `id` is the full `accounts/{id}/locations/{id}` name the v4 Local Posts API + * needs as its parent; `location_name` is the short `locations/{id}` name the + * v1 Business Information and Performance APIs expect. + * + * @return list + */ + public function fetchLocations(string $accessToken): array + { + return collect($this->accountNames($accessToken)) + ->flatMap(fn (string $accountName) => $this->locationsFor($accessToken, $accountName)) + ->values() + ->all(); + } + + /** + * Profile photos live at `/media/profile` — do not walk the full media list. + * + * @see https://developers.google.com/my-business/reference/rest/v4/accounts.locations.media/get + */ + public function fetchLocationPhoto(string $accessToken, string $fullLocationName): ?string + { + $payload = $this->getOrNull( + $accessToken, + $this->url('local_posts_api', "/{$fullLocationName}/media/profile"), + 'Google Business Profile location profile photo fetch failed', + ['location' => $fullLocationName], + ); + $url = data_get($payload, 'thumbnailUrl') ?: data_get($payload, 'googleUrl'); + + return filled($url) ? (string) $url : null; + } + + /** + * @return array + */ + public function fetchLocalPost(SocialAccount $account, string $localPostName): array + { + return $this->get( + $account->access_token, + $this->url('local_posts_api', "/{$localPostName}"), + [], + 'Google Business Profile post lookup failed', + level: 'warning', + ); + } + + /** + * @return array + */ + private function payload(PostPlatform $postPlatform): array + { + $topicType = TopicType::fromMeta(data_get($postPlatform->meta, 'topic_type')); + $language = ContentLanguage::tryFrom((string) $postPlatform->post->workspace->content_language) + ?? ContentLanguage::DEFAULT; + + return [ + 'languageCode' => $language->bcp47(), + 'summary' => $postPlatform->post->content + ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, Platform::GoogleBusiness) + : '', + 'topicType' => $topicType->value, + ...array_filter([ + 'callToAction' => $this->callToAction($postPlatform, $topicType), + 'media' => $this->photo($postPlatform), + 'event' => $topicType->requiresEvent() ? $this->event($postPlatform, $topicType) : null, + 'offer' => $topicType === TopicType::Offer ? $this->offer($postPlatform) : null, + ]), + ]; + } + + /** + * @return array{actionType: string, url?: mixed}|null + */ + private function callToAction(PostPlatform $postPlatform, TopicType $topicType): ?array + { + $action = CtaAction::fromMeta(data_get($postPlatform->meta, 'call_to_action.action_type')); + + if (! $topicType->allowsCallToAction() || $action === CtaAction::None) { + return null; + } + + return [ + 'actionType' => $action->value, + ...($action->requiresUrl() ? [ + 'url' => data_get($postPlatform->meta, 'call_to_action.url'), + ] : []), + ]; + } + + /** + * @return list|null + */ + private function photo(PostPlatform $postPlatform): ?array + { + $media = $postPlatform->post->mediaItems->first( + fn (MediaItem $item): bool => $item->isImage() && ! MediaType::isGif($item->mime_type), + ); + + return $media ? [[ + 'mediaFormat' => 'PHOTO', + 'sourceUrl' => $this->imageSourceUrl($media, $postPlatform->id), + ]] : null; + } + + private function imageSourceUrl(MediaItem $media, string $postPlatformId): string + { + if (blank($media->path) || ! Storage::exists($media->path)) { + return $media->url; + } + + $input = tempnam(sys_get_temp_dir(), 'gbp_'); + $optimized = null; + + if ($input === false) { + return $media->url; + } + + try { + file_put_contents($input, Storage::get($media->path)); + $optimized = app(MediaOptimizer::class)->optimizeImage($input, Platform::GoogleBusiness); + $path = GoogleBusinessDerivativeCleaner::pathFor($postPlatformId); + Storage::put($path, file_get_contents($optimized)); + + return Storage::url($path); + } catch (Throwable $e) { + Log::warning('Google Business Profile image derivative failed; sending the original', [ + 'path' => $media->path, + 'error' => $e->getMessage(), + ]); + + return $media->url; + } finally { + @unlink($input); + + if ($optimized !== null) { + @unlink($optimized); + } + } + } + + /** + * @return array{title: string, schedule: array} + */ + private function event(PostPlatform $postPlatform, TopicType $topicType): array + { + $datesRequired = __('posts.errors.google_business.event_dates_required'); + + return [ + 'title' => $this->required( + data_get($postPlatform->meta, 'event.title'), + $topicType === TopicType::Offer + ? __('posts.form.google_business.offer_title_required') + : __('posts.form.google_business.event_title_required'), + ), + 'schedule' => array_filter([ + 'startDate' => $this->dateParts($this->required(data_get($postPlatform->meta, 'event.start_date'), $datesRequired)), + 'endDate' => $this->dateParts($this->required(data_get($postPlatform->meta, 'event.end_date'), $datesRequired)), + 'startTime' => $this->timeParts(data_get($postPlatform->meta, 'event.start_time')), + 'endTime' => $this->timeParts(data_get($postPlatform->meta, 'event.end_time')), + ]), + ]; + } + + /** + * @return array + */ + private function offer(PostPlatform $postPlatform): array + { + return array_filter([ + 'couponCode' => data_get($postPlatform->meta, 'offer.coupon_code'), + 'redeemOnlineUrl' => data_get($postPlatform->meta, 'offer.redeem_online_url'), + 'termsConditions' => data_get($postPlatform->meta, 'offer.terms_conditions'), + ], filled(...)); + } + + private function required(mixed $value, string $message, ErrorCategory $category = ErrorCategory::ContentPolicy): string + { + $value = (string) $value; + + if (blank($value)) { + throw new GoogleBusinessPublishException(userMessage: $message, category: $category); + } + + return $value; + } + + /** + * @return array{year: int, month: int, day: int} + */ + private function dateParts(string $date): array + { + $carbon = CarbonImmutable::parse($date); + + return ['year' => $carbon->year, 'month' => $carbon->month, 'day' => $carbon->day]; + } + + /** + * @return array{hours: int, minutes: int, seconds: int, nanos: int}|null + */ + private function timeParts(mixed $time): ?array + { + if (blank($time)) { + return null; + } + + $carbon = CarbonImmutable::parse((string) $time); + + return ['hours' => $carbon->hour, 'minutes' => $carbon->minute, 'seconds' => 0, 'nanos' => 0]; + } + + /** + * @return list + */ + private function accountNames(string $accessToken): array + { + return $this->pages( + $accessToken, + $this->url('account_management_api', '/accounts'), + // The Account Management API caps this at 20; asking for more is + // silently clamped and hides the real page size. + ['pageSize' => 20], + 'Google Business Profile accounts fetch failed', + fn (array $data): array => collect(data_get($data, 'accounts', [])) + ->map(fn (array $account): string => (string) data_get($account, 'name')) + ->all(), + ); + } + + /** + * @return list + */ + private function locationsFor(string $accessToken, string $accountName): array + { + return $this->pages( + $accessToken, + $this->url('business_information_api', "/{$accountName}/locations"), + [ + 'readMask' => 'name,title,storefrontAddress,metadata', + 'pageSize' => 100, + ], + 'Google Business Profile locations fetch failed', + fn (array $data): array => collect(data_get($data, 'locations', [])) + ->reject(fn (array $location): bool => data_get($location, 'metadata.canOperateLocalPost') === false) + ->map(fn (array $location): array => $this->mapLocation($accountName, $location)) + ->values() + ->all(), + ['account' => $accountName], + ); + } + + /** + * @param array $location + * @return array{id: string, account_name: string, location_name: string, title: string, address: ?string, maps_uri: ?string} + */ + private function mapLocation(string $accountName, array $location): array + { + $shortName = (string) data_get($location, 'name'); + $mapsUri = data_get($location, 'metadata.mapsUri'); + + return [ + 'id' => GoogleBusinessResourceName::toFullLocationName($accountName, $shortName), + 'account_name' => $accountName, + 'location_name' => $shortName, + 'title' => (string) data_get($location, 'title'), + 'address' => $this->formatAddress(data_get($location, 'storefrontAddress')), + 'maps_uri' => filled($mapsUri) ? (string) $mapsUri : null, + ]; + } + + private function formatAddress(mixed $storefrontAddress): ?string + { + $parts = array_filter([ + implode(' ', (array) data_get($storefrontAddress, 'addressLines', [])), + data_get($storefrontAddress, 'locality'), + ]); + + return $parts === [] ? null : implode(', ', $parts); + } + + /** + * @template T + * + * @param array $query + * @param callable(array): list $map + * @param array $context + * @return list + */ + private function pages(string $token, string $url, array $query, string $message, callable $map, array $context = []): array + { + $items = []; + $pageToken = null; + + do { + $data = $this->get( + $token, + $url, + [...$query, ...filled($pageToken) ? ['pageToken' => $pageToken] : []], + $message, + $context, + ); + array_push($items, ...$map($data)); + $pageToken = data_get($data, 'nextPageToken'); + } while (filled($pageToken)); + + return $items; + } + + /** + * @param array $data + * @return array + */ + private function post(string $token, string $url, array $data, string $message): array + { + return $this->json($this->socialHttp()->withToken($token)->post($url, $data), $message); + } + + /** + * @param array $query + * @param array $context + * @return array + */ + private function get(string $token, string $url, array $query, string $message, array $context = [], string $level = 'error'): array + { + return $this->json($this->socialHttp()->withToken($token)->get($url, $query), $message, $context, $level); + } + + /** + * @param array $context + * @return array|null + */ + private function getOrNull(string $token, string $url, string $message, array $context = []): ?array + { + $response = $this->socialHttp()->withToken($token)->get($url); + + if ($response->successful()) { + return $response->json(); + } + + Log::warning($message, [ + ...$context, + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + + return null; + } + + /** + * @param array $context + * @return array + */ + private function json(Response $response, string $message, array $context = [], string $level = 'error'): array + { + if ($response->successful()) { + return $response->json() ?? []; + } + + Log::log($level, $message, [ + ...$context, + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + + throw GoogleBusinessPublishException::fromApiResponse($response); + } + + private function url(string $key, string $path = ''): string + { + return (string) config("trypost.platforms.google_business.{$key}").$path; + } +} diff --git a/app/Support/GoogleBusinessResourceName.php b/app/Support/GoogleBusinessResourceName.php new file mode 100644 index 000000000..257a7c509 --- /dev/null +++ b/app/Support/GoogleBusinessResourceName.php @@ -0,0 +1,52 @@ + $id, 'name' => $name]; + } + + private static function locationId(string $resourceName): string + { + return Str::afterLast($resourceName, '/'); + } +} diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index 6eb0e75ab..c904ee952 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -4,10 +4,13 @@ namespace App\Support; +use App\Enums\GoogleBusiness\CtaAction; +use App\Enums\GoogleBusiness\TopicType; use App\Enums\PostPlatform\AspectRatio; use App\Enums\SocialAccount\Platform; use App\Enums\TikTok\PrivacyLevel; use App\Models\Post; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; use Illuminate\Validation\Validator; @@ -65,6 +68,22 @@ public static function rules(): array 'platforms.*.meta.embeds.*.url' => ['sometimes', 'nullable', 'url'], 'platforms.*.meta.embeds.*.image' => ['sometimes', 'nullable', 'url'], 'platforms.*.meta.embeds.*.color' => ['sometimes', 'nullable', 'string', 'regex:/^#?[0-9A-Fa-f]{6}$/'], + + // Google Business Profile + 'platforms.*.meta.topic_type' => ['sometimes', 'nullable', 'string', Rule::enum(TopicType::class)], + 'platforms.*.meta.call_to_action' => ['sometimes', 'nullable', 'array'], + 'platforms.*.meta.call_to_action.action_type' => ['sometimes', 'nullable', 'string', Rule::enum(CtaAction::class)], + 'platforms.*.meta.call_to_action.url' => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], + 'platforms.*.meta.event' => ['sometimes', 'nullable', 'array'], + 'platforms.*.meta.event.title' => ['sometimes', 'nullable', 'string', 'max:'.TopicType::TITLE_MAX_LENGTH], + 'platforms.*.meta.event.start_date' => ['sometimes', 'nullable', 'date'], + 'platforms.*.meta.event.end_date' => ['sometimes', 'nullable', 'date', 'after_or_equal:platforms.*.meta.event.start_date'], + 'platforms.*.meta.event.start_time' => ['sometimes', 'nullable', 'date_format:H:i'], + 'platforms.*.meta.event.end_time' => ['sometimes', 'nullable', 'date_format:H:i'], + 'platforms.*.meta.offer' => ['sometimes', 'nullable', 'array'], + 'platforms.*.meta.offer.coupon_code' => ['sometimes', 'nullable', 'string'], + 'platforms.*.meta.offer.redeem_online_url' => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], + 'platforms.*.meta.offer.terms_conditions' => ['sometimes', 'nullable', 'string', 'max:5000'], ]; } @@ -79,6 +98,8 @@ public static function messages(): array 'platforms.*.meta.link.url' => __('posts.form.pinterest.link_invalid'), 'platforms.*.meta.link.max' => __('posts.form.pinterest.link_max'), 'platforms.*.meta.title.max' => __('posts.form.pinterest.title_max'), + 'platforms.*.meta.event.end_date.after_or_equal' => __('posts.form.google_business.event_end_date_before_start'), + 'platforms.*.meta.event.title.max' => __('posts.form.google_business.title_max'), ]; } @@ -92,6 +113,8 @@ public static function attributes(): array return [ 'platforms.*.meta.title' => __('posts.form.pinterest.title'), 'platforms.*.meta.link' => __('posts.form.pinterest.link'), + 'platforms.*.meta.event.title' => __('posts.form.google_business.event_title'), + 'platforms.*.meta.call_to_action.url' => __('posts.form.google_business.cta_url'), ]; } @@ -151,14 +174,95 @@ public static function assertStoredPostPublishable(Post $post): void */ public static function requiredMetaViolation(?Platform $platform, mixed $meta): ?array { + $topicType = TopicType::fromMeta(data_get($meta, 'topic_type')); + $ctaAction = CtaAction::fromMeta(data_get($meta, 'call_to_action.action_type')); + $needsGoogleBusinessEvent = $platform === Platform::GoogleBusiness && $topicType->requiresEvent(); + return match (true) { $platform === Platform::TikTok => self::tiktokPrivacyViolation($meta), $platform === Platform::Pinterest && blank(data_get($meta, 'board_id')) => ['board_id', trans('posts.form.pinterest.board_required')], $platform === Platform::Discord && blank(data_get($meta, 'channel_id')) => ['channel_id', trans('posts.form.discord.channel_required')], + $needsGoogleBusinessEvent + && blank(data_get($meta, 'event.title')) => [ + 'event.title', + trans($topicType === TopicType::Offer + ? 'posts.form.google_business.offer_title_required' + : 'posts.form.google_business.event_title_required'), + ], + $needsGoogleBusinessEvent + && self::googleBusinessEventTitleExceedsLimit($meta) => [ + 'event.title', + trans('posts.form.google_business.title_max'), + ], + $needsGoogleBusinessEvent + && blank(data_get($meta, 'event.start_date')) => ['event.start_date', trans('posts.form.google_business.event_start_date_required')], + $needsGoogleBusinessEvent + && blank(data_get($meta, 'event.end_date')) => ['event.end_date', trans('posts.form.google_business.event_end_date_required')], + $needsGoogleBusinessEvent + && self::googleBusinessEventEndsBeforeStart($meta) => self::googleBusinessEventRangeViolation($meta), + $platform === Platform::GoogleBusiness + && $topicType->allowsCallToAction() + && $ctaAction->requiresUrl() + && blank(data_get($meta, 'call_to_action.url')) => ['call_to_action.url', trans('posts.form.google_business.cta_url_required')], default => null, }; } + /** + * Event/offer titles over TITLE_MAX_LENGTH fail publish even when stored + * outside rules() — MCP PublishPostTool only runs requiredMetaViolation(). + */ + private static function googleBusinessEventTitleExceedsLimit(mixed $meta): bool + { + $title = data_get($meta, 'event.title'); + + return filled($title) && Str::length((string) $title) > TopicType::TITLE_MAX_LENGTH; + } + + /** + * Whether the Google Business event/offer schedule ends before it starts. + * Same-day times count: 18:00 → 09:00 is invalid even when the dates match. + */ + public static function googleBusinessEventEndsBeforeStart(mixed $meta): bool + { + $startDate = data_get($meta, 'event.start_date'); + $endDate = data_get($meta, 'event.end_date'); + + if (blank($startDate) || blank($endDate)) { + return false; + } + + $startDate = (string) $startDate; + $endDate = (string) $endDate; + + if ($endDate < $startDate) { + return true; + } + + if ($endDate !== $startDate) { + return false; + } + + $startTime = data_get($meta, 'event.start_time'); + $endTime = data_get($meta, 'event.end_time'); + + return filled($startTime) && filled($endTime) && (string) $endTime < (string) $startTime; + } + + /** + * @return array{0: string, 1: string} + */ + private static function googleBusinessEventRangeViolation(mixed $meta): array + { + $sameDay = (string) data_get($meta, 'event.end_date') === (string) data_get($meta, 'event.start_date') + && filled(data_get($meta, 'event.start_time')) + && filled(data_get($meta, 'event.end_time')); + + return $sameDay + ? ['event.end_time', trans('posts.form.google_business.event_end_time_before_start')] + : ['event.end_date', trans('posts.form.google_business.event_end_date_before_start')]; + } + /** * @return array{0: string, 1: string}|null */ diff --git a/app/Support/Social/AbandonGoogleBusinessReview.php b/app/Support/Social/AbandonGoogleBusinessReview.php new file mode 100644 index 000000000..948f91c33 --- /dev/null +++ b/app/Support/Social/AbandonGoogleBusinessReview.php @@ -0,0 +1,37 @@ + $errorContext + */ + public static function execute(PostPlatform $postPlatform, string $errorMessage, array $errorContext = []): void + { + if ($postPlatform->status === Status::PendingReview) { + $postPlatform->markAsRejected( + (string) $postPlatform->platform_post_id, + $postPlatform->platform_url, + $errorMessage, + $errorContext, + ); + } + + app(GoogleBusinessDerivativeCleaner::class)->cleanup($postPlatform->id); + app(FinalizePostPublication::class)->handle($postPlatform->post); + PostPlatformStatusUpdated::dispatch($postPlatform->fresh()); + } +} diff --git a/app/Support/Social/GoogleBusinessDerivativeCleaner.php b/app/Support/Social/GoogleBusinessDerivativeCleaner.php new file mode 100644 index 000000000..0148d8599 --- /dev/null +++ b/app/Support/Social/GoogleBusinessDerivativeCleaner.php @@ -0,0 +1,34 @@ + $postPlatformId, + 'path' => $path, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/config/services.php b/config/services.php index 4b5d5690f..94f3e58b0 100644 --- a/config/services.php +++ b/config/services.php @@ -117,6 +117,15 @@ 'redirect' => env('DISCORD_CLIENT_REDIRECT'), ], + // Google Business Profile — dedicated OAuth app, isolated from 'google' + // (YouTube) so adding the sensitive business.manage scope never triggers + // Google to re-review the YouTube app's already-verified scope set. + 'google-business' => [ + 'client_id' => env('GOOGLE_BUSINESS_CLIENT_ID'), + 'client_secret' => env('GOOGLE_BUSINESS_CLIENT_SECRET'), + 'redirect' => env('GOOGLE_BUSINESS_CLIENT_REDIRECT'), + ], + 'gtm' => [ 'id' => env('GTM_ID'), ], diff --git a/config/trypost.php b/config/trypost.php index a03a69ff5..2ae1d9029 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -271,6 +271,21 @@ 'permissions' => env('DISCORD_PERMISSIONS', '248832'), 'scopes' => array_values(array_filter(array_map('trim', explode(',', (string) env('DISCORD_SCOPES', 'bot,identify,guilds'))))), ], + 'google_business' => [ + 'enabled' => env('GOOGLE_BUSINESS_ENABLED', true), + // Account Management API — lists the Business accounts a user administers. + 'account_management_api' => env('GOOGLE_BUSINESS_ACCOUNT_MANAGEMENT_API', 'https://mybusinessaccountmanagement.googleapis.com/v1'), + // Business Information API — lists locations under an account. + 'business_information_api' => env('GOOGLE_BUSINESS_BUSINESS_INFORMATION_API', 'https://mybusinessbusinessinformation.googleapis.com/v1'), + // Legacy but still-active v4 API — the only home for Local Post create/update/delete. + 'local_posts_api' => env('GOOGLE_BUSINESS_LOCAL_POSTS_API', 'https://mybusiness.googleapis.com/v4'), + // Business Profile Performance API — location-level analytics. + 'performance_api' => env('GOOGLE_BUSINESS_PERFORMANCE_API', 'https://businessprofileperformance.googleapis.com/v1'), + // OAuth token endpoint, same host Google uses for every OAuth2 client. + 'oauth_api' => env('GOOGLE_BUSINESS_OAUTH_API', 'https://oauth2.googleapis.com'), + // Business Profile web UI — post URL fallback and the social-account profile link. + 'dashboard' => env('GOOGLE_BUSINESS_DASHBOARD', 'https://business.google.com'), + ], ], ]; diff --git a/database/factories/PostPlatformFactory.php b/database/factories/PostPlatformFactory.php index fce0da4ed..72da643ee 100644 --- a/database/factories/PostPlatformFactory.php +++ b/database/factories/PostPlatformFactory.php @@ -61,6 +61,14 @@ public function failed(): static ]); } + public function pendingReview(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => Status::PendingReview, + 'submitted_at' => now(), + ]); + } + public function linkedin(): static { return $this->state(fn (array $attributes) => [ @@ -132,6 +140,14 @@ public function pinterest(): static ]); } + public function googleBusiness(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::GoogleBusiness, + 'content_type' => ContentType::GoogleBusinessPost, + ]); + } + public function pinterestVideoPin(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index 927aba12a..3087f9fb9 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -109,6 +109,20 @@ public function pinterest(): static ]); } + public function googleBusiness(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::GoogleBusiness, + 'scopes' => Platform::GoogleBusiness->requiredPublishScopes(), + 'meta' => [ + 'location_id' => 'accounts/123456789/locations/987654321', + 'account_name' => 'accounts/123456789', + 'location_name' => 'locations/987654321', + 'google_user_id' => 'google-user-123', + ], + ]); + } + public function bluesky(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/migrations/2026_09_01_003002_add_review_tracking_to_post_platforms_table.php b/database/migrations/2026_09_01_003002_add_review_tracking_to_post_platforms_table.php new file mode 100644 index 000000000..047a923ee --- /dev/null +++ b/database/migrations/2026_09_01_003002_add_review_tracking_to_post_platforms_table.php @@ -0,0 +1,28 @@ +timestamp('submitted_at')->nullable()->after('published_at'); + $table->timestamp('last_reconciled_at')->nullable()->after('submitted_at'); + + $table->index(['status', 'last_reconciled_at']); + }); + } + + public function down(): void + { + Schema::table('post_platforms', function (Blueprint $table) { + $table->dropIndex(['status', 'last_reconciled_at']); + $table->dropColumn(['submitted_at', 'last_reconciled_at']); + }); + } +}; diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index 5f9e8f3f8..204b0dae4 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -41,6 +41,7 @@ 'mastodon' => 'اربط حسابك على Mastodon', 'telegram' => 'اربط قناة أو مجموعة على Telegram', 'discord' => 'اربط خادم Discord', + 'google_business' => 'اربط موقع Google Business Profile', ], 'disconnect_modal' => [ @@ -176,5 +177,16 @@ 'no_facebook_instagram_pages' => 'لم يتم العثور على صفحات Facebook مرتبطة بحسابات Instagram.', 'no_youtube_channels' => 'لم يتم العثور على قنوات YouTube. يرجى إنشاء قناة أولًا.', 'not_linkedin_admin' => 'أنت لست مشرفًا على أي صفحة LinkedIn.', + 'no_google_business_locations' => 'لم يتم العثور على مواقع Google Business Profile. يرجى التحقق من نشاطك التجاري أولاً.', + 'location_not_found' => 'الموقع غير موجود.', + 'error_connecting_location' => 'خطأ في ربط الموقع. يرجى المحاولة مرة أخرى.', + ], + + 'google_business' => [ + 'title' => 'اختر موقع النشاط التجاري', + 'description' => 'اختر الموقع الذي تريد ربطه', + 'no_locations' => 'لم يتم العثور على مواقع', + 'no_locations_description' => 'أنت لست مديرًا لأي موقع تم التحقق منه في Google Business Profile.', + 'choose' => 'اختيار', ], ]; diff --git a/lang/ar/analytics.php b/lang/ar/analytics.php index 33a8e9250..adee88c5c 100644 --- a/lang/ar/analytics.php +++ b/lang/ar/analytics.php @@ -7,6 +7,11 @@ 'select_account' => 'اختر حسابًا لعرض التحليلات.', 'no_data' => 'لا تتوفر بيانات تحليلات.', + 'search_keywords' => [ + 'title' => 'كلمات البحث', + 'estimated' => 'أخفت Google العدد الدقيق لهذا المصطلح', + ], + 'metrics' => [ 'avg_view_duration' => 'متوسط مدة المشاهدة (ث)', 'avg_view_percentage' => 'متوسط نسبة المشاهدة', @@ -52,5 +57,16 @@ 'video_views' => 'مشاهدات الفيديو', 'videos' => 'مقاطع الفيديو', 'views' => 'المشاهدات', + 'website_clicks' => 'نقرات الموقع الإلكتروني', + 'call_clicks' => 'نقرات الاتصال', + 'direction_requests' => 'طلبات الاتجاهات', + 'desktop_map_impressions' => 'مرات ظهور الخريطة على سطح المكتب', + 'mobile_map_impressions' => 'مرات ظهور الخريطة على الجوال', + 'desktop_search_impressions' => 'ظهور في بحث سطح المكتب', + 'mobile_search_impressions' => 'ظهور في بحث الجوال', + 'conversations' => 'المحادثات', + 'bookings' => 'الحجوزات', + 'food_orders' => 'طلبات الطعام', + 'food_menu_clicks' => 'النقرات على القائمة', ], ]; diff --git a/lang/ar/notifications.php b/lang/ar/notifications.php index 799c1bc7c..2dd8d925b 100644 --- a/lang/ar/notifications.php +++ b/lang/ar/notifications.php @@ -18,4 +18,12 @@ 'post_at_risk' => [ 'title' => '{1} منشور واحد قادم معرض للخطر|{2} منشوران قادمان معرضان للخطر|[3,10] :count منشورات قادمة معرضة للخطر|[11,*] :count منشورًا قادمًا معرضًا للخطر', ], + 'post_published' => [ + 'title' => 'تم نشر المنشور بنجاح', + 'body' => ':platforms', + ], + 'post_failed' => [ + 'title' => 'فشل نشر المنشور', + 'body' => 'فشل في: :platforms', + ], ]; diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 43445d185..5bd4cb358 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -186,6 +186,48 @@ 'embed_image' => 'رابط الصورة', 'embed_color' => 'اللون', ], + 'google_business' => [ + 'settings' => 'إعدادات ملف Google Business Profile', + 'posting_to' => 'النشر إلى', + 'topic_type_label' => 'نوع المنشور', + 'topic_type' => [ + 'standard' => 'ما الجديد', + 'event' => 'حدث', + 'offer' => 'عرض', + ], + 'cta_label' => 'زر', + 'cta_none' => 'لا شيء', + 'cta' => [ + 'book' => 'احجز', + 'order' => 'اطلب عبر الإنترنت', + 'shop' => 'اشتر', + 'learn_more' => 'تعرف على المزيد', + 'sign_up' => 'اشترك', + 'call' => 'اتصل الآن', + ], + 'cta_url' => 'رابط الزر', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'أدخل رابطًا لهذا الزر، أو اختر "لا شيء".', + 'event_title' => 'عنوان الحدث', + 'event_title_placeholder' => 'عنوان حدثك', + 'event_title_required' => 'أدخل عنوان الحدث.', + 'event_start_date' => 'البداية', + 'event_start_date_required' => 'أدخل تاريخ البدء.', + 'event_end_date' => 'النهاية', + 'event_end_date_required' => 'أدخل تاريخ الانتهاء.', + 'event_end_date_before_start' => 'يجب أن يكون تاريخ الانتهاء في يوم تاريخ البدء أو بعده.', + 'event_end_time_before_start' => 'يجب أن يكون وقت الانتهاء بعد وقت البدء.', + 'title_max' => 'يجب ألا يتجاوز العنوان 58 حرفًا.', + 'event_start_time' => 'وقت البدء', + 'event_end_time' => 'وقت الانتهاء', + 'offer_title' => 'عنوان العرض', + 'offer_title_placeholder' => 'أدخل عنوانًا لعرضك', + 'offer_title_required' => 'أدخل عنوان العرض.', + 'offer_coupon_code' => 'رمز القسيمة', + 'offer_redeem_url' => 'رابط العرض', + 'offer_terms' => 'الشروط والأحكام', + 'event_times_use_location' => 'الأوقات تتبع التوقيت المحلي للموقع، وليس متصفحك.', + ], 'warnings' => [ 'no_variant' => 'اختر نوع منشور للمتابعة.', 'requires_media' => 'يتطلب هذا النوع من المنشورات صورة أو فيديو واحدًا على الأقل.', @@ -310,6 +352,7 @@ 'metrics_loading' => 'جارٍ تحميل المقاييس…', 'metrics_unavailable' => 'المقاييس غير متاحة لهذه المنصة بعد.', 'metrics_empty' => 'لم تُرجَع أي مقاييس.', + 'pending_review' => 'يجري Google مراجعة هذا المنشور. سنحدّثه عند انتهاء المراجعة.', ], 'edit' => [ @@ -424,6 +467,8 @@ 'publishing' => 'جارٍ النشر...', 'retrying' => 'جارٍ إعادة المحاولة...', 'failed' => 'فشل', + 'pending_review' => 'قيد مراجعة Google', + 'rejected' => 'مرفوض', ], 'delete_modal' => [ @@ -553,6 +598,10 @@ 'label' => 'رسالة', 'description' => 'رسالة إلى قناة Discord مع وسائط وتضمينات اختيارية', ], + 'google_business_post' => [ + 'label' => 'منشور', + 'description' => 'يظهر على ملفك التجاري في البحث والخرائط', + ], ], 'platforms' => [ @@ -581,10 +630,25 @@ 'errors' => [ 'account_disconnected' => 'الحساب الاجتماعي مفصول', 'account_inactive' => 'الحساب الاجتماعي مُعطَّل', + 'target_disabled' => 'تم إيقاف وجهة النشر هذه', 'account_token_expired' => 'انتهت جلسة الحساب الاجتماعي — يرجى إعادة الربط', 'platform_unavailable' => 'المنصة غير متاحة مؤقتًا. سنعيد المحاولة قريبًا.', 'platform_unavailable_exhausted' => 'ظلت المنصة غير متاحة بعد عدة محاولات. يرجى المحاولة لاحقًا.', 'publishing_timed_out' => 'انتهت مهلة النشر. يرجى المحاولة مرة أخرى.', + 'rejected_in_review' => 'رفضت Google هذا المنشور أثناء المراجعة. عدّل المحتوى أو الصورة وحاول مرة أخرى.', + 'review_unconfirmed' => 'لم تؤكد Google حالة هذا المنشور. تحقّق من ملفك التجاري وحاول مرة أخرى.', + 'google_business' => [ + 'no_location' => 'حساب Google Business Profile هذا بلا موقع مُعد. أعد ربطه.', + 'permission_denied' => 'تم رفض الإذن. أعد الربط وأكّد الوصول إلى هذا الموقع.', + 'not_found' => 'الموقع غير موجود. ربما حُذف — أعد ربط الحساب.', + 'invalid_content' => 'المحتوى غير صالح. راجع تفاصيل المنشور.', + 'rate_limited' => 'تم تجاوز حد الطلبات. حاول مرة أخرى لاحقًا.', + 'server_error' => 'خطأ في خادم Google Business Profile. حاول مرة أخرى.', + 'rejected' => 'رفض Google Business Profile هذا المنشور. حاول مرة أخرى.', + 'event_dates_required' => 'يحتاج هذا المنشور إلى تاريخي بدء وانتهاء. أضفهما وحاول مرة أخرى.', + 'token_expired' => 'رمز وصول Google Business Profile غير صالح أو منتهٍ', + 'no_refresh_token' => 'لا يوجد رمز تحديث لحساب Google Business Profile', + ], ], 'delete' => [ diff --git a/lang/de/accounts.php b/lang/de/accounts.php index 3fd7bee0e..d6e306f26 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -43,6 +43,7 @@ 'mastodon' => 'Verbinde dein Mastodon-Konto', 'telegram' => 'Verbinde einen Telegram-Kanal oder eine Telegram-Gruppe', 'discord' => 'Verbinde einen Discord-Server', + 'google_business' => 'Verbinde einen Google Unternehmensprofil-Standort', ], 'disconnect_modal' => [ @@ -178,5 +179,16 @@ 'no_facebook_instagram_pages' => 'Keine Facebook-Seiten mit verknüpften Instagram-Konten gefunden.', 'no_youtube_channels' => 'Keine YouTube-Kanäle gefunden. Bitte erstelle zuerst einen Kanal.', 'not_linkedin_admin' => 'Du bist kein Administrator einer LinkedIn-Seite.', + 'no_google_business_locations' => 'Keine Google Unternehmensprofil-Standorte gefunden. Bestätige zuerst dein Unternehmen.', + 'location_not_found' => 'Standort nicht gefunden.', + 'error_connecting_location' => 'Fehler beim Verbinden des Standorts. Bitte versuche es erneut.', + ], + + 'google_business' => [ + 'title' => 'Standort auswählen', + 'description' => 'Wähle aus, welchen Standort du verbinden möchtest', + 'no_locations' => 'Keine Standorte gefunden', + 'no_locations_description' => 'Du bist kein Manager eines verifizierten Google Unternehmensprofil-Standorts.', + 'choose' => 'Auswählen', ], ]; diff --git a/lang/de/analytics.php b/lang/de/analytics.php index ea4826ece..8d9de7433 100644 --- a/lang/de/analytics.php +++ b/lang/de/analytics.php @@ -9,6 +9,11 @@ 'select_account' => 'Wähle ein Konto, um die Analysedaten anzuzeigen.', 'no_data' => 'Keine Analysedaten verfügbar.', + 'search_keywords' => [ + 'title' => 'Suchbegriffe', + 'estimated' => 'Google gibt für diesen Begriff keine genaue Zahl an', + ], + 'metrics' => [ 'avg_view_duration' => 'Durchschn. Wiedergabedauer (s)', 'avg_view_percentage' => 'Durchschn. Wiedergabeanteil', @@ -54,5 +59,16 @@ 'video_views' => 'Videoaufrufe', 'videos' => 'Videos', 'views' => 'Aufrufe', + 'website_clicks' => 'Website-Klicks', + 'call_clicks' => 'Anruf-Klicks', + 'direction_requests' => 'Routenanfragen', + 'desktop_map_impressions' => 'Kartenimpressionen auf dem Desktop', + 'mobile_map_impressions' => 'Kartenimpressionen auf Mobilgeräten', + 'desktop_search_impressions' => 'Suchimpressionen (Desktop)', + 'mobile_search_impressions' => 'Suchimpressionen (Mobil)', + 'conversations' => 'Chats', + 'bookings' => 'Buchungen', + 'food_orders' => 'Essensbestellungen', + 'food_menu_clicks' => 'Klicks auf die Speisekarte', ], ]; diff --git a/lang/de/notifications.php b/lang/de/notifications.php index 8abf45f2b..b04830000 100644 --- a/lang/de/notifications.php +++ b/lang/de/notifications.php @@ -18,4 +18,12 @@ 'post_at_risk' => [ 'title' => '{1} :count bevorstehender Beitrag ist gefährdet|[2,*] :count bevorstehende Beiträge sind gefährdet', ], + 'post_published' => [ + 'title' => 'Beitrag erfolgreich veröffentlicht', + 'body' => ':platforms', + ], + 'post_failed' => [ + 'title' => 'Veröffentlichung fehlgeschlagen', + 'body' => 'Fehlgeschlagen bei: :platforms', + ], ]; diff --git a/lang/de/posts.php b/lang/de/posts.php index 481bc0c33..f8ab34c30 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -188,6 +188,48 @@ 'embed_image' => 'Bild-URL', 'embed_color' => 'Farbe', ], + 'google_business' => [ + 'settings' => 'Google Business Profile-Einstellungen', + 'posting_to' => 'Veröffentlichen auf', + 'topic_type_label' => 'Beitragstyp', + 'topic_type' => [ + 'standard' => 'Neuigkeiten', + 'event' => 'Veranstaltung', + 'offer' => 'Angebot', + ], + 'cta_label' => 'Schaltfläche', + 'cta_none' => 'Keine', + 'cta' => [ + 'book' => 'Buchen', + 'order' => 'Online bestellen', + 'shop' => 'Kaufen', + 'learn_more' => 'Mehr erfahren', + 'sign_up' => 'Anmelden', + 'call' => 'Jetzt anrufen', + ], + 'cta_url' => 'Schaltflächenlink', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Gib einen Link für diese Schaltfläche ein oder wähle "Keine".', + 'event_title' => 'Veranstaltungstitel', + 'event_title_placeholder' => 'Dein Veranstaltungstitel', + 'event_title_required' => 'Gib einen Veranstaltungstitel ein.', + 'event_start_date' => 'Beginn', + 'event_start_date_required' => 'Gib ein Startdatum ein.', + 'event_end_date' => 'Ende', + 'event_end_date_required' => 'Gib ein Enddatum ein.', + 'event_end_date_before_start' => 'Das Enddatum muss am oder nach dem Startdatum liegen.', + 'event_end_time_before_start' => 'Die Endzeit muss nach der Startzeit liegen.', + 'title_max' => 'Der Titel darf höchstens 58 Zeichen lang sein.', + 'event_start_time' => 'Startzeit', + 'event_end_time' => 'Endzeit', + 'offer_title' => 'Angebotstitel', + 'offer_title_placeholder' => 'Gib einen Titel für dein Angebot ein', + 'offer_title_required' => 'Gib einen Angebotstitel ein.', + 'offer_coupon_code' => 'Gutscheincode', + 'offer_redeem_url' => 'Angebotslink', + 'offer_terms' => 'Geschäftsbedingungen', + 'event_times_use_location' => 'Die Zeiten gelten in der Ortszeit des Standorts, nicht in der Browserzeit.', + ], 'warnings' => [ 'no_variant' => 'Wähle einen Beitragstyp, um fortzufahren.', 'requires_media' => 'Dieser Beitragstyp erfordert mindestens ein Bild oder Video.', @@ -312,6 +354,7 @@ 'metrics_loading' => 'Kennzahlen werden geladen…', 'metrics_unavailable' => 'Kennzahlen für diese Plattform sind noch nicht verfügbar.', 'metrics_empty' => 'Keine Kennzahlen zurückgegeben.', + 'pending_review' => 'Google prüft diesen Beitrag. Wir aktualisieren ihn, sobald die Prüfung fertig ist.', ], 'edit' => [ @@ -426,6 +469,8 @@ 'publishing' => 'Wird veröffentlicht...', 'retrying' => 'Erneuter Versuch...', 'failed' => 'Fehlgeschlagen', + 'pending_review' => 'Google-Prüfung läuft', + 'rejected' => 'Abgelehnt', ], 'delete_modal' => [ @@ -555,6 +600,10 @@ 'label' => 'Nachricht', 'description' => 'Nachricht an einen Discord-Kanal mit optionalen Medien & Embeds', ], + 'google_business_post' => [ + 'label' => 'Beitrag', + 'description' => 'Wird in deinem Geschäftsprofil in Suche und Karten angezeigt', + ], ], 'platforms' => [ @@ -583,10 +632,25 @@ 'errors' => [ 'account_disconnected' => 'Social-Media-Konto ist getrennt', 'account_inactive' => 'Social-Media-Konto ist deaktiviert', + 'target_disabled' => 'Dieses Ziel wurde deaktiviert', 'account_token_expired' => 'Sitzung des Social-Media-Kontos abgelaufen – bitte erneut verbinden', '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.', + 'rejected_in_review' => 'Google hat diesen Beitrag bei der Prüfung abgelehnt. Bearbeite den Inhalt oder das Bild und versuche es erneut.', + 'review_unconfirmed' => 'Google hat den Status dieses Beitrags nicht bestätigt. Prüfe dein Unternehmensprofil und versuche es erneut.', + 'google_business' => [ + 'no_location' => 'Dieses Google-Business-Profile-Konto hat keinen Standort konfiguriert. Bitte erneut verbinden.', + 'permission_denied' => 'Zugriff verweigert. Verbinde erneut und bestätige den Zugriff auf diesen Standort.', + 'not_found' => 'Standort nicht gefunden. Er wurde möglicherweise gelöscht — bitte erneut verbinden.', + 'invalid_content' => 'Ungültiger Inhalt. Prüfe die Beitragsdetails.', + 'rate_limited' => 'Anfragelimit überschritten. Bitte später erneut versuchen.', + 'server_error' => 'Serverfehler bei Google Business Profile. Bitte erneut versuchen.', + 'rejected' => 'Google Business Profile hat diesen Beitrag abgelehnt. Bitte erneut versuchen.', + 'event_dates_required' => 'Dieser Beitrag braucht ein Start- und Enddatum. Füge sie hinzu und versuche es erneut.', + 'token_expired' => 'Das Google-Business-Profile-Zugriffstoken ist ungültig oder abgelaufen', + 'no_refresh_token' => 'Kein Refresh-Token für das Google-Business-Profile-Konto verfügbar', + ], ], 'delete' => [ diff --git a/lang/el/accounts.php b/lang/el/accounts.php index 7623f1e5b..d66f1fb71 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -41,6 +41,7 @@ 'mastodon' => 'Συνδέστε τον λογαριασμό σας Mastodon', 'telegram' => 'Συνδέστε ένα κανάλι ή ομάδα Telegram', 'discord' => 'Συνδέστε έναν διακομιστή Discord', + 'google_business' => 'Συνδέστε μια τοποθεσία Google Business Profile', ], 'disconnect_modal' => [ @@ -176,5 +177,16 @@ 'no_facebook_instagram_pages' => 'Δεν βρέθηκαν σελίδες Facebook με συνδεδεμένους λογαριασμούς Instagram.', 'no_youtube_channels' => 'Δεν βρέθηκαν κανάλια YouTube. Παρακαλούμε δημιουργήστε πρώτα ένα κανάλι.', 'not_linkedin_admin' => 'Δεν είστε διαχειριστής καμίας σελίδας LinkedIn.', + 'no_google_business_locations' => 'Δεν βρέθηκαν τοποθεσίες Google Business Profile. Επαληθεύστε πρώτα την επιχείρησή σας.', + 'location_not_found' => 'Η τοποθεσία δεν βρέθηκε.', + 'error_connecting_location' => 'Σφάλμα κατά τη σύνδεση της τοποθεσίας. Δοκιμάστε ξανά.', + ], + + 'google_business' => [ + 'title' => 'Επιλογή Τοποθεσίας Επιχείρησης', + 'description' => 'Επιλέξτε ποια τοποθεσία θέλετε να συνδέσετε', + 'no_locations' => 'Δεν βρέθηκαν τοποθεσίες', + 'no_locations_description' => 'Δεν είστε διαχειριστής καμίας επαληθευμένης τοποθεσίας Google Business Profile.', + 'choose' => 'Επιλογή', ], ]; diff --git a/lang/el/analytics.php b/lang/el/analytics.php index 1ae39d699..af402c594 100644 --- a/lang/el/analytics.php +++ b/lang/el/analytics.php @@ -7,6 +7,11 @@ 'select_account' => 'Επιλέξτε έναν λογαριασμό για να δείτε στατιστικά.', 'no_data' => 'Δεν υπάρχουν διαθέσιμα στατιστικά δεδομένα.', + 'search_keywords' => [ + 'title' => 'Όροι αναζήτησης', + 'estimated' => 'Η Google αποκρύπτει τον ακριβή αριθμό για αυτόν τον όρο', + ], + 'metrics' => [ 'avg_view_duration' => 'Μέση διάρκεια προβολής (δευτ.)', 'avg_view_percentage' => 'Μέσο ποσοστό προβολής', @@ -52,5 +57,16 @@ 'video_views' => 'Προβολές βίντεο', 'videos' => 'Βίντεο', 'views' => 'Προβολές', + 'website_clicks' => 'Κλικ ιστοσελίδας', + 'call_clicks' => 'Κλικ κλήσης', + 'direction_requests' => 'Αιτήματα κατεύθυνσης', + 'desktop_map_impressions' => 'Εμφανίσεις χάρτη σε υπολογιστή', + 'mobile_map_impressions' => 'Εμφανίσεις χάρτη σε κινητό', + 'desktop_search_impressions' => 'Εμφανίσεις στην Αναζήτηση (υπολογιστή)', + 'mobile_search_impressions' => 'Εμφανίσεις στην Αναζήτηση (κινητό)', + 'conversations' => 'Συνομιλίες', + 'bookings' => 'Κρατήσεις', + 'food_orders' => 'Παραγγελίες φαγητού', + 'food_menu_clicks' => 'Κλικ στο μενού', ], ]; diff --git a/lang/el/notifications.php b/lang/el/notifications.php index ebb9a5137..ae06d5af7 100644 --- a/lang/el/notifications.php +++ b/lang/el/notifications.php @@ -18,4 +18,12 @@ 'post_at_risk' => [ 'title' => '{1} :count επερχόμενη ανάρτηση κινδυνεύει|[2,*] :count επερχόμενες αναρτήσεις κινδυνεύουν', ], + 'post_published' => [ + 'title' => 'Η δημοσίευση ολοκληρώθηκε', + 'body' => ':platforms', + ], + 'post_failed' => [ + 'title' => 'Η δημοσίευση απέτυχε', + 'body' => 'Απέτυχε σε: :platforms', + ], ]; diff --git a/lang/el/posts.php b/lang/el/posts.php index dbf866950..4da982a4f 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -186,6 +186,48 @@ 'embed_image' => 'URL εικόνας', 'embed_color' => 'Χρώμα', ], + 'google_business' => [ + 'settings' => 'Ρυθμίσεις Google Business Profile', + 'posting_to' => 'Δημοσίευση σε', + 'topic_type_label' => 'Τύπος δημοσίευσης', + 'topic_type' => [ + 'standard' => 'Τι νέο υπάρχει', + 'event' => 'Εκδήλωση', + 'offer' => 'Προσφορά', + ], + 'cta_label' => 'Κουμπί', + 'cta_none' => 'Κανένα', + 'cta' => [ + 'book' => 'Κράτηση', + 'order' => 'Παραγγελία online', + 'shop' => 'Αγορά', + 'learn_more' => 'Μάθετε περισσότερα', + 'sign_up' => 'Εγγραφή', + 'call' => 'Καλέστε τώρα', + ], + 'cta_url' => 'Σύνδεσμος κουμπιού', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Εισάγετε έναν σύνδεσμο για αυτό το κουμπί ή επιλέξτε "Κανένα".', + 'event_title' => 'Τίτλος εκδήλωσης', + 'event_title_placeholder' => 'Ο τίτλος της εκδήλωσής σας', + 'event_title_required' => 'Εισάγετε τίτλο εκδήλωσης.', + 'event_start_date' => 'Έναρξη', + 'event_start_date_required' => 'Εισάγετε ημερομηνία έναρξης.', + 'event_end_date' => 'Λήξη', + 'event_end_date_required' => 'Εισάγετε ημερομηνία λήξης.', + 'event_end_date_before_start' => 'Η ημερομηνία λήξης πρέπει να είναι ίδια ή μεταγενέστερη της ημερομηνίας έναρξης.', + 'event_end_time_before_start' => 'Η ώρα λήξης πρέπει να είναι μετά την ώρα έναρξης.', + 'title_max' => 'Ο τίτλος πρέπει να έχει έως 58 χαρακτήρες.', + 'event_start_time' => 'Ώρα έναρξης', + 'event_end_time' => 'Ώρα λήξης', + 'offer_title' => 'Τίτλος προσφοράς', + 'offer_title_placeholder' => 'Εισάγετε έναν τίτλο για την προσφορά σας', + 'offer_title_required' => 'Εισάγετε τίτλο προσφοράς.', + 'offer_coupon_code' => 'Κωδικός κουπονιού', + 'offer_redeem_url' => 'Σύνδεσμος προσφοράς', + 'offer_terms' => 'Όροι και προϋποθέσεις', + 'event_times_use_location' => 'Οι ώρες ακολουθούν την τοπική ώρα της τοποθεσίας, όχι του προγράμματος περιήγησης.', + ], 'warnings' => [ 'no_variant' => 'Επιλέξτε έναν τύπο δημοσίευσης για να συνεχίσετε.', 'requires_media' => 'Αυτός ο τύπος δημοσίευσης απαιτεί τουλάχιστον μία εικόνα ή βίντεο.', @@ -310,6 +352,7 @@ 'metrics_loading' => 'Φόρτωση μετρήσεων…', 'metrics_unavailable' => 'Οι μετρήσεις δεν είναι ακόμη διαθέσιμες για αυτή την πλατφόρμα.', 'metrics_empty' => 'Δεν επιστράφηκαν μετρήσεις.', + 'pending_review' => 'Η Google εξετάζει αυτή την ανάρτηση. Θα την ενημερώσουμε όταν ολοκληρωθεί ο έλεγχος.', ], 'edit' => [ @@ -424,6 +467,8 @@ 'publishing' => 'Δημοσίευση...', 'retrying' => 'Επανάληψη...', 'failed' => 'Απέτυχε', + 'pending_review' => 'Σε έλεγχο από την Google', + 'rejected' => 'Απορρίφθηκε', ], 'delete_modal' => [ @@ -553,6 +598,10 @@ 'label' => 'Μήνυμα', 'description' => 'Μήνυμα σε κανάλι Discord με προαιρετικά πολυμέσα και embeds', ], + 'google_business_post' => [ + 'label' => 'Δημοσίευση', + 'description' => 'Εμφανίζεται στο Επιχειρηματικό σας Προφίλ στην Αναζήτηση και τους Χάρτες', + ], ], 'platforms' => [ @@ -581,10 +630,25 @@ 'errors' => [ 'account_disconnected' => 'Ο λογαριασμός κοινωνικού δικτύου έχει αποσυνδεθεί', 'account_inactive' => 'Ο λογαριασμός κοινωνικού δικτύου έχει απενεργοποιηθεί', + 'target_disabled' => 'Αυτός ο προορισμός απενεργοποιήθηκε', 'account_token_expired' => 'Η συνεδρία του λογαριασμού κοινωνικού δικτύου έληξε — επανασυνδεθείτε', 'platform_unavailable' => 'Η πλατφόρμα είναι προσωρινά μη διαθέσιμη. Θα δοκιμάσουμε ξανά σύντομα.', 'platform_unavailable_exhausted' => 'Η πλατφόρμα παρέμεινε μη διαθέσιμη μετά από αρκετές προσπάθειες. Δοκιμάστε ξανά αργότερα.', 'publishing_timed_out' => 'Η δημοσίευση έληξε. Δοκιμάστε ξανά.', + 'rejected_in_review' => 'Η Google απέρριψε αυτήν τη δημοσίευση κατά τον έλεγχο. Επεξεργάσου το περιεχόμενο ή την εικόνα και δοκίμασε ξανά.', + 'review_unconfirmed' => 'Η Google δεν επιβεβαίωσε την κατάσταση αυτής της δημοσίευσης. Έλεγξε το προφίλ σου και δοκίμασε ξανά.', + 'google_business' => [ + 'no_location' => 'Αυτός ο λογαριασμός Google Business Profile δεν έχει ρυθμισμένη τοποθεσία. Σύνδεσέ τον ξανά.', + 'permission_denied' => 'Άρνηση πρόσβασης. Σύνδεσε ξανά και επιβεβαίωσε την πρόσβαση σε αυτή την τοποθεσία.', + 'not_found' => 'Η τοποθεσία δεν βρέθηκε. Μπορεί να διαγράφηκε — σύνδεσε ξανά τον λογαριασμό.', + 'invalid_content' => 'Μη έγκυρο περιεχόμενο. Έλεγξε τα στοιχεία της ανάρτησης.', + 'rate_limited' => 'Ξεπεράστηκε το όριο αιτημάτων. Δοκίμασε ξανά αργότερα.', + 'server_error' => 'Σφάλμα διακομιστή του Google Business Profile. Δοκίμασε ξανά.', + 'rejected' => 'Το Google Business Profile απέρριψε αυτή την ανάρτηση. Δοκίμασε ξανά.', + 'event_dates_required' => 'Αυτή η ανάρτηση χρειάζεται ημερομηνία έναρξης και λήξης. Πρόσθεσέ τις και δοκίμασε ξανά.', + 'token_expired' => 'Το διακριτικό πρόσβασης του Google Business Profile είναι άκυρο ή έχει λήξει', + 'no_refresh_token' => 'Δεν υπάρχει refresh token για τον λογαριασμό Google Business Profile', + ], ], 'delete' => [ diff --git a/lang/en/accounts.php b/lang/en/accounts.php index c63dd1a5e..9e5882aff 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -41,6 +41,7 @@ 'mastodon' => 'Connect your Mastodon account', 'telegram' => 'Connect a Telegram channel or group', 'discord' => 'Connect a Discord server', + 'google_business' => 'Connect a Google Business Profile location', ], 'disconnect_modal' => [ @@ -176,5 +177,16 @@ 'no_facebook_instagram_pages' => 'No Facebook Pages with linked Instagram accounts found.', 'no_youtube_channels' => 'No YouTube channels found. Please create a channel first.', 'not_linkedin_admin' => 'You are not an administrator of any LinkedIn page.', + 'no_google_business_locations' => 'No Google Business Profile locations found. Verify your business first.', + 'location_not_found' => 'Location not found.', + 'error_connecting_location' => 'Error connecting location. Please try again.', + ], + + 'google_business' => [ + 'title' => 'Select Business Location', + 'description' => 'Choose which location you want to connect', + 'no_locations' => 'No locations found', + 'no_locations_description' => 'You are not a manager of any verified Google Business Profile location.', + 'choose' => 'Choose', ], ]; diff --git a/lang/en/analytics.php b/lang/en/analytics.php index 0d912b9e3..3212f6cac 100644 --- a/lang/en/analytics.php +++ b/lang/en/analytics.php @@ -7,6 +7,11 @@ 'select_account' => 'Select an account to view analytics.', 'no_data' => 'No analytics data available.', + 'search_keywords' => [ + 'title' => 'Search terms', + 'estimated' => 'Google withholds the exact count for this term', + ], + 'metrics' => [ 'avg_view_duration' => 'Avg. View Duration (s)', 'avg_view_percentage' => 'Avg. View Percentage', @@ -52,5 +57,16 @@ 'video_views' => 'Video Views', 'videos' => 'Videos', 'views' => 'Views', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', + 'desktop_search_impressions' => 'Desktop search impressions', + 'mobile_search_impressions' => 'Mobile search impressions', + 'conversations' => 'Conversations', + 'bookings' => 'Bookings', + 'food_orders' => 'Food orders', + 'food_menu_clicks' => 'Menu clicks', ], ]; diff --git a/lang/en/notifications.php b/lang/en/notifications.php index a51f6fa12..d79f3ac39 100644 --- a/lang/en/notifications.php +++ b/lang/en/notifications.php @@ -18,4 +18,12 @@ 'post_at_risk' => [ 'title' => '{1} :count upcoming post is at risk|[2,*] :count upcoming posts are at risk', ], + 'post_published' => [ + 'title' => 'Post published successfully', + 'body' => ':platforms', + ], + 'post_failed' => [ + 'title' => 'Post failed to publish', + 'body' => 'Failed on: :platforms', + ], ]; diff --git a/lang/en/posts.php b/lang/en/posts.php index 4add724af..12a91b91d 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -186,6 +186,48 @@ 'embed_image' => 'Image URL', 'embed_color' => 'Color', ], + 'google_business' => [ + 'settings' => 'Google Business Profile Settings', + 'posting_to' => 'Posting to', + 'topic_type_label' => 'Post type', + 'topic_type' => [ + 'standard' => "What's New", + 'event' => 'Event', + 'offer' => 'Offer', + ], + 'cta_label' => 'Button', + 'cta_none' => 'None', + 'cta' => [ + 'book' => 'Book', + 'order' => 'Order online', + 'shop' => 'Buy', + 'learn_more' => 'Learn more', + 'sign_up' => 'Sign up', + 'call' => 'Call now', + ], + 'cta_url' => 'Button link', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Enter a link for this button, or choose "None".', + 'event_title' => 'Event title', + 'event_title_placeholder' => 'Your event title', + 'event_title_required' => 'Enter an event title.', + 'event_start_date' => 'Start', + 'event_start_date_required' => 'Enter an event start date.', + 'event_end_date' => 'End', + 'event_end_date_required' => 'Enter an event end date.', + 'event_end_date_before_start' => 'The end date must be on or after the start date.', + 'event_end_time_before_start' => 'The end time must be after the start time.', + 'title_max' => 'Title must be 58 characters or fewer.', + 'event_start_time' => 'Start time', + 'event_end_time' => 'End time', + 'offer_title' => 'Offer title', + 'offer_title_placeholder' => 'Enter a title for your offer', + 'offer_title_required' => 'Enter an offer title.', + 'offer_coupon_code' => 'Coupon code', + 'offer_redeem_url' => 'Offer link', + 'offer_terms' => 'Terms & conditions', + 'event_times_use_location' => "Times follow the location's local time, not your browser.", + ], 'warnings' => [ 'no_variant' => 'Pick a post type to continue.', 'requires_media' => 'This post type requires at least one image or video.', @@ -310,6 +352,7 @@ 'metrics_loading' => 'Loading metrics…', 'metrics_unavailable' => 'Metrics unavailable for this platform yet.', 'metrics_empty' => 'No metrics returned.', + 'pending_review' => 'Google is reviewing this post. We will update it when the review finishes.', ], 'edit' => [ @@ -424,6 +467,8 @@ 'publishing' => 'Publishing...', 'retrying' => 'Retrying...', 'failed' => 'Failed', + 'pending_review' => 'In review by Google', + 'rejected' => 'Rejected', ], 'delete_modal' => [ @@ -553,6 +598,10 @@ 'label' => 'Message', 'description' => 'Message to a Discord channel with optional media & embeds', ], + 'google_business_post' => [ + 'label' => 'Post', + 'description' => 'Appears on your Business Profile in Search and Maps', + ], ], 'platforms' => [ @@ -581,10 +630,25 @@ 'errors' => [ 'account_disconnected' => 'Social account is disconnected', 'account_inactive' => 'Social account is deactivated', + 'target_disabled' => 'This destination was switched off', 'account_token_expired' => 'Social account session expired — please reconnect', '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.', + 'rejected_in_review' => 'Google rejected this post in review. Edit the content or image and try again.', + 'review_unconfirmed' => 'Google never confirmed this post. Check your Business Profile and try again.', + 'google_business' => [ + 'no_location' => 'This Google Business Profile account has no location configured. Please reconnect it.', + 'permission_denied' => 'Permission denied. Please reconnect and confirm access to this business location.', + 'not_found' => 'Business location not found. It may have been deleted — please reconnect.', + 'invalid_content' => 'Invalid post content. Please check your post details.', + 'rate_limited' => 'Rate limit exceeded. Please try again later.', + 'server_error' => 'Google Business Profile server error. Please try again.', + 'rejected' => 'Google Business Profile rejected this post. Please try again.', + 'event_dates_required' => 'This post needs a start and end date. Please add them and try again.', + 'token_expired' => 'Google Business Profile access token is invalid or expired', + 'no_refresh_token' => 'No refresh token available for Google Business Profile account', + ], ], 'delete' => [ diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 3fa56202e..684628a3b 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -41,6 +41,7 @@ 'mastodon' => 'Conecta tu cuenta de Mastodon', 'telegram' => 'Conecta un canal o grupo de Telegram', 'discord' => 'Conecta un servidor de Discord', + 'google_business' => 'Conecta una ubicación de Google Business Profile', ], 'disconnect_modal' => [ @@ -176,5 +177,16 @@ 'no_facebook_instagram_pages' => 'No se encontraron páginas de Facebook con cuentas de Instagram vinculadas.', 'no_youtube_channels' => 'No se encontraron canales de YouTube. Crea un canal primero.', 'not_linkedin_admin' => 'No eres administrador de ninguna página de LinkedIn.', + 'no_google_business_locations' => 'No se encontraron ubicaciones de Google Business Profile. Verifica tu negocio primero.', + 'location_not_found' => 'Ubicación no encontrada.', + 'error_connecting_location' => 'Error al conectar la ubicación. Por favor, inténtalo de nuevo.', + ], + + 'google_business' => [ + 'title' => 'Seleccionar Ubicación del Negocio', + 'description' => 'Elige qué ubicación quieres conectar', + 'no_locations' => 'No se encontraron ubicaciones', + 'no_locations_description' => 'No eres administrador de ninguna ubicación verificada de Google Business Profile.', + 'choose' => 'Elegir', ], ]; diff --git a/lang/es/analytics.php b/lang/es/analytics.php index e6075494f..5b97ec60c 100644 --- a/lang/es/analytics.php +++ b/lang/es/analytics.php @@ -7,6 +7,11 @@ 'select_account' => 'Selecciona una cuenta para ver analytics.', 'no_data' => 'No hay datos de analytics disponibles.', + 'search_keywords' => [ + 'title' => 'Términos de búsqueda', + 'estimated' => 'Google oculta el número exacto de este término', + ], + 'metrics' => [ 'avg_view_duration' => 'Duración Media (s)', 'avg_view_percentage' => 'Porcentaje Medio de Visualización', @@ -52,5 +57,16 @@ 'video_views' => 'Vistas de Vídeo', 'videos' => 'Vídeos', 'views' => 'Vistas', + 'website_clicks' => 'Clics de Sitio Web', + 'call_clicks' => 'Clics de Llamada', + 'direction_requests' => 'Solicitudes de Dirección', + 'desktop_map_impressions' => 'Impresiones de Mapa de Escritorio', + 'mobile_map_impressions' => 'Impresiones de Mapa Móvil', + 'desktop_search_impressions' => 'Impresiones en Búsqueda (escritorio)', + 'mobile_search_impressions' => 'Impresiones en Búsqueda (móvil)', + 'conversations' => 'Conversaciones', + 'bookings' => 'Reservas', + 'food_orders' => 'Pedidos de comida', + 'food_menu_clicks' => 'Clics en el menú', ], ]; diff --git a/lang/es/notifications.php b/lang/es/notifications.php index 7956aa38f..6d5ada0f1 100644 --- a/lang/es/notifications.php +++ b/lang/es/notifications.php @@ -18,4 +18,12 @@ 'post_at_risk' => [ 'title' => '{1} :count próxima publicación está en riesgo|[2,*] :count próximas publicaciones están en riesgo', ], + 'post_published' => [ + 'title' => 'Post publicado correctamente', + 'body' => ':platforms', + ], + 'post_failed' => [ + 'title' => 'El post no se pudo publicar', + 'body' => 'Falló en: :platforms', + ], ]; diff --git a/lang/es/posts.php b/lang/es/posts.php index 7eb08c207..ce9c2e387 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -186,6 +186,48 @@ 'embed_image' => 'URL de la imagen', 'embed_color' => 'Color', ], + 'google_business' => [ + 'settings' => 'Configuración de Google Business Profile', + 'posting_to' => 'Publicando en', + 'topic_type_label' => 'Tipo de publicación', + 'topic_type' => [ + 'standard' => 'Novedades', + 'event' => 'Evento', + 'offer' => 'Oferta', + ], + 'cta_label' => 'Botón', + 'cta_none' => 'Ninguno', + 'cta' => [ + 'book' => 'Reservar', + 'order' => 'Pedir online', + 'shop' => 'Comprar', + 'learn_more' => 'Más información', + 'sign_up' => 'Registrarse', + 'call' => 'Llamar ahora', + ], + 'cta_url' => 'Enlace del botón', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Ingresa un enlace para este botón, o elige "Ninguno".', + 'event_title' => 'Título del evento', + 'event_title_placeholder' => 'El título de tu evento', + 'event_title_required' => 'Ingresa un título de evento.', + 'event_start_date' => 'Inicio', + 'event_start_date_required' => 'Ingresa una fecha de inicio.', + 'event_end_date' => 'Fin', + 'event_end_date_required' => 'Ingresa una fecha de finalización.', + 'event_end_date_before_start' => 'La fecha de fin debe ser igual o posterior a la fecha de inicio.', + 'event_end_time_before_start' => 'La hora de fin debe ser posterior a la hora de inicio.', + 'title_max' => 'El título no puede superar los 58 caracteres.', + 'event_start_time' => 'Hora de inicio', + 'event_end_time' => 'Hora de finalización', + 'offer_title' => 'Título de la oferta', + 'offer_title_placeholder' => 'Ingresa un título para tu oferta', + 'offer_title_required' => 'Ingresa un título de oferta.', + 'offer_coupon_code' => 'Código de cupón', + 'offer_redeem_url' => 'Enlace de la oferta', + 'offer_terms' => 'Términos y condiciones', + 'event_times_use_location' => 'Los horarios siguen la hora local de la ubicación, no la del navegador.', + ], 'warnings' => [ 'no_variant' => 'Elige un tipo de publicación para continuar.', 'requires_media' => 'Este tipo requiere al menos una imagen o video.', @@ -310,6 +352,7 @@ 'metrics_loading' => 'Cargando métricas…', 'metrics_unavailable' => 'Métricas aún no disponibles para esta plataforma.', 'metrics_empty' => 'No se devolvieron métricas.', + 'pending_review' => 'Google está revisando esta publicación. La actualizaremos cuando termine la revisión.', ], 'edit' => [ @@ -424,6 +467,8 @@ 'publishing' => 'Publicando...', 'retrying' => 'Reintentando...', 'failed' => 'Fallido', + 'pending_review' => 'En revisión por Google', + 'rejected' => 'Rechazado', ], 'delete_modal' => [ @@ -553,6 +598,10 @@ 'label' => 'Mensaje', 'description' => 'Mensaje a un canal de Discord con multimedia y embeds opcionales', ], + 'google_business_post' => [ + 'label' => 'Publicación', + 'description' => 'Aparece en tu Perfil Empresarial en Búsqueda y Mapas', + ], ], 'platforms' => [ @@ -581,10 +630,25 @@ 'errors' => [ 'account_disconnected' => 'Cuenta social desconectada', 'account_inactive' => 'Cuenta social desactivada', + 'target_disabled' => 'Este destino se desactivó', 'account_token_expired' => 'Sesión de la cuenta social expirada — reconecta la cuenta', '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.', + 'rejected_in_review' => 'Google rechazó esta publicación durante la revisión. Edita el contenido o la imagen e inténtalo de nuevo.', + 'review_unconfirmed' => 'Google nunca confirmó esta publicación. Revisa tu Perfil de Empresa e inténtalo de nuevo.', + 'google_business' => [ + 'no_location' => 'Esta cuenta de Google Business Profile no tiene una ubicación configurada. Vuelve a conectarla.', + 'permission_denied' => 'Permiso denegado. Reconecta y confirma el acceso a esta ubicación.', + 'not_found' => 'Ubicación no encontrada. Puede que se haya eliminado — reconecta la cuenta.', + 'invalid_content' => 'Contenido no válido. Revisa los detalles de la publicación.', + 'rate_limited' => 'Se superó el límite de solicitudes. Inténtalo de nuevo más tarde.', + 'server_error' => 'Error del servidor de Google Business Profile. Inténtalo de nuevo.', + 'rejected' => 'Google Business Profile rechazó esta publicación. Inténtalo de nuevo.', + 'event_dates_required' => 'Esta publicación necesita una fecha de inicio y de fin. Añádelas e inténtalo de nuevo.', + 'token_expired' => 'El token de acceso de Google Business Profile no es válido o ha caducado', + 'no_refresh_token' => 'No hay refresh token disponible para la cuenta de Google Business Profile', + ], ], 'delete' => [ diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index 70da21867..7a1a4f2d0 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -41,6 +41,7 @@ 'mastodon' => 'Connectez votre compte Mastodon', 'telegram' => 'Connectez un canal ou un groupe Telegram', 'discord' => 'Connectez un serveur Discord', + 'google_business' => 'Connectez un établissement Google Business Profile', ], 'disconnect_modal' => [ @@ -176,5 +177,16 @@ 'no_facebook_instagram_pages' => 'Aucune page Facebook associée à un compte Instagram trouvée.', 'no_youtube_channels' => 'Aucune chaîne YouTube trouvée. Veuillez d\'abord créer une chaîne.', 'not_linkedin_admin' => 'Vous n\'êtes administrateur d\'aucune page LinkedIn.', + 'no_google_business_locations' => 'Aucun établissement Google Business Profile trouvé. Vérifiez d\'abord votre établissement.', + 'location_not_found' => 'Établissement introuvable.', + 'error_connecting_location' => 'Erreur lors de la connexion de l\'établissement. Veuillez réessayer.', + ], + + 'google_business' => [ + 'title' => 'Sélectionner l\'établissement', + 'description' => 'Choisissez l\'établissement que vous souhaitez connecter', + 'no_locations' => 'Aucun établissement trouvé', + 'no_locations_description' => 'Vous n\'êtes gestionnaire d\'aucun établissement Google Business Profile vérifié.', + 'choose' => 'Choisir', ], ]; diff --git a/lang/fr/analytics.php b/lang/fr/analytics.php index bc9fbb735..465dc8f56 100644 --- a/lang/fr/analytics.php +++ b/lang/fr/analytics.php @@ -7,6 +7,11 @@ 'select_account' => 'Sélectionnez un compte pour voir les statistiques.', 'no_data' => 'Aucune donnée statistique disponible.', + 'search_keywords' => [ + 'title' => 'Termes de recherche', + 'estimated' => 'Google ne communique pas le nombre exact pour ce terme', + ], + 'metrics' => [ 'avg_view_duration' => 'Durée de visionnage moy. (s)', 'avg_view_percentage' => 'Pourcentage de visionnage moy.', @@ -52,5 +57,16 @@ 'video_views' => 'Vues de la vidéo', 'videos' => 'Vidéos', 'views' => 'Vues', + 'website_clicks' => 'Clics sur le site web', + 'call_clicks' => 'Clics d\'appel', + 'direction_requests' => 'Demandes d\'itinéraire', + 'desktop_map_impressions' => 'Impressions de carte sur ordinateur', + 'mobile_map_impressions' => 'Impressions de carte sur mobile', + 'desktop_search_impressions' => 'Impressions dans la recherche (ordinateur)', + 'mobile_search_impressions' => 'Impressions dans la recherche (mobile)', + 'conversations' => 'Conversations', + 'bookings' => 'Réservations', + 'food_orders' => 'Commandes de repas', + 'food_menu_clicks' => 'Clics sur le menu', ], ]; diff --git a/lang/fr/notifications.php b/lang/fr/notifications.php index 4d12beed1..583338c76 100644 --- a/lang/fr/notifications.php +++ b/lang/fr/notifications.php @@ -18,4 +18,12 @@ 'post_at_risk' => [ 'title' => '{1} :count publication à venir est à risque|[2,*] :count publications à venir sont à risque', ], + 'post_published' => [ + 'title' => 'Publication réussie', + 'body' => ':platforms', + ], + 'post_failed' => [ + 'title' => 'Échec de la publication', + 'body' => 'Échec sur : :platforms', + ], ]; diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 183ae393e..59b72e357 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -186,6 +186,48 @@ 'embed_image' => 'URL de l\'image', 'embed_color' => 'Couleur', ], + 'google_business' => [ + 'settings' => 'Paramètres de Google Business Profile', + 'posting_to' => 'Publier sur', + 'topic_type_label' => 'Type de publication', + 'topic_type' => [ + 'standard' => 'Nouveautés', + 'event' => 'Événement', + 'offer' => 'Offre', + ], + 'cta_label' => 'Bouton', + 'cta_none' => 'Aucun', + 'cta' => [ + 'book' => 'Réserver', + 'order' => 'Commander en ligne', + 'shop' => 'Acheter', + 'learn_more' => 'En savoir plus', + 'sign_up' => 'S\'inscrire', + 'call' => 'Appelez maintenant', + ], + 'cta_url' => 'Lien du bouton', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Entrez un lien pour ce bouton, ou choisissez "Aucun".', + 'event_title' => 'Titre de l\'événement', + 'event_title_placeholder' => 'Le titre de votre événement', + 'event_title_required' => 'Entrez un titre d\'événement.', + 'event_start_date' => 'Début', + 'event_start_date_required' => 'Entrez une date de début.', + 'event_end_date' => 'Fin', + 'event_end_date_required' => 'Entrez une date de fin.', + 'event_end_date_before_start' => 'La date de fin doit être égale ou postérieure à la date de début.', + 'event_end_time_before_start' => 'L\'heure de fin doit être postérieure à l\'heure de début.', + 'title_max' => 'Le titre ne doit pas dépasser 58 caractères.', + 'event_start_time' => 'Heure de début', + 'event_end_time' => 'Heure de fin', + 'offer_title' => 'Titre de l\'offre', + 'offer_title_placeholder' => 'Entrez un titre pour votre offre', + 'offer_title_required' => 'Entrez un titre d\'offre.', + 'offer_coupon_code' => 'Code de coupon', + 'offer_redeem_url' => 'Lien de l\'offre', + 'offer_terms' => 'Conditions et termes', + 'event_times_use_location' => "Les horaires suivent l'heure locale de l'établissement, pas celle du navigateur.", + ], 'warnings' => [ 'no_variant' => 'Choisissez un type de publication pour continuer.', 'requires_media' => 'Ce type de publication nécessite au moins une image ou une vidéo.', @@ -310,6 +352,7 @@ 'metrics_loading' => 'Chargement des métriques…', 'metrics_unavailable' => 'Métriques pas encore disponibles pour cette plateforme.', 'metrics_empty' => 'Aucune métrique renvoyée.', + 'pending_review' => 'Google examine cette publication. Nous la mettrons à jour à la fin de la révision.', ], 'edit' => [ @@ -424,6 +467,8 @@ 'publishing' => 'Publication en cours...', 'retrying' => 'Nouvelle tentative...', 'failed' => 'Échec', + 'pending_review' => 'En cours d\'examen par Google', + 'rejected' => 'Refusé', ], 'delete_modal' => [ @@ -553,6 +598,10 @@ 'label' => 'Message', 'description' => 'Message vers un salon Discord avec médias et embeds facultatifs', ], + 'google_business_post' => [ + 'label' => 'Publication', + 'description' => 'Apparaît dans votre Profil Entreprise dans la Recherche et Cartes', + ], ], 'platforms' => [ @@ -581,10 +630,25 @@ 'errors' => [ 'account_disconnected' => 'Le compte social est déconnecté', 'account_inactive' => 'Le compte social est désactivé', + 'target_disabled' => 'Cette destination a été désactivée', 'account_token_expired' => 'La session du compte social a expiré — veuillez reconnecter', '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.', + 'rejected_in_review' => 'Google a refusé ce post lors de l\'examen. Modifie le contenu ou l\'image et réessaie.', + 'review_unconfirmed' => 'Google n\'a jamais confirmé ce post. Vérifie ta fiche d\'établissement et réessaie.', + 'google_business' => [ + 'no_location' => 'Ce compte Google Business Profile n\'a aucun établissement configuré. Reconnecte-le.', + 'permission_denied' => 'Autorisation refusée. Reconnecte le compte et confirme l\'accès à cet établissement.', + 'not_found' => 'Établissement introuvable. Il a peut-être été supprimé — reconnecte le compte.', + 'invalid_content' => 'Contenu invalide. Vérifie les détails du post.', + 'rate_limited' => 'Limite de requêtes dépassée. Réessaie plus tard.', + 'server_error' => 'Erreur serveur Google Business Profile. Réessaie.', + 'rejected' => 'Google Business Profile a refusé ce post. Réessaie.', + 'event_dates_required' => 'Ce post a besoin d\'une date de début et de fin. Ajoute-les et réessaie.', + 'token_expired' => 'Le jeton d\'accès Google Business Profile est invalide ou a expiré', + 'no_refresh_token' => 'Aucun jeton de rafraîchissement disponible pour le compte Google Business Profile', + ], ], 'delete' => [ diff --git a/lang/it/accounts.php b/lang/it/accounts.php index bb100bc11..1ea5fd2a9 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -41,6 +41,7 @@ 'mastodon' => 'Collega il tuo account Mastodon', 'telegram' => 'Collega un canale o gruppo Telegram', 'discord' => 'Collega un server Discord', + 'google_business' => 'Collega una sede di Google Business Profile', ], 'disconnect_modal' => [ @@ -176,5 +177,16 @@ 'no_facebook_instagram_pages' => 'Nessuna pagina Facebook con account Instagram collegati trovata.', 'no_youtube_channels' => 'Nessun canale YouTube trovato. Crea prima un canale.', 'not_linkedin_admin' => 'Non sei amministratore di alcuna pagina LinkedIn.', + 'no_google_business_locations' => 'Nessuna sede di Google Business Profile trovata. Verifica prima la tua attività.', + 'location_not_found' => 'Sede non trovata.', + 'error_connecting_location' => 'Errore nella connessione della sede. Riprova.', + ], + + 'google_business' => [ + 'title' => 'Seleziona Sede Attività', + 'description' => 'Scegli quale sede vuoi collegare', + 'no_locations' => 'Nessuna sede trovata', + 'no_locations_description' => 'Non sei gestore di nessuna sede verificata di Google Business Profile.', + 'choose' => 'Scegli', ], ]; diff --git a/lang/it/analytics.php b/lang/it/analytics.php index 2ea39ede4..b2963aecc 100644 --- a/lang/it/analytics.php +++ b/lang/it/analytics.php @@ -7,6 +7,11 @@ 'select_account' => 'Seleziona un account per visualizzare le statistiche.', 'no_data' => 'Nessun dato statistico disponibile.', + 'search_keywords' => [ + 'title' => 'Termini di ricerca', + 'estimated' => 'Google non fornisce il numero esatto per questo termine', + ], + 'metrics' => [ 'avg_view_duration' => 'Durata media visualizzazione (s)', 'avg_view_percentage' => 'Percentuale media di visualizzazione', @@ -52,5 +57,16 @@ 'video_views' => 'Visualizzazioni video', 'videos' => 'Video', 'views' => 'Visualizzazioni', + 'website_clicks' => 'Clic sul sito web', + 'call_clicks' => 'Clic sulla chiamata', + 'direction_requests' => 'Richieste di indicazioni', + 'desktop_map_impressions' => 'Impressioni della mappa su desktop', + 'mobile_map_impressions' => 'Impressioni della mappa mobile', + 'desktop_search_impressions' => 'Impressioni in Ricerca (desktop)', + 'mobile_search_impressions' => 'Impressioni in Ricerca (mobile)', + 'conversations' => 'Conversazioni', + 'bookings' => 'Prenotazioni', + 'food_orders' => 'Ordini di cibo', + 'food_menu_clicks' => 'Clic sul menu', ], ]; diff --git a/lang/it/notifications.php b/lang/it/notifications.php index ec283247d..5f27b20b0 100644 --- a/lang/it/notifications.php +++ b/lang/it/notifications.php @@ -18,4 +18,12 @@ 'post_at_risk' => [ 'title' => '{1} :count post imminente è a rischio|[2,*] :count post imminenti sono a rischio', ], + 'post_published' => [ + 'title' => 'Post pubblicato con successo', + 'body' => ':platforms', + ], + 'post_failed' => [ + 'title' => 'Pubblicazione non riuscita', + 'body' => 'Fallito su: :platforms', + ], ]; diff --git a/lang/it/posts.php b/lang/it/posts.php index f6b3198e9..c03e75e11 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -186,6 +186,48 @@ 'embed_image' => 'URL immagine', 'embed_color' => 'Colore', ], + 'google_business' => [ + 'settings' => 'Impostazioni Google Business Profile', + 'posting_to' => 'Pubblicazione su', + 'topic_type_label' => 'Tipo di post', + 'topic_type' => [ + 'standard' => 'Novità', + 'event' => 'Evento', + 'offer' => 'Offerta', + ], + 'cta_label' => 'Pulsante', + 'cta_none' => 'Nessuno', + 'cta' => [ + 'book' => 'Prenota', + 'order' => 'Ordina online', + 'shop' => 'Acquista', + 'learn_more' => 'Scopri di più', + 'sign_up' => 'Iscriviti', + 'call' => 'Chiama ora', + ], + 'cta_url' => 'Link del pulsante', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Inserisci un link per questo pulsante, oppure scegli "Nessuno".', + 'event_title' => 'Titolo evento', + 'event_title_placeholder' => 'Il titolo del tuo evento', + 'event_title_required' => 'Inserisci un titolo evento.', + 'event_start_date' => 'Inizio', + 'event_start_date_required' => 'Inserisci una data inizio.', + 'event_end_date' => 'Fine', + 'event_end_date_required' => 'Inserisci una data fine.', + 'event_end_date_before_start' => 'La data di fine deve essere uguale o successiva alla data di inizio.', + 'event_end_time_before_start' => 'L\'ora di fine deve essere successiva all\'ora di inizio.', + 'title_max' => 'Il titolo deve avere al massimo 58 caratteri.', + 'event_start_time' => 'Ora inizio', + 'event_end_time' => 'Ora fine', + 'offer_title' => 'Titolo offerta', + 'offer_title_placeholder' => 'Inserisci un titolo per la tua offerta', + 'offer_title_required' => 'Inserisci un titolo offerta.', + 'offer_coupon_code' => 'Codice coupon', + 'offer_redeem_url' => 'Link dell\'offerta', + 'offer_terms' => 'Termini e condizioni', + 'event_times_use_location' => "Gli orari seguono l'ora locale della sede, non quella del browser.", + ], 'warnings' => [ 'no_variant' => 'Scegli un tipo di post per continuare.', 'requires_media' => 'Questo tipo di post richiede almeno un\'immagine o un video.', @@ -310,6 +352,7 @@ 'metrics_loading' => 'Caricamento metriche…', 'metrics_unavailable' => 'Metriche non ancora disponibili per questa piattaforma.', 'metrics_empty' => 'Nessuna metrica restituita.', + 'pending_review' => 'Google sta esaminando questo post. Lo aggiorneremo a revisione conclusa.', ], 'edit' => [ @@ -424,6 +467,8 @@ 'publishing' => 'Pubblicazione in corso...', 'retrying' => 'Nuovo tentativo...', 'failed' => 'Non riuscito', + 'pending_review' => 'In revisione da Google', + 'rejected' => 'Rifiutato', ], 'delete_modal' => [ @@ -553,6 +598,10 @@ 'label' => 'Messaggio', 'description' => 'Messaggio a un canale Discord con media ed embed facoltativi', ], + 'google_business_post' => [ + 'label' => 'Pubblicazione', + 'description' => 'Appare nel tuo Profilo Aziendale in Ricerca e Mappe', + ], ], 'platforms' => [ @@ -581,10 +630,25 @@ 'errors' => [ 'account_disconnected' => 'L\'account social è scollegato', 'account_inactive' => 'L\'account social è disattivato', + 'target_disabled' => 'Questa destinazione è stata disattivata', 'account_token_expired' => 'Sessione dell\'account social scaduta — ricollegalo', '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.', + 'rejected_in_review' => 'Google ha rifiutato questo post durante la revisione. Modifica il contenuto o l\'immagine e riprova.', + 'review_unconfirmed' => 'Google non ha mai confermato questo post. Controlla il tuo profilo aziendale e riprova.', + 'google_business' => [ + 'no_location' => 'Questo account Google Business Profile non ha una sede configurata. Ricollegalo.', + 'permission_denied' => 'Permesso negato. Ricollega e conferma l\'accesso a questa sede.', + 'not_found' => 'Sede non trovata. Potrebbe essere stata eliminata — ricollega l\'account.', + 'invalid_content' => 'Contenuto non valido. Controlla i dettagli del post.', + 'rate_limited' => 'Limite di richieste superato. Riprova più tardi.', + 'server_error' => 'Errore del server di Google Business Profile. Riprova.', + 'rejected' => 'Google Business Profile ha rifiutato questo post. Riprova.', + 'event_dates_required' => 'Questo post richiede una data di inizio e di fine. Aggiungile e riprova.', + 'token_expired' => 'Il token di accesso di Google Business Profile non è valido o è scaduto', + 'no_refresh_token' => 'Nessun refresh token disponibile per l\'account Google Business Profile', + ], ], 'delete' => [ diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index 37e8c6b2b..0f0d63295 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -41,6 +41,7 @@ 'mastodon' => 'Mastodon アカウントを接続', 'telegram' => 'Telegram チャンネルまたはグループを接続', 'discord' => 'Discord サーバーを接続', + 'google_business' => 'Google ビジネス プロフィールの店舗を接続', ], 'disconnect_modal' => [ @@ -176,5 +177,16 @@ 'no_facebook_instagram_pages' => 'Instagram アカウントが連携された Facebook ページが見つかりません。', 'no_youtube_channels' => 'YouTube チャンネルが見つかりません。先にチャンネルを作成してください。', 'not_linkedin_admin' => 'あなたは管理者となっている LinkedIn ページがありません。', + 'no_google_business_locations' => 'Google ビジネス プロフィールの店舗が見つかりません。まずビジネスを確認してください。', + 'location_not_found' => '店舗が見つかりません。', + 'error_connecting_location' => '店舗の接続中にエラーが発生しました。もう一度お試しください。', + ], + + 'google_business' => [ + 'title' => 'ビジネス店舗を選択', + 'description' => '接続する店舗を選択してください', + 'no_locations' => '店舗が見つかりません', + 'no_locations_description' => '確認済みの Google ビジネス プロフィール店舗の管理者ではありません。', + 'choose' => '選択', ], ]; diff --git a/lang/ja/analytics.php b/lang/ja/analytics.php index 7007223de..cbb1c8588 100644 --- a/lang/ja/analytics.php +++ b/lang/ja/analytics.php @@ -7,6 +7,11 @@ 'select_account' => 'アナリティクスを表示するアカウントを選択してください。', 'no_data' => '利用できるアナリティクスデータがありません。', + 'search_keywords' => [ + 'title' => '検索キーワード', + 'estimated' => 'この語句の正確な件数は Google が非公開にしています', + ], + 'metrics' => [ 'avg_view_duration' => '平均視聴時間(秒)', 'avg_view_percentage' => '平均視聴率', @@ -52,5 +57,16 @@ 'video_views' => '動画再生数', 'videos' => '動画', 'views' => '再生数', + 'website_clicks' => 'ウェブサイトクリック', + 'call_clicks' => '通話クリック', + 'direction_requests' => '経路リクエスト', + 'desktop_map_impressions' => 'デスクトップ地図インプレッション', + 'mobile_map_impressions' => 'モバイル地図インプレッション', + 'desktop_search_impressions' => '検索での表示(デスクトップ)', + 'mobile_search_impressions' => '検索での表示(モバイル)', + 'conversations' => 'メッセージのやり取り', + 'bookings' => '予約', + 'food_orders' => 'フード注文', + 'food_menu_clicks' => 'メニューのクリック', ], ]; diff --git a/lang/ja/notifications.php b/lang/ja/notifications.php index b839c1990..8b0b58b99 100644 --- a/lang/ja/notifications.php +++ b/lang/ja/notifications.php @@ -18,4 +18,12 @@ 'post_at_risk' => [ 'title' => '{1} :count 件の予定投稿にリスクがあります|[2,*] :count 件の予定投稿にリスクがあります', ], + 'post_published' => [ + 'title' => '投稿を公開しました', + 'body' => ':platforms', + ], + 'post_failed' => [ + 'title' => '投稿の公開に失敗しました', + 'body' => '失敗: :platforms', + ], ]; diff --git a/lang/ja/posts.php b/lang/ja/posts.php index 731c23ebf..e066e8686 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -186,6 +186,48 @@ 'embed_image' => '画像 URL', 'embed_color' => '色', ], + 'google_business' => [ + 'settings' => 'Google Business Profile 設定', + 'posting_to' => '投稿先', + 'topic_type_label' => '投稿タイプ', + 'topic_type' => [ + 'standard' => '最新情報', + 'event' => 'イベント', + 'offer' => 'オファー', + ], + 'cta_label' => 'ボタン', + 'cta_none' => 'なし', + 'cta' => [ + 'book' => '予約', + 'order' => 'オンラインで注文', + 'shop' => '購入', + 'learn_more' => 'もっと詳しく', + 'sign_up' => '登録', + 'call' => '今すぐ電話', + ], + 'cta_url' => 'ボタンリンク', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'このボタンのリンクを入力するか、「なし」を選択してください。', + 'event_title' => 'イベントタイトル', + 'event_title_placeholder' => 'イベントのタイトル', + 'event_title_required' => 'イベントタイトルを入力してください。', + 'event_start_date' => '開始', + 'event_start_date_required' => '開始日を入力してください。', + 'event_end_date' => '終了', + 'event_end_date_required' => '終了日を入力してください。', + 'event_end_date_before_start' => '終了日は開始日以降にしてください。', + 'event_end_time_before_start' => '終了時刻は開始時刻より後にしてください。', + 'title_max' => 'タイトルは58文字以内にしてください。', + 'event_start_time' => '開始時刻', + 'event_end_time' => '終了時刻', + 'offer_title' => 'オファータイトル', + 'offer_title_placeholder' => 'オファーのタイトルを入力', + 'offer_title_required' => 'オファータイトルを入力してください。', + 'offer_coupon_code' => 'クーポンコード', + 'offer_redeem_url' => 'オファーリンク', + 'offer_terms' => '利用規約', + 'event_times_use_location' => '時刻はブラウザではなく、ビジネス所在地の現地時間です。', + ], 'warnings' => [ 'no_variant' => '続けるには投稿タイプを選択してください。', 'requires_media' => 'この投稿タイプには少なくとも 1 つの画像または動画が必要です。', @@ -310,6 +352,7 @@ 'metrics_loading' => 'メトリクスを読み込み中…', 'metrics_unavailable' => 'このプラットフォームのメトリクスはまだ利用できません。', 'metrics_empty' => 'メトリクスが返されませんでした。', + 'pending_review' => 'Googleがこの投稿を審査しています。審査が終わると更新します。', ], 'edit' => [ @@ -424,6 +467,8 @@ 'publishing' => '公開中...', 'retrying' => '再試行中...', 'failed' => '失敗', + 'pending_review' => 'Google が審査中', + 'rejected' => '拒否されました', ], 'delete_modal' => [ @@ -553,6 +598,10 @@ 'label' => 'メッセージ', 'description' => 'メディアと埋め込み(任意)付きの Discord チャンネルへのメッセージ', ], + 'google_business_post' => [ + 'label' => '投稿', + 'description' => 'ビジネス プロフィールに検索とマップで表示されます', + ], ], 'platforms' => [ @@ -581,10 +630,25 @@ 'errors' => [ 'account_disconnected' => 'ソーシャルアカウントの接続が解除されています', 'account_inactive' => 'ソーシャルアカウントが無効化されています', + 'target_disabled' => 'この投稿先はオフになりました', 'account_token_expired' => 'ソーシャルアカウントのセッションの有効期限が切れました — 再接続してください', 'platform_unavailable' => 'プラットフォームが一時的に利用できません。まもなく再試行します。', 'platform_unavailable_exhausted' => '何度か再試行しましたがプラットフォームが利用できませんでした。後でもう一度お試しください。', 'publishing_timed_out' => '公開がタイムアウトしました。もう一度お試しください。', + 'rejected_in_review' => 'Google の審査でこの投稿が拒否されました。本文または画像を修正して、もう一度お試しください。', + 'review_unconfirmed' => 'Google からこの投稿の結果が返りませんでした。ビジネス プロフィールを確認して、もう一度お試しください。', + 'google_business' => [ + 'no_location' => 'この Google ビジネス プロフィール アカウントには店舗が設定されていません。再接続してください。', + 'permission_denied' => '権限がありません。再接続して、この店舗へのアクセスを確認してください。', + 'not_found' => '店舗が見つかりません。削除された可能性があります。再接続してください。', + 'invalid_content' => '投稿内容が無効です。詳細を確認してください。', + 'rate_limited' => 'リクエスト上限を超えました。後でもう一度お試しください。', + 'server_error' => 'Google ビジネス プロフィールのサーバーエラーです。もう一度お試しください。', + 'rejected' => 'Google ビジネス プロフィールがこの投稿を拒否しました。もう一度お試しください。', + 'event_dates_required' => 'この投稿には開始日と終了日が必要です。追加してもう一度お試しください。', + 'token_expired' => 'Google ビジネス プロフィールのアクセストークンが無効または期限切れです', + 'no_refresh_token' => 'Google ビジネス プロフィール アカウントのリフレッシュトークンがありません', + ], ], 'delete' => [ diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index bcacc592f..86b63384f 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -41,6 +41,7 @@ 'mastodon' => 'Mastodon 계정을 연결하세요', 'telegram' => 'Telegram 채널 또는 그룹을 연결하세요', 'discord' => 'Discord 서버를 연결하세요', + 'google_business' => 'Google 비즈니스 프로필 위치를 연결하세요', ], 'disconnect_modal' => [ @@ -176,5 +177,16 @@ 'no_facebook_instagram_pages' => 'Instagram 계정이 연결된 Facebook 페이지를 찾을 수 없습니다.', 'no_youtube_channels' => 'YouTube 채널을 찾을 수 없습니다. 먼저 채널을 만드세요.', 'not_linkedin_admin' => '관리자로 있는 LinkedIn 페이지가 없습니다.', + 'no_google_business_locations' => 'Google 비즈니스 프로필 위치를 찾을 수 없습니다. 먼저 비즈니스를 인증하세요.', + 'location_not_found' => '위치를 찾을 수 없습니다.', + 'error_connecting_location' => '위치 연결 중 오류가 발생했습니다. 다시 시도해 주세요.', + ], + + 'google_business' => [ + 'title' => '비즈니스 위치 선택', + 'description' => '연결할 위치를 선택하세요', + 'no_locations' => '위치를 찾을 수 없습니다', + 'no_locations_description' => '인증된 Google 비즈니스 프로필 위치의 관리자가 아닙니다.', + 'choose' => '선택', ], ]; diff --git a/lang/ko/analytics.php b/lang/ko/analytics.php index 3093663e4..2001a0388 100644 --- a/lang/ko/analytics.php +++ b/lang/ko/analytics.php @@ -7,6 +7,11 @@ 'select_account' => '분석을 보려면 계정을 선택하세요.', 'no_data' => '사용 가능한 분석 데이터가 없습니다.', + 'search_keywords' => [ + 'title' => '검색어', + 'estimated' => 'Google가 이 검색어의 정확한 수를 공개하지 않습니다', + ], + 'metrics' => [ 'avg_view_duration' => '평균 시청 시간 (초)', 'avg_view_percentage' => '평균 시청률', @@ -52,5 +57,16 @@ 'video_views' => '동영상 조회수', 'videos' => '동영상', 'views' => '조회수', + 'website_clicks' => '웹사이트 클릭', + 'call_clicks' => '통화 클릭', + 'direction_requests' => '길찾기 요청', + 'desktop_map_impressions' => '데스크톱 지도 노출수', + 'mobile_map_impressions' => '모바일 지도 노출수', + 'desktop_search_impressions' => '검색 노출(데스크톱)', + 'mobile_search_impressions' => '검색 노출(모바일)', + 'conversations' => '대화', + 'bookings' => '예약', + 'food_orders' => '음식 주문', + 'food_menu_clicks' => '메뉴 클릭', ], ]; diff --git a/lang/ko/notifications.php b/lang/ko/notifications.php index 25abc4371..d5f151df9 100644 --- a/lang/ko/notifications.php +++ b/lang/ko/notifications.php @@ -18,4 +18,12 @@ 'post_at_risk' => [ 'title' => '{1} 예정된 게시물 :count건이 위험합니다|[2,*] 예정된 게시물 :count건이 위험합니다', ], + 'post_published' => [ + 'title' => '게시물이 게시되었습니다', + 'body' => ':platforms', + ], + 'post_failed' => [ + 'title' => '게시물 게시에 실패했습니다', + 'body' => '실패: :platforms', + ], ]; diff --git a/lang/ko/posts.php b/lang/ko/posts.php index f93cd8564..13bfc4421 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -186,6 +186,48 @@ 'embed_image' => '이미지 URL', 'embed_color' => '색상', ], + 'google_business' => [ + 'settings' => 'Google Business Profile 설정', + 'posting_to' => '게시 대상', + 'topic_type_label' => '게시물 유형', + 'topic_type' => [ + 'standard' => '새로운 소식', + 'event' => '이벤트', + 'offer' => '오퍼', + ], + 'cta_label' => '버튼', + 'cta_none' => '없음', + 'cta' => [ + 'book' => '예약', + 'order' => '온라인 주문', + 'shop' => '구매', + 'learn_more' => '자세히 알아보기', + 'sign_up' => '가입', + 'call' => '지금 전화', + ], + 'cta_url' => '버튼 링크', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => '이 버튼의 링크를 입력하거나 "없음"을 선택하세요.', + 'event_title' => '이벤트 제목', + 'event_title_placeholder' => '이벤트 제목을 입력하세요', + 'event_title_required' => '이벤트 제목을 입력하세요.', + 'event_start_date' => '시작', + 'event_start_date_required' => '시작 날짜를 입력하세요.', + 'event_end_date' => '종료', + 'event_end_date_required' => '종료 날짜를 입력하세요.', + 'event_end_date_before_start' => '종료일은 시작일 이후여야 합니다.', + 'event_end_time_before_start' => '종료 시간은 시작 시간 이후여야 합니다.', + 'title_max' => '제목은 58자 이하여야 합니다.', + 'event_start_time' => '시작 시간', + 'event_end_time' => '종료 시간', + 'offer_title' => '오퍼 제목', + 'offer_title_placeholder' => '오퍼 제목을 입력하세요', + 'offer_title_required' => '오퍼 제목을 입력하세요.', + 'offer_coupon_code' => '쿠폰 코드', + 'offer_redeem_url' => '오퍼 링크', + 'offer_terms' => '약관', + 'event_times_use_location' => '시간은 브라우저가 아니라 해당 위치의 현지 시간을 따릅니다.', + ], 'warnings' => [ 'no_variant' => '계속하려면 게시물 유형을 선택하세요.', 'requires_media' => '이 게시물 유형에는 이미지 또는 동영상이 하나 이상 필요합니다.', @@ -310,6 +352,7 @@ 'metrics_loading' => '지표를 불러오는 중…', 'metrics_unavailable' => '아직 이 플랫폼의 지표를 사용할 수 없습니다.', 'metrics_empty' => '반환된 지표가 없습니다.', + 'pending_review' => 'Google이 이 게시물을 검토 중입니다. 검토가 끝나면 업데이트합니다.', ], 'edit' => [ @@ -424,6 +467,8 @@ 'publishing' => '게시 중...', 'retrying' => '재시도 중...', 'failed' => '실패', + 'pending_review' => 'Google 검토 중', + 'rejected' => '거부됨', ], 'delete_modal' => [ @@ -553,6 +598,10 @@ 'label' => '메시지', 'description' => '선택적 미디어 및 임베드가 있는 Discord 채널 메시지', ], + 'google_business_post' => [ + 'label' => '게시물', + 'description' => '비즈니스 프로필에 검색 및 지도에 표시됩니다', + ], ], 'platforms' => [ @@ -581,10 +630,25 @@ 'errors' => [ 'account_disconnected' => '소셜 계정 연결이 해제되었습니다', 'account_inactive' => '소셜 계정이 비활성화되었습니다', + 'target_disabled' => '이 게시 대상이 꺼졌습니다', 'account_token_expired' => '소셜 계정 세션이 만료되었습니다 — 재연결하세요', 'platform_unavailable' => '플랫폼을 일시적으로 사용할 수 없습니다. 곧 다시 시도합니다.', 'platform_unavailable_exhausted' => '여러 번 재시도했지만 플랫폼을 사용할 수 없었습니다. 나중에 다시 시도하세요.', 'publishing_timed_out' => '게시에 시간이 초과되었습니다. 다시 시도하세요.', + 'rejected_in_review' => 'Google 검토에서 이 게시물이 거부되었습니다. 내용이나 이미지를 수정한 후 다시 시도하세요.', + 'review_unconfirmed' => 'Google가 이 게시물을 확인해 주지 않았습니다. 비즈니스 프로필을 확인한 후 다시 시도하세요.', + 'google_business' => [ + 'no_location' => '이 Google 비즈니스 프로필 계정에 설정된 위치가 없습니다. 다시 연결하세요.', + 'permission_denied' => '권한이 거부되었습니다. 다시 연결하고 이 위치에 대한 액세스를 확인하세요.', + 'not_found' => '위치를 찾을 수 없습니다. 삭제되었을 수 있습니다. 다시 연결하세요.', + 'invalid_content' => '게시물 내용이 올바르지 않습니다. 세부 정보를 확인하세요.', + 'rate_limited' => '요청 한도를 초과했습니다. 나중에 다시 시도하세요.', + 'server_error' => 'Google 비즈니스 프로필 서버 오류입니다. 다시 시도하세요.', + 'rejected' => 'Google 비즈니스 프로필이 이 게시물을 거부했습니다. 다시 시도하세요.', + 'event_dates_required' => '이 게시물에는 시작일과 종료일이 필요합니다. 추가한 후 다시 시도하세요.', + 'token_expired' => 'Google 비즈니스 프로필 액세스 토큰이 유효하지 않거나 만료되었습니다', + 'no_refresh_token' => 'Google 비즈니스 프로필 계정에 사용할 새로고침 토큰이 없습니다', + ], ], 'delete' => [ diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index 04850b074..07ddc9a97 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -41,6 +41,7 @@ 'mastodon' => 'Koppel je Mastodon-account', 'telegram' => 'Koppel een Telegram-kanaal of -groep', 'discord' => 'Koppel een Discord-server', + 'google_business' => 'Koppel een Google Bedrijfsprofiel-locatie', ], 'disconnect_modal' => [ @@ -176,5 +177,16 @@ 'no_facebook_instagram_pages' => 'Geen Facebook-pagina\'s met gekoppelde Instagram-accounts gevonden.', 'no_youtube_channels' => 'Geen YouTube-kanalen gevonden. Maak eerst een kanaal aan.', 'not_linkedin_admin' => 'Je bent geen beheerder van een LinkedIn-pagina.', + 'no_google_business_locations' => 'Geen Google Bedrijfsprofiel-locaties gevonden. Verifieer eerst je bedrijf.', + 'location_not_found' => 'Locatie niet gevonden.', + 'error_connecting_location' => 'Fout bij het koppelen van de locatie. Probeer het opnieuw.', + ], + + 'google_business' => [ + 'title' => 'Selecteer Bedrijfslocatie', + 'description' => 'Kies welke locatie je wilt koppelen', + 'no_locations' => 'Geen locaties gevonden', + 'no_locations_description' => 'Je bent geen beheerder van een geverifieerde Google Bedrijfsprofiel-locatie.', + 'choose' => 'Kiezen', ], ]; diff --git a/lang/nl/analytics.php b/lang/nl/analytics.php index 1b5748a36..70e81f62a 100644 --- a/lang/nl/analytics.php +++ b/lang/nl/analytics.php @@ -7,6 +7,11 @@ 'select_account' => 'Selecteer een account om statistieken te bekijken.', 'no_data' => 'Geen statistieken beschikbaar.', + 'search_keywords' => [ + 'title' => 'Zoektermen', + 'estimated' => 'Google geeft het exacte aantal voor deze term niet vrij', + ], + 'metrics' => [ 'avg_view_duration' => 'Gem. kijkduur (s)', 'avg_view_percentage' => 'Gem. kijkpercentage', @@ -52,5 +57,16 @@ 'video_views' => 'Videoweergaven', 'videos' => 'Video\'s', 'views' => 'Weergaven', + 'website_clicks' => 'Websiteklikken', + 'call_clicks' => 'Oproepklikken', + 'direction_requests' => 'Routeaanvragen', + 'desktop_map_impressions' => 'Kaartweergaven op desktop', + 'mobile_map_impressions' => 'Kaartweergaven op mobiel', + 'desktop_search_impressions' => 'Zoekvertoningen (desktop)', + 'mobile_search_impressions' => 'Zoekvertoningen (mobiel)', + 'conversations' => 'Gesprekken', + 'bookings' => 'Reserveringen', + 'food_orders' => 'Maaltijdbestellingen', + 'food_menu_clicks' => 'Menuklikken', ], ]; diff --git a/lang/nl/notifications.php b/lang/nl/notifications.php index f7de37d47..b93e6613d 100644 --- a/lang/nl/notifications.php +++ b/lang/nl/notifications.php @@ -18,4 +18,12 @@ 'post_at_risk' => [ 'title' => '{1} :count aankomende post loopt risico|[2,*] :count aankomende posts lopen risico', ], + 'post_published' => [ + 'title' => 'Post succesvol gepubliceerd', + 'body' => ':platforms', + ], + 'post_failed' => [ + 'title' => 'Publiceren van post mislukt', + 'body' => 'Mislukt op: :platforms', + ], ]; diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 63c37c615..3338a4118 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -186,6 +186,48 @@ 'embed_image' => 'Afbeeldings-URL', 'embed_color' => 'Kleur', ], + 'google_business' => [ + 'settings' => 'Google Business Profile-instellingen', + 'posting_to' => 'Posten naar', + 'topic_type_label' => 'Posttype', + 'topic_type' => [ + 'standard' => 'Wat is nieuw', + 'event' => 'Evenement', + 'offer' => 'Aanbod', + ], + 'cta_label' => 'Knop', + 'cta_none' => 'Geen', + 'cta' => [ + 'book' => 'Boeken', + 'order' => 'Online bestellen', + 'shop' => 'Kopen', + 'learn_more' => 'Meer informatie', + 'sign_up' => 'Aanmelden', + 'call' => 'Nu bellen', + ], + 'cta_url' => 'Koppelinformatie voor knop', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Voer een link in voor deze knop, of kies "Geen".', + 'event_title' => 'Evenementtitel', + 'event_title_placeholder' => 'De titel van je evenement', + 'event_title_required' => 'Voer een evenementtitel in.', + 'event_start_date' => 'Start', + 'event_start_date_required' => 'Voer een startdatum in.', + 'event_end_date' => 'Einde', + 'event_end_date_required' => 'Voer een einddatum in.', + 'event_end_date_before_start' => 'De einddatum moet op of na de startdatum liggen.', + 'event_end_time_before_start' => 'De eindtijd moet na de starttijd liggen.', + 'title_max' => 'De titel mag maximaal 58 tekens zijn.', + 'event_start_time' => 'Starttijd', + 'event_end_time' => 'Eindtijd', + 'offer_title' => 'Aanbodtitel', + 'offer_title_placeholder' => 'Voer een titel in voor je aanbod', + 'offer_title_required' => 'Voer een aanbodtitel in.', + 'offer_coupon_code' => 'Couponcode', + 'offer_redeem_url' => 'Aanbodlink', + 'offer_terms' => 'Voorwaarden', + 'event_times_use_location' => 'Tijden volgen de lokale tijd van de locatie, niet die van de browser.', + ], 'warnings' => [ 'no_variant' => 'Kies een posttype om door te gaan.', 'requires_media' => 'Dit posttype vereist ten minste één afbeelding of video.', @@ -310,6 +352,7 @@ 'metrics_loading' => 'Statistieken laden…', 'metrics_unavailable' => 'Statistieken zijn voor dit platform nog niet beschikbaar.', 'metrics_empty' => 'Geen statistieken teruggegeven.', + 'pending_review' => 'Google beoordeelt dit bericht. We werken het bij wanneer de review klaar is.', ], 'edit' => [ @@ -424,6 +467,8 @@ 'publishing' => 'Publiceren...', 'retrying' => 'Opnieuw proberen...', 'failed' => 'Mislukt', + 'pending_review' => 'In beoordeling bij Google', + 'rejected' => 'Afgewezen', ], 'delete_modal' => [ @@ -553,6 +598,10 @@ 'label' => 'Bericht', 'description' => 'Bericht naar een Discord-kanaal met optionele media en embeds', ], + 'google_business_post' => [ + 'label' => 'Bericht', + 'description' => 'Wordt weergegeven in je Bedrijfsprofiel in Zoeken en Kaarten', + ], ], 'platforms' => [ @@ -581,10 +630,25 @@ 'errors' => [ 'account_disconnected' => 'Social account is losgekoppeld', 'account_inactive' => 'Social account is gedeactiveerd', + 'target_disabled' => 'Deze bestemming is uitgeschakeld', 'account_token_expired' => 'Sessie van social account verlopen — koppel opnieuw', '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.', + 'rejected_in_review' => 'Google heeft dit bericht bij de beoordeling afgewezen. Pas de inhoud of afbeelding aan en probeer het opnieuw.', + 'review_unconfirmed' => 'Google heeft dit bericht nooit bevestigd. Controleer je bedrijfsprofiel en probeer het opnieuw.', + 'google_business' => [ + 'no_location' => 'Dit Google Business Profile-account heeft geen locatie ingesteld. Verbind het opnieuw.', + 'permission_denied' => 'Toegang geweigerd. Verbind opnieuw en bevestig toegang tot deze locatie.', + 'not_found' => 'Locatie niet gevonden. Die is mogelijk verwijderd — verbind opnieuw.', + 'invalid_content' => 'Ongeldige inhoud. Controleer de berichtgegevens.', + 'rate_limited' => 'Aanvraaglimiet overschreden. Probeer het later opnieuw.', + 'server_error' => 'Serverfout bij Google Business Profile. Probeer het opnieuw.', + 'rejected' => 'Google Business Profile heeft dit bericht geweigerd. Probeer het opnieuw.', + 'event_dates_required' => 'Dit bericht heeft een start- en einddatum nodig. Voeg ze toe en probeer het opnieuw.', + 'token_expired' => 'Het toegangstoken van Google Business Profile is ongeldig of verlopen', + 'no_refresh_token' => 'Geen refresh-token beschikbaar voor het Google Business Profile-account', + ], ], 'delete' => [ diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index 6e4a4ce73..af434c67e 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -41,6 +41,7 @@ 'mastodon' => 'Połącz swoje konto Mastodon', 'telegram' => 'Połącz kanał lub grupę na Telegramie', 'discord' => 'Połącz serwer Discord', + 'google_business' => 'Połącz lokalizację Google Business Profile', ], 'disconnect_modal' => [ @@ -176,5 +177,16 @@ 'no_facebook_instagram_pages' => 'Nie znaleziono stron na Facebooku z powiązanymi kontami Instagram.', 'no_youtube_channels' => 'Nie znaleziono kanałów YouTube. Najpierw utwórz kanał.', 'not_linkedin_admin' => 'Nie jesteś administratorem żadnej strony LinkedIn.', + 'no_google_business_locations' => 'Nie znaleziono lokalizacji Google Business Profile. Najpierw zweryfikuj swoją firmę.', + 'location_not_found' => 'Nie znaleziono lokalizacji.', + 'error_connecting_location' => 'Błąd podczas łączenia lokalizacji. Spróbuj ponownie.', + ], + + 'google_business' => [ + 'title' => 'Wybierz lokalizację firmy', + 'description' => 'Wybierz, którą lokalizację chcesz połączyć', + 'no_locations' => 'Nie znaleziono lokalizacji', + 'no_locations_description' => 'Nie jesteś menedżerem żadnej zweryfikowanej lokalizacji Google Business Profile.', + 'choose' => 'Wybierz', ], ]; diff --git a/lang/pl/analytics.php b/lang/pl/analytics.php index e9a4b683b..eb22d018e 100644 --- a/lang/pl/analytics.php +++ b/lang/pl/analytics.php @@ -7,6 +7,11 @@ 'select_account' => 'Wybierz konto, aby wyświetlić analitykę.', 'no_data' => 'Brak dostępnych danych analitycznych.', + 'search_keywords' => [ + 'title' => 'Wyszukiwane hasła', + 'estimated' => 'Google nie podaje dokładnej liczby dla tego hasła', + ], + 'metrics' => [ 'avg_view_duration' => 'Śr. czas oglądania (s)', 'avg_view_percentage' => 'Śr. procent obejrzenia', @@ -52,5 +57,16 @@ 'video_views' => 'Wyświetlenia wideo', 'videos' => 'Filmy', 'views' => 'Wyświetlenia', + 'website_clicks' => 'Kliknięcia witryny', + 'call_clicks' => 'Kliknięcia połączeń', + 'direction_requests' => 'Żądania tras', + 'desktop_map_impressions' => 'Wyświetlenia map na komputerze', + 'mobile_map_impressions' => 'Wyświetlenia map na urządzeniu mobilnym', + 'desktop_search_impressions' => 'Wyświetlenia w wyszukiwarce (komputer)', + 'mobile_search_impressions' => 'Wyświetlenia w wyszukiwarce (telefon)', + 'conversations' => 'Rozmowy', + 'bookings' => 'Rezerwacje', + 'food_orders' => 'Zamówienia jedzenia', + 'food_menu_clicks' => 'Kliknięcia menu', ], ]; diff --git a/lang/pl/notifications.php b/lang/pl/notifications.php index c0ba06860..bc8375947 100644 --- a/lang/pl/notifications.php +++ b/lang/pl/notifications.php @@ -18,4 +18,12 @@ 'post_at_risk' => [ 'title' => ':count nadchodzący post jest zagrożony|:count nadchodzące posty są zagrożone|:count nadchodzących postów jest zagrożonych', ], + 'post_published' => [ + 'title' => 'Post opublikowany pomyślnie', + 'body' => ':platforms', + ], + 'post_failed' => [ + 'title' => 'Nie udało się opublikować posta', + 'body' => 'Niepowodzenie na: :platforms', + ], ]; diff --git a/lang/pl/posts.php b/lang/pl/posts.php index e397a7474..8deb478fd 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -186,6 +186,48 @@ 'embed_image' => 'Adres URL obrazu', 'embed_color' => 'Kolor', ], + 'google_business' => [ + 'settings' => 'Ustawienia Google Business Profile', + 'posting_to' => 'Publikowanie na', + 'topic_type_label' => 'Typ posta', + 'topic_type' => [ + 'standard' => 'Nowości', + 'event' => 'Wydarzenie', + 'offer' => 'Oferta', + ], + 'cta_label' => 'Przycisk', + 'cta_none' => 'Brak', + 'cta' => [ + 'book' => 'Zarezerwuj', + 'order' => 'Zamów online', + 'shop' => 'Kup', + 'learn_more' => 'Dowiedz się więcej', + 'sign_up' => 'Zarejestruj się', + 'call' => 'Zadzwoń teraz', + ], + 'cta_url' => 'Link przycisku', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Wpisz link dla tego przycisku lub wybierz "Brak".', + 'event_title' => 'Tytuł wydarzenia', + 'event_title_placeholder' => 'Tytuł Twojego wydarzenia', + 'event_title_required' => 'Wpisz tytuł wydarzenia.', + 'event_start_date' => 'Start', + 'event_start_date_required' => 'Wpisz datę rozpoczęcia.', + 'event_end_date' => 'Koniec', + 'event_end_date_required' => 'Wpisz datę zakończenia.', + 'event_end_date_before_start' => 'Data zakończenia musi być równa lub późniejsza niż data rozpoczęcia.', + 'event_end_time_before_start' => 'Godzina zakończenia musi być późniejsza niż godzina rozpoczęcia.', + 'title_max' => 'Tytuł może mieć maksymalnie 58 znaków.', + 'event_start_time' => 'Czas rozpoczęcia', + 'event_end_time' => 'Czas zakończenia', + 'offer_title' => 'Tytuł oferty', + 'offer_title_placeholder' => 'Wpisz tytuł swojej oferty', + 'offer_title_required' => 'Wpisz tytuł oferty.', + 'offer_coupon_code' => 'Kod kuponu', + 'offer_redeem_url' => 'Link oferty', + 'offer_terms' => 'Warunki i postanowienia', + 'event_times_use_location' => 'Godziny są w czasie lokalnym lokalizacji, nie przeglądarki.', + ], 'warnings' => [ 'no_variant' => 'Wybierz typ posta, aby kontynuować.', 'requires_media' => 'Ten typ posta wymaga co najmniej jednego obrazu lub filmu.', @@ -310,6 +352,7 @@ 'metrics_loading' => 'Wczytywanie metryk…', 'metrics_unavailable' => 'Metryki dla tej platformy nie są jeszcze dostępne.', 'metrics_empty' => 'Nie zwrócono żadnych metryk.', + 'pending_review' => 'Google recenzuje ten wpis. Zaktualizujemy go po zakończeniu recenzji.', ], 'edit' => [ @@ -424,6 +467,8 @@ 'publishing' => 'Publikowanie...', 'retrying' => 'Ponawianie...', 'failed' => 'Nieudany', + 'pending_review' => 'W weryfikacji przez Google', + 'rejected' => 'Odrzucony', ], 'delete_modal' => [ @@ -553,6 +598,10 @@ 'label' => 'Wiadomość', 'description' => 'Wiadomość na kanale Discord z opcjonalnymi multimediami i osadzeniami', ], + 'google_business_post' => [ + 'label' => 'Post', + 'description' => 'Pojawia się w twoim Profilu Biznesowym w Wyszukiwaniu i Mapach', + ], ], 'platforms' => [ @@ -581,10 +630,25 @@ 'errors' => [ 'account_disconnected' => 'Konto społecznościowe jest rozłączone', 'account_inactive' => 'Konto społecznościowe jest dezaktywowane', + 'target_disabled' => 'Ten cel publikacji został wyłączony', 'account_token_expired' => 'Sesja konta społecznościowego wygasła — połącz ponownie', '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.', + 'rejected_in_review' => 'Google odrzuciło ten post podczas weryfikacji. Zmień treść lub obraz i spróbuj ponownie.', + 'review_unconfirmed' => 'Google nie potwierdziło tego posta. Sprawdź swój profil firmy i spróbuj ponownie.', + 'google_business' => [ + 'no_location' => 'To konto Google Business Profile nie ma skonfigurowanej lokalizacji. Połącz je ponownie.', + 'permission_denied' => 'Brak uprawnień. Połącz ponownie i potwierdź dostęp do tej lokalizacji.', + 'not_found' => 'Nie znaleziono lokalizacji. Mogła zostać usunięta — połącz konto ponownie.', + 'invalid_content' => 'Nieprawidłowa treść. Sprawdź szczegóły posta.', + 'rate_limited' => 'Przekroczono limit zapytań. Spróbuj ponownie później.', + 'server_error' => 'Błąd serwera Google Business Profile. Spróbuj ponownie.', + 'rejected' => 'Google Business Profile odrzuciło ten post. Spróbuj ponownie.', + 'event_dates_required' => 'Ten post wymaga daty rozpoczęcia i zakończenia. Dodaj je i spróbuj ponownie.', + 'token_expired' => 'Token dostępu Google Business Profile jest nieprawidłowy lub wygasł', + 'no_refresh_token' => 'Brak refresh tokena dla konta Google Business Profile', + ], ], 'delete' => [ diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index 8e54c04bb..eea4987d8 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -41,6 +41,7 @@ 'mastodon' => 'Conecte sua conta do Mastodon', 'telegram' => 'Conecte um canal ou grupo do Telegram', 'discord' => 'Conecte um servidor do Discord', + 'google_business' => 'Conecte um local do Google Business Profile', ], 'disconnect_modal' => [ @@ -176,5 +177,16 @@ 'no_facebook_instagram_pages' => 'Nenhuma página do Facebook com conta do Instagram vinculada foi encontrada.', 'no_youtube_channels' => 'Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.', 'not_linkedin_admin' => 'Você não é administrador de nenhuma página do LinkedIn.', + 'no_google_business_locations' => 'Nenhum local do Google Business Profile encontrado. Verifique sua empresa primeiro.', + 'location_not_found' => 'Local não encontrado.', + 'error_connecting_location' => 'Erro ao conectar local. Por favor, tente novamente.', + ], + + 'google_business' => [ + 'title' => 'Selecionar Local Comercial', + 'description' => 'Escolha qual local você deseja conectar', + 'no_locations' => 'Nenhum local encontrado', + 'no_locations_description' => 'Você não é gerente de nenhum local verificado do Google Business Profile.', + 'choose' => 'Escolher', ], ]; diff --git a/lang/pt-BR/analytics.php b/lang/pt-BR/analytics.php index 6e7eee85e..74bd1348e 100644 --- a/lang/pt-BR/analytics.php +++ b/lang/pt-BR/analytics.php @@ -7,6 +7,11 @@ 'select_account' => 'Selecione uma conta para ver analytics.', 'no_data' => 'Nenhum dado de analytics disponível.', + 'search_keywords' => [ + 'title' => 'Termos de busca', + 'estimated' => 'O Google não informa o número exato deste termo', + ], + 'metrics' => [ 'avg_view_duration' => 'Duração Média (s)', 'avg_view_percentage' => 'Visualização Média', @@ -52,5 +57,16 @@ 'video_views' => 'Visualizações de Vídeo', 'videos' => 'Vídeos', 'views' => 'Visualizações', + 'website_clicks' => 'Cliques no Site', + 'call_clicks' => 'Cliques de Chamada', + 'direction_requests' => 'Solicitações de Direções', + 'desktop_map_impressions' => 'Impressões de Mapa no Desktop', + 'mobile_map_impressions' => 'Impressões de Mapa no Celular', + 'desktop_search_impressions' => 'Impressões na Busca (desktop)', + 'mobile_search_impressions' => 'Impressões na Busca (celular)', + 'conversations' => 'Conversas', + 'bookings' => 'Reservas', + 'food_orders' => 'Pedidos de comida', + 'food_menu_clicks' => 'Cliques no cardápio', ], ]; diff --git a/lang/pt-BR/notifications.php b/lang/pt-BR/notifications.php index 43c078d5c..3691d3d1a 100644 --- a/lang/pt-BR/notifications.php +++ b/lang/pt-BR/notifications.php @@ -18,4 +18,12 @@ 'post_at_risk' => [ 'title' => '{1} :count post agendado está em risco|[2,*] :count posts agendados estão em risco', ], + 'post_published' => [ + 'title' => 'Post publicado com sucesso', + 'body' => ':platforms', + ], + 'post_failed' => [ + 'title' => 'Falha ao publicar o post', + 'body' => 'Falhou em: :platforms', + ], ]; diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 37018fac1..c6d04ee18 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -186,6 +186,48 @@ 'embed_image' => 'URL da imagem', 'embed_color' => 'Cor', ], + 'google_business' => [ + 'settings' => 'Configurações do Google Business Profile', + 'posting_to' => 'Publicando em', + 'topic_type_label' => 'Tipo de publicação', + 'topic_type' => [ + 'standard' => 'Novidades', + 'event' => 'Evento', + 'offer' => 'Oferta', + ], + 'cta_label' => 'Botão', + 'cta_none' => 'Nenhum', + 'cta' => [ + 'book' => 'Reservar', + 'order' => 'Pedir online', + 'shop' => 'Comprar', + 'learn_more' => 'Saiba mais', + 'sign_up' => 'Inscreva-se', + 'call' => 'Ligar agora', + ], + 'cta_url' => 'Link do botão', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Insira um link para este botão, ou escolha "Nenhum".', + 'event_title' => 'Título do evento', + 'event_title_placeholder' => 'O título do seu evento', + 'event_title_required' => 'Insira um título de evento.', + 'event_start_date' => 'Início', + 'event_start_date_required' => 'Insira uma data de início.', + 'event_end_date' => 'Término', + 'event_end_date_required' => 'Insira uma data de término.', + 'event_end_date_before_start' => 'A data de término deve ser igual ou posterior à data de início.', + 'event_end_time_before_start' => 'A hora de término deve ser posterior à hora de início.', + 'title_max' => 'O título deve ter no máximo 58 caracteres.', + 'event_start_time' => 'Hora de início', + 'event_end_time' => 'Hora de término', + 'offer_title' => 'Título da oferta', + 'offer_title_placeholder' => 'Insira um título para a sua oferta', + 'offer_title_required' => 'Insira um título de oferta.', + 'offer_coupon_code' => 'Código do cupom', + 'offer_redeem_url' => 'Link da oferta', + 'offer_terms' => 'Termos e condições', + 'event_times_use_location' => 'Os horários seguem o fuso da localização, não o do navegador.', + ], 'warnings' => [ 'no_variant' => 'Escolha um tipo de publicação para continuar.', 'requires_media' => 'Este tipo exige pelo menos uma imagem ou vídeo.', @@ -310,6 +352,7 @@ 'metrics_loading' => 'Carregando métricas…', 'metrics_unavailable' => 'Métricas ainda não disponíveis para esta plataforma.', 'metrics_empty' => 'Nenhuma métrica retornada.', + 'pending_review' => 'O Google está revisando este post. Atualizamos quando a revisão terminar.', ], 'edit' => [ @@ -424,6 +467,8 @@ 'publishing' => 'Publicando...', 'retrying' => 'Tentando novamente...', 'failed' => 'Falhou', + 'pending_review' => 'Em revisão pelo Google', + 'rejected' => 'Recusado', ], 'delete_modal' => [ @@ -553,6 +598,10 @@ 'label' => 'Mensagem', 'description' => 'Mensagem para um canal do Discord com mídia e embeds opcionais', ], + 'google_business_post' => [ + 'label' => 'Publicação', + 'description' => 'Aparece no seu Perfil Empresarial em Pesquisa e Mapas', + ], ], 'platforms' => [ @@ -581,10 +630,25 @@ 'errors' => [ 'account_disconnected' => 'Conta social está desconectada', 'account_inactive' => 'Conta social está desativada', + 'target_disabled' => 'Este destino foi desligado', 'account_token_expired' => 'Sessão da conta social expirou — reconecte a conta', '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.', + 'rejected_in_review' => 'O Google recusou este post na revisão. Edite o conteúdo ou a imagem e tente novamente.', + 'review_unconfirmed' => 'O Google nunca confirmou este post. Confira seu Perfil da Empresa e tente novamente.', + 'google_business' => [ + 'no_location' => 'Esta conta do Google Business Profile não tem um local configurado. Reconecte-a.', + 'permission_denied' => 'Permissão negada. Reconecte e confirme o acesso a este local.', + 'not_found' => 'Local não encontrado. Pode ter sido excluído — reconecte a conta.', + 'invalid_content' => 'Conteúdo inválido. Confira os detalhes do post.', + 'rate_limited' => 'Limite de requisições excedido. Tente novamente mais tarde.', + 'server_error' => 'Erro no servidor do Google Business Profile. Tente novamente.', + 'rejected' => 'O Google Business Profile recusou este post. Tente novamente.', + 'event_dates_required' => 'Este post precisa de uma data de início e de término. Adicione-as e tente novamente.', + 'token_expired' => 'O token de acesso do Google Business Profile é inválido ou expirou', + 'no_refresh_token' => 'Nenhum refresh token disponível para a conta do Google Business Profile', + ], ], 'delete' => [ diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index c237d979e..1efee374a 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -41,6 +41,7 @@ 'mastodon' => 'Подключите аккаунт Mastodon', 'telegram' => 'Подключите канал или группу Telegram', 'discord' => 'Подключите сервер Discord', + 'google_business' => 'Подключите местоположение Google Business Profile', ], 'disconnect_modal' => [ @@ -176,5 +177,16 @@ 'no_facebook_instagram_pages' => 'Не найдено страниц Facebook со связанными аккаунтами Instagram.', 'no_youtube_channels' => 'Каналы YouTube не найдены. Сначала создайте канал.', 'not_linkedin_admin' => 'Вы не являетесь администратором ни одной страницы LinkedIn.', + 'no_google_business_locations' => 'Местоположения Google Business Profile не найдены. Сначала подтвердите свою компанию.', + 'location_not_found' => 'Местоположение не найдено.', + 'error_connecting_location' => 'Ошибка при подключении местоположения. Пожалуйста, попробуйте снова.', + ], + + 'google_business' => [ + 'title' => 'Выберите местоположение компании', + 'description' => 'Выберите местоположение, которое хотите подключить', + 'no_locations' => 'Местоположения не найдены', + 'no_locations_description' => 'Вы не являетесь менеджером ни одного подтверждённого местоположения Google Business Profile.', + 'choose' => 'Выбрать', ], ]; diff --git a/lang/ru/analytics.php b/lang/ru/analytics.php index f4bf2796f..0abb9aae6 100644 --- a/lang/ru/analytics.php +++ b/lang/ru/analytics.php @@ -7,6 +7,11 @@ 'select_account' => 'Выберите аккаунт, чтобы посмотреть аналитику.', 'no_data' => 'Данные аналитики отсутствуют.', + 'search_keywords' => [ + 'title' => 'Поисковые запросы', + 'estimated' => 'Google не раскрывает точное число по этому запросу', + ], + 'metrics' => [ 'avg_view_duration' => 'Средняя длительность просмотра (с)', 'avg_view_percentage' => 'Средний процент просмотра', @@ -52,5 +57,16 @@ 'video_views' => 'Просмотры видео', 'videos' => 'Видео', 'views' => 'Просмотры', + 'website_clicks' => 'Клики по веб-сайту', + 'call_clicks' => 'Клики на звонок', + 'direction_requests' => 'Запросы маршрутов', + 'desktop_map_impressions' => 'Показы карты на ПК', + 'mobile_map_impressions' => 'Показы карты на мобильном', + 'desktop_search_impressions' => 'Показы в Поиске (компьютер)', + 'mobile_search_impressions' => 'Показы в Поиске (телефон)', + 'conversations' => 'Переписки', + 'bookings' => 'Бронирования', + 'food_orders' => 'Заказы еды', + 'food_menu_clicks' => 'Клики по меню', ], ]; diff --git a/lang/ru/notifications.php b/lang/ru/notifications.php index b92e54a4d..ffb9f3b73 100644 --- a/lang/ru/notifications.php +++ b/lang/ru/notifications.php @@ -18,4 +18,12 @@ 'post_at_risk' => [ 'title' => '{1} :count запланированный пост под угрозой|[2,4] :count запланированных поста под угрозой|[5,*] :count запланированных постов под угрозой', ], + 'post_published' => [ + 'title' => 'Пост успешно опубликован', + 'body' => ':platforms', + ], + 'post_failed' => [ + 'title' => 'Не удалось опубликовать пост', + 'body' => 'Ошибка на: :platforms', + ], ]; diff --git a/lang/ru/posts.php b/lang/ru/posts.php index 947601df7..4d557f7f4 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -186,6 +186,48 @@ 'embed_image' => 'URL изображения', 'embed_color' => 'Цвет', ], + 'google_business' => [ + 'settings' => 'Настройки Google Business Profile', + 'posting_to' => 'Публикация в', + 'topic_type_label' => 'Тип поста', + 'topic_type' => [ + 'standard' => 'Что нового', + 'event' => 'Событие', + 'offer' => 'Предложение', + ], + 'cta_label' => 'Кнопка', + 'cta_none' => 'Нет', + 'cta' => [ + 'book' => 'Забронировать', + 'order' => 'Заказать онлайн', + 'shop' => 'Купить', + 'learn_more' => 'Узнать больше', + 'sign_up' => 'Зарегистрироваться', + 'call' => 'Позвонить сейчас', + ], + 'cta_url' => 'Ссылка кнопки', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Введите ссылку для этой кнопки или выберите "Нет".', + 'event_title' => 'Название события', + 'event_title_placeholder' => 'Название вашего события', + 'event_title_required' => 'Введите название события.', + 'event_start_date' => 'Начало', + 'event_start_date_required' => 'Введите дату начала.', + 'event_end_date' => 'Конец', + 'event_end_date_required' => 'Введите дату окончания.', + 'event_end_date_before_start' => 'Дата окончания должна быть не раньше даты начала.', + 'event_end_time_before_start' => 'Время окончания должно быть позже времени начала.', + 'title_max' => 'Заголовок не должен превышать 58 символов.', + 'event_start_time' => 'Время начала', + 'event_end_time' => 'Время окончания', + 'offer_title' => 'Название предложения', + 'offer_title_placeholder' => 'Введите название вашего предложения', + 'offer_title_required' => 'Введите название предложения.', + 'offer_coupon_code' => 'Код купона', + 'offer_redeem_url' => 'Ссылка на предложение', + 'offer_terms' => 'Условия использования', + 'event_times_use_location' => 'Время указано по местному времени локации, а не браузера.', + ], 'warnings' => [ 'no_variant' => 'Выберите тип поста, чтобы продолжить.', 'requires_media' => 'Этот тип поста требует хотя бы одно изображение или видео.', @@ -310,6 +352,7 @@ 'metrics_loading' => 'Загрузка метрик…', 'metrics_unavailable' => 'Метрики для этой платформы пока недоступны.', 'metrics_empty' => 'Метрики отсутствуют.', + 'pending_review' => 'Google проверяет эту публикацию. Мы обновим её, когда проверка закончится.', ], 'edit' => [ @@ -424,6 +467,8 @@ 'publishing' => 'Публикация...', 'retrying' => 'Повторная попытка...', 'failed' => 'Ошибка', + 'pending_review' => 'На проверке в Google', + 'rejected' => 'Отклонено', ], 'delete_modal' => [ @@ -553,6 +598,10 @@ 'label' => 'Сообщение', 'description' => 'Сообщение в канал Discord с опциональным медиа и встраиваниями', ], + 'google_business_post' => [ + 'label' => 'Пост', + 'description' => 'Отображается в вашем Профиле компании в Поиске и Картах', + ], ], 'platforms' => [ @@ -581,10 +630,25 @@ 'errors' => [ 'account_disconnected' => 'Социальный аккаунт отключён', 'account_inactive' => 'Социальный аккаунт деактивирован', + 'target_disabled' => 'Этот канал публикации отключён', 'account_token_expired' => 'Сессия социального аккаунта истекла — переподключите', 'platform_unavailable' => 'Платформа временно недоступна. Мы повторим попытку вскоре.', 'platform_unavailable_exhausted' => 'Платформа оставалась недоступной после нескольких попыток. Попробуйте позже.', 'publishing_timed_out' => 'Публикация превысила время ожидания. Попробуйте снова.', + 'rejected_in_review' => 'Google отклонил эту публикацию при проверке. Измените текст или изображение и попробуйте снова.', + 'review_unconfirmed' => 'Google так и не подтвердил эту публикацию. Проверьте профиль компании и попробуйте снова.', + 'google_business' => [ + 'no_location' => 'У этого аккаунта Google Business Profile не настроена локация. Подключите его заново.', + 'permission_denied' => 'Доступ запрещён. Подключите аккаунт заново и подтвердите доступ к этой локации.', + 'not_found' => 'Локация не найдена. Возможно, её удалили — подключите аккаунт заново.', + 'invalid_content' => 'Недопустимое содержимое. Проверьте данные публикации.', + 'rate_limited' => 'Превышен лимит запросов. Попробуйте позже.', + 'server_error' => 'Ошибка сервера Google Business Profile. Попробуйте снова.', + 'rejected' => 'Google Business Profile отклонил эту публикацию. Попробуйте снова.', + 'event_dates_required' => 'Этой публикации нужны даты начала и окончания. Добавьте их и попробуйте снова.', + 'token_expired' => 'Токен доступа Google Business Profile недействителен или истёк', + 'no_refresh_token' => 'Нет refresh-токена для аккаунта Google Business Profile', + ], ], 'delete' => [ diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index 10e42ccd6..c6e976b5f 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -43,6 +43,7 @@ 'mastodon' => 'Mastodon hesabınızı bağlayın', 'telegram' => 'Bir Telegram kanalı veya grubu bağlayın', 'discord' => 'Bir Discord sunucusu bağlayın', + 'google_business' => 'Bir Google İşletme Profili konumu bağlayın', ], 'disconnect_modal' => [ @@ -178,5 +179,16 @@ 'no_facebook_instagram_pages' => 'Bağlı Instagram hesabı olan Facebook Sayfası bulunamadı.', 'no_youtube_channels' => 'YouTube kanalı bulunamadı. Lütfen önce bir kanal oluşturun.', 'not_linkedin_admin' => 'Hiçbir LinkedIn sayfasının yöneticisi değilsiniz.', + 'no_google_business_locations' => 'Google İşletme Profili konumu bulunamadı. Önce işletmenizi doğrulayın.', + 'location_not_found' => 'Konum bulunamadı.', + 'error_connecting_location' => 'Konum bağlanırken hata oluştu. Lütfen tekrar deneyin.', + ], + + 'google_business' => [ + 'title' => 'İşletme Konumu Seç', + 'description' => 'Bağlamak istediğiniz konumu seçin', + 'no_locations' => 'Konum bulunamadı', + 'no_locations_description' => 'Doğrulanmış herhangi bir Google İşletme Profili konumunun yöneticisi değilsiniz.', + 'choose' => 'Seç', ], ]; diff --git a/lang/tr/analytics.php b/lang/tr/analytics.php index dc3a0f6e3..15ff9f09c 100644 --- a/lang/tr/analytics.php +++ b/lang/tr/analytics.php @@ -9,6 +9,11 @@ 'select_account' => 'Analitiği görüntülemek için bir hesap seçin.', 'no_data' => 'Kullanılabilir analitik verisi yok.', + 'search_keywords' => [ + 'title' => 'Arama terimleri', + 'estimated' => 'Google bu terim için kesin sayıyı paylaşmıyor', + ], + 'metrics' => [ 'avg_view_duration' => 'Ort. İzlenme Süresi (sn)', 'avg_view_percentage' => 'Ort. İzlenme Yüzdesi', @@ -54,5 +59,16 @@ 'video_views' => 'Video Görüntülemeleri', 'videos' => 'Videolar', 'views' => 'Görüntülemeler', + 'website_clicks' => 'Web Sitesi Tıklamaları', + 'call_clicks' => 'Arama Tıklamaları', + 'direction_requests' => 'Rota Talepleri', + 'desktop_map_impressions' => 'Masaüstü Harita Gösterimleri', + 'mobile_map_impressions' => 'Mobil Harita Gösterimleri', + 'desktop_search_impressions' => 'Aramada gösterim (masaüstü)', + 'mobile_search_impressions' => 'Aramada gösterim (mobil)', + 'conversations' => 'Sohbetler', + 'bookings' => 'Rezervasyonlar', + 'food_orders' => 'Yemek siparişleri', + 'food_menu_clicks' => 'Menü tıklamaları', ], ]; diff --git a/lang/tr/notifications.php b/lang/tr/notifications.php index 75679f675..fd99184a7 100644 --- a/lang/tr/notifications.php +++ b/lang/tr/notifications.php @@ -18,4 +18,12 @@ 'post_at_risk' => [ 'title' => '{1} :count planlanan gönderi risk altında|[2,*] :count planlanan gönderi risk altında', ], + 'post_published' => [ + 'title' => 'Gönderi başarıyla yayınlandı', + 'body' => ':platforms', + ], + 'post_failed' => [ + 'title' => 'Gönderi yayınlanamadı', + 'body' => 'Başarısız: :platforms', + ], ]; diff --git a/lang/tr/posts.php b/lang/tr/posts.php index 5faa9a22f..b47e9c531 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -188,6 +188,48 @@ 'embed_image' => 'Görsel URL\'si', 'embed_color' => 'Renk', ], + 'google_business' => [ + 'settings' => 'Google Business Profile Ayarları', + 'posting_to' => 'Şuraya paylaşılıyor', + 'topic_type_label' => 'Gönderi türü', + 'topic_type' => [ + 'standard' => 'Yenilikler', + 'event' => 'Etkinlik', + 'offer' => 'Teklif', + ], + 'cta_label' => 'Düğme', + 'cta_none' => 'Yok', + 'cta' => [ + 'book' => 'Rezervasyon', + 'order' => 'Çevrimiçi sipariş', + 'shop' => 'Satın al', + 'learn_more' => 'Daha fazla bilgi', + 'sign_up' => 'Kaydol', + 'call' => 'Hemen ara', + ], + 'cta_url' => 'Düğme bağlantısı', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Bu düğme için bir bağlantı girin veya "Yok" seçeneğini seçin.', + 'event_title' => 'Etkinlik başlığı', + 'event_title_placeholder' => 'Etkinlik başlığın', + 'event_title_required' => 'Bir etkinlik başlığı girin.', + 'event_start_date' => 'Başlangıç', + 'event_start_date_required' => 'Başlangıç tarihini girin.', + 'event_end_date' => 'Bitiş', + 'event_end_date_required' => 'Bitiş tarihini girin.', + 'event_end_date_before_start' => 'Bitiş tarihi, başlangıç tarihiyle aynı veya daha sonra olmalıdır.', + 'event_end_time_before_start' => 'Bitiş saati, başlangıç saatinden sonra olmalıdır.', + 'title_max' => 'Başlık en fazla 58 karakter olabilir.', + 'event_start_time' => 'Başlangıç saati', + 'event_end_time' => 'Bitiş saati', + 'offer_title' => 'Teklif başlığı', + 'offer_title_placeholder' => 'Teklifin için bir başlık gir', + 'offer_title_required' => 'Bir teklif başlığı girin.', + 'offer_coupon_code' => 'Kupon kodu', + 'offer_redeem_url' => 'Teklif bağlantısı', + 'offer_terms' => 'Şartlar ve koşullar', + 'event_times_use_location' => 'Saatler tarayıcınızın değil, konumun yerel saatine göredir.', + ], 'warnings' => [ 'no_variant' => 'Devam etmek için bir gönderi türü seçin.', 'requires_media' => 'Bu gönderi türü en az bir görsel veya video gerektirir.', @@ -312,6 +354,7 @@ 'metrics_loading' => 'Metrikler yükleniyor…', 'metrics_unavailable' => 'Bu platform için metrikler henüz kullanılamıyor.', 'metrics_empty' => 'Metrik döndürülmedi.', + 'pending_review' => 'Google bu gönderiyi inceliyor. İnceleme bitince güncelleyeceğiz.', ], 'edit' => [ @@ -426,6 +469,8 @@ 'publishing' => 'Yayınlanıyor...', 'retrying' => 'Yeniden deneniyor...', 'failed' => 'Başarısız', + 'pending_review' => 'Google incelemesinde', + 'rejected' => 'Reddedildi', ], 'delete_modal' => [ @@ -555,6 +600,10 @@ 'label' => 'Mesaj', 'description' => 'İsteğe bağlı medya ve yerleştirmeler içeren Discord kanalına mesaj', ], + 'google_business_post' => [ + 'label' => 'Gönderi', + 'description' => 'İşletme Profilinde Arama ve Haritalar\'da görünür', + ], ], 'platforms' => [ @@ -583,10 +632,25 @@ 'errors' => [ 'account_disconnected' => 'Sosyal hesabın bağlantısı kesildi', 'account_inactive' => 'Sosyal hesap devre dışı bırakıldı', + 'target_disabled' => 'Bu hedef kapatıldı', 'account_token_expired' => 'Sosyal hesap oturumunun süresi doldu — lütfen yeniden bağlanın', '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.', + 'rejected_in_review' => 'Google bu gönderiyi incelemede reddetti. İçeriği veya görseli düzenleyip tekrar deneyin.', + 'review_unconfirmed' => 'Google bu gönderiyi hiç onaylamadı. İşletme Profili\'ni kontrol edip tekrar deneyin.', + 'google_business' => [ + 'no_location' => 'Bu Google Business Profile hesabında yapılandırılmış bir konum yok. Yeniden bağlayın.', + 'permission_denied' => 'İzin reddedildi. Yeniden bağlanın ve bu konuma erişimi onaylayın.', + 'not_found' => 'Konum bulunamadı. Silinmiş olabilir — hesabı yeniden bağlayın.', + 'invalid_content' => 'Geçersiz içerik. Gönderi ayrıntılarını kontrol edin.', + 'rate_limited' => 'İstek sınırı aşıldı. Daha sonra tekrar deneyin.', + 'server_error' => 'Google Business Profile sunucu hatası. Tekrar deneyin.', + 'rejected' => 'Google Business Profile bu gönderiyi reddetti. Tekrar deneyin.', + 'event_dates_required' => 'Bu gönderinin bir başlangıç ve bitiş tarihi olmalı. Ekleyip tekrar deneyin.', + 'token_expired' => 'Google Business Profile erişim belirteci geçersiz veya süresi dolmuş', + 'no_refresh_token' => 'Google Business Profile hesabı için yenileme belirteci yok', + ], ], 'delete' => [ diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index 464aaae51..e3f9402fd 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -41,6 +41,7 @@ 'mastodon' => 'Підключіть акаунт Mastodon', 'telegram' => 'Підключіть канал або групу Telegram', 'discord' => 'Підключіть сервер Discord', + 'google_business' => 'Підключіть місцезнаходження Google Business Profile', ], 'disconnect_modal' => [ @@ -176,5 +177,16 @@ 'no_facebook_instagram_pages' => 'Не знайдено сторінок Facebook із підключеними акаунтами Instagram.', 'no_youtube_channels' => 'Каналів YouTube не знайдено. Спочатку створіть канал.', 'not_linkedin_admin' => 'Ви не є адміністратором жодної сторінки LinkedIn.', + 'no_google_business_locations' => 'Місцезнаходження Google Business Profile не знайдено. Спочатку підтвердьте свій бізнес.', + 'location_not_found' => 'Місцезнаходження не знайдено.', + 'error_connecting_location' => 'Помилка під час підключення місцезнаходження. Спробуйте ще раз.', + ], + + 'google_business' => [ + 'title' => 'Виберіть місцезнаходження бізнесу', + 'description' => 'Виберіть місцезнаходження, яке хочете підключити', + 'no_locations' => 'Місцезнаходження не знайдено', + 'no_locations_description' => 'Ви не є менеджером жодного підтвердженого місцезнаходження Google Business Profile.', + 'choose' => 'Вибрати', ], ]; diff --git a/lang/uk/analytics.php b/lang/uk/analytics.php index 79ad1e8ab..615409b4d 100644 --- a/lang/uk/analytics.php +++ b/lang/uk/analytics.php @@ -7,6 +7,11 @@ 'select_account' => 'Виберіть акаунт, щоб переглянути аналітику.', 'no_data' => 'Дані аналітики недоступні.', + 'search_keywords' => [ + 'title' => 'Пошукові запити', + 'estimated' => 'Google не розкриває точне число за цим запитом', + ], + 'metrics' => [ 'avg_view_duration' => 'Сер. тривалість перегляду (с)', 'avg_view_percentage' => 'Сер. відсоток перегляду', @@ -52,5 +57,16 @@ 'video_views' => 'Перегляди відео', 'videos' => 'Відео', 'views' => 'Перегляди', + 'website_clicks' => 'Кліки по веб-сайту', + 'call_clicks' => 'Кліки на дзвінок', + 'direction_requests' => 'Запити маршрутів', + 'desktop_map_impressions' => 'Покази карти на комп\'ютері', + 'mobile_map_impressions' => 'Покази карти на мобільному', + 'desktop_search_impressions' => 'Покази в Пошуку (комп’ютер)', + 'mobile_search_impressions' => 'Покази в Пошуку (телефон)', + 'conversations' => 'Розмови', + 'bookings' => 'Бронювання', + 'food_orders' => 'Замовлення їжі', + 'food_menu_clicks' => 'Кліки по меню', ], ]; diff --git a/lang/uk/notifications.php b/lang/uk/notifications.php index bd93ce6f9..f02e71afc 100644 --- a/lang/uk/notifications.php +++ b/lang/uk/notifications.php @@ -18,4 +18,12 @@ 'post_at_risk' => [ 'title' => '{1} :count запланована публікація під загрозою|[2,*] :count заплановані публікації під загрозою', ], + 'post_published' => [ + 'title' => 'Пост успішно опубліковано', + 'body' => ':platforms', + ], + 'post_failed' => [ + 'title' => 'Не вдалося опублікувати пост', + 'body' => 'Помилка на: :platforms', + ], ]; diff --git a/lang/uk/posts.php b/lang/uk/posts.php index 4ee02d9fe..5fc6a6cd2 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -186,6 +186,48 @@ 'embed_image' => 'URL зображення', 'embed_color' => 'Колір', ], + 'google_business' => [ + 'settings' => 'Налаштування Google Business Profile', + 'posting_to' => 'Публікація в', + 'topic_type_label' => 'Тип посту', + 'topic_type' => [ + 'standard' => 'Що нового', + 'event' => 'Подія', + 'offer' => 'Пропозиція', + ], + 'cta_label' => 'Кнопка', + 'cta_none' => 'Немає', + 'cta' => [ + 'book' => 'Забронювати', + 'order' => 'Замовити онлайн', + 'shop' => 'Купити', + 'learn_more' => 'Дізнатись більше', + 'sign_up' => 'Зареєструватися', + 'call' => 'Позвонити зараз', + ], + 'cta_url' => 'Посилання кнопки', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => 'Введіть посилання для цієї кнопки або виберіть "Немає".', + 'event_title' => 'Назва події', + 'event_title_placeholder' => 'Назва вашої події', + 'event_title_required' => 'Введіть назву події.', + 'event_start_date' => 'Початок', + 'event_start_date_required' => 'Введіть дату початку.', + 'event_end_date' => 'Кінець', + 'event_end_date_required' => 'Введіть дату завершення.', + 'event_end_date_before_start' => 'Дата завершення має бути не раніше дати початку.', + 'event_end_time_before_start' => 'Час завершення має бути пізніше за час початку.', + 'title_max' => 'Заголовок має бути не довшим за 58 символів.', + 'event_start_time' => 'Час початку', + 'event_end_time' => 'Час завершення', + 'offer_title' => 'Назва пропозиції', + 'offer_title_placeholder' => 'Введіть назву вашої пропозиції', + 'offer_title_required' => 'Введіть назву пропозиції.', + 'offer_coupon_code' => 'Код купона', + 'offer_redeem_url' => 'Посилання пропозиції', + 'offer_terms' => 'Умови та положення', + 'event_times_use_location' => 'Час указано за місцевим часом локації, а не браузера.', + ], 'warnings' => [ 'no_variant' => 'Виберіть тип поста, щоб продовжити.', 'requires_media' => 'Цей тип поста потребує принаймні одного зображення або відео.', @@ -310,6 +352,7 @@ 'metrics_loading' => 'Завантаження метрик…', 'metrics_unavailable' => 'Метрики для цієї платформи поки недоступні.', 'metrics_empty' => 'Метрики відсутні.', + 'pending_review' => 'Google перевіряє цю публікацію. Оновимо її, коли перевірка завершиться.', ], 'edit' => [ @@ -424,6 +467,8 @@ 'publishing' => 'Публікується...', 'retrying' => 'Повторна спроба...', 'failed' => 'Помилка', + 'pending_review' => 'На перевірці в Google', + 'rejected' => 'Відхилено', ], 'delete_modal' => [ @@ -553,6 +598,10 @@ 'label' => 'Повідомлення', 'description' => 'Повідомлення в канал Discord із необов’язковим медіа та вбудовуваннями', ], + 'google_business_post' => [ + 'label' => 'Публікація', + 'description' => 'Відображається у вашому Профілі компанії в Пошуку та Картах', + ], ], 'platforms' => [ @@ -581,10 +630,25 @@ 'errors' => [ 'account_disconnected' => 'Соціальний акаунт відключено', 'account_inactive' => 'Соціальний акаунт деактивовано', + 'target_disabled' => 'Цей канал публікації вимкнено', 'account_token_expired' => 'Сесія соціального акаунта закінчилася — перепідключіть', 'platform_unavailable' => 'Платформа тимчасово недоступна. Ми спробуємо знову незабаром.', 'platform_unavailable_exhausted' => 'Платформа залишалася недоступною після кількох спроб. Спробуйте пізніше.', 'publishing_timed_out' => 'Публікація перевищила час очікування. Спробуйте ще раз.', + 'rejected_in_review' => 'Google відхилив цю публікацію під час перевірки. Змініть текст або зображення та спробуйте ще раз.', + 'review_unconfirmed' => 'Google так і не підтвердив цю публікацію. Перевірте профіль компанії та спробуйте ще раз.', + 'google_business' => [ + 'no_location' => 'У цього акаунта Google Business Profile немає налаштованої локації. Підключіть його знову.', + 'permission_denied' => 'Доступ заборонено. Підключіть акаунт знову й підтвердьте доступ до цієї локації.', + 'not_found' => 'Локацію не знайдено. Можливо, її видалили — підключіть акаунт знову.', + 'invalid_content' => 'Неприпустимий вміст. Перевірте деталі публікації.', + 'rate_limited' => 'Перевищено ліміт запитів. Спробуйте пізніше.', + 'server_error' => 'Помилка сервера Google Business Profile. Спробуйте ще раз.', + 'rejected' => 'Google Business Profile відхилив цю публікацію. Спробуйте ще раз.', + 'event_dates_required' => 'Цій публікації потрібні дати початку і завершення. Додайте їх і спробуйте ще раз.', + 'token_expired' => 'Токен доступу Google Business Profile недійсний або прострочений', + 'no_refresh_token' => 'Немає refresh-токена для акаунта Google Business Profile', + ], ], 'delete' => [ diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index bc47536b1..691f8778d 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -41,6 +41,7 @@ 'mastodon' => '连接你的 Mastodon 账号', 'telegram' => '连接一个 Telegram 频道或群组', 'discord' => '连接一个 Discord 服务器', + 'google_business' => '连接一个 Google 商家资料位置', ], 'disconnect_modal' => [ @@ -176,5 +177,16 @@ 'no_facebook_instagram_pages' => '未找到关联了 Instagram 账号的 Facebook 主页。', 'no_youtube_channels' => '未找到 YouTube 频道,请先创建一个频道。', 'not_linkedin_admin' => '你不是任何 LinkedIn 页面的管理员。', + 'no_google_business_locations' => '未找到 Google 商家资料位置。请先验证您的企业。', + 'location_not_found' => '未找到该位置。', + 'error_connecting_location' => '连接位置时出错。请重试。', + ], + + 'google_business' => [ + 'title' => '选择商家位置', + 'description' => '选择您要连接的位置', + 'no_locations' => '未找到位置', + 'no_locations_description' => '您不是任何已验证的 Google 商家资料位置的管理者。', + 'choose' => '选择', ], ]; diff --git a/lang/zh/analytics.php b/lang/zh/analytics.php index 539d2de22..f8840f2d1 100644 --- a/lang/zh/analytics.php +++ b/lang/zh/analytics.php @@ -7,6 +7,11 @@ 'select_account' => '选择一个账号以查看分析数据。', 'no_data' => '暂无分析数据。', + 'search_keywords' => [ + 'title' => '搜索词', + 'estimated' => 'Google 不公开该搜索词的精确次数', + ], + 'metrics' => [ 'avg_view_duration' => '平均观看时长(秒)', 'avg_view_percentage' => '平均观看比例', @@ -52,5 +57,16 @@ 'video_views' => '视频观看量', 'videos' => '视频', 'views' => '观看量', + 'website_clicks' => '网站点击', + 'call_clicks' => '通话点击', + 'direction_requests' => '方向请求', + 'desktop_map_impressions' => '桌面地图展示量', + 'mobile_map_impressions' => '移动地图展示量', + 'desktop_search_impressions' => '搜索展示次数(桌面端)', + 'mobile_search_impressions' => '搜索展示次数(移动端)', + 'conversations' => '对话数', + 'bookings' => '预订数', + 'food_orders' => '餐饮订单', + 'food_menu_clicks' => '菜单点击', ], ]; diff --git a/lang/zh/notifications.php b/lang/zh/notifications.php index 949e7e1ca..7a76d88c3 100644 --- a/lang/zh/notifications.php +++ b/lang/zh/notifications.php @@ -18,4 +18,12 @@ 'post_at_risk' => [ 'title' => '{1} 有 :count 篇待发布的帖子存在风险|[2,*] 有 :count 篇待发布的帖子存在风险', ], + 'post_published' => [ + 'title' => '帖子已成功发布', + 'body' => ':platforms', + ], + 'post_failed' => [ + 'title' => '帖子发布失败', + 'body' => '失败平台::platforms', + ], ]; diff --git a/lang/zh/posts.php b/lang/zh/posts.php index c8e519378..271cfeff9 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -186,6 +186,48 @@ 'embed_image' => '图片 URL', 'embed_color' => '颜色', ], + 'google_business' => [ + 'settings' => 'Google Business Profile 设置', + 'posting_to' => '发布到', + 'topic_type_label' => '帖子类型', + 'topic_type' => [ + 'standard' => '最新动态', + 'event' => '活动', + 'offer' => '优惠', + ], + 'cta_label' => '按钮', + 'cta_none' => '无', + 'cta' => [ + 'book' => '预订', + 'order' => '在线订购', + 'shop' => '购买', + 'learn_more' => '了解详情', + 'sign_up' => '注册', + 'call' => '立即致电', + ], + 'cta_url' => '按钮链接', + 'cta_url_placeholder' => 'https://example.com', + 'cta_url_required' => '请输入此按钮的链接,或选择"无"。', + 'event_title' => '活动标题', + 'event_title_placeholder' => '您的活动标题', + 'event_title_required' => '请输入活动标题。', + 'event_start_date' => '开始', + 'event_start_date_required' => '请输入开始日期。', + 'event_end_date' => '结束', + 'event_end_date_required' => '请输入结束日期。', + 'event_end_date_before_start' => '结束日期必须等于或晚于开始日期。', + 'event_end_time_before_start' => '结束时间必须晚于开始时间。', + 'title_max' => '标题不能超过 58 个字符。', + 'event_start_time' => '开始时间', + 'event_end_time' => '结束时间', + 'offer_title' => '优惠标题', + 'offer_title_placeholder' => '输入优惠标题', + 'offer_title_required' => '请输入优惠标题。', + 'offer_coupon_code' => '优惠券代码', + 'offer_redeem_url' => '优惠链接', + 'offer_terms' => '条款和条件', + 'event_times_use_location' => '时间以门店当地时间为准,而不是浏览器时区。', + ], 'warnings' => [ 'no_variant' => '请选择一个帖子类型以继续。', 'requires_media' => '此帖子类型至少需要一张图片或一个视频。', @@ -310,6 +352,7 @@ 'metrics_loading' => '正在加载指标…', 'metrics_unavailable' => '此平台暂无可用指标。', 'metrics_empty' => '未返回任何指标。', + 'pending_review' => 'Google 正在审核这篇帖子。审核结束后我们会更新状态。', ], 'edit' => [ @@ -424,6 +467,8 @@ 'publishing' => '发布中…', 'retrying' => '重试中…', 'failed' => '已失败', + 'pending_review' => 'Google 审核中', + 'rejected' => '已拒绝', ], 'delete_modal' => [ @@ -553,6 +598,10 @@ 'label' => '消息', 'description' => '发送到 Discord 频道的消息,可附带媒体和嵌入内容', ], + 'google_business_post' => [ + 'label' => '帖子', + 'description' => '在搜索和地图中显示在您的商业资料中', + ], ], 'platforms' => [ @@ -581,10 +630,25 @@ 'errors' => [ 'account_disconnected' => '社交账号已断开连接', 'account_inactive' => '社交账号已停用', + 'target_disabled' => '已关闭此发布目标', 'account_token_expired' => '社交账号会话已过期——请重新连接', 'platform_unavailable' => '平台暂时不可用。我们稍后会重试。', 'platform_unavailable_exhausted' => '多次重试后平台仍不可用。请稍后再试。', 'publishing_timed_out' => '发布超时。请重试。', + 'rejected_in_review' => 'Google 在审核中拒绝了这篇帖子。请修改内容或图片后重试。', + 'review_unconfirmed' => 'Google 始终未确认这篇帖子。请检查你的商家资料后重试。', + 'google_business' => [ + 'no_location' => '此 Google 商家资料账号尚未配置地点。请重新连接。', + 'permission_denied' => '权限被拒绝。请重新连接并确认对此地点的访问权限。', + 'not_found' => '找不到该地点。可能已被删除 — 请重新连接。', + 'invalid_content' => '帖子内容无效。请检查帖子详情。', + 'rate_limited' => '已超出请求限额。请稍后再试。', + 'server_error' => 'Google 商家资料服务器错误。请重试。', + 'rejected' => 'Google 商家资料拒绝了这篇帖子。请重试。', + 'event_dates_required' => '这篇帖子需要开始日期和结束日期。请添加后重试。', + 'token_expired' => 'Google 商家资料访问令牌无效或已过期', + 'no_refresh_token' => '此 Google 商家资料账号没有可用的刷新令牌', + ], ], 'delete' => [ diff --git a/public/images/accounts/google_business.png b/public/images/accounts/google_business.png new file mode 100644 index 000000000..2ff782a9e Binary files /dev/null and b/public/images/accounts/google_business.png differ diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue index 813d5dff0..69134079a 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -1,9 +1,10 @@ + + diff --git a/resources/js/components/billing/PlanPicker.vue b/resources/js/components/billing/PlanPicker.vue index 47ecc3166..402a593b5 100644 --- a/resources/js/components/billing/PlanPicker.vue +++ b/resources/js/components/billing/PlanPicker.vue @@ -82,6 +82,7 @@ const PLAN_NETWORKS = [ Platform.Mastodon, Platform.Telegram, Platform.Discord, + Platform.GoogleBusiness, ] as const; const SHARED_FEATURES: Omit[] = [ diff --git a/resources/js/components/posts/editor/GoogleBusinessSettings.vue b/resources/js/components/posts/editor/GoogleBusinessSettings.vue new file mode 100644 index 000000000..5cc1f7099 --- /dev/null +++ b/resources/js/components/posts/editor/GoogleBusinessSettings.vue @@ -0,0 +1,290 @@ + + + diff --git a/resources/js/components/posts/editor/ScheduleTab.vue b/resources/js/components/posts/editor/ScheduleTab.vue index 4239fc1af..f2ac69f9a 100644 --- a/resources/js/components/posts/editor/ScheduleTab.vue +++ b/resources/js/components/posts/editor/ScheduleTab.vue @@ -182,31 +182,41 @@ const channels = computed(() =>
-
- - - - {{ getPlatformDisplayName(pp) }} -
-
- {{ $t('posts.edit.status.published') }} - - - {{ $t('posts.edit.status.publishing') }} - - {{ $t('posts.edit.status.failed') }} - - - +
+
+ + + + {{ getPlatformDisplayName(pp) }} +
+
+ {{ $t('posts.edit.status.published') }} + + + {{ $t('posts.edit.status.publishing') }} + + {{ $t('posts.edit.status.pending_review') }} + {{ $t('posts.edit.status.rejected') }} + {{ $t('posts.edit.status.failed') }} + + + +
+

+ {{ pp.error_message }} +

diff --git a/resources/js/components/posts/previews/GoogleBusinessPreview.vue b/resources/js/components/posts/previews/GoogleBusinessPreview.vue new file mode 100644 index 000000000..93e3a173d --- /dev/null +++ b/resources/js/components/posts/previews/GoogleBusinessPreview.vue @@ -0,0 +1,184 @@ + + + diff --git a/resources/js/components/posts/previews/PlatformPreview.vue b/resources/js/components/posts/previews/PlatformPreview.vue index 69e38bd82..490528c69 100644 --- a/resources/js/components/posts/previews/PlatformPreview.vue +++ b/resources/js/components/posts/previews/PlatformPreview.vue @@ -8,6 +8,7 @@ import type { MediaItem } from '@/types/media'; import BlueskyPreview from './BlueskyPreview.vue'; import DiscordPreview from './DiscordPreview.vue'; import FacebookPreview from './FacebookPreview.vue'; +import GoogleBusinessPreview from './GoogleBusinessPreview.vue'; import InstagramPreview from './InstagramPreview.vue'; import LinkedInPreview from './LinkedInPreview.vue'; import MastodonPreview from './MastodonPreview.vue'; @@ -88,6 +89,8 @@ const previewComponent = computed(() => { return TelegramPreview; case 'discord': return DiscordPreview; + case 'google_business': + return GoogleBusinessPreview; default: return LinkedInPreview; } diff --git a/resources/js/components/posts/previews/index.ts b/resources/js/components/posts/previews/index.ts index f0695a85d..c8619f98e 100644 --- a/resources/js/components/posts/previews/index.ts +++ b/resources/js/components/posts/previews/index.ts @@ -8,3 +8,4 @@ export { default as TikTokPreview } from './TikTokPreview.vue'; export { default as YouTubePreview } from './YouTubePreview.vue'; export { default as PinterestPreview } from './PinterestPreview.vue'; export { default as BlueskyPreview } from './BlueskyPreview.vue'; +export { default as GoogleBusinessPreview } from './GoogleBusinessPreview.vue'; diff --git a/resources/js/composables/useOAuthPopup.ts b/resources/js/composables/useOAuthPopup.ts index acde69840..350c1b36c 100644 --- a/resources/js/composables/useOAuthPopup.ts +++ b/resources/js/composables/useOAuthPopup.ts @@ -5,6 +5,7 @@ import { toast } from 'vue-sonner'; import { connect as blueskyConnect } from '@/routes/app/social/bluesky'; import { connect as discordConnect } from '@/routes/app/social/discord'; import { connect as facebookConnect } from '@/routes/app/social/facebook'; +import { connect as googleBusinessConnect } from '@/routes/app/social/google-business'; import { connect as instagramConnect } from '@/routes/app/social/instagram'; import { connect as instagramFacebookConnect } from '@/routes/app/social/instagram-facebook'; import { connect as linkedinConnect } from '@/routes/app/social/linkedin'; @@ -23,6 +24,7 @@ const CONNECT_ROUTES: Record = { mastodon: '/images/accounts/mastodon.png', telegram: '/images/accounts/telegram.png', discord: '/images/accounts/discord.png', + google_business: '/images/accounts/google_business.png', }; const PLATFORM_LABELS: Record = { @@ -30,6 +31,7 @@ const PLATFORM_LABELS: Record = { mastodon: 'Mastodon', telegram: 'Telegram', discord: 'Discord', + google_business: 'Google Business Profile', }; const PLATFORM_CONTENT_TYPES: Record = { @@ -51,6 +53,7 @@ const PLATFORM_CONTENT_TYPES: Record = { mastodon: ['mastodon_post'], telegram: ['telegram_post'], discord: ['discord_message'], + google_business: ['google_business_post'], }; export interface ContentTypeOption { @@ -73,6 +76,7 @@ const PLATFORM_THEMES: Record = { mastodon: { bg: 'bg-violet-200', rotate: 'rotate-1' }, telegram: { bg: 'bg-sky-200', rotate: '-rotate-2' }, discord: { bg: 'bg-indigo-200', rotate: 'rotate-1' }, + google_business: { bg: 'bg-blue-100', rotate: 'rotate-2' }, }; export const getPlatformLogo = (platform: string): string => diff --git a/resources/js/composables/usePostCompliance.ts b/resources/js/composables/usePostCompliance.ts index b5877a4a9..d2b751ad4 100644 --- a/resources/js/composables/usePostCompliance.ts +++ b/resources/js/composables/usePostCompliance.ts @@ -6,6 +6,16 @@ import { getMediaRulesForContentType } from '@/composables/useMediaRules'; import { getPlatformLabel } from '@/composables/usePlatformLogo'; import { useXLinkDefuser } from '@/composables/useXLinkDefuser'; import { mediaLimitsDocsUrl } from '@/lib/docs'; +import { + GOOGLE_BUSINESS_EVENT_TITLE_MAX, + GOOGLE_BUSINESS_EVENT_TOPIC_TYPES, + GoogleBusinessCtaAction, + GoogleBusinessTopicType, + googleBusinessAllowsCallToAction, + googleBusinessEventEndsBeforeStart, + resolveGoogleBusinessCtaAction, + resolveGoogleBusinessTopicType, +} from '@/lib/googleBusiness'; import { ContentType } from '@/types/content-type'; import type { MediaItem } from '@/types/media'; import { Platform } from '@/types/platform'; @@ -80,6 +90,41 @@ const PLATFORM_META_RULES: Record = { valid: Boolean(meta.channel_id), tooltipKey: meta.channel_id ? null : 'posts.form.discord.channel_required', }), + // Mirrors PostPlatformMetaRules::requiredMetaViolation()'s Google Business + // arms, including their check order. + [Platform.GoogleBusiness]: (meta) => { + const topicType = resolveGoogleBusinessTopicType(meta.topic_type); + const needsEvent = GOOGLE_BUSINESS_EVENT_TOPIC_TYPES.includes(topicType); + const ctaActionType = resolveGoogleBusinessCtaAction(meta.call_to_action?.action_type); + const ctaNeedsUrl = googleBusinessAllowsCallToAction(topicType) + && ctaActionType !== GoogleBusinessCtaAction.None + && ctaActionType !== GoogleBusinessCtaAction.Call; + let tooltipKey: string | null = null; + if (needsEvent && !meta.event?.title?.trim()) { + tooltipKey = topicType === GoogleBusinessTopicType.Offer + ? 'posts.form.google_business.offer_title_required' + : 'posts.form.google_business.event_title_required'; + } else if (needsEvent && (meta.event?.title?.length ?? 0) > GOOGLE_BUSINESS_EVENT_TITLE_MAX) { + tooltipKey = 'posts.form.google_business.title_max'; + } else if (needsEvent && !meta.event?.start_date) { + tooltipKey = 'posts.form.google_business.event_start_date_required'; + } else if (needsEvent && !meta.event?.end_date) { + tooltipKey = 'posts.form.google_business.event_end_date_required'; + } else if (needsEvent && googleBusinessEventEndsBeforeStart(meta.event)) { + const sameDayTimes = meta.event?.start_date === meta.event?.end_date + && meta.event?.start_time + && meta.event?.end_time; + tooltipKey = sameDayTimes + ? 'posts.form.google_business.event_end_time_before_start' + : 'posts.form.google_business.event_end_date_before_start'; + } else if (ctaNeedsUrl && !meta.call_to_action?.url) { + tooltipKey = 'posts.form.google_business.cta_url_required'; + } + return { + valid: tooltipKey === null, + tooltipKey, + }; + }, }; /** diff --git a/resources/js/composables/usePostStatus.ts b/resources/js/composables/usePostStatus.ts index b381d779d..e13a7e507 100644 --- a/resources/js/composables/usePostStatus.ts +++ b/resources/js/composables/usePostStatus.ts @@ -1,12 +1,16 @@ import { IconAlertCircle, + IconBan, IconCircleCheck, IconClock, IconFileText, + IconHourglass, IconLoader2, } from '@tabler/icons-vue'; import { trans } from 'laravel-vue-i18n'; +import { PostPlatformStatus, PostStatus } from '@/types/post'; + type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'outline'; interface StatusConfig { @@ -23,6 +27,31 @@ const CONFIGS: Record> = { published: { variant: 'success', icon: IconCircleCheck }, partially_published: { variant: 'warning', icon: IconAlertCircle }, failed: { variant: 'destructive', icon: IconAlertCircle }, + rejected: { variant: 'destructive', icon: IconBan }, + pending_review: { variant: 'warning', icon: IconHourglass }, +}; + +const IN_FLIGHT_PLATFORM_STATUSES: readonly string[] = [ + PostPlatformStatus.Publishing, + PostPlatformStatus.Pending, + PostPlatformStatus.Retrying, +]; + +/** + * Full-screen publishing overlay only while a target is still in flight. + * `pending_review` keeps the post status `publishing`, but Google is already + * holding the Local Post — hide the spinner and show the platform rows. + */ +export const isActivelyPublishing = ( + postStatus: string, + platforms: { enabled?: boolean; status: string }[], +): boolean => { + if (postStatus !== PostStatus.Publishing) { + return false; + } + + return platforms.some((platform) => platform.enabled !== false + && IN_FLIGHT_PLATFORM_STATUSES.includes(platform.status)); }; export const getPostStatusConfig = (status: string): StatusConfig => { @@ -37,6 +66,8 @@ export const getPlatformStatusConfig = (status: string): StatusConfig => { retrying: 'retrying', published: 'published', failed: 'failed', + rejected: 'rejected', + pending_review: 'pending_review', }; const key = map[status] ?? 'draft'; const config = CONFIGS[key]; diff --git a/resources/js/lib/docs.ts b/resources/js/lib/docs.ts index 336a4a7ee..cf19714d5 100644 --- a/resources/js/lib/docs.ts +++ b/resources/js/lib/docs.ts @@ -19,6 +19,7 @@ const MEDIA_LIMITS_ANCHOR: Record = { [Platform.Mastodon]: 'mastodon', [Platform.Discord]: 'discord', [Platform.Telegram]: 'telegram', + [Platform.GoogleBusiness]: 'google-business-profile', }; export const mediaLimitsDocsUrl = (platform: string): string => { diff --git a/resources/js/lib/googleBusiness.ts b/resources/js/lib/googleBusiness.ts new file mode 100644 index 000000000..308f9ee7b --- /dev/null +++ b/resources/js/lib/googleBusiness.ts @@ -0,0 +1,146 @@ +/** + * Google Business Profile Local Post helpers shared by the editor settings + * panel, the preview, and the publish compliance gate. + */ + +import { + GOOGLE_BUSINESS_CTA_ACTION_VALUES, + GOOGLE_BUSINESS_EVENT_TOPIC_TYPES, + GoogleBusinessCtaAction, + GoogleBusinessTopicType, + googleBusinessCtaActionLabelKey, + googleBusinessTopicTypeLabelKey, + resolveGoogleBusinessCtaAction, + resolveGoogleBusinessTopicType, + type GoogleBusinessCtaActionValue, + type GoogleBusinessTopicTypeValue, +} from '@/types/google-business'; + +export { + GOOGLE_BUSINESS_CTA_ACTION_VALUES, + GOOGLE_BUSINESS_EVENT_TITLE_MAX, + GOOGLE_BUSINESS_EVENT_TOPIC_TYPES, + GoogleBusinessCtaAction, + GoogleBusinessTopicType, + googleBusinessCtaActionLabelKey, + googleBusinessTopicTypeLabelKey, + isGoogleBusinessCtaAction, + isGoogleBusinessTopicType, + resolveGoogleBusinessCtaAction, + resolveGoogleBusinessTopicType, + type GoogleBusinessCtaActionValue, + type GoogleBusinessTopicTypeValue, +} from '@/types/google-business'; + +export interface GoogleBusinessTopicTypeOption { + value: GoogleBusinessTopicTypeValue; + labelKey: string; +} + +/** + * Local Post topic types, in the order the editor lists them: What's New, + * Offer, Event. STANDARD is Google's API name for What's New. + */ +export const GOOGLE_BUSINESS_TOPIC_TYPES: readonly GoogleBusinessTopicTypeOption[] = [ + { value: GoogleBusinessTopicType.Standard, labelKey: googleBusinessTopicTypeLabelKey[GoogleBusinessTopicType.Standard] }, + { value: GoogleBusinessTopicType.Offer, labelKey: googleBusinessTopicTypeLabelKey[GoogleBusinessTopicType.Offer] }, + { value: GoogleBusinessTopicType.Event, labelKey: googleBusinessTopicTypeLabelKey[GoogleBusinessTopicType.Event] }, +]; + +/** Google ignores `callToAction` on OFFER posts. */ +export const googleBusinessAllowsCallToAction = (topicType?: string | null): boolean => + resolveGoogleBusinessTopicType(topicType) !== GoogleBusinessTopicType.Offer; + +export interface GoogleBusinessCtaOption { + value: GoogleBusinessCtaActionValue; + labelKey: string; +} + +/** + * Call-to-action button types, in the order the editor lists them. `NONE` is the + * "None" choice and has no preview label. + */ +export const GOOGLE_BUSINESS_CTA_OPTIONS: readonly GoogleBusinessCtaOption[] = + GOOGLE_BUSINESS_CTA_ACTION_VALUES.map((value) => ({ + value, + labelKey: googleBusinessCtaActionLabelKey[value], + })); + +/** + * Combine stored event date + time for the DatePicker (`YYYY-MM-DD` or + * `YYYY-MM-DDTHH:mm:00`). The editor DatePicker always writes a time (default + * 09:00). API/MCP may omit it — Google then treats the schedule as the start + * of the day at the location. + */ +export const googleBusinessEventDateTimeValue = (date?: string | null, time?: string | null): string => { + if (!date) { + return ''; + } + + return time ? `${date}T${time}:00` : date; +}; + +/** + * Split a DatePicker value back into the `event.start_date` / `event.start_time` + * (or end) meta fields the API, MCP, and publisher persist. + */ +export const googleBusinessEventDateTimeParts = (value: string | null): { date: string | null; time: string | null } => { + if (!value?.trim()) { + return { date: null, time: null }; + } + + const match = /^(?\d{4}-\d{2}-\d{2})(?:T(?