feat(tiktok): photo carousel support + UX Content Sharing API compliance
## Photo carousel support - Adds `ContentType::TikTokPhoto` enum case (max 35 photos, 1:1 aspect, supportsImage true, supportsVideo false) and JS mirror in content-type.ts. - Variant pill picker (Video / Photo carousel) at the top of TikTokSettings, mirroring the Instagram pattern. Wired through ScheduleTab to the parent editor's existing update:platformContentType emit. - i18n keys for variant_label / variant.video / variant.photo in en/pt-BR/es. - Publisher: split buildPostInfo into buildVideoPostInfo (uses `title`, TikTok cap 2200 chars) and buildPhotoPostInfo (uses `description`, cap 4000 chars; omits Duet/Stitch/AIGC since they don't apply). Removed the no-longer-needed queryCreatorInfo() call from publishVideo/publishPhotos — its only previous consumer (silent privacy_level fallback) is gone. ## UX Content Sharing API compliance Per TikTok review feedback citing https://developers.tiktok.com/doc/content-sharing-guidelines#required_ux_implementation_in_your_app Point 1 — already satisfied (creator_info fetch + nickname display). Point 2/4 — Music Usage Confirmation declaration is now always visible in TikTokSettings; text changes between "Music Usage Confirmation" and "Branded Content Policy and Music Usage Confirmation" based on toggle state. Previously the entire `<p>` block was conditional on a brand toggle being selected, hiding the baseline declaration. Point 2b — privacy_level may not have a default. UI was already correct; backend hardened: UpdatePostRequest now requires meta.privacy_level for tiktok platforms when status is publishing/scheduled (via withValidator); TikTokPublisher::resolveRequiredPrivacyLevel throws TikTokPublishException (ContentPolicy category) when missing instead of silently falling back to the creator's preferred level. Point 2c — interaction settings now condition on content type: - Photo posts hide Duet/Stitch (they don't apply per TikTok docs). - Photo posts hide AIGC (also video-only). - Video posts hide Auto Add Music (photos-only feature). - Max-duration warning hidden when not a video post. Source of truth is the user-selected contentType prop, not inferred from media — ensures the UI reacts immediately to the variant pill. Point 3a — publish button stays disabled when Disclose toggle is on without a sub-selection (already the case via tiktokComplianceValid). The disabled tooltip now uses the verbatim TikTok-required text "You need to indicate if your content promotes yourself, a third party, or both." instead of the generic "Some platform settings are incomplete..." when the only blocker is TikTok disclosure incompleteness. Point 3b — SELF_ONLY (Only me) privacy option is no longer filtered out when Branded Content is checked. It is rendered disabled with a hover tooltip "Branded content visibility cannot be set to private." plus a persistent amber warning paragraph below the dropdown. When the user toggles Branded Content while privacy is SELF_ONLY, the privacy clears and a vue-sonner warning toast surfaces the change. ## Cross-cutting - New `resources/js/enums/platform.ts` mirrors the PHP Platform enum, used in Edit.vue (tiktokComplianceValid + tiktokDisclosureIncomplete) and ScheduleTab.vue (all selected*Platforms computeds) to replace string literal comparisons against `'tiktok'` / `'facebook'` / etc. - PostPlatformFactory tiktok() state defaults meta.privacy_level to SELF_ONLY so existing test fixtures keep passing under the new publisher/FormRequest requirements. ## Tests - New tests/Unit/Enums/PostPlatform/TikTokPhotoContentTypeTest.php covering the new enum case (4 tests). - TikTokPublisherTest: added "video uses title not description" and "throws when meta.privacy_level missing" regression tests; renamed two existing tests that depended on the removed silent fallback. - New tests/Feature/UpdatePostRequestTest.php with 3 tests covering the FormRequest's privacy_level enforcement (publish-rejected, publish-passes, draft-allowed). Full Pest suite: 1490 passed, 2 skipped (pre-existing).
This commit is contained in:
parent
9b42701a9d
commit
2c08da7787
13 changed files with 359 additions and 78 deletions
|
|
@ -6,9 +6,11 @@
|
|||
|
||||
use App\Enums\Post\Status;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Rules\ContentTypeCompatibleWithMedia;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class UpdatePostRequest extends FormRequest
|
||||
{
|
||||
|
|
@ -79,4 +81,44 @@ public function messages(): array
|
|||
'scheduled_at.after' => 'The scheduled date must be in the future.',
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function ($validator): void {
|
||||
if (! $this->isPublishingOrScheduling()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$platforms = $this->input('platforms', []);
|
||||
$ids = collect($platforms)->pluck('id')->filter()->all();
|
||||
|
||||
// Scope to the current post via the route-bound relation — defensive
|
||||
// (cross-post leakage is prevented and we use the existing relation
|
||||
// rather than a separate global query).
|
||||
$platformsById = $this->route('post')
|
||||
->postPlatforms()
|
||||
->whereIn('id', $ids)
|
||||
->pluck('platform', 'id');
|
||||
|
||||
foreach ($platforms as $i => $platform) {
|
||||
$platformEnum = $platformsById[data_get($platform, 'id')] ?? null;
|
||||
if ($platformEnum === Platform::TikTok
|
||||
&& blank(data_get($platform, 'meta.privacy_level'))) {
|
||||
$validator->errors()->add(
|
||||
"platforms.{$i}.meta.privacy_level",
|
||||
'TikTok privacy level is required when publishing.',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function isPublishingOrScheduling(): bool
|
||||
{
|
||||
return in_array(
|
||||
$this->input('status'),
|
||||
[Status::Scheduled->value, Status::Publishing->value],
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,26 +75,24 @@ private function getHttpClient(): PendingRequest
|
|||
return $this->socialHttp()->asJson()->withToken($this->accessToken);
|
||||
}
|
||||
|
||||
private function queryCreatorInfo(SocialAccount $account): array
|
||||
/**
|
||||
* Resolve the user-selected privacy_level from meta, throwing when missing.
|
||||
* TikTok UX Guideline Point 2b forbids any default — the user must pick
|
||||
* explicitly. The FormRequest validates this upstream; this is the safety
|
||||
* net for queue/job paths that bypass the request layer.
|
||||
*/
|
||||
private function resolveRequiredPrivacyLevel(PostPlatform $postPlatform): string
|
||||
{
|
||||
$info = app(TikTokCreatorInfo::class)->fetch($account);
|
||||
$privacyOptions = data_get($info, 'privacy_level_options') ?: ['SELF_ONLY'];
|
||||
$privacyLevel = data_get($postPlatform->meta ?? [], 'privacy_level');
|
||||
|
||||
// Prefer PUBLIC_TO_EVERYONE > MUTUAL_FOLLOW_FRIENDS > FOLLOWER_OF_CREATOR > SELF_ONLY
|
||||
$preferred = ['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'];
|
||||
|
||||
$privacyLevel = 'SELF_ONLY';
|
||||
foreach ($preferred as $level) {
|
||||
if (in_array($level, $privacyOptions)) {
|
||||
$privacyLevel = $level;
|
||||
break;
|
||||
}
|
||||
if (blank($privacyLevel)) {
|
||||
throw new TikTokPublishException(
|
||||
userMessage: 'TikTok privacy level is required. Please open the post and pick a visibility option.',
|
||||
category: ErrorCategory::ContentPolicy,
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'privacy_level' => $privacyLevel,
|
||||
'max_video_post_duration_sec' => data_get($info, 'max_video_post_duration_sec'),
|
||||
];
|
||||
return (string) $privacyLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -104,16 +102,13 @@ private function queryCreatorInfo(SocialAccount $account): array
|
|||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildVideoPostInfo(PostPlatform $postPlatform, ?string $content, array $creatorInfo): array
|
||||
private function buildVideoPostInfo(PostPlatform $postPlatform, ?string $content): array
|
||||
{
|
||||
$meta = $postPlatform->meta ?? [];
|
||||
|
||||
$privacyLevel = data_get($meta, 'privacy_level')
|
||||
?: data_get($creatorInfo, 'privacy_level', 'SELF_ONLY');
|
||||
|
||||
$postInfo = [
|
||||
'title' => $content ?? '',
|
||||
'privacy_level' => $privacyLevel,
|
||||
'privacy_level' => $this->resolveRequiredPrivacyLevel($postPlatform),
|
||||
'disable_duet' => ! data_get($meta, 'allow_duet', false),
|
||||
'disable_comment' => ! data_get($meta, 'allow_comments', true),
|
||||
'disable_stitch' => ! data_get($meta, 'allow_stitch', false),
|
||||
|
|
@ -142,16 +137,13 @@ private function buildVideoPostInfo(PostPlatform $postPlatform, ?string $content
|
|||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPhotoPostInfo(PostPlatform $postPlatform, ?string $content, array $creatorInfo): array
|
||||
private function buildPhotoPostInfo(PostPlatform $postPlatform, ?string $content): array
|
||||
{
|
||||
$meta = $postPlatform->meta ?? [];
|
||||
|
||||
$privacyLevel = data_get($meta, 'privacy_level')
|
||||
?: data_get($creatorInfo, 'privacy_level', 'SELF_ONLY');
|
||||
|
||||
$postInfo = [
|
||||
'description' => $content ?? '',
|
||||
'privacy_level' => $privacyLevel,
|
||||
'privacy_level' => $this->resolveRequiredPrivacyLevel($postPlatform),
|
||||
'disable_comment' => ! data_get($meta, 'allow_comments', true),
|
||||
];
|
||||
|
||||
|
|
@ -168,11 +160,9 @@ private function buildPhotoPostInfo(PostPlatform $postPlatform, ?string $content
|
|||
|
||||
private function publishVideo(PostPlatform $postPlatform, $media, ?string $content): array
|
||||
{
|
||||
$creatorInfo = $this->queryCreatorInfo($postPlatform->socialAccount);
|
||||
|
||||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/post/publish/video/init/", [
|
||||
'post_info' => $this->buildVideoPostInfo($postPlatform, $content, $creatorInfo),
|
||||
'post_info' => $this->buildVideoPostInfo($postPlatform, $content),
|
||||
'source_info' => [
|
||||
'source' => 'PULL_FROM_URL',
|
||||
'video_url' => $media->url,
|
||||
|
|
@ -223,9 +213,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st
|
|||
);
|
||||
}
|
||||
|
||||
$creatorInfo = $this->queryCreatorInfo($postPlatform->socialAccount);
|
||||
|
||||
$postInfo = $this->buildPhotoPostInfo($postPlatform, $content, $creatorInfo);
|
||||
$postInfo = $this->buildPhotoPostInfo($postPlatform, $content);
|
||||
|
||||
// Auto add music is only for photos.
|
||||
$meta = $postPlatform->meta ?? [];
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ public function tiktok(): static
|
|||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => Platform::TikTok,
|
||||
'content_type' => ContentType::TikTokVideo,
|
||||
'meta' => ['privacy_level' => 'SELF_ONLY'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@
|
|||
'write_caption' => 'Write your caption...',
|
||||
'tiktok' => [
|
||||
'settings' => 'TikTok Settings',
|
||||
'variant_label' => 'Post type',
|
||||
'variant' => [
|
||||
'video' => 'Video',
|
||||
'photo' => 'Photo carousel',
|
||||
],
|
||||
'posting_to' => 'Posting to',
|
||||
'privacy_level' => 'Who can see this video?',
|
||||
'privacy_placeholder' => 'Select visibility',
|
||||
|
|
@ -57,6 +62,7 @@
|
|||
'friends' => 'Mutual follow friends',
|
||||
'followers' => 'Followers',
|
||||
'private' => 'Only me',
|
||||
'private_disabled_branded' => 'Branded content visibility cannot be set to private.',
|
||||
],
|
||||
'privacy_hint' => 'The available options depend on your TikTok account settings.',
|
||||
'auto_add_music' => 'Auto add music',
|
||||
|
|
@ -74,7 +80,7 @@
|
|||
'promotional_paid_title' => 'Your photo/video will be labeled as "Paid partnership".',
|
||||
'promotional_description' => 'This cannot be changed once your video is posted.',
|
||||
'compliance_incomplete' => 'You need to indicate if your content promotes yourself, a third party, or both.',
|
||||
'branded_blocks_private' => 'Branded content cannot be private. Choose Public or Mutual follow friends.',
|
||||
'branded_cleared_private' => 'Privacy was cleared because Branded Content cannot be private.',
|
||||
'interaction_disabled_by_creator' => 'Disabled by your TikTok account settings.',
|
||||
'max_duration_exceeded' => 'Video is :duration s long but this account can only post videos up to :max s.',
|
||||
'creator_info_loading' => 'Loading your TikTok account settings…',
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@
|
|||
'write_caption' => 'Escribe tu descripción...',
|
||||
'tiktok' => [
|
||||
'settings' => 'Configuración de TikTok',
|
||||
'variant_label' => 'Tipo de publicación',
|
||||
'variant' => [
|
||||
'video' => 'Video',
|
||||
'photo' => 'Carrusel de fotos',
|
||||
],
|
||||
'posting_to' => 'Publicando en',
|
||||
'privacy_level' => '¿Quién puede ver este video?',
|
||||
'privacy_placeholder' => 'Selecciona la visibilidad',
|
||||
|
|
@ -57,6 +62,7 @@
|
|||
'friends' => 'Amigos mutuos',
|
||||
'followers' => 'Seguidores',
|
||||
'private' => 'Solo yo',
|
||||
'private_disabled_branded' => 'El contenido de marca no puede ser privado.',
|
||||
],
|
||||
'privacy_hint' => 'Las opciones disponibles dependen de la configuración de tu cuenta de TikTok.',
|
||||
'auto_add_music' => 'Agregar música automáticamente',
|
||||
|
|
@ -74,7 +80,7 @@
|
|||
'promotional_paid_title' => 'Tu foto/video será etiquetado como "Asociación pagada".',
|
||||
'promotional_description' => 'Esto no se puede cambiar una vez publicado el video.',
|
||||
'compliance_incomplete' => 'Debes indicar si tu contenido promociona a ti mismo, a un tercero o a ambos.',
|
||||
'branded_blocks_private' => 'El contenido patrocinado no puede ser privado. Elige Público o Amigos mutuos.',
|
||||
'branded_cleared_private' => 'La visibilidad se borró porque el contenido de marca no puede ser privado.',
|
||||
'interaction_disabled_by_creator' => 'Desactivado por la configuración de tu cuenta TikTok.',
|
||||
'max_duration_exceeded' => 'El video dura :duration s pero esta cuenta solo permite videos de hasta :max s.',
|
||||
'creator_info_loading' => 'Cargando la configuración de tu cuenta TikTok…',
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@
|
|||
'write_caption' => 'Escreva sua legenda...',
|
||||
'tiktok' => [
|
||||
'settings' => 'Configurações do TikTok',
|
||||
'variant_label' => 'Tipo de publicação',
|
||||
'variant' => [
|
||||
'video' => 'Vídeo',
|
||||
'photo' => 'Carrossel de fotos',
|
||||
],
|
||||
'posting_to' => 'Publicando em',
|
||||
'privacy_level' => 'Quem pode ver este vídeo?',
|
||||
'privacy_placeholder' => 'Selecione a visibilidade',
|
||||
|
|
@ -57,6 +62,7 @@
|
|||
'friends' => 'Amigos em comum',
|
||||
'followers' => 'Seguidores',
|
||||
'private' => 'Apenas eu',
|
||||
'private_disabled_branded' => 'Conteúdo de marca não pode ser privado.',
|
||||
],
|
||||
'privacy_hint' => 'As opções disponíveis dependem das configurações da sua conta TikTok.',
|
||||
'auto_add_music' => 'Adicionar música automaticamente',
|
||||
|
|
@ -74,7 +80,7 @@
|
|||
'promotional_paid_title' => 'Sua foto/vídeo será rotulado como "Parceria paga".',
|
||||
'promotional_description' => 'Isso não poderá ser alterado após a publicação.',
|
||||
'compliance_incomplete' => 'Você precisa indicar se o conteúdo promove você mesmo, terceiros ou ambos.',
|
||||
'branded_blocks_private' => 'Conteúdo patrocinado não pode ser privado. Escolha Público ou Amigos em comum.',
|
||||
'branded_cleared_private' => 'A visibilidade foi limpa porque conteúdo de marca não pode ser privado.',
|
||||
'interaction_disabled_by_creator' => 'Desativado pelas configurações da sua conta TikTok.',
|
||||
'max_duration_exceeded' => 'Vídeo tem :duration s mas esta conta só permite vídeos de até :max s.',
|
||||
'creator_info_loading' => 'Carregando configurações da sua conta TikTok…',
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Avatar } from '@/components/ui/avatar';
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import { Platform } from '@/enums/platform';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
@ -97,33 +98,33 @@ const emit = defineEmits<{
|
|||
|
||||
const selectedTikTokPlatforms = computed(() =>
|
||||
props.postPlatforms.filter(
|
||||
(pp) => pp.platform === 'tiktok' && props.selectedPlatformIds.includes(pp.id),
|
||||
(pp) => pp.platform === Platform.TikTok && props.selectedPlatformIds.includes(pp.id),
|
||||
),
|
||||
);
|
||||
|
||||
const selectedInstagramPlatforms = computed(() =>
|
||||
props.postPlatforms.filter(
|
||||
(pp) => ['instagram', 'instagram-facebook'].includes(pp.platform)
|
||||
(pp) => (pp.platform === Platform.Instagram || pp.platform === Platform.InstagramFacebook)
|
||||
&& props.selectedPlatformIds.includes(pp.id),
|
||||
),
|
||||
);
|
||||
|
||||
const selectedFacebookPlatforms = computed(() =>
|
||||
props.postPlatforms.filter(
|
||||
(pp) => pp.platform === 'facebook' && props.selectedPlatformIds.includes(pp.id),
|
||||
(pp) => pp.platform === Platform.Facebook && props.selectedPlatformIds.includes(pp.id),
|
||||
),
|
||||
);
|
||||
|
||||
const selectedLinkedInPlatforms = computed(() =>
|
||||
props.postPlatforms.filter(
|
||||
(pp) => ['linkedin', 'linkedin-page'].includes(pp.platform)
|
||||
(pp) => (pp.platform === Platform.LinkedIn || pp.platform === Platform.LinkedInPage)
|
||||
&& props.selectedPlatformIds.includes(pp.id),
|
||||
),
|
||||
);
|
||||
|
||||
const selectedPinterestPlatforms = computed(() =>
|
||||
props.postPlatforms.filter(
|
||||
(pp) => pp.platform === 'pinterest' && props.selectedPlatformIds.includes(pp.id),
|
||||
(pp) => pp.platform === Platform.Pinterest && props.selectedPlatformIds.includes(pp.id),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -267,8 +268,10 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
|
|||
:creator-info="getCreatorInfo(pp)"
|
||||
:creator-info-loading="tiktokCreatorInfos === undefined || tiktokCreatorInfos === null"
|
||||
:video-duration-sec="videoDurationSec"
|
||||
:content-type="platformContentTypes[pp.id] ?? ''"
|
||||
:meta="platformMeta[pp.id] ?? {}"
|
||||
:disabled="isReadOnly"
|
||||
@update:content-type="emit('update:platformContentType', pp.id, $event)"
|
||||
@update:meta="emit('update:platformMeta', pp.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
|
@ -13,6 +15,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import { ContentType } from '@/enums/content-type';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
@ -39,6 +42,7 @@ interface Props {
|
|||
creatorInfo?: CreatorInfo | null;
|
||||
creatorInfoLoading?: boolean;
|
||||
videoDurationSec?: number | null;
|
||||
contentType: string;
|
||||
meta: Record<string, any>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
|
@ -52,8 +56,19 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
|
||||
const emit = defineEmits<{
|
||||
'update:meta': [value: Record<string, any>];
|
||||
'update:contentType': [value: string];
|
||||
}>();
|
||||
|
||||
const variants = [
|
||||
{ value: ContentType.TikTokVideo, labelKey: 'posts.form.tiktok.variant.video' },
|
||||
{ value: ContentType.TikTokPhoto, labelKey: 'posts.form.tiktok.variant.photo' },
|
||||
] as const;
|
||||
|
||||
const pickVariant = (value: string) => {
|
||||
if (props.disabled) return;
|
||||
emit('update:contentType', value);
|
||||
};
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const updateMeta = (patch: Record<string, any>) => {
|
||||
|
|
@ -118,17 +133,24 @@ const allPrivacyOptions = computed(() => {
|
|||
return fromApi.length > 0 ? fromApi : props.publishConfig?.privacyLevelOptions ?? [];
|
||||
});
|
||||
|
||||
// Branded content cannot be private (TikTok compliance).
|
||||
const privacyOptions = computed(() =>
|
||||
brandContentToggle.value
|
||||
? allPrivacyOptions.value.filter((o: string) => o !== 'SELF_ONLY')
|
||||
: allPrivacyOptions.value,
|
||||
);
|
||||
// Render every option creator_info returns. SELF_ONLY is shown but disabled when
|
||||
// Branded Content is checked (TikTok UX Guideline Point 3b — must show interaction,
|
||||
// not hide it).
|
||||
const privacyOptions = computed(() => allPrivacyOptions.value);
|
||||
|
||||
const isSelfOnlyDisabled = (option: string): boolean =>
|
||||
option === 'SELF_ONLY' && brandContentToggle.value;
|
||||
|
||||
const commentDisabled = computed(() => Boolean(props.creatorInfo?.comment_disabled));
|
||||
const duetDisabled = computed(() => Boolean(props.creatorInfo?.duet_disabled));
|
||||
const stitchDisabled = computed(() => Boolean(props.creatorInfo?.stitch_disabled));
|
||||
|
||||
// Derive from the user's explicit variant choice (contentType), not from attached
|
||||
// media. The variant selector is the single source of truth — user picks it, the
|
||||
// gating responds immediately, and validation enforces media↔content_type matching.
|
||||
const isPhotoPost = computed(() => props.contentType === ContentType.TikTokPhoto);
|
||||
const isVideoPost = computed(() => props.contentType === ContentType.TikTokVideo);
|
||||
|
||||
// Max video duration check (when creator_info is available and we have duration).
|
||||
const maxDurationSec = computed(() => props.creatorInfo?.max_video_post_duration_sec ?? null);
|
||||
const exceedsMaxDuration = computed(() => {
|
||||
|
|
@ -152,9 +174,12 @@ const promotionalTitleKey = computed(() =>
|
|||
);
|
||||
|
||||
// If user flips branded content ON while privacy is SELF_ONLY, clear it so they must re-pick.
|
||||
// Surface a toast so the user understands why the field reset (TikTok UX Guideline Point 3b
|
||||
// requires informing the user when an auto-switch happens).
|
||||
watch(brandContentToggle, (value) => {
|
||||
if (value && privacyLevel.value === 'SELF_ONLY') {
|
||||
privacyLevel.value = '';
|
||||
toast.warning(trans('posts.form.tiktok.branded_cleared_private'));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -191,6 +216,26 @@ watch(
|
|||
</button>
|
||||
|
||||
<div v-if="open" class="space-y-5 border-t-2 border-foreground/10 px-4 pb-4 pt-4">
|
||||
<!-- Variant: Video / Photo carousel -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.tiktok.variant_label') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="variant in variants"
|
||||
:key="variant.value"
|
||||
type="button"
|
||||
class="cursor-pointer rounded-full border-2 px-3 py-1 text-xs font-bold uppercase tracking-widest transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="contentType === variant.value
|
||||
? 'border-foreground bg-violet-100 text-foreground shadow-2xs'
|
||||
: 'border-foreground/30 text-foreground/70 hover:border-foreground hover:text-foreground'"
|
||||
:disabled="props.disabled"
|
||||
@click="pickVariant(variant.value)"
|
||||
>
|
||||
{{ $t(variant.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="creatorInfoLoading" class="flex items-center gap-2 text-xs font-medium text-foreground/60">
|
||||
<span class="inline-block size-3 animate-pulse rounded-full bg-foreground/30" />
|
||||
{{ $t('posts.form.tiktok.creator_info_loading') }}
|
||||
|
|
@ -220,22 +265,35 @@ watch(
|
|||
<SelectValue :placeholder="$t('posts.form.tiktok.privacy_placeholder')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in privacyOptions" :key="option" :value="option">
|
||||
<SelectItem
|
||||
v-for="option in privacyOptions"
|
||||
:key="option"
|
||||
:value="option"
|
||||
:disabled="isSelfOnlyDisabled(option)"
|
||||
:title="isSelfOnlyDisabled(option) ? $t('posts.form.tiktok.privacy.private_disabled_branded') : undefined"
|
||||
>
|
||||
{{ $t(privacyLabelKey[option] ?? option) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-xs font-medium text-foreground/60">{{ $t("posts.form.tiktok.privacy_hint") }}</p>
|
||||
<p
|
||||
v-if="brandContentToggle"
|
||||
class="flex items-start gap-1.5 rounded-md border-2 border-foreground bg-amber-50 p-2 text-xs font-semibold text-amber-800"
|
||||
>
|
||||
<IconAlertTriangle class="mt-0.5 size-3.5 shrink-0" />
|
||||
{{ $t('posts.form.tiktok.privacy.private_disabled_branded') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Max duration warning -->
|
||||
<p v-if="exceedsMaxDuration" class="flex items-start gap-2 rounded-lg border-2 border-foreground bg-rose-50 p-2 text-xs font-semibold text-rose-700">
|
||||
<p v-if="isVideoPost && exceedsMaxDuration" class="flex items-start gap-2 rounded-lg border-2 border-foreground bg-rose-50 p-2 text-xs font-semibold text-rose-700">
|
||||
<IconAlertTriangle class="mt-0.5 size-3.5 shrink-0" />
|
||||
{{ $t('posts.form.tiktok.max_duration_exceeded', { duration: String(videoDurationSec ?? 0), max: String(maxDurationSec ?? 0) }) }}
|
||||
</p>
|
||||
|
||||
<!-- Auto Add Music (photos only) -->
|
||||
<div class="space-y-2">
|
||||
<div v-if="isPhotoPost" class="space-y-2">
|
||||
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t("posts.form.tiktok.auto_add_music") }}</Label>
|
||||
<Select v-model="autoAddMusic" :disabled="props.disabled">
|
||||
<SelectTrigger class="w-full">
|
||||
|
|
@ -257,21 +315,23 @@ watch(
|
|||
<Checkbox v-model="allowComments" :disabled="props.disabled || commentDisabled" />
|
||||
{{ $t('posts.form.tiktok.comments') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm" :class="{ 'opacity-50': duetDisabled }" :title="duetDisabled ? $t('posts.form.tiktok.interaction_disabled_by_creator') : ''">
|
||||
<Checkbox v-model="allowDuet" :disabled="props.disabled || duetDisabled" />
|
||||
{{ $t('posts.form.tiktok.duet') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm" :class="{ 'opacity-50': stitchDisabled }" :title="stitchDisabled ? $t('posts.form.tiktok.interaction_disabled_by_creator') : ''">
|
||||
<Checkbox v-model="allowStitch" :disabled="props.disabled || stitchDisabled" />
|
||||
{{ $t('posts.form.tiktok.stitch') }}
|
||||
</label>
|
||||
<template v-if="!isPhotoPost">
|
||||
<label class="flex items-center gap-2 text-sm" :class="{ 'opacity-50': duetDisabled }" :title="duetDisabled ? $t('posts.form.tiktok.interaction_disabled_by_creator') : ''">
|
||||
<Checkbox v-model="allowDuet" :disabled="props.disabled || duetDisabled" />
|
||||
{{ $t('posts.form.tiktok.duet') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm" :class="{ 'opacity-50': stitchDisabled }" :title="stitchDisabled ? $t('posts.form.tiktok.interaction_disabled_by_creator') : ''">
|
||||
<Checkbox v-model="allowStitch" :disabled="props.disabled || stitchDisabled" />
|
||||
{{ $t('posts.form.tiktok.stitch') }}
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t-2 border-foreground/10" />
|
||||
|
||||
<!-- Video made with AI (independent) -->
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<label v-if="!isPhotoPost" class="flex items-center gap-2 text-sm">
|
||||
<Checkbox v-model="isAigc" :disabled="props.disabled" />
|
||||
{{ $t('posts.form.tiktok.is_aigc') }}
|
||||
</label>
|
||||
|
|
@ -317,19 +377,10 @@ watch(
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compliance declaration -->
|
||||
<p v-if="hasAnyBrandToggle" class="text-xs text-muted-foreground">
|
||||
<!-- Compliance declaration — always visible per TikTok UX guideline Point 2/4 -->
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ $t('posts.form.tiktok.compliance.agree') }}
|
||||
<a
|
||||
:href="publishConfig?.musicUsageConfirmationUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-bold text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
{{ $t('posts.form.tiktok.compliance.music_usage') }}
|
||||
</a>
|
||||
<template v-if="brandContentToggle">
|
||||
{{ ' ' + $t('posts.form.tiktok.compliance.and') + ' ' }}
|
||||
<a
|
||||
:href="publishConfig?.brandedContentPolicyUrl"
|
||||
target="_blank"
|
||||
|
|
@ -338,7 +389,16 @@ watch(
|
|||
>
|
||||
{{ $t('posts.form.tiktok.compliance.branded_policy') }}
|
||||
</a>
|
||||
{{ ' ' + $t('posts.form.tiktok.compliance.and') + ' ' }}
|
||||
</template>
|
||||
<a
|
||||
:href="publishConfig?.musicUsageConfirmationUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-bold text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
{{ $t('posts.form.tiktok.compliance.music_usage') }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export const ContentType = {
|
|||
FacebookReel: 'facebook_reel',
|
||||
FacebookStory: 'facebook_story',
|
||||
TikTokVideo: 'tiktok_video',
|
||||
TikTokPhoto: 'tiktok_photo',
|
||||
YouTubeShort: 'youtube_short',
|
||||
XPost: 'x_post',
|
||||
ThreadsPost: 'threads_post',
|
||||
|
|
|
|||
16
resources/js/enums/platform.ts
Normal file
16
resources/js/enums/platform.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export const Platform = {
|
||||
LinkedIn: 'linkedin',
|
||||
LinkedInPage: 'linkedin-page',
|
||||
X: 'x',
|
||||
TikTok: 'tiktok',
|
||||
YouTube: 'youtube',
|
||||
Facebook: 'facebook',
|
||||
Instagram: 'instagram',
|
||||
InstagramFacebook: 'instagram-facebook',
|
||||
Threads: 'threads',
|
||||
Pinterest: 'pinterest',
|
||||
Bluesky: 'bluesky',
|
||||
Mastodon: 'mastodon',
|
||||
} as const;
|
||||
|
||||
export type PlatformValue = (typeof Platform)[keyof typeof Platform];
|
||||
|
|
@ -22,6 +22,7 @@ import { getMediaItemIssue } from '@/composables/useMedia';
|
|||
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
|
||||
import dayjs from '@/dayjs';
|
||||
import debounce from '@/debounce';
|
||||
import { Platform } from '@/enums/platform';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { destroy as destroyPost, update as updatePost } from '@/routes/app/posts';
|
||||
|
||||
|
|
@ -238,7 +239,7 @@ const mediaCompliancePerPlatformValid = computed(
|
|||
// - if disclosure toggle is ON, at least one sub-toggle must be selected
|
||||
const tiktokComplianceValid = computed(() => {
|
||||
const tiktokPlatforms = post.value.post_platforms.filter(
|
||||
(pp) => pp.platform === 'tiktok' && selectedPlatformIds.value.includes(pp.id),
|
||||
(pp) => pp.platform === Platform.TikTok && selectedPlatformIds.value.includes(pp.id),
|
||||
);
|
||||
return tiktokPlatforms.every((pp) => {
|
||||
const meta = platformMeta.value[pp.id] ?? {};
|
||||
|
|
@ -255,13 +256,29 @@ const canSchedule = computed(
|
|||
const postActionTooltip = computed(() => {
|
||||
if (canSchedule.value) return '';
|
||||
|
||||
// Collect platform-specific media compatibility issues.
|
||||
const reasons = post.value.post_platforms
|
||||
.filter((pp) => selectedPlatformIds.value.includes(pp.id) && platformIssues.value[pp.id])
|
||||
.map((pp) => `${pp.platform_name ?? pp.platform}: ${platformIssues.value[pp.id]}`);
|
||||
|
||||
if (reasons.length === 0) return trans('posts.edit.compliance_incomplete');
|
||||
if (reasons.length > 0) return reasons.join('\n');
|
||||
|
||||
return reasons.join('\n');
|
||||
// No media issues — the only remaining blocker is TikTok compliance.
|
||||
// Surface the exact TikTok-required tooltip when the disclosure toggle
|
||||
// is on without a sub-selection (UX Guideline Point 3a). For other TikTok
|
||||
// cases (e.g. missing privacy_level), fall back to the generic message.
|
||||
const tiktokDisclosureIncomplete = post.value.post_platforms.some((pp) => {
|
||||
if (pp.platform !== Platform.TikTok) return false;
|
||||
if (!selectedPlatformIds.value.includes(pp.id)) return false;
|
||||
const meta = platformMeta.value[pp.id] ?? {};
|
||||
return Boolean(meta.disclose) && !meta.brand_organic_toggle && !meta.brand_content_toggle;
|
||||
});
|
||||
|
||||
if (tiktokDisclosureIncomplete) {
|
||||
return trans('posts.form.tiktok.compliance_incomplete');
|
||||
}
|
||||
|
||||
return trans('posts.edit.compliance_incomplete');
|
||||
});
|
||||
|
||||
// Schedule
|
||||
|
|
|
|||
97
tests/Feature/UpdatePostRequestTest.php
Normal file
97
tests/Feature/UpdatePostRequestTest.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Post\Status;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
|
||||
$this->post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
// Media payload used by tests that need to satisfy ContentTypeCompatibleWithMedia.
|
||||
$this->mediaPayload = [
|
||||
[
|
||||
'id' => 'test-media-video',
|
||||
'path' => 'media/2026-01/test-video.mp4',
|
||||
'url' => 'https://example.com/media/2026-01/test-video.mp4',
|
||||
'type' => 'video',
|
||||
'mime_type' => 'video/mp4',
|
||||
'original_filename' => 'test-video.mp4',
|
||||
],
|
||||
];
|
||||
$this->socialAccount = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::TikTok,
|
||||
]);
|
||||
$this->postPlatform = PostPlatform::factory()->tiktok()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'social_account_id' => $this->socialAccount->id,
|
||||
// Override factory default so we control privacy_level per test.
|
||||
'meta' => [],
|
||||
]);
|
||||
});
|
||||
|
||||
test('publishing a tiktok post without privacy_level is rejected', function () {
|
||||
$response = $this->actingAs($this->user)
|
||||
->put(route('app.posts.update', $this->post), [
|
||||
'status' => Status::Publishing->value,
|
||||
'media' => $this->mediaPayload,
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $this->postPlatform->id,
|
||||
'content_type' => ContentType::TikTokVideo->value,
|
||||
'meta' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('platforms.0.meta.privacy_level');
|
||||
});
|
||||
|
||||
test('publishing a tiktok post with privacy_level passes privacy_level validation', function () {
|
||||
$response = $this->actingAs($this->user)
|
||||
->put(route('app.posts.update', $this->post), [
|
||||
'status' => Status::Publishing->value,
|
||||
'media' => $this->mediaPayload,
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $this->postPlatform->id,
|
||||
'content_type' => ContentType::TikTokVideo->value,
|
||||
'meta' => ['privacy_level' => 'SELF_ONLY'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionDoesntHaveErrors(['platforms.0.meta.privacy_level']);
|
||||
});
|
||||
|
||||
test('saving a tiktok post as draft without privacy_level skips the privacy_level rule', function () {
|
||||
$response = $this->actingAs($this->user)
|
||||
->put(route('app.posts.update', $this->post), [
|
||||
'status' => Status::Draft->value,
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $this->postPlatform->id,
|
||||
'content_type' => ContentType::TikTokVideo->value,
|
||||
'meta' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionDoesntHaveErrors(['platforms.0.meta.privacy_level']);
|
||||
});
|
||||
|
|
@ -310,7 +310,10 @@
|
|||
expect($result['url'])->toBeNull();
|
||||
});
|
||||
|
||||
test('tiktok publisher falls back to self only when creator info fails', function () {
|
||||
test('tiktok publisher publishes with user-selected privacy level even when creator info query fails', function () {
|
||||
// User has explicitly selected SELF_ONLY in meta. creator_info failure must not block publishing.
|
||||
$this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]);
|
||||
|
||||
$this->post->update([
|
||||
'media' => [
|
||||
[
|
||||
|
|
@ -324,7 +327,7 @@
|
|||
]);
|
||||
|
||||
Http::fake([
|
||||
// creator_info/query returns 500 — publisher should fall back to SELF_ONLY
|
||||
// creator_info/query returns 500 — should not affect publishing since user picked privacy_level.
|
||||
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
|
||||
'error' => ['code' => 'internal_error', 'message' => 'Internal server error'],
|
||||
], 500),
|
||||
|
|
@ -344,7 +347,7 @@
|
|||
expect($result)->toHaveKey('id');
|
||||
expect($result['id'])->toBe('pub_fallback_123');
|
||||
|
||||
// Assert SELF_ONLY was used in the video init payload
|
||||
// Assert SELF_ONLY (user's explicit pick) was used in the video init payload
|
||||
Http::assertSent(function ($request) {
|
||||
if (! str_contains($request->url(), '/post/publish/video/init/')) {
|
||||
return false;
|
||||
|
|
@ -538,8 +541,9 @@
|
|||
});
|
||||
});
|
||||
|
||||
test('tiktok publisher uses default settings when meta is empty', function () {
|
||||
$this->postPlatform->update(['meta' => null]);
|
||||
test('tiktok publisher uses default settings when only privacy_level is set', function () {
|
||||
// Only privacy_level is set (required); all other meta keys absent — exercise default toggles.
|
||||
$this->postPlatform->update(['meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE']]);
|
||||
|
||||
$this->post->update([
|
||||
'media' => [
|
||||
|
|
@ -576,7 +580,7 @@
|
|||
$body = json_decode($request->body(), true);
|
||||
$postInfo = data_get($body, 'post_info');
|
||||
|
||||
// When meta is empty, uses creator_info privacy and defaults
|
||||
// privacy_level passes through; toggles use safe defaults (duet/stitch off, comments on).
|
||||
return $postInfo['privacy_level'] === 'PUBLIC_TO_EVERYONE'
|
||||
&& $postInfo['disable_comment'] === false
|
||||
&& $postInfo['disable_duet'] === true
|
||||
|
|
@ -627,3 +631,37 @@
|
|||
&& ! isset($body['post_info']['description']);
|
||||
});
|
||||
});
|
||||
|
||||
test('tiktok publisher throws when meta.privacy_level is missing and user did not pick', function () {
|
||||
$this->post->update([
|
||||
'media' => [
|
||||
[
|
||||
'id' => 'test-media-video',
|
||||
'path' => 'media/2026-01/test-video.mp4',
|
||||
'url' => 'https://example.com/media/2026-01/test-video.mp4',
|
||||
'mime_type' => 'video/mp4',
|
||||
'original_filename' => 'test-video.mp4',
|
||||
],
|
||||
],
|
||||
]);
|
||||
// Explicitly clear privacy_level from meta (simulate UI never set it).
|
||||
$this->postPlatform->update(['meta' => []]);
|
||||
|
||||
Http::fake([
|
||||
// creator_info returns a healthy response — fallback would have silently picked PUBLIC_TO_EVERYONE.
|
||||
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
|
||||
'data' => [
|
||||
'creator_nickname' => 'test',
|
||||
'creator_username' => 'test',
|
||||
'privacy_level_options' => ['PUBLIC_TO_EVERYONE', 'SELF_ONLY'],
|
||||
'comment_disabled' => false,
|
||||
'duet_disabled' => false,
|
||||
'stitch_disabled' => false,
|
||||
'max_video_post_duration_sec' => 300,
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
expect(fn () => $this->publisher->publish($this->postPlatform))
|
||||
->toThrow(TikTokPublishException::class);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue