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:
Paulo Castellano 2026-05-09 12:47:49 -03:00
parent 9b42701a9d
commit 2c08da7787
13 changed files with 359 additions and 78 deletions

View file

@ -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,
);
}
}

View file

@ -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 ?? [];

View file

@ -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'],
]);
}

View file

@ -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…',

View file

@ -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…',

View file

@ -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…',

View file

@ -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>

View file

@ -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 mediacontent_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>

View file

@ -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',

View 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];

View file

@ -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

View 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']);
});

View file

@ -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);
});