diff --git a/app/Console/Commands/RefreshExpiringTokens.php b/app/Console/Commands/RefreshExpiringTokens.php new file mode 100644 index 00000000..170d08f2 --- /dev/null +++ b/app/Console/Commands/RefreshExpiringTokens.php @@ -0,0 +1,36 @@ +where('status', Status::Connected) + ->whereNotNull('token_expires_at') + ->where('token_expires_at', '<=', now()->addHours(2)) + ->where('token_expires_at', '>', now()) + ->chunk(50, function ($accounts) use (&$count) { + foreach ($accounts as $account) { + RefreshSocialToken::dispatch($account); + $count++; + } + }); + + $this->info("Dispatched {$count} token refresh jobs."); + } +} diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 54f5e4a6..6325935b 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -117,6 +117,19 @@ public function supportsTextOnly(): bool }; } + public function queue(): string + { + return 'social-'.$this->value; + } + + /** + * @return array + */ + public static function allQueues(): array + { + return array_map(fn (self $platform) => $platform->queue(), self::cases()); + } + public function isEnabled(): bool { return config("trypost.platforms.{$this->value}.enabled", true); diff --git a/app/Http/Requests/App/Media/StoreChunkedMediaRequest.php b/app/Http/Requests/App/Media/StoreChunkedMediaRequest.php index 2bf36fb0..59d326ce 100644 --- a/app/Http/Requests/App/Media/StoreChunkedMediaRequest.php +++ b/app/Http/Requests/App/Media/StoreChunkedMediaRequest.php @@ -44,6 +44,21 @@ public function rules(): array ]; } + public function after(): array + { + return [ + function ($validator) { + if ($validator->errors()->has('content_range')) { + return; + } + + if ($this->totalSize() > 1073741824) { // 1GB + $validator->errors()->add('content_range', 'File size exceeds the maximum allowed (1GB).'); + } + }, + ]; + } + public function rangeStart(): int { return (int) $this->parsedRange()[1]; diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index 51732221..76f2cab0 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -21,7 +21,7 @@ public function rules(): array return [ 'status' => ['required', 'string', Rule::in(array_column(Status::cases(), 'value'))], 'synced' => ['required', 'boolean'], - 'scheduled_at' => ['sometimes', 'nullable', 'string'], + 'scheduled_at' => ['sometimes', 'nullable', 'date'], 'platforms' => ['required', 'array'], 'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post')->id ?? $this->route('post'))], 'platforms.*.content' => ['nullable', 'string', 'max:63206'], diff --git a/app/Jobs/PublishPost.php b/app/Jobs/PublishPost.php index 1cfc0e36..928e0588 100644 --- a/app/Jobs/PublishPost.php +++ b/app/Jobs/PublishPost.php @@ -28,11 +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(), + 'error' => $exception?->getMessage(), ]); + + $this->post->markAsFailed(); } } diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 4d5f72f7..46b11b76 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -16,6 +16,7 @@ use App\Models\Post; use App\Models\PostPlatform; use App\Services\Social\BlueskyPublisher; +use App\Services\Social\ConnectionVerifier; use App\Services\Social\FacebookPublisher; use App\Services\Social\InstagramPublisher; use App\Services\Social\LinkedInPagePublisher; @@ -38,7 +39,10 @@ class PublishToSocialPlatform implements ShouldQueue public int $timeout = 600; // 10 minutes — large video uploads need time - public function __construct(public PostPlatform $postPlatform) {} + public function __construct(public PostPlatform $postPlatform) + { + $this->onQueue($postPlatform->platform->queue()); + } public function handle(): void { @@ -68,33 +72,53 @@ public function handle(): void $this->postPlatform->markAsPublishing(); $this->broadcastStatus(); - try { - $publisher = $this->getPublisher(); - $result = $publisher->publish($this->postPlatform); + $maxAttempts = 2; // Original attempt + 1 retry after token refresh - $this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url')); - } catch (TokenExpiredException $e) { - Log::error('Token expired while publishing to social platform', [ - 'post_platform_id' => $this->postPlatform->id, - 'platform' => $this->postPlatform->platform->value, - 'error' => $e->getMessage(), - 'platform_error_code' => $e->platformErrorCode, - ]); + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + try { + $publisher = $this->getPublisher(); + $result = $publisher->publish($this->postPlatform); + $this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url')); + break; + } catch (TokenExpiredException $e) { + if ($attempt < $maxAttempts) { + try { + $this->refreshAccountToken(); - $this->postPlatform->markAsFailed($e->getMessage()); - $this->postPlatform->socialAccount->markAsDisconnected($e->getMessage()); - } catch (SocialPublishException $e) { - Log::error('Social publish failed: '.$e->userMessage); + continue; + } catch (\Throwable $refreshError) { + Log::error('Token refresh failed during publish retry', [ + 'post_platform_id' => $this->postPlatform->id, + 'platform' => $this->postPlatform->platform->value, + 'error' => $refreshError->getMessage(), + ]); + } + } - $this->postPlatform->markAsFailed($e->userMessage); - } catch (\Throwable $e) { - Log::error('Failed to publish to social platform', [ - 'post_platform_id' => $this->postPlatform->id, - 'platform' => $this->postPlatform->platform->value, - 'error' => $e->getMessage(), - ]); + // All attempts exhausted or refresh failed + Log::error('Token expired while publishing to social platform', [ + 'post_platform_id' => $this->postPlatform->id, + 'platform' => $this->postPlatform->platform->value, + 'error' => $e->getMessage(), + 'platform_error_code' => $e->platformErrorCode, + ]); - $this->postPlatform->markAsFailed($e->getMessage()); + $this->postPlatform->markAsFailed($e->getMessage()); + $this->postPlatform->socialAccount->markAsDisconnected($e->getMessage()); + break; + } catch (SocialPublishException $e) { + Log::error('Social publish failed: '.$e->userMessage); + $this->postPlatform->markAsFailed($e->userMessage); + break; + } catch (\Throwable $e) { + Log::error('Failed to publish to social platform', [ + 'post_platform_id' => $this->postPlatform->id, + 'platform' => $this->postPlatform->platform->value, + 'error' => $e->getMessage(), + ]); + $this->postPlatform->markAsFailed($e->getMessage()); + break; + } } // Always check and update post status after each platform finishes @@ -104,6 +128,14 @@ public function handle(): void $this->broadcastStatus(); } + private function refreshAccountToken(): void + { + $account = $this->postPlatform->socialAccount; + + // Delegate to ConnectionVerifier which already has per-platform refresh logic + app(ConnectionVerifier::class)->verify($account); + } + private function broadcastStatus(): void { PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); diff --git a/app/Jobs/RefreshSocialToken.php b/app/Jobs/RefreshSocialToken.php new file mode 100644 index 00000000..2162c604 --- /dev/null +++ b/app/Jobs/RefreshSocialToken.php @@ -0,0 +1,33 @@ +verify($this->account); + } catch (\Throwable $e) { + Log::warning('Proactive token refresh failed', [ + 'account_id' => $this->account->id, + 'platform' => $this->account->platform->value, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 46c3f341..7e714795 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -10,12 +10,15 @@ use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; +use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class BlueskyPublisher { + use HasSocialHttpClient; + public function publish(PostPlatform $postPlatform): array { $account = $postPlatform->socialAccount; @@ -72,7 +75,7 @@ public function publish(PostPlatform $postPlatform): array $record['facets'] = $facets; } - $response = Http::withToken($account->access_token) + $response = $this->socialHttp()->withToken($account->access_token) ->post("{$service}/xrpc/com.atproto.repo.createRecord", [ 'repo' => $account->platform_user_id, 'collection' => 'app.bsky.feed.post', @@ -82,7 +85,7 @@ public function publish(PostPlatform $postPlatform): array if ($response->failed()) { Log::error('Bluesky post failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); @@ -130,7 +133,7 @@ private function uploadBlob(SocialAccount $account, string $service, string $url $stream = fopen($tempFile, 'r'); - $response = Http::withToken($account->access_token) + $response = $this->socialHttp()->withToken($account->access_token) ->withHeaders(['Content-Type' => $mimeType]) ->withBody($stream, $mimeType) ->post("{$service}/xrpc/com.atproto.repo.uploadBlob"); @@ -142,7 +145,7 @@ private function uploadBlob(SocialAccount $account, string $service, string $url if ($response->failed()) { Log::error('Bluesky blob upload failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); return null; @@ -266,7 +269,7 @@ public function refreshToken(SocialAccount $account): void $service = $account->meta['service'] ?? 'https://bsky.social'; // Try refresh first - $response = Http::withToken($account->refresh_token) + $response = $this->socialHttp()->withToken($account->refresh_token) ->post("{$service}/xrpc/com.atproto.server.refreshSession"); if ($response->successful()) { @@ -282,7 +285,6 @@ public function refreshToken(SocialAccount $account): void Log::warning('Bluesky refresh token failed, trying re-authentication', [ 'status' => $response->status(), - 'body' => $response->body(), ]); // If refresh fails, re-authenticate with stored credentials diff --git a/app/Services/Social/Concerns/HasSocialHttpClient.php b/app/Services/Social/Concerns/HasSocialHttpClient.php new file mode 100644 index 00000000..fa314be9 --- /dev/null +++ b/app/Services/Social/Concerns/HasSocialHttpClient.php @@ -0,0 +1,40 @@ + $exception->response?->status() === 429, + throw: false, + )->timeout(120); + } + + protected function redactResponseBody(string $body): string + { + return preg_replace( + [ + '/access_token=([^&"\s]+)/', + '/"access_token"\s*:\s*"([^"]+)"/', + '/Bearer\s+\S+/', + '/"token"\s*:\s*"([^"]+)"/', + ], + [ + 'access_token=[REDACTED]', + '"access_token":"[REDACTED]"', + 'Bearer [REDACTED]', + '"token":"[REDACTED]"', + ], + $body + ); + } +} diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 31a7397d..f2ec7bc4 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -75,7 +75,7 @@ private function refreshLinkedInToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('ConnectionVerifier: LinkedIn token refresh failed', ['body' => $response->body()]); + Log::error('ConnectionVerifier: LinkedIn token refresh failed', ['body' => $this->redactBody($response->body())]); throw new TokenExpiredException('Failed to refresh LinkedIn token'); } @@ -107,7 +107,7 @@ private function refreshXToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('ConnectionVerifier: X token refresh failed', ['body' => $response->body()]); + Log::error('ConnectionVerifier: X token refresh failed', ['body' => $this->redactBody($response->body())]); throw new TokenExpiredException('Failed to refresh X token'); } @@ -190,7 +190,7 @@ private function refreshYouTubeToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('ConnectionVerifier: YouTube token refresh failed', ['body' => $response->body()]); + Log::error('ConnectionVerifier: YouTube token refresh failed', ['body' => $this->redactBody($response->body())]); throw new TokenExpiredException('Failed to refresh YouTube token'); } @@ -218,7 +218,7 @@ private function refreshTikTokToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('ConnectionVerifier: TikTok token refresh failed', ['body' => $response->body()]); + Log::error('ConnectionVerifier: TikTok token refresh failed', ['body' => $this->redactBody($response->body())]); throw new TokenExpiredException('Failed to refresh TikTok token'); } @@ -250,7 +250,7 @@ private function refreshPinterestToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('ConnectionVerifier: Pinterest token refresh failed', ['body' => $response->body()]); + Log::error('ConnectionVerifier: Pinterest token refresh failed', ['body' => $this->redactBody($response->body())]); throw new TokenExpiredException('Failed to refresh Pinterest token'); } @@ -274,7 +274,7 @@ private function refreshThreadsToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('ConnectionVerifier: Threads token refresh failed', ['body' => $response->body()]); + Log::error('ConnectionVerifier: Threads token refresh failed', ['body' => $this->redactBody($response->body())]); throw new TokenExpiredException('Failed to refresh Threads token'); } @@ -298,7 +298,7 @@ private function refreshInstagramToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('ConnectionVerifier: Instagram token refresh failed', ['body' => $response->body()]); + Log::error('ConnectionVerifier: Instagram token refresh failed', ['body' => $this->redactBody($response->body())]); throw new TokenExpiredException('Failed to refresh Instagram token'); } @@ -502,4 +502,23 @@ private function verifyMastodon(SocialAccount $account): bool return $response->successful(); } + + private function redactBody(string $body): string + { + return preg_replace( + [ + '/access_token=([^&"\s]+)/', + '/"access_token"\s*:\s*"([^"]+)"/', + '/Bearer\s+\S+/', + '/"token"\s*:\s*"([^"]+)"/', + ], + [ + 'access_token=[REDACTED]', + '"access_token":"[REDACTED]"', + 'Bearer [REDACTED]', + '"token":"[REDACTED]"', + ], + $body + ); + } } diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index be649e8c..481ab06a 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -7,12 +7,15 @@ use App\Enums\PostPlatform\ContentType; use App\Exceptions\Social\FacebookPublishException; use App\Models\PostPlatform; +use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class FacebookPublisher { + use HasSocialHttpClient; + private string $baseUrl = 'https://graph.facebook.com/v24.0'; public function publish(PostPlatform $postPlatform): array @@ -65,7 +68,7 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte private function publishTextPost(string $pageId, string $accessToken, string $content): array { - $response = Http::post("{$this->baseUrl}/{$pageId}/feed", [ + $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/feed", [ 'message' => $content, 'access_token' => $accessToken, ]); @@ -73,7 +76,7 @@ private function publishTextPost(string $pageId, string $accessToken, string $co if ($response->failed()) { Log::error('Facebook text post failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -89,7 +92,7 @@ private function publishTextPost(string $pageId, string $accessToken, string $co private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, $media): array { - $response = Http::post("{$this->baseUrl}/{$pageId}/photos", [ + $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ 'message' => $content, 'url' => $media->url, 'access_token' => $accessToken, @@ -98,7 +101,7 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st if ($response->failed()) { Log::error('Facebook single image post failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -122,7 +125,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str continue; } - $uploadResponse = Http::post("{$this->baseUrl}/{$pageId}/photos", [ + $uploadResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ 'url' => $media->url, 'published' => 'false', 'access_token' => $accessToken, @@ -130,7 +133,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str if ($uploadResponse->failed()) { Log::error('Facebook image upload failed', [ - 'body' => $uploadResponse->body(), + 'body' => $this->redactResponseBody($uploadResponse->body()), ]); continue; @@ -154,12 +157,12 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str $postData["attached_media[{$index}]"] = json_encode($media); } - $response = Http::post("{$this->baseUrl}/{$pageId}/feed", $postData); + $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/feed", $postData); if ($response->failed()) { Log::error('Facebook multi-image post failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -176,7 +179,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str private function publishVideoPost(string $pageId, string $accessToken, ?string $content, $media): array { // Use resumable upload for videos - $response = Http::post("{$this->baseUrl}/{$pageId}/videos", [ + $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/videos", [ 'description' => $content, 'file_url' => $media->url, 'access_token' => $accessToken, @@ -185,7 +188,7 @@ private function publishVideoPost(string $pageId, string $accessToken, ?string $ if ($response->failed()) { Log::error('Facebook video post failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -202,7 +205,7 @@ private function publishVideoPost(string $pageId, string $accessToken, ?string $ private function publishReel(string $pageId, string $accessToken, ?string $content, $media): array { // Upload video as reel - $response = Http::post("{$this->baseUrl}/{$pageId}/video_reels", [ + $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [ 'upload_phase' => 'start', 'access_token' => $accessToken, ]); @@ -210,7 +213,7 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte if ($response->failed()) { Log::error('Facebook reel upload start failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -241,7 +244,7 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte } if ($uploadResponse->failed()) { - Log::error('Facebook reel upload transfer failed', ['body' => $uploadResponse->body()]); + Log::error('Facebook reel upload transfer failed', ['body' => $this->redactResponseBody($uploadResponse->body())]); $this->handleApiError($uploadResponse); } } finally { @@ -249,7 +252,7 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte } // Finish and publish the reel - $finishResponse = Http::post("{$this->baseUrl}/{$pageId}/video_reels", [ + $finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [ 'upload_phase' => 'finish', 'video_id' => $videoId, 'video_state' => 'PUBLISHED', @@ -259,7 +262,7 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte if ($finishResponse->failed()) { Log::error('Facebook reel finish failed', [ - 'body' => $finishResponse->body(), + 'body' => $this->redactResponseBody($finishResponse->body()), ]); $this->handleApiError($finishResponse); } @@ -279,7 +282,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra if ($isVideo) { // Video story - $response = Http::post("{$this->baseUrl}/{$pageId}/video_stories", [ + $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ 'upload_phase' => 'start', 'access_token' => $accessToken, ]); @@ -288,7 +291,11 @@ private function publishStory(string $pageId, string $accessToken, $media): arra $this->handleApiError($response); } - $videoId = $response->json()['video_id']; + $videoId = $response->json()['video_id'] ?? null; + + if (! $videoId) { + throw new \Exception('Facebook story upload failed: no video ID returned'); + } // Transfer the video $tempFile = tempnam(sys_get_temp_dir(), 'fb_story_'); @@ -313,7 +320,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra } if ($transferResponse->failed()) { - Log::error('Facebook video story transfer failed', ['body' => $transferResponse->body()]); + Log::error('Facebook video story transfer failed', ['body' => $this->redactResponseBody($transferResponse->body())]); $this->handleApiError($transferResponse); } } finally { @@ -321,7 +328,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra } // Finish the story - $finishResponse = Http::post("{$this->baseUrl}/{$pageId}/video_stories", [ + $finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ 'upload_phase' => 'finish', 'video_id' => $videoId, 'access_token' => $accessToken, @@ -340,14 +347,14 @@ private function publishStory(string $pageId, string $accessToken, $media): arra } // Image story - $response = Http::post("{$this->baseUrl}/{$pageId}/photo_stories", [ + $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photo_stories", [ 'photo_id' => $this->uploadUnpublishedPhoto($pageId, $accessToken, $media), 'access_token' => $accessToken, ]); if ($response->failed()) { Log::error('Facebook photo story failed', [ - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -362,7 +369,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra private function uploadUnpublishedPhoto(string $pageId, string $accessToken, $media): string { - $response = Http::post("{$this->baseUrl}/{$pageId}/photos", [ + $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ 'url' => $media->url, 'published' => 'false', 'access_token' => $accessToken, diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 0585a596..88684c04 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -9,12 +9,15 @@ use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; +use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class InstagramPublisher { + use HasSocialHttpClient; + private string $baseUrl = 'https://graph.instagram.com/v24.0'; public function publish(PostPlatform $postPlatform): array @@ -64,7 +67,7 @@ private function publishFeed(string $instagramId, string $accessToken, ?string $ private function publishSingleImage(string $instagramId, string $accessToken, ?string $content, $media): array { // Step 1: Create container - $containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", [ + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [ 'image_url' => $media->url, 'caption' => $content, 'access_token' => $accessToken, @@ -73,7 +76,7 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s if ($containerResponse->failed()) { Log::error('Instagram container creation failed', [ 'status' => $containerResponse->status(), - 'body' => $containerResponse->body(), + 'body' => $this->redactResponseBody($containerResponse->body()), ]); $this->handleApiError($containerResponse); } @@ -94,7 +97,7 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s private function publishReel(string $instagramId, string $accessToken, ?string $content, $media): array { // Step 1: Create container for video/reel - $containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", [ + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [ 'video_url' => $media->url, 'caption' => $content, 'media_type' => 'REELS', @@ -104,7 +107,7 @@ private function publishReel(string $instagramId, string $accessToken, ?string $ if ($containerResponse->failed()) { Log::error('Instagram reel container creation failed', [ 'status' => $containerResponse->status(), - 'body' => $containerResponse->body(), + 'body' => $this->redactResponseBody($containerResponse->body()), ]); $this->handleApiError($containerResponse); } @@ -138,12 +141,12 @@ private function publishStory(string $instagramId, string $accessToken, $media): } // Step 1: Create story container - $containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", $params); + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params); if ($containerResponse->failed()) { Log::error('Instagram story container creation failed', [ 'status' => $containerResponse->status(), - 'body' => $containerResponse->body(), + 'body' => $this->redactResponseBody($containerResponse->body()), ]); $this->handleApiError($containerResponse); } @@ -181,11 +184,11 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri $params['image_url'] = $media->url; } - $containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", $params); + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params); if ($containerResponse->failed()) { Log::error('Instagram carousel item creation failed', [ - 'body' => $containerResponse->body(), + 'body' => $this->redactResponseBody($containerResponse->body()), ]); continue; @@ -194,7 +197,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri $childId = $containerResponse->json()['id'] ?? null; if (! $childId) { - Log::error('Instagram carousel item creation returned no ID', ['body' => $containerResponse->body()]); + Log::error('Instagram carousel item creation returned no ID', ['body' => $this->redactResponseBody($containerResponse->body())]); continue; } @@ -212,7 +215,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri } // Step 2: Create carousel container - $carouselResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", [ + $carouselResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [ 'media_type' => 'CAROUSEL', 'caption' => $content, 'children' => implode(',', $childContainers), @@ -221,7 +224,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri if ($carouselResponse->failed()) { Log::error('Instagram carousel container creation failed', [ - 'body' => $carouselResponse->body(), + 'body' => $this->redactResponseBody($carouselResponse->body()), ]); $this->handleApiError($carouselResponse); } @@ -241,7 +244,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri private function publishContainer(string $instagramId, string $accessToken, string $containerId): array { - $publishResponse = Http::post("{$this->baseUrl}/{$instagramId}/media_publish", [ + $publishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media_publish", [ 'creation_id' => $containerId, 'access_token' => $accessToken, ]); @@ -249,15 +252,19 @@ private function publishContainer(string $instagramId, string $accessToken, stri if ($publishResponse->failed()) { Log::error('Instagram publish failed', [ 'status' => $publishResponse->status(), - 'body' => $publishResponse->body(), + 'body' => $this->redactResponseBody($publishResponse->body()), ]); $this->handleApiError($publishResponse); } - $mediaId = $publishResponse->json()['id']; + $mediaId = $publishResponse->json()['id'] ?? null; + + if (! $mediaId) { + throw new \Exception('Instagram publish failed: no media ID returned'); + } // Get permalink - $permalinkResponse = Http::get("{$this->baseUrl}/{$mediaId}", [ + $permalinkResponse = $this->socialHttp()->get("{$this->baseUrl}/{$mediaId}", [ 'fields' => 'permalink', 'access_token' => $accessToken, ]); @@ -273,7 +280,7 @@ private function publishContainer(string $instagramId, string $accessToken, stri private function waitForMediaProcessing(string $containerId, string $accessToken, int $maxAttempts = 30): void { for ($i = 0; $i < $maxAttempts; $i++) { - $statusResponse = Http::get("{$this->baseUrl}/{$containerId}", [ + $statusResponse = $this->socialHttp()->get("{$this->baseUrl}/{$containerId}", [ 'fields' => 'status_code', 'access_token' => $accessToken, ]); @@ -308,9 +315,9 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('Instagram token refresh failed', ['body' => $response->body()]); + Log::error('Instagram token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - throw new TokenExpiredException('Failed to refresh Instagram token: '.$response->body()); + throw new TokenExpiredException('Failed to refresh Instagram token'); } $data = $response->json(); diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index 1c09c417..2b8acb15 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -11,6 +11,7 @@ use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; +use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; @@ -18,6 +19,8 @@ class LinkedInPagePublisher { + use HasSocialHttpClient; + private string $baseUrl = 'https://api.linkedin.com'; private string $apiVersion = '202601'; @@ -124,7 +127,7 @@ private function publishPost(string $organizationUrn, ?string $content, $media, if ($response->failed()) { Log::error('LinkedIn Page post creation failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -190,7 +193,7 @@ private function publishCarousel(string $organizationUrn, ?string $content, $med if ($response->failed()) { Log::error('LinkedIn Page carousel post creation failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -211,13 +214,12 @@ private function publishCarousel(string $organizationUrn, ?string $content, $med private function getHttpClient(): PendingRequest { - return Http::withToken($this->accessToken) + return $this->socialHttp()->withToken($this->accessToken) ->withHeaders([ 'X-Restli-Protocol-Version' => '2.0.0', 'LinkedIn-Version' => $this->apiVersion, 'Content-Type' => 'application/json', - ]) - ->timeout(300); + ]); } private function uploadMedia($mediaItem, string $ownerUrn): ?string @@ -248,7 +250,7 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string ]); if ($initResponse->failed()) { - Log::error('LinkedIn Page image init failed', ['body' => $initResponse->body()]); + Log::error('LinkedIn Page image init failed', ['body' => $this->redactResponseBody($initResponse->body())]); $this->handleApiError($initResponse); } @@ -292,7 +294,7 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string } if ($uploadResponse->failed()) { - Log::error('LinkedIn Page image upload failed', ['body' => $uploadResponse->body()]); + Log::error('LinkedIn Page image upload failed', ['body' => $this->redactResponseBody($uploadResponse->body())]); $this->handleApiError($uploadResponse); } @@ -335,7 +337,7 @@ private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn): ]); if ($initResponse->failed()) { - Log::error('LinkedIn Page video init failed', ['body' => $initResponse->body()]); + Log::error('LinkedIn Page video init failed', ['body' => $this->redactResponseBody($initResponse->body())]); $this->handleApiError($initResponse); } @@ -373,7 +375,7 @@ private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn): if ($chunkResponse->failed()) { Log::error('LinkedIn Page video chunk upload failed', [ 'index' => $index, - 'body' => $chunkResponse->body(), + 'body' => $this->redactResponseBody($chunkResponse->body()), ]); $this->handleApiError($chunkResponse); } @@ -399,7 +401,7 @@ private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn): ]); if ($finalizeResponse->failed()) { - Log::error('LinkedIn Page video finalize failed', ['body' => $finalizeResponse->body()]); + Log::error('LinkedIn Page video finalize failed', ['body' => $this->redactResponseBody($finalizeResponse->body())]); $this->handleApiError($finalizeResponse); } diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 0fad60d6..633d72bc 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -11,6 +11,7 @@ use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; +use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; @@ -18,6 +19,8 @@ class LinkedInPublisher { + use HasSocialHttpClient; + private string $baseUrl = 'https://api.linkedin.com'; private string $apiVersion = '202601'; @@ -117,7 +120,7 @@ private function publishPost(string $personUrn, ?string $content, $media): array if ($response->failed()) { Log::error('LinkedIn post creation failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -177,7 +180,7 @@ private function publishCarousel(string $personUrn, ?string $content, $mediaColl if ($response->failed()) { Log::error('LinkedIn carousel post creation failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -192,13 +195,12 @@ private function publishCarousel(string $personUrn, ?string $content, $mediaColl private function getHttpClient(): PendingRequest { - return Http::withToken($this->accessToken) + return $this->socialHttp()->withToken($this->accessToken) ->withHeaders([ 'X-Restli-Protocol-Version' => '2.0.0', 'LinkedIn-Version' => $this->apiVersion, 'Content-Type' => 'application/json', - ]) - ->timeout(300); + ]); } private function uploadMedia($mediaItem, string $ownerUrn): ?string @@ -229,7 +231,7 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string ]); if ($initResponse->failed()) { - Log::error('LinkedIn image init failed', ['body' => $initResponse->body()]); + Log::error('LinkedIn image init failed', ['body' => $this->redactResponseBody($initResponse->body())]); $this->handleApiError($initResponse); } @@ -273,7 +275,7 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string } if ($uploadResponse->failed()) { - Log::error('LinkedIn image upload failed', ['body' => $uploadResponse->body()]); + Log::error('LinkedIn image upload failed', ['body' => $this->redactResponseBody($uploadResponse->body())]); $this->handleApiError($uploadResponse); } @@ -316,7 +318,7 @@ private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn): ]); if ($initResponse->failed()) { - Log::error('LinkedIn video init failed', ['body' => $initResponse->body()]); + Log::error('LinkedIn video init failed', ['body' => $this->redactResponseBody($initResponse->body())]); $this->handleApiError($initResponse); } @@ -354,7 +356,7 @@ private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn): if ($chunkResponse->failed()) { Log::error('LinkedIn video chunk upload failed', [ 'index' => $index, - 'body' => $chunkResponse->body(), + 'body' => $this->redactResponseBody($chunkResponse->body()), ]); $this->handleApiError($chunkResponse); } @@ -380,7 +382,7 @@ private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn): ]); if ($finalizeResponse->failed()) { - Log::error('LinkedIn video finalize failed', ['body' => $finalizeResponse->body()]); + Log::error('LinkedIn video finalize failed', ['body' => $this->redactResponseBody($finalizeResponse->body())]); $this->handleApiError($finalizeResponse); } diff --git a/app/Services/Social/MastodonPublisher.php b/app/Services/Social/MastodonPublisher.php index 5857c730..b3f7d94b 100644 --- a/app/Services/Social/MastodonPublisher.php +++ b/app/Services/Social/MastodonPublisher.php @@ -9,12 +9,15 @@ use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; +use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class MastodonPublisher { + use HasSocialHttpClient; + public function publish(PostPlatform $postPlatform): array { $account = $postPlatform->socialAccount; @@ -41,13 +44,13 @@ public function publish(PostPlatform $postPlatform): array $payload['media_ids'] = $mediaIds; } - $response = Http::withToken($account->access_token) + $response = $this->socialHttp()->withToken($account->access_token) ->post("{$instance}/api/v1/statuses", $payload); if ($response->failed()) { Log::error('Mastodon post failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -93,7 +96,7 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u $stream = fopen($tempFile, 'r'); - $response = Http::withToken($account->access_token) + $response = $this->socialHttp()->withToken($account->access_token) ->attach('file', $stream, $name) ->post("{$instance}/api/v1/media"); @@ -104,7 +107,7 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u if ($response->failed()) { Log::error('Mastodon media upload failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); return null; diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index 8229ad8c..379fb1b2 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -10,12 +10,15 @@ use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; +use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class PinterestPublisher { + use HasSocialHttpClient; + private const API_BASE = 'https://api.pinterest.com/v5'; public function publish(PostPlatform $postPlatform): array @@ -98,13 +101,13 @@ private function publishImagePin(PostPlatform $postPlatform): array $payload['alt_text'] = substr(data_get($postPlatform->meta, 'alt_text'), 0, 500); } - $response = Http::withToken($account->access_token) + $response = $this->socialHttp()->withToken($account->access_token) ->post(self::API_BASE.'/pins', $payload); if ($response->failed()) { Log::error('Pinterest pin creation failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -133,7 +136,7 @@ private function publishVideoPin(PostPlatform $postPlatform): array } // Step 1: Register media upload - $registerResponse = Http::withToken($account->access_token) + $registerResponse = $this->socialHttp()->withToken($account->access_token) ->post(self::API_BASE.'/media', [ 'media_type' => 'video', ]); @@ -141,13 +144,17 @@ private function publishVideoPin(PostPlatform $postPlatform): array if ($registerResponse->failed()) { Log::error('Pinterest media registration failed', [ 'status' => $registerResponse->status(), - 'body' => $registerResponse->body(), + 'body' => $this->redactResponseBody($registerResponse->body()), ]); $this->handleApiError($registerResponse); } $registerData = $registerResponse->json(); - $mediaId = $registerData['media_id']; + $mediaId = $registerData['media_id'] ?? null; + + if (! $mediaId) { + throw new \Exception('Pinterest media registration failed: no media ID returned'); + } // Step 2: Upload video to S3 $uploadParams = $registerData['upload_parameters'] ?? []; @@ -228,13 +235,13 @@ private function publishVideoPin(PostPlatform $postPlatform): array $payload['media_source']['cover_image_url'] = data_get($postPlatform->meta, 'cover_image_url'); } - $response = Http::withToken($account->access_token) + $response = $this->socialHttp()->withToken($account->access_token) ->post(self::API_BASE.'/pins', $payload); if ($response->failed()) { Log::error('Pinterest video pin creation failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -286,13 +293,13 @@ private function publishCarousel(PostPlatform $postPlatform): array $payload['link'] = data_get($postPlatform->meta, 'link'); } - $response = Http::withToken($account->access_token) + $response = $this->socialHttp()->withToken($account->access_token) ->post(self::API_BASE.'/pins', $payload); if ($response->failed()) { Log::error('Pinterest carousel creation failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -308,14 +315,14 @@ private function publishCarousel(PostPlatform $postPlatform): array private function waitForMediaProcessing(SocialAccount $account, string $mediaId, int $maxAttempts = 30): void { for ($i = 0; $i < $maxAttempts; $i++) { - $response = Http::withToken($account->access_token) + $response = $this->socialHttp()->withToken($account->access_token) ->get(self::API_BASE."/media/{$mediaId}"); if ($response->failed()) { Log::warning('Pinterest media status check failed', [ 'media_id' => $mediaId, 'attempt' => $i, - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); sleep(3); @@ -353,7 +360,7 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('Pinterest token refresh failed', ['body' => $response->body()]); + Log::error('Pinterest token refresh failed', ['body' => $this->redactResponseBody($response->body())]); $this->handleApiError($response); } @@ -376,13 +383,13 @@ public function getBoards(SocialAccount $account): array $account->refresh(); } - $response = Http::withToken($account->access_token) + $response = $this->socialHttp()->withToken($account->access_token) ->get(self::API_BASE.'/boards', [ 'page_size' => 100, ]); if ($response->failed()) { - Log::error('Pinterest get boards failed', ['body' => $response->body()]); + Log::error('Pinterest get boards failed', ['body' => $this->redactResponseBody($response->body())]); $this->handleApiError($response); } diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index ada9eb2f..de15003e 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -7,12 +7,15 @@ use App\Exceptions\Social\ThreadsPublishException; use App\Models\PostPlatform; use App\Models\SocialAccount; +use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class ThreadsPublisher { + use HasSocialHttpClient; + private string $baseUrl = 'https://graph.threads.net/v1.0'; public function publish(PostPlatform $postPlatform): array @@ -57,7 +60,7 @@ public function publish(PostPlatform $postPlatform): array private function publishTextPost(string $userId, string $accessToken, string $content): array { // Step 1: Create container - $containerResponse = Http::post("{$this->baseUrl}/{$userId}/threads", [ + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [ 'media_type' => 'TEXT', 'text' => $content, 'access_token' => $accessToken, @@ -66,12 +69,16 @@ private function publishTextPost(string $userId, string $accessToken, string $co if ($containerResponse->failed()) { Log::error('Threads container creation failed', [ 'status' => $containerResponse->status(), - 'body' => $containerResponse->body(), + 'body' => $this->redactResponseBody($containerResponse->body()), ]); $this->handleApiError($containerResponse); } - $containerId = $containerResponse->json()['id']; + $containerId = $containerResponse->json()['id'] ?? null; + + if (! $containerId) { + throw new \Exception('Threads text container creation failed: no container ID returned'); + } // Step 2: Publish return $this->publishContainer($userId, $accessToken, $containerId); @@ -80,7 +87,7 @@ private function publishTextPost(string $userId, string $accessToken, string $co private function publishImagePost(string $userId, string $accessToken, ?string $content, $media): array { // Step 1: Create container - $containerResponse = Http::post("{$this->baseUrl}/{$userId}/threads", [ + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [ 'media_type' => 'IMAGE', 'image_url' => $media->url, 'text' => $content, @@ -90,12 +97,16 @@ private function publishImagePost(string $userId, string $accessToken, ?string $ if ($containerResponse->failed()) { Log::error('Threads image container creation failed', [ 'status' => $containerResponse->status(), - 'body' => $containerResponse->body(), + 'body' => $this->redactResponseBody($containerResponse->body()), ]); $this->handleApiError($containerResponse); } - $containerId = $containerResponse->json()['id']; + $containerId = $containerResponse->json()['id'] ?? null; + + if (! $containerId) { + throw new \Exception('Threads image container creation failed: no container ID returned'); + } // Step 2: Wait for image processing $this->waitForMediaProcessing($containerId, $accessToken); @@ -107,7 +118,7 @@ private function publishImagePost(string $userId, string $accessToken, ?string $ private function publishVideoPost(string $userId, string $accessToken, ?string $content, $media): array { // Step 1: Create container - $containerResponse = Http::post("{$this->baseUrl}/{$userId}/threads", [ + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [ 'media_type' => 'VIDEO', 'video_url' => $media->url, 'text' => $content, @@ -117,12 +128,16 @@ private function publishVideoPost(string $userId, string $accessToken, ?string $ if ($containerResponse->failed()) { Log::error('Threads video container creation failed', [ 'status' => $containerResponse->status(), - 'body' => $containerResponse->body(), + 'body' => $this->redactResponseBody($containerResponse->body()), ]); $this->handleApiError($containerResponse); } - $containerId = $containerResponse->json()['id']; + $containerId = $containerResponse->json()['id'] ?? null; + + if (! $containerId) { + throw new \Exception('Threads video container creation failed: no container ID returned'); + } // Wait for video processing $this->waitForMediaProcessing($containerId, $accessToken); @@ -152,17 +167,23 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c $params['image_url'] = $media->url; } - $containerResponse = Http::post("{$this->baseUrl}/{$userId}/threads", $params); + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", $params); if ($containerResponse->failed()) { Log::error('Threads carousel item creation failed', [ - 'body' => $containerResponse->body(), + 'body' => $this->redactResponseBody($containerResponse->body()), ]); continue; } - $childId = $containerResponse->json()['id']; + $childId = $containerResponse->json()['id'] ?? null; + + if (! $childId) { + Log::error('Threads carousel item creation returned no ID', ['body' => $this->redactResponseBody($containerResponse->body())]); + + continue; + } // Wait for media processing (both images and videos) $this->waitForMediaProcessing($childId, $accessToken); @@ -175,7 +196,7 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c } // Step 2: Create carousel container - $carouselResponse = Http::post("{$this->baseUrl}/{$userId}/threads", [ + $carouselResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [ 'media_type' => 'CAROUSEL', 'text' => $content, 'children' => implode(',', $childContainers), @@ -184,12 +205,16 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c if ($carouselResponse->failed()) { Log::error('Threads carousel container creation failed', [ - 'body' => $carouselResponse->body(), + 'body' => $this->redactResponseBody($carouselResponse->body()), ]); $this->handleApiError($carouselResponse); } - $carouselId = $carouselResponse->json()['id']; + $carouselId = $carouselResponse->json()['id'] ?? null; + + if (! $carouselId) { + throw new \Exception('Threads carousel container creation failed: no container ID returned'); + } // Step 3: Publish carousel return $this->publishContainer($userId, $accessToken, $carouselId); @@ -197,7 +222,7 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c private function publishContainer(string $userId, string $accessToken, string $containerId): array { - $publishResponse = Http::post("{$this->baseUrl}/{$userId}/threads_publish", [ + $publishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads_publish", [ 'creation_id' => $containerId, 'access_token' => $accessToken, ]); @@ -205,15 +230,19 @@ private function publishContainer(string $userId, string $accessToken, string $c if ($publishResponse->failed()) { Log::error('Threads publish failed', [ 'status' => $publishResponse->status(), - 'body' => $publishResponse->body(), + 'body' => $this->redactResponseBody($publishResponse->body()), ]); $this->handleApiError($publishResponse); } - $mediaId = $publishResponse->json()['id']; + $mediaId = $publishResponse->json()['id'] ?? null; + + if (! $mediaId) { + throw new \Exception('Threads publish failed: no media ID returned'); + } // Get permalink - $permalinkResponse = Http::get("{$this->baseUrl}/{$mediaId}", [ + $permalinkResponse = $this->socialHttp()->get("{$this->baseUrl}/{$mediaId}", [ 'fields' => 'permalink', 'access_token' => $accessToken, ]); @@ -229,7 +258,7 @@ private function publishContainer(string $userId, string $accessToken, string $c private function waitForMediaProcessing(string $containerId, string $accessToken, int $maxAttempts = 30): void { for ($i = 0; $i < $maxAttempts; $i++) { - $statusResponse = Http::get("{$this->baseUrl}/{$containerId}", [ + $statusResponse = $this->socialHttp()->get("{$this->baseUrl}/{$containerId}", [ 'fields' => 'status,error_message', 'access_token' => $accessToken, ]); @@ -238,7 +267,7 @@ private function waitForMediaProcessing(string $containerId, string $accessToken Log::warning('Threads status check failed', [ 'container_id' => $containerId, 'attempt' => $i, - 'body' => $statusResponse->body(), + 'body' => $this->redactResponseBody($statusResponse->body()), ]); sleep(3); @@ -273,7 +302,7 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('Threads token refresh failed', ['body' => $response->body()]); + Log::error('Threads token refresh failed', ['body' => $this->redactResponseBody($response->body())]); $this->handleApiError($response); } diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index db45766c..085e3487 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -8,6 +8,7 @@ use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; +use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; @@ -15,6 +16,8 @@ class TikTokPublisher { + use HasSocialHttpClient; + private string $baseUrl = 'https://open.tiktokapis.com/v2'; private string $accessToken; @@ -53,11 +56,10 @@ public function publish(PostPlatform $postPlatform): array private function getHttpClient(): PendingRequest { - return Http::withToken($this->accessToken) + return $this->socialHttp()->withToken($this->accessToken) ->withHeaders([ 'Content-Type' => 'application/json; charset=UTF-8', - ]) - ->timeout(120); + ]); } private function queryCreatorInfo(): array @@ -66,7 +68,7 @@ private function queryCreatorInfo(): array ->post("{$this->baseUrl}/post/publish/creator_info/query/"); if ($response->failed()) { - Log::warning('TikTok creator_info query failed', ['body' => $response->body()]); + Log::warning('TikTok creator_info query failed', ['body' => $this->redactResponseBody($response->body())]); return ['privacy_level' => 'SELF_ONLY']; } @@ -113,7 +115,7 @@ private function publishVideo(PostPlatform $postPlatform, $media): array if ($response->failed()) { Log::error('TikTok video publish failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -168,7 +170,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar if ($response->failed()) { Log::error('TikTok photo publish failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -203,7 +205,7 @@ private function waitForPublishStatus(string $publishId, int $maxAttempts = 20): if ($response->failed()) { Log::warning('TikTok status check failed', [ 'attempt' => $i, - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); continue; @@ -254,7 +256,7 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('TikTok token refresh failed', ['body' => $response->body()]); + Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]); $this->handleApiError($response); } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 8616a233..64979574 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -10,6 +10,7 @@ use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; +use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; @@ -17,6 +18,8 @@ class XPublisher { + use HasSocialHttpClient; + private string $baseUrl = 'https://api.x.com'; private string $accessToken; @@ -70,7 +73,7 @@ public function publish(PostPlatform $postPlatform): array if ($response->failed()) { Log::error('X post creation failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -86,12 +89,11 @@ public function publish(PostPlatform $postPlatform): array private function getHttpClient(): PendingRequest { - return Http::withToken($this->accessToken) + return $this->socialHttp()->withToken($this->accessToken) ->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json', - ]) - ->timeout(360); + ]); } private function uploadMedia($mediaItem): ?array @@ -130,7 +132,7 @@ private function uploadMedia($mediaItem): ?array } // Simple upload for small images - $response = Http::withToken($this->accessToken) + $response = $this->socialHttp()->withToken($this->accessToken) ->timeout(360) ->attach( 'media', @@ -149,7 +151,7 @@ private function uploadMedia($mediaItem): ?array if ($response->failed()) { Log::error('X media upload error', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } @@ -171,7 +173,7 @@ private function uploadMedia($mediaItem): ?array private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeType, string $mediaCategory): array { // INIT - $initResponse = Http::withToken($this->accessToken) + $initResponse = $this->socialHttp()->withToken($this->accessToken) ->timeout(60) ->post("{$this->baseUrl}/2/media/upload/initialize", [ 'media_type' => $mimeType, @@ -182,7 +184,7 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy if ($initResponse->failed()) { Log::error('X chunked upload INIT error', [ 'status' => $initResponse->status(), - 'body' => $initResponse->body(), + 'body' => $this->redactResponseBody($initResponse->body()), ]); $this->handleApiError($initResponse); } @@ -207,7 +209,7 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy break; } - $appendResponse = Http::withToken($this->accessToken) + $appendResponse = $this->socialHttp()->withToken($this->accessToken) ->timeout(300) ->attach('media', $chunk, 'chunk'.$index) ->post("{$this->baseUrl}/2/media/upload/{$mediaId}/append", [ @@ -217,7 +219,7 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy if ($appendResponse->failed()) { Log::error('X chunked upload APPEND error', [ 'status' => $appendResponse->status(), - 'body' => $appendResponse->body(), + 'body' => $this->redactResponseBody($appendResponse->body()), 'segment' => $index, ]); $this->handleApiError($appendResponse); @@ -230,14 +232,14 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy } // FINALIZE - Use the new v2 endpoint - $finalizeResponse = Http::withToken($this->accessToken) + $finalizeResponse = $this->socialHttp()->withToken($this->accessToken) ->timeout(60) ->post("{$this->baseUrl}/2/media/upload/{$mediaId}/finalize"); if ($finalizeResponse->failed()) { Log::error('X chunked upload FINALIZE error', [ 'status' => $finalizeResponse->status(), - 'body' => $finalizeResponse->body(), + 'body' => $this->redactResponseBody($finalizeResponse->body()), ]); $this->handleApiError($finalizeResponse); } @@ -281,7 +283,7 @@ private function waitForProcessing(string $mediaId, int $maxAttempts = 20): bool ->get("{$this->baseUrl}/2/media/{$mediaId}"); if ($response->failed()) { - Log::error('X media status check error: '.$response->body()); + Log::error('X media status check error', ['body' => $this->redactResponseBody($response->body())]); sleep(3); continue; diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index c373bfd3..cd6e5385 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -8,6 +8,7 @@ use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; +use App\Services\Social\Concerns\HasSocialHttpClient; use Google\Client as GoogleClient; use Google\Service\YouTube; use Google\Service\YouTube\Video; @@ -19,6 +20,8 @@ class YouTubePublisher { + use HasSocialHttpClient; + private const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks public function publish(PostPlatform $postPlatform): array @@ -207,9 +210,9 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('YouTube token refresh failed', ['body' => $response->body()]); + Log::error('YouTube token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - throw new TokenExpiredException('Failed to refresh YouTube token: '.$response->body()); + throw new TokenExpiredException('Failed to refresh YouTube token'); } $data = $response->json(); diff --git a/config/horizon.php b/config/horizon.php index 57ec0c84..a5ba8e0d 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\SocialAccount\Platform; use Illuminate\Support\Str; return [ @@ -224,6 +225,21 @@ 'timeout' => 630, 'nice' => 0, ], + + 'social-publishing' => [ + 'connection' => 'redis', + 'queue' => Platform::allQueues(), + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'minProcesses' => 1, + 'maxProcesses' => 3, + 'timeout' => 630, + 'maxTime' => 0, + 'maxJobs' => 0, + 'memory' => 256, + 'tries' => 1, + 'nice' => 0, + ], ], 'environments' => [ @@ -233,12 +249,22 @@ 'balanceMaxShift' => 1, 'balanceCooldown' => 3, ], + + 'social-publishing' => [ + 'maxProcesses' => 10, + 'balanceMaxShift' => 1, + 'balanceCooldown' => 3, + ], ], 'local' => [ 'supervisor-1' => [ 'maxProcesses' => 3, ], + + 'social-publishing' => [ + 'maxProcesses' => 3, + ], ], ], ]; diff --git a/lang/php_en.json b/lang/php_en.json deleted file mode 100644 index 6a296fed..00000000 --- a/lang/php_en.json +++ /dev/null @@ -1 +0,0 @@ -{"auth.failed":"These credentials do not match our records.","auth.password":"The provided password is incorrect.","auth.throttle":"Too many login attempts. Please try again in :seconds seconds.","pagination.previous":"« Previous","pagination.next":"Next »","passwords.reset":"Your password has been reset.","passwords.sent":"We have emailed your password reset link.","passwords.throttled":"Please wait before retrying.","passwords.token":"This password reset token is invalid.","passwords.user":"We can't find a user with that email address.","validation.accepted":"The :attribute field must be accepted.","validation.accepted_if":"The :attribute field must be accepted when :other is :value.","validation.active_url":"The :attribute field must be a valid URL.","validation.after":"The :attribute field must be a date after :date.","validation.after_or_equal":"The :attribute field must be a date after or equal to :date.","validation.alpha":"The :attribute field must only contain letters.","validation.alpha_dash":"The :attribute field must only contain letters, numbers, dashes, and underscores.","validation.alpha_num":"The :attribute field must only contain letters and numbers.","validation.any_of":"The :attribute field is invalid.","validation.array":"The :attribute field must be an array.","validation.ascii":"The :attribute field must only contain single-byte alphanumeric characters and symbols.","validation.before":"The :attribute field must be a date before :date.","validation.before_or_equal":"The :attribute field must be a date before or equal to :date.","validation.between.array":"The :attribute field must have between :min and :max items.","validation.between.file":"The :attribute field must be between :min and :max kilobytes.","validation.between.numeric":"The :attribute field must be between :min and :max.","validation.between.string":"The :attribute field must be between :min and :max characters.","validation.boolean":"The :attribute field must be true or false.","validation.can":"The :attribute field contains an unauthorized value.","validation.confirmed":"The :attribute field confirmation does not match.","validation.contains":"The :attribute field is missing a required value.","validation.current_password":"The password is incorrect.","validation.date":"The :attribute field must be a valid date.","validation.date_equals":"The :attribute field must be a date equal to :date.","validation.date_format":"The :attribute field must match the format :format.","validation.decimal":"The :attribute field must have :decimal decimal places.","validation.declined":"The :attribute field must be declined.","validation.declined_if":"The :attribute field must be declined when :other is :value.","validation.different":"The :attribute field and :other must be different.","validation.digits":"The :attribute field must be :digits digits.","validation.digits_between":"The :attribute field must be between :min and :max digits.","validation.dimensions":"The :attribute field has invalid image dimensions.","validation.distinct":"The :attribute field has a duplicate value.","validation.doesnt_contain":"The :attribute field must not contain any of the following: :values.","validation.doesnt_end_with":"The :attribute field must not end with one of the following: :values.","validation.doesnt_start_with":"The :attribute field must not start with one of the following: :values.","validation.email":"The :attribute field must be a valid email address.","validation.encoding":"The :attribute field must be encoded in :encoding.","validation.ends_with":"The :attribute field must end with one of the following: :values.","validation.enum":"The selected :attribute is invalid.","validation.exists":"The selected :attribute is invalid.","validation.extensions":"The :attribute field must have one of the following extensions: :values.","validation.file":"The :attribute field must be a file.","validation.filled":"The :attribute field must have a value.","validation.gt.array":"The :attribute field must have more than :value items.","validation.gt.file":"The :attribute field must be greater than :value kilobytes.","validation.gt.numeric":"The :attribute field must be greater than :value.","validation.gt.string":"The :attribute field must be greater than :value characters.","validation.gte.array":"The :attribute field must have :value items or more.","validation.gte.file":"The :attribute field must be greater than or equal to :value kilobytes.","validation.gte.numeric":"The :attribute field must be greater than or equal to :value.","validation.gte.string":"The :attribute field must be greater than or equal to :value characters.","validation.hex_color":"The :attribute field must be a valid hexadecimal color.","validation.image":"The :attribute field must be an image.","validation.in":"The selected :attribute is invalid.","validation.in_array":"The :attribute field must exist in :other.","validation.in_array_keys":"The :attribute field must contain at least one of the following keys: :values.","validation.integer":"The :attribute field must be an integer.","validation.ip":"The :attribute field must be a valid IP address.","validation.ipv4":"The :attribute field must be a valid IPv4 address.","validation.ipv6":"The :attribute field must be a valid IPv6 address.","validation.json":"The :attribute field must be a valid JSON string.","validation.list":"The :attribute field must be a list.","validation.lowercase":"The :attribute field must be lowercase.","validation.lt.array":"The :attribute field must have less than :value items.","validation.lt.file":"The :attribute field must be less than :value kilobytes.","validation.lt.numeric":"The :attribute field must be less than :value.","validation.lt.string":"The :attribute field must be less than :value characters.","validation.lte.array":"The :attribute field must not have more than :value items.","validation.lte.file":"The :attribute field must be less than or equal to :value kilobytes.","validation.lte.numeric":"The :attribute field must be less than or equal to :value.","validation.lte.string":"The :attribute field must be less than or equal to :value characters.","validation.mac_address":"The :attribute field must be a valid MAC address.","validation.max.array":"The :attribute field must not have more than :max items.","validation.max.file":"The :attribute field must not be greater than :max kilobytes.","validation.max.numeric":"The :attribute field must not be greater than :max.","validation.max.string":"The :attribute field must not be greater than :max characters.","validation.max_digits":"The :attribute field must not have more than :max digits.","validation.mimes":"The :attribute field must be a file of type: :values.","validation.mimetypes":"The :attribute field must be a file of type: :values.","validation.min.array":"The :attribute field must have at least :min items.","validation.min.file":"The :attribute field must be at least :min kilobytes.","validation.min.numeric":"The :attribute field must be at least :min.","validation.min.string":"The :attribute field must be at least :min characters.","validation.min_digits":"The :attribute field must have at least :min digits.","validation.missing":"The :attribute field must be missing.","validation.missing_if":"The :attribute field must be missing when :other is :value.","validation.missing_unless":"The :attribute field must be missing unless :other is :value.","validation.missing_with":"The :attribute field must be missing when :values is present.","validation.missing_with_all":"The :attribute field must be missing when :values are present.","validation.multiple_of":"The :attribute field must be a multiple of :value.","validation.not_in":"The selected :attribute is invalid.","validation.not_regex":"The :attribute field format is invalid.","validation.numeric":"The :attribute field must be a number.","validation.password.letters":"The :attribute field must contain at least one letter.","validation.password.mixed":"The :attribute field must contain at least one uppercase and one lowercase letter.","validation.password.numbers":"The :attribute field must contain at least one number.","validation.password.symbols":"The :attribute field must contain at least one symbol.","validation.password.uncompromised":"The given :attribute has appeared in a data leak. Please choose a different :attribute.","validation.present":"The :attribute field must be present.","validation.present_if":"The :attribute field must be present when :other is :value.","validation.present_unless":"The :attribute field must be present unless :other is :value.","validation.present_with":"The :attribute field must be present when :values is present.","validation.present_with_all":"The :attribute field must be present when :values are present.","validation.prohibited":"The :attribute field is prohibited.","validation.prohibited_if":"The :attribute field is prohibited when :other is :value.","validation.prohibited_if_accepted":"The :attribute field is prohibited when :other is accepted.","validation.prohibited_if_declined":"The :attribute field is prohibited when :other is declined.","validation.prohibited_unless":"The :attribute field is prohibited unless :other is in :values.","validation.prohibits":"The :attribute field prohibits :other from being present.","validation.regex":"The :attribute field format is invalid.","validation.required":"The :attribute field is required.","validation.required_array_keys":"The :attribute field must contain entries for: :values.","validation.required_if":"The :attribute field is required when :other is :value.","validation.required_if_accepted":"The :attribute field is required when :other is accepted.","validation.required_if_declined":"The :attribute field is required when :other is declined.","validation.required_unless":"The :attribute field is required unless :other is in :values.","validation.required_with":"The :attribute field is required when :values is present.","validation.required_with_all":"The :attribute field is required when :values are present.","validation.required_without":"The :attribute field is required when :values is not present.","validation.required_without_all":"The :attribute field is required when none of :values are present.","validation.same":"The :attribute field must match :other.","validation.size.array":"The :attribute field must contain :size items.","validation.size.file":"The :attribute field must be :size kilobytes.","validation.size.numeric":"The :attribute field must be :size.","validation.size.string":"The :attribute field must be :size characters.","validation.starts_with":"The :attribute field must start with one of the following: :values.","validation.string":"The :attribute field must be a string.","validation.timezone":"The :attribute field must be a valid timezone.","validation.unique":"The :attribute has already been taken.","validation.uploaded":"The :attribute failed to upload.","validation.uppercase":"The :attribute field must be uppercase.","validation.url":"The :attribute field must be a valid URL.","validation.ulid":"The :attribute field must be a valid ULID.","validation.uuid":"The :attribute field must be a valid UUID.","validation.custom.attribute-name.rule-name":"custom-message","accounts.title":"Connections","accounts.page_title":"Connected Accounts","accounts.description":"Connect your social networks to schedule and publish posts","accounts.not_connected":"Not connected","accounts.connect":"Connect","accounts.connection_lost":"Connection lost","accounts.reconnect_account":"Reconnect account","accounts.view_profile":"View profile","accounts.disconnect":"Disconnect","accounts.disconnect_modal.title":"Disconnect Account","accounts.disconnect_modal.description":"Are you sure you want to disconnect this account? You can reconnect it at any time.","accounts.disconnect_modal.confirm":"Disconnect","accounts.disconnect_modal.cancel":"Cancel","accounts.bluesky.title":"Connect Bluesky","accounts.bluesky.description":"Enter your credentials to connect","accounts.bluesky.email":"Email","accounts.bluesky.email_placeholder":"yourhandle.bsky.social","accounts.bluesky.app_password":"App Password","accounts.bluesky.app_password_placeholder":"xxxx-xxxx-xxxx-xxxx","accounts.bluesky.app_password_hint":"Use an App Password for security. Create one at bsky.app/settings.","accounts.bluesky.submit":"Connect Bluesky","accounts.bluesky.submitting":"Connecting...","accounts.mastodon.title":"Connect Mastodon","accounts.mastodon.description":"Enter your Mastodon instance","accounts.mastodon.instance_url":"Instance URL","accounts.mastodon.instance_placeholder":"https://mastodon.social","accounts.mastodon.instance_hint":"Enter your Mastodon instance URL (e.g., mastodon.social, techhub.social)","accounts.mastodon.submit":"Continue with Mastodon","accounts.mastodon.submitting":"Connecting...","accounts.facebook.title":"Select Facebook Page","accounts.facebook.description":"Choose which page you want to connect","accounts.facebook.no_pages":"No pages found","accounts.facebook.no_pages_description":"You are not an admin of any Facebook page.","accounts.facebook.page_label":"Facebook Page","accounts.linkedin.title":"Select LinkedIn Page","accounts.linkedin.description":"Choose which page you want to connect","accounts.linkedin.no_pages":"No pages found","accounts.linkedin.no_pages_description":"You are not an administrator of any LinkedIn page.","accounts.linkedin.page_label":"LinkedIn Page","accounts.flash.disconnected":"Account disconnected successfully!","accounts.flash.connected":"Account connected successfully!","accounts.flash.session_expired":"Session expired. Please try again.","accounts.flash.workspace_not_found":"Workspace not found.","accounts.flash.activated":"Account activated!","accounts.flash.deactivated":"Account deactivated!","accounts.flash.already_connected":"This platform is already connected.","accounts.flash.no_youtube_channels":"No YouTube channels found. Please create a channel first.","auth.flash.welcome":"Welcome to TryPost!","auth.flash.welcome_trial":"Welcome to TryPost! Your trial has started.","auth.legal":"By continuing, you agree to our Terms of Service and Privacy Policy.","auth.slides.calendar.title":"Visual Calendar","auth.slides.calendar.description":"Plan and schedule your content with an intuitive drag-and-drop calendar across all your social accounts.","auth.slides.scheduling.title":"Smart Scheduling","auth.slides.scheduling.description":"Schedule posts across LinkedIn, X, Instagram, TikTok, YouTube, and more — all from one place.","auth.slides.media.title":"Rich Media","auth.slides.media.description":"Publish images, carousels, stories, and reels. Each platform gets the right format automatically.","auth.slides.video.title":"Video Publishing","auth.slides.video.description":"Upload videos once and publish to TikTok, YouTube Shorts, Instagram Reels, and Facebook Reels.","auth.slides.team.title":"Team Workspaces","auth.slides.team.description":"Invite your team, assign roles, and manage multiple brands from separate workspaces.","auth.slides.hashtags.title":"Hashtag Groups","auth.slides.hashtags.description":"Save hashtag collections and add them to posts with one click. Organize with labels and filters.","auth.or_continue_with":"Or continue with","auth.google_login":"Log in with Google","auth.google_signup":"Sign up with Google","auth.signup_success.page_title":"Welcome","auth.signup_success.title":"Setting up your account","auth.signup_success.description":"This usually takes just a few seconds...","auth.login.title":"Log in to your account","auth.login.description":"Enter your email and password below to log in","auth.login.page_title":"Log in","auth.login.email":"Email address","auth.login.password":"Password","auth.login.forgot_password":"Forgot password?","auth.login.remember_me":"Remember me","auth.login.submit":"Log in","auth.login.no_account":"Don't have an account?","auth.login.sign_up":"Sign up","auth.register.title":"Create an account","auth.register.description":"Enter your details below to create your account","auth.register.page_title":"Register","auth.register.name":"Name","auth.register.name_placeholder":"Full name","auth.register.email":"Email address","auth.register.password":"Password","auth.register.show_password":"Show password","auth.register.hide_password":"Hide password","auth.register.submit":"Create account","auth.register.has_account":"Already have an account?","auth.register.log_in":"Log in","auth.forgot_password.title":"Forgot password","auth.forgot_password.description":"Enter your email to receive a password reset link","auth.forgot_password.page_title":"Forgot password","auth.forgot_password.email":"Email address","auth.forgot_password.submit":"Email password reset link","auth.forgot_password.return_to":"Or, return to","auth.forgot_password.log_in":"log in","auth.reset_password.title":"Reset password","auth.reset_password.description":"Please enter your new password below","auth.reset_password.page_title":"Reset password","auth.reset_password.email":"Email","auth.reset_password.password":"Password","auth.reset_password.confirm_password":"Confirm Password","auth.reset_password.confirm_placeholder":"Confirm password","auth.reset_password.submit":"Reset password","auth.verify_email.title":"Verify email","auth.verify_email.description":"Please verify your email address by clicking on the link we just emailed to you.","auth.verify_email.page_title":"Email verification","auth.verify_email.link_sent":"A new verification link has been sent to the email address you provided during registration.","auth.verify_email.resend":"Resend verification email","auth.verify_email.log_out":"Log out","auth.accept_invite.page_title":"Accept Invite","auth.accept_invite.title":"You've been invited!","auth.accept_invite.description":"You've been invited to join the :workspace workspace.","auth.accept_invite.workspace":"Workspace","auth.accept_invite.your_role":"Your role","auth.accept_invite.email":"Email","auth.accept_invite.accept":"Accept Invite","auth.accept_invite.decline":"Decline Invite","auth.accept_invite.login_prompt":"Log in or create an account to accept this invite.","auth.accept_invite.log_in":"Log in","auth.accept_invite.create_account":"Create Account","billing.title":"Subscription","billing.description":"Manage your subscription and payment method","billing.subscribe.page_title":"Start your free trial","billing.subscribe.title":"Start your free trial","billing.subscribe.description":":days days free to explore all features.","billing.subscribe.start_trial":"Start :days-day free trial","billing.subscribe.cancel_anytime":"Cancel anytime. No questions asked.","billing.subscribe.switch_workspace":"Switch workspace","billing.subscribe.features.calendar":"Visual drag-and-drop calendar","billing.subscribe.features.scheduling":"Unlimited post scheduling","billing.subscribe.features.media":"Images, carousels & stories","billing.subscribe.features.video":"Video publishing across platforms","billing.subscribe.features.team":"Team collaboration & workspaces","billing.subscribe.features.hashtags":"Hashtag groups & labels","billing.trial.title":"Trial period active","billing.trial.description":"Your trial ends on :date. After that, your subscription will be charged automatically.","billing.subscription.title":"Your Subscription","billing.subscription.status":"Status","billing.subscription.workspaces":"Workspaces","billing.subscription.quantity":"Subscription quantity","billing.subscription.expires":"Expires :date","billing.subscription.canceled_on":"Your subscription will be canceled on :date","billing.subscription.manage":"Manage on Stripe","billing.invoices.title":"Invoices","billing.invoices.description":"Payment history","billing.invoices.empty":"No invoices found","billing.invoices.paid":"Paid","billing.processing.page_title":"Processing...","billing.processing.title":"Processing your subscription","billing.processing.description":"Please wait while we set up your account. This will only take a moment.","billing.processing.success_title":"You're all set!","billing.processing.success_description":"Your subscription is active. Redirecting you to your workspaces...","billing.processing.cancelled_title":"Checkout cancelled","billing.processing.cancelled_description":"Your checkout was cancelled. No charges were made.","billing.processing.retry":"Try again","billing.status.active":"Active","billing.status.canceled":"Canceled","billing.status.incomplete":"Incomplete","billing.status.incomplete_expired":"Expired","billing.status.past_due":"Past due","billing.status.trialing":"Trial","billing.status.unpaid":"Unpaid","calendar.title":"Calendar","calendar.today":"Today","calendar.day":"Day","calendar.week":"Week","calendar.month":"Month","calendar.new_post":"New Post","calendar.no_content":"No content","calendar.more":"+:count more","common.confirm_modal.cannot_be_undone":"This cannot be undone.","common.confirm_modal.type":"Type","common.confirm_modal.to_confirm":"to confirm.","common.confirm_modal.copy_to_clipboard":"Copy to clipboard","common.photo_upload.upload":"Upload","common.photo_upload.uploading":"Uploading...","common.photo_upload.remove":"Remove photo","common.photo_upload.hint":"Recommended: square image, max 2 MB.","common.timezone.select":"Select timezone","common.timezone.search":"Search timezone...","common.timezone.empty":"No timezone found","common.date_picker.select":"Select date","common.cancel":"Cancel","hashtags.title":"Hashtags","hashtags.description":"Create hashtag groups to quickly add to your posts","hashtags.search":"Search hashtags...","hashtags.new_group":"New Group","hashtags.no_groups_yet":"No hashtag groups yet","hashtags.no_groups_description":"Create hashtag groups to quickly add popular hashtags to your posts","hashtags.create_first_group":"Create your first group","hashtags.hashtags_count":":count hashtags","hashtags.create.title":"Create Hashtag Group","hashtags.create.description":"Give your group a name and add hashtags separated by spaces or commas","hashtags.create.name":"Group Name","hashtags.create.name_placeholder":"e.g. Marketing, Travel, Food","hashtags.create.hashtags":"Hashtags","hashtags.create.hashtags_placeholder":"#marketing #socialmedia #business #growth","hashtags.create.hashtags_hint":"Enter hashtags separated by spaces or commas. Include the # symbol.","hashtags.create.submit":"Create Group","hashtags.create.submitting":"Creating...","hashtags.edit.title":"Edit Hashtag Group","hashtags.edit.description":"Update the name and hashtags for this group","hashtags.edit.name":"Group Name","hashtags.edit.name_placeholder":"e.g. Marketing, Travel, Food","hashtags.edit.hashtags":"Hashtags","hashtags.edit.hashtags_placeholder":"#marketing #socialmedia #business #growth","hashtags.edit.hashtags_hint":"Enter hashtags separated by spaces or commas. Include the # symbol.","hashtags.edit.submit":"Save Changes","hashtags.edit.submitting":"Saving...","hashtags.delete.title":"Delete Hashtag Group","hashtags.delete.description":"Are you sure you want to delete this hashtag group? This action cannot be undone.","hashtags.delete.confirm":"Delete","hashtags.delete.cancel":"Cancel","hashtags.flash.created":"Hashtag group created successfully!","hashtags.flash.updated":"Hashtag group updated successfully!","hashtags.flash.deleted":"Hashtag group deleted successfully!","labels.title":"Labels","labels.description":"Create labels to organize and categorize your posts","labels.search":"Search labels...","labels.new_label":"New Label","labels.no_labels_yet":"No labels yet","labels.create_first_label":"Create your first label","labels.create.title":"Create Label","labels.create.description":"Give your label a name and pick a color","labels.create.name":"Name","labels.create.name_placeholder":"Enter label name...","labels.create.color":"Color","labels.create.submit":"Create Label","labels.create.submitting":"Creating...","labels.edit.title":"Edit Label","labels.edit.description":"Update the name and color for this label","labels.edit.name":"Name","labels.edit.name_placeholder":"Enter label name...","labels.edit.color":"Color","labels.edit.submit":"Save Changes","labels.edit.submitting":"Saving...","labels.delete.title":"Delete Label","labels.delete.description":"Are you sure you want to delete this label? This action cannot be undone.","labels.delete.confirm":"Delete","labels.delete.cancel":"Cancel","labels.flash.created":"Label created successfully!","labels.flash.updated":"Label updated successfully!","labels.flash.deleted":"Label deleted successfully!","mail.workspace_connections_disconnected.subject":"{1} :count account needs to be reconnected in :workspace|[2,*] :count accounts need to be reconnected in :workspace","mail.workspace_connections_disconnected.title":"Accounts Need Reconnection","mail.workspace_connections_disconnected.intro":"The following social accounts in your :workspace workspace have been disconnected and need to be reconnected:","mail.workspace_connections_disconnected.reasons_title":"This may have happened because:","mail.workspace_connections_disconnected.reason_expired":"Access tokens expired","mail.workspace_connections_disconnected.reason_revoked":"You revoked access to TryPost on the platform","mail.workspace_connections_disconnected.reason_changed":"The platform changed their authentication requirements","mail.workspace_connections_disconnected.reconnect_cta":"Please reconnect these accounts to continue scheduling and publishing posts.","mail.workspace_connections_disconnected.button":"Reconnect Accounts","onboarding.role.page_title":"Welcome - Tell us about yourself","onboarding.role.title":"Tell us about yourself","onboarding.role.description":"Help us personalize your experience","onboarding.role.submit":"Continue","onboarding.personas.founder.label":"Founder","onboarding.personas.founder.description":"Building a startup or new venture","onboarding.personas.creator.label":"Creator","onboarding.personas.creator.description":"Content creator or influencer","onboarding.personas.agency.label":"Agency","onboarding.personas.agency.description":"Marketing or social media agency","onboarding.personas.enterprise.label":"Enterprise","onboarding.personas.enterprise.description":"Large company or corporation","onboarding.personas.small_business.label":"Small Business","onboarding.personas.small_business.description":"Small to medium business","onboarding.personas.personal.label":"Personal","onboarding.personas.personal.description":"Personal brand or hobby","onboarding.connect.page_title":"Connect your accounts","onboarding.connect.title":"Connect your accounts","onboarding.connect.description":"Connect at least one social network to get started","onboarding.connect.submit":"Continue","onboarding.connect.error":"Something went wrong. Please try again.","onboarding.connect.go_back":"Go Back","posts.title":"Posts","posts.search":"Search posts...","posts.all_posts":"All Posts","posts.new_post":"New Post","posts.no_posts":"No posts found","posts.start_creating":"Start by creating your first post.","posts.manage_posts":"Manage all your posts","posts.delete_confirm":"Are you sure you want to delete this post?","posts.by":"by","posts.actions.view":"View post","posts.actions.delete":"Delete post","posts.form.post_type":"Post Type","posts.form.board":"Board","posts.form.select_board":"Select a board","posts.form.search_board":"Search board...","posts.form.no_board_found":"No board found","posts.form.media":"Media","posts.form.min":"Min","posts.form.uploading":"Uploading...","posts.form.drop_to_upload":"Drop to upload","posts.form.drag_and_drop":"Drag & drop or click to upload","posts.form.photos_and_videos":"Photos and videos","posts.form.photos_only":"Photos only","posts.form.videos_only":"Videos only","posts.form.drag_to_reorder":"Drag to reorder","posts.form.caption":"Caption","posts.form.write_caption":"Write your caption...","posts.status.pending":"Pending","posts.status.draft":"Draft","posts.status.scheduled":"Scheduled","posts.status.publishing":"Publishing","posts.status.published":"Published","posts.status.partially_published":"Partially Published","posts.status.failed":"Failed","posts.descriptions.draft":"Posts waiting to be scheduled","posts.descriptions.scheduled":"Posts scheduled for publishing","posts.descriptions.published":"Posts already published","posts.edit.title":"Edit Post","posts.edit.view_title":"View Post","posts.edit.manage_platforms":"Manage platforms","posts.edit.sync":"Sync","posts.edit.labels":"Labels","posts.edit.hashtags":"Hashtags","posts.edit.schedule":"Schedule","posts.edit.publish":"Publish","posts.edit.delete":"Delete","posts.edit.settings":"Settings","posts.edit.schedule_for":"Schedule for","posts.edit.saving":"Saving...","posts.edit.saved":"Saved","posts.edit.scheduled_at":"Scheduled:","posts.edit.published_at":"Published:","posts.edit.media":"Media","posts.edit.caption":"Caption","posts.edit.no_caption":"No caption","posts.edit.no_content":"No content","posts.edit.empty_state.title":"No platforms selected","posts.edit.empty_state.description":"Select at least one platform to create your post","posts.edit.delete_modal.title":"Delete Post","posts.edit.delete_modal.description":"Are you sure you want to delete this post? This action cannot be undone.","posts.edit.delete_modal.action":"Delete","posts.edit.delete_modal.cancel":"Cancel","posts.edit.sync_enable.title":"Enable sync?","posts.edit.sync_enable.description":"All platforms will share the same content. Any custom edits made to individual platforms will be replaced with the current content.","posts.edit.sync_enable.cancel":"Cancel","posts.edit.sync_enable.action":"Enable sync","posts.edit.sync_disable.title":"Disable sync?","posts.edit.sync_disable.description":"Each platform will keep its current content, but future edits will only apply to the platform you're editing.","posts.edit.sync_disable.customize_note":"You'll be able to customize the content for each platform individually.","posts.edit.sync_disable.cancel":"Cancel","posts.edit.sync_disable.action":"Disable sync","posts.edit.platforms_dialog.title":"Select Platforms","posts.edit.platforms_dialog.description":"Choose which platforms to publish this post to.","posts.edit.hashtags_modal.search":"Search hashtags...","posts.edit.hashtags_modal.no_results":"No hashtags found.","posts.edit.validation.select_board":"Select a board","posts.edit.validation.images_not_supported":"Images not supported","posts.edit.validation.videos_not_supported":"Videos not supported","posts.edit.validation.max_images":"Max :count images","posts.edit.validation.requires_media":"Requires media","posts.edit.validation.exceeded":":count exceeded","posts.edit.validation.does_not_support_images":":platform does not support images","posts.edit.validation.supports_up_to_images":":platform supports up to :count images","posts.edit.validation.does_not_support_videos":":platform does not support videos","posts.content_types.instagram_feed.label":"Feed Post","posts.content_types.instagram_feed.description":"Appears in your feed and profile","posts.content_types.instagram_reel.label":"Reel","posts.content_types.instagram_reel.description":"Short video up to 90 seconds","posts.content_types.instagram_story.label":"Story","posts.content_types.instagram_story.description":"Disappears after 24 hours","posts.content_types.linkedin_post.label":"Post","posts.content_types.linkedin_post.description":"Standard post with text and media","posts.content_types.linkedin_carousel.label":"Carousel","posts.content_types.linkedin_carousel.description":"Swipeable images","posts.content_types.linkedin_page_post.label":"Post","posts.content_types.linkedin_page_post.description":"Standard post with text and media","posts.content_types.linkedin_page_carousel.label":"Carousel","posts.content_types.linkedin_page_carousel.description":"Swipeable images","posts.content_types.facebook_post.label":"Post","posts.content_types.facebook_post.description":"Standard post on your page","posts.content_types.facebook_reel.label":"Reel","posts.content_types.facebook_reel.description":"Short video up to 90 seconds","posts.content_types.facebook_story.label":"Story","posts.content_types.facebook_story.description":"Disappears after 24 hours","posts.content_types.tiktok_video.label":"Video","posts.content_types.tiktok_video.description":"Short-form video content","posts.content_types.youtube_short.label":"Short","posts.content_types.youtube_short.description":"Vertical video up to 60 seconds","posts.content_types.x_post.label":"Post","posts.content_types.x_post.description":"Tweet with text and media","posts.content_types.threads_post.label":"Post","posts.content_types.threads_post.description":"Text post with optional media","posts.content_types.pinterest_pin.label":"Pin","posts.content_types.pinterest_pin.description":"Image pin with link","posts.content_types.pinterest_video_pin.label":"Video Pin","posts.content_types.pinterest_video_pin.description":"Video content","posts.content_types.pinterest_carousel.label":"Carousel","posts.content_types.pinterest_carousel.description":"2-5 images","posts.content_types.bluesky_post.label":"Post","posts.content_types.bluesky_post.description":"Text post with optional images","posts.content_types.mastodon_post.label":"Post","posts.content_types.mastodon_post.description":"Text post with optional media","posts.platforms.linkedin":"LinkedIn","posts.platforms.linkedin-page":"LinkedIn Page","posts.platforms.x":"X","posts.platforms.tiktok":"TikTok","posts.platforms.youtube":"YouTube Shorts","posts.platforms.facebook":"Facebook Page","posts.platforms.instagram":"Instagram","posts.platforms.threads":"Threads","posts.platforms.pinterest":"Pinterest","posts.platforms.bluesky":"Bluesky","posts.platforms.mastodon":"Mastodon","posts.flash.scheduled":"Post scheduled successfully!","posts.flash.publishing":"Post is being published!","posts.flash.deleted":"Post deleted successfully!","posts.flash.cannot_edit_published":"Published posts cannot be edited.","posts.flash.connect_first":"Connect at least one social network before creating a post.","posts.errors.account_disconnected":"Social account is disconnected","posts.errors.account_inactive":"Social account is deactivated","settings.title":"Settings","settings.description":"Manage your profile and account settings","settings.nav.profile":"Profile","settings.nav.password":"Password","settings.nav.workspace":"Workspace","settings.nav.members":"Members","settings.nav.notifications":"Notifications","settings.nav.billing":"Billing","settings.notifications.title":"Notification preferences","settings.notifications.heading":"Email notifications","settings.notifications.description":"Choose which email notifications you want to receive","settings.notifications.post_published":"Post published","settings.notifications.post_published_description":"Receive an email when your post is published successfully","settings.notifications.post_failed":"Post failed","settings.notifications.post_failed_description":"Receive an email when your post fails to publish","settings.notifications.account_disconnected":"Account disconnected","settings.notifications.account_disconnected_description":"Receive an email when a social account is disconnected","settings.notifications.save":"Save preferences","settings.profile.title":"Profile settings","settings.profile.photo_heading":"Profile photo","settings.profile.photo_description":"Upload a profile photo","settings.profile.heading":"Profile information","settings.profile.description":"Update your name and email address","settings.profile.avatar":"Avatar","settings.profile.name":"Name","settings.profile.name_placeholder":"Full name","settings.profile.email":"Email address","settings.profile.email_placeholder":"Email address","settings.profile.email_unverified":"Your email address is unverified.","settings.profile.resend_verification":"Click here to resend the verification email.","settings.profile.verification_sent":"A new verification link has been sent to your email address.","settings.profile.save":"Save","settings.password.title":"Password settings","settings.password.heading":"Update password","settings.password.description":"Ensure your account is using a long, random password to stay secure","settings.password.current_password":"Current password","settings.password.current_password_placeholder":"Current password","settings.password.new_password":"New password","settings.password.new_password_placeholder":"New password","settings.password.confirm_password":"Confirm password","settings.password.confirm_password_placeholder":"Confirm password","settings.password.save":"Save password","settings.delete_account.heading":"Delete account","settings.delete_account.description":"Delete your account and all of its resources","settings.delete_account.warning":"Warning","settings.delete_account.warning_message":"Please proceed with caution, this cannot be undone.","settings.delete_account.button":"Delete account","settings.delete_account.modal_title":"Are you sure you want to delete your account?","settings.delete_account.modal_description":"Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.","settings.delete_account.password":"Password","settings.delete_account.password_placeholder":"Password","settings.delete_account.cancel":"Cancel","settings.delete_account.confirm":"Delete account","settings.workspace.title":"Workspace settings","settings.workspace.logo_heading":"Workspace logo","settings.workspace.logo_description":"Upload a logo for your workspace","settings.workspace.heading":"Workspace name","settings.workspace.description":"Update your workspace name and timezone","settings.workspace.members_heading":"Members","settings.workspace.members_description":"Manage workspace members and invitations","settings.workspace.name":"Name","settings.workspace.name_placeholder":"My Workspace","settings.workspace.timezone":"Timezone","settings.workspace.save":"Save","settings.members.title":"Members","settings.members.heading":"Team members","settings.members.description":"Manage members and invites for this workspace","settings.members.cancel":"Cancel","settings.members.remove":"Remove","settings.members.make_admin":"Make admin","settings.members.make_member":"Make member","settings.members.invite.title":"Invite Member","settings.members.invite.description":"Send an email invite to add collaborators","settings.members.invite.email":"Email","settings.members.invite.email_placeholder":"collaborator@email.com","settings.members.invite.role":"Role","settings.members.invite.role_placeholder":"Select a role","settings.members.invite.submit":"Send Invite","settings.members.pending.title":"Pending Invites","settings.members.pending.description":"Invites awaiting acceptance","settings.members.pending.empty":"No pending invites","settings.members.list.title":"Members","settings.members.list.description":"People with access to this workspace","settings.members.list.empty":"No members besides the owner","settings.members.remove_modal.title":"Remove member","settings.members.remove_modal.description":"Are you sure you want to remove this member from the workspace? They will lose access to all workspace resources.","settings.members.remove_modal.action":"Remove member","settings.members.cancel_invite_modal.title":"Cancel invitation","settings.members.cancel_invite_modal.description":"Are you sure you want to cancel this invitation?","settings.members.cancel_invite_modal.action":"Cancel invitation","settings.members.roles.owner":"Owner","settings.members.roles.admin":"Admin","settings.members.roles.member":"Member","settings.members.flash.invite_sent":"Invite sent successfully!","settings.members.flash.invite_deleted":"Invite deleted.","settings.members.flash.member_removed":"Member removed successfully.","settings.members.flash.role_updated":"Member role updated.","settings.members.flash.wrong_email":"This invite is for a different email address.","settings.members.flash.already_member":"You are already a member of this workspace.","settings.members.flash.invite_accepted":"Welcome! You are now a member of the workspace.","settings.members.flash.invite_declined":"Invite declined.","settings.flash.profile_updated":"Profile updated successfully!","settings.flash.language_updated":"Language updated successfully!","settings.flash.password_updated":"Password updated successfully!","settings.flash.workspace_updated":"Settings updated successfully!","settings.flash.photo_updated":"Photo updated successfully!","settings.flash.photo_deleted":"Photo removed successfully!","settings.flash.logo_updated":"Logo uploaded successfully!","settings.flash.logo_deleted":"Logo removed successfully!","settings.flash.notifications_updated":"Notification preferences updated!","settings.api_keys.title":"API Keys","settings.api_keys.page_title":"API Keys","settings.api_keys.heading":"API Keys","settings.api_keys.description":"Manage API keys for programmatic access to your workspace.","settings.api_keys.create":"Create API Key","settings.api_keys.copy":"Copy","settings.api_keys.new_token_message":"Your new API key has been created. Copy it now — you won't be able to see it again.","settings.api_keys.table.name":"Name","settings.api_keys.table.key":"Key","settings.api_keys.table.status":"Status","settings.api_keys.table.expires":"Expires","settings.api_keys.table.last_used":"Last Used","settings.api_keys.table.never":"Never","settings.api_keys.actions.copy_id":"Copy API Key ID","settings.api_keys.actions.copy_id_success":"API Key ID copied to clipboard","settings.api_keys.actions.delete":"Delete","settings.api_keys.empty.title":"No API keys yet","settings.api_keys.empty.description":"Create an API key to access your workspace programmatically.","settings.api_keys.delete_modal.title":"Delete API key","settings.api_keys.delete_modal.description":"Are you sure you want to delete this API key? Any applications using this key will lose access immediately.","settings.api_keys.delete_modal.action":"Delete API key","settings.api_keys.create_dialog.title":"Create API Key","settings.api_keys.create_dialog.description":"Create a new API key for programmatic access to your workspace.","settings.api_keys.create_dialog.name":"Name","settings.api_keys.create_dialog.name_placeholder":"e.g. Production API Key","settings.api_keys.create_dialog.expires":"Expiration date (optional)","settings.api_keys.create_dialog.expires_placeholder":"No expiration","settings.api_keys.create_dialog.submit":"Create","settings.api_keys.create_dialog.cancel":"Cancel","settings.api_keys.flash.created":"API key created successfully!","settings.api_keys.flash.deleted":"API key deleted successfully!","sidebar.workspaces":"Workspaces","sidebar.select_workspace":"Select workspace","sidebar.create_workspace":"Create workspace","sidebar.create_post":"Create post","sidebar.profile":"Profile","sidebar.account":"Account","sidebar.log_out":"Log out","sidebar.workspace":"Workspace: :name","sidebar.workspace_select":"Workspace: Select","sidebar.theme":"Theme: :name","sidebar.theme_light":"Light","sidebar.theme_dark":"Dark","sidebar.theme_system":"System","sidebar.language":"Language: :name","sidebar.language_select":"Language: Select","sidebar.groups.posts":"Posts","sidebar.groups.configuration":"Configurations","sidebar.groups.support":"Support","sidebar.posts.calendar":"Calendar","sidebar.posts.all":"All","sidebar.posts.scheduled":"Scheduled","sidebar.posts.posted":"Posted","sidebar.posts.drafts":"Drafts","sidebar.config.connections":"Connections","sidebar.config.hashtags":"Hashtags","sidebar.config.labels":"Labels","sidebar.config.settings":"Settings","sidebar.notifications":"Notifications","sidebar.mark_all_read":"Mark all as read","sidebar.mark_as_read":"Mark as read","sidebar.archive_all":"Archive all","sidebar.no_notifications":"No notifications","sidebar.support.discord":"Discord","sidebar.support.share_feedback":"Share feedback","sidebar.support.last_updates":"Last Updates","sidebar.support.docs":"Documentation","workspaces.title":"Workspaces","workspaces.select_title":"Your workspaces","workspaces.select_description":"Select a workspace to continue","workspaces.current":"Current","workspaces.connections":":count connections","workspaces.posts":":count posts","workspaces.create.title":"Create a workspace","workspaces.create.description":"Give your workspace a name to get started","workspaces.create.name":"Workspace name","workspaces.create.name_placeholder":"My Workspace","workspaces.create.submit":"Create workspace"} \ No newline at end of file diff --git a/lang/php_es.json b/lang/php_es.json deleted file mode 100644 index a1c74fec..00000000 --- a/lang/php_es.json +++ /dev/null @@ -1 +0,0 @@ -{"accounts.title":"Conexiones","accounts.page_title":"Cuentas conectadas","accounts.description":"Conecta tus redes sociales para programar y publicar posts","accounts.not_connected":"No conectado","accounts.connect":"Conectar","accounts.connection_lost":"Conexión perdida","accounts.reconnect_account":"Reconectar cuenta","accounts.view_profile":"Ver perfil","accounts.disconnect":"Desconectar","accounts.disconnect_modal.title":"Desconectar cuenta","accounts.disconnect_modal.description":"¿Estás seguro de que deseas desconectar esta cuenta? Puedes volver a conectarla en cualquier momento.","accounts.disconnect_modal.confirm":"Desconectar","accounts.disconnect_modal.cancel":"Cancelar","accounts.bluesky.title":"Conectar Bluesky","accounts.bluesky.description":"Introduce tus credenciales para conectar","accounts.bluesky.email":"Correo electrónico","accounts.bluesky.email_placeholder":"tuusuario.bsky.social","accounts.bluesky.app_password":"Contraseña de app","accounts.bluesky.app_password_placeholder":"xxxx-xxxx-xxxx-xxxx","accounts.bluesky.app_password_hint":"Usa una Contraseña de App por seguridad. Crea una en bsky.app/settings.","accounts.bluesky.submit":"Conectar Bluesky","accounts.bluesky.submitting":"Conectando...","accounts.mastodon.title":"Conectar Mastodon","accounts.mastodon.description":"Introduce tu instancia de Mastodon","accounts.mastodon.instance_url":"URL de la instancia","accounts.mastodon.instance_placeholder":"https://mastodon.social","accounts.mastodon.instance_hint":"Introduce la URL de tu instancia de Mastodon (ej: mastodon.social, techhub.social)","accounts.mastodon.submit":"Continuar con Mastodon","accounts.mastodon.submitting":"Conectando...","accounts.facebook.title":"Seleccionar página de Facebook","accounts.facebook.description":"Elige qué página deseas conectar","accounts.facebook.no_pages":"No se encontraron páginas","accounts.facebook.no_pages_description":"No eres administrador de ninguna página de Facebook.","accounts.facebook.page_label":"Página de Facebook","accounts.linkedin.title":"Seleccionar página de LinkedIn","accounts.linkedin.description":"Elige qué página deseas conectar","accounts.linkedin.no_pages":"No se encontraron páginas","accounts.linkedin.no_pages_description":"No eres administrador de ninguna página de LinkedIn.","accounts.linkedin.page_label":"Página de LinkedIn","accounts.flash.disconnected":"¡Cuenta desconectada correctamente!","accounts.flash.connected":"¡Cuenta conectada correctamente!","accounts.flash.session_expired":"Sesión expirada. Inténtalo de nuevo.","accounts.flash.workspace_not_found":"Workspace no encontrado.","accounts.flash.activated":"¡Cuenta activada!","accounts.flash.deactivated":"¡Cuenta desactivada!","accounts.flash.already_connected":"Esta plataforma ya está conectada.","accounts.flash.no_youtube_channels":"No se encontraron canales de YouTube. Crea un canal primero.","auth.failed":"Estas credenciales no coinciden con nuestros registros.","auth.password":"La contraseña proporcionada es incorrecta.","auth.throttle":"Demasiados intentos de inicio de sesión. Inténtalo de nuevo en :seconds segundos.","auth.flash.welcome":"¡Bienvenido a TryPost!","auth.flash.welcome_trial":"¡Bienvenido a TryPost! Tu prueba ha comenzado.","auth.legal":"Al continuar, aceptas nuestros Términos de Servicio y Política de Privacidad.","auth.slides.calendar.title":"Calendario Visual","auth.slides.calendar.description":"Planifica y programa tu contenido con un calendario intuitivo de arrastrar y soltar en todas tus cuentas sociales.","auth.slides.scheduling.title":"Programación Inteligente","auth.slides.scheduling.description":"Programa posts en LinkedIn, X, Instagram, TikTok, YouTube y más — todo desde un solo lugar.","auth.slides.media.title":"Contenido Multimedia","auth.slides.media.description":"Publica imágenes, carruseles, historias y reels. Cada plataforma recibe el formato correcto automáticamente.","auth.slides.video.title":"Publicación de Video","auth.slides.video.description":"Sube videos una vez y publícalos en TikTok, YouTube Shorts, Instagram Reels y Facebook Reels.","auth.slides.team.title":"Workspaces en Equipo","auth.slides.team.description":"Invita a tu equipo, asigna roles y gestiona múltiples marcas en workspaces separados.","auth.slides.hashtags.title":"Grupos de Hashtags","auth.slides.hashtags.description":"Guarda colecciones de hashtags y agrégalos a tus posts con un clic. Organiza con etiquetas y filtros.","auth.or_continue_with":"O continuar con","auth.google_login":"Iniciar sesión con Google","auth.google_signup":"Registrarse con Google","auth.signup_success.page_title":"Bienvenido","auth.signup_success.title":"Configurando tu cuenta","auth.signup_success.description":"Esto suele tardar solo unos segundos...","auth.login.title":"Inicia sesión en tu cuenta","auth.login.description":"Introduce tu correo y contraseña para iniciar sesión","auth.login.page_title":"Iniciar sesión","auth.login.email":"Correo electrónico","auth.login.password":"Contraseña","auth.login.forgot_password":"¿Olvidaste tu contraseña?","auth.login.remember_me":"Recuérdame","auth.login.submit":"Iniciar sesión","auth.login.no_account":"¿No tienes una cuenta?","auth.login.sign_up":"Regístrate","auth.register.title":"Crear una cuenta","auth.register.description":"Introduce tus datos para crear tu cuenta","auth.register.page_title":"Registro","auth.register.name":"Nombre","auth.register.name_placeholder":"Nombre completo","auth.register.email":"Correo electrónico","auth.register.password":"Contraseña","auth.register.show_password":"Mostrar contraseña","auth.register.hide_password":"Ocultar contraseña","auth.register.submit":"Crear cuenta","auth.register.has_account":"¿Ya tienes una cuenta?","auth.register.log_in":"Iniciar sesión","auth.forgot_password.title":"Olvidé mi contraseña","auth.forgot_password.description":"Introduce tu correo para recibir un enlace de restablecimiento","auth.forgot_password.page_title":"Olvidé mi contraseña","auth.forgot_password.email":"Correo electrónico","auth.forgot_password.submit":"Enviar enlace de restablecimiento","auth.forgot_password.return_to":"O vuelve a","auth.forgot_password.log_in":"iniciar sesión","auth.reset_password.title":"Restablecer contraseña","auth.reset_password.description":"Introduce tu nueva contraseña","auth.reset_password.page_title":"Restablecer contraseña","auth.reset_password.email":"Correo electrónico","auth.reset_password.password":"Contraseña","auth.reset_password.confirm_password":"Confirmar contraseña","auth.reset_password.confirm_placeholder":"Confirmar contraseña","auth.reset_password.submit":"Restablecer contraseña","auth.verify_email.title":"Verificar correo","auth.verify_email.description":"Verifica tu correo electrónico haciendo clic en el enlace que acabamos de enviarte.","auth.verify_email.page_title":"Verificación de correo","auth.verify_email.link_sent":"Se ha enviado un nuevo enlace de verificación al correo electrónico proporcionado durante el registro.","auth.verify_email.resend":"Reenviar correo de verificación","auth.verify_email.log_out":"Cerrar sesión","auth.accept_invite.page_title":"Aceptar invitación","auth.accept_invite.title":"¡Has sido invitado!","auth.accept_invite.description":"Has sido invitado a unirte al workspace :workspace.","auth.accept_invite.workspace":"Workspace","auth.accept_invite.your_role":"Tu rol","auth.accept_invite.email":"Correo electrónico","auth.accept_invite.accept":"Aceptar invitación","auth.accept_invite.decline":"Rechazar invitación","auth.accept_invite.login_prompt":"Inicia sesión o crea una cuenta para aceptar esta invitación.","auth.accept_invite.log_in":"Iniciar sesión","auth.accept_invite.create_account":"Crear cuenta","billing.title":"Suscripción","billing.description":"Administra tu suscripción y método de pago","billing.subscribe.page_title":"Comienza tu prueba gratuita","billing.subscribe.title":"Comienza tu prueba gratuita","billing.subscribe.description":":days días gratis para explorar todas las funciones.","billing.subscribe.start_trial":"Comenzar prueba de :days días","billing.subscribe.cancel_anytime":"Cancela en cualquier momento. Sin preguntas.","billing.subscribe.switch_workspace":"Cambiar workspace","billing.subscribe.features.calendar":"Calendario visual con arrastrar y soltar","billing.subscribe.features.scheduling":"Programación ilimitada de posts","billing.subscribe.features.media":"Imágenes, carruseles e historias","billing.subscribe.features.video":"Publicación de videos en todas las plataformas","billing.subscribe.features.team":"Colaboración en equipo y workspaces","billing.subscribe.features.hashtags":"Grupos de hashtags y etiquetas","billing.trial.title":"Periodo de prueba activo","billing.trial.description":"Tu prueba termina el :date. Después, tu suscripción se cobrará automáticamente.","billing.subscription.title":"Tu suscripción","billing.subscription.status":"Estado","billing.subscription.workspaces":"Workspaces","billing.subscription.quantity":"Cantidad de suscripción","billing.subscription.expires":"Expira :date","billing.subscription.canceled_on":"Tu suscripción se cancelará el :date","billing.subscription.manage":"Administrar en Stripe","billing.invoices.title":"Facturas","billing.invoices.description":"Historial de pagos","billing.invoices.empty":"No se encontraron facturas","billing.invoices.paid":"Pagado","billing.processing.page_title":"Procesando...","billing.processing.title":"Procesando tu suscripción","billing.processing.description":"Espera mientras configuramos tu cuenta. Solo tomará un momento.","billing.processing.success_title":"¡Todo listo!","billing.processing.success_description":"Tu suscripción está activa. Redirigiendo a tus workspaces...","billing.processing.cancelled_title":"Pago cancelado","billing.processing.cancelled_description":"Tu pago fue cancelado. No se realizaron cargos.","billing.processing.retry":"Intentar de nuevo","billing.status.active":"Activa","billing.status.canceled":"Cancelada","billing.status.incomplete":"Incompleta","billing.status.incomplete_expired":"Expirada","billing.status.past_due":"Vencida","billing.status.trialing":"Prueba","billing.status.unpaid":"Sin pagar","calendar.title":"Calendario","calendar.today":"Hoy","calendar.day":"Día","calendar.week":"Semana","calendar.month":"Mes","calendar.new_post":"Nuevo post","calendar.no_content":"Sin contenido","calendar.more":"+:count más","common.confirm_modal.cannot_be_undone":"Esta acción no se puede deshacer.","common.confirm_modal.type":"Escribe","common.confirm_modal.to_confirm":"para confirmar.","common.confirm_modal.copy_to_clipboard":"Copiar al portapapeles","common.photo_upload.upload":"Subir","common.photo_upload.uploading":"Subiendo...","common.photo_upload.remove":"Eliminar foto","common.photo_upload.hint":"Recomendado: imagen cuadrada, máximo 2 MB.","common.timezone.select":"Seleccionar zona horaria","common.timezone.search":"Buscar zona horaria...","common.timezone.empty":"No se encontró zona horaria","common.date_picker.select":"Seleccionar fecha","common.cancel":"Cancelar","hashtags.title":"Hashtags","hashtags.description":"Crea grupos de hashtags para agregarlos rápidamente a tus posts","hashtags.search":"Buscar hashtags...","hashtags.new_group":"Nuevo grupo","hashtags.no_groups_yet":"Aún no hay grupos de hashtags","hashtags.no_groups_description":"Crea grupos de hashtags para agregar rápidamente hashtags populares a tus posts","hashtags.create_first_group":"Crea tu primer grupo","hashtags.hashtags_count":":count hashtags","hashtags.create.title":"Crear grupo de hashtags","hashtags.create.description":"Dale un nombre a tu grupo y agrega hashtags separados por espacios o comas","hashtags.create.name":"Nombre del grupo","hashtags.create.name_placeholder":"ej: Marketing, Viajes, Comida","hashtags.create.hashtags":"Hashtags","hashtags.create.hashtags_placeholder":"#marketing #redessociales #negocios #crecimiento","hashtags.create.hashtags_hint":"Ingresa hashtags separados por espacios o comas. Incluye el símbolo #.","hashtags.create.submit":"Crear grupo","hashtags.create.submitting":"Creando...","hashtags.edit.title":"Editar grupo de hashtags","hashtags.edit.description":"Actualiza el nombre y los hashtags de este grupo","hashtags.edit.name":"Nombre del grupo","hashtags.edit.name_placeholder":"ej: Marketing, Viajes, Comida","hashtags.edit.hashtags":"Hashtags","hashtags.edit.hashtags_placeholder":"#marketing #redessociales #negocios #crecimiento","hashtags.edit.hashtags_hint":"Ingresa hashtags separados por espacios o comas. Incluye el símbolo #.","hashtags.edit.submit":"Guardar cambios","hashtags.edit.submitting":"Guardando...","hashtags.delete.title":"Eliminar grupo de hashtags","hashtags.delete.description":"¿Estás seguro de que deseas eliminar este grupo de hashtags? Esta acción no se puede deshacer.","hashtags.delete.confirm":"Eliminar","hashtags.delete.cancel":"Cancelar","hashtags.flash.created":"¡Grupo de hashtags creado correctamente!","hashtags.flash.updated":"¡Grupo de hashtags actualizado correctamente!","hashtags.flash.deleted":"¡Grupo de hashtags eliminado correctamente!","labels.title":"Etiquetas","labels.description":"Crea etiquetas para organizar y categorizar tus posts","labels.search":"Buscar etiquetas...","labels.new_label":"Nueva etiqueta","labels.no_labels_yet":"Aún no hay etiquetas","labels.create_first_label":"Crea tu primera etiqueta","labels.create.title":"Crear etiqueta","labels.create.description":"Dale un nombre y elige un color para tu etiqueta","labels.create.name":"Nombre","labels.create.name_placeholder":"Nombre de la etiqueta...","labels.create.color":"Color","labels.create.submit":"Crear etiqueta","labels.create.submitting":"Creando...","labels.edit.title":"Editar etiqueta","labels.edit.description":"Actualiza el nombre y el color de esta etiqueta","labels.edit.name":"Nombre","labels.edit.name_placeholder":"Nombre de la etiqueta...","labels.edit.color":"Color","labels.edit.submit":"Guardar cambios","labels.edit.submitting":"Guardando...","labels.delete.title":"Eliminar etiqueta","labels.delete.description":"¿Estás seguro de que deseas eliminar esta etiqueta? Esta acción no se puede deshacer.","labels.delete.confirm":"Eliminar","labels.delete.cancel":"Cancelar","labels.flash.created":"¡Etiqueta creada correctamente!","labels.flash.updated":"¡Etiqueta actualizada correctamente!","labels.flash.deleted":"¡Etiqueta eliminada correctamente!","mail.workspace_connections_disconnected.subject":"{1} :count cuenta necesita ser reconectada en :workspace|[2,*] :count cuentas necesitan ser reconectadas en :workspace","mail.workspace_connections_disconnected.title":"Cuentas necesitan reconexión","mail.workspace_connections_disconnected.intro":"Las siguientes cuentas sociales en tu workspace :workspace se han desconectado y necesitan ser reconectadas:","mail.workspace_connections_disconnected.reasons_title":"Esto puede haber ocurrido porque:","mail.workspace_connections_disconnected.reason_expired":"Los tokens de acceso expiraron","mail.workspace_connections_disconnected.reason_revoked":"Revocaste el acceso a TryPost en la plataforma","mail.workspace_connections_disconnected.reason_changed":"La plataforma cambió sus requisitos de autenticación","mail.workspace_connections_disconnected.reconnect_cta":"Reconecta estas cuentas para seguir programando y publicando posts.","mail.workspace_connections_disconnected.button":"Reconectar cuentas","onboarding.role.page_title":"Bienvenido - Cuéntanos sobre ti","onboarding.role.title":"Cuéntanos sobre ti","onboarding.role.description":"Ayúdanos a personalizar tu experiencia","onboarding.role.submit":"Continuar","onboarding.personas.founder.label":"Fundador","onboarding.personas.founder.description":"Construyendo una startup o nuevo negocio","onboarding.personas.creator.label":"Creador de Contenido","onboarding.personas.creator.description":"Creador de contenido o influencer","onboarding.personas.agency.label":"Agencia","onboarding.personas.agency.description":"Agencia de marketing o redes sociales","onboarding.personas.enterprise.label":"Empresa","onboarding.personas.enterprise.description":"Gran empresa o corporación","onboarding.personas.small_business.label":"Pequeño Negocio","onboarding.personas.small_business.description":"Pequeña o mediana empresa","onboarding.personas.personal.label":"Personal","onboarding.personas.personal.description":"Marca personal o hobby","onboarding.connect.page_title":"Conecta tus cuentas","onboarding.connect.title":"Conecta tus cuentas","onboarding.connect.description":"Conecta al menos una red social para comenzar","onboarding.connect.submit":"Continuar","onboarding.connect.error":"Algo salió mal. Inténtalo de nuevo.","onboarding.connect.go_back":"Volver","pagination.previous":"« Anterior","pagination.next":"Siguiente »","passwords.reset":"Tu contraseña ha sido restablecida.","passwords.sent":"Te hemos enviado un enlace para restablecer tu contraseña.","passwords.throttled":"Espera antes de intentarlo de nuevo.","passwords.token":"Este token de restablecimiento de contraseña no es válido.","passwords.user":"No encontramos un usuario con ese correo electrónico.","posts.title":"Posts","posts.search":"Buscar posts...","posts.all_posts":"Todos los posts","posts.new_post":"Nuevo post","posts.no_posts":"No se encontraron posts","posts.start_creating":"Empieza creando tu primer post.","posts.manage_posts":"Administra todos tus posts","posts.delete_confirm":"¿Estás seguro de que deseas eliminar este post?","posts.by":"por","posts.actions.view":"Ver post","posts.actions.delete":"Eliminar post","posts.form.post_type":"Tipo de post","posts.form.board":"Tablero","posts.form.select_board":"Seleccionar tablero","posts.form.search_board":"Buscar tablero...","posts.form.no_board_found":"No se encontró tablero","posts.form.media":"Multimedia","posts.form.min":"Min","posts.form.uploading":"Subiendo...","posts.form.drop_to_upload":"Suelta para subir","posts.form.drag_and_drop":"Arrastra y suelta o haz clic para subir","posts.form.photos_and_videos":"Fotos y videos","posts.form.photos_only":"Solo fotos","posts.form.videos_only":"Solo videos","posts.form.drag_to_reorder":"Arrastra para reordenar","posts.form.caption":"Descripción","posts.form.write_caption":"Escribe tu descripción...","posts.status.pending":"Pendiente","posts.status.draft":"Borrador","posts.status.scheduled":"Programado","posts.status.publishing":"Publicando","posts.status.published":"Publicado","posts.status.partially_published":"Parcialmente publicado","posts.status.failed":"Fallido","posts.descriptions.draft":"Posts esperando ser programados","posts.descriptions.scheduled":"Posts programados para publicar","posts.descriptions.published":"Posts ya publicados","posts.edit.title":"Editar post","posts.edit.view_title":"Ver post","posts.edit.manage_platforms":"Administrar plataformas","posts.edit.sync":"Sincronizar","posts.edit.labels":"Etiquetas","posts.edit.hashtags":"Hashtags","posts.edit.schedule":"Programar","posts.edit.publish":"Publicar","posts.edit.delete":"Eliminar","posts.edit.settings":"Configuración","posts.edit.schedule_for":"Programar para","posts.edit.saving":"Guardando...","posts.edit.saved":"Guardado","posts.edit.scheduled_at":"Programado:","posts.edit.published_at":"Publicado:","posts.edit.media":"Multimedia","posts.edit.caption":"Descripción","posts.edit.no_caption":"Sin descripción","posts.edit.no_content":"Sin contenido","posts.edit.empty_state.title":"No hay plataformas seleccionadas","posts.edit.empty_state.description":"Selecciona al menos una plataforma para crear tu post","posts.edit.delete_modal.title":"Eliminar post","posts.edit.delete_modal.description":"¿Estás seguro de que deseas eliminar este post? Esta acción no se puede deshacer.","posts.edit.delete_modal.action":"Eliminar","posts.edit.delete_modal.cancel":"Cancelar","posts.edit.sync_enable.title":"¿Activar sincronización?","posts.edit.sync_enable.description":"Todas las plataformas compartirán el mismo contenido. Las ediciones personalizadas realizadas en plataformas individuales serán reemplazadas con el contenido actual.","posts.edit.sync_enable.cancel":"Cancelar","posts.edit.sync_enable.action":"Activar sincronización","posts.edit.sync_disable.title":"¿Desactivar sincronización?","posts.edit.sync_disable.description":"Cada plataforma mantendrá su contenido actual, pero las ediciones futuras solo se aplicarán a la plataforma que estés editando.","posts.edit.sync_disable.customize_note":"Podrás personalizar el contenido de cada plataforma individualmente.","posts.edit.sync_disable.cancel":"Cancelar","posts.edit.sync_disable.action":"Desactivar sincronización","posts.edit.platforms_dialog.title":"Seleccionar plataformas","posts.edit.platforms_dialog.description":"Elige en qué plataformas publicar este post.","posts.edit.hashtags_modal.search":"Buscar hashtags...","posts.edit.hashtags_modal.no_results":"No se encontraron hashtags.","posts.edit.validation.select_board":"Selecciona un tablero","posts.edit.validation.images_not_supported":"Imágenes no soportadas","posts.edit.validation.videos_not_supported":"Videos no soportados","posts.edit.validation.max_images":"Máximo :count imágenes","posts.edit.validation.requires_media":"Requiere multimedia","posts.edit.validation.exceeded":":count excedido","posts.edit.validation.does_not_support_images":":platform no soporta imágenes","posts.edit.validation.supports_up_to_images":":platform soporta hasta :count imágenes","posts.edit.validation.does_not_support_videos":":platform no soporta videos","posts.content_types.instagram_feed.label":"Post del feed","posts.content_types.instagram_feed.description":"Aparece en tu feed y perfil","posts.content_types.instagram_reel.label":"Reel","posts.content_types.instagram_reel.description":"Video corto de hasta 90 segundos","posts.content_types.instagram_story.label":"Historia","posts.content_types.instagram_story.description":"Desaparece después de 24 horas","posts.content_types.linkedin_post.label":"Post","posts.content_types.linkedin_post.description":"Post estándar con texto y multimedia","posts.content_types.linkedin_carousel.label":"Carrusel","posts.content_types.linkedin_carousel.description":"Imágenes deslizables","posts.content_types.linkedin_page_post.label":"Post","posts.content_types.linkedin_page_post.description":"Post estándar con texto y multimedia","posts.content_types.linkedin_page_carousel.label":"Carrusel","posts.content_types.linkedin_page_carousel.description":"Imágenes deslizables","posts.content_types.facebook_post.label":"Post","posts.content_types.facebook_post.description":"Post estándar en tu página","posts.content_types.facebook_reel.label":"Reel","posts.content_types.facebook_reel.description":"Video corto de hasta 90 segundos","posts.content_types.facebook_story.label":"Historia","posts.content_types.facebook_story.description":"Desaparece después de 24 horas","posts.content_types.tiktok_video.label":"Video","posts.content_types.tiktok_video.description":"Contenido de video corto","posts.content_types.youtube_short.label":"Short","posts.content_types.youtube_short.description":"Video vertical de hasta 60 segundos","posts.content_types.x_post.label":"Post","posts.content_types.x_post.description":"Tweet con texto y multimedia","posts.content_types.threads_post.label":"Post","posts.content_types.threads_post.description":"Post de texto con multimedia opcional","posts.content_types.pinterest_pin.label":"Pin","posts.content_types.pinterest_pin.description":"Pin de imagen con enlace","posts.content_types.pinterest_video_pin.label":"Pin de video","posts.content_types.pinterest_video_pin.description":"Contenido de video","posts.content_types.pinterest_carousel.label":"Carrusel","posts.content_types.pinterest_carousel.description":"2-5 imágenes","posts.content_types.bluesky_post.label":"Post","posts.content_types.bluesky_post.description":"Post de texto con imágenes opcionales","posts.content_types.mastodon_post.label":"Post","posts.content_types.mastodon_post.description":"Post de texto con multimedia opcional","posts.platforms.linkedin":"LinkedIn","posts.platforms.linkedin-page":"Página de LinkedIn","posts.platforms.x":"X","posts.platforms.tiktok":"TikTok","posts.platforms.youtube":"YouTube Shorts","posts.platforms.facebook":"Página de Facebook","posts.platforms.instagram":"Instagram","posts.platforms.threads":"Threads","posts.platforms.pinterest":"Pinterest","posts.platforms.bluesky":"Bluesky","posts.platforms.mastodon":"Mastodon","posts.flash.scheduled":"¡Post programado correctamente!","posts.flash.publishing":"¡El post se está publicando!","posts.flash.deleted":"¡Post eliminado correctamente!","posts.flash.cannot_edit_published":"Los posts publicados no se pueden editar.","posts.flash.connect_first":"Conecta al menos una red social antes de crear un post.","posts.errors.account_disconnected":"Cuenta social desconectada","posts.errors.account_inactive":"Cuenta social desactivada","settings.title":"Configuración","settings.description":"Administra tu perfil y configuración de la cuenta","settings.nav.profile":"Perfil","settings.nav.password":"Contraseña","settings.nav.workspace":"Workspace","settings.nav.members":"Miembros","settings.nav.notifications":"Notificaciones","settings.nav.billing":"Facturación","settings.notifications.title":"Preferencias de notificaciones","settings.notifications.heading":"Notificaciones por correo","settings.notifications.description":"Elige qué notificaciones por correo deseas recibir","settings.notifications.post_published":"Post publicado","settings.notifications.post_published_description":"Recibir un correo cuando tu post se publique correctamente","settings.notifications.post_failed":"Post fallido","settings.notifications.post_failed_description":"Recibir un correo cuando tu post falle al publicar","settings.notifications.account_disconnected":"Cuenta desconectada","settings.notifications.account_disconnected_description":"Recibir un correo cuando una cuenta social se desconecte","settings.notifications.save":"Guardar preferencias","settings.profile.title":"Configuración del perfil","settings.profile.photo_heading":"Foto de perfil","settings.profile.photo_description":"Sube una foto de perfil","settings.profile.heading":"Información del perfil","settings.profile.description":"Actualiza tu nombre y correo electrónico","settings.profile.avatar":"Avatar","settings.profile.name":"Nombre","settings.profile.name_placeholder":"Nombre completo","settings.profile.email":"Correo electrónico","settings.profile.email_placeholder":"Correo electrónico","settings.profile.email_unverified":"Tu correo electrónico no ha sido verificado.","settings.profile.resend_verification":"Haz clic aquí para reenviar el correo de verificación.","settings.profile.verification_sent":"Se ha enviado un nuevo enlace de verificación a tu correo electrónico.","settings.profile.save":"Guardar","settings.password.title":"Configuración de contraseña","settings.password.heading":"Actualizar contraseña","settings.password.description":"Asegúrate de que tu cuenta use una contraseña larga y aleatoria para mantenerte seguro","settings.password.current_password":"Contraseña actual","settings.password.current_password_placeholder":"Contraseña actual","settings.password.new_password":"Nueva contraseña","settings.password.new_password_placeholder":"Nueva contraseña","settings.password.confirm_password":"Confirmar contraseña","settings.password.confirm_password_placeholder":"Confirmar contraseña","settings.password.save":"Guardar contraseña","settings.delete_account.heading":"Eliminar cuenta","settings.delete_account.description":"Elimina tu cuenta y todos sus recursos","settings.delete_account.warning":"Advertencia","settings.delete_account.warning_message":"Procede con precaución, esta acción no se puede deshacer.","settings.delete_account.button":"Eliminar cuenta","settings.delete_account.modal_title":"¿Estás seguro de que deseas eliminar tu cuenta?","settings.delete_account.modal_description":"Una vez eliminada tu cuenta, todos sus recursos y datos también se eliminarán permanentemente. Introduce tu contraseña para confirmar que deseas eliminar permanentemente tu cuenta.","settings.delete_account.password":"Contraseña","settings.delete_account.password_placeholder":"Contraseña","settings.delete_account.cancel":"Cancelar","settings.delete_account.confirm":"Eliminar cuenta","settings.workspace.title":"Configuración del workspace","settings.workspace.logo_heading":"Logo del workspace","settings.workspace.logo_description":"Sube un logo para tu workspace","settings.workspace.heading":"Nombre del workspace","settings.workspace.description":"Actualiza el nombre y zona horaria del workspace","settings.workspace.members_heading":"Miembros","settings.workspace.members_description":"Administra miembros e invitaciones del workspace","settings.workspace.name":"Nombre","settings.workspace.name_placeholder":"Mi Workspace","settings.workspace.timezone":"Zona horaria","settings.workspace.save":"Guardar","settings.members.title":"Miembros","settings.members.heading":"Miembros del equipo","settings.members.description":"Administra miembros e invitaciones de este workspace","settings.members.cancel":"Cancelar","settings.members.remove":"Eliminar","settings.members.make_admin":"Hacer administrador","settings.members.make_member":"Hacer miembro","settings.members.invite.title":"Invitar miembro","settings.members.invite.description":"Envía una invitación por correo para agregar colaboradores","settings.members.invite.email":"Correo electrónico","settings.members.invite.email_placeholder":"colaborador@email.com","settings.members.invite.role":"Rol","settings.members.invite.role_placeholder":"Selecciona un rol","settings.members.invite.submit":"Enviar invitación","settings.members.pending.title":"Invitaciones pendientes","settings.members.pending.description":"Invitaciones en espera de aceptación","settings.members.pending.empty":"No hay invitaciones pendientes","settings.members.list.title":"Miembros","settings.members.list.description":"Personas con acceso a este workspace","settings.members.list.empty":"No hay miembros además del propietario","settings.members.remove_modal.title":"Eliminar miembro","settings.members.remove_modal.description":"¿Estás seguro de que deseas eliminar a este miembro del workspace? Perderá acceso a todos los recursos del workspace.","settings.members.remove_modal.action":"Eliminar miembro","settings.members.cancel_invite_modal.title":"Cancelar invitación","settings.members.cancel_invite_modal.description":"¿Estás seguro de que deseas cancelar esta invitación?","settings.members.cancel_invite_modal.action":"Cancelar invitación","settings.members.roles.owner":"Propietario","settings.members.roles.admin":"Administrador","settings.members.roles.member":"Miembro","settings.members.flash.invite_sent":"¡Invitación enviada correctamente!","settings.members.flash.invite_deleted":"Invitación eliminada.","settings.members.flash.member_removed":"¡Miembro eliminado correctamente!","settings.members.flash.role_updated":"Rol del miembro actualizado.","settings.members.flash.wrong_email":"Esta invitación es para otro correo electrónico.","settings.members.flash.already_member":"Ya eres miembro de este workspace.","settings.members.flash.invite_accepted":"¡Bienvenido! Ahora eres miembro del workspace.","settings.members.flash.invite_declined":"Invitación rechazada.","settings.flash.profile_updated":"¡Perfil actualizado correctamente!","settings.flash.language_updated":"¡Idioma actualizado correctamente!","settings.flash.password_updated":"¡Contraseña actualizada correctamente!","settings.flash.workspace_updated":"¡Configuración actualizada correctamente!","settings.flash.photo_updated":"¡Foto actualizada correctamente!","settings.flash.photo_deleted":"¡Foto eliminada correctamente!","settings.flash.logo_updated":"¡Logo subido correctamente!","settings.flash.logo_deleted":"¡Logo eliminado correctamente!","settings.flash.notifications_updated":"¡Preferencias de notificaciones actualizadas!","settings.api_keys.title":"Claves API","settings.api_keys.page_title":"Claves API","settings.api_keys.heading":"Claves API","settings.api_keys.description":"Administra claves API para acceso programático a tu workspace.","settings.api_keys.create":"Crear clave API","settings.api_keys.copy":"Copiar","settings.api_keys.new_token_message":"Tu nueva clave API ha sido creada. Cópiala ahora — no podrás verla de nuevo.","settings.api_keys.table.name":"Nombre","settings.api_keys.table.key":"Clave","settings.api_keys.table.status":"Estado","settings.api_keys.table.expires":"Expira","settings.api_keys.table.last_used":"Último uso","settings.api_keys.table.never":"Nunca","settings.api_keys.actions.copy_id":"Copiar ID de clave API","settings.api_keys.actions.copy_id_success":"ID de clave API copiado","settings.api_keys.actions.delete":"Eliminar","settings.api_keys.empty.title":"No hay claves API","settings.api_keys.empty.description":"Crea una clave API para acceder a tu workspace programáticamente.","settings.api_keys.delete_modal.title":"Eliminar clave API","settings.api_keys.delete_modal.description":"¿Estás seguro de que deseas eliminar esta clave API? Las aplicaciones que la usen perderán acceso inmediatamente.","settings.api_keys.delete_modal.action":"Eliminar clave API","settings.api_keys.create_dialog.title":"Crear clave API","settings.api_keys.create_dialog.description":"Crea una nueva clave API para acceso programático a tu workspace.","settings.api_keys.create_dialog.name":"Nombre","settings.api_keys.create_dialog.name_placeholder":"ej. Clave API de Producción","settings.api_keys.create_dialog.expires":"Fecha de expiración (opcional)","settings.api_keys.create_dialog.expires_placeholder":"Sin expiración","settings.api_keys.create_dialog.submit":"Crear","settings.api_keys.create_dialog.cancel":"Cancelar","settings.api_keys.flash.created":"¡Clave API creada correctamente!","settings.api_keys.flash.deleted":"¡Clave API eliminada correctamente!","sidebar.workspaces":"Workspaces","sidebar.select_workspace":"Seleccionar workspace","sidebar.create_workspace":"Crear workspace","sidebar.create_post":"Crear post","sidebar.profile":"Perfil","sidebar.account":"Cuenta","sidebar.log_out":"Cerrar sesión","sidebar.workspace":"Workspace: :name","sidebar.workspace_select":"Workspace: Seleccionar","sidebar.theme":"Tema: :name","sidebar.theme_light":"Claro","sidebar.theme_dark":"Oscuro","sidebar.theme_system":"Sistema","sidebar.language":"Idioma: :name","sidebar.language_select":"Idioma: Seleccionar","sidebar.groups.posts":"Posts","sidebar.groups.configuration":"Configuración","sidebar.groups.support":"Soporte","sidebar.posts.calendar":"Calendario","sidebar.posts.all":"Todos","sidebar.posts.scheduled":"Programados","sidebar.posts.posted":"Publicados","sidebar.posts.drafts":"Borradores","sidebar.config.connections":"Conexiones","sidebar.config.hashtags":"Hashtags","sidebar.config.labels":"Etiquetas","sidebar.config.settings":"Configuración","sidebar.notifications":"Notificaciones","sidebar.mark_all_read":"Marcar todo como leído","sidebar.mark_as_read":"Marcar como leído","sidebar.archive_all":"Archivar todo","sidebar.no_notifications":"Sin notificaciones","sidebar.support.discord":"Discord","sidebar.support.share_feedback":"Dar feedback","sidebar.support.last_updates":"Últimas actualizaciones","sidebar.support.docs":"Documentación","validation.accepted":"El campo :attribute debe ser aceptado.","validation.accepted_if":"El campo :attribute debe ser aceptado cuando :other es :value.","validation.active_url":"El campo :attribute debe ser una URL válida.","validation.after":"El campo :attribute debe ser una fecha posterior a :date.","validation.after_or_equal":"El campo :attribute debe ser una fecha posterior o igual a :date.","validation.alpha":"El campo :attribute solo puede contener letras.","validation.alpha_dash":"El campo :attribute solo puede contener letras, números, guiones y guiones bajos.","validation.alpha_num":"El campo :attribute solo puede contener letras y números.","validation.any_of":"El campo :attribute no es válido.","validation.array":"El campo :attribute debe ser un arreglo.","validation.ascii":"El campo :attribute solo puede contener caracteres alfanuméricos de un byte y símbolos.","validation.before":"El campo :attribute debe ser una fecha anterior a :date.","validation.before_or_equal":"El campo :attribute debe ser una fecha anterior o igual a :date.","validation.between.array":"El campo :attribute debe tener entre :min y :max elementos.","validation.between.file":"El campo :attribute debe pesar entre :min y :max kilobytes.","validation.between.numeric":"El campo :attribute debe estar entre :min y :max.","validation.between.string":"El campo :attribute debe tener entre :min y :max caracteres.","validation.boolean":"El campo :attribute debe ser verdadero o falso.","validation.can":"El campo :attribute contiene un valor no autorizado.","validation.confirmed":"La confirmación del campo :attribute no coincide.","validation.contains":"Al campo :attribute le falta un valor requerido.","validation.current_password":"La contraseña es incorrecta.","validation.date":"El campo :attribute debe ser una fecha válida.","validation.date_equals":"El campo :attribute debe ser una fecha igual a :date.","validation.date_format":"El campo :attribute debe coincidir con el formato :format.","validation.decimal":"El campo :attribute debe tener :decimal decimales.","validation.declined":"El campo :attribute debe ser rechazado.","validation.declined_if":"El campo :attribute debe ser rechazado cuando :other es :value.","validation.different":"El campo :attribute y :other deben ser diferentes.","validation.digits":"El campo :attribute debe tener :digits dígitos.","validation.digits_between":"El campo :attribute debe tener entre :min y :max dígitos.","validation.dimensions":"El campo :attribute tiene dimensiones de imagen no válidas.","validation.distinct":"El campo :attribute tiene un valor duplicado.","validation.doesnt_contain":"El campo :attribute no debe contener ninguno de los siguientes: :values.","validation.doesnt_end_with":"El campo :attribute no debe terminar con uno de los siguientes: :values.","validation.doesnt_start_with":"El campo :attribute no debe comenzar con uno de los siguientes: :values.","validation.email":"El campo :attribute debe ser un correo electrónico válido.","validation.encoding":"El campo :attribute debe estar codificado en :encoding.","validation.ends_with":"El campo :attribute debe terminar con uno de los siguientes: :values.","validation.enum":"El :attribute seleccionado no es válido.","validation.exists":"El :attribute seleccionado no es válido.","validation.extensions":"El campo :attribute debe tener una de las siguientes extensiones: :values.","validation.file":"El campo :attribute debe ser un archivo.","validation.filled":"El campo :attribute debe tener un valor.","validation.gt.array":"El campo :attribute debe tener más de :value elementos.","validation.gt.file":"El campo :attribute debe pesar más de :value kilobytes.","validation.gt.numeric":"El campo :attribute debe ser mayor que :value.","validation.gt.string":"El campo :attribute debe tener más de :value caracteres.","validation.gte.array":"El campo :attribute debe tener :value elementos o más.","validation.gte.file":"El campo :attribute debe pesar :value kilobytes o más.","validation.gte.numeric":"El campo :attribute debe ser mayor o igual a :value.","validation.gte.string":"El campo :attribute debe tener :value caracteres o más.","validation.hex_color":"El campo :attribute debe ser un color hexadecimal válido.","validation.image":"El campo :attribute debe ser una imagen.","validation.in":"El :attribute seleccionado no es válido.","validation.in_array":"El campo :attribute debe existir en :other.","validation.in_array_keys":"El campo :attribute debe contener al menos una de las siguientes claves: :values.","validation.integer":"El campo :attribute debe ser un número entero.","validation.ip":"El campo :attribute debe ser una dirección IP válida.","validation.ipv4":"El campo :attribute debe ser una dirección IPv4 válida.","validation.ipv6":"El campo :attribute debe ser una dirección IPv6 válida.","validation.json":"El campo :attribute debe ser una cadena JSON válida.","validation.list":"El campo :attribute debe ser una lista.","validation.lowercase":"El campo :attribute debe estar en minúsculas.","validation.lt.array":"El campo :attribute debe tener menos de :value elementos.","validation.lt.file":"El campo :attribute debe pesar menos de :value kilobytes.","validation.lt.numeric":"El campo :attribute debe ser menor que :value.","validation.lt.string":"El campo :attribute debe tener menos de :value caracteres.","validation.lte.array":"El campo :attribute no debe tener más de :value elementos.","validation.lte.file":"El campo :attribute debe pesar :value kilobytes o menos.","validation.lte.numeric":"El campo :attribute debe ser menor o igual a :value.","validation.lte.string":"El campo :attribute debe tener :value caracteres o menos.","validation.mac_address":"El campo :attribute debe ser una dirección MAC válida.","validation.max.array":"El campo :attribute no debe tener más de :max elementos.","validation.max.file":"El campo :attribute no debe pesar más de :max kilobytes.","validation.max.numeric":"El campo :attribute no debe ser mayor que :max.","validation.max.string":"El campo :attribute no debe tener más de :max caracteres.","validation.max_digits":"El campo :attribute no debe tener más de :max dígitos.","validation.mimes":"El campo :attribute debe ser un archivo de tipo: :values.","validation.mimetypes":"El campo :attribute debe ser un archivo de tipo: :values.","validation.min.array":"El campo :attribute debe tener al menos :min elementos.","validation.min.file":"El campo :attribute debe pesar al menos :min kilobytes.","validation.min.numeric":"El campo :attribute debe ser al menos :min.","validation.min.string":"El campo :attribute debe tener al menos :min caracteres.","validation.min_digits":"El campo :attribute debe tener al menos :min dígitos.","validation.missing":"El campo :attribute debe estar ausente.","validation.missing_if":"El campo :attribute debe estar ausente cuando :other es :value.","validation.missing_unless":"El campo :attribute debe estar ausente a menos que :other sea :value.","validation.missing_with":"El campo :attribute debe estar ausente cuando :values está presente.","validation.missing_with_all":"El campo :attribute debe estar ausente cuando :values están presentes.","validation.multiple_of":"El campo :attribute debe ser múltiplo de :value.","validation.not_in":"El :attribute seleccionado no es válido.","validation.not_regex":"El formato del campo :attribute no es válido.","validation.numeric":"El campo :attribute debe ser un número.","validation.password.letters":"El campo :attribute debe contener al menos una letra.","validation.password.mixed":"El campo :attribute debe contener al menos una letra mayúscula y una minúscula.","validation.password.numbers":"El campo :attribute debe contener al menos un número.","validation.password.symbols":"El campo :attribute debe contener al menos un símbolo.","validation.password.uncompromised":"El :attribute proporcionado ha aparecido en una filtración de datos. Elige un :attribute diferente.","validation.present":"El campo :attribute debe estar presente.","validation.present_if":"El campo :attribute debe estar presente cuando :other es :value.","validation.present_unless":"El campo :attribute debe estar presente a menos que :other sea :value.","validation.present_with":"El campo :attribute debe estar presente cuando :values está presente.","validation.present_with_all":"El campo :attribute debe estar presente cuando :values están presentes.","validation.prohibited":"El campo :attribute está prohibido.","validation.prohibited_if":"El campo :attribute está prohibido cuando :other es :value.","validation.prohibited_if_accepted":"El campo :attribute está prohibido cuando :other es aceptado.","validation.prohibited_if_declined":"El campo :attribute está prohibido cuando :other es rechazado.","validation.prohibited_unless":"El campo :attribute está prohibido a menos que :other esté en :values.","validation.prohibits":"El campo :attribute prohíbe que :other esté presente.","validation.regex":"El formato del campo :attribute no es válido.","validation.required":"El campo :attribute es obligatorio.","validation.required_array_keys":"El campo :attribute debe contener entradas para: :values.","validation.required_if":"El campo :attribute es obligatorio cuando :other es :value.","validation.required_if_accepted":"El campo :attribute es obligatorio cuando :other es aceptado.","validation.required_if_declined":"El campo :attribute es obligatorio cuando :other es rechazado.","validation.required_unless":"El campo :attribute es obligatorio a menos que :other esté en :values.","validation.required_with":"El campo :attribute es obligatorio cuando :values está presente.","validation.required_with_all":"El campo :attribute es obligatorio cuando :values están presentes.","validation.required_without":"El campo :attribute es obligatorio cuando :values no está presente.","validation.required_without_all":"El campo :attribute es obligatorio cuando ninguno de :values está presente.","validation.same":"El campo :attribute debe coincidir con :other.","validation.size.array":"El campo :attribute debe contener :size elementos.","validation.size.file":"El campo :attribute debe pesar :size kilobytes.","validation.size.numeric":"El campo :attribute debe ser :size.","validation.size.string":"El campo :attribute debe tener :size caracteres.","validation.starts_with":"El campo :attribute debe comenzar con uno de los siguientes: :values.","validation.string":"El campo :attribute debe ser una cadena de texto.","validation.timezone":"El campo :attribute debe ser una zona horaria válida.","validation.unique":"El :attribute ya ha sido registrado.","validation.uploaded":"El :attribute no se pudo subir.","validation.uppercase":"El campo :attribute debe estar en mayúsculas.","validation.url":"El campo :attribute debe ser una URL válida.","validation.ulid":"El campo :attribute debe ser un ULID válido.","validation.uuid":"El campo :attribute debe ser un UUID válido.","validation.custom.attribute-name.rule-name":"custom-message","workspaces.title":"Workspaces","workspaces.select_title":"Tus workspaces","workspaces.select_description":"Selecciona un workspace para continuar","workspaces.current":"Actual","workspaces.connections":":count conexiones","workspaces.posts":":count posts","workspaces.create.title":"Crear un workspace","workspaces.create.description":"Dale un nombre a tu workspace para comenzar","workspaces.create.name":"Nombre del workspace","workspaces.create.name_placeholder":"Mi Workspace","workspaces.create.submit":"Crear workspace"} \ No newline at end of file diff --git a/lang/php_pt-BR.json b/lang/php_pt-BR.json deleted file mode 100644 index afe0a944..00000000 --- a/lang/php_pt-BR.json +++ /dev/null @@ -1 +0,0 @@ -{"accounts.title":"Conexões","accounts.page_title":"Contas Conectadas","accounts.description":"Conecte suas redes sociais para agendar e publicar posts","accounts.not_connected":"Não conectado","accounts.connect":"Conectar","accounts.connection_lost":"Conexão perdida","accounts.reconnect_account":"Reconectar conta","accounts.view_profile":"Ver perfil","accounts.disconnect":"Desconectar","accounts.disconnect_modal.title":"Desconectar Conta","accounts.disconnect_modal.description":"Tem certeza que deseja desconectar esta conta? Você pode reconectá-la a qualquer momento.","accounts.disconnect_modal.confirm":"Desconectar","accounts.disconnect_modal.cancel":"Cancelar","accounts.bluesky.title":"Conectar Bluesky","accounts.bluesky.description":"Digite suas credenciais para conectar","accounts.bluesky.email":"E-mail","accounts.bluesky.email_placeholder":"seuhandle.bsky.social","accounts.bluesky.app_password":"Senha do App","accounts.bluesky.app_password_placeholder":"xxxx-xxxx-xxxx-xxxx","accounts.bluesky.app_password_hint":"Use uma Senha do App por segurança. Crie uma em bsky.app/settings.","accounts.bluesky.submit":"Conectar Bluesky","accounts.bluesky.submitting":"Conectando...","accounts.mastodon.title":"Conectar Mastodon","accounts.mastodon.description":"Digite a instância do seu Mastodon","accounts.mastodon.instance_url":"URL da Instância","accounts.mastodon.instance_placeholder":"https://mastodon.social","accounts.mastodon.instance_hint":"Digite a URL da sua instância Mastodon (ex: mastodon.social, techhub.social)","accounts.mastodon.submit":"Continuar com Mastodon","accounts.mastodon.submitting":"Conectando...","accounts.facebook.title":"Selecionar Página do Facebook","accounts.facebook.description":"Escolha qual página você deseja conectar","accounts.facebook.no_pages":"Nenhuma página encontrada","accounts.facebook.no_pages_description":"Você não é administrador de nenhuma página do Facebook.","accounts.facebook.page_label":"Página do Facebook","accounts.linkedin.title":"Selecionar Página do LinkedIn","accounts.linkedin.description":"Escolha qual página você deseja conectar","accounts.linkedin.no_pages":"Nenhuma página encontrada","accounts.linkedin.no_pages_description":"Você não é administrador de nenhuma página do LinkedIn.","accounts.linkedin.page_label":"Página do LinkedIn","accounts.flash.disconnected":"Conta desconectada com sucesso!","accounts.flash.connected":"Conta conectada com sucesso!","accounts.flash.session_expired":"Sessão expirada. Por favor, tente novamente.","accounts.flash.workspace_not_found":"Workspace não encontrado.","accounts.flash.activated":"Conta ativada!","accounts.flash.deactivated":"Conta desativada!","accounts.flash.already_connected":"Esta plataforma já está conectada.","accounts.flash.no_youtube_channels":"Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.","auth.failed":"Essas credenciais não correspondem aos nossos registros.","auth.password":"A senha fornecida está incorreta.","auth.throttle":"Muitas tentativas de login. Por favor, tente novamente em :seconds segundos.","auth.flash.welcome":"Bem-vindo ao TryPost!","auth.flash.welcome_trial":"Bem-vindo ao TryPost! Seu período de teste começou.","auth.legal":"Ao continuar, você concorda com nossos Termos de Serviço e Política de Privacidade.","auth.slides.calendar.title":"Calendário Visual","auth.slides.calendar.description":"Planeje e agende seu conteúdo com um calendário intuitivo de arrastar e soltar em todas as suas contas sociais.","auth.slides.scheduling.title":"Agendamento Inteligente","auth.slides.scheduling.description":"Agende posts no LinkedIn, X, Instagram, TikTok, YouTube e mais — tudo em um só lugar.","auth.slides.media.title":"Mídia Rica","auth.slides.media.description":"Publique imagens, carrosséis, stories e reels. Cada plataforma recebe o formato correto automaticamente.","auth.slides.video.title":"Publicação de Vídeo","auth.slides.video.description":"Envie vídeos uma vez e publique no TikTok, YouTube Shorts, Instagram Reels e Facebook Reels.","auth.slides.team.title":"Workspaces em Equipe","auth.slides.team.description":"Convide sua equipe, atribua funções e gerencie múltiplas marcas em workspaces separados.","auth.slides.hashtags.title":"Grupos de Hashtags","auth.slides.hashtags.description":"Salve coleções de hashtags e adicione aos posts com um clique. Organize com etiquetas e filtros.","auth.or_continue_with":"Ou continue com","auth.google_login":"Entrar com Google","auth.google_signup":"Cadastrar com Google","auth.signup_success.page_title":"Bem-vindo","auth.signup_success.title":"Configurando sua conta","auth.signup_success.description":"Isso geralmente leva apenas alguns segundos...","auth.login.title":"Entrar na sua conta","auth.login.description":"Digite seu email e senha abaixo para entrar","auth.login.page_title":"Entrar","auth.login.email":"Endereço de email","auth.login.password":"Senha","auth.login.forgot_password":"Esqueceu a senha?","auth.login.remember_me":"Lembrar de mim","auth.login.submit":"Entrar","auth.login.no_account":"Não tem uma conta?","auth.login.sign_up":"Cadastre-se","auth.register.title":"Criar uma conta","auth.register.description":"Digite seus dados abaixo para criar sua conta","auth.register.page_title":"Cadastro","auth.register.name":"Nome","auth.register.name_placeholder":"Nome completo","auth.register.email":"Endereço de email","auth.register.password":"Senha","auth.register.show_password":"Mostrar senha","auth.register.hide_password":"Esconder senha","auth.register.submit":"Criar conta","auth.register.has_account":"Já tem uma conta?","auth.register.log_in":"Entrar","auth.forgot_password.title":"Esqueceu a senha","auth.forgot_password.description":"Digite seu email para receber um link de redefinição de senha","auth.forgot_password.page_title":"Esqueceu a senha","auth.forgot_password.email":"Endereço de email","auth.forgot_password.submit":"Enviar link de redefinição","auth.forgot_password.return_to":"Ou, volte para","auth.forgot_password.log_in":"entrar","auth.reset_password.title":"Redefinir senha","auth.reset_password.description":"Por favor, digite sua nova senha abaixo","auth.reset_password.page_title":"Redefinir senha","auth.reset_password.email":"Email","auth.reset_password.password":"Senha","auth.reset_password.confirm_password":"Confirmar Senha","auth.reset_password.confirm_placeholder":"Confirmar senha","auth.reset_password.submit":"Redefinir senha","auth.verify_email.title":"Verificar email","auth.verify_email.description":"Por favor, verifique seu endereço de email clicando no link que acabamos de enviar.","auth.verify_email.page_title":"Verificação de email","auth.verify_email.link_sent":"Um novo link de verificação foi enviado para o endereço de email que você forneceu durante o cadastro.","auth.verify_email.resend":"Reenviar email de verificação","auth.verify_email.log_out":"Sair","auth.accept_invite.page_title":"Aceitar Convite","auth.accept_invite.title":"Você foi convidado!","auth.accept_invite.description":"Você foi convidado para participar do workspace :workspace.","auth.accept_invite.workspace":"Workspace","auth.accept_invite.your_role":"Seu cargo","auth.accept_invite.email":"Email","auth.accept_invite.accept":"Aceitar Convite","auth.accept_invite.decline":"Recusar Convite","auth.accept_invite.login_prompt":"Entre ou crie uma conta para aceitar este convite.","auth.accept_invite.log_in":"Entrar","auth.accept_invite.create_account":"Criar Conta","billing.title":"Assinatura","billing.description":"Gerencie sua assinatura e método de pagamento","billing.subscribe.page_title":"Comece seu teste gratuito","billing.subscribe.title":"Comece seu teste gratuito","billing.subscribe.description":":days dias grátis para explorar todos os recursos.","billing.subscribe.start_trial":"Começar teste de :days dias","billing.subscribe.cancel_anytime":"Cancele a qualquer momento. Sem perguntas.","billing.subscribe.switch_workspace":"Trocar workspace","billing.subscribe.features.calendar":"Calendário visual com arrastar e soltar","billing.subscribe.features.scheduling":"Agendamento ilimitado de posts","billing.subscribe.features.media":"Imagens, carrosséis e stories","billing.subscribe.features.video":"Publicação de vídeos em todas as plataformas","billing.subscribe.features.team":"Colaboração em equipe e workspaces","billing.subscribe.features.hashtags":"Grupos de hashtags e etiquetas","billing.trial.title":"Período de teste ativo","billing.trial.description":"Seu período de teste termina em :date. Após isso, sua assinatura será cobrada automaticamente.","billing.subscription.title":"Sua Assinatura","billing.subscription.status":"Status","billing.subscription.workspaces":"Workspaces","billing.subscription.quantity":"Quantidade da assinatura","billing.subscription.expires":"Expira em :date","billing.subscription.canceled_on":"Sua assinatura será cancelada em :date","billing.subscription.manage":"Gerenciar no Stripe","billing.invoices.title":"Faturas","billing.invoices.description":"Histórico de pagamentos","billing.invoices.empty":"Nenhuma fatura encontrada","billing.invoices.paid":"Pago","billing.processing.page_title":"Processando...","billing.processing.title":"Processando sua assinatura","billing.processing.description":"Aguarde enquanto configuramos sua conta. Isso levará apenas um momento.","billing.processing.success_title":"Tudo pronto!","billing.processing.success_description":"Sua assinatura está ativa. Redirecionando para seus workspaces...","billing.processing.cancelled_title":"Pagamento cancelado","billing.processing.cancelled_description":"Seu pagamento foi cancelado. Nenhuma cobrança foi realizada.","billing.processing.retry":"Tentar novamente","billing.status.active":"Ativo","billing.status.canceled":"Cancelado","billing.status.incomplete":"Incompleto","billing.status.incomplete_expired":"Expirado","billing.status.past_due":"Vencido","billing.status.trialing":"Teste","billing.status.unpaid":"Não pago","calendar.title":"Calendário","calendar.today":"Hoje","calendar.day":"Dia","calendar.week":"Semana","calendar.month":"Mês","calendar.new_post":"Novo Post","calendar.no_content":"Sem conteúdo","calendar.more":"+:count mais","common.confirm_modal.cannot_be_undone":"Esta ação não pode ser desfeita.","common.confirm_modal.type":"Digite","common.confirm_modal.to_confirm":"para confirmar.","common.confirm_modal.copy_to_clipboard":"Copiar para a área de transferência","common.photo_upload.upload":"Enviar","common.photo_upload.uploading":"Enviando...","common.photo_upload.remove":"Remover foto","common.photo_upload.hint":"Recomendado: imagem quadrada, máximo 2 MB.","common.timezone.select":"Selecionar fuso horário","common.timezone.search":"Buscar fuso horário...","common.timezone.empty":"Fuso horário não encontrado","common.date_picker.select":"Selecionar data","common.cancel":"Cancelar","hashtags.title":"Hashtags","hashtags.description":"Crie grupos de hashtags para adicionar rapidamente aos seus posts","hashtags.search":"Buscar hashtags...","hashtags.new_group":"Novo Grupo","hashtags.no_groups_yet":"Nenhum grupo de hashtags ainda","hashtags.no_groups_description":"Crie grupos de hashtags para adicionar rapidamente hashtags populares aos seus posts","hashtags.create_first_group":"Crie seu primeiro grupo","hashtags.hashtags_count":":count hashtags","hashtags.create.title":"Criar Grupo de Hashtags","hashtags.create.description":"Dê um nome ao grupo e adicione hashtags separadas por espaços ou vírgulas","hashtags.create.name":"Nome do Grupo","hashtags.create.name_placeholder":"ex: Marketing, Viagem, Comida","hashtags.create.hashtags":"Hashtags","hashtags.create.hashtags_placeholder":"#marketing #redessociais #negocios #crescimento","hashtags.create.hashtags_hint":"Digite as hashtags separadas por espaços ou vírgulas. Inclua o símbolo #.","hashtags.create.submit":"Criar Grupo","hashtags.create.submitting":"Criando...","hashtags.edit.title":"Editar Grupo de Hashtags","hashtags.edit.description":"Atualize o nome e as hashtags deste grupo","hashtags.edit.name":"Nome do Grupo","hashtags.edit.name_placeholder":"ex: Marketing, Viagem, Comida","hashtags.edit.hashtags":"Hashtags","hashtags.edit.hashtags_placeholder":"#marketing #redessociais #negocios #crescimento","hashtags.edit.hashtags_hint":"Digite as hashtags separadas por espaços ou vírgulas. Inclua o símbolo #.","hashtags.edit.submit":"Salvar Alterações","hashtags.edit.submitting":"Salvando...","hashtags.delete.title":"Excluir Grupo de Hashtags","hashtags.delete.description":"Tem certeza que deseja excluir este grupo de hashtags? Esta ação não pode ser desfeita.","hashtags.delete.confirm":"Excluir","hashtags.delete.cancel":"Cancelar","hashtags.flash.created":"Grupo de hashtags criado com sucesso!","hashtags.flash.updated":"Grupo de hashtags atualizado com sucesso!","hashtags.flash.deleted":"Grupo de hashtags excluído com sucesso!","labels.title":"Etiquetas","labels.description":"Crie etiquetas para organizar e categorizar seus posts","labels.search":"Buscar etiquetas...","labels.new_label":"Nova Etiqueta","labels.no_labels_yet":"Nenhuma etiqueta ainda","labels.create_first_label":"Crie sua primeira etiqueta","labels.create.title":"Criar Etiqueta","labels.create.description":"Dê um nome e escolha uma cor para sua etiqueta","labels.create.name":"Nome","labels.create.name_placeholder":"Digite o nome da etiqueta...","labels.create.color":"Cor","labels.create.submit":"Criar Etiqueta","labels.create.submitting":"Criando...","labels.edit.title":"Editar Etiqueta","labels.edit.description":"Atualize o nome e a cor desta etiqueta","labels.edit.name":"Nome","labels.edit.name_placeholder":"Digite o nome da etiqueta...","labels.edit.color":"Cor","labels.edit.submit":"Salvar Alterações","labels.edit.submitting":"Salvando...","labels.delete.title":"Excluir Etiqueta","labels.delete.description":"Tem certeza que deseja excluir esta etiqueta? Esta ação não pode ser desfeita.","labels.delete.confirm":"Excluir","labels.delete.cancel":"Cancelar","labels.flash.created":"Etiqueta criada com sucesso!","labels.flash.updated":"Etiqueta atualizada com sucesso!","labels.flash.deleted":"Etiqueta excluída com sucesso!","mail.workspace_connections_disconnected.subject":"{1} :count conta precisa ser reconectada em :workspace|[2,*] :count contas precisam ser reconectadas em :workspace","mail.workspace_connections_disconnected.title":"Contas Precisam ser Reconectadas","mail.workspace_connections_disconnected.intro":"As seguintes contas de redes sociais no seu workspace :workspace foram desconectadas e precisam ser reconectadas:","mail.workspace_connections_disconnected.reasons_title":"Isso pode ter acontecido porque:","mail.workspace_connections_disconnected.reason_expired":"Os tokens de acesso expiraram","mail.workspace_connections_disconnected.reason_revoked":"Você revogou o acesso ao TryPost na plataforma","mail.workspace_connections_disconnected.reason_changed":"A plataforma mudou os requisitos de autenticação","mail.workspace_connections_disconnected.reconnect_cta":"Por favor, reconecte essas contas para continuar agendando e publicando posts.","mail.workspace_connections_disconnected.button":"Reconectar Contas","onboarding.role.page_title":"Bem-vindo - Conte-nos sobre você","onboarding.role.title":"Conte-nos sobre você","onboarding.role.description":"Nos ajude a personalizar sua experiência","onboarding.role.submit":"Continuar","onboarding.personas.founder.label":"Fundador","onboarding.personas.founder.description":"Construindo uma startup ou novo negócio","onboarding.personas.creator.label":"Criador de Conteúdo","onboarding.personas.creator.description":"Criador de conteúdo ou influenciador","onboarding.personas.agency.label":"Agência","onboarding.personas.agency.description":"Agência de marketing ou mídias sociais","onboarding.personas.enterprise.label":"Empresa","onboarding.personas.enterprise.description":"Grande empresa ou corporação","onboarding.personas.small_business.label":"Pequeno Negócio","onboarding.personas.small_business.description":"Pequena ou média empresa","onboarding.personas.personal.label":"Pessoal","onboarding.personas.personal.description":"Marca pessoal ou hobby","onboarding.connect.page_title":"Conecte suas contas","onboarding.connect.title":"Conecte suas contas","onboarding.connect.description":"Conecte pelo menos uma rede social para começar","onboarding.connect.submit":"Continuar","onboarding.connect.error":"Algo deu errado. Tente novamente.","onboarding.connect.go_back":"Voltar","pagination.previous":"« Anterior","pagination.next":"Próximo »","passwords.reset":"Sua senha foi redefinida.","passwords.sent":"Enviamos o link de redefinição de senha por e-mail.","passwords.throttled":"Por favor, aguarde antes de tentar novamente.","passwords.token":"Este token de redefinição de senha é inválido.","passwords.user":"Não conseguimos encontrar um usuário com esse endereço de e-mail.","posts.title":"Posts","posts.search":"Buscar posts...","posts.all_posts":"Todos os Posts","posts.new_post":"Novo Post","posts.no_posts":"Nenhum post encontrado","posts.start_creating":"Comece criando seu primeiro post.","posts.manage_posts":"Gerencie todos os seus posts","posts.delete_confirm":"Tem certeza que deseja excluir este post?","posts.by":"por","posts.actions.view":"Ver post","posts.actions.delete":"Excluir post","posts.form.post_type":"Tipo de Post","posts.form.board":"Pasta","posts.form.select_board":"Selecione uma pasta","posts.form.search_board":"Buscar pasta...","posts.form.no_board_found":"Nenhuma pasta encontrada","posts.form.media":"Mídia","posts.form.min":"Mín","posts.form.uploading":"Enviando...","posts.form.drop_to_upload":"Solte para enviar","posts.form.drag_and_drop":"Arraste e solte ou clique para enviar","posts.form.photos_and_videos":"Fotos e vídeos","posts.form.photos_only":"Apenas fotos","posts.form.videos_only":"Apenas vídeos","posts.form.drag_to_reorder":"Arraste para reordenar","posts.form.caption":"Legenda","posts.form.write_caption":"Escreva sua legenda...","posts.status.pending":"Pendente","posts.status.draft":"Rascunho","posts.status.scheduled":"Agendado","posts.status.publishing":"Publicando","posts.status.published":"Publicado","posts.status.partially_published":"Parcialmente Publicado","posts.status.failed":"Falhou","posts.descriptions.draft":"Posts aguardando agendamento","posts.descriptions.scheduled":"Posts agendados para publicação","posts.descriptions.published":"Posts já publicados","posts.edit.title":"Editar Post","posts.edit.view_title":"Visualizar Post","posts.edit.manage_platforms":"Gerenciar plataformas","posts.edit.sync":"Sincronizar","posts.edit.labels":"Etiqueta","posts.edit.hashtags":"Hashtags","posts.edit.schedule":"Agendar","posts.edit.publish":"Publicar","posts.edit.delete":"Excluir","posts.edit.settings":"Configurações","posts.edit.schedule_for":"Agendar para","posts.edit.saving":"Salvando...","posts.edit.saved":"Salvo","posts.edit.scheduled_at":"Agendado:","posts.edit.published_at":"Publicado:","posts.edit.media":"Mídia","posts.edit.caption":"Legenda","posts.edit.no_caption":"Sem legenda","posts.edit.no_content":"Sem conteúdo","posts.edit.empty_state.title":"Nenhuma plataforma selecionada","posts.edit.empty_state.description":"Selecione pelo menos uma plataforma para criar seu post","posts.edit.delete_modal.title":"Excluir Post","posts.edit.delete_modal.description":"Tem certeza que deseja excluir este post? Esta ação não pode ser desfeita.","posts.edit.delete_modal.action":"Excluir","posts.edit.delete_modal.cancel":"Cancelar","posts.edit.sync_enable.title":"Ativar sincronização?","posts.edit.sync_enable.description":"Todas as plataformas compartilharão o mesmo conteúdo. Qualquer edição personalizada feita em plataformas individuais será substituída pelo conteúdo atual.","posts.edit.sync_enable.cancel":"Cancelar","posts.edit.sync_enable.action":"Ativar sincronização","posts.edit.sync_disable.title":"Desativar sincronização?","posts.edit.sync_disable.description":"Cada plataforma manterá seu conteúdo atual, mas edições futuras serão aplicadas apenas à plataforma que você estiver editando.","posts.edit.sync_disable.customize_note":"Você poderá personalizar o conteúdo para cada plataforma individualmente.","posts.edit.sync_disable.cancel":"Cancelar","posts.edit.sync_disable.action":"Desativar sincronização","posts.edit.platforms_dialog.title":"Selecionar Plataformas","posts.edit.platforms_dialog.description":"Escolha em quais plataformas publicar este post.","posts.edit.hashtags_modal.search":"Buscar hashtags...","posts.edit.hashtags_modal.no_results":"Nenhuma hashtag encontrada.","posts.edit.validation.select_board":"Selecione uma pasta","posts.edit.validation.images_not_supported":"Imagens não suportadas","posts.edit.validation.videos_not_supported":"Vídeos não suportados","posts.edit.validation.max_images":"Máx :count imagens","posts.edit.validation.requires_media":"Requer mídia","posts.edit.validation.exceeded":":count excedido","posts.edit.validation.does_not_support_images":":platform não suporta imagens","posts.edit.validation.supports_up_to_images":":platform suporta até :count imagens","posts.edit.validation.does_not_support_videos":":platform não suporta vídeos","posts.content_types.instagram_feed.label":"Post do Feed","posts.content_types.instagram_feed.description":"Aparece no seu feed e perfil","posts.content_types.instagram_reel.label":"Reels","posts.content_types.instagram_reel.description":"Vídeo curto de até 90 segundos","posts.content_types.instagram_story.label":"Story","posts.content_types.instagram_story.description":"Desaparece após 24 horas","posts.content_types.linkedin_post.label":"Post","posts.content_types.linkedin_post.description":"Post padrão com texto e mídia","posts.content_types.linkedin_carousel.label":"Carrossel","posts.content_types.linkedin_carousel.description":"Imagens deslizáveis","posts.content_types.linkedin_page_post.label":"Post","posts.content_types.linkedin_page_post.description":"Post padrão com texto e mídia","posts.content_types.linkedin_page_carousel.label":"Carrossel","posts.content_types.linkedin_page_carousel.description":"Imagens deslizáveis","posts.content_types.facebook_post.label":"Post","posts.content_types.facebook_post.description":"Post padrão na sua página","posts.content_types.facebook_reel.label":"Reels","posts.content_types.facebook_reel.description":"Vídeo curto de até 90 segundos","posts.content_types.facebook_story.label":"Story","posts.content_types.facebook_story.description":"Desaparece após 24 horas","posts.content_types.tiktok_video.label":"Vídeo","posts.content_types.tiktok_video.description":"Conteúdo de vídeo curto","posts.content_types.youtube_short.label":"Short","posts.content_types.youtube_short.description":"Vídeo vertical de até 60 segundos","posts.content_types.x_post.label":"Post","posts.content_types.x_post.description":"Tweet com texto e mídia","posts.content_types.threads_post.label":"Post","posts.content_types.threads_post.description":"Post de texto com mídia opcional","posts.content_types.pinterest_pin.label":"Pin","posts.content_types.pinterest_pin.description":"Pin de imagem com link","posts.content_types.pinterest_video_pin.label":"Pin de Vídeo","posts.content_types.pinterest_video_pin.description":"Conteúdo em vídeo","posts.content_types.pinterest_carousel.label":"Carrossel","posts.content_types.pinterest_carousel.description":"2-5 imagens","posts.content_types.bluesky_post.label":"Post","posts.content_types.bluesky_post.description":"Post de texto com imagens opcionais","posts.content_types.mastodon_post.label":"Post","posts.content_types.mastodon_post.description":"Post de texto com mídia opcional","posts.platforms.linkedin":"LinkedIn","posts.platforms.linkedin-page":"Página do LinkedIn","posts.platforms.x":"X","posts.platforms.tiktok":"TikTok","posts.platforms.youtube":"YouTube Shorts","posts.platforms.facebook":"Página do Facebook","posts.platforms.instagram":"Instagram","posts.platforms.threads":"Threads","posts.platforms.pinterest":"Pinterest","posts.platforms.bluesky":"Bluesky","posts.platforms.mastodon":"Mastodon","posts.flash.scheduled":"Post agendado com sucesso!","posts.flash.publishing":"Post está sendo publicado!","posts.flash.deleted":"Post excluído com sucesso!","posts.flash.cannot_edit_published":"Posts publicados não podem ser editados.","posts.flash.connect_first":"Conecte pelo menos uma rede social antes de criar um post.","posts.errors.account_disconnected":"Conta social está desconectada","posts.errors.account_inactive":"Conta social está desativada","settings.title":"Configurações","settings.description":"Gerencie seu perfil e configurações da conta","settings.nav.profile":"Perfil","settings.nav.password":"Senha","settings.nav.workspace":"Workspace","settings.nav.members":"Membros","settings.nav.notifications":"Notificações","settings.nav.billing":"Faturamento","settings.notifications.title":"Preferências de notificações","settings.notifications.heading":"Notificações por e-mail","settings.notifications.description":"Escolha quais notificações por e-mail deseja receber","settings.notifications.post_published":"Post publicado","settings.notifications.post_published_description":"Receber um e-mail quando seu post for publicado com sucesso","settings.notifications.post_failed":"Post falhou","settings.notifications.post_failed_description":"Receber um e-mail quando seu post falhar ao publicar","settings.notifications.account_disconnected":"Conta desconectada","settings.notifications.account_disconnected_description":"Receber um e-mail quando uma conta social for desconectada","settings.notifications.save":"Salvar preferências","settings.profile.title":"Configurações do perfil","settings.profile.photo_heading":"Foto do perfil","settings.profile.photo_description":"Envie uma foto de perfil","settings.profile.heading":"Informações do perfil","settings.profile.description":"Atualize seu nome e endereço de e-mail","settings.profile.avatar":"Avatar","settings.profile.name":"Nome","settings.profile.name_placeholder":"Nome completo","settings.profile.email":"Endereço de e-mail","settings.profile.email_placeholder":"Endereço de e-mail","settings.profile.email_unverified":"Seu endereço de e-mail não foi verificado.","settings.profile.resend_verification":"Clique aqui para reenviar o e-mail de verificação.","settings.profile.verification_sent":"Um novo link de verificação foi enviado para seu endereço de e-mail.","settings.profile.save":"Salvar","settings.password.title":"Configurações de senha","settings.password.heading":"Atualizar senha","settings.password.description":"Certifique-se de que sua conta esteja usando uma senha longa e aleatória para se manter seguro","settings.password.current_password":"Senha atual","settings.password.current_password_placeholder":"Senha atual","settings.password.new_password":"Nova senha","settings.password.new_password_placeholder":"Nova senha","settings.password.confirm_password":"Confirmar senha","settings.password.confirm_password_placeholder":"Confirmar senha","settings.password.save":"Salvar senha","settings.delete_account.heading":"Excluir conta","settings.delete_account.description":"Exclua sua conta e todos os seus recursos","settings.delete_account.warning":"Atenção","settings.delete_account.warning_message":"Por favor, prossiga com cuidado, isso não pode ser desfeito.","settings.delete_account.button":"Excluir conta","settings.delete_account.modal_title":"Tem certeza que deseja excluir sua conta?","settings.delete_account.modal_description":"Uma vez que sua conta for excluída, todos os seus recursos e dados também serão permanentemente excluídos. Por favor, digite sua senha para confirmar que deseja excluir permanentemente sua conta.","settings.delete_account.password":"Senha","settings.delete_account.password_placeholder":"Senha","settings.delete_account.cancel":"Cancelar","settings.delete_account.confirm":"Excluir conta","settings.workspace.title":"Configurações do workspace","settings.workspace.logo_heading":"Logo do workspace","settings.workspace.logo_description":"Envie um logo para o workspace","settings.workspace.heading":"Nome do workspace","settings.workspace.description":"Atualize o nome e fuso horário do workspace","settings.workspace.members_heading":"Membros","settings.workspace.members_description":"Gerencie membros e convites do workspace","settings.workspace.name":"Nome","settings.workspace.name_placeholder":"Meu Workspace","settings.workspace.timezone":"Fuso horário","settings.workspace.save":"Salvar","settings.members.title":"Membros","settings.members.heading":"Membros da equipe","settings.members.description":"Gerencie membros e convites deste workspace","settings.members.cancel":"Cancelar","settings.members.remove":"Remover","settings.members.make_admin":"Tornar administrador","settings.members.make_member":"Tornar membro","settings.members.invite.title":"Convidar Membro","settings.members.invite.description":"Envie um convite por e-mail para adicionar colaboradores","settings.members.invite.email":"E-mail","settings.members.invite.email_placeholder":"colaborador@email.com","settings.members.invite.role":"Função","settings.members.invite.role_placeholder":"Selecione uma função","settings.members.invite.submit":"Enviar Convite","settings.members.pending.title":"Convites Pendentes","settings.members.pending.description":"Convites aguardando aceitação","settings.members.pending.empty":"Nenhum convite pendente","settings.members.list.title":"Membros","settings.members.list.description":"Pessoas com acesso a este workspace","settings.members.list.empty":"Nenhum membro além do proprietário","settings.members.remove_modal.title":"Remover membro","settings.members.remove_modal.description":"Tem certeza que deseja remover este membro do workspace? Ele perderá acesso a todos os recursos do workspace.","settings.members.remove_modal.action":"Remover membro","settings.members.cancel_invite_modal.title":"Cancelar convite","settings.members.cancel_invite_modal.description":"Tem certeza que deseja cancelar este convite?","settings.members.cancel_invite_modal.action":"Cancelar convite","settings.members.roles.owner":"Proprietário","settings.members.roles.admin":"Administrador","settings.members.roles.member":"Membro","settings.members.flash.invite_sent":"Convite enviado com sucesso!","settings.members.flash.invite_deleted":"Convite excluído.","settings.members.flash.member_removed":"Membro removido com sucesso.","settings.members.flash.role_updated":"Função do membro atualizada.","settings.members.flash.wrong_email":"Este convite é para um endereço de e-mail diferente.","settings.members.flash.already_member":"Você já é membro deste workspace.","settings.members.flash.invite_accepted":"Bem-vindo! Você agora é membro do workspace.","settings.members.flash.invite_declined":"Convite recusado.","settings.flash.profile_updated":"Perfil atualizado com sucesso!","settings.flash.language_updated":"Idioma atualizado com sucesso!","settings.flash.password_updated":"Senha atualizada com sucesso!","settings.flash.workspace_updated":"Configurações atualizadas com sucesso!","settings.flash.photo_updated":"Foto atualizada com sucesso!","settings.flash.photo_deleted":"Foto removida com sucesso!","settings.flash.logo_updated":"Logo enviado com sucesso!","settings.flash.logo_deleted":"Logo removido com sucesso!","settings.flash.notifications_updated":"Preferências de notificações atualizadas!","settings.api_keys.title":"Chaves API","settings.api_keys.page_title":"Chaves API","settings.api_keys.heading":"Chaves API","settings.api_keys.description":"Gerencie chaves API para acesso programático ao seu workspace.","settings.api_keys.create":"Criar chave API","settings.api_keys.copy":"Copiar","settings.api_keys.new_token_message":"Sua nova chave API foi criada. Copie agora — você não poderá vê-la novamente.","settings.api_keys.table.name":"Nome","settings.api_keys.table.key":"Chave","settings.api_keys.table.status":"Status","settings.api_keys.table.expires":"Expira","settings.api_keys.table.last_used":"Último uso","settings.api_keys.table.never":"Nunca","settings.api_keys.actions.copy_id":"Copiar ID da chave API","settings.api_keys.actions.copy_id_success":"ID da chave API copiado","settings.api_keys.actions.delete":"Excluir","settings.api_keys.empty.title":"Nenhuma chave API","settings.api_keys.empty.description":"Crie uma chave API para acessar seu workspace programaticamente.","settings.api_keys.delete_modal.title":"Excluir chave API","settings.api_keys.delete_modal.description":"Tem certeza que deseja excluir esta chave API? Aplicações que a usam perderão acesso imediatamente.","settings.api_keys.delete_modal.action":"Excluir chave API","settings.api_keys.create_dialog.title":"Criar chave API","settings.api_keys.create_dialog.description":"Crie uma nova chave API para acesso programático ao seu workspace.","settings.api_keys.create_dialog.name":"Nome","settings.api_keys.create_dialog.name_placeholder":"ex. Chave API de Produção","settings.api_keys.create_dialog.expires":"Data de expiração (opcional)","settings.api_keys.create_dialog.expires_placeholder":"Sem expiração","settings.api_keys.create_dialog.submit":"Criar","settings.api_keys.create_dialog.cancel":"Cancelar","settings.api_keys.flash.created":"Chave de API criada com sucesso!","settings.api_keys.flash.deleted":"Chave de API excluída com sucesso!","sidebar.workspaces":"Espaços de trabalho","sidebar.select_workspace":"Selecionar workspace","sidebar.create_workspace":"Criar workspace","sidebar.create_post":"Novo post","sidebar.profile":"Perfil","sidebar.account":"Conta","sidebar.log_out":"Sair","sidebar.workspace":"Workspace: :name","sidebar.workspace_select":"Workspace: Selecionar","sidebar.theme":"Tema: :name","sidebar.theme_light":"Claro","sidebar.theme_dark":"Escuro","sidebar.theme_system":"Sistema","sidebar.language":"Idioma: :name","sidebar.language_select":"Idioma: Selecionar","sidebar.groups.posts":"Posts","sidebar.groups.configuration":"Configurações","sidebar.groups.support":"Suporte","sidebar.posts.calendar":"Calendário","sidebar.posts.all":"Todos","sidebar.posts.scheduled":"Agendados","sidebar.posts.posted":"Publicados","sidebar.posts.drafts":"Rascunhos","sidebar.config.connections":"Conexões","sidebar.config.hashtags":"Hashtags","sidebar.config.labels":"Etiquetas","sidebar.config.settings":"Configurações","sidebar.notifications":"Notificações","sidebar.mark_all_read":"Marcar tudo como lido","sidebar.mark_as_read":"Marcar como lido","sidebar.archive_all":"Arquivar tudo","sidebar.no_notifications":"Sem notificações","sidebar.support.discord":"Discord","sidebar.support.share_feedback":"Enviar feedback","sidebar.support.last_updates":"Últimas Atualizações","sidebar.support.docs":"Documentação","validation.accepted":"O campo :attribute deve ser aceito.","validation.accepted_if":"O campo :attribute deve ser aceito quando :other for :value.","validation.active_url":"O campo :attribute deve ser uma URL válida.","validation.after":"O campo :attribute deve ser uma data posterior a :date.","validation.after_or_equal":"O campo :attribute deve ser uma data posterior ou igual a :date.","validation.alpha":"O campo :attribute deve conter apenas letras.","validation.alpha_dash":"O campo :attribute deve conter apenas letras, números, hifens e underscores.","validation.alpha_num":"O campo :attribute deve conter apenas letras e números.","validation.any_of":"O campo :attribute é inválido.","validation.array":"O campo :attribute deve ser um array.","validation.ascii":"O campo :attribute deve conter apenas caracteres alfanuméricos e símbolos de um byte.","validation.before":"O campo :attribute deve ser uma data anterior a :date.","validation.before_or_equal":"O campo :attribute deve ser uma data anterior ou igual a :date.","validation.between.array":"O campo :attribute deve ter entre :min e :max itens.","validation.between.file":"O campo :attribute deve estar entre :min e :max kilobytes.","validation.between.numeric":"O campo :attribute deve estar entre :min e :max.","validation.between.string":"O campo :attribute deve estar entre :min e :max caracteres.","validation.boolean":"O campo :attribute deve ser verdadeiro ou falso.","validation.can":"O campo :attribute contém um valor não autorizado.","validation.confirmed":"A confirmação do campo :attribute não corresponde.","validation.contains":"O campo :attribute está faltando um valor obrigatório.","validation.current_password":"A senha está incorreta.","validation.date":"O campo :attribute deve ser uma data válida.","validation.date_equals":"O campo :attribute deve ser uma data igual a :date.","validation.date_format":"O campo :attribute deve corresponder ao formato :format.","validation.decimal":"O campo :attribute deve ter :decimal casas decimais.","validation.declined":"O campo :attribute deve ser recusado.","validation.declined_if":"O campo :attribute deve ser recusado quando :other for :value.","validation.different":"O campo :attribute e :other devem ser diferentes.","validation.digits":"O campo :attribute deve ter :digits dígitos.","validation.digits_between":"O campo :attribute deve ter entre :min e :max dígitos.","validation.dimensions":"O campo :attribute deve ter dimensões de imagem válidas.","validation.distinct":"O campo :attribute tem um valor duplicado.","validation.doesnt_contain":"O campo :attribute não deve conter nenhum dos seguintes: :values.","validation.doesnt_end_with":"O campo :attribute não deve terminar com nenhum dos seguintes: :values.","validation.doesnt_start_with":"O campo :attribute não deve começar com nenhum dos seguintes: :values.","validation.email":"O campo :attribute deve ser um endereço de e-mail válido.","validation.encoding":"O campo :attribute deve ser codificado em :encoding.","validation.ends_with":"O campo :attribute deve terminar com um dos seguintes: :values.","validation.enum":"O :attribute selecionado é inválido.","validation.exists":"O :attribute selecionado é inválido.","validation.extensions":"O campo :attribute deve ter uma das seguintes extensões: :values.","validation.file":"O campo :attribute deve ser um arquivo.","validation.filled":"O campo :attribute deve ter um valor.","validation.gt.array":"O campo :attribute deve ter mais de :value itens.","validation.gt.file":"O campo :attribute deve ser maior que :value kilobytes.","validation.gt.numeric":"O campo :attribute deve ser maior que :value.","validation.gt.string":"O campo :attribute deve ser maior que :value caracteres.","validation.gte.array":"O campo :attribute deve ter :value itens ou mais.","validation.gte.file":"O campo :attribute deve ser maior ou igual a :value kilobytes.","validation.gte.numeric":"O campo :attribute deve ser maior ou igual a :value.","validation.gte.string":"O campo :attribute deve ser maior ou igual a :value caracteres.","validation.hex_color":"O campo :attribute deve ser uma cor hexadecimal válida.","validation.image":"O campo :attribute deve ser uma imagem.","validation.in":"O :attribute selecionado é inválido.","validation.in_array":"O campo :attribute deve existir em :other.","validation.in_array_keys":"O campo :attribute deve conter pelo menos uma das seguintes chaves: :values.","validation.integer":"O campo :attribute deve ser um inteiro.","validation.ip":"O campo :attribute deve ser um endereço IP válido.","validation.ipv4":"O campo :attribute deve ser um endereço IPv4 válido.","validation.ipv6":"O campo :attribute deve ser um endereço IPv6 válido.","validation.json":"O campo :attribute deve ser uma string JSON válida.","validation.list":"O campo :attribute deve ser uma lista.","validation.lowercase":"O campo :attribute deve estar em minúsculas.","validation.lt.array":"O campo :attribute deve ter menos de :value itens.","validation.lt.file":"O campo :attribute deve ser menor que :value kilobytes.","validation.lt.numeric":"O campo :attribute deve ser menor que :value.","validation.lt.string":"O campo :attribute deve ser menor que :value caracteres.","validation.lte.array":"O campo :attribute deve ter :value itens ou menos.","validation.lte.file":"O campo :attribute deve ser menor ou igual a :value kilobytes.","validation.lte.numeric":"O campo :attribute deve ser menor ou igual a :value.","validation.lte.string":"O campo :attribute deve ser menor ou igual a :value caracteres.","validation.mac_address":"O campo :attribute deve ser um endereço MAC válido.","validation.max.array":"O campo :attribute deve ter no máximo :max itens.","validation.max.file":"O campo :attribute deve ter no máximo :max kilobytes.","validation.max.numeric":"O campo :attribute deve ter no máximo :max.","validation.max.string":"O campo :attribute deve ter no máximo :max caracteres.","validation.max_digits":"O campo :attribute não deve ter mais que :max dígitos.","validation.mimes":"O campo :attribute deve ser um arquivo do tipo: :values.","validation.mimetypes":"O campo :attribute deve ser um arquivo do tipo: :values.","validation.min.array":"O campo :attribute deve ter pelo menos :min itens.","validation.min.file":"O campo :attribute deve ter pelo menos :min kilobytes.","validation.min.numeric":"O campo :attribute deve ter pelo menos :min.","validation.min.string":"O campo :attribute deve ter pelo menos :min caracteres.","validation.min_digits":"O campo :attribute deve ter pelo menos :min dígitos.","validation.missing":"O campo :attribute deve estar ausente.","validation.missing_if":"O campo :attribute deve estar ausente quando :other for :value.","validation.missing_unless":"O campo :attribute deve estar ausente a menos que :other seja :value.","validation.missing_with":"O campo :attribute deve estar ausente quando :values estiver presente.","validation.missing_with_all":"O campo :attribute deve estar ausente quando :values estiverem presentes.","validation.multiple_of":"O campo :attribute deve ser um múltiplo de :value.","validation.not_in":"O :attribute selecionado é inválido.","validation.not_regex":"O formato do campo :attribute é inválido.","validation.numeric":"O campo :attribute deve ser um número.","validation.password.letters":"O campo :attribute deve conter pelo menos uma letra.","validation.password.mixed":"O campo :attribute deve conter pelo menos uma letra maiúscula e uma minúscula.","validation.password.numbers":"O campo :attribute deve conter pelo menos um número.","validation.password.symbols":"O campo :attribute deve conter pelo menos um símbolo.","validation.password.uncompromised":"O :attribute fornecido apareceu em um vazamento de dados. Por favor, escolha um :attribute diferente.","validation.present":"O campo :attribute deve estar presente.","validation.present_if":"O campo :attribute deve estar presente quando :other for :value.","validation.present_unless":"O campo :attribute deve estar presente a menos que :other seja :value.","validation.present_with":"O campo :attribute deve estar presente quando :values estiver presente.","validation.present_with_all":"O campo :attribute deve estar presente quando :values estiverem presentes.","validation.prohibited":"O campo :attribute é proibido.","validation.prohibited_if":"O campo :attribute é proibido quando :other for :value.","validation.prohibited_if_accepted":"O campo :attribute é proibido quando :other for aceito.","validation.prohibited_if_declined":"O campo :attribute é proibido quando :other for recusado.","validation.prohibited_unless":"O campo :attribute é proibido a menos que :other esteja em :values.","validation.prohibits":"O campo :attribute proíbe :other de estar presente.","validation.regex":"O formato do campo :attribute é inválido.","validation.required":"O campo :attribute é obrigatório.","validation.required_array_keys":"O campo :attribute deve conter entradas para: :values.","validation.required_if":"O campo :attribute é obrigatório quando :other for :value.","validation.required_if_accepted":"O campo :attribute é obrigatório quando :other for aceito.","validation.required_if_declined":"O campo :attribute é obrigatório quando :other for recusado.","validation.required_unless":"O campo :attribute é obrigatório a menos que :other esteja em :values.","validation.required_with":"O campo :attribute é obrigatório quando :values estiver presente.","validation.required_with_all":"O campo :attribute é obrigatório quando :values estiverem presentes.","validation.required_without":"O campo :attribute é obrigatório quando :values não estiver presente.","validation.required_without_all":"O campo :attribute é obrigatório quando nenhum dos :values estiver presente.","validation.same":"O campo :attribute deve ser igual a :other.","validation.size.array":"O campo :attribute deve conter :size itens.","validation.size.file":"O campo :attribute deve ter :size kilobytes.","validation.size.numeric":"O campo :attribute deve ser :size.","validation.size.string":"O campo :attribute deve ter :size caracteres.","validation.starts_with":"O campo :attribute deve começar com um dos seguintes: :values.","validation.string":"O campo :attribute deve ser uma string.","validation.timezone":"O campo :attribute deve ser um fuso horário válido.","validation.unique":"O :attribute já foi utilizado.","validation.uploaded":"O :attribute falhou ao ser enviado.","validation.uppercase":"O campo :attribute deve estar em maiúsculo.","validation.url":"O campo :attribute deve ser uma URL válida.","validation.ulid":"O campo :attribute deve ser um ULID válido.","validation.uuid":"O campo :attribute deve ser um UUID válido.","validation.custom.attribute-name.rule-name":"custom-message","workspaces.title":"Workspaces","workspaces.select_title":"Seus workspaces","workspaces.select_description":"Selecione um workspace para continuar","workspaces.current":"Atual","workspaces.connections":":count conexões","workspaces.posts":":count posts","workspaces.create.title":"Criar um workspace","workspaces.create.description":"Dê um nome ao seu workspace para começar","workspaces.create.name":"Nome do workspace","workspaces.create.name_placeholder":"Meu Workspace","workspaces.create.submit":"Criar workspace"} \ No newline at end of file diff --git a/routes/console.php b/routes/console.php index b5b7531c..0953d8c6 100644 --- a/routes/console.php +++ b/routes/console.php @@ -4,7 +4,9 @@ use App\Console\Commands\CheckSocialConnections; use App\Console\Commands\ProcessScheduledPosts; +use App\Console\Commands\RefreshExpiringTokens; use Illuminate\Support\Facades\Schedule; Schedule::command(ProcessScheduledPosts::class)->everyMinute(); Schedule::command(CheckSocialConnections::class)->daily(); +Schedule::command(RefreshExpiringTokens::class)->hourly(); diff --git a/tests/Feature/Commands/RefreshExpiringTokensTest.php b/tests/Feature/Commands/RefreshExpiringTokensTest.php new file mode 100644 index 00000000..417070e2 --- /dev/null +++ b/tests/Feature/Commands/RefreshExpiringTokensTest.php @@ -0,0 +1,63 @@ +create(); + + // Should be refreshed (expires in 1 hour) + $expiringSoon = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::LinkedIn, + 'status' => Status::Connected, + 'token_expires_at' => now()->addHour(), + ]); + + // Should NOT be refreshed (expires in 5 hours) + SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::Instagram, + 'status' => Status::Connected, + 'token_expires_at' => now()->addHours(5), + ]); + + // Should NOT be refreshed (already expired) + SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::TikTok, + 'status' => Status::Connected, + 'token_expires_at' => now()->subHour(), + ]); + + // Should NOT be refreshed (disconnected) + SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::X, + 'status' => Status::Disconnected, + 'token_expires_at' => now()->addHour(), + ]); + + $this->artisan('social:refresh-expiring-tokens') + ->assertSuccessful(); + + Queue::assertPushed(RefreshSocialToken::class, 1); + Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $expiringSoon->id); +}); + +test('it dispatches nothing when no tokens are expiring', function () { + Queue::fake(); + + $this->artisan('social:refresh-expiring-tokens') + ->assertSuccessful(); + + Queue::assertNothingPushed(); +}); diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 4eb34a22..efc7138e 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -15,6 +15,7 @@ use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; +use App\Services\Social\ConnectionVerifier; use App\Services\Social\LinkedInPublisher; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Mail; @@ -259,6 +260,62 @@ Queue::assertPushed(SendNotification::class); }); +test('it retries with token refresh when token expires during publish', function () { + Event::fake(); + + $callCount = 0; + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish') + ->twice() + ->andReturnUsing(function () use (&$callCount) { + $callCount++; + if ($callCount === 1) { + throw new TokenExpiredException('Token expired', '401'); + } + + return ['id' => 'post-123', 'url' => 'https://linkedin.com/post/123']; + }); + + $this->app->instance(LinkedInPublisher::class, $publisher); + + $verifier = Mockery::mock(ConnectionVerifier::class); + $verifier->shouldReceive('verify')->once()->andReturn(true); + + $this->app->instance(ConnectionVerifier::class, $verifier); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + $this->socialAccount->refresh(); + + expect($this->postPlatform->status)->toBe(PlatformStatus::Published); + expect($this->socialAccount->status)->not->toBe(AccountStatus::Disconnected); +}); + +test('it disconnects account when token refresh fails during publish retry', function () { + Event::fake(); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish') + ->once() + ->andThrow(new TokenExpiredException('Token expired', '401')); + + $this->app->instance(LinkedInPublisher::class, $publisher); + + $verifier = Mockery::mock(ConnectionVerifier::class); + $verifier->shouldReceive('verify')->once()->andThrow(new Exception('Refresh failed')); + + $this->app->instance(ConnectionVerifier::class, $verifier); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + $this->socialAccount->refresh(); + + expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); + expect($this->socialAccount->status)->toBe(AccountStatus::Disconnected); +}); + test('publish to social platform skips if already published (idempotency)', function () { Event::fake(); diff --git a/tests/Unit/Services/Social/Concerns/HasSocialHttpClientTest.php b/tests/Unit/Services/Social/Concerns/HasSocialHttpClientTest.php new file mode 100644 index 00000000..ee5f0643 --- /dev/null +++ b/tests/Unit/Services/Social/Concerns/HasSocialHttpClientTest.php @@ -0,0 +1,68 @@ +client = new class + { + use HasSocialHttpClient; + + public function makeRequest(string $url): Response + { + return $this->socialHttp()->get($url); + } + }; +}); + +it('retries on 429 responses', function () { + Http::fake([ + 'https://example.com/api' => Http::sequence() + ->push('Rate limited', 429) + ->push(['success' => true], 200), + ]); + + $response = $this->client->makeRequest('https://example.com/api'); + + expect($response->status())->toBe(200); + Http::assertSentCount(2); +}); + +it('does not retry on non-429 errors', function () { + Http::fake([ + 'https://example.com/api' => Http::response('Server error', 500), + ]); + + $response = $this->client->makeRequest('https://example.com/api'); + + expect($response->status())->toBe(500); + Http::assertSentCount(1); +}); + +it('gives up after 3 retries', function () { + Http::fake([ + 'https://example.com/api' => Http::sequence() + ->push('Rate limited', 429) + ->push('Rate limited', 429) + ->push('Rate limited', 429), + ]); + + $response = $this->client->makeRequest('https://example.com/api'); + + expect($response->status())->toBe(429); + Http::assertSentCount(3); +}); + +it('returns successful response normally', function () { + Http::fake([ + 'https://example.com/api' => Http::response(['data' => 'ok'], 200), + ]); + + $response = $this->client->makeRequest('https://example.com/api'); + + expect($response->status())->toBe(200); + Http::assertSentCount(1); +}); diff --git a/tests/Unit/Services/Social/LinkedInPagePublisherTest.php b/tests/Unit/Services/Social/LinkedInPagePublisherTest.php index c5194e3c..bb0cbd70 100644 --- a/tests/Unit/Services/Social/LinkedInPagePublisherTest.php +++ b/tests/Unit/Services/Social/LinkedInPagePublisherTest.php @@ -217,3 +217,54 @@ expect($result['url'])->toContain('linkedin.com/feed/update/urn:li:share:1234567890'); }); + +test('linkedin page publisher can publish post with image using organization urn', function () { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 512000, + 'order' => 0, + 'meta' => ['width' => 1920, 'height' => 1080], + ]); + + $uploadUrl = 'https://www.linkedin.com/dms/upload/v2/pic/0/OrgFake'; + + Http::fake(function ($request) use ($uploadUrl) { + $url = $request->url(); + + if (str_contains($url, '/rest/images')) { + return Http::response([ + 'value' => [ + 'uploadUrl' => $uploadUrl, + 'image' => 'urn:li:image:OrgFakeImageUrn', + ], + ], 200); + } + + if ($url === $uploadUrl) { + return Http::response(null, 201); + } + + if (str_contains($url, '/rest/posts')) { + return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:9999999999']); + } + + // Media download fallback + return Http::response('fake-image-content', 200); + }); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('urn:li:share:9999999999'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/images')); + + // Assert the post was created with organization URN as author + Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/posts') + && ($request['author'] ?? '') === 'urn:li:organization:123456' + && isset($request['content']['media']['id']) + ); +}); diff --git a/tests/Unit/Services/Social/YouTubePublisherTest.php b/tests/Unit/Services/Social/YouTubePublisherTest.php index 9d62df63..b59ce155 100644 --- a/tests/Unit/Services/Social/YouTubePublisherTest.php +++ b/tests/Unit/Services/Social/YouTubePublisherTest.php @@ -163,3 +163,30 @@ expect(fn () => $this->publisher->publish($this->postPlatform)) ->toThrow(Exception::class, 'YouTube Shorts require a title'); }); + +test('youtube publisher builds correct title with shorts tag', function () { + $publisher = new YouTubePublisher; + $reflection = new ReflectionClass($publisher); + $method = $reflection->getMethod('buildTitle'); + $method->setAccessible(true); + + // Short content: appends #Shorts + $title = $method->invoke($publisher, 'My awesome short video'); + expect($title)->toBe('My awesome short video #Shorts'); + + // Long content: truncates to leave room for #Shorts tag (100 chars max) + $longContent = str_repeat('A', 200); + $title = $method->invoke($publisher, $longContent); + expect(strlen($title))->toBeLessThanOrEqual(100); + expect($title)->toEndWith(' #Shorts'); + + // Multi-line content: only uses first line before period + $multiLine = "First sentence. Second part.\nSecond line"; + $title = $method->invoke($publisher, $multiLine); + expect($title)->toBe('First sentence #Shorts'); + + // Newline-separated: stops at newline + $newlineContent = "Title line\nMore content here"; + $title = $method->invoke($publisher, $newlineContent); + expect($title)->toBe('Title line #Shorts'); +});