diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index 8ad9fc18..c0669c53 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -273,6 +273,8 @@ private function publishVideoPin(PostPlatform $postPlatform, ?string $content): if (! empty(data_get($postPlatform->meta, 'cover_image_url'))) { $payload['media_source']['cover_image_url'] = data_get($postPlatform->meta, 'cover_image_url'); + } else { + $payload['media_source']['cover_image_key_frame_time'] = 0; } $response = $this->socialHttp()->withToken($account->access_token) diff --git a/lang/en/posts.php b/lang/en/posts.php index 6d6987f8..e4f52fed 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -353,6 +353,7 @@ 'document_too_large' => 'PDF exceeds the size limit for this platform.', 'aspect_ratio_invalid' => 'Aspect ratio is not supported by this format.', 'no_content_type' => 'Pick a content type for this platform.', + 'requires_text' => 'Add text — this format needs a title.', ], 'publishing' => 'Publishing...', 'publishing_overlay_title' => 'Your post is being published', diff --git a/lang/es/posts.php b/lang/es/posts.php index c4c3af01..61509fd8 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -353,6 +353,7 @@ 'document_too_large' => 'El PDF supera el límite de tamaño de esta plataforma.', 'aspect_ratio_invalid' => 'La proporción de aspecto no es compatible con este formato.', 'no_content_type' => 'Elige un tipo de contenido para esta plataforma.', + 'requires_text' => 'Agrega texto — este formato necesita un título.', ], 'publishing' => 'Publicando...', 'publishing_overlay_title' => 'Tu publicación se está enviando', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 1f0b03c5..72a8a361 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -353,6 +353,7 @@ 'document_too_large' => 'O PDF excede o limite de tamanho desta plataforma.', 'aspect_ratio_invalid' => 'A proporção da imagem não é suportada por este formato.', 'no_content_type' => 'Escolha um tipo de conteúdo para esta plataforma.', + 'requires_text' => 'Adicione texto — este formato precisa de um título.', ], 'publishing' => 'Publicando...', 'publishing_overlay_title' => 'Seu post está sendo publicado', diff --git a/resources/js/composables/usePostCompliance.ts b/resources/js/composables/usePostCompliance.ts index c2d4a6f7..0ee49eb6 100644 --- a/resources/js/composables/usePostCompliance.ts +++ b/resources/js/composables/usePostCompliance.ts @@ -30,6 +30,10 @@ export const PLATFORM_VARIANTS: Record = { [Platform.Pinterest]: [ContentType.PinterestPin, ContentType.PinterestVideoPin, ContentType.PinterestCarousel], }; +// Content types whose post needs text to publish — YouTube Shorts derive their +// required title from the post content, so an empty post can't be scheduled. +const CONTENT_TYPES_REQUIRING_TEXT = new Set([ContentType.YouTubeShort]); + type MetaRule = (meta: Record) => { valid: boolean; tooltipKey: string | null }; // Platforms whose `meta` blob has publish-time requirements. `valid` gates @@ -198,6 +202,11 @@ export const usePostCompliance = (opts: UsePostComplianceOptions) => { continue; } + if (CONTENT_TYPES_REQUIRING_TEXT.has(contentType) && content.value.trim() === '') { + issues[pp.id] = trans('posts.edit.compliance.requires_text'); + continue; + } + const reason = getMediaIncompatibilityReason(contentType, media.value); if (!reason) continue; diff --git a/tests/Feature/Services/Social/PinterestPublisherTest.php b/tests/Feature/Services/Social/PinterestPublisherTest.php index cacc62a9..7f727115 100644 --- a/tests/Feature/Services/Social/PinterestPublisherTest.php +++ b/tests/Feature/Services/Social/PinterestPublisherTest.php @@ -481,10 +481,60 @@ expect($result['id'])->toBe('video_pin_999'); expect($result['url'])->toBe('https://pinterest.com/pin/video_pin_999'); - Http::assertSent(function ($request) { - return str_contains($request->url(), '/v5/pins') - && data_get($request->data(), 'media_source.source_type') === 'video_id'; + Http::assertSent(fn ($request) => $request->url() === config('trypost.platforms.pinterest.api').'/pins' + && data_get($request->data(), 'media_source.source_type') === 'video_id' + && data_get($request->data(), 'media_source.cover_image_key_frame_time') === 0); +}); + +test('pinterest video pin uses the provided cover image url over the default frame', function () { + $this->postPlatform->update([ + 'content_type' => ContentType::PinterestVideoPin, + 'meta' => ['board_id' => 'board_123', 'cover_image_url' => 'https://example.com/cover.jpg'], + ]); + + $this->post->update([ + 'media' => [[ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/video.mp4', + 'url' => 'https://example.com/media/2026-01/video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'video.mp4', + ]], + ]); + + $s3UploadUrl = 'https://pinterest-media-upload.s3.amazonaws.com/upload'; + + Http::fake(function ($request) use ($s3UploadUrl) { + $url = $request->url(); + + if (str_contains($url, '/v5/media') && $request->method() === 'POST') { + return Http::response([ + 'media_id' => 'media_video_789', + 'upload_url' => $s3UploadUrl, + 'upload_parameters' => [], + ], 201); + } + + if ($url === $s3UploadUrl) { + return Http::response('', 204); + } + + if (str_contains($url, '/v5/media/media_video_789')) { + return Http::response(['status' => 'succeeded'], 200); + } + + if (str_contains($url, '/v5/pins')) { + return Http::response(['id' => 'video_pin_cover'], 200); + } + + return Http::response('fake-video-content', 200); }); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => $request->url() === config('trypost.platforms.pinterest.api').'/pins' + && data_get($request->data(), 'media_source.cover_image_url') === 'https://example.com/cover.jpg' + && data_get($request->data(), 'media_source.cover_image_key_frame_time') === null); }); test('pinterest publisher throws exception for unsupported content type', function () {