chore: working
This commit is contained in:
parent
b3b59b4d13
commit
35646bbaf6
18 changed files with 1180 additions and 631 deletions
333
TIKTOK_REVIEW_VIDEO_SCRIPT.md
Normal file
333
TIKTOK_REVIEW_VIDEO_SCRIPT.md
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
# TikTok Direct Post API — Review Video Script
|
||||
|
||||
Script for re-submitting trypost to TikTok's Direct Post API audit after the initial rejection. Based on https://developers.tiktok.com/doc/content-sharing-guidelines/
|
||||
|
||||
Goal: in ~2–3 minutes of screen recording, prove **every** compliance requirement visually. Narration in English (reviewers are in English-speaking teams).
|
||||
|
||||
---
|
||||
|
||||
## 0. Pre-recording checklist
|
||||
|
||||
Before hitting record, set up the state so you can flow through the script without pauses.
|
||||
|
||||
### Account / environment
|
||||
- [ ] A **real TikTok account** connected to trypost (use your personal, not a test stub)
|
||||
- [ ] Post log in production (`app.trypost.it`) — **not** localhost. Reviewers distrust `.test` domains
|
||||
- [ ] `npm run build` deployed. Browser cache cleared (hard reload)
|
||||
- [ ] Dev Tools closed, clean Chrome profile (no extensions bar visible)
|
||||
|
||||
### Content to upload
|
||||
- [ ] One short vertical video (15–30s, 720p+) — original content. **Not** reposted from another platform
|
||||
- [ ] A caption draft written (avoid preset auto-text — reviewer must see it's editable)
|
||||
- [ ] Optional: a long video (>max duration of your account) to demonstrate the duration warning
|
||||
|
||||
### Workspace state
|
||||
- [ ] Have a **second TikTok account** also connected — the video should demonstrate multi-account support (each collapse is per-account)
|
||||
- [ ] Confirm `creator_info` is actually loading (watch Network tab once before recording)
|
||||
|
||||
### Recording tool
|
||||
- Mac: QuickTime > File > New Screen Recording (record cursor + microphone)
|
||||
- Resolution: 1440p minimum, browser window at 80% scale
|
||||
- Narration: headset mic, quiet room
|
||||
|
||||
---
|
||||
|
||||
## 1. Opening shot (0:00–0:10)
|
||||
|
||||
**Screen:** trypost dashboard home (`/calendar` or `/posts`).
|
||||
|
||||
**Narration:**
|
||||
> "Hi TikTok team. I'm demonstrating the trypost Direct Post integration. trypost is a social media scheduler for authentic creators. Let me walk you through the posting flow for TikTok."
|
||||
|
||||
**Why:** establishes you're not copying from another platform and the app is a scheduler for original content (addresses "Intended Use Requirements" in the doc).
|
||||
|
||||
---
|
||||
|
||||
## 2. TikTok authorization flow (0:10–0:30)
|
||||
|
||||
**Required by the submission form: "User flow of TikTok authorization page".**
|
||||
|
||||
**Screen:** navigate to `/accounts` with no TikTok account connected yet.
|
||||
|
||||
**Action:**
|
||||
1. Click "Connect TikTok" button
|
||||
2. Wait for TikTok's consent screen to load
|
||||
3. Show the scopes list on TikTok's page
|
||||
4. Click "Authorize"
|
||||
5. Return to trypost with the account now connected (avatar + @handle visible)
|
||||
|
||||
**Narration:**
|
||||
> "To use trypost with TikTok, the creator first authorizes our app. Clicking Connect TikTok takes me to TikTok's official consent page, where I can see the exact scopes we request: user profile, basic info, stats, and video publishing permissions. Once I authorize, I'm returned to trypost and my TikTok account is connected."
|
||||
|
||||
**What MUST be visible:**
|
||||
- trypost's Connect TikTok button
|
||||
- TikTok's native authorization page (not a clone — the real `open-api.tiktok.com/platform/oauth/connect/` screen)
|
||||
- The scope list on TikTok's page
|
||||
- Return redirect back to trypost with the account populated
|
||||
|
||||
## 3. Open post editor — creator_info fetched (0:30–0:45)
|
||||
|
||||
**Screen:** click "Create post" or open an existing draft.
|
||||
|
||||
**Action:** just wait 1–2s for the editor to render.
|
||||
|
||||
**Narration:**
|
||||
> "Now I'll open the post editor. When it loads, trypost automatically fetches the latest creator_info for my TikTok account, so all visibility options and interaction settings you'll see reflect my current account state."
|
||||
|
||||
**What MUST be visible:**
|
||||
- The post editor with the TikTok account nickname visible
|
||||
- Optional: DevTools Network tab briefly showing the `post/publish/creator_info/query/` call
|
||||
|
||||
---
|
||||
|
||||
## 3. Compose + media upload (0:25–0:40)
|
||||
|
||||
**Screen:** post composer.
|
||||
|
||||
**Action:**
|
||||
1. Type a caption manually (e.g. "Testing the TikTok Direct Post integration")
|
||||
2. Upload the video
|
||||
|
||||
**Narration:**
|
||||
> "I'll write my caption here — notice the caption field is fully editable; trypost does not inject any preset text or hashtags. Now I'll add my video, which I recorded myself."
|
||||
|
||||
**What MUST be visible:**
|
||||
- Caption is user-typed (no auto-fill)
|
||||
- Media upload visible
|
||||
- No watermarks or promotional logos added anywhere by trypost
|
||||
|
||||
---
|
||||
|
||||
## 4. Open the Schedule tab + show creator nickname (0:40–0:55)
|
||||
|
||||
**Screen:** right sidebar, click "Schedule" tab.
|
||||
|
||||
**Action:** click on the TikTok platform in "Publish to" to select it.
|
||||
|
||||
**Narration:**
|
||||
> "I'll select my TikTok account to publish to. Notice the creator's nickname is displayed here so I know exactly which TikTok account this will post to."
|
||||
|
||||
**What MUST be visible:**
|
||||
- The TikTok platform card with **avatar + nickname visible** (not hidden in tooltip)
|
||||
- Account visibly selected (primary ring)
|
||||
|
||||
---
|
||||
|
||||
## 5. Expand TikTok Settings — walk through each compliance field (0:55–1:45)
|
||||
|
||||
**Screen:** click to expand "TikTok Settings" collapse.
|
||||
|
||||
**Narration (go slowly here — this is the compliance core):**
|
||||
|
||||
### 5.1 Creator identity
|
||||
> "Inside the settings, I see again which account I'm posting to: my nickname and handle."
|
||||
|
||||
### 5.2 Privacy level
|
||||
**Action:** click the "Who can see this video?" dropdown — reveal it's empty with "Select visibility" placeholder.
|
||||
|
||||
> "Privacy is required — there is no default value. The user must explicitly choose a visibility. The options listed come directly from TikTok's creator_info API response, so only the visibility levels that my account can actually use are shown."
|
||||
|
||||
**Key moment:** hover the dropdown open so the reviewer sees the options list.
|
||||
|
||||
### 5.3 Interaction checkboxes
|
||||
> "Next, the interaction settings — Comments, Duet, and Stitch. None of them are checked by default; I opt in to each."
|
||||
|
||||
**Key moment:** if any of them are greyed out on your account (because you disabled them in TikTok app settings), hover over one and show the tooltip *"Disabled by your TikTok account settings"*.
|
||||
|
||||
> "If any of these are disabled on my TikTok account itself, trypost respects that — the checkbox is greyed out and I cannot enable it."
|
||||
|
||||
### 5.4 Video made with AI
|
||||
> "There's an independent toggle to declare if the video was made with AI — off by default."
|
||||
|
||||
### 5.5 Disclose video content toggle (CRITICAL SECTION)
|
||||
**Action:** click the "Disclose video content" checkbox.
|
||||
|
||||
> "This is the commercial content disclosure toggle. It starts OFF by default, as required. I'll turn it on to demonstrate."
|
||||
|
||||
**What happens when ON:** sub-toggles "Your brand" and "Branded content" appear; a reminder text says *"You need to indicate if your content promotes yourself, a third party, or both"*.
|
||||
|
||||
**Action:** scroll up to the top of the page — point at the **disabled** Schedule/Post now button.
|
||||
|
||||
> "Right now, with the disclosure toggle on but no sub-option selected, the publish button is disabled, matching TikTok's requirement."
|
||||
|
||||
**Action:** back to the settings, click "Your brand" sub-toggle.
|
||||
|
||||
> "If I select Your brand, TikTok will classify this as Brand Organic. The disclosure reads: 'Your photo or video will be labeled as Promotional content'. Notice the agreement text below: 'By posting, you agree to TikTok's Music Usage Confirmation'."
|
||||
|
||||
**Action:** un-select "Your brand", click "Branded content" instead.
|
||||
|
||||
> "If I switch to Branded content, the label changes to 'Paid partnership', and the agreement now includes TikTok's Branded Content Policy, as required."
|
||||
|
||||
**Action:** open the privacy dropdown again.
|
||||
|
||||
> "And because branded content cannot be posted privately, the 'Only me' option is no longer available. If I had already picked 'Only me' before, trypost automatically clears the selection to force me to pick a valid one."
|
||||
|
||||
### 5.6 Duration warning (skip if not applicable)
|
||||
If you set up the long-video scenario: show the red warning *"Video is Xs long but this account can only post videos up to Ys"*.
|
||||
|
||||
---
|
||||
|
||||
## 6. Preview tab (1:45–2:00)
|
||||
|
||||
**Screen:** click the "Preview" tab.
|
||||
|
||||
**Narration:**
|
||||
> "Before posting, I can preview exactly how my post will look. This is the explicit consent step — I only publish after reviewing the content here."
|
||||
|
||||
**What MUST be visible:**
|
||||
- The preview shows the actual video + caption
|
||||
- Clear that this happens before the upload
|
||||
|
||||
---
|
||||
|
||||
## 7. Publish + processing notification (2:00–2:20)
|
||||
|
||||
**Screen:** back to the composer / schedule view.
|
||||
|
||||
**Action:**
|
||||
1. Select a valid privacy level (e.g. Public)
|
||||
2. Make sure disclose is OFF or one sub-toggle is picked
|
||||
3. Click "Post now"
|
||||
|
||||
**Narration:**
|
||||
> "I'll set privacy to public and publish. After I hit post, trypost shows me a notification saying the content may take a few minutes to process and appear on my TikTok profile — exactly what TikTok's guideline specifies."
|
||||
|
||||
**What MUST be visible:**
|
||||
- Flash banner: *"Post is being published! It may take a few minutes to process and appear on each platform."*
|
||||
- Button disables / spinner while submitting
|
||||
|
||||
---
|
||||
|
||||
## 8. Verify on TikTok (2:20–2:40)
|
||||
|
||||
**Action:** open tiktok.com in a new tab, navigate to your profile.
|
||||
|
||||
**Narration:**
|
||||
> "And here's the post on my TikTok profile, with the correct visibility, the interaction settings I chose, and — because I had Branded Content disclosed — the 'Paid partnership' label is applied by TikTok automatically."
|
||||
|
||||
**What MUST be visible:**
|
||||
- The exact video just uploaded, live on the TikTok profile
|
||||
- If branded content was enabled: the "Paid partnership" label
|
||||
|
||||
---
|
||||
|
||||
## 9. Bonus (optional, strong signal): multi-account support (2:40–3:00)
|
||||
|
||||
**Screen:** go back to the post editor (or create a new post).
|
||||
|
||||
**Action:** select a **second** TikTok account. Show that a second, independent "TikTok Settings" collapse appears for that account.
|
||||
|
||||
**Narration:**
|
||||
> "trypost supports posting to multiple TikTok accounts simultaneously. Each selected account gets its own settings panel with its own creator_info — so each account's privacy options and interaction rules are respected independently."
|
||||
|
||||
**Why this helps:** shows you understand the per-creator nature of the compliance, which matches "creator-specific restrictions" in the doc.
|
||||
|
||||
---
|
||||
|
||||
## 10. Closing (3:00 end)
|
||||
|
||||
**Narration:**
|
||||
> "That's the trypost Direct Post flow — fully aligned with TikTok's Content Sharing Guidelines. Thanks for reviewing."
|
||||
|
||||
**Screen:** end on the TikTok profile showing the freshly-posted video.
|
||||
|
||||
---
|
||||
|
||||
## Final checklist before uploading the video
|
||||
|
||||
Use this as a last gate. **Every** item must be true or the rejection comes back.
|
||||
|
||||
### Compliance items demonstrated
|
||||
- [ ] Creator nickname visible on the upload page
|
||||
- [ ] Preview of content before publishing
|
||||
- [ ] Privacy dropdown uses API values (not a static preset)
|
||||
- [ ] Privacy has no default — user selects
|
||||
- [ ] Comment / Duet / Stitch all unchecked by default
|
||||
- [ ] Greyed-out interaction (shown at least once if your account has it)
|
||||
- [ ] Commercial disclosure toggle default OFF
|
||||
- [ ] "Your brand" sub-toggle revealed, label "Promotional content"
|
||||
- [ ] "Branded content" sub-toggle, label "Paid partnership"
|
||||
- [ ] "Music Usage Confirmation" text + link visible (hover the link)
|
||||
- [ ] "Branded Content Policy" text + link (when branded content)
|
||||
- [ ] Publish button disabled while disclose ON + no sub-toggle picked
|
||||
- [ ] SELF_ONLY (Only me) removed when Branded Content is on
|
||||
- [ ] Caption/title fully user-editable, no auto-presets
|
||||
- [ ] Post-publish "may take a few minutes to process" notification
|
||||
- [ ] Content is original (recorded by you, not reposted)
|
||||
|
||||
### What NOT to show
|
||||
- [ ] No localhost or `.test` domain in the URL bar
|
||||
- [ ] No dev tools consoles open (unless briefly for the creator_info call)
|
||||
- [ ] No watermarks/promotional overlays added by trypost
|
||||
- [ ] No reposted content from other platforms
|
||||
- [ ] No "Other Apps / tools we compete with" references
|
||||
- [ ] No preset hashtags or AI-generated caption that the user didn't edit
|
||||
|
||||
### Technical
|
||||
- [ ] Video resolution ≥ 1080p
|
||||
- [ ] Clear, slow narration in English
|
||||
- [ ] Cursor visible at each click
|
||||
- [ ] Total length 2–4 minutes
|
||||
- [ ] Exported as mp4, < 100MB
|
||||
|
||||
---
|
||||
|
||||
## Why the last submission was rejected (so you know what changed)
|
||||
|
||||
Based on the doc, the previous build likely violated:
|
||||
1. Privacy level had a default (`SELF_ONLY`)
|
||||
2. Comments checkbox was pre-checked
|
||||
3. No "Paid partnership" label when branded content selected
|
||||
4. No block on SELF_ONLY + branded content
|
||||
5. Publish button not disabled when disclose ON without sub-toggle
|
||||
6. No creator_info fetched — interactions & privacy options were static
|
||||
7. No max-duration check
|
||||
8. No processing-time notification
|
||||
|
||||
All addressed in this build. The video above deliberately demonstrates each of these fixes in order.
|
||||
|
||||
---
|
||||
|
||||
## Submission notes
|
||||
|
||||
When submitting on TikTok's developer portal for the **Direct Post API audit**:
|
||||
- Paste a direct link to the video (YouTube unlisted, Loom, or upload in the form)
|
||||
- In the text field, reference this doc: "See attached video demonstrating compliance with Content Sharing Guidelines sections: creator_info fetch, mandatory metadata, commercial content disclosure labels, privacy restrictions for branded content, publish-button gating, processing notification."
|
||||
- The video intentionally does not include the OAuth/authentication flow, which falls under the Login Kit audit, not Direct Post API.
|
||||
- If asked about unaudited-client-restrictions: confirm your test accounts are currently private and posts went to SELF_ONLY during development.
|
||||
|
||||
---
|
||||
|
||||
# 📋 Form Answers (copy / paste)
|
||||
|
||||
## Describe your organization's work as it relates to TikTok
|
||||
|
||||
trypost is a social media scheduling platform that helps authentic creators, small businesses, and agencies plan, schedule, and publish original content across multiple social networks from a single workspace. Our integration with TikTok enables users to compose and schedule short-form video content for their own TikTok accounts, select per-post settings (privacy level, interaction permissions, commercial disclosure) in full compliance with TikTok's Content Sharing Guidelines, preview content before publishing, and manage multiple TikTok accounts side by side — each account's creator_info (privacy options, interaction rules, max video duration) is respected independently.
|
||||
|
||||
We do not repost content from other platforms, inject promotional watermarks, or automate content without explicit user action. Every post is composed, reviewed, and published only upon the user's explicit confirmation. Our role is purely to give creators a better scheduling workflow for content they already own.
|
||||
|
||||
## Explain the goal of your application and how Content Posting API integration can be beneficial
|
||||
|
||||
trypost helps creators and agencies publish original content consistently across social networks from one workspace, so they can focus on content instead of logging into each platform daily. The Content Posting API is essential because it lets us publish directly to TikTok at scheduled times, fetch creator_info at post time so each account's privacy options, interaction rules and max video duration are respected, and enforce TikTok's Content Sharing Guidelines in the UI — including the commercial disclosure flow, the "Promotional content" / "Paid partnership" labels, and the Music Usage and Branded Content Policy declarations. This benefits TikTok by ensuring uploads go through official endpoints (PULL_FROM_URL, status polling, token refresh), and commercial content is correctly disclosed before it reaches the platform.
|
||||
|
||||
## Explain how you determined the daily usage estimate (less than 100)
|
||||
|
||||
trypost launched publicly very recently, so our active user base is still small. Our current sign-up volume and TikTok account connection rate put realistic daily publishing on TikTok in the low double digits at most, with clear headroom before approaching 100. We chose this tier because it reflects actual observed usage, not a projection we cannot yet back with data. When real traction pushes us close to the cap, we will reapply with concrete numbers from our analytics to request a higher tier.
|
||||
|
||||
## Please list the API response data fields that your API client will save in its database
|
||||
|
||||
From /v2/oauth/token/ and /v2/user/info/ (social_accounts table):
|
||||
- open_id (stored as platform_user_id)
|
||||
- display_name
|
||||
- username
|
||||
- avatar_url (image copied to our storage)
|
||||
- access_token (encrypted at rest)
|
||||
- refresh_token (encrypted at rest)
|
||||
- expires_in (converted to a timestamp)
|
||||
- granted scopes
|
||||
|
||||
From /v2/post/publish/video/init/, /content/init/ and /status/fetch/ (post_platforms table):
|
||||
- publish_id
|
||||
- publicaly_available_post_id
|
||||
|
||||
Responses from /v2/post/publish/creator_info/query/ and analytics endpoints are fetched on demand and not persisted.
|
||||
|
|
@ -176,6 +176,29 @@ public function isEnabled(): bool
|
|||
return config("trypost.platforms.{$this->value}.enabled", true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static, platform-specific data exposed to the frontend (e.g. TikTok privacy options,
|
||||
* compliance URLs). Returns an empty array for platforms with no extra config.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function publishConfig(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::TikTok => [
|
||||
'privacyLevelOptions' => [
|
||||
'PUBLIC_TO_EVERYONE',
|
||||
'MUTUAL_FOLLOW_FRIENDS',
|
||||
'FOLLOWER_OF_CREATOR',
|
||||
'SELF_ONLY',
|
||||
],
|
||||
'musicUsageConfirmationUrl' => 'https://www.tiktok.com/legal/page/global/music-usage-confirmation/en',
|
||||
'brandedContentPolicyUrl' => 'https://www.tiktok.com/legal/page/global/bc-policy/en',
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all enabled platforms.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@
|
|||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Http\Requests\App\Post\UpdatePostRequest;
|
||||
use App\Http\Resources\App\PlatformConfigResource;
|
||||
use App\Models\Post;
|
||||
use App\Services\Social\PinterestPublisher;
|
||||
use App\Services\Social\TikTokCreatorInfo;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -165,13 +167,7 @@ public function edit(Request $request, Post $post): Response|RedirectResponse
|
|||
$hashtags = $workspace->hashtags;
|
||||
|
||||
$platformConfigs = $socialAccounts->mapWithKeys(fn ($account) => [
|
||||
$account->id => [
|
||||
'maxContentLength' => $account->platform->maxContentLength(),
|
||||
'maxImages' => $account->platform->maxImages(),
|
||||
'allowedMediaTypes' => array_map(fn ($type) => $type->value, $account->platform->allowedMediaTypes()),
|
||||
'supportsTextOnly' => $account->platform->supportsTextOnly(),
|
||||
'requiresContent' => $account->platform->requiresContent(),
|
||||
],
|
||||
$account->id => new PlatformConfigResource($account),
|
||||
]);
|
||||
|
||||
$pinterestBoards = [];
|
||||
|
|
@ -184,12 +180,21 @@ public function edit(Request $request, Post $post): Response|RedirectResponse
|
|||
}
|
||||
}
|
||||
|
||||
$tiktokAccounts = $socialAccounts->where('platform', Platform::TikTok);
|
||||
|
||||
return Inertia::render('posts/Edit', [
|
||||
'workspace' => $workspace,
|
||||
'post' => $post,
|
||||
'socialAccounts' => $socialAccounts,
|
||||
'platformConfigs' => $platformConfigs,
|
||||
'pinterestBoards' => $pinterestBoards,
|
||||
'tiktokCreatorInfos' => Inertia::defer(fn () => $tiktokAccounts->mapWithKeys(fn ($account) => [
|
||||
$account->id => rescue(
|
||||
fn () => app(TikTokCreatorInfo::class)->fetch($account),
|
||||
null,
|
||||
report: false,
|
||||
),
|
||||
])->filter()),
|
||||
'labels' => $labels,
|
||||
'hashtags' => $hashtags,
|
||||
'authUserId' => $request->user()->id,
|
||||
|
|
|
|||
|
|
@ -38,12 +38,13 @@ public function rules(): array
|
|||
'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post')->id ?? $this->route('post'))],
|
||||
'platforms.*.content_type' => ['sometimes', 'string', Rule::in(array_column(ContentType::cases(), 'value'))],
|
||||
'platforms.*.meta' => ['nullable', 'array'],
|
||||
'platforms.*.meta.privacy_level' => ['sometimes', 'string', Rule::in(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'])],
|
||||
'platforms.*.meta.privacy_level' => ['sometimes', 'nullable', 'string', Rule::in(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'])],
|
||||
'platforms.*.meta.auto_add_music' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.allow_comments' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.allow_duet' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.allow_stitch' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.is_aigc' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.disclose' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'],
|
||||
'label_ids' => ['sometimes', 'array'],
|
||||
|
|
|
|||
31
app/Http/Resources/App/PlatformConfigResource.php
Normal file
31
app/Http/Resources/App/PlatformConfigResource.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\App;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* Wraps a SocialAccount to expose per-platform publishing configuration.
|
||||
*/
|
||||
class PlatformConfigResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'platform' => $this->platform->value,
|
||||
'maxContentLength' => $this->platform->maxContentLength(),
|
||||
'maxImages' => $this->platform->maxImages(),
|
||||
'allowedMediaTypes' => array_map(fn ($type) => $type->value, $this->platform->allowedMediaTypes()),
|
||||
'supportsTextOnly' => $this->platform->supportsTextOnly(),
|
||||
'requiresContent' => $this->platform->requiresContent(),
|
||||
'publishConfig' => $this->platform->publishConfig(),
|
||||
];
|
||||
}
|
||||
}
|
||||
126
app/Services/Social/TikTokCreatorInfo.php
Normal file
126
app/Services/Social/TikTokCreatorInfo.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social;
|
||||
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Services\Social\Concerns\HasSocialHttpClient;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TikTokCreatorInfo
|
||||
{
|
||||
use HasSocialHttpClient;
|
||||
|
||||
private string $baseUrl = 'https://open.tiktokapis.com/v2';
|
||||
|
||||
private string $accessToken;
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* creator_nickname: ?string,
|
||||
* creator_username: ?string,
|
||||
* creator_avatar_url: ?string,
|
||||
* privacy_level_options: array<int, string>,
|
||||
* comment_disabled: bool,
|
||||
* duet_disabled: bool,
|
||||
* stitch_disabled: bool,
|
||||
* max_video_post_duration_sec: ?int,
|
||||
* }
|
||||
*/
|
||||
public function fetch(SocialAccount $account): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
|
||||
$account->refresh();
|
||||
}
|
||||
|
||||
$this->accessToken = $account->access_token;
|
||||
|
||||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/post/publish/creator_info/query/", []);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('TikTok creator_info query failed', [
|
||||
'social_account_id' => $account->id,
|
||||
'body' => $this->redactResponseBody($response->body()),
|
||||
]);
|
||||
|
||||
return $this->emptyPayload();
|
||||
}
|
||||
|
||||
$data = data_get($response->json(), 'data', []);
|
||||
|
||||
return [
|
||||
'creator_nickname' => data_get($data, 'creator_nickname'),
|
||||
'creator_username' => data_get($data, 'creator_username'),
|
||||
'creator_avatar_url' => data_get($data, 'creator_avatar_url'),
|
||||
'privacy_level_options' => data_get($data, 'privacy_level_options', []),
|
||||
'comment_disabled' => (bool) data_get($data, 'comment_disabled', false),
|
||||
'duet_disabled' => (bool) data_get($data, 'duet_disabled', false),
|
||||
'stitch_disabled' => (bool) data_get($data, 'stitch_disabled', false),
|
||||
'max_video_post_duration_sec' => data_get($data, 'max_video_post_duration_sec'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* creator_nickname: null,
|
||||
* creator_username: null,
|
||||
* creator_avatar_url: null,
|
||||
* privacy_level_options: array<int, string>,
|
||||
* comment_disabled: bool,
|
||||
* duet_disabled: bool,
|
||||
* stitch_disabled: bool,
|
||||
* max_video_post_duration_sec: null,
|
||||
* }
|
||||
*/
|
||||
private function emptyPayload(): array
|
||||
{
|
||||
return [
|
||||
'creator_nickname' => null,
|
||||
'creator_username' => null,
|
||||
'creator_avatar_url' => null,
|
||||
'privacy_level_options' => [],
|
||||
'comment_disabled' => false,
|
||||
'duet_disabled' => false,
|
||||
'stitch_disabled' => false,
|
||||
'max_video_post_duration_sec' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function getHttpClient(): PendingRequest
|
||||
{
|
||||
return $this->socialHttp()->asJson()->withToken($this->accessToken);
|
||||
}
|
||||
|
||||
private function refreshToken(SocialAccount $account): void
|
||||
{
|
||||
if (! $account->refresh_token) {
|
||||
throw new TokenExpiredException('No refresh token available for TikTok account');
|
||||
}
|
||||
|
||||
$response = Http::asForm()->post('https://open.tiktokapis.com/v2/oauth/token/', [
|
||||
'client_key' => config('services.tiktok.client_id'),
|
||||
'client_secret' => config('services.tiktok.client_secret'),
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
|
||||
throw new TokenExpiredException('TikTok token refresh failed');
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
$account->update([
|
||||
'access_token' => data_get($data, 'access_token'),
|
||||
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
|
||||
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -63,19 +63,10 @@ private function getHttpClient(): PendingRequest
|
|||
return $this->socialHttp()->asJson()->withToken($this->accessToken);
|
||||
}
|
||||
|
||||
private function queryCreatorInfo(): array
|
||||
private function queryCreatorInfo(SocialAccount $account): array
|
||||
{
|
||||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/post/publish/creator_info/query/", []);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('TikTok creator_info query failed', ['body' => $this->redactResponseBody($response->body())]);
|
||||
|
||||
return ['privacy_level' => 'SELF_ONLY'];
|
||||
}
|
||||
|
||||
$data = data_get($response->json(), 'data', []);
|
||||
$privacyOptions = data_get($data, 'privacy_level_options', ['SELF_ONLY']);
|
||||
$info = app(TikTokCreatorInfo::class)->fetch($account);
|
||||
$privacyOptions = data_get($info, 'privacy_level_options') ?: ['SELF_ONLY'];
|
||||
|
||||
// Prefer PUBLIC_TO_EVERYONE > MUTUAL_FOLLOW_FRIENDS > FOLLOWER_OF_CREATOR > SELF_ONLY
|
||||
$preferred = ['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'];
|
||||
|
|
@ -90,7 +81,7 @@ private function queryCreatorInfo(): array
|
|||
|
||||
return [
|
||||
'privacy_level' => $privacyLevel,
|
||||
'max_video_post_duration_sec' => data_get($data, 'max_video_post_duration_sec'),
|
||||
'max_video_post_duration_sec' => data_get($info, 'max_video_post_duration_sec'),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +117,7 @@ private function buildPostInfo(PostPlatform $postPlatform, ?string $content, arr
|
|||
|
||||
private function publishVideo(PostPlatform $postPlatform, $media, ?string $content): array
|
||||
{
|
||||
$creatorInfo = $this->queryCreatorInfo();
|
||||
$creatorInfo = $this->queryCreatorInfo($postPlatform->socialAccount);
|
||||
|
||||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/post/publish/video/init/", [
|
||||
|
|
@ -175,7 +166,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st
|
|||
throw new \Exception('No valid images found for TikTok photo post');
|
||||
}
|
||||
|
||||
$creatorInfo = $this->queryCreatorInfo();
|
||||
$creatorInfo = $this->queryCreatorInfo($postPlatform->socialAccount);
|
||||
|
||||
$postInfo = $this->buildPostInfo($postPlatform, $content, $creatorInfo);
|
||||
// Photos don't support duet/stitch/is_aigc
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@
|
|||
'write_caption' => 'Write your caption...',
|
||||
'tiktok' => [
|
||||
'settings' => 'TikTok Settings',
|
||||
'posting_to' => 'Posting to',
|
||||
'privacy_level' => 'Who can see this video?',
|
||||
'privacy_placeholder' => 'Select visibility',
|
||||
'privacy' => [
|
||||
'public' => 'Public to everyone',
|
||||
'friends' => 'Mutual follow friends',
|
||||
|
|
@ -52,10 +54,27 @@
|
|||
'duet' => 'Duet',
|
||||
'stitch' => 'Stitch',
|
||||
'is_aigc' => 'Video made with AI',
|
||||
'brand_content' => 'Disclose paid partnership',
|
||||
'brand_content_hint' => 'This video promotes a third-party business, brand, or product.',
|
||||
'brand_organic' => 'Disclose your own brand',
|
||||
'brand_organic_hint' => 'This video promotes your own business, brand, or product.',
|
||||
'disclose' => 'Disclose video content',
|
||||
'disclose_hint' => 'Turn on to disclose that this video promotes goods or services in exchange for something of value. Your video could promote yourself, a third party, or both.',
|
||||
'promotional_organic_title' => 'Your photo/video will be labeled as "Promotional content".',
|
||||
'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.',
|
||||
'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…',
|
||||
'processing_hint' => 'After publishing, it may take a few minutes for the content to process and appear on your TikTok profile.',
|
||||
'brand_organic' => 'Your brand',
|
||||
'brand_organic_hint' => 'You are promoting yourself or your own brand. This video will be classified as Brand Organic.',
|
||||
'brand_content' => 'Branded content',
|
||||
'brand_content_hint' => 'You are promoting another brand or a third party. This video will be classified as Branded Content.',
|
||||
'compliance' => [
|
||||
'agree' => "By posting, you agree to TikTok's",
|
||||
'music_usage' => 'Music Usage Confirmation',
|
||||
'and' => 'and',
|
||||
'branded_policy' => 'Branded Content Policy',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
|
@ -261,7 +280,7 @@
|
|||
|
||||
'flash' => [
|
||||
'scheduled' => 'Post scheduled successfully!',
|
||||
'publishing' => 'Post is being published!',
|
||||
'publishing' => 'Post is being published! It may take a few minutes to process and appear on each platform.',
|
||||
'deleted' => 'Post deleted successfully!',
|
||||
'cannot_edit_published' => 'Published posts cannot be edited.',
|
||||
'connect_first' => 'Connect at least one social network before creating a post.',
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@
|
|||
'write_caption' => 'Escribe tu descripción...',
|
||||
'tiktok' => [
|
||||
'settings' => 'Configuración de TikTok',
|
||||
'posting_to' => 'Publicando en',
|
||||
'privacy_level' => '¿Quién puede ver este video?',
|
||||
'privacy_placeholder' => 'Selecciona la visibilidad',
|
||||
'privacy' => [
|
||||
'public' => 'Público para todos',
|
||||
'friends' => 'Amigos mutuos',
|
||||
|
|
@ -52,10 +54,27 @@
|
|||
'duet' => 'Dueto',
|
||||
'stitch' => 'Stitch',
|
||||
'is_aigc' => 'Video hecho con IA',
|
||||
'brand_content' => 'Divulgar asociación pagada',
|
||||
'brand_content_hint' => 'Este video promueve un negocio, marca o producto de terceros.',
|
||||
'brand_organic' => 'Divulgar tu propia marca',
|
||||
'brand_organic_hint' => 'Este video promueve tu propio negocio, marca o producto.',
|
||||
'disclose' => 'Divulgar contenido del video',
|
||||
'disclose_hint' => 'Activa para divulgar que este video promueve bienes o servicios a cambio de algo de valor. Tu video puede promocionarte a ti, a un tercero o ambos.',
|
||||
'promotional_organic_title' => 'Tu foto/video será etiquetado como "Contenido Promocional".',
|
||||
'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.',
|
||||
'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…',
|
||||
'processing_hint' => 'Después de publicar, puede tardar unos minutos en procesarse y aparecer en tu perfil de TikTok.',
|
||||
'brand_organic' => 'Tu marca',
|
||||
'brand_organic_hint' => 'Estás promocionándote a ti mismo o a tu propia marca. Este video será clasificado como Brand Organic.',
|
||||
'brand_content' => 'Contenido patrocinado',
|
||||
'brand_content_hint' => 'Estás promocionando otra marca o a un tercero. Este video será clasificado como Branded Content.',
|
||||
'compliance' => [
|
||||
'agree' => 'Al publicar, aceptas la',
|
||||
'music_usage' => 'Confirmación de Uso de Música',
|
||||
'and' => 'y la',
|
||||
'branded_policy' => 'Política de Contenido Patrocinado',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
|
@ -274,7 +293,7 @@
|
|||
|
||||
'flash' => [
|
||||
'scheduled' => '¡Post programado correctamente!',
|
||||
'publishing' => '¡El post se está publicando!',
|
||||
'publishing' => '¡El post se está publicando! Puede tardar unos minutos en procesarse y aparecer en cada plataforma.',
|
||||
'deleted' => '¡Post eliminado correctamente!',
|
||||
'cannot_edit_published' => 'Los posts publicados no se pueden editar.',
|
||||
'connect_first' => 'Conecta al menos una red social antes de crear un post.',
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -35,7 +35,9 @@
|
|||
'write_caption' => 'Escreva sua legenda...',
|
||||
'tiktok' => [
|
||||
'settings' => 'Configurações do TikTok',
|
||||
'posting_to' => 'Publicando em',
|
||||
'privacy_level' => 'Quem pode ver este vídeo?',
|
||||
'privacy_placeholder' => 'Selecione a visibilidade',
|
||||
'privacy' => [
|
||||
'public' => 'Público para todos',
|
||||
'friends' => 'Amigos em comum',
|
||||
|
|
@ -52,10 +54,27 @@
|
|||
'duet' => 'Dueto',
|
||||
'stitch' => 'Stitch',
|
||||
'is_aigc' => 'Vídeo feito com IA',
|
||||
'brand_content' => 'Divulgar parceria paga',
|
||||
'brand_content_hint' => 'Este vídeo promove um negócio, marca ou produto de terceiros.',
|
||||
'brand_organic' => 'Divulgar sua própria marca',
|
||||
'brand_organic_hint' => 'Este vídeo promove seu próprio negócio, marca ou produto.',
|
||||
'disclose' => 'Divulgar conteúdo do vídeo',
|
||||
'disclose_hint' => 'Ative para divulgar que este vídeo promove bens ou serviços em troca de algo de valor. Seu vídeo pode promover você, terceiros ou ambos.',
|
||||
'promotional_organic_title' => 'Sua foto/vídeo será rotulado como "Conteúdo Promocional".',
|
||||
'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.',
|
||||
'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…',
|
||||
'processing_hint' => 'Após publicar, pode levar alguns minutos para o conteúdo ser processado e aparecer no seu perfil TikTok.',
|
||||
'brand_organic' => 'Sua marca',
|
||||
'brand_organic_hint' => 'Você está promovendo você mesmo ou sua própria marca. Este vídeo será classificado como Brand Organic.',
|
||||
'brand_content' => 'Conteúdo patrocinado',
|
||||
'brand_content_hint' => 'Você está promovendo outra marca ou terceiros. Este vídeo será classificado como Branded Content.',
|
||||
'compliance' => [
|
||||
'agree' => 'Ao publicar, você concorda com a',
|
||||
'music_usage' => 'Confirmação de Uso de Música',
|
||||
'and' => 'e',
|
||||
'branded_policy' => 'Política de Conteúdo Patrocinado',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
|
@ -274,7 +293,7 @@
|
|||
|
||||
'flash' => [
|
||||
'scheduled' => 'Post agendado com sucesso!',
|
||||
'publishing' => 'Post está sendo publicado!',
|
||||
'publishing' => 'Post está sendo publicado! Pode levar alguns minutos para processar e aparecer em cada plataforma.',
|
||||
'deleted' => 'Post excluído com sucesso!',
|
||||
'cannot_edit_published' => 'Posts publicados não podem ser editados.',
|
||||
'connect_first' => 'Conecte pelo menos uma rede social antes de criar um post.',
|
||||
|
|
|
|||
|
|
@ -1,562 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
IconPhoto,
|
||||
IconVideo,
|
||||
IconX,
|
||||
IconPlus,
|
||||
IconCloudUpload,
|
||||
IconLoader2,
|
||||
IconGripVertical,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconSearch,
|
||||
IconChevronUp,
|
||||
} from '@tabler/icons-vue';
|
||||
import { FocusScope } from 'reka-ui';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxAnchor,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxItemIndicator,
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from '@/components/ui/combobox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useMediaRules } from '@/composables/useMediaRules';
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
group_id: string | null;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
}
|
||||
|
||||
interface ContentTypeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface PinterestBoard {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
platform: string;
|
||||
content: string;
|
||||
media: MediaItem[];
|
||||
contentType: string;
|
||||
contentTypeOptions: ContentTypeOption[];
|
||||
meta?: Record<string, any>;
|
||||
platformData?: {
|
||||
boards?: PinterestBoard[];
|
||||
};
|
||||
charCount: number;
|
||||
maxLength: number;
|
||||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isUploading: false,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
'update:contentType': [value: string];
|
||||
'update:meta': [value: Record<string, any>];
|
||||
'upload': [files: File[]];
|
||||
'remove-media': [mediaId: string];
|
||||
'reorder-media': [mediaIds: string[]];
|
||||
}>();
|
||||
|
||||
// Media rules
|
||||
const contentTypeRef = computed(() => props.contentType);
|
||||
const { rules: mediaRules } = useMediaRules(contentTypeRef);
|
||||
|
||||
// File drag and drop (for uploading)
|
||||
const isDraggingFile = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Media reorder drag and drop
|
||||
const draggedItemId = ref<string | null>(null);
|
||||
const dragOverItemId = ref<string | null>(null);
|
||||
|
||||
// Pinterest board selection
|
||||
interface BoardOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const boards = computed(() => props.platformData?.boards || []);
|
||||
const boardOptions = computed<BoardOption[]>(() =>
|
||||
boards.value.map(board => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedBoard = computed({
|
||||
get: () => boardOptions.value.find(b => b.value === props.meta?.board_id),
|
||||
set: (board: BoardOption | undefined) => {
|
||||
emit('update:meta', { ...props.meta, board_id: board?.value || null });
|
||||
},
|
||||
});
|
||||
|
||||
// TikTok settings
|
||||
const isTikTok = computed(() => props.platform === 'tiktok');
|
||||
const tiktokSettingsOpen = ref(true);
|
||||
|
||||
const tiktokPrivacyLevel = computed({
|
||||
get: () => props.meta?.privacy_level || 'SELF_ONLY',
|
||||
set: (value: string) => {
|
||||
emit('update:meta', { ...props.meta, privacy_level: value });
|
||||
},
|
||||
});
|
||||
|
||||
const tiktokAllowComments = computed({
|
||||
get: () => props.meta?.allow_comments ?? true,
|
||||
set: (value: boolean) => {
|
||||
emit('update:meta', { ...props.meta, allow_comments: value });
|
||||
},
|
||||
});
|
||||
|
||||
const tiktokAllowDuet = computed({
|
||||
get: () => props.meta?.allow_duet ?? false,
|
||||
set: (value: boolean) => {
|
||||
emit('update:meta', { ...props.meta, allow_duet: value });
|
||||
},
|
||||
});
|
||||
|
||||
const tiktokAllowStitch = computed({
|
||||
get: () => props.meta?.allow_stitch ?? false,
|
||||
set: (value: boolean) => {
|
||||
emit('update:meta', { ...props.meta, allow_stitch: value });
|
||||
},
|
||||
});
|
||||
|
||||
const tiktokAutoAddMusic = computed({
|
||||
get: () => props.meta?.auto_add_music ?? false,
|
||||
set: (value: boolean) => {
|
||||
emit('update:meta', { ...props.meta, auto_add_music: value });
|
||||
},
|
||||
});
|
||||
|
||||
const tiktokIsAigc = computed({
|
||||
get: () => props.meta?.is_aigc ?? false,
|
||||
set: (value: boolean) => {
|
||||
emit('update:meta', { ...props.meta, is_aigc: value });
|
||||
},
|
||||
});
|
||||
|
||||
const tiktokBrandContentToggle = computed({
|
||||
get: () => props.meta?.brand_content_toggle ?? false,
|
||||
set: (value: boolean) => {
|
||||
emit('update:meta', { ...props.meta, brand_content_toggle: value });
|
||||
},
|
||||
});
|
||||
|
||||
const tiktokBrandOrganicToggle = computed({
|
||||
get: () => props.meta?.brand_organic_toggle ?? false,
|
||||
set: (value: boolean) => {
|
||||
emit('update:meta', { ...props.meta, brand_organic_toggle: value });
|
||||
},
|
||||
});
|
||||
|
||||
// Computed
|
||||
const hasMultipleContentTypes = computed(() => props.contentTypeOptions.length > 1);
|
||||
const canAddMore = computed(() => props.media.length < mediaRules.value.maxFiles);
|
||||
const acceptString = computed(() => {
|
||||
if (mediaRules.value.acceptImages && mediaRules.value.acceptVideos) return 'image/*,video/*';
|
||||
if (mediaRules.value.acceptImages) return 'image/*';
|
||||
return 'video/*';
|
||||
});
|
||||
|
||||
// File validation
|
||||
const isValidFileType = (file: File): boolean => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
if (isImage && !mediaRules.value.acceptImages) return false;
|
||||
if (isVideo && !mediaRules.value.acceptVideos) return false;
|
||||
return isImage || isVideo;
|
||||
};
|
||||
|
||||
// File upload handlers
|
||||
const handleFileDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Only handle if dragging files (not internal reorder)
|
||||
if (e.dataTransfer?.types.includes('Files') && !props.disabled && !props.isUploading && canAddMore.value) {
|
||||
isDraggingFile.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingFile.value = false;
|
||||
};
|
||||
|
||||
const handleFileDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingFile.value = false;
|
||||
|
||||
if (props.disabled || props.isUploading || !e.dataTransfer?.files) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files).filter(isValidFileType);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const remainingSlots = mediaRules.value.maxFiles - props.media.length;
|
||||
const filesToUpload = files.slice(0, remainingSlots);
|
||||
|
||||
if (filesToUpload.length > 0) {
|
||||
emit('upload', filesToUpload);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
|
||||
const files = Array.from(input.files).filter(isValidFileType);
|
||||
const remainingSlots = mediaRules.value.maxFiles - props.media.length;
|
||||
const filesToUpload = files.slice(0, remainingSlots);
|
||||
|
||||
if (filesToUpload.length > 0) {
|
||||
emit('upload', filesToUpload);
|
||||
}
|
||||
input.value = '';
|
||||
};
|
||||
|
||||
const openFilePicker = () => {
|
||||
if (!props.disabled && !props.isUploading && canAddMore.value) {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
};
|
||||
|
||||
// Media reorder handlers
|
||||
const handleDragStart = (e: DragEvent, itemId: string) => {
|
||||
if (props.disabled) return;
|
||||
draggedItemId.value = itemId;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', itemId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
draggedItemId.value = null;
|
||||
dragOverItemId.value = null;
|
||||
};
|
||||
|
||||
const handleDragOverItem = (e: DragEvent, itemId: string) => {
|
||||
e.preventDefault();
|
||||
if (draggedItemId.value && draggedItemId.value !== itemId) {
|
||||
dragOverItemId.value = itemId;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeaveItem = () => {
|
||||
dragOverItemId.value = null;
|
||||
};
|
||||
|
||||
const handleDropOnItem = (e: DragEvent, targetId: string) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggedItemId.value || draggedItemId.value === targetId) {
|
||||
handleDragEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new order
|
||||
const currentIds = props.media.map(m => m.id);
|
||||
const draggedIndex = currentIds.indexOf(draggedItemId.value);
|
||||
const targetIndex = currentIds.indexOf(targetId);
|
||||
|
||||
if (draggedIndex === -1 || targetIndex === -1) {
|
||||
handleDragEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new order by moving dragged item to target position
|
||||
const newIds = [...currentIds];
|
||||
newIds.splice(draggedIndex, 1);
|
||||
newIds.splice(targetIndex, 0, draggedItemId.value);
|
||||
|
||||
emit('reorder-media', newIds);
|
||||
handleDragEnd();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Content Type Selector (Tabs Style) -->
|
||||
<div v-if="hasMultipleContentTypes">
|
||||
<Label class="text-sm font-medium mb-2 block">{{ $t('posts.form.post_type') }}</Label>
|
||||
<div
|
||||
class="bg-muted text-muted-foreground inline-flex h-10 w-auto items-center justify-start rounded-lg p-1">
|
||||
<button v-for="option in contentTypeOptions" :key="option.value" type="button"
|
||||
@click="emit('update:contentType', option.value)" :class="[
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all',
|
||||
contentType === option.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'hover:bg-background/50 hover:text-foreground'
|
||||
]">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pinterest Board Selector -->
|
||||
<div v-if="platform === 'pinterest' && boards.length > 0">
|
||||
<Label class="text-sm font-medium mb-2 block">{{ $t('posts.form.board') }}</Label>
|
||||
<FocusScope as-child>
|
||||
<Combobox v-model="selectedBoard">
|
||||
<ComboboxAnchor as-child>
|
||||
<ComboboxTrigger as-child>
|
||||
<button type="button"
|
||||
class="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{{ selectedBoard ? selectedBoard.label : $t('posts.form.select_board') }}
|
||||
<IconChevronDown class="h-4 w-4 opacity-50" />
|
||||
</button>
|
||||
</ComboboxTrigger>
|
||||
</ComboboxAnchor>
|
||||
<ComboboxList class="w-[var(--reka-combobox-trigger-width)]">
|
||||
<div class="relative">
|
||||
<ComboboxInput :placeholder="$t('posts.form.search_board')" />
|
||||
<span class="absolute inset-y-0 start-0 flex items-center justify-center px-3">
|
||||
<IconSearch class="size-4 text-muted-foreground" />
|
||||
</span>
|
||||
</div>
|
||||
<ComboboxEmpty>{{ $t('posts.form.no_board_found') }}</ComboboxEmpty>
|
||||
<ComboboxGroup>
|
||||
<ComboboxItem v-for="board in boardOptions" :key="board.value" :value="board">
|
||||
<span class="min-w-0 flex-1 truncate">{{ board.label }}</span>
|
||||
<ComboboxItemIndicator>
|
||||
<IconCheck class="ml-auto h-4 w-4" />
|
||||
</ComboboxItemIndicator>
|
||||
</ComboboxItem>
|
||||
</ComboboxGroup>
|
||||
</ComboboxList>
|
||||
</Combobox>
|
||||
</FocusScope>
|
||||
</div>
|
||||
|
||||
<!-- Media Upload Area -->
|
||||
<div>
|
||||
<Label class="text-sm font-medium mb-2 block">
|
||||
{{ $t('posts.form.media') }}
|
||||
<span class="text-muted-foreground font-normal ml-1">
|
||||
({{ props.media.length }}/{{ mediaRules.maxFiles }})
|
||||
</span>
|
||||
<span v-if="mediaRules.minFiles && mediaRules.minFiles > 1" class="text-muted-foreground font-normal">
|
||||
· {{ $t('posts.form.min') }} {{ mediaRules.minFiles }}
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<div class="border-2 border-dashed rounded-lg transition-colors" :class="[
|
||||
isDraggingFile ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-muted-foreground/40',
|
||||
props.disabled ? 'opacity-50 cursor-not-allowed' : '',
|
||||
]" @dragover="handleFileDragOver" @dragleave="handleFileDragLeave" @drop="handleFileDrop">
|
||||
<!-- Empty State -->
|
||||
<div v-if="media.length === 0" class="p-8 flex flex-col items-center justify-center cursor-pointer"
|
||||
@click="openFilePicker">
|
||||
<div v-if="isUploading" class="flex flex-col items-center gap-3">
|
||||
<IconLoader2 class="h-10 w-10 text-muted-foreground animate-spin" />
|
||||
<p class="text-sm text-muted-foreground">{{ $t('posts.form.uploading') }}</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center gap-3">
|
||||
<div class="p-4 rounded-full transition-colors"
|
||||
:class="isDraggingFile ? 'bg-primary/10' : 'bg-muted'">
|
||||
<IconCloudUpload v-if="isDraggingFile" class="h-8 w-8 text-primary" />
|
||||
<IconVideo v-else-if="!mediaRules.acceptImages" class="h-8 w-8 text-muted-foreground" />
|
||||
<IconPhoto v-else class="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-medium">
|
||||
{{ isDraggingFile ? $t('posts.form.drop_to_upload') : $t('posts.form.drag_and_drop') }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{{ mediaRules.acceptImages && mediaRules.acceptVideos ? $t('posts.form.photos_and_videos') :
|
||||
(mediaRules.acceptImages ? $t('posts.form.photos_only') : $t('posts.form.videos_only')) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media Grid -->
|
||||
<div v-else class="p-3">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<!-- Media Items -->
|
||||
<div v-for="(item, index) in media" :key="item.id" draggable="true"
|
||||
@dragstart="handleDragStart($event, item.id)" @dragend="handleDragEnd"
|
||||
@dragover="handleDragOverItem($event, item.id)" @dragleave="handleDragLeaveItem"
|
||||
@drop="handleDropOnItem($event, item.id)"
|
||||
class="relative aspect-square rounded-lg overflow-hidden bg-muted group transition-all"
|
||||
:class="[
|
||||
draggedItemId === item.id ? 'opacity-50 scale-95' : '',
|
||||
dragOverItemId === item.id ? 'ring-2 ring-primary ring-offset-2' : '',
|
||||
!props.disabled ? 'cursor-grab active:cursor-grabbing' : '',
|
||||
]">
|
||||
<img v-if="item.type === 'image'" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover pointer-events-none" />
|
||||
<video v-else :src="item.url"
|
||||
class="w-full h-full object-cover bg-black pointer-events-none" muted playsinline />
|
||||
<!-- Order number -->
|
||||
<div v-if="media.length > 1"
|
||||
class="absolute top-1 left-1 bg-black/70 text-white text-[10px] font-bold w-5 h-5 rounded flex items-center justify-center">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<!-- Video indicator -->
|
||||
<div v-if="item.type === 'video'"
|
||||
class="absolute bottom-1 left-1 bg-black/70 text-white text-[10px] px-1.5 py-0.5 rounded flex items-center gap-0.5">
|
||||
<IconVideo class="h-3 w-3" />
|
||||
</div>
|
||||
<!-- Remove button -->
|
||||
<button v-if="!props.disabled" type="button" @click.stop="emit('remove-media', item.id)"
|
||||
class="absolute top-1 right-1 bg-black/70 hover:bg-black/90 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<IconX class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<!-- Drag hint -->
|
||||
<div v-if="media.length > 1 && !props.disabled"
|
||||
class="absolute bottom-1 right-1 bg-black/50 text-white rounded p-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<IconGripVertical class="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add More Button -->
|
||||
<button v-if="canAddMore && !props.disabled" type="button" @click="openFilePicker"
|
||||
class="aspect-square rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/40 flex items-center justify-center transition-colors"
|
||||
:class="isUploading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-muted/50'">
|
||||
<IconLoader2 v-if="isUploading" class="h-6 w-6 text-muted-foreground animate-spin" />
|
||||
<IconPlus v-else class="h-6 w-6 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="media.length > 1" class="text-xs text-muted-foreground mt-2 text-center">
|
||||
{{ $t('posts.form.drag_to_reorder') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input ref="fileInput" type="file" :accept="acceptString" :multiple="mediaRules.maxFiles > 1" class="hidden"
|
||||
@change="handleFileSelect" :disabled="props.disabled || props.isUploading" />
|
||||
</div>
|
||||
|
||||
<!-- Caption/Text Input -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<Label class="text-sm font-medium">{{ $t('posts.form.caption') }}</Label>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full"
|
||||
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'">
|
||||
{{ validationMessage }}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea :model-value="content" @update:model-value="emit('update:content', $event as string)"
|
||||
:placeholder="$t('posts.form.write_caption')" class="min-h-[120px] resize-none" :disabled="props.disabled" />
|
||||
</div>
|
||||
|
||||
<!-- TikTok Settings -->
|
||||
<div v-if="isTikTok" class="rounded-lg border">
|
||||
<button type="button"
|
||||
class="flex w-full items-center justify-between p-4 text-sm font-medium"
|
||||
@click="tiktokSettingsOpen = !tiktokSettingsOpen">
|
||||
{{ $t('posts.form.tiktok.settings') }}
|
||||
<IconChevronUp v-if="tiktokSettingsOpen" class="h-4 w-4 text-muted-foreground" />
|
||||
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
<div v-if="tiktokSettingsOpen" class="space-y-5 border-t px-4 pb-4 pt-4">
|
||||
<!-- Privacy Level -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ $t('posts.form.tiktok.privacy_level') }}</Label>
|
||||
<Select v-model="tiktokPrivacyLevel" :disabled="props.disabled">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PUBLIC_TO_EVERYONE">{{ $t('posts.form.tiktok.privacy.public') }}</SelectItem>
|
||||
<SelectItem value="MUTUAL_FOLLOW_FRIENDS">{{ $t('posts.form.tiktok.privacy.friends') }}</SelectItem>
|
||||
<SelectItem value="FOLLOWER_OF_CREATOR">{{ $t('posts.form.tiktok.privacy.followers') }}</SelectItem>
|
||||
<SelectItem value="SELF_ONLY">{{ $t('posts.form.tiktok.privacy.private') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-xs text-muted-foreground">{{ $t('posts.form.tiktok.privacy_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Auto Add Music (photos only) -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ $t('posts.form.tiktok.auto_add_music') }}</Label>
|
||||
<Select :model-value="tiktokAutoAddMusic ? 'yes' : 'no'" @update:model-value="tiktokAutoAddMusic = $event === 'yes'" :disabled="props.disabled">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="yes">{{ $t('posts.form.tiktok.yes') }}</SelectItem>
|
||||
<SelectItem value="no">{{ $t('posts.form.tiktok.no') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-xs text-muted-foreground">{{ $t('posts.form.tiktok.auto_add_music_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Allow User To -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ $t('posts.form.tiktok.allow_users') }}</Label>
|
||||
<div class="flex items-center gap-6">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox :checked="tiktokAllowComments" @update:checked="tiktokAllowComments = $event" :disabled="props.disabled" />
|
||||
{{ $t('posts.form.tiktok.comments') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox :checked="tiktokAllowDuet" @update:checked="tiktokAllowDuet = $event" :disabled="props.disabled" />
|
||||
{{ $t('posts.form.tiktok.duet') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox :checked="tiktokAllowStitch" @update:checked="tiktokAllowStitch = $event" :disabled="props.disabled" />
|
||||
{{ $t('posts.form.tiktok.stitch') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Disclosure -->
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox :checked="tiktokIsAigc" @update:checked="tiktokIsAigc = $event" :disabled="props.disabled" />
|
||||
{{ $t('posts.form.tiktok.is_aigc') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox :checked="tiktokBrandContentToggle" @update:checked="tiktokBrandContentToggle = $event" :disabled="props.disabled" />
|
||||
{{ $t('posts.form.tiktok.brand_content') }}
|
||||
</label>
|
||||
<p v-if="tiktokBrandContentToggle" class="text-xs text-muted-foreground ml-6">{{ $t('posts.form.tiktok.brand_content_hint') }}</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox :checked="tiktokBrandOrganicToggle" @update:checked="tiktokBrandOrganicToggle = $event" :disabled="props.disabled" />
|
||||
{{ $t('posts.form.tiktok.brand_organic') }}
|
||||
</label>
|
||||
<p v-if="tiktokBrandOrganicToggle" class="text-xs text-muted-foreground ml-6">{{ $t('posts.form.tiktok.brand_organic_hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
33
|
||||
|
|
@ -14,11 +14,11 @@ import {
|
|||
IconExternalLink,
|
||||
IconLoader2,
|
||||
} from '@tabler/icons-vue';
|
||||
import { type Component } from 'vue';
|
||||
import { computed, type Component } from 'vue';
|
||||
|
||||
import TikTokSettings from '@/components/posts/editor/TikTokSettings.vue';
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
@ -51,19 +51,72 @@ interface Label {
|
|||
color: string;
|
||||
}
|
||||
|
||||
interface PlatformConfig {
|
||||
id: string;
|
||||
platform: string;
|
||||
maxContentLength: number;
|
||||
maxImages: number;
|
||||
allowedMediaTypes: string[];
|
||||
supportsTextOnly: boolean;
|
||||
requiresContent: boolean;
|
||||
publishConfig: Record<string, any>;
|
||||
}
|
||||
|
||||
interface TikTokCreatorInfo {
|
||||
creator_nickname: string | null;
|
||||
creator_username: string | null;
|
||||
creator_avatar_url: string | null;
|
||||
privacy_level_options: string[];
|
||||
comment_disabled: boolean;
|
||||
duet_disabled: boolean;
|
||||
stitch_disabled: boolean;
|
||||
max_video_post_duration_sec: number | null;
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type?: string;
|
||||
mime_type?: string;
|
||||
meta?: Record<string, any>;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
postPlatforms: PostPlatform[];
|
||||
selectedPlatformIds: string[];
|
||||
labels: Label[];
|
||||
selectedLabelIds: string[];
|
||||
isReadOnly: boolean;
|
||||
platformConfigs: Record<string, PlatformConfig>;
|
||||
platformMeta: Record<string, Record<string, any>>;
|
||||
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
|
||||
media?: MediaItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
togglePlatform: [platformId: string];
|
||||
toggleLabel: [labelId: string];
|
||||
'update:platformMeta': [platformId: string, meta: Record<string, any>];
|
||||
}>();
|
||||
|
||||
const selectedTikTokPlatforms = computed(() =>
|
||||
props.postPlatforms.filter(
|
||||
(pp) => pp.platform === 'tiktok' && props.selectedPlatformIds.includes(pp.id),
|
||||
),
|
||||
);
|
||||
|
||||
const getPublishConfig = (pp: PostPlatform): Record<string, any> | null =>
|
||||
pp.social_account_id ? props.platformConfigs[pp.social_account_id]?.publishConfig ?? null : null;
|
||||
|
||||
const getCreatorInfo = (pp: PostPlatform): TikTokCreatorInfo | null =>
|
||||
pp.social_account_id ? props.tiktokCreatorInfos?.[pp.social_account_id] ?? null : null;
|
||||
|
||||
const videoDurationSec = computed(() => {
|
||||
const video = props.media?.find((m) => m.type === 'video' || m.mime_type?.startsWith('video/'));
|
||||
const duration = video?.meta?.duration;
|
||||
return typeof duration === 'number' ? Math.ceil(duration) : null;
|
||||
});
|
||||
|
||||
const platformIcons: Record<string, Component> = {
|
||||
linkedin: IconBrandLinkedin,
|
||||
'linkedin-page': IconBrandLinkedin,
|
||||
|
|
@ -95,27 +148,27 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
|
|||
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{{ $t('posts.edit.publish_to') }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<TooltipProvider v-for="pp in postPlatforms" :key="pp.id">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-all"
|
||||
:class="selectedPlatformIds.includes(pp.id) ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'border-border opacity-50 hover:opacity-80'"
|
||||
@click="emit('togglePlatform', pp.id)"
|
||||
>
|
||||
<Avatar :src="getPlatformAvatar(pp)" :name="getPlatformDisplayName(pp)" class="h-6 w-6 shrink-0 rounded-full" />
|
||||
<component :is="getPlatformIcon(pp.platform)" class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Badge v-if="pp.status === 'published'" variant="default" class="absolute -top-1.5 -right-1.5 h-4 w-4 p-0">
|
||||
<IconCircleCheck class="h-2.5 w-2.5" />
|
||||
</Badge>
|
||||
<Badge v-else-if="pp.status === 'failed'" variant="destructive" class="absolute -top-1.5 -right-1.5 h-4 w-4 p-0 text-[9px]">!</Badge>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ getPlatformDisplayName(pp) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
v-for="pp in postPlatforms"
|
||||
:key="pp.id"
|
||||
type="button"
|
||||
class="flex w-20 flex-col items-center gap-1.5 transition-opacity"
|
||||
:class="selectedPlatformIds.includes(pp.id) ? 'opacity-100' : 'opacity-40 hover:opacity-70'"
|
||||
@click="emit('togglePlatform', pp.id)"
|
||||
>
|
||||
<div class="relative">
|
||||
<Avatar :src="getPlatformAvatar(pp)" :name="getPlatformDisplayName(pp)" class="h-10 w-10 shrink-0 rounded-full ring-2 ring-offset-2" :class="selectedPlatformIds.includes(pp.id) ? 'ring-primary' : 'ring-transparent'" />
|
||||
<span class="absolute -bottom-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-background ring-1 ring-border">
|
||||
<component :is="getPlatformIcon(pp.platform)" class="h-3 w-3" />
|
||||
</span>
|
||||
<Badge v-if="pp.status === 'published'" variant="default" class="absolute -top-1 -right-1 h-4 w-4 p-0">
|
||||
<IconCircleCheck class="h-2.5 w-2.5" />
|
||||
</Badge>
|
||||
<Badge v-else-if="pp.status === 'failed'" variant="destructive" class="absolute -top-1 -right-1 h-4 w-4 p-0 text-[9px]">!</Badge>
|
||||
</div>
|
||||
<span class="line-clamp-2 text-center text-xs leading-tight">{{ getPlatformDisplayName(pp) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -144,6 +197,22 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Platform-specific settings -->
|
||||
<div v-if="selectedTikTokPlatforms.length > 0" class="space-y-4">
|
||||
<TikTokSettings
|
||||
v-for="pp in selectedTikTokPlatforms"
|
||||
:key="pp.id"
|
||||
:social-account="pp.social_account"
|
||||
:publish-config="getPublishConfig(pp)"
|
||||
:creator-info="getCreatorInfo(pp)"
|
||||
:creator-info-loading="tiktokCreatorInfos === undefined || tiktokCreatorInfos === null"
|
||||
:video-duration-sec="videoDurationSec"
|
||||
:meta="platformMeta[pp.id] ?? {}"
|
||||
:disabled="isReadOnly"
|
||||
@update:meta="emit('update:platformMeta', pp.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{{ $t('posts.edit.labels') }}
|
||||
|
|
|
|||
347
resources/js/components/posts/editor/TikTokSettings.vue
Normal file
347
resources/js/components/posts/editor/TikTokSettings.vue
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
<script setup lang="ts">
|
||||
import { IconAlertTriangle, IconBrandTiktok, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
display_name: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface PublishConfig {
|
||||
privacyLevelOptions: string[];
|
||||
musicUsageConfirmationUrl: string;
|
||||
brandedContentPolicyUrl: string;
|
||||
}
|
||||
|
||||
interface CreatorInfo {
|
||||
creator_nickname: string | null;
|
||||
creator_username: string | null;
|
||||
creator_avatar_url: string | null;
|
||||
privacy_level_options: string[];
|
||||
comment_disabled: boolean;
|
||||
duet_disabled: boolean;
|
||||
stitch_disabled: boolean;
|
||||
max_video_post_duration_sec: number | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
socialAccount: SocialAccount | null;
|
||||
publishConfig: PublishConfig | null;
|
||||
creatorInfo?: CreatorInfo | null;
|
||||
creatorInfoLoading?: boolean;
|
||||
videoDurationSec?: number | null;
|
||||
meta: Record<string, any>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
creatorInfo: null,
|
||||
creatorInfoLoading: false,
|
||||
videoDurationSec: null,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:meta': [value: Record<string, any>];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const updateMeta = (patch: Record<string, any>) => {
|
||||
emit('update:meta', { ...props.meta, ...patch });
|
||||
};
|
||||
|
||||
const privacyLevel = computed({
|
||||
get: () => props.meta?.privacy_level ?? '',
|
||||
set: (value: string) => updateMeta({ privacy_level: value }),
|
||||
});
|
||||
|
||||
const autoAddMusic = computed({
|
||||
get: () => (props.meta?.auto_add_music ?? false) ? 'yes' : 'no',
|
||||
set: (value: string) => updateMeta({ auto_add_music: value === 'yes' }),
|
||||
});
|
||||
|
||||
const allowComments = computed({
|
||||
get: () => props.meta?.allow_comments ?? false,
|
||||
set: (value: boolean) => updateMeta({ allow_comments: value }),
|
||||
});
|
||||
|
||||
const allowDuet = computed({
|
||||
get: () => props.meta?.allow_duet ?? false,
|
||||
set: (value: boolean) => updateMeta({ allow_duet: value }),
|
||||
});
|
||||
|
||||
const allowStitch = computed({
|
||||
get: () => props.meta?.allow_stitch ?? false,
|
||||
set: (value: boolean) => updateMeta({ allow_stitch: value }),
|
||||
});
|
||||
|
||||
const isAigc = computed({
|
||||
get: () => props.meta?.is_aigc ?? false,
|
||||
set: (value: boolean) => updateMeta({ is_aigc: value }),
|
||||
});
|
||||
|
||||
const discloseOpen = computed({
|
||||
get: () => props.meta?.disclose ?? false,
|
||||
set: (value: boolean) => {
|
||||
if (value) {
|
||||
updateMeta({ disclose: true });
|
||||
return;
|
||||
}
|
||||
// turning disclosure off clears its sub-toggles
|
||||
updateMeta({ disclose: false, brand_organic_toggle: false, brand_content_toggle: false });
|
||||
},
|
||||
});
|
||||
|
||||
const brandOrganicToggle = computed({
|
||||
get: () => props.meta?.brand_organic_toggle ?? false,
|
||||
set: (value: boolean) => updateMeta({ brand_organic_toggle: value }),
|
||||
});
|
||||
|
||||
const brandContentToggle = computed({
|
||||
get: () => props.meta?.brand_content_toggle ?? false,
|
||||
set: (value: boolean) => updateMeta({ brand_content_toggle: value }),
|
||||
});
|
||||
|
||||
// Prefer the creator_info API response; fall back to the static list from the Platform enum.
|
||||
const allPrivacyOptions = computed(() => {
|
||||
const fromApi = props.creatorInfo?.privacy_level_options ?? [];
|
||||
return fromApi.length > 0 ? fromApi : props.publishConfig?.privacyLevelOptions ?? [];
|
||||
});
|
||||
|
||||
// Branded content cannot be private (TikTok compliance).
|
||||
const privacyOptions = computed(() =>
|
||||
brandContentToggle.value
|
||||
? allPrivacyOptions.value.filter((o) => o !== 'SELF_ONLY')
|
||||
: allPrivacyOptions.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));
|
||||
|
||||
// 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(() => {
|
||||
if (maxDurationSec.value === null || props.videoDurationSec === null) return false;
|
||||
return props.videoDurationSec > maxDurationSec.value;
|
||||
});
|
||||
|
||||
const privacyLabelKey: Record<string, string> = {
|
||||
PUBLIC_TO_EVERYONE: 'posts.form.tiktok.privacy.public',
|
||||
MUTUAL_FOLLOW_FRIENDS: 'posts.form.tiktok.privacy.friends',
|
||||
FOLLOWER_OF_CREATOR: 'posts.form.tiktok.privacy.followers',
|
||||
SELF_ONLY: 'posts.form.tiktok.privacy.private',
|
||||
};
|
||||
|
||||
const hasAnyBrandToggle = computed(() => brandOrganicToggle.value || brandContentToggle.value);
|
||||
|
||||
// Label required by TikTok: "Paid partnership" when branded content (with or without organic),
|
||||
// otherwise "Promotional content".
|
||||
const promotionalTitleKey = computed(() =>
|
||||
brandContentToggle.value ? 'posts.form.tiktok.promotional_paid_title' : 'posts.form.tiktok.promotional_organic_title',
|
||||
);
|
||||
|
||||
// If user flips branded content ON while privacy is SELF_ONLY, clear it so they must re-pick.
|
||||
watch(brandContentToggle, (value) => {
|
||||
if (value && privacyLevel.value === 'SELF_ONLY') {
|
||||
privacyLevel.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// When creator_info reveals that an interaction is disabled for this account, force the meta
|
||||
// flag off so we never submit a disallowed value.
|
||||
watch(
|
||||
() => [commentDisabled.value, duetDisabled.value, stitchDisabled.value] as const,
|
||||
([comment, duet, stitch]) => {
|
||||
const patch: Record<string, any> = {};
|
||||
if (comment && allowComments.value) patch.allow_comments = false;
|
||||
if (duet && allowDuet.value) patch.allow_duet = false;
|
||||
if (stitch && allowStitch.value) patch.allow_stitch = false;
|
||||
if (Object.keys(patch).length > 0) updateMeta(patch);
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between p-4 text-sm font-medium"
|
||||
@click="open = !open"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<IconBrandTiktok class="h-4 w-4" />
|
||||
{{ $t('posts.form.tiktok.settings') }}
|
||||
</span>
|
||||
<IconChevronUp v-if="open" class="h-4 w-4 text-muted-foreground" />
|
||||
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
<div v-if="open" class="space-y-5 border-t px-4 pb-4 pt-4">
|
||||
<p v-if="creatorInfoLoading" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="inline-block h-3 w-3 animate-pulse rounded-full bg-muted" />
|
||||
{{ $t('posts.form.tiktok.creator_info_loading') }}
|
||||
</p>
|
||||
|
||||
<!-- Creator identity -->
|
||||
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-muted/50 p-3">
|
||||
<Avatar
|
||||
:src="socialAccount.avatar_url"
|
||||
:name="socialAccount.display_name"
|
||||
class="h-9 w-9 shrink-0 rounded-full"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs text-muted-foreground">{{ $t('posts.form.tiktok.posting_to') }}</p>
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ socialAccount.display_name }}
|
||||
<span class="text-muted-foreground">@{{ socialAccount.username }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy Level -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ $t('posts.form.tiktok.privacy_level') }}</Label>
|
||||
<Select v-model="privacyLevel" :disabled="props.disabled">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue :placeholder="$t('posts.form.tiktok.privacy_placeholder')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in privacyOptions" :key="option" :value="option">
|
||||
{{ $t(privacyLabelKey[option] ?? option) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-xs text-muted-foreground">{{ $t('posts.form.tiktok.privacy_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Max duration warning -->
|
||||
<p v-if="exceedsMaxDuration" class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">
|
||||
<IconAlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
{{ $t('posts.form.tiktok.max_duration_exceeded', { duration: videoDurationSec ?? 0, max: maxDurationSec ?? 0 }) }}
|
||||
</p>
|
||||
|
||||
<!-- Auto Add Music (photos only) -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ $t('posts.form.tiktok.auto_add_music') }}</Label>
|
||||
<Select v-model="autoAddMusic" :disabled="props.disabled">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="yes">{{ $t('posts.form.tiktok.yes') }}</SelectItem>
|
||||
<SelectItem value="no">{{ $t('posts.form.tiktok.no') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-xs text-muted-foreground">{{ $t('posts.form.tiktok.auto_add_music_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Allow User To -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ $t('posts.form.tiktok.allow_users') }}</Label>
|
||||
<div class="flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
<label class="flex items-center gap-2 text-sm" :class="{ 'opacity-50': commentDisabled }" :title="commentDisabled ? $t('posts.form.tiktok.interaction_disabled_by_creator') : ''">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t" />
|
||||
|
||||
<!-- Video made with AI (independent) -->
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox v-model="isAigc" :disabled="props.disabled" />
|
||||
{{ $t('posts.form.tiktok.is_aigc') }}
|
||||
</label>
|
||||
|
||||
<!-- Disclose video content (parent toggle) -->
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center gap-2 text-sm font-medium">
|
||||
<Checkbox v-model="discloseOpen" :disabled="props.disabled" />
|
||||
{{ $t('posts.form.tiktok.disclose') }}
|
||||
</label>
|
||||
<p class="ml-6 text-xs text-muted-foreground">{{ $t('posts.form.tiktok.disclose_hint') }}</p>
|
||||
|
||||
<!-- Promotional Content warning (shown once a sub-toggle is picked) -->
|
||||
<div v-if="hasAnyBrandToggle" class="ml-6 flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-950/30">
|
||||
<IconAlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div class="text-xs text-amber-800 dark:text-amber-200">
|
||||
<p class="font-medium">{{ $t(promotionalTitleKey) }}</p>
|
||||
<p>{{ $t('posts.form.tiktok.promotional_description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compliance incomplete hint (disclose ON but no sub-toggle) -->
|
||||
<p v-else-if="discloseOpen" class="ml-6 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ $t('posts.form.tiktok.compliance_incomplete') }}
|
||||
</p>
|
||||
|
||||
<!-- Sub-toggles (only visible when disclose is on) -->
|
||||
<div v-if="discloseOpen" class="ml-6 space-y-3">
|
||||
<div class="space-y-1">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox v-model="brandOrganicToggle" :disabled="props.disabled" />
|
||||
{{ $t('posts.form.tiktok.brand_organic') }}
|
||||
</label>
|
||||
<p class="ml-6 text-xs text-muted-foreground">{{ $t('posts.form.tiktok.brand_organic_hint') }}</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox v-model="brandContentToggle" :disabled="props.disabled" />
|
||||
{{ $t('posts.form.tiktok.brand_content') }}
|
||||
</label>
|
||||
<p class="ml-6 text-xs text-muted-foreground">{{ $t('posts.form.tiktok.brand_content_hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compliance declaration -->
|
||||
<p v-if="hasAnyBrandToggle" class="text-xs text-muted-foreground">
|
||||
{{ $t('posts.form.tiktok.compliance.agree') }}
|
||||
<a
|
||||
:href="publishConfig?.musicUsageConfirmationUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="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"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
{{ $t('posts.form.tiktok.compliance.branded_policy') }}
|
||||
</a>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -84,12 +84,24 @@ interface Workspace {
|
|||
name: string;
|
||||
}
|
||||
|
||||
interface TikTokCreatorInfo {
|
||||
creator_nickname: string | null;
|
||||
creator_username: string | null;
|
||||
creator_avatar_url: string | null;
|
||||
privacy_level_options: string[];
|
||||
comment_disabled: boolean;
|
||||
duet_disabled: boolean;
|
||||
stitch_disabled: boolean;
|
||||
max_video_post_duration_sec: number | null;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
workspace: Workspace;
|
||||
post: Post;
|
||||
socialAccounts: SocialAccount[];
|
||||
platformConfigs: Record<string, any>;
|
||||
pinterestBoards: any[];
|
||||
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
|
||||
labels: { id: string; name: string; color: string }[];
|
||||
hashtags: { id: string; name: string; hashtags: string }[];
|
||||
authUserId: string;
|
||||
|
|
@ -107,6 +119,30 @@ const selectedPlatformIds = ref<string[]>(
|
|||
post.value.post_platforms.filter((pp) => pp.enabled).map((pp) => pp.id),
|
||||
);
|
||||
|
||||
// Per-platform meta (TikTok settings, Pinterest board, etc.)
|
||||
const platformMeta = ref<Record<string, Record<string, any>>>(
|
||||
Object.fromEntries(post.value.post_platforms.map((pp) => [pp.id, { ...(pp.meta ?? {}) }])),
|
||||
);
|
||||
|
||||
const updatePlatformMeta = (platformId: string, meta: Record<string, any>) => {
|
||||
platformMeta.value = { ...platformMeta.value, [platformId]: meta };
|
||||
};
|
||||
|
||||
// TikTok compliance per docs:
|
||||
// - privacy_level must be explicitly selected
|
||||
// - 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),
|
||||
);
|
||||
return tiktokPlatforms.every((pp) => {
|
||||
const meta = platformMeta.value[pp.id] ?? {};
|
||||
if (!meta.privacy_level) return false;
|
||||
if (meta.disclose && !meta.brand_organic_toggle && !meta.brand_content_toggle) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// Schedule
|
||||
const getLocalSchedule = () => {
|
||||
if (!post.value.scheduled_at) return '';
|
||||
|
|
@ -247,7 +283,7 @@ const getSubmitData = () => {
|
|||
.map((pp) => ({
|
||||
id: pp.id,
|
||||
content_type: pp.content_type,
|
||||
meta: pp.meta || {},
|
||||
meta: platformMeta.value[pp.id] ?? pp.meta ?? {},
|
||||
}));
|
||||
|
||||
return {
|
||||
|
|
@ -295,7 +331,7 @@ const triggerAutosave = () => {
|
|||
}
|
||||
};
|
||||
|
||||
watch([content, media, selectedPlatformIds, scheduledDateTime, selectedLabelIds], triggerAutosave, { deep: true });
|
||||
watch([content, media, selectedPlatformIds, scheduledDateTime, selectedLabelIds, platformMeta], triggerAutosave, { deep: true });
|
||||
|
||||
onUnmounted(() => {
|
||||
debouncedSave.cancel();
|
||||
|
|
@ -402,14 +438,15 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
|
|||
|
||||
<PickTimePopover
|
||||
v-model="scheduledDateTime"
|
||||
:disabled="isSubmitting || selectedPlatformIds.length === 0"
|
||||
:disabled="isSubmitting || selectedPlatformIds.length === 0 || !tiktokComplianceValid"
|
||||
@confirm="hasPickedTime = true"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="isSubmitting || selectedPlatformIds.length === 0"
|
||||
:disabled="isSubmitting || selectedPlatformIds.length === 0 || !tiktokComplianceValid"
|
||||
:title="!tiktokComplianceValid ? $t('posts.form.tiktok.compliance_incomplete') : ''"
|
||||
>
|
||||
<IconCalendar class="h-4 w-4" />
|
||||
{{ pickTimeLabel }}
|
||||
|
|
@ -419,7 +456,8 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
|
|||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
:disabled="isSubmitting || selectedPlatformIds.length === 0"
|
||||
:disabled="isSubmitting || selectedPlatformIds.length === 0 || !tiktokComplianceValid"
|
||||
:title="!tiktokComplianceValid ? $t('posts.form.tiktok.compliance_incomplete') : ''"
|
||||
@click="submit(hasPickedTime ? 'scheduled' : 'publishing')"
|
||||
>
|
||||
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
|
||||
|
|
@ -635,8 +673,13 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
|
|||
:labels="labels"
|
||||
:selected-label-ids="selectedLabelIds"
|
||||
:is-read-only="isReadOnly"
|
||||
:platform-configs="platformConfigs"
|
||||
:platform-meta="platformMeta"
|
||||
:tiktok-creator-infos="tiktokCreatorInfos"
|
||||
:media="media"
|
||||
@toggle-platform="togglePlatform"
|
||||
@toggle-label="toggleLabel"
|
||||
@update:platformMeta="updatePlatformMeta"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
|||
85
tests/Unit/Services/Social/TikTokCreatorInfoTest.php
Normal file
85
tests/Unit/Services/Social/TikTokCreatorInfoTest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Social\TikTokCreatorInfo;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->account = SocialAccount::factory()->tiktok()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'token_expires_at' => now()->addDays(1),
|
||||
]);
|
||||
|
||||
$this->service = new TikTokCreatorInfo;
|
||||
});
|
||||
|
||||
test('it returns full creator payload from api response', function () {
|
||||
Http::fake([
|
||||
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
|
||||
'data' => [
|
||||
'creator_nickname' => 'Paulo',
|
||||
'creator_username' => 'paulocastellano',
|
||||
'creator_avatar_url' => 'https://cdn.tiktok.com/avatar.jpg',
|
||||
'privacy_level_options' => ['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'SELF_ONLY'],
|
||||
'comment_disabled' => false,
|
||||
'duet_disabled' => true,
|
||||
'stitch_disabled' => true,
|
||||
'max_video_post_duration_sec' => 600,
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$info = $this->service->fetch($this->account);
|
||||
|
||||
expect($info['creator_nickname'])->toBe('Paulo')
|
||||
->and($info['creator_username'])->toBe('paulocastellano')
|
||||
->and($info['creator_avatar_url'])->toBe('https://cdn.tiktok.com/avatar.jpg')
|
||||
->and($info['privacy_level_options'])->toBe(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'SELF_ONLY'])
|
||||
->and($info['comment_disabled'])->toBeFalse()
|
||||
->and($info['duet_disabled'])->toBeTrue()
|
||||
->and($info['stitch_disabled'])->toBeTrue()
|
||||
->and($info['max_video_post_duration_sec'])->toBe(600);
|
||||
});
|
||||
|
||||
test('it returns an empty payload when the api fails', function () {
|
||||
Http::fake([
|
||||
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response(['error' => 'unauthorized'], 401),
|
||||
]);
|
||||
|
||||
$info = $this->service->fetch($this->account);
|
||||
|
||||
expect($info['creator_nickname'])->toBeNull()
|
||||
->and($info['privacy_level_options'])->toBe([])
|
||||
->and($info['comment_disabled'])->toBeFalse()
|
||||
->and($info['duet_disabled'])->toBeFalse()
|
||||
->and($info['stitch_disabled'])->toBeFalse()
|
||||
->and($info['max_video_post_duration_sec'])->toBeNull();
|
||||
});
|
||||
|
||||
test('it refreshes the token before calling when expired', function () {
|
||||
$this->account->update(['token_expires_at' => now()->subMinute()]);
|
||||
|
||||
Http::fake([
|
||||
'https://open.tiktokapis.com/v2/oauth/token/' => Http::response([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh',
|
||||
'expires_in' => 3600,
|
||||
], 200),
|
||||
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
|
||||
'data' => [
|
||||
'privacy_level_options' => ['PUBLIC_TO_EVERYONE'],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$this->service->fetch($this->account);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/oauth/token/'));
|
||||
expect($this->account->fresh()->access_token)->toBe('new-token');
|
||||
});
|
||||
Loading…
Reference in a new issue