Add privacy/terms pages and TikTok posting-screen requirements

- Public /privacy and /terms pages (Quorla, operated by Metatroncube
  Software Solutions) for platform app listings; includes Google API
  Limited Use statement and TikTok data use
- TikTok post panel follows the Content Posting API guidelines: shows the
  creator's name/avatar, audience options from creator info with no
  default, comment/Duet/Stitch off by default and locked when the creator
  disabled them, max video length, commercial content disclosure (Your
  brand / Branded content, branded content can't be private), Music Usage
  Confirmation / Branded Content Policy consent
- Placeholder app icon (branding/quorla-icon-1024.png)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-20 19:14:28 -04:00
parent f24e6ada4b
commit 4da66b41a9
9 changed files with 297 additions and 3 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@ -107,6 +107,14 @@ export async function checkConnection(connId) {
}
}
/** Platform-specific live account settings shown on the post screen (TikTok's creator info). */
export async function creatorInfo(connId) {
const { conn, tokens } = await tokensFor(connId);
const a = adapter(conn.platform);
if (!a.creatorInfo) throw err(`${a.name} has no creator settings.`);
return a.creatorInfo(tokens);
}
// ---------- posting ----------
/** Problems that stop a post going to this platform (checked when the draft is saved). */
export function validate(platform, client, post) {
@ -121,6 +129,7 @@ export function validate(platform, client, post) {
if (f.required && (opts[f.key] === undefined || opts[f.key] === "")) throw err(`${a.name}: ${f.label.toLowerCase()} is required.`);
if (f.max && String(opts[f.key] || "").length > f.max) throw err(`${a.name}: ${f.label.toLowerCase()} is longer than ${f.max} characters.`);
}
if (a.checkOptions) a.checkOptions(opts);
const cap = a.limits?.caption;
const caption = opts.caption ?? post.caption ?? "";
if (cap && caption.length > cap) throw err(`${a.name}: the caption is ${caption.length} characters; the limit is ${cap}. Shorten it or add a ${a.name} caption.`);

View File

@ -50,12 +50,32 @@ export default {
limits: { caption: 2200, images: 35 },
fields: [
{ key: "privacy", label: "Who can view", type: "select", options: PRIVACY, required: true, hint: "TikTok requires you to choose. Unaudited apps can only use “Only me”." },
{ key: "allowComments", label: "Allow comments", type: "checkbox", default: true },
{ key: "allowComments", label: "Allow comments", type: "checkbox", default: false },
{ key: "allowDuet", label: "Allow Duet", type: "checkbox", default: false, onlyFor: ["REELS"] },
{ key: "allowStitch", label: "Allow Stitch", type: "checkbox", default: false, onlyFor: ["REELS"] },
{ key: "title", label: "Photo post title", type: "text", max: 90, onlyFor: ["IMAGE", "CAROUSEL"], hint: "Optional, up to 90 characters. The caption becomes the description." },
{ key: "discloseCommercial", label: "Disclose commercial content", type: "checkbox", default: false, hint: "Turn on if this promotes the client's own business or a third party." },
{ key: "brandOrganic", label: "Your brand (labelled “Promotional content”)", type: "checkbox", default: false },
{ key: "brandedContent", label: "Branded content (labelled “Paid partnership”)", type: "checkbox", default: false },
],
/** What this creator allows right now (TikTok requires the post screen to follow it). */
async creatorInfo(tokens) {
const { data } = await request("TikTok", `${API}/post/publish/creator_info/query/`, { token: tokens.accessToken, method: "POST", json: {} });
const c = data.data || {};
return {
nickname: c.creator_nickname || null, username: c.creator_username || null, avatar: c.creator_avatar_url || null,
privacyOptions: c.privacy_level_options || [], commentDisabled: Boolean(c.comment_disabled), duetDisabled: Boolean(c.duet_disabled),
stitchDisabled: Boolean(c.stitch_disabled), maxVideoSeconds: c.max_video_post_duration_sec || null,
};
},
/** TikTok's commercial-content rules, checked when the draft is saved and again before posting. */
checkOptions(opts) {
if (opts.discloseCommercial && !opts.brandOrganic && !opts.brandedContent) throw new Error("TikTok: you turned on commercial content disclosure. Choose “Your brand”, “Branded content” or both.");
if (opts.brandedContent && opts.privacy === "SELF_ONLY") throw new Error("TikTok: branded content can't be private (“Only me”). Choose another audience.");
},
async identify(tokens) {
const { data } = await request("TikTok", `${API}/user/info/`, { token: tokens.accessToken, query: { fields: "open_id,avatar_url,display_name,username,follower_count,profile_deep_link" } });
const u = data.data?.user || {};
@ -65,7 +85,10 @@ export default {
async publish({ tokens, conn, post, opts, caption }) {
const token = tokens.accessToken;
if (!opts.privacy) throw new Error("TikTok: choose who can view the post.");
this.checkOptions(opts);
const creator = (await request("TikTok", `${API}/post/publish/creator_info/query/`, { token, method: "POST", json: {} })).data.data || {};
const brand = { brand_content_toggle: Boolean(opts.discloseCommercial && opts.brandedContent), brand_organic_toggle: Boolean(opts.discloseCommercial && opts.brandOrganic) };
const noComment = opts.allowComments !== true || Boolean(creator.comment_disabled);
if (creator.privacy_level_options && !creator.privacy_level_options.includes(opts.privacy)) {
throw new Error(`TikTok: this account can't post as “${opts.privacy}”. Allowed: ${creator.privacy_level_options.join(", ")}.`);
}
@ -77,7 +100,7 @@ export default {
const { data } = await request("TikTok", `${API}/post/publish/video/init/`, {
token, method: "POST",
json: {
post_info: { title: String(caption || "").slice(0, 2200), privacy_level: opts.privacy, disable_comment: opts.allowComments === false, disable_duet: !opts.allowDuet, disable_stitch: !opts.allowStitch },
post_info: { title: String(caption || "").slice(0, 2200), privacy_level: opts.privacy, disable_comment: noComment, disable_duet: !opts.allowDuet || Boolean(creator.duet_disabled), disable_stitch: !opts.allowStitch || Boolean(creator.stitch_disabled), ...brand },
source_info: { source: "FILE_UPLOAD", video_size: bytes.length, chunk_size: plan.chunkSize, total_chunk_count: plan.count },
},
});
@ -93,7 +116,7 @@ export default {
const { data } = await request("TikTok", `${API}/post/publish/content/init/`, {
token, method: "POST",
json: {
post_info: { title: String(opts.title || "").slice(0, 90), description: String(caption || "").slice(0, 4000), privacy_level: opts.privacy, disable_comment: opts.allowComments === false, auto_add_music: true },
post_info: { title: String(opts.title || "").slice(0, 90), description: String(caption || "").slice(0, 4000), privacy_level: opts.privacy, disable_comment: noComment, auto_add_music: true, ...brand },
source_info: { source: "PULL_FROM_URL", photo_cover_index: 0, photo_images: urls },
post_mode: "DIRECT_POST", media_type: "PHOTO",
},

View File

@ -214,6 +214,21 @@ test("TikTok: connect, privacy is enforced, video uploads and photo posts pull f
assert.equal(init.source_info.total_chunk_count, 1);
assert.equal(init.post_info.privacy_level, "SELF_ONLY");
assert.equal(sent(/upload\.tiktok/)[0].headers["Content-Range"], "bytes 0-1023/1024");
assert.equal(init.post_info.disable_comment, true, "comments are off unless the person turns them on");
assert.equal(init.post_info.brand_content_toggle, false);
// commercial content disclosure rules
assert.throws(() => P.validate("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "SELF_ONLY", discloseCommercial: true } } }), /Choose “Your brand”/);
assert.throws(() => P.validate("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "SELF_ONLY", discloseCommercial: true, brandedContent: true } } }), /can't be private/);
routes = routes.filter((x) => !String(x.match).includes("creator_info"));
route("POST", "https://open.tiktokapis.com/v2/post/publish/creator_info/query/", json({ data: { creator_nickname: "Acme", creator_username: "acme", privacy_level_options: ["PUBLIC_TO_EVERYONE", "SELF_ONLY"], comment_disabled: true, max_video_post_duration_sec: 600 }, error: { code: "ok" } }));
const info = await P.creatorInfo(conn.id);
assert.deepEqual([info.nickname, info.commentDisabled, info.maxVideoSeconds, info.privacyOptions.length], ["Acme", true, 600, 2]);
calls = [];
await P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "Promo", options: { tiktok: { privacy: "PUBLIC_TO_EVERYONE", allowComments: true, discloseCommercial: true, brandOrganic: true } } });
const promo = bodyJson(sent(/video\/init/)[0]).post_info;
assert.equal(promo.brand_organic_toggle, true);
assert.equal(promo.brand_content_toggle, false);
assert.equal(promo.disable_comment, true, "creator turned comments off in TikTok, so they stay off");
route("POST", "https://open.tiktokapis.com/v2/post/publish/content/init/", json({ data: { publish_id: "pub2" }, error: { code: "ok" } }));
await P.publish("tiktok", "acme", { type: "CAROUSEL", media: [IMG, IMG2], caption: "Menu", options: { tiktok: { privacy: "SELF_ONLY", title: "New menu" } } });
const photo = bodyJson(sent(/content\/init/)[0]);

View File

@ -2,6 +2,7 @@
import fs from "node:fs";
import path from "node:path";
import express from "express";
import { fileURLToPath } from "node:url";
import { PORT, WEB_DIST, AGENCY_NAME, BRANDING_DIR, ODOO } from "./config.js";
import { login, logout, currentUser, requireAuth, requireAdmin, setupFirstAdmin, issueSession } from "./auth.js";
import * as users from "./users.js";
@ -49,6 +50,12 @@ app.get("/api/auth/me", (req, res) => res.json({ user: currentUser(req), agency:
app.get("/brand/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-logo.png")));
app.get("/brand/mark.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-mark.png")));
// Public legal pages (linked from platform app listings: TikTok, Google, LinkedIn, Pinterest, Meta).
const LEGAL_DIR = fileURLToPath(new URL("./legal/", import.meta.url));
app.get("/privacy", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "privacy.html")));
app.get("/terms", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "terms.html")));
app.get("/legal/legal.css", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "legal.css")));
// Signed, expiring media links that platforms (TikTok, Threads, Google Business Profile) fetch posts from.
app.get("/m/:token/:name", (req, res) => {
const f = resolveMediaToken(req.params.token);
@ -99,6 +106,7 @@ app.get("/api/oauth/:platform/start", requireAdmin, h(async (req) => {
return { url: extra.startConnect({ platform: req.params.platform, client, redirectUri: redirectUri(req, req.params.platform), user: req.user }) };
}));
app.post("/api/connections/:id/check", requireAdmin, h(async (req) => ({ connection: await extra.checkConnection(req.params.id) })));
app.get("/api/connections/:id/creator-info", h(async (req) => ({ creator: await extra.creatorInfo(req.params.id) })));
app.get("/api/connections/:id/targets", h(async (req) => ({ targets: await extra.targetsFor(req.params.id) })));
app.patch("/api/connections/:id", requireAdmin, h(async (req) => ({ connection: await extra.setTarget(req.params.id, String(req.body?.targetId || "")) })));
app.delete("/api/connections/:id", requireAdmin, h(async (req) => { extra.vault.removeConnection(req.params.id); return { ok: true }; }));

View File

@ -0,0 +1,13 @@
:root { --bg: #ffffff; --fg: #1f2024; --muted: #6b6e78; --line: #e4e7ec; --accent: #2c5f96; color-scheme: light dark; }
@media (prefers-color-scheme: dark) { :root { --bg: #14171b; --fg: #e8eaed; --muted: #9aa0a8; --line: #2a2f36; --accent: #7fb2e5; } }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.65 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 720px; margin: 0 auto; padding: 48px 20px 80px; }
.brand { font-weight: 800; letter-spacing: .02em; color: var(--accent); margin: 0 0 24px; }
h1 { font-size: 32px; line-height: 1.2; margin: 0 0 6px; }
.meta { color: var(--muted); font-size: 14px; margin: 0 0 28px; }
h2 { font-size: 19px; margin: 32px 0 8px; padding-top: 20px; border-top: 1px solid var(--line); }
ul { padding-left: 1.2em; }
li { margin: 6px 0; }
a { color: var(--accent); }
.links { margin-top: 40px; font-size: 14px; }

View File

@ -0,0 +1,75 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Privacy Policy · Quorla</title>
<link rel="stylesheet" href="/legal/legal.css">
</head>
<body>
<main>
<p class="brand">Quorla</p>
<h1>Privacy Policy</h1>
<p class="meta">Effective 20 October 2026 · Operated by Metatroncube Software Solutions, Waterloo, Ontario, Canada</p>
<p>Quorla is a social media management service used by marketing agencies to plan, approve and publish content for the businesses they work with, and to see how that content performs. This policy explains what information Quorla handles, why, and the choices you have.</p>
<h2>Who this covers</h2>
<ul>
<li><b>Agency users</b>: people who sign in to Quorla (agency owners, managers, designers, editors).</li>
<li><b>Connected account owners</b>: businesses and creators whose social media accounts an agency connects to Quorla with the owner's permission.</li>
</ul>
<h2>Information we collect</h2>
<ul>
<li><b>Account details</b>: name, work email and role of agency users. Passwords are stored only as one-way hashes.</li>
<li><b>Content</b>: images, videos, captions, plans and reports that agency users upload or create.</li>
<li><b>Connected platform data</b>: when an account is connected, Quorla receives access tokens and the data each platform allows for the permissions granted, such as the account's ID, name, username, profile picture, follower count, posts, post insights and comments.</li>
<li><b>Technical logs</b>: sign-in times, actions such as approvals and publishing results, and errors, used to run and secure the service.</li>
</ul>
<h2>Data from connected platforms</h2>
<p>Quorla only uses platform data to provide the features the account owner and their agency use, and only within each platform's terms:</p>
<ul>
<li><b>TikTok</b> (Login Kit, Content Posting API): basic profile information (open ID, display name, username, avatar) and follower counts to show which account is connected; and permission to upload and publish videos and photo posts that a person has reviewed and approved in Quorla. Quorla does not post anything without that approval and does not add watermarks or promotional branding to content.</li>
<li><b>Meta</b> (Facebook, Instagram, Threads): Page and account details, insights, posts and comments for reporting and replies, and publishing of approved posts.</li>
<li><b>Google</b> (YouTube, Google Business Profile): channel and business location details, and uploading or posting approved content. Quorla's use and transfer of information received from Google APIs adheres to the <a href="https://developers.google.com/terms/api-services-user-data-policy">Google API Services User Data Policy</a>, including the Limited Use requirements.</li>
<li><b>LinkedIn, Pinterest and Reddit</b>: profile, Page or board details needed to post, and publishing of approved content.</li>
</ul>
<p>We never sell platform data, never use it for advertising, and never use it to train AI models.</p>
<h2>How we use information</h2>
<ul>
<li>To run Quorla: sign-in, content library, scheduling, approvals, publishing and reports.</li>
<li>To produce AI-assisted drafts (content plans, captions, reports). Relevant content and performance data are processed by our AI provider, Anthropic, under terms that do not allow it to train models on that data.</li>
<li>To keep the service secure and to fix problems.</li>
</ul>
<h2>Sharing</h2>
<p>Information is shared only with the agency that controls the account in Quorla, with the social platforms when you publish to them, with service providers that host or process data for us (hosting, AI processing, email), or when the law requires it. Each provider may use the data only to provide its service to us.</p>
<h2>Security and storage</h2>
<p>Quorla is served over HTTPS. Platform access tokens and app keys are encrypted at rest. Access is limited to the agency's signed-in users, and only admins can connect accounts or approve posts.</p>
<h2>Retention and deletion</h2>
<ul>
<li>Disconnecting an account in Quorla deletes its stored access tokens immediately. You can also remove Quorla's access in the platform's own settings (for example TikTok: Settings and privacy → Security → Manage app permissions).</li>
<li>Content and reports are kept while the agency uses Quorla, then deleted within 90 days after the agency's account closes, unless the law requires us to keep them longer.</li>
<li>To ask for a copy or deletion of your information, email us at the address below. We respond within 30 days.</li>
</ul>
<h2>Your rights</h2>
<p>Depending on where you live (for example under Canada's PIPEDA or the EU/UK GDPR), you may have the right to access, correct, delete or move your information, and to object to some processing. Contact us to use these rights.</p>
<h2>Children</h2>
<p>Quorla is a business tool and is not intended for anyone under 18.</p>
<h2>Changes</h2>
<p>We will post changes on this page and update the date at the top. For significant changes we will notify agency admins.</p>
<h2>Contact</h2>
<p>Metatroncube Software Solutions, Waterloo, Ontario, Canada<br>Email: <a href="mailto:support@quorlasocial.com">support@quorlasocial.com</a></p>
<p class="links"><a href="/terms">Terms of Service</a></p>
</main>
</body>
</html>

View File

@ -0,0 +1,68 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Terms of Service · Quorla</title>
<link rel="stylesheet" href="/legal/legal.css">
</head>
<body>
<main>
<p class="brand">Quorla</p>
<h1>Terms of Service</h1>
<p class="meta">Effective 20 October 2026 · Operated by Metatroncube Software Solutions, Waterloo, Ontario, Canada</p>
<p>These terms apply to anyone who uses Quorla, the social media management service operated by Metatroncube Software Solutions ("we", "us"). By signing in to or using Quorla you agree to them.</p>
<h2>1. The service</h2>
<p>Quorla lets agencies plan, review, approve and publish content to social media accounts they manage, and see performance. Posts are published only after a person with admin rights approves them.</p>
<h2>2. Accounts</h2>
<ul>
<li>You must give accurate details and keep your password private. You are responsible for activity under your account.</li>
<li>Agency admins decide who has access and what role they have.</li>
</ul>
<h2>3. Connecting social accounts</h2>
<ul>
<li>You may only connect accounts you own or that the owner has authorized you to manage, and only for the purposes the owner agreed to.</li>
<li>When you connect or post to a platform, that platform's own terms and policies also apply, including the <a href="https://www.tiktok.com/legal/terms-of-service">TikTok Terms of Service</a>, TikTok's <a href="https://www.tiktok.com/legal/page/global/music-usage-confirmation/en">Music Usage Confirmation</a> and <a href="https://www.tiktok.com/legal/page/global/bc-policy/en">Branded Content Policy</a>, Meta's terms, the <a href="https://www.youtube.com/t/terms">YouTube Terms of Service</a>, and the terms of LinkedIn, Pinterest and Reddit.</li>
<li>You can disconnect any account at any time in Quorla, or remove Quorla's access in the platform's settings.</li>
</ul>
<h2>4. Your content</h2>
<p>You keep all rights to the content you upload or create. You give us permission to store, process and publish it only as needed to provide Quorla to you. You confirm you have the rights to everything you publish, including music, images and people's likenesses, and that paid partnerships and promotional content are disclosed as the law and each platform require.</p>
<h2>5. Acceptable use</h2>
<p>You must not use Quorla to:</p>
<ul>
<li>post spam, misleading content, fake reviews or testimonials, or anything illegal;</li>
<li>buy followers or engagement, or run engagement schemes;</li>
<li>break any platform's rules or rate limits, or try to get around their restrictions;</li>
<li>access accounts or data you aren't authorized to use, or interfere with the service's security.</li>
</ul>
<h2>6. AI features</h2>
<p>AI-generated drafts are suggestions. A person must review them before they are published, and you are responsible for what you approve.</p>
<h2>7. Fees</h2>
<p>Fees, if any, are set out in your plan or agreement with us.</p>
<h2>8. Availability and changes</h2>
<p>We work to keep Quorla available but don't guarantee it will be uninterrupted. Platforms can change or limit their APIs at any time, which may affect features. We may update Quorla and these terms; we'll post changes here and notify agency admins of significant ones.</p>
<h2>9. Disclaimer and liability</h2>
<p>Quorla is provided "as is". To the extent the law allows, we are not liable for indirect or consequential losses, for actions taken by social platforms, or for content you approve. Our total liability for any claim is limited to the fees you paid us in the 12 months before the claim.</p>
<h2>10. Ending use</h2>
<p>You can stop using Quorla at any time. We may suspend accounts that break these terms. Our <a href="/privacy">Privacy Policy</a> explains what happens to data afterwards.</p>
<h2>11. Governing law</h2>
<p>These terms are governed by the laws of the Province of Ontario and the federal laws of Canada that apply there.</p>
<h2>Contact</h2>
<p>Metatroncube Software Solutions, Waterloo, Ontario, Canada<br>Email: <a href="mailto:support@quorlasocial.com">support@quorlasocial.com</a></p>
<p class="links"><a href="/privacy">Privacy Policy</a></p>
</main>
</body>
</html>

View File

@ -47,7 +47,90 @@ function TargetSelect({ conn, value, onChange, id }) {
);
}
const TT_PRIVACY = { PUBLIC_TO_EVERYONE: "Everyone", MUTUAL_FOLLOW_FRIENDS: "Friends", FOLLOWER_OF_CREATOR: "Followers", SELF_ONLY: "Only me" };
/** TikTok's post screen, following its Content Posting API guidelines (creator info, no default audience, disclosure, consent). */
function TikTokFields({ platform, type, options, setOption, caption }) {
const [creator, setCreator] = useState(null);
const [error, setError] = useState(null);
useEffect(() => { api(`/api/connections/${platform.conn.id}/creator-info`).then((r) => setCreator(r.creator)).catch(setError); }, [platform.conn.id]);
const video = type === "REELS";
const branded = Boolean(options.discloseCommercial && options.brandedContent);
const organic = Boolean(options.discloseCommercial && options.brandOrganic);
const check = (key, label, disabled, note) => (
<label htmlFor={`opt-tiktok-${key}`} className={cx("flex items-center gap-2 text-[13px]", disabled && "text-faint")}>
<input id={`opt-tiktok-${key}`} type="checkbox" disabled={disabled} checked={!disabled && Boolean(options[key])} onChange={(e) => setOption(key, e.target.checked)} />
{label}{disabled && note && <span className="text-[12px]">({note})</span>}
</label>
);
const limit = platform.limits?.caption;
const used = (options.caption ?? caption ?? "").length;
return (
<div className="rounded-lg border border-line p-3 space-y-3">
<div className="flex items-center gap-2 text-[13px]">
<span className="size-2.5 rounded-full" style={{ background: platform.color }} aria-hidden="true" />
<span className="font-semibold">TikTok</span>
{creator && <>
<span className="text-muted">· posting to</span>
{creator.avatar && <img src={creator.avatar} alt="" className="size-5 rounded-full" />}
<span className="font-medium">{creator.nickname}</span>{creator.username && <span className="text-muted">@{creator.username}</span>}
</>}
</div>
{error && <div className="text-[12.5px] text-bad">Couldn't load this TikTok account's settings: {error.message}. Reconnect TikTok on the Connections page.</div>}
{!creator && !error && <div className="text-[12.5px] text-muted">Loading the account's TikTok settings…</div>}
{creator && (
<>
<div className="grid grid-cols-2 gap-3">
<Field label="Who can view this post" hint={branded ? "Branded content can't be private." : undefined}>
<select id="opt-tiktok-privacy" className="input" value={options.privacy || ""} onChange={(e) => setOption("privacy", e.target.value)}>
<option value="" disabled>Choose…</option>
{creator.privacyOptions.map((p) => <option key={p} value={p} disabled={branded && p === "SELF_ONLY"}>{TT_PRIVACY[p] || p}</option>)}
</select>
</Field>
{!video && (
<Field label="Title (optional)" hint="Up to 90 characters.">
<input id="opt-tiktok-title" className="input" maxLength={90} value={options.title || ""} onChange={(e) => setOption("title", e.target.value)} />
</Field>
)}
</div>
<div className="flex flex-wrap gap-x-5 gap-y-1.5">
{check("allowComments", "Allow comments", creator.commentDisabled, "off in this account's TikTok settings")}
{video && check("allowDuet", "Allow Duet", creator.duetDisabled, "off in TikTok settings")}
{video && check("allowStitch", "Allow Stitch", creator.stitchDisabled, "off in TikTok settings")}
</div>
{video && creator.maxVideoSeconds && <div className="text-[12px] text-muted">This account can post videos up to {Math.floor(creator.maxVideoSeconds / 60)} min {creator.maxVideoSeconds % 60 ? `${creator.maxVideoSeconds % 60} s` : ""}.</div>}
<div className="rounded-md bg-canvas p-2.5 space-y-2">
<label htmlFor="opt-tiktok-discloseCommercial" className="flex items-center gap-2 text-[13px] font-medium">
<input id="opt-tiktok-discloseCommercial" type="checkbox" checked={Boolean(options.discloseCommercial)} onChange={(e) => setOption("discloseCommercial", e.target.checked)} />
Disclose commercial content
</label>
<p className="text-[12px] text-muted">Turn on if this post promotes the client's own business, a product or a third party.</p>
{options.discloseCommercial && (
<div className="pl-6 space-y-1.5">
{check("brandOrganic", "Your brand: you're promoting yourself or your own business")}
{check("brandedContent", "Branded content: you're promoting another brand or a third party", options.privacy === "SELF_ONLY", "not available for “Only me”")}
{(organic || branded) && <div className="text-[12px] text-ink-2">The post will be labelled “{branded ? "Paid partnership" : "Promotional content"}”.</div>}
{!organic && !branded && <div className="text-[12px] text-warn">Choose at least one option.</div>}
</div>
)}
</div>
</>
)}
<Field label="TikTok caption (optional)" hint={`Leave empty to use the main caption. ${used}/${limit} characters.`}>
<textarea id="opt-tiktok-caption" className={cx("input h-16 py-2", used > limit && "border-bad")} value={options.caption ?? ""} onChange={(e) => setOption("caption", e.target.value || undefined)} />
</Field>
<p className="text-[12px] text-muted">
By posting, you agree to TikTok's <a className="underline" href="https://www.tiktok.com/legal/page/global/music-usage-confirmation/en" target="_blank" rel="noreferrer">Music Usage Confirmation</a>
{branded && <> and <a className="underline" href="https://www.tiktok.com/legal/page/global/bc-policy/en" target="_blank" rel="noreferrer">Branded Content Policy</a></>}.
{" "}After it's published, the post can take a few minutes to appear on the profile.
</p>
</div>
);
}
function PlatformFields({ platform, type, options, setOption, caption }) {
if (platform.id === "tiktok") return <TikTokFields platform={platform} type={type} options={options} setOption={setOption} caption={caption} />;
const fields = platform.fields.filter((f) => !f.onlyFor || f.onlyFor.includes(type));
const own = options.caption;
const limit = platform.limits?.caption;