+mp.usd+' once'; const p=document.createElement('div'); p.className='small'; p.textContent='TikTok posts through the upload method with no monthly cap for '+mp.months+' months, on this account. One payment, no subscription; your plan stays Free. Full refund within 14 days if it did not work for you.'; const a=document.createElement('a'); a.className='btn sm'; a.href=mp.url; a.style.marginTop='8px'; a.textContent='Get the Method Pass — // label — for the person adding a client or a teammate. Never before the first connection (the block above). const fo = d && d.upgrade_featured; if(fo && fo.url && !(d.connect_first && d.connect_first.step!=='publish') && !(tc && tc.connect)){ const box=document.createElement('div'); box.className='wg wall-featured'; box.style.cssText='padding:10px 12px;border:1.5px solid var(--wire);background:var(--wire-soft);border-radius:10px'; const h=document.createElement('div'); h.style.fontWeight='600'; h.textContent=(fo.label||('For agencies: '+fo.name))+(fo.price_usd_month!=null?' — $'+fo.price_usd_month+'/month':''); const p=document.createElement('div'); p.className='small'; p.textContent=(fo.pitch||fo.unlocks||'')+' Cancel any time; full refund within 14 days of the first payment.'; const a=document.createElement('a'); a.className='btn ghost sm'; a.href=fo.url; a.style.marginTop='8px'; a.textContent='Get '+fo.name+' →'; a.onclick=()=>{ track('quota_upgrade_click', 'wall:'+code+' plan='+fo.plan+' alt=1 exp=e2'); }; box.appendChild(h); box.appendChild(p); // The Agency card trial (AGENCY_TRIAL switch, src/plans.js): its own button first, with its trust lines; the plain // checkout stays as the second link. if(fo.trial && fo.trial.url){ const ta=document.createElement('a'); ta.className='btn sm'; ta.href=fo.trial.url; ta.style.cssText='margin-top:8px;margin-right:8px'; ta.textContent=fo.trial.label; ta.onclick=()=>{ track('quota_upgrade_click', 'wall:'+code+' plan='+fo.plan+' alt=1 exp=e2 trial=1'); }; const tl=document.createElement('div'); tl.className='small'; tl.textContent=fo.trial.trust||''; box.appendChild(ta); box.appendChild(a); box.appendChild(tl); } else box.appendChild(a); t.appendChild(box); } // PostWire Coach: the same wall with this post's numbers and the price per day (src/coach.js wallMoment). if(d && d.coach_moment && window.PWCoach){ const cm=document.createElement('div'); cm.innerHTML=PWCoach.momentHtml(d.coach_moment,'wall'); t.appendChild(cm); } const x = document.createElement('button'); x.className='x'; x.setAttribute('aria-label','Dismiss'); x.innerHTML=''; x.onclick = ()=>t.remove(); t.appendChild(x); $('#toasts').appendChild(t); track('upgrade_seen', 'wall:'+code+' plan='+((up&&up.plan)||'-')+(fo&&fo.plan?' alt='+fo.plan:'')); }catch(e){} } /* ---- The free plan's locks (gtm/12-free-vs-pro.md, 2-oct): networks per post (C1), the queue (C2) and the What works patterns (C4). The work is always done and shown — the drafts written, the week planned, the patterns found — and only the last step waits behind the lock. The numbers come from /api/me (limits.networks_per_post / limits.scheduled_queue), so a grandfathered account sees no lock at all. */ function planLim(){ const l=(ME&&ME.limits)||{}, n=l.networks_per_post||{}, q=l.scheduled_queue||{}; return { nets: (n.limit==null?null:n.limit), queue: (q.limit==null?null:q.limit), queued: q.used||0, held: q.held||0 }; } // No paywall before value (8-oct-2026, src/preconnect.js): until the first network is connected, every lock and wall // says what the feature does and offers ONE thing — connecting a network. The server decides (/api/me `preconnect`) and // strips the offers from its answers; the page follows it and drops it the moment a network is connected here. const CONNECT_FIRST_LABEL='Connect your first network'; function preconnectNow(){ return !!(ME && ME.preconnect && !((window.__conns||[]).length)); } // A-offer-moment (9-oct-2026): connected but nothing published yet (/api/me `prepublish`) is NOT a lock state: the // locks and walls of the approvals, team, brands and queue screens keep their upgrade button (review 9-oct-2026, // profit protection). Only the offers nobody asked for wait for the first post (the server strips them; the trial // cards of this page already wait for ever_posted). function goConnectFirst(){ navTo('accounts'); const t=$('#netTiles'); if(!t) return; t.scrollIntoView({behavior:'smooth',block:'center'}); const f=t.querySelector('.tile:not(.on)')||t.querySelector('.tile'); if(f) setTimeout(()=>f.focus({preventScroll:true}),300); } // The button every pre-connect lock draws (the approvals, team and automation screens use it too): one click handler. function connectFirstBtn(where, cls){ return ''; } document.addEventListener('click', (e)=>{ const b=e.target && e.target.closest ? e.target.closest('[data-connect-first]') : null; if(!b) return; e.preventDefault(); track('connect_first_click', 'lock:'+(b.dataset.connectFirst||'-')); goConnectFirst(); }); // The way out of a lock, in this order: connect a first network (nothing connected yet: no price, no trial); the Pro // trial when this account can still have it — the card trial as soon as a network is connected (8-oct-2026: where the // intent is highest), the no-card one after a first post; otherwise the normal checkout for the plan that lifts it. // `d`: the wall's answer, when there is one (its trial_offer / upgrade.cta say the server's decision). // What a plan buys, said on a wall (21-oct-2026): the whole promise for Starter, the plan's own unlocks line otherwise, // then the three trust lines. HTML. function wallPitch(up){ const hasPrice = up.price_usd_month!=null && up.price_usd_month!==''; const head = esc(up.name)+(hasPrice?' — $'+esc(up.price_usd_month)+'/month':''); const s=(PLANS_INFO && PLANS_INFO.starter)||{}; const body = up.plan==='starter' ? Number(s.limit||1000).toLocaleString('en-US')+' posts a month, every network in one post, unlimited scheduling, files up to 1 GB with no daily upload cap, '+Number(s.genDay||60)+' AI drafts a day, 3 brands.' : esc(up.unlocks||'')+'.'; return ''+head+': '+body+'
'+ICON_CHECK+'Starts the moment you pay'+ICON_CHECK+'Cancel anytime'+ICON_CHECK+'Full refund within 14 days
'; } function lockCta(d){ if(preconnectNow()) return { label:CONNECT_FIRST_LABEL, connect:true, plan:'-' }; // 21-oct-2026: the wall's own plan when the server sent a smaller plan than the trial's as the main offer. if(d && d.secondary_offer && d.upgrade && d.upgrade.url) return { label:'Upgrade to '+d.upgrade.name+' — $'+d.upgrade.price_usd_month+'/mo', trial:false, plan:d.upgrade.plan, url:d.upgrade.url }; const o = (d && d.trial_offer && d.trial_offer.url && Object.assign({ card:true }, d.trial_offer)) || trialOffer(); if(o && (o.reverse ? (ME && ME.ever_posted) : true)) return { label:trialBtnLabel(o), trial:true, plan:o.plan, offer:o }; const s=(PLANS_INFO && PLANS_INFO.starter) || {name:'Starter', price:9}; const sh = ME && ME.starter_half && ME.starter_half.plan==='starter' ? ME.starter_half : null; // arm B of the Starter A/B (21-oct) return { label:'Unlock with '+s.name+' — '+(sh ? '$'+sh.first_month_usd.toFixed(2)+' first month' : '$'+s.price+'/mo'), trial:false, plan:'starter' }; } // 21-oct-2026 (Renzo): the payment happens HERE, inside the dashboard — Stripe Embedded Checkout, as on rotatepilot.com — // never a jump to checkout.stripe.com with somebody's header on it. The server returns { client_secret } to mount; a plan // switch on a running subscription or an unpaid invoice still comes back as { url } and goes there. Every former // `location.href=url` after POST /api/billing/checkout comes through here. On success the webhook moves the plan, so we // poll /api/me a few seconds and reload with ?upgraded=1 (billingReturn thanks them either way). let STRIPE_JS=null, PW_CHK=null; function stripeJs(){ if(STRIPE_JS) return STRIPE_JS; STRIPE_JS=new Promise((ok,no)=>{ if(window.Stripe) return ok(window.Stripe); const s=document.createElement('script'); s.src='https://js.stripe.com/v3/'; s.onload=()=>ok(window.Stripe); s.onerror=()=>{ STRIPE_JS=null; no(new Error('the payment form did not load — check your connection and try again')); }; document.head.appendChild(s); }); return STRIPE_JS; } function closePay(){ try{ if(PW_CHK) PW_CHK.destroy(); }catch(e){} PW_CHK=null; const b=$('#pwPay'); if(b) b.remove(); } async function openCheckout(body){ const r=await api('/api/billing/checkout',{method:'POST',body:JSON.stringify(Object.assign({embedded:true},body||{}))}); if(r.url){ location.href=r.url; return; } if(!r.client_secret) throw new Error('no checkout came back'); const pk=(typeof ME!=='undefined'&&ME&&ME.stripe_pk)||null; if(!pk) throw new Error('payments are not set up on this server'); const Stripe=await stripeJs(); closePay(); const box=document.createElement('div'); box.id='pwPay'; box.innerHTML=''; document.body.appendChild(box); const before=window.__mePlan||'free'; const done=()=>{ track('checkout_complete','embedded plan='+(body&&body.plan||'')); const m=$('#pwPayMount'); if(m) m.innerHTML='

Payment received — activating your plan…

'; let n=0; const tick=async()=>{ n++; try{ const me=await api('/api/me'); if((me.plan||'free')!==before || n>=12){ location.href='/dashboard.html?upgraded=1'; return; } }catch(e){} setTimeout(tick,2000); }; setTimeout(tick,1500); }; const inst=window.__stripe||(window.__stripe=Stripe(pk)); PW_CHK=await inst.initEmbeddedCheckout({clientSecret:r.client_secret, onComplete:done}); $('#pwPayMount').innerHTML=''; PW_CHK.mount('#pwPayMount'); box.querySelector('.pwpay-x').onclick=()=>{ track('checkout_close','embedded plan='+(body&&body.plan||'')); closePay(); toast('Nothing was charged. The plans stay in Settings whenever you are ready.'); }; } async function runLockCta(code, btn, d){ const c=lockCta(d); if(c.connect){ track('connect_first_click', 'lock:'+code); return goConnectFirst(); } track('quota_upgrade_click', 'lock:'+code+' plan='+c.plan+(c.trial?' trial=1':'')); if(c.trial) return startTrial(('lock_'+code).slice(0,30), btn, c.offer); // the offer the button names (the wall's own, when it sent one) if(c.url){ location.href=c.url; return; } const label=btn?btn.textContent:''; if(btn){ btn.disabled=true; btn.textContent='Opening the secure checkout…'; } try{ await openCheckout({plan:c.plan}); if(btn){ btn.disabled=false; btn.textContent=label; } } catch(e){ if(btn){ btn.disabled=false; btn.textContent=label; } toast('Could not open the checkout: '+e.message,'bad'); } } function lockBtn(code, cls){ return ''; } function wireLocks(host){ (host||document).querySelectorAll('[data-lock]').forEach(b=>b.onclick=()=>runLockCta(b.dataset.lock,b)); } const LOCK_IC=''; const SEEN_LOCKS=new Set(); function lockSeen(code, detail){ if(SEEN_LOCKS.has(code)) return; SEEN_LOCKS.add(code); track('upgrade_seen', code+(detail?' '+detail:'')); } const andList=(a)=>a.length<=1?(a[0]||''):a.slice(0,-1).join(', ')+' and '+a[a.length-1]; // Accepts a selector or the element itself. refineCard() passes the element, and $(element) threw // "Failed to execute querySelector" BEFORE the request went out, so Shorten/Regenerate/Expand/Formal/ // Casual never worked for anybody (found 26-sep while recording the landing). function msg(id,text,ok){ const e=(typeof id==='string')?$(id):id; if(!e)return; e.textContent=text; e.className='msg '+(ok?'ok':'bad'); } // Network ids are shown by their real names: "Tiktok", "Linkedin" and "Youtube" were all over the UI. const NET_NAMES = {tiktok:'TikTok',linkedin:'LinkedIn',youtube:'YouTube',x:'X',bluesky:'Bluesky',instagram:'Instagram',facebook:'Facebook',telegram:'Telegram',discord:'Discord',mastodon:'Mastodon',reddit:'Reddit',threads:'Threads',wordpress:'WordPress',devto:'Dev.to',hashnode:'Hashnode',slack:'Slack',nostr:'Nostr'}; function cap(s){ if(NET_NAMES[s]) return NET_NAMES[s]; return (s||'').charAt(0).toUpperCase()+(s||'').slice(1); } // Network names are written in ink next to the network's own logo (the landing's sprite, #i-). const LABEL_COLOR = {}; const NET_ICONS = new Set(['tiktok','instagram','youtube','linkedin','facebook','threads','bluesky','mastodon','telegram','discord','x','reddit','wordpress','devto','hashnode','slack','nostr']); function ni(p, cls){ return NET_ICONS.has(p) ? '' : ''; } // What each network's version is, as the landing demo labels it. const NET_FMT = {tiktok:'Video caption', instagram:'Caption', youtube:'Title, description, tags', linkedin:'Profile post', facebook:'Page post', bluesky:'Post', mastodon:'Post', telegram:'Channel message', discord:'Channel message', reddit:'Post', threads:'Post', x:'Post', wordpress:'Blog article', devto:'Article', hashnode:'Article', slack:'Channel message', nostr:'Note'}; const fmtNum = (n)=>Number(n||0).toLocaleString('en-US'); // "(0.0 MB)" for a 40 KB photo read as if nothing had been picked. function fmtSize(b){ b=Number(b)||0; return b>=1048576 ? (b/1048576).toFixed(1)+' MB' : Math.max(1,Math.round(b/1024))+' KB'; } function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function safeUrl(u){ return /^https:\/\//i.test(String(u||'')) ? String(u) : ''; } // only allow https image src /* toast system */ function toast(text, kind='ok', sticky=false){ const t=document.createElement('div'); t.className='toast '+(kind==='bad'?'bad':kind==='ok'?'ok':''); const span=document.createElement('span'); span.textContent=text; // textContent: no HTML injection from msg/error strings const btn=document.createElement('button'); btn.className='x'; btn.setAttribute('aria-label','Dismiss'); btn.innerHTML=''; t.appendChild(span); t.appendChild(btn); $('#toasts').appendChild(t); const close=()=>t.remove(); btn.onclick=close; if(!sticky && kind!=='bad'){ setTimeout(close,4000); } return t; } /* ---------- view routing ---------- */ function navTo(v){ document.querySelectorAll('.view').forEach(s=>s.classList.toggle('active', s.dataset.view===v)); document.querySelectorAll('#nav a').forEach(a=>a.classList.toggle('active', a.dataset.v===v)); $('#side').classList.remove('open'); $('#scrim').classList.remove('open'); window.scrollTo(0,0); if(v!=='start') mountTikTokPanel($('#ttHome')); if(v==='compose'){ markPubStart(); prefillComposeIdea(); requestAnimationFrame(drawComposeWires); } if(v==='accounts'){ renderNetTiles(); renderXCard(); } if(v==='api'){ loadKeys(); loadWebhooks(); if(window.PWGov) PWGov.loadGuardrails(); } if(v==='approvals' && window.PWGov){ PWGov.loadApprovals(); PWGov.loadRules(); } if(v==='activity' && window.PWGov) PWGov.loadActivity(); if(v==='queue'){ loadQueue(); loadSlots(); } if(v==='automate' && window.PWAuto) PWAuto.load(); if(v==='bio' && window.PWBio) PWBio.load(); if(v==='start') renderOnb(); if(v==='home' && typeof renderHomeChecklist==='function' && ME) renderHomeChecklist(); // same ticks as the sidebar, never a stale copy if(v==='settings'){ loadEmailPrefs(); if(window.PWTeams) PWTeams.loadTeam(); loadReferral(); } if(v==='works') loadWorks(); if(v==='home' && window.PWCoach) PWCoach.onHome(); renderTrialBar(v); renderRtBanner(v); renderBizCard(v); } // href="#" makes the items reachable with Tab/Enter (anchors without href are skipped by the keyboard). document.querySelectorAll('#nav a[data-v]').forEach(a=>a.onclick=(e)=>{ e.preventDefault(); navTo(a.dataset.v); }); $('#hamb').onclick=()=>{ $('#side').classList.toggle('open'); $('#scrim').classList.toggle('open'); }; $('#scrim').onclick=()=>{ $('#side').classList.remove('open'); $('#scrim').classList.remove('open'); }; $('#openConnect') && ($('#openConnect').onclick=()=>{ navTo('accounts'); const t=$('#netTiles'); t.scrollIntoView({behavior:'smooth',block:'center'}); const f=t.querySelector('.tile:not(.on)')||t.querySelector('.tile'); if(f) setTimeout(()=>f.focus({preventScroll:true}),300); }); // Shell shortcuts: the phone bar's "Write a post", Home's quick actions, the sidebar's plan box. $('#mtopCompose').onclick=()=>navTo('compose'); document.querySelectorAll('.qa[data-goto]').forEach(b=>b.onclick=()=>{ if(b.dataset.goto==='week'){ if(!actState().week){ navTo('start'); onbShow(4); } else { navTo('compose'); const w=$('#weekCard'); if(w) setTimeout(()=>{ w.scrollIntoView({behavior:'smooth',block:'center'}); $('#weekTopic').focus({preventScroll:true}); },60); } return; } navTo(b.dataset.goto); }); // 21-oct-2026: on Free the sidebar's "Upgrade — $9/mo" opens Starter's checkout at once (it scrolled to the plans grid, one more // screen between the intent and the pay page); the plans grid stays one click away in Settings. A paying plan still goes there. $('#sideUpgrade').onclick=async()=>{ const pl=window.__mePlan||'free'; track(pl==='free'?'quota_upgrade_click':'plan_billing_click','sidebar plan='+pl+(pl==='free'?' direct=1':'')); if(pl==='free'){ const b=$('#sideUpgrade'); const label=b.textContent; b.disabled=true; b.textContent='Opening the secure checkout…'; try{ await openCheckout({plan:'starter'}); b.disabled=false; b.textContent=label; return; } catch(e){ b.disabled=false; b.textContent=label; toast('Could not open the checkout: '+e.message,'bad'); } } navTo('settings'); setTimeout(()=>{ const el=$('#plans'); if(el) el.scrollIntoView({behavior:'smooth',block:'center'}); },60); }; $('#pbSchedToggle').onclick=()=>{ const open=$('#pubBar').classList.toggle('sched-open'); $('#pbSchedToggle').setAttribute('aria-expanded',String(open)); if(open) $('#schedAt').focus(); }; function showAuth(){ show('#auth'); show('#app',false); show('#signup'); show('#enterKey',false); show('#keyReveal',false); track('signup_view'); showAuthError(); } // Back from "Continue with Google" / "Continue with Apple" without being signed in (/api/auth//callback → // #auth_error=, plus &p=apple for Apple): say why in one sentence on the same card, with the email option right // under it. The sentences are written for Google and renamed for Apple. The detail is in pw_funnel signin_error. const AUTH_ERR = { cancelled:'Google sign-in was cancelled. Try again, or use your email.', expired:'That Google sign-in took too long, or was started in another browser. Try again, or use your email.', state_mismatch:'We could not verify that Google sign-in in this browser. Try again, or use your email.', email_unverified:'Google has not verified that email address yet. Use your email instead — we send you a link.', no_email:'Google did not share an email address with PostWire. Use your email instead.', check_email:'That email already has a PostWire account, and Google cannot vouch for that address — so we emailed it a sign-in link. Check your inbox.', check_email_apple:'You already have a PostWire account with this email. To keep it safe we emailed you a sign-in link: open it and you are in — and from then on “Continue with Apple” signs you straight in.', rate_limited:'Too many sign-in attempts from this connection. Wait a few minutes, or use your email.', not_configured:'Google sign-in is not available right now. Use your email instead.', unavailable:'Google did not answer just now. Try again in a moment, or use your email.', google_error:'Google sign-in did not work. Try again, or use your email.', }; let AUTH_ERR_PENDING = null; (function readAuthError(){ try{ const hp=new URLSearchParams(location.hash.slice(1)), c=hp.get('auth_error'); if(!c) return; AUTH_ERR_PENDING={code: AUTH_ERR[c] ? c : 'google_error', screen: hp.get('screen')==='signin' ? 'signin' : 'signup', apple: hp.get('p')==='apple' || c==='apple_error'}; history.replaceState({},'',location.pathname+location.search); }catch(e){} })(); function showAuthError(){ const e=AUTH_ERR_PENDING; if(!e) return; AUTH_ERR_PENDING=null; const ok = e.code==='check_email' || e.code==='check_email_apple'; const text = e.apple ? AUTH_ERR[e.code].replace(/Google/g,'Apple') : AUTH_ERR[e.code]; if(e.screen==='signin'){ show('#signup',false); show('#enterKey'); msg('#gsiSigninMsg', text, ok); } else msg('#gsiSignupMsg', text, ok); } AUTH_ERR.apple_error = AUTH_ERR.google_error; // "Continue with Google" / "Continue with Apple" are plain form POSTs (they work without this script). With it, the // form also carries what the email signup sends: the first touch (window.__pwFirstTouch, /t.js), the Product Hunt // flag, and where to land (the server only accepts the dashboard and a known #go=; src/social-signin.js safeNext). document.querySelectorAll('form[data-gsi]').forEach(f=>f.addEventListener('submit',()=>{ try{ f.querySelector('[name=ctx]').value=JSON.stringify({first_touch: window.__pwFirstTouch || null, ph: window.__pwPH === true}); f.querySelector('[name=next]').value=location.pathname+location.search; }catch(e){} const b=f.querySelector('button'); if(b){ setTimeout(()=>{ b.disabled=true; b.lastElementChild.textContent='Opening '+(f.dataset.provider||'Google')+'…'; },0); } })); // Back/forward cache: a page restored after the round trip must not keep a disabled button. window.addEventListener('pageshow',()=>document.querySelectorAll('form[data-gsi]').forEach(f=>{ const b=f.querySelector('button'); if(!b) return; b.disabled=false; b.lastElementChild.textContent=f.dataset.label||'Continue with Google'; })); function showEnter(){ show('#signup',false); show('#enterKey'); show('#keyReveal',false); } // The shell paints at once (with a spinner where the content goes) and the three reads run side by // side. They used to run one after another behind a black screen: 4 to 6.5 seconds on the first // visit (platforms, then /api/me, then plans), measured on 30-sep on desktop and on a phone. async function showDash(){ show('#auth',false); show('#app'); $('#app').classList.add('booting'); renderMcp(); renderCurl(); const plansP = fetch('/api/plans').then(r=>r.json()).catch(()=>null); try{ await Promise.all([loadPlatforms().catch(()=>{}), refresh()]); }finally{ $('#app').classList.remove('booting'); } renderComposeTargets(); // again, now that both the platforms and the account are in plansP.then(j=>renderPlans(j)).catch(()=>{}); // Nobody who has not published yet lands on an empty Home: they land on the first step they are missing. if(importDemoDrafts()){ navTo('start'); onbShow(1); } else if(onbCurrent(actState())<=3) navTo('start'); else navTo('home'); maybeOpenIntentCheckout(); } function renderMcp(){ const cfg = {mcpServers:{postwire:{command:"npx",args:["-y","postwire-mcp"],env:{POSTWIRE_API_KEY: KEY||"pw_live_xxx"}}}}; $('#mcpCfg').textContent = JSON.stringify(cfg,null,2); } function renderCurl(){ $('#curlEx').textContent = `curl -X POST https://postwire.io/api/post \\ -H "Authorization: Bearer ${KEY||'pw_live_xxx'}" \\ -H "Content-Type: application/json" \\ -d '{"platforms":["facebook"],"text":"Hello from PostWire 🚀"}'`; $('#apiKeyVal').textContent = KEY || 'pw_live_xxx'; } // TikTok Sandbox for the app-review demo (internal accounts only; the server refuses anyone else). try{ if(/[?&]tiktok_sandbox=1/.test(location.search)) sessionStorage.setItem('pw_tt_sandbox','1'); }catch(e){} // Threads preview (1-oct-2026): while Threads is hidden (Meta App Review pending) an internal account can still // connect it for testing and the review demo by opening the dashboard with ?threads_preview=1. The tile is added // here only for internal accounts; the server refuses "preview" from anyone else. try{ if(/[?&]threads_preview=1/.test(location.search)) sessionStorage.setItem('pw_threads_preview','1'); }catch(e){} function threadsPreviewOn(){ try{ return sessionStorage.getItem('pw_threads_preview')==='1'; }catch(e){ return false; } } let THREADS_PREVIEW = false; // true when the Threads tile is this internal preview, not the public network function ttMode(p, mode){ if(p==='threads') return THREADS_PREVIEW ? 'preview' : mode; try{ return p==='tiktok' && sessionStorage.getItem('pw_tt_sandbox')==='1' ? 'sandbox' : mode; }catch(e){ return mode; } } function addThreadsPreview(){ if(!threadsPreviewOn() || !ME || !ME.internal || !PLATFORMS.length || PLATFORMS.some(p=>p.name==='threads')) return; PLATFORMS.push({name:'threads', oauth:true, fields:[], available:true, needs:'text', max_media:20, preview:true}); THREADS_PREVIEW = true; const sel=$('#platSel'), cur=sel.value; sel.innerHTML = PLATFORMS.map(p=>``).join(''); if(cur) sel.value=cur; renderNetTiles(); } async function loadPlatforms(){ if(PLATFORMS.length) return; const j = await fetch('/api/platforms').then(r=>r.json()); // The server only lists networks that can be connected; filtered here too, for an older server. PLATFORMS = (j.platforms||[]).filter(p=>p.available!==false && !HIDDEN_NETS.includes(p.name)); $('#platSel').innerHTML = PLATFORMS.map(p=>``).join(''); $('#platSel').onchange = renderFields; renderFields(); renderNetTiles(); addThreadsPreview(); } // Accounts: one tile per network (logo, name, who is connected, or what it needs), in place of the old // '; const help=(h&&h.help&&h.help[f]) ? '

'+h.help[f]+'

' : ''; return ''+help+''; } // Format checks before submit (22-oct-2026; same rules as src/connect-copy.js, which the server enforces too). // Bluesky: 57 of 162 failed attempts in a week pasted the real password; Telegram: tokens and chat ids pasted wrong. const CRED_FMT = { bluesky: { app_password: [/^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/i, 'That looks like your main password. Bluesky needs an App Password (4 groups of 4 characters).'] }, telegram: { bot_token: [/^\d{6,}:[A-Za-z0-9_-]{30,}$/, 'That does not look like a bot token. Copy it from @BotFather (/mybots → your bot → API Token): digits, a colon, then about 35 letters and numbers.'], chat_id: [/^(?:-?\d+|@[A-Za-z0-9_]{4,})$/, 'The chat id must be a number (a channel’s starts with -100…) or @channelusername.'] }, }; // "@Name", "name", "name.bsky.social" -> "name.bsky.social" function normBskyHandle(v){ let h=String(v||'').trim().replace(/^@+/,'').trim().toLowerCase(); if(h && h.indexOf('@')<0 && h.indexOf('.')<0) h+='.bsky.social'; return h; } // Normalises the handle in place, shows the inline message under each bad field, focuses the first. true = fine to submit. function credCheck(platform, root){ if(!root) return true; const rules=CRED_FMT[platform]||{}; let first=null; root.querySelectorAll('[data-err]').forEach(e=>{ e.hidden=true; e.textContent=''; }); root.querySelectorAll('[data-f]').forEach(i=>{ const f=i.dataset.f; if(platform==='bluesky' && f==='handle' && i.value.trim()) i.value=normBskyHandle(i.value); const v=String(i.value||'').trim(), r=rules[f]; if(r && v && !r[0].test(v)){ const e=root.querySelector('[data-err="'+f+'"]'); if(e){ e.textContent=r[1]; e.hidden=false; } if(!first) first=i; } }); if(first){ first.focus(); return false; } return true; } const credFields = (p, h, idp) => (p.fields||[]).map(f=>credField(f,h,idp,false)).join('') + (p.optional_fields||[]).map(f=>credField(f,h,idp,true)).join(''); // "Test connection": the same check Connect runs with the network (POST /api/connect, dry_run), nothing saved. async function testCreds(platform, scope, btn, out){ if(!credCheck(platform, document.querySelector(scope))) return; const credentials={}; document.querySelectorAll(scope+' [data-f]').forEach(i=>{ const v=String(i.value||'').trim(); if(v) credentials[i.dataset.f]=v; }); btn.disabled=true; const t=btn.textContent; btn.textContent='Checking with '+nm(platform)+'…'; out.textContent=''; out.style.color=''; try{ await api('/api/connect',{method:'POST',body:JSON.stringify({platform,credentials,dry_run:true})}); out.textContent=nm(platform)+' accepted these details. Connect to save them.'; out.style.color='var(--ok)'; } catch(e){ out.textContent=e.message; out.style.color='var(--bad)'; } btn.disabled=false; btn.textContent=t; } function renderFields(){ const p = PLATFORMS.find(x=>x.name===$('#platSel').value); const oauth = p?.oauth; const h = FIELD_HELP[p?.name]; const m = NET_META[p?.name] || {}; const connected = p && composeConns().some(c=>c.platform===p.name); if($('#connTitle')) $('#connTitle').textContent = p ? ((connected?'Connect ':'Connect ')+nm(p.name)+(connected?' again':'')) : 'Connect a platform'; if($('#connLogo')) $('#connLogo').innerHTML = p ? ni(p.name) : ''; const how = m.how ? `

${esc(m.how)}

` : ''; const lim = p ? (guideOf(p.name)||{}).limits : ''; const guide = h ? `

${h.intro}

${h.steps}
${lim?`

${esc(lim)}

`:''}` : ''; const igChoice = p?.name === 'instagram' ? igDoors('igMode', true) : ''; const cg = p ? guideOf(p.name) : null; // Instagram with its doors drawn: the door's own two lines replace the generic "before" and "screen" paragraphs. const igLines = p?.name === 'instagram' && /igdoor/.test(igChoice); $('#platFields').innerHTML = (oauth ? (igLines ? `${how}${igChoice}

No password is ever shared with PostWire.

` : `${how}${cg&&cg.before?`

Before you continue: ${esc(cg.before)}

`:''}

${cg&&cg.screen?`What ${esc(nm(p.name))} will show you: ${esc(cg.screen)}`:`You sign in on ${esc(nm(p.name))} and come straight back here.`} No password is ever shared with PostWire.

${igChoice}`) : guide + (p ? credFields(p, h, 'cf_') + '
' : '')); const tc=$('#testConn'); if(tc) tc.onclick=()=>testCreds(p.name, '#platFields', tc, $('#testConnMsg')); $('#doConnect').textContent = oauth ? ('Continue to '+nm(p.name)) : ('Connect '+(p?nm(p.name):'')); renderBrandPicker(); } $('#doConnect').onclick = async ()=>{ const platform = $('#platSel').value; const p = PLATFORMS.find(x=>x.name===platform); try{ if(p?.oauth && platform==='x' && !confirm('X will show a warning: "Sensitive permissions requested · This app is not affiliated with X". X shows it for every outside app. Tick "I trust this app", then Authorize app. PostWire only posts what you approve and never likes, follows, replies or sends messages.')) return; if(p?.oauth){ const mode = platform==='instagram' ? igModeOf('igMode') : undefined; const {url} = await api('/api/oauth/'+platform+'/url',{method:'POST',body:JSON.stringify({brand_id: activeBrand()||undefined, mode: ttMode(platform, mode)})}); markPendingConnect(platform, mode); location.href = url; return; } if(!credCheck(platform, $('#platFields'))) return; const credentials={}; document.querySelectorAll('#platFields [data-f]').forEach(i=>{ if(i.value) credentials[i.dataset.f]=isNaN(i.value)||i.value.length>15?i.value:Number(i.value); }); await api('/api/connect',{method:'POST',body:JSON.stringify({platform,credentials,brand_id: activeBrand()||undefined})}); msg('#connMsg','Connected '+cap(platform)+' ✓',true); toast('Connected '+cap(platform)); refresh(); }catch(e){ msg('#connMsg',e.message,false); toast(e.message,'bad'); // brand_limit_reached: api() already drew the one-click upgrade card (showWall). } }; // Shows the next plan up, with the number that actually matters to this user right now. // Offered once a post has actually gone out, because that is where both real sales came from: the // product worked, then a second business or a client showed up. Shown at most once a week so a // daily poster is not nagged, and never to someone whose plan already has room. function offerSecondBrand(sel, justPosted){ // Renamed in spirit, not in name: this fires right after a post goes out and it used to pitch a // SECOND BRAND. Nobody in the base runs a second business — every active account uses exactly one // — so it was shown once, clicked zero times, and deserved to be. What they DO hit is the post // cap, because a post is counted per network: one idea to three accounts spends three of thirty. // On 8-sep two users were on pace to run out mid-month while being offered a brand they had no // use for. So the pitch is now their own number and their own date. const u = window.__usage || {}; const limit = u.limit, used = (u.posts || 0) + (justPosted || 0); // /api/me was read before this post const box = $(sel); if (!box) { track('upgrade_skip', 'after_post:no_box ' + sel); return; } if (!(limit > 0)) { track('upgrade_skip', 'after_post:unlimited'); return; } const nets = Math.max(1, ((window.__conns || []).length) || 1); const left = Math.max(0, limit - used); const pubsLeft = Math.floor(left / nets); const now = new Date(); const day = now.getUTCDate(); const days = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)).getUTCDate(); const perDay = day > 0 ? used / day : 0; // Same rule as the server (src/quota.js): warn when the PACE runs them out before the month // resets, not when a percentage is crossed. The most urgent account in the base sat at 43%. let runsOut = null; if (perDay > 0 && day >= 3 && left > 0 && (left / perDay) < (days - day)) { runsOut = new Date(Date.now() + (left / perDay) * 86400000); } if (!(used / limit >= 0.7 || runsOut)) { track('upgrade_skip', 'after_post:room used=' + used + '/' + limit + ' nets=' + nets); return; } const next = (PLANS_INFO && PLANS_INFO.starter) || { price: 9, limit: 300, name: 'Starter' }; const tr = (trialOffer() && ((ME && ME.ever_posted) || DID_POST)) ? trialOffer() : null; const pct = Math.min(100, Math.round(used / limit * 100)); // UTC on purpose: src/quota.js formats the same date for the email and the API notice, and a // user who is told 'Sep 21' by email and 'Sep 22' by the dashboard stops believing both. const when = runsOut ? runsOut.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }) : null; const wrap = document.createElement('div'); wrap.style.cssText = 'margin-top:12px;padding:14px;border:1px solid var(--bd);border-left:3px solid var(--acc);border-radius:8px'; wrap.innerHTML = '
' + (left === 0 ? 'That was your last post this month.' : (when ? 'That went out — and at this pace you run out around ' + when + '.' : 'That went out. You are close to your monthly limit.')) + '
' + '
' + '
' + '
' + '
' + used + ' / ' + limit + '
' + '
' + '
A post is counted per network, so one idea sent to your ' + nets + ' account' + (nets === 1 ? '' : 's') + ' spends ' + nets + '. ' + 'That leaves you ' + pubsLeft + ' more publish' + (pubsLeft === 1 ? '' : 'es') + ' this month.
' + '
' + (tr ? esc(tr.name) + ' raises it to ' + esc(tr.unlocks.split(', ')[0]) + '' + (tr.half ? '. Your first month is ' + halfMoney(tr.first_month_usd) + ' (' + esc(tr.percent_off) + '% off), then $' + esc(tr.price_usd_month) + '/month. Cancel anytime.' : ': free for ' + tr.days + ' days, then $' + esc(tr.price_usd_month) + '/month unless you cancel.') + trialFine(tr) : (next.name || 'Starter') + ' raises the cap to ' + next.limit + ' a month for $' + next.price + '. Your queue and connections stay as they are. Cancel any time.') + '
'; const b = document.createElement('button'); b.className = 'btn sm'; b.style.marginTop = '12px'; // The Pro trial when this account can still start it (3-oct): the same rule as the locks (lockCta). if (tr) { b.textContent = trialBtnLabel(tr); b.onclick = () => { track('quota_upgrade_click', 'after_post:quota trial=1 used=' + used + '/' + limit); startTrial('compose_quota', b); }; wrap.appendChild(b); box.appendChild(wrap); if (trialSeenOnce('compose')) meEvent('trial_offer_seen', 'surface=compose via=quota'); track('upgrade_seen', 'after_post:quota trial=1 used=' + used + '/' + limit + ' nets=' + nets + (when ? ' runsout=' + when : '')); return true; } b.textContent = 'Raise my limit — $' + next.price + '/mo →'; b.onclick = async () => { track('quota_upgrade_click', 'after_post:quota used=' + used + '/' + limit); const lbl = b.textContent; b.disabled = true; b.textContent = 'Opening checkout…'; try { await openCheckout({ plan: 'starter' }); b.disabled = false; b.textContent = lbl; } catch (e) { b.disabled = false; b.textContent = 'See plans →'; navTo('settings'); } }; wrap.appendChild(b); box.appendChild(wrap); track('upgrade_seen', 'after_post:quota used=' + used + '/' + limit + ' nets=' + nets + (when ? ' runsout=' + when : '')); return true; } // A visible "3 of 3 brands" is what turns a silent ceiling into an upgrade decision. function renderBrandMeter(me){ const host=$('#connMsg'); if(!host||!me.brands) return; let el=$('#brandMeter'); if(!el){ el=document.createElement('div'); el.id='brandMeter'; el.className='small'; el.style.cssText='margin:6px 0 2px'; host.parentNode.insertBefore(el, host); } const used=me.brands.used, lim=me.brands.limit, full=lim>=0&&used>=lim; // Only worth a line when it limits something: at the plan's brand limit, or with several brands. if(!full && used<2){ el.innerHTML=''; return; } el.innerHTML='A brand is one business with all of its networks. '+used+' of '+(lim<0?'\u221e':lim)+' used' + (full?' \u2014 that is your plan\u2019s limit. Adding networks to an existing brand is always free.':''); if(full && !me.top_plan){ // the top plan has nothing above it to show const b=document.createElement('button'); b.id='brandUpgradeBtn'; b.className='btn sm'; b.style.marginLeft='8px'; b.textContent='See plans \u2192'; b.onclick=()=>{ navTo('settings'); const pl=document.querySelector('#plans'); if(pl) pl.scrollIntoView({behavior:'smooth'}); }; el.appendChild(b); } } function setStep(id,done){ $(id).classList.toggle('done',!!done); } // The boot sequence already fetched /api/me to check the saved key; reuse that answer once instead // of asking again (each /api/me is several database round-trips — seconds on a slow connection). let ME_PRELOAD = null; async function refresh(){ const me = ME_PRELOAD || await api('/api/me'); ME_PRELOAD = null; ME = me; if(window.PWTeams) try{ PWTeams.onMe(me); }catch(e){} // workspace switcher + what this member's role can do addThreadsPreview(); const email = (me.member && me.member.email) || me.email || ''; // the person signed in (a team member sees their own address) $('#acctEmail').textContent = email; $('#setEmail').textContent = email; // the bare address: "Delete account" reads it $('#avatar').textContent = ((me.name||email)[0]||'P').toUpperCase(); renderSigninMethod(me); $('#acctPlanS').textContent = me.plan; $('#sPlan').textContent = cap(me.plan); const lim = me.usage.limit, used = me.usage.posts; $('#sUsed').textContent = used; $('#useTxt').textContent = used + ' / ' + (lim<0?'∞':lim) + ' posts'; $('#useBar').style.width = (lim<0?5:Math.min(100,used/lim*100))+'%'; // the sidebar's plan box: the plan, the month's posts, and the way up // A trial says so, with its end date; the top plan (or internal) is never offered an "Upgrade" — // it shows the plan it is on (2-oct: a Pro customer's "Plan & billing" click was read as an upgrade). const trialEnds = me.trial && me.trial.ends_at ? new Date(me.trial.ends_at).toLocaleDateString([], {weekday:'short', month:'short', day:'numeric'}) : ''; $('#sidePlan').textContent = (me.plan_name || cap(me.plan)) + (me.trial ? ' trial' : ' plan'); $('#sideUseN').textContent = used + ' / ' + (lim<0?'∞':lim); $('#sideUseBar').style.width = (lim<0?5:Math.min(100,used/lim*100))+'%'; $('#planBox').querySelector('.pb-use').textContent = me.trial ? (me.trial.canceled ? 'posts this month · trial ends '+trialEnds+', not renewing' : 'posts this month · trial ends '+trialEnds) : me.top_plan ? 'posts this month · top plan' : 'posts this month'; // Nothing connected yet (src/preconnect.js): "Plans", not "Upgrade" — no paywall before value. paintSideUpgrade(); $('#acctPlanS').textContent = (me.workspace && me.workspace.members>1 && me.member ? me.member.role_label+' · ' : '') + (me.plan_name || me.plan) + (me.trial ? ' trial' : ''); const conns = me.connections||[]; window.__conns = conns; // who is connected, so the next screen can name the account $('#sAccts').textContent = conns.length; BRANDS = me.brand_list || []; window.__brandLimit = (me.brands||{}).limit; window.__brandsUsed = (me.brands||{}).used; // the second-brand offer needs both numbers window.__acct = me.account_id; window.__usage = me.usage; window.__agencyTrial = me.agency_trial_offer || null; // the Agency card trial (AGENCY_TRIAL switch), when this account can have it window.__mePlan = me.plan; // so the plans grid can mark the right one // the offer is built from this, so it must be fresh // funnel rows are useless if we cannot tell people apart renderBrandBar(); renderBrandMeter(me); DID_POST = !!me.ever_posted; // the account decides, not the browser: another key used here left the check on const wh=$('#welcomeH'); if(wh) wh.textContent = (conns.length || me.ever_posted) ? 'Welcome back' : 'Welcome to PostWire'; if(me.ever_posted) localStorage.setItem('pw_posted','1'); renderQuotaNotice(me); renderVerifyState(me); renderActivation(me); renderTrialBar($('.view.active')?.dataset.view); renderRtBanner($('.view.active')?.dataset.view, true); renderBizCard($('.view.active')?.dataset.view); maybeAskSource(me); loadX(); // the X section (Accounts): credits, prices, buy buttons loadAi(); // the AI drafts card (Settings & plan): the month, AI credits, buy buttons // account cards, grouped by brand, each saying WHICH account it is (@handle, name, picture) window.__connObjs = conns; const cardOf = (c)=>{ const id=connIdent(c); const key=esc(c.platform+':'+(c.brand_id||'')); const extra = c.platform==='youtube' ? `
` : ''; const others = BRANDS.filter(b=>b.id!==c.brand_id); const move = others.length ? `` : ''; const who = id.label ? (id.url ? `${esc(id.label)}` : esc(id.label)) : `${id.err ? 'account name unknown' : 'checking which account…'}`; const sub = (id.handle && id.name && id.name!==id.handle) ? `
${esc(id.name)}
` : ''; const why = (!id.label && id.err) ? `
${esc(String(id.err).slice(0,120))}
` : ''; return `
${avatarHtml(c)}
${nm(c.platform)}
${who}
${sub}${why}
checking…
Connected
${move}
${extra}
`; }; const groups = BRANDS.length ? BRANDS.map(b=>({b, list:conns.filter(c=>c.brand_id===b.id)})) : [{b:null, list:conns}]; const orphans = BRANDS.length ? conns.filter(c=>!BRANDS.some(b=>b.id===c.brand_id)) : []; if(orphans.length) groups.push({b:{id:'',name:'Other'}, list:orphans}); const showHead = BRANDS.length>1; $('#connList').innerHTML = conns.length ? groups.filter(g=>showHead||g.list.length).map(g=>`
${showHead?`
${esc(g.b?g.b.name:'')}${g.list.length} account${g.list.length===1?'':'s'}
`:''}${g.list.length?`
${g.list.map(cardOf).join('')}
`:`
No accounts in this brand yet — choose it under “Add this account to” when you connect one, or move one here.
`}
`).join('') : `
No accounts connected yet
Pick a network above. Bluesky (about a minute) and LinkedIn (one click) take plain text; TikTok, Instagram and YouTube need a photo or video with every post.
`; const ec=document.getElementById('emptyConnect'); if(ec) ec.onclick=()=>{ if(PLATFORMS.some(p=>p.name==='bluesky')) openConnectFor('bluesky'); else { navTo('start'); onbShow(2); } }; // A stored OAuth token can be dead while the card still says "Connected" — that is how four real // accounts sat here for weeks unable to publish. Ask each platform and say so plainly. The answer // also carries the account's identity, which the server re-asks of the network once a day. conns.forEach(async c=>{ const key=CSS.escape(c.platform+':'+(c.brand_id||'')); const lbl=document.querySelector(`[data-health="${key}"]`); const badge=document.querySelector(`[data-badge="${key}"]`); if(!lbl) return; try{ const h=await api('/api/connect/'+c.platform+'/health'+(c.brand_id?'?brand_id='+encodeURIComponent(c.brand_id):'')); if(h.label){ const hd=document.querySelector(`[data-hd="${key}"]`); if(hd && !hd.querySelector('a') && hd.textContent!==h.label) hd.textContent=h.label; } if(h.healthy===null){ lbl.textContent='connected'; return; } if(h.healthy){ lbl.textContent='working'; return; } lbl.textContent = h.reason ? ('needs reconnecting — '+h.reason).slice(0,90) : 'needs reconnecting'; lbl.style.color='var(--bad)'; if(badge){ badge.textContent='Reconnect needed'; badge.className='badge bad'; } }catch(e){ // A failed check is not a healthy account. Saying "connected" here is how a dead token hides: // the rate limiter answering 429 looked exactly like a working LinkedIn. lbl.textContent='could not check right now'; lbl.style.color='var(--warn)'; } }); document.querySelectorAll('#connList [data-disc]').forEach(b=>b.onclick=async()=>{ const plat=b.dataset.disc, brand=b.dataset.brand; const bname = BRANDS.length>1 ? ((BRANDS.find(x=>x.id===brand)||{}).name||'') : ''; const who = connIdent(conns.find(c=>c.platform===plat&&(c.brand_id||'')===brand)||{}).label||''; if(!confirm('Disconnect '+cap(plat)+(who?' '+who:'')+(bname?' from '+bname:'')+'? PostWire will no longer post to this account'+(bname?' for '+bname:'')+'.')) return; // Only this brand's connection: without brand_id the server used to remove the network from every brand. try{ await api('/api/connect/'+plat+(brand?'?brand_id='+encodeURIComponent(brand):''),{method:'DELETE'}); toast(cap(plat)+' disconnected'); refresh(); } catch(e){ toast(e.message,'bad'); } }); // Move one login to another brand of this account: the server describes the move, the person confirms it. document.querySelectorAll('#connList select.mv').forEach(sel=>sel.onchange=async()=>{ const to=sel.value; if(!to) return; const plat=sel.dataset.mv, from=sel.dataset.brand; try{ const pv=await api('/api/connect/'+plat+'/move',{method:'POST',body:JSON.stringify({from_brand_id:from||undefined,to_brand_id:to})}); const q='Move '+nm(plat)+' '+(connIdent({identity:pv.account}).label||'')+'\nfrom '+(pv.from.name||'its brand')+'\nto '+pv.to.name+'?\n\nSame login, nothing to reconnect. Posts for '+(pv.from.name||'the old brand')+' will no longer go to this account.'; if(!confirm(q)){ sel.value=''; return; } const r=await api('/api/connect/'+plat+'/move',{method:'POST',body:JSON.stringify({from_brand_id:from||undefined,to_brand_id:to,confirm:true})}); toast(r.message,'ok'); refresh(); }catch(e){ sel.value=''; toast(e.message,'bad'); msg('#connMsg',e.message,false); } }); // YouTube default visibility, per connected channel (i.e. per brand) document.querySelectorAll('#connList .yt-priv').forEach(sel=>{ const q = sel.dataset.brand ? '?brand_id='+encodeURIComponent(sel.dataset.brand) : ''; api('/api/connect/youtube/settings'+q).then(s=>{ if(s&&s.default_privacy) sel.value=s.default_privacy; }).catch(()=>{}); sel.onchange=()=>api('/api/connect/youtube/settings'+q,{method:'PATCH',body:JSON.stringify({default_privacy:sel.value})}).then(()=>toast('YouTube default visibility: '+sel.value)).catch(e=>toast(e.message,'bad')); }); renderComposeTargets(); renderNetTiles(); // onboarding progress renderTextWayOut(window.__conns); renderHomeChecklist(); renderOnbNav(); if(window.PWCoach) try{ PWCoach.onMe(me); }catch(e){} // Home: Your results, goal, next best post } $('#genLink').onclick = async ()=>{ // With two or more brands, the link connects into the brand picked above (an agency's client lands in their own brand). const bid = BRANDS.length > 1 ? activeBrand() : '', bname = bid ? (BRANDS.find(b=>b.id===bid)||{}).name : ''; try{ const {url} = await api('/api/connect-link',{method:'POST',body:JSON.stringify(bid ? {brand_id: bid} : {})}); $('#linkOut').innerHTML = `
${url}
expires in 1h${bname ? ' · connects to '+esc(bname) : ''}`; }catch(e){ $('#linkOut').innerHTML=''+esc(e.message)+''; } }; /* ---- Smart Distribute: generate native drafts → edit → publish ---- */ let DRAFTS = {}, GEN_PLATFORMS = []; // Threads counts an emoji as its UTF-8 bytes (Threads API docs), so its counter does too (same rule as src/ai.js). const EMOJI_CP = new RegExp("\\p{Extended_Pictographic}|\\p{Regional_Indicator}|\\p{Emoji_Modifier}|[" + String.fromCharCode(0x200D, 0xFE0F, 0x20E3) + "]", "u"); // ZWJ, VS16, keycap // Bluesky counts graphemes (an emoji is 1); Mastodon too, with every link counted as 23 (src/social-thread.js). const GSEG=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('en',{granularity:'grapheme'}):null; function graphemes(s){ s=String(s||''); return GSEG?Array.from(GSEG.segment(s),g=>g.segment):Array.from(s); } function mastoLen(t){ let n=0, last=0; const re=/\bhttps?:\/\/[^\s<>"]+[^\s<>".,;:!?)\]'”’]/gi; let m; while((m=re.exec(t))){ n+=graphemes(t.slice(last,m.index)).length+23; last=m.index+m[0].length; } return n+graphemes(t.slice(last)).length; } function netLen(p, t){ t=String(t||''); if(p==='x') return xLen(t); if(p==='bluesky') return graphemes(t).length; if(p==='mastodon') return mastoLen(t); if(p!=='threads') return t.length; let n=0; for(const ch of t) n += EMOJI_CP.test(ch) ? new TextEncoder().encode(ch).length : 1; return n; } // X, counted and priced the way the server does (src/x-pricing.js): a link counts 23 characters and makes the post use // 10 X credits instead of 1; an emoji counts 2; each image or video adds a credit. The numbers come from GET /api/x. const X_URL_SRC='(?:https?:\\/\\/[^\\s<>"]+)|(?"]*)?'; const X_LIGHT=[[0,4351],[8192,8205],[8208,8223],[8242,8247]]; function xWeigh(s){ let n=0; const seg=window.Intl&&Intl.Segmenter?Array.from(new Intl.Segmenter('en',{granularity:'grapheme'}).segment(s),g=>g.segment):Array.from(s); for(const g of seg){ if(/\p{Extended_Pictographic}|\p{Regional_Indicator}/u.test(g)){ n+=2; continue; } for(const ch of g){ const cp=ch.codePointAt(0); n+=X_LIGHT.some(([a,b])=>cp>=a&&cp<=b)?1:2; } } return n; } function xLen(t){ const s=String(t||'').normalize('NFC'); let n=0,last=0; for(const m of s.matchAll(new RegExp(X_URL_SRC,'gi'))){ const u=m[0].replace(/[.,;:!?)\]}'"…]+$/,''); if(!u) continue; n+=xWeigh(s.slice(last,m.index))+23; last=m.index+u.length; } return n+xWeigh(s.slice(last)); } const xHasLink=(t)=>new RegExp(X_URL_SRC,'i').test(String(t||'').normalize('NFC')); function xCreditsFor(t, nMedia){ const c=(window.__x&&window.__x.pricing&&window.__x.pricing.credits)||{post:1,post_with_link:10,per_image_or_video:1}; return (xHasLink(t)?c.post_with_link:c.post)+(nMedia||0)*c.per_image_or_video; } function xCostLine(t, nMedia){ const n=xCreditsFor(t,nMedia), why=[xHasLink(t)?'it contains a link':null, nMedia?(nMedia+' image'+(nMedia===1?'':'s')+' or video'):null].filter(Boolean); return 'uses '+n+' X credit'+(n===1?'':'s')+(why.length?': '+why.join(', '):''); } const composeMediaCount=()=>{ const l=mediaListNow(); if(l.length) return Math.min(4,l.length); return ($('#postVideo')&&$('#postVideo').value.trim())?1:0; }; const composeMediaIsVideo=()=>{ const l=mediaListNow(); if(l.length) return l.some(x=>x.type==='video'); const v=$('#postVideo')&&$('#postVideo').value.trim(); return !!v && mediaKind(v)==='video'; }; // X thread + the reply under it (5-oct-2026; src/x-thread.js): the X draft card takes more posts, each a reply to // the one before, and one reply under the last one (the "link in the reply" pattern). Every post is an X post: it // uses credits by the same rule (1, or 10 with a link) and media stays on post 1. Sent as per_platform.x.thread and // options.x.reply; the server reserves the whole chain before the first post goes out. const X_THREAD_MAX=25; function xThreadItem(t){ return '
  • ' +'
  • '; } function xThreadHtml(d){ const th=Array.isArray(d&&d.thread)?d.thread.filter(t=>typeof t==='string'):[], rp=d&&typeof d.reply==='string'?d.reply:(d&&typeof d.first_comment==='string'?d.first_comment:''); return '
      '+th.map(xThreadItem).join('')+'
    ' +'
    First commenta reply under the last post · for the link
    ' +'
    ' +'
    ' +'
    '; } function xThreadOf(card){ const box=card&&card.querySelector('[data-xth]'); if(!box) return {thread:[], reply:''}; const rp=box.querySelector('.xth-rp'); return { thread:[...box.querySelectorAll('.xth-t')].map(t=>t.value.trim()).filter(Boolean), reply: rp&&!rp.hidden ? box.querySelector('.xth-reply').value.trim() : '' }; } const xChainOn=(card)=>{ const c=xThreadOf(card); return c.thread.length>0 || !!c.reply; }; // "This thread uses 13 X credits: 4 X posts, 1 with a link." — the server's words (src/x-pricing.js chainSentence). function xChainSentence(main, thread, reply){ const nMedia=composeMediaCount(), video=nMedia>0 && composeMediaIsVideo(); const posts=[main].concat(thread||[], reply?[reply]:[]); const credits=posts.reduce((s,t,i)=>s+xCreditsFor(t, i===0?(video?1:nMedia):0), 0), links=posts.filter(xHasLink).length; const why=[posts.length+' X posts', links?links+' with a link':null, !video&&nMedia?nMedia+' image'+(nMedia===1?'':'s'):null, video?'a video':null].filter(Boolean); return ((thread||[]).length?'This thread':'This post and its reply')+' uses '+credits+' X credit'+(credits===1?'':'s')+': '+why.join(', ')+'.'; } function xThreadRefresh(card){ const box=card.querySelector('[data-xth]'); if(!box) return; const items=[...box.querySelectorAll('.xth-item')], total=1+items.length; const count=(t, cc)=>{ const n=xLen(t.value); cc.textContent=fmtNum(n)+' of 280'; cc.classList.toggle('over', n>280); }; items.forEach((li,i)=>{ li.querySelector('.xth-n').textContent='Post '+(i+2)+' of '+total; count(li.querySelector('textarea'), li.querySelector('.xcc')); }); const r=box.querySelector('.xth-reply'); if(r) count(r, box.querySelector('.xth-rp .xcc')); box.querySelector('.xth-add').disabled=items.length>=X_THREAD_MAX; const c=xThreadOf(card); box.querySelector('.xth-cost').textContent=(c.thread.length||c.reply) ? xChainSentence(card.querySelector('.dft-text').value, c.thread, c.reply) : ''; } function xThreadWire(card, upd){ const box=card.querySelector('[data-xth]'); if(!box) return; const fit=(t)=>{ t.style.height='auto'; t.style.height=Math.min(Math.max(t.scrollHeight+3, 64), 260)+'px'; }; box.addEventListener('input', e=>{ if(e.target.tagName==='TEXTAREA'){ fit(e.target); upd(); } }); box.addEventListener('click', e=>{ const b=e.target.closest('button'); if(!b) return; if(b.classList.contains('xth-add')){ const l=box.querySelector('.xth-list'); if(l.children.length>=X_THREAD_MAX) return; l.insertAdjacentHTML('beforeend', xThreadItem('')); l.lastElementChild.querySelector('textarea').focus(); } else if(b.classList.contains('xth-del')) b.closest('.xth-item').remove(); else if(b.classList.contains('xth-rp-on')){ box.querySelector('.xth-rp').hidden=false; b.hidden=true; box.querySelector('.xth-reply').focus(); } else if(b.classList.contains('xth-rp-off')){ const rp=box.querySelector('.xth-rp'); rp.querySelector('textarea').value=''; rp.hidden=true; box.querySelector('.xth-rp-on').hidden=false; } else return; upd(); }); requestAnimationFrame(()=>box.querySelectorAll('textarea').forEach(fit)); } // A post of the thread (or the reply) over X's 280: said before anything is sent, with the box to fix. function xThreadProblem(){ const card=document.querySelector('#drafts .card[data-plat="x"]:not([data-locked])'); if(!card) return null; const items=[...card.querySelectorAll('.xth-t')]; const i=items.findIndex(t=>xLen(t.value)>280); if(i>=0) return {text:'X: post '+(i+2)+' of the thread is '+fmtNum(xLen(items[i].value))+' characters as X counts them (280 at most). Shorten it or split it in two.', el:items[i]}; const r=card.querySelector('.xth-rp:not([hidden]) .xth-reply'); if(r && xLen(r.value)>280) return {text:'X: the reply is '+fmtNum(xLen(r.value))+' characters as X counts them (280 at most).', el:r}; return null; } // " thread posted: 4 of 4" after an X result (src/x-thread.js state): pending/failed/unknown say so; the result's // notice carries the sentence with the reason. function xThreadLine(r){ const fc=r && r.first_comment, fcl=!fc?'':fc.ok?' · first comment ✓':fc.retry_pending?' · first comment retried shortly':' · first comment not posted'; const t=r && r.thread; if(!t || !t.total || t.total<2) return fcl; // Bluesky / Mastodon (src/social-thread.js): posts, status complete | partial. if(Array.isArray(t.posts)) return ' Thread posted: '+t.posted+' of '+t.total+(t.status==='partial'?' ('+cap(r.platform)+' refused post '+t.failed_post+')':'')+fcl; const what=(t.tweets||[]).concat(t.pending||[], t.not_posted||[]).some(x=>x.role==='thread') ? 'Thread' : 'Post + reply'; return ' '+what+' posted: '+t.posted+' of '+t.total+(t.status==='pending'?' (the rest is retried)':t.status==='failed'?' (X refused the rest)':t.status==='unknown'?' (stopped: X did not answer)':''); } // Bluesky / Mastodon thread and the first comment on LinkedIn, Bluesky and Mastodon (9-oct-2026, src/social-thread.js). // The "Thread" toggle opens the posts after the first (each a reply to the one before; media stays on post 1); "Split my // text" cuts a long text at the network's limit at paragraph, sentence or word boundaries. The first comment is posted // by the author under the post (under the last post of a thread): the place for the link. Sent as per_platform.. const CHAIN_MAX_POSTS=10, FC_MAX={linkedin:1250, bluesky:300, mastodon:500}; const CHAIN_NETS=['bluesky','mastodon']; function chainItem(t){ return '
  • ' +'
  • '; } function chainHtml(p, d){ const th=CHAIN_NETS.includes(p)&&Array.isArray(d&&d.thread)?d.thread.filter(t=>typeof t==='string'):[], fc=d&&typeof d.first_comment==='string'?d.first_comment:''; const thread=CHAIN_NETS.includes(p); return '
    ' +(thread?'
      '+th.map(chainItem).join('')+'
    ':'') +'
    First comment'+(thread?'under the last post':'under the post')+' · for the link
    ' +'
    ' +'
    '+(thread?'':'') +'
    '; } function chainOfCard(card){ const box=card&&card.querySelector('[data-nth]'); if(!box) return {thread:[], first_comment:''}; const list=box.querySelector('.xth-list'), fw=box.querySelector('.nth-fcw'); return { thread: list&&!list.hidden ? [...list.querySelectorAll('.nth-t')].map(t=>t.value.trim()).filter(Boolean) : [], first_comment: fw&&!fw.hidden ? box.querySelector('.nth-fc').value.trim() : '' }; } // The server's splitter (src/social-thread.js splitIntoPosts), measured as the network counts. function splitPosts(text, max, len){ const levels=[/(\n[ \t]*\n\s*)/, /((?<=[.!?…]["'”’)\]]?)[ \t]+|\n)/, /([ \t]+)/]; const hard=(s)=>{ const out=[]; let cur=''; for(const g of graphemes(s)){ if(cur && len(cur+g)>max){ out.push(cur); cur=''; } cur+=g; } if(cur) out.push(cur); return out; }; const pieces=(s, lv)=>{ if(len(s)<=max) return [s]; if(lv>=levels.length) return hard(s); const parts=s.split(levels[lv]); if(parts.length===1) return pieces(s, lv+1); const out=[]; let cur=''; for(let i=0;i0?(/\n/.test(parts[i-1])?(lv===0?'\n\n':'\n'):' '):''; const cand=cur?cur+sep+pc:pc; if(len(cand)<=max){ cur=cand; continue; } if(cur) out.push(cur); if(len(pc)<=max){ cur=pc; continue; } const sub=pieces(pc, lv+1); out.push(...sub.slice(0,-1)); cur=sub[sub.length-1]||''; } if(cur) out.push(cur); return out; }; const s=String(text||'').replace(/\r\n?/g,'\n').trim(); if(!s) return []; return len(s)<=max ? [s] : pieces(s,0).map(t=>t.trim()).filter(Boolean); } function chainRefresh(card){ const box=card.querySelector('[data-nth]'); if(!box) return; const p=card.dataset.plat, max=PLAT_MAX[p]||500; const count=(t, cc, m)=>{ const n=netLen(p, t.value); cc.textContent=fmtNum(n)+' of '+fmtNum(m); cc.classList.toggle('over', n>m); }; const items=[...box.querySelectorAll('.xth-item')], total=1+items.length; items.forEach((li,i)=>{ li.querySelector('.xth-n').textContent='Post '+(i+2)+' of '+total; count(li.querySelector('textarea'), li.querySelector('.xcc'), max); }); const fc=box.querySelector('.nth-fc'); if(fc) count(fc, box.querySelector('.nth-fcw .xcc'), FC_MAX[p]||max); const add=box.querySelector('.nth-add'); if(add) add.disabled=items.length>=CHAIN_MAX_POSTS-1; const split=box.querySelector('.nth-split'), main=card.querySelector('.dft-text'); if(split) split.hidden=!(main && netLen(p, main.value)>max); } function chainWire(card, upd){ const box=card.querySelector('[data-nth]'); if(!box) return; const p=card.dataset.plat, list=box.querySelector('.xth-list'); const fit=(t)=>{ t.style.height='auto'; t.style.height=Math.min(Math.max(t.scrollHeight+3, 64), 260)+'px'; }; const setOn=(on)=>{ const tg=box.querySelector('.nth-toggle'); if(!tg) return; tg.setAttribute('aria-pressed', on?'true':'false'); list.hidden=!on; box.querySelector('.nth-add').hidden=!on; }; box.addEventListener('input', e=>{ if(e.target.tagName==='TEXTAREA'){ fit(e.target); upd(); } }); box.addEventListener('click', e=>{ const b=e.target.closest('button'); if(!b) return; if(b.classList.contains('nth-toggle')){ const on=b.getAttribute('aria-pressed')!=='true'; setOn(on); if(on && !list.children.length){ list.insertAdjacentHTML('beforeend', chainItem('')); list.lastElementChild.querySelector('textarea').focus(); } } else if(b.classList.contains('nth-add')){ if(list.children.length>=CHAIN_MAX_POSTS-1) return; list.insertAdjacentHTML('beforeend', chainItem('')); list.lastElementChild.querySelector('textarea').focus(); } else if(b.classList.contains('nth-split')){ const main=card.querySelector('.dft-text'), posts=splitPosts(main.value, PLAT_MAX[p]||500, (t)=>netLen(p,t)); if(posts.length>CHAIN_MAX_POSTS){ toast(cap(p)+': this text is '+posts.length+' posts and a thread takes '+CHAIN_MAX_POSTS+'. Shorten it first.','bad'); return; } const rest=[...list.querySelectorAll('.nth-t')].map(t=>t.value.trim()).filter(Boolean); main.value=posts[0]||''; list.innerHTML=posts.slice(1).concat(rest).slice(0, CHAIN_MAX_POSTS-1).map(chainItem).join(''); setOn(true); requestAnimationFrame(()=>list.querySelectorAll('textarea').forEach(fit)); } else if(b.classList.contains('nth-del')){ b.closest('.xth-item').remove(); if(!list.children.length) setOn(false); } else if(b.classList.contains('nth-fc-on')){ box.querySelector('.nth-fcw').hidden=false; b.hidden=true; box.querySelector('.nth-fc').focus(); } else if(b.classList.contains('nth-fc-off')){ const w=box.querySelector('.nth-fcw'); w.querySelector('textarea').value=''; w.hidden=true; box.querySelector('.nth-fc-on').hidden=false; } else return; upd(); }); requestAnimationFrame(()=>box.querySelectorAll('textarea').forEach(fit)); } // A thread post or a first comment over the network's limit: said before anything is sent, with the box to fix. function chainProblemClient(){ for(const card of document.querySelectorAll('#drafts .card[data-plat]:not([data-locked])')){ const box=card.querySelector('[data-nth]'); if(!box) continue; const p=card.dataset.plat, max=PLAT_MAX[p]||500, list=box.querySelector('.xth-list'); const items=list&&!list.hidden?[...list.querySelectorAll('.nth-t')]:[]; const i=items.findIndex(t=>netLen(p,t.value)>max); if(i>=0) return {text:cap(p)+': post '+(i+2)+' of the thread is '+fmtNum(netLen(p,items[i].value))+' characters ('+fmtNum(max)+' at most). Shorten it or split it in two.', el:items[i]}; const fw=box.querySelector('.nth-fcw'), fc=fw&&!fw.hidden?box.querySelector('.nth-fc'):null; if(fc && netLen(p,fc.value)>(FC_MAX[p]||max)) return {text:cap(p)+': the first comment is '+fmtNum(netLen(p,fc.value))+' characters ('+fmtNum(FC_MAX[p]||max)+' at most).', el:fc}; } return null; } // The reply travels as options.x.reply (the API's place for it); the thread stays in per_platform.x.thread. function xReplyToOptions(body){ const x=body.per_platform && body.per_platform.x; if(!x || x.reply==null) return body; const {reply, ...rest}=x; body.per_platform=Object.assign({}, body.per_platform, {x:rest}); if(reply){ body.options=body.options||{}; body.options.x=Object.assign({}, body.options.x||{}, {reply}); } return body; } const PLAT_MAX = { youtube:5000, tiktok:2200, instagram:2200, linkedin:3000, x:280, facebook:2000, threads:500, bluesky:300, mastodon:500, reddit:10000, telegram:4000, discord:2000, wordpress:50000, devto:50000, hashnode:50000, slack:4000, nostr:30000 }; // The blog destinations (src/ai.js ARTICLE_PLATFORMS): their draft is an article — title, summary, tags, publish or draft. const ARTICLE_NETS = ['wordpress','devto','hashnode']; const ART_TAGS = { wordpress:'Tags, up to 5 (comma separated)', devto:'Tags, at most 4: one word each, e.g. webdev, ai', hashnode:'Tags, up to 5 (comma separated)' }; // --- media requirements per platform (mirrors the server-side checks in src/platforms/*) --- // Checking here means the user never burns a post on something the platform is certain to reject. const MEDIA_REQ = { tiktok: { need:'any', why:'' }, youtube: { need:'video', why:'YouTube needs a video file.' }, instagram: { need:'any', why:'' }, }; // Three answers, not two. Guessing "photo" for anything without a known video extension is how a // perfectly good video URL — a signed CDN link, one ending in #t=30, an extensionless share link — // got called a photo, and mediaBlockers below then refused to publish at all. The request never // reached the server, so nothing was logged anywhere and the user simply could not post. // When the extension does not say, we do not know, and the platform is the one that gets to decide. function mediaKind(url){ if(!url) return null; if(window.__uploaded && window.__uploaded.url===url) return window.__uploaded.kind; if(/\.(mp4|mov|webm|m3u8|avi|mkv|m4v)(\?|#|$)/i.test(url)) return 'video'; if(/\.(jpe?g|png|gif|webp|heic|heif|avif|bmp|tiff?)(\?|#|$)/i.test(url)) return 'photo'; return 'unknown'; } // Which request field carries the media. This is the half of the 12-sep fix that was missing: // mediaKind() stopped BLOCKING a .mp4#t=30 or an extensionless CDN link, but publishing and // scheduling still used the old two-way regex and sent every one of them as photo_url — so TikTok // and YouTube were refused on the server with "needs a video" for a perfectly good video link. // A file uploaded here is known exactly (its MIME type), whatever its URL looks like. function mediaFields(url, platforms, metaFmt){ if(!url) return {}; let kind = (window.__uploaded && window.__uploaded.url===url) ? window.__uploaded.kind : mediaKind(url); if(kind==='unknown'){ // Send it where the chosen networks need it: a video network means it has to be the video. const wantsVideo = (platforms||[]).some(p=>(MEDIA_REQ[p]&&MEDIA_REQ[p].need==='video') || p==='tiktok' || ((p==='instagram'||p==='facebook') && metaFmt==='reels')); kind = wantsVideo ? 'video' : 'photo'; } return kind==='video' ? {video_url:url} : {photo_url:url}; } // Platforms in `platforms` that cannot accept the media currently in the composer. function mediaBlockers(platforms, url, metaFmt){ const kind = mediaKind(url), out = []; for(const p of platforms){ let need = MEDIA_REQ[p] && MEDIA_REQ[p].need, why = (MEDIA_REQ[p] && MEDIA_REQ[p].why) || ''; if((p==='instagram'||p==='facebook') && metaFmt==='reels'){ need='video'; why='A Reel is a vertical video.'; } else if((p==='instagram'||p==='facebook') && metaFmt==='story'){ need='any'; why='A Story needs a photo or a video.'; } if(!need) continue; // 'unknown' is never a blocker. We only stop a publish when we are sure it cannot work: a // recognised image where a video is required, or no media at all where media is required. if(need==='video' && kind==='photo') out.push({ platform:p, reason: cap(p)+' needs a video — you attached a photo. ' + why }); else if(need==='video' && !kind) out.push({ platform:p, reason: cap(p)+' needs a video. ' + why }); else if(need==='any' && !kind) out.push({ platform:p, reason: cap(p)+' needs a photo or a video. ' + why }); } // Several files: what each network takes in one post (the server checks the same numbers). const list = mediaListNow(); if(list.length>1){ for(const p of platforms){ const max = MEDIA_MAX[p]; if(!max || out.some(o=>o.platform===p)) continue; if(list.length>max) out.push({ platform:p, reason: cap(p)+' takes up to '+max+' in one post — you added '+list.length+'.' }); else if((p==='tiktok'||p==='bluesky') && list.some(x=>x.type==='video')) out.push({ platform:p, reason: cap(p)+' takes photos only in a multi-photo post.' }); } } return out; } // Items per post, by network (src/media.js MEDIA_LIMITS). Anything else gets the first file only. const MEDIA_MAX = { instagram:10, tiktok:35, bluesky:4, mastodon:4, linkedin:20, threads:20, x:4 }; // The files picked together in the composer, with the alt text typed for each, or [] for one file. function mediaListNow(){ const l = window.__mediaList || []; if(l.length<2 || !$('#postVideo') || $('#postVideo').value.trim()!==l[0].url) return []; return l.map((x,i)=>{ const a=document.querySelector('#mediaList input[data-i="'+i+'"]'); const alt=a?a.value.trim():''; return alt?{url:x.url,type:x.type,alt}:{url:x.url,type:x.type}; }); } // Live hint under the media field so the problem shows up while composing, not after publishing. function updateMediaWarn(){ const el=$('#mediaWarn'); if(!el) return; const platforms=(GEN_PLATFORMS.length?GEN_PLATFORMS:selectedPlatforms()); const b=mediaBlockers(platforms, $('#postVideo')?$('#postVideo').value.trim():'', ($('#metaFormat')&&$('#metaFormat').value)||'feed'); // The banner at the top of Compose says the same thing in more words: it goes once media is attached. if(typeof renderTextWayOut==='function') renderTextWayOut((window.__conns||[]).map(c=>typeof c==='string'?c:c.platform)); if(typeof updatePubTargets==='function') updatePubTargets(); if(!b.length){ el.textContent=''; el.style.display='none'; return; } // Name what CAN go out, so the warning ends in something to do rather than a dead end. const rest = platforms.filter(p=>!b.some(x=>x.platform===p)); el.textContent = b.map(x=>x.reason).join(' ') + (canMakeCard(b) ? ' No photo to hand? Press "Make an image from this post".' : canMakeVideo(b) ? ' No video to hand? Press "Make a video from this post".' : '') + (rest.length ? ' You can still publish this to ' + rest.map(cap).join(' and ') + ' now — untick the rest.' : ''); el.style.display = ''; } document.addEventListener('input', e=>{ if(e.target && e.target.id==='postVideo') updateMediaWarn(); }); document.addEventListener('change', e=>{ if(e.target && (e.target.id==='metaFormat' || (e.target.closest && e.target.closest('#postPlats')))) updateMediaWarn(); }); // X is not offered at all (it cannot be connected: no X app keys in production). Real users kept picking it and // hitting platform_unavailable, so it is no longer shown anywhere a network is picked. const TRY_PLATFORMS = ['linkedin','bluesky','instagram','tiktok','youtube']; const platformAvailable = (n)=>{ if(HIDDEN_NETS.includes(n)) return false; const p=(PLATFORMS||[]).find(x=>x.name===n); return p ? p.available!==false : true; }; const RF_LABEL = { regenerate:'↻ Regenerate', shorten:'– Shorten', expand:'+ Expand', formal:'Formal', casual:'Casual' }; // In the order they were chosen: the networks ticked when the composer opened first (as listed), then each // one ticked afterwards. On a plan with networks per post the first ones are the ones that go out (C1). let PICK_ORDER = []; function selectedPlatforms(){ const on=[...document.querySelectorAll('#postPlats input[type=checkbox]:checked')].map(i=>i.value); const rank=(p)=>{ const i=PICK_ORDER.indexOf(p); return i<0 ? -1 : i; }; return on.map((p,i)=>({p,i})).sort((a,b)=>{ const ra=rank(a.p), rb=rank(b.p); return ra===rb ? a.i-b.i : ra-rb; }).map(x=>x.p); } document.addEventListener('change', e=>{ const cb=e.target; if(!cb || cb.type!=='checkbox' || !cb.closest || !cb.closest('#postPlats')) return; PICK_ORDER=PICK_ORDER.filter(p=>p!==cb.value); if(cb.checked) PICK_ORDER.push(cb.value); }); // Which of these networks a post goes out to now, and which wait behind the lock (Free: the first 2). function splitNets(platforms){ const n=planLim().nets; return (n==null || platforms.length<=n) ? {send:platforms.slice(), locked:[]} : {send:platforms.slice(0,n), locked:platforms.slice(n)}; } // Brand voice: persisted per device, applied to every AI generation (text only — never touches media). function brandVoice(){ return ($('#brandVoice')?.value || '').trim(); } (function initBrandVoice(){ const el=$('#brandVoice'); if(!el) return; el.value=localStorage.getItem('pw_brandvoice')||''; el.addEventListener('input',()=>localStorage.setItem('pw_brandvoice', el.value)); })(); $('#doGenerate').onclick = async ()=>{ const platforms = selectedPlatforms(); if(!platforms.length) return msg('#genMsg','Pick at least one account (check a box above).',false); const prompt = $('#genPrompt').value.trim(); if(!prompt) return msg('#genMsg','Describe your idea in the prompt box first.',false); const media = $('#postVideo').value.trim(); // The new front door: someone generating before connecting anything. Worth counting on its own. if(!(window.__conns||[]).length) track('generate_no_conns', platforms.join(',')); $('#doGenerate').disabled=true; msg('#genMsg','✨ Writing a native post for each platform…',true); try{ const {drafts} = await api('/api/generate',{method:'POST',body:JSON.stringify({prompt,media_url:media||undefined,platforms,brand_voice:brandVoice()||undefined})}); DRAFTS = drafts||{}; GEN_PLATFORMS = platforms; renderDrafts(); msg('#genMsg','',true); show('#draftsWrap'); $('#draftsWrap').scrollIntoView({behavior:'smooth',block:'start'}); }catch(e){ msg('#genMsg',e.message,false); toast(e.message,'bad'); } $('#doGenerate').disabled=false; }; function renderDrafts(){ const wrap=$('#drafts'); wrap.innerHTML=''; const split=splitNets(GEN_PLATFORMS), nLim=planLim().nets; for(const p of GEN_PLATFORMS){ const d=cleanDraft(DRAFTS[p]||{text:''}); const max=PLAT_MAX[p]||3000; const isLocked=split.locked.includes(p); const card=document.createElement('div'); card.className='card draft'+(isLocked?' locked':''); card.dataset.plat=p; if(isLocked) card.dataset.locked='1'; let html=`
    ${ni(p)}${esc(cap(p))}${esc(NET_FMT[p]||'')}
    `; // C1: the version is written and shown in full; only sending it waits for the plan. if(isLocked) html+=`
    ${LOCK_IC}
    Written — waits for the planFree sends each post to ${nLim} networks. ${esc(cap(p))} goes out too on Starter and Pro.
    ` +`
    ${lockBtn('networks_per_post')}
    `; const art=ARTICLE_NETS.includes(p); if(p==='youtube'||p==='reddit'||art) html+=``; html+=``; if(art){ html+=`` +`` +`
    On ${esc(cap(p))}
    `; } if(p==='x' && !isLocked) html+=xThreadHtml(d); if((p==='bluesky'||p==='mastodon'||p==='linkedin') && !isLocked) html+=chainHtml(p, d); if(p==='youtube') html+=``; if(p==='youtube') html+=`
    Visibility on YouTube
    `; html+=`
    ` +['regenerate','shorten','expand','formal','casual'].map(a=>``).join('') +`
    `; card.innerHTML=html; wrap.appendChild(card); const ta=card.querySelector('.dft-text'), cc=card.querySelector('.cc'); const upd=()=>{ const n=netLen(p, ta.value); cc.textContent=fmtNum(n)+' of '+fmtNum(max)+(p==='x' && !xChainOn(card)?' · '+xCostLine(ta.value, composeMediaCount()):''); cc.style.color = n>max?'var(--bad)':''; cc.style.fontWeight = n>max?'700':''; if(p==='x') xThreadRefresh(card); else chainRefresh(card); // the box grows with the draft instead of a fixed five rows with a scrollbar ta.style.height='auto'; ta.style.height=Math.min(Math.max(ta.scrollHeight+3, 96), 420)+'px'; }; if(p==='x' && !isLocked) xThreadWire(card, upd); if(p!=='x' && !isLocked) chainWire(card, upd); ta.addEventListener('input',upd); ta._fit=upd; upd(); const pv=card.querySelector('.dft-privacy'); if(pv){ // Pre-fill from this brand's YouTube default; save the new choice as that connection's default on change. // brand_id: without it the server used the account's first YouTube connection, so on a hub with several // brands the draft showed (and changed) another brand's default (3-oct-2026). const yq=activeBrand()?'?brand_id='+encodeURIComponent(activeBrand()):''; api('/api/connect/youtube/settings'+yq).then(s=>{ if(s&&s.default_privacy) pv.value=s.default_privacy; }).catch(()=>{}); pv.onchange=()=>api('/api/connect/youtube/settings'+yq,{method:'PATCH',body:JSON.stringify({default_privacy:pv.value})}).then(()=>toast('YouTube default visibility: '+pv.value)).catch(()=>{}); } // Only the rewrite buttons: the thread and first-comment buttons are .rf too, and clicking "+ Add to thread" used to // fire a rewrite of the whole draft as well (an AI call that replaced the text). card.querySelectorAll('.rfs .rf[data-a]').forEach(b=>b.onclick=()=>refineCard(p,b.dataset.a,card)); } renderNetsNote(split); wireLocks(wrap); wrap.querySelectorAll('[data-swap]').forEach(b=>b.onclick=()=>swapInNetwork(b.dataset.swap)); mountTikTokPanel($('#ttHome')); if(GEN_PLATFORMS.includes('tiktok')){ show('#ttOptions'); loadTikTokPanel(); } else show('#ttOptions',false); updateTikTokPreview(); // the caption just changed: the preview must not keep the old one updateMediaWarn(); updatePubTargets(); // the cards were sized while the drafts were still hidden: size them again once they are on screen requestAnimationFrame(()=>{ document.querySelectorAll('#drafts .dft-text').forEach(t=>t._fit&&t._fit()); drawComposeWires(true); }); } // One line over the drafts: which networks this post goes to now and which are written and waiting (C1). function renderNetsNote(split){ const el=$('#netsNote'); if(!el) return; if(!split || !split.locked.length){ el.innerHTML=''; show('#netsNote',false); return; } const n=planLim().nets; el.innerHTML=LOCK_IC+'
    '+esc(andList(split.send.map(cap)))+' go out now. Free sends each post to '+n+' networks, so ' +esc(andList(split.locked.map(cap)))+(split.locked.length===1?' is':' are')+' written below and wait'+(split.locked.length===1?'s':'')+' behind the lock. Connecting and writing for every network stays free.
    '; show('#netsNote',true); lockSeen('networks_per_post', 'nets='+(split.send.length+split.locked.length)); } // "Send TikTok instead": the locked network takes the place of the last one that goes out. Edits are kept. function swapInNetwork(p){ const cur=collectDrafts(); Object.keys(cur).forEach(k=>{ DRAFTS[k]=Object.assign({}, DRAFTS[k]||{}, cur[k]); }); const {send}=splitNets(GEN_PLATFORMS); if(!send.length) return; const out=send[send.length-1], i=GEN_PLATFORMS.indexOf(p), j=GEN_PLATFORMS.indexOf(out); if(i<0||j<0) return; GEN_PLATFORMS[i]=out; GEN_PLATFORMS[j]=p; PICK_ORDER=GEN_PLATFORMS.slice(); renderDrafts(); toast(cap(p)+' goes out now; '+cap(out)+' waits with the others.','ok'); } // Before any draft exists, the right column shows where each network's post will appear. function renderDraftsEmpty(){ const host=$('#draftsGhost'); if(!host) return; let plats=selectedPlatforms(); if(!plats.length) plats=['linkedin','bluesky','instagram']; host.innerHTML=plats.slice(0,6).map(p=>'
    '+ni(p)+''+esc(nm(p))+''+esc(NET_FMT[p]||'')+'
    ').join(''); requestAnimationFrame(()=>drawComposeWires()); } // The landing's wires: one line from the idea to each network's card (desktop widths only). function drawComposeWires(animate){ const svg=$('#composeWires'); if(!svg) return; const view=$('[data-view="compose"]'); if(!view || !view.classList.contains('active')) return; const wrap=svg.parentElement; if(getComputedStyle(wrap).display==='none') return; const box=wrap.getBoundingClientRect(); if(!box.width || !box.height) return; const a=$('#genPrompt').getBoundingClientRect(); const y0=Math.max(12, Math.min(box.height-12, a.top+Math.min(a.height,140)/2-box.top)); const w=box.width; const cards=[...document.querySelectorAll('#drafts .card.draft, #draftsEmpty .ghostcard')].filter(c=>c.offsetParent); let html=''; cards.forEach((c,i)=>{ const y=c.getBoundingClientRect().top-box.top+22; html+=''; }); svg.innerHTML=html; if(animate && !matchMedia('(prefers-reduced-motion: reduce)').matches){ svg.querySelectorAll('path').forEach(p=>{ const L=p.getTotalLength(); p.style.strokeDasharray=L; p.style.strokeDashoffset=L; p.getBoundingClientRect(); p.style.transition='stroke-dashoffset .55s cubic-bezier(.3,.7,.2,1) '+p.style.getPropertyValue('--d'); p.style.strokeDashoffset='0'; }); } } if(window.ResizeObserver){ const ro=new ResizeObserver(()=>drawComposeWires()); ro.observe($('#composer')); } window.addEventListener('resize', ()=>drawComposeWires()); // Where the Publish button says the post goes, right next to it. function updatePubTargets(){ const el=$('#pubTargets'); if(!el) return; const all=GEN_PLATFORMS.length?GEN_PLATFORMS:selectedPlatforms(); const {send:plats, locked}=splitNets(all); const who=targetSentence(plats); el.textContent = (who ? 'Goes to: '+who : (plats.length ? 'Not connected yet: '+plats.map(cap).join(', ') : '')) + (locked.length ? ' · written, waiting: '+locked.map(cap).join(', ') : ''); } // The writer once returned TikTok's draft as '{"title": "…"}, "text": "…"}' (fixed on the server, // src/ai.js). Drafts saved in this browser before that fix still carry it, so unwrap here too. function cleanDraft(d){ if(!d || typeof d.text!=='string') return d||{text:''}; const t=d.text.trim(); if(!/^[\[{"]/.test(t) || !/"(text|caption|body)"\s*:/.test(t)) return d; try{ const o=JSON.parse(t); const x=(o&&typeof o==='object')?(o.text||o.caption||o.body):null; if(typeof x==='string') return Object.assign({},d,{text:x, title:d.title||o.title}); }catch(e){} const pick=(k)=>{ const m=new RegExp('"'+k+'"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)').exec(t); if(!m) return null; try{ return JSON.parse('"'+m[1]+'"'); }catch(e){ return m[1]; } }; const x=pick('text')||pick('caption')||pick('body'); return x ? Object.assign({},d,{text:x, title:d.title||pick('title')||undefined}) : d; } function collectDrafts(){ const out={}; document.querySelectorAll('#drafts .card[data-plat]').forEach(card=>{ const p=card.dataset.plat, o={text:card.querySelector('.dft-text').value}; const t=card.querySelector('.dft-title'); if(t) o.title=t.value; const tg=card.querySelector('.dft-tags'); if(tg) o.tags=tg.value.split(',').map(s=>s.trim()).filter(Boolean); const sm=card.querySelector('.dft-summary'); if(sm && sm.value.trim()) o.summary=sm.value.trim(); const st=card.querySelector('.dft-status'); if(st && st.value) o.status=st.value; const pv=card.querySelector('.dft-privacy'); if(pv) o.privacy=pv.value; if(p==='x'){ const c=xThreadOf(card); if(c.thread.length) o.thread=c.thread; if(c.reply) o.reply=c.reply; } else if(card.querySelector('[data-nth]')){ const c=chainOfCard(card); if(c.thread.length) o.thread=c.thread; if(c.first_comment) o.first_comment=c.first_comment; } out[p]=o; }); return out; } async function refineCard(platform, action, card){ const cur=collectDrafts()[platform]||{}, m=card.querySelector('.rfmsg'); card.querySelectorAll('.rfs .rf').forEach(b=>b.disabled=true); msg(m,'…',true); try{ const {draft}=await api('/api/refine',{method:'POST',body:JSON.stringify({platform,current:cur,action,prompt:$('#genPrompt').value.trim(),brand_voice:brandVoice()||undefined})}); DRAFTS[platform]=draft; card.querySelector('.dft-text').value=draft.text||''; const t=card.querySelector('.dft-title'); if(t&&draft.title!=null) t.value=draft.title; const tg=card.querySelector('.dft-tags'); if(tg&&draft.tags) tg.value=draft.tags.join(', '); const sm=card.querySelector('.dft-summary'); if(sm&&draft.summary!=null) sm.value=draft.summary; card.querySelector('.dft-text').dispatchEvent(new Event('input')); msg(m,'',true); }catch(e){ msg(m,e.message,false); } card.querySelectorAll('.rfs .rf').forEach(b=>b.disabled=false); } // A Publish press that cannot go out. Measured 30-sep: the reason was written 962 px (desktop) and // 1441 px (phone) below the button, nothing else moved, and no request was sent — so the button looked // dead and people left. Now: the message sits under the button, a toast says it too, the thing to fix // is scrolled to, focused and highlighted, and the attempt is counted. function blockPublish(msgSel, reason, text, focusEl, actions, noToastDup){ msg(msgSel, text, false); const box=$(msgSel); if(box && actions && actions.length){ const row=document.createElement('div'); row.className='row'; row.style.cssText='gap:8px;margin-top:8px'; actions.forEach(([label,fn,cls],i)=>{ const b=document.createElement('button'); b.className='btn sm'+(cls?' '+cls:'')+(i?' ghost':''); b.textContent=label; b.onclick=fn; row.appendChild(b); }); box.appendChild(row); } const t=toast(noToastDup ? 'Not published yet — see the note by the Publish button.' : text, 'bad'); setTimeout(()=>t.remove(), 7000); // the note by the button stays; the toast only has to be seen track('publish_blocked', String(reason).slice(0,80)); if(focusEl){ focusEl.scrollIntoView({behavior:'smooth',block:'center'}); flash(focusEl); setTimeout(()=>{ try{ focusEl.focus({preventScroll:true}); }catch(e){} }, 350); } else if(box) box.scrollIntoView({behavior:'smooth',block:'nearest'}); } // Once the thing a red toast asked for is done, the toast is wrong: take them down. function clearBadToasts(){ document.querySelectorAll('#toasts .toast.bad').forEach(t=>t.remove()); } function flash(el){ if(!el) return; el.classList.remove('attn'); void el.offsetWidth; el.classList.add('attn'); setTimeout(()=>el.classList.remove('attn'), 2600); } /* ---- "Make an image from this post": a text-only idea becomes publishable on TikTok/Instagram ---- */ // A photo only helps networks that take a photo: not YouTube, not a Reel. function canMakeCard(blocked){ return (($('#metaFormat')||{}).value||'feed')!=='reels' && blocked.every(b=>['tiktok','instagram','facebook'].includes(b.platform)); } async function makeCard(text, format){ const r=await api('/api/media/card',{method:'POST',body:JSON.stringify({text, format})}); return r; // {media_url, kind:'photo', width, height, text_on_image} } // Which draft goes on the image, and which shape: TikTok alone gets 9:16, anything else 4:5. async function makeCardForCompose(){ const plats=GEN_PLATFORMS.length?GEN_PLATFORMS:selectedPlatforms(); const drafts=document.querySelectorAll('#drafts .card[data-plat]').length?collectDrafts():{}; const src=['tiktok','instagram'].find(p=>plats.includes(p)&&drafts[p]&&drafts[p].text) || plats.find(p=>drafts[p]&&drafts[p].text); const text=(src&&drafts[src].text) || $('#genPrompt').value.trim(); const m=$('#makeCardMsg'), b=$('#makeCard'); if(!text || text.length<3){ msg(m,'Write your idea (or generate the drafts) first — the image is made from those words.',false); flash($('#genPrompt')); $('#genPrompt').focus(); return false; } const format = plats.includes('tiktok') && !plats.includes('instagram') ? 'story' : 'portrait'; track('card_image_click','compose '+format+' src='+(src||'prompt')); b.disabled=true; m.className='small muted'; m.textContent='Making the image…'; try{ const r=await makeCard(text, format); $('#postVideo').value=r.media_url; window.__uploaded={url:r.media_url, kind:'photo'}; window.__mediaList=null; renderMediaList(); $('#mediaFile').value=''; showMediaThumb(null); $('#mediaState').textContent=''; m.textContent=''; showCardPreview($('#cardPreview'), r, ()=>{ $('#postVideo').value=''; window.__uploaded=null; show('#cardPreview',false); updateMediaWarn(); updateTikTokPreview(); }); updateMediaWarn(); updateTikTokPreview(); clearBadToasts(); toast('Image ready — it is attached to this post.','ok'); const pm=$('#publishMsg'); if(pm && pm.classList.contains('bad')) pm.textContent=''; b.disabled=false; return true; }catch(e){ msg(m, e.message, false); toast(e.message,'bad'); } b.disabled=false; return false; } function showCardPreview(host, r, onRemove){ if(!host) return; host.innerHTML='
    The image made from your post
    Image made from your post
    '+r.width+' × '+r.height+' — your words only, no watermark. It is attached; publish as usual.
    '; host.classList.remove('hidden'); host.querySelector('[data-rm]').onclick=onRemove; } $('#makeCard') && ($('#makeCard').onclick=makeCardForCompose); /* ---- "Make a video from this post" (4-oct): YouTube takes only a video and TikTok a video or photos. A person with words and no clip (a Claude user who connected only YouTube never posted) gets a 9:16 video of 8-12 s made from the post's own words, stored like an upload (src/cardvideo.js). ---- */ const VIDEO_NETS=['youtube','tiktok','instagram','facebook']; // A video fixes every blocked network here (a YouTube, a Reel, a TikTok, an Instagram post), unless several // files are attached (a carousel is the person's choice, not a missing video). function canMakeVideo(blocked){ return blocked.length>0 && blocked.every(b=>VIDEO_NETS.includes(b.platform)) && !mediaListNow().length; } // The brand's own name as the small footer line, unless it is still the default "My brand". function videoFooter(){ const n=String((BRANDS.find(b=>b.id===activeBrand())||{}).name||'').trim(); return n && !/^my brand$/i.test(n) ? n.slice(0,40) : undefined; } async function makeVideo(text){ return api('/api/media/video',{method:'POST',body:JSON.stringify({text, footer:videoFooter()})}); } function showVideoPreview(host, r, onRemove){ if(!host) return; host.innerHTML='
    Video made from your post
    '+esc(r.duration_s)+' s, '+r.width+' × '+r.height+' — your words only, no watermark'+(r.music&&r.music!=='none'?', with royalty-free music':'')+'. It is attached; publish as usual.
    '; host.classList.remove('hidden'); host.querySelector('[data-rm]').onclick=onRemove; } async function makeVideoForCompose(){ const plats=GEN_PLATFORMS.length?GEN_PLATFORMS:selectedPlatforms(); const drafts=document.querySelectorAll('#drafts .card[data-plat]').length?collectDrafts():{}; const src=['youtube','tiktok','instagram'].find(p=>plats.includes(p)&&drafts[p]&&drafts[p].text) || plats.find(p=>drafts[p]&&drafts[p].text); const text=(src&&drafts[src].text) || $('#genPrompt').value.trim(); const m=$('#makeCardMsg'), b=$('#makeVideo'); if(!text || text.length<3){ msg(m,'Write your idea (or generate the drafts) first — the video is made from those words.',false); flash($('#genPrompt')); $('#genPrompt').focus(); return false; } track('video_click','compose src='+(src||'prompt')+' nets='+plats.join('+')); if(b) b.disabled=true; m.className='small muted'; m.textContent='Making the video… (about 10 seconds)'; try{ const r=await makeVideo(text); $('#postVideo').value=r.media_url; window.__uploaded={url:r.media_url, kind:'video'}; window.__mediaList=null; renderMediaList(); $('#mediaFile').value=''; showMediaThumb(null); $('#mediaState').textContent=''; m.textContent=''; offerQualityCheck(r.media_url); showVideoPreview($('#cardPreview'), r, ()=>{ $('#postVideo').value=''; window.__uploaded=null; show('#cardPreview',false); updateMediaWarn(); updateTikTokPreview(); }); updateMediaWarn(); updateTikTokPreview(); clearBadToasts(); toast('Video ready — it is attached to this post.','ok'); const pm=$('#publishMsg'); if(pm && pm.classList.contains('bad')) pm.textContent=''; if(b) b.disabled=false; return true; }catch(e){ msg(m, e.message, false); toast(e.message,'bad'); } if(b) b.disabled=false; return false; } $('#makeVideo') && ($('#makeVideo').onclick=makeVideoForCompose); async function publishAll(per_platform, msgSel, opts){ // Clicking Publish while the file is still uploading used to fail with "fix the media first" — the // URL was not in the field yet. Wait for the upload instead; that is what the person meant. if(window.__uploadP){ msg(msgSel,'Waiting for your file to finish uploading…',true); try{ await window.__uploadP; }catch{} } // C1: on a plan with networks per post only the first ones are sent; the rest are written and wait. Every // check below (connected, media, TikTok's box) is about the networks that actually go out. const {send:platforms, locked:heldNets} = splitNets((opts && opts.platforms) || (GEN_PLATFORMS.length?GEN_PLATFORMS:selectedPlatforms())); const media=$('#postVideo').value.trim(); let photo_url,video_url; const metaFmt=($('#metaFormat')&&$('#metaFormat').value)||'feed'; ({photo_url,video_url} = mediaFields(media, platforms, metaFmt)); // Nothing connected: publishing is the first step that truly needs OAuth, so ask for it here — // after they have seen the drafts — rather than as a wall in front of the composer. if(!(window.__conns||[]).length){ blockPublish(msgSel, 'no_network', 'These drafts are saved. To send them, connect the account you want to post to — one click for LinkedIn, TikTok or Instagram.', null, [['Connect an account →', ()=>{ track('connect_from_compose'); navTo('start'); onbShow(2); }]]); return false; } // Networks ticked here that are not connected: the server refuses the whole batch, so say it first. const notConn = platforms.filter(p=>!(window.__conns||[]).includes(p)); if(notConn.length){ blockPublish(msgSel, 'not_connected:'+notConn.join(','), notConn.map(cap).join(' and ')+(notConn.length>1?' are':' is')+' not connected yet. Connect '+(notConn.length>1?'them':'it')+', or untick '+(notConn.length>1?'them':'it')+' above.', null, [['Connect '+cap(notConn[0])+' →', ()=>{ navTo('start'); onbShow(2); setTimeout(()=>onbOpenNet(notConn[0]),60); }]]); return false; } // Stop the ones that are guaranteed to fail, and offer to publish the rest instead of losing the whole batch. const blocked = mediaBlockers(platforms, media, metaFmt); if(blocked.length){ const rest = platforms.filter(p=>!blocked.some(b=>b.platform===p)); const acts=[]; // Not a dead end (2-oct): one button makes the image from the post and carries on with this same // publish. TikTok still stops at its privacy box — never chosen for them. const wow = canMakeCard(blocked); if(wow) acts.push(['Make an image and publish', async (ev)=>{ const btn=ev && ev.currentTarget; if(btn){ btn.disabled=true; btn.textContent='Making the image…'; } const made = await makeCardForCompose(); if(!made){ if(btn){ btn.disabled=false; btn.textContent='Make an image and publish'; } return; } WOW_PUB = true; try{ const dr=collectDrafts(); await publishAll(Object.keys(dr).length?dr:per_platform, msgSel, Object.assign({}, opts||{}, {afterCard:true})); }catch(e){ msg(msgSel,e.message,false); toast(e.message,'bad'); } }, 'wow']); // A network that takes only a video (YouTube, a Reel) cannot use the image: the video from the post's // words is the way out there (4-oct), and next to the image when both would work (TikTok, Instagram). const vid = canMakeVideo(blocked); if(vid) acts.push(['Make a video and publish', async (ev)=>{ const btn=ev && ev.currentTarget; if(btn){ btn.disabled=true; btn.textContent='Making the video…'; } const made = await makeVideoForCompose(); if(!made){ if(btn){ btn.disabled=false; btn.textContent='Make a video and publish'; } return; } WOW_PUB = true; try{ const dr=collectDrafts(); await publishAll(Object.keys(dr).length?dr:per_platform, msgSel, Object.assign({}, opts||{}, {afterCard:true})); }catch(e){ msg(msgSel,e.message,false); toast(e.message,'bad'); } }, wow ? undefined : 'wow']); acts.push([wow||vid?'Choose my own photo or video':'Choose a photo or video', ()=>{ const f=$('#mediaFile'); f.scrollIntoView({behavior:'smooth',block:'center'}); flash(f); f.click(); }]); if(rest.length) acts.push(['Publish to the other '+rest.length+' only', async ()=>{ try{ await publishAll(per_platform, msgSel, {platforms:rest}); }catch(e){ msg(msgSel,e.message,false); } }]); blockPublish(msgSel, 'media:'+blocked.map(b=>b.platform).join(','), blocked.map(b=>b.reason).join(' ') + (wow ? ' No photo to hand? PostWire turns your post into an image and publishes it.' : vid ? ' No video to hand? PostWire turns your post into a short video and publishes it.' : ''), wow || vid ? null : $('#mediaFile'), acts, true); return false; } const body={platforms,photo_url,video_url,per_platform,brand_id:composeBrandId()}; const mlist=mediaListNow(); if(mlist.length) body.media=mlist; if(platforms.includes('tiktok')){ const priv=selectedPrivacy(); if(!document.getElementById('ttPrivSel')){ blockPublish(msgSel, 'tiktok_panel_not_loaded', 'TikTok settings could not be loaded. Reconnect TikTok in Accounts, then try again.', $('#ttOptions')); return false; } if(!priv){ if(opts && opts.afterCard){ blockPublish(msgSel, 'tiktok_privacy after_card', (window.__uploaded && window.__uploaded.kind==='video' ? 'Your video' : 'Your image')+' is ready and attached. Last step: choose who can view this post on TikTok (the highlighted box), then press Publish now.', document.getElementById('ttPrivSel'), null, true); return false; } blockPublish(msgSel, 'tiktok_privacy', 'TikTok: choose who can view this post first — it is the highlighted box above.', document.getElementById('ttPrivSel')); return false; } const disclose=$('#ttDisclose').checked, organic=$('#ttBrandOrganic').checked, branded=$('#ttBranded').checked; if(disclose && !organic && !branded){ blockPublish(msgSel, 'tiktok_disclosure', 'TikTok: say whether this promotes your own brand, another brand, or both.', $('#ttBrandOrganic')); return false; } body.options={ tiktok:{ privacy_level:priv, disable_comment:!$('#tt_comment')?.checked, disable_duet:!$('#tt_duet')?.checked, disable_stitch:!$('#tt_stitch')?.checked, brand_organic_toggle:disclose&&organic, branded_content_toggle:disclose&&branded, ...ttMethodOpt() } }; } // Meta (Instagram/Facebook) format: feed / reels / story if(metaFmt!=='feed' && (platforms.includes('instagram')||platforms.includes('facebook'))){ body.options=body.options||{}; if(platforms.includes('instagram')) body.options.instagram={...(body.options.instagram||{}),post_type:metaFmt}; if(platforms.includes('facebook')) body.options.facebook ={...(body.options.facebook ||{}),post_type:metaFmt}; } // X thread / reply: a post over 280 is refused by the server before anything goes out; say it here first. if(platforms.includes('x')){ const xp=xThreadProblem(); if(xp){ blockPublish(msgSel, 'x_thread_over', xp.text, xp.el); return false; } } { const cp=chainProblemClient(); if(cp){ blockPublish(msgSel, 'thread_over', cp.text, cp.el); return false; } } xReplyToOptions(body); // An account with several brands confirms the brand and every account before anything goes out. if(!confirmTargets(platforms, 'Publish now')){ msg(msgSel,'Not published.',false); return false; } if(refusedBefore(body)){ const ex=explainClient({code:'tiktok_invalid_post'},'tiktok'); blockPublish(msgSel, 'same_refused', ex.what+' '+ex.fix, document.querySelector('#drafts .card[data-plat="tiktok"] .dft-text')); return false; } const j=await api('/api/post',{method:'POST',body:JSON.stringify(body)}); rememberRefused(body, j.results); const okc=j.results.filter(r=>r.ok).length; const detail=j.results.filter(r=>!r.held).map(r=>`${cap(r.platform||'?')}${r.account&&r.account.handle?' '+r.account.handle:(r.account&&r.account.display_name?' '+r.account.display_name:'')}: ${r.ok?'✓'+xThreadLine(r):'✗ '+(r.error||'')}`).join(' · '); // A post that succeeded but is invisible is worse than one that failed loudly, so any // notice a platform sent back is shown, not buried in the JSON. const notices=j.results.flatMap(r=>[r.notice, r.warning]).filter(Boolean); const sentR=j.results.filter(r=>!r.held), heldAll=[...new Set(heldNets.concat((j.held_networks||[])))]; msg(msgSel, `Published to ${okc}/${sentR.length}${BRANDS.length>1?' for '+brandNameOf(activeBrand()):''} — ${detail}` + (notices.length?` ⚠️ ${notices.join(' · ')}`:''), okc>0); if(heldAll.length){ const box=document.createElement('div'); box.className='heldbox'; box.innerHTML=LOCK_IC+'

    '+esc(andList(heldAll.map(cap)))+(heldAll.length===1?' was':' were')+' written but not sent. Free sends each post to '+(planLim().nets||2)+' networks; Starter and Pro send it to every network you connect.

    '+lockBtn('networks_per_post')+(j.coach_moment&&window.PWCoach?PWCoach.momentHtml(j.coach_moment,'publish'):'')+'
    '; $(msgSel).appendChild(box); wireLocks(box); } // A network refused for the monthly cap: api() already drew the upgrade card with the checkout link. if(platforms.includes('tiktok') && j.results.some(r=>r.ok && r.platform==='tiktok')){ const note=document.createElement('div'); note.className='muted small'; note.style.marginTop='6px'; note.textContent='TikTok may take a few minutes to process before the post is visible on your profile.'; $(msgSel).appendChild(note); } const failedR=j.results.filter(r=>!r.ok && !r.held); if(failedR.length){ const ex=explainClient({message:failedR[0].error, code:failedR[0].code}, failedR[0].platform); fixCard($(msgSel), {what: cap(failedR[0].platform||'')+': '+ex.what, fix: ex.fix, action: ex.action}); } const xr=j.results.find(r=>r.platform==='x' && r.ok && r.thread); toast(`Published to ${okc}/${j.results.filter(r=>!r.held).length}`+(xr?' · '+xThreadLine(xr).trim():''), okc>0?'ok':'bad'); if(okc>0){ const first=!DID_POST, wowed=WOW_PUB; WOW_PUB=false; if(first) track('first_post', platforms.join(',')+(wowed?' card=1':'')); DID_POST=true; localStorage.setItem('pw_posted','1'); // The first post ever: celebrate it where they are, with the links, the time it took and the trial. const quotaCard = offerSecondBrand(msgSel, okc); if(first) showCelebrationModal({ results:j.results.filter(r=>r.ok), via:'compose', wow:wowed }); // Every later publish: the trial, quietly, under the result — unless the held-networks box or the quota card // already offers it (one offer per result, never two). else if(!heldAll.length && !quotaCard) mountTrialStrip($(msgSel), 'compose', trialWhy('compose', {sent:okc})); refresh(); } return true; } $('#doPublish').onclick = async ()=>{ // The bar is on screen before any draft exists: say what to do instead of sending an empty post. if(!document.querySelector('#drafts .card[data-plat]')){ return blockPublish('#publishMsg', 'no_drafts', 'There are no drafts yet. Write the posts first — or publish your idea exactly as written.', $('#genPrompt'), [['Write the posts', ()=>$('#doGenerate').click()], ['Post my text as-is', ()=>$('#doPostPlain').click()]], true); } $('#doPublish').disabled=true; try{ await publishAll(collectDrafts(),'#publishMsg'); } catch(e){ msg('#publishMsg',e.message,false); toast(e.message,'bad'); } $('#doPublish').disabled=false; }; $('#doPostPlain').onclick = async ()=>{ const platforms=selectedPlatforms(); if(!platforms.length) return msg('#genMsg','Pick at least one account first.',false); const text=$('#genPrompt').value; if(!text.trim()) return msg('#genMsg','Type your text in the prompt box (it will be posted exactly as written).',false); GEN_PLATFORMS=platforms; if(platforms.includes('tiktok')){ show('#draftsWrap'); show('#ttOptions'); await loadTikTokPanel(); } const per={}; platforms.forEach(p=>per[p]={text}); $('#doPostPlain').disabled=true; try{ await publishAll(per, '#genMsg'); } catch(e){ msg('#genMsg',e.message,false); toast(e.message,'bad'); } $('#doPostPlain').disabled=false; }; /* ---- Unverified mode: full view, no actions that touch a real account ---- */ let PREVIEW = false; const GATED = ['#doConnect','#doPublish','#doPostPlain','#doSchedule','#doSlot','#doWeek','#addBrand']; function renderVerifyState(me){ PREVIEW = !me.verified || me.key_scope === 'preview'; show('#verifyBar', PREVIEW); if(!PREVIEW && $('#startGate')) show('#startGate', false); if(!PREVIEW){ // Unlocked (link opened here, or the code typed): give the buttons back. They used to stay grey // until a reload, because this only ever disabled them. GATED.forEach(sel=>{ const b=$(sel); if(b && b.title==='Confirm your email first'){ b.disabled=false; b.title=''; } }); document.querySelectorAll('[data-codebox]').forEach(h=>h.innerHTML=''); return; } const em = me.email || localStorage.getItem('pw_pending_email') || ''; const lbl=$('#verifyEmail'); if(lbl) lbl.textContent = em ? ('Sent to ' + em) : ''; // Verified somewhere else (the mail app's own browser, another device) but this browser still holds // the preview key: the link cannot help here any more — the emailed code can. const elsewhere = !!me.verified; $('#verifyH').textContent = elsewhere ? 'Your email is confirmed — unlock this browser' : 'Confirm your email to connect accounts'; $('#verifyWhy').textContent = elsewhere ? 'You confirmed it in another app or browser. Type the 6-digit code from the same email here and this tab can connect and publish too.' : 'Open the link we emailed you — or, if it opens in another app or browser, type the 6-digit code from that email below. Connecting a social account and publishing need a confirmed address; that is what stops someone using your email to post as you.'; renderCodeBoxes(elsewhere); // Grey out the actions that will be refused anyway, so nobody hits a wall mid-task. GATED.forEach(sel=>{ const b=$(sel); if(b){ b.disabled=true; b.title='Confirm your email first'; } }); if(elsewhere) autoSendCode(); } // The 6-digit code box, in the Home banner and in the Get started gate (same behaviour in both). function renderCodeBoxes(elsewhere){ document.querySelectorAll('[data-codebox]').forEach(host=>{ // The Get started strip already says "or type its code here": its label just names the field. const lbl = (elsewhere || host.closest('#startGate')) ? 'The 6‑digit code from the confirmation email' : 'Opened the link in another app? Type the code from the same email'; if(host.querySelector('input')){ host.querySelector('label').textContent=lbl; return; } // keep what the person is typing host.innerHTML='' + '
    ' + '
    ' + '
    '; const inp=host.querySelector('input'), go=host.querySelector('[data-code-go]'), send=host.querySelector('[data-code-send]'), m=host.querySelector('[data-code-msg]'); const submit=async()=>{ const code=inp.value.replace(/\D/g,''); if(code.length!==6){ msg(m,'The code has 6 digits.',false); inp.focus(); return; } go.disabled=true; msg(m,'Checking…',true); try{ await api('/api/me/verify-code/check',{method:'POST',body:JSON.stringify({code})}); msg(m,'Unlocked.',true); await afterUnlock(); }catch(e){ msg(m,e.message,false); go.disabled=false; inp.select(); } }; go.onclick=submit; inp.addEventListener('keydown',e=>{ if(e.key==='Enter') submit(); }); inp.addEventListener('input',()=>{ if(inp.value.replace(/\D/g,'').length===6) submit(); }); send.onclick=async()=>{ send.disabled=true; await sendCode(m); setTimeout(()=>{ send.disabled=false; },30000); }; }); } async function sendCode(m, quiet){ try{ const r=await api('/api/me/verify-code',{method:'POST'}); if(r.already){ await afterUnlock(); return; } const t = r.sent ? 'Code sent to '+(r.email||'your inbox')+' — check spam too.' : (r.error||'Could not send the code.'); if(m) msg(m, t, !!r.sent); else if(!quiet || r.sent) toast(t, r.sent?'ok':'bad'); }catch(e){ if(m) msg(m,e.message,false); else if(!quiet) toast(e.message,'bad'); } } // Once per browser session: the person already proved they want in; one email with the code saves a click. function autoSendCode(){ const k='pw_code_auto_'+(window.__acct||''); try{ if(sessionStorage.getItem(k)) return; sessionStorage.setItem(k,'1'); }catch(e){ return; } sendCode(null, true); } async function afterUnlock(){ ME_PRELOAD=null; await refresh(); if($('.view.active')?.dataset.view==='start') onbShow(ONB_VIEW); toast('This browser is unlocked — you can connect a network and publish now.','ok'); } $('#resendVerify') && ($('#resendVerify').onclick = async ()=>{ const em = localStorage.getItem('pw_pending_email') || ''; if(!em) return msg('#verifyMsg','Sign in again to resend the link.',false); $('#resendVerify').disabled=true; try{ await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:em})}); msg('#verifyMsg','Sent again — check your inbox (and spam).',true); }catch(e){ msg('#verifyMsg',e.message,false); } $('#resendVerify').disabled=false; }); /* ---- Funnel: where exactly people stop ---- */ function track(step, detail){ try{ const ft = window.__pwFirstTouch || {}; // pw_funnel has no account_id column and the table lives on a Supabase project the CLI token // cannot reach, so the account rides along in detail. Ugly, but it is the difference between // "someone saw the offer" and "this user saw the offer and still did not buy". const acct = window.__acct; const det = [detail, acct ? 'acct=' + acct : null].filter(Boolean).join(' ') || undefined; const body = JSON.stringify({ step, detail: det, visitor_id: ft.visitor_id, utm_source: ft.utm_source }); if(navigator.sendBeacon) navigator.sendBeacon('/api/f', new Blob([body],{type:'application/json'})); else fetch('/api/f',{method:'POST',headers:{'content-type':'application/json'},body,keepalive:true}).catch(()=>{}); }catch(e){} } // Asked inside the app, after they are already in — not as a toll on the signup form. // 20-oct-2026: one tap on a chip (469 signups in a day, 4 answers with the old dropdown + Save), the chips name the // places a wave of TikTok editors came from, and the card follows the person into whichever view they open for the // first 7 days, so the people who go straight to Compose see it too. The chip saves at once; the link is optional. function maybeAskSource(me){ const el=$('#askSource'); if(!el) return; if(!me.verified){ show('#askSource',false); return; } const age = me.created_at ? (Date.now()-Date.parse(me.created_at))/864e5 : 99; if(localStorage.getItem('pw_source_asked')==='1' || me.heard_from || age>7){ show('#askSource',false); return; } show('#askSource', true); if(!window.__askLogged){ window.__askLogged=true; track('source_asked','v=chips'); } const det=$('#heardDetail'), chips=$('#heardChips'); if(chips && !chips.__wired){ chips.__wired=true; let picked=''; const save=async(v, detail)=>{ try{ await api('/api/me/source',{method:'POST',body:JSON.stringify({heard_from:v, heard_from_detail:detail||undefined})}); }catch(e){} track('source_answered', v+(detail?' detail=1':'')); }; chips.querySelectorAll('.chip').forEach(b=>{ b.onclick=async()=>{ picked=b.dataset.src; chips.querySelectorAll('.chip').forEach(x=>x.setAttribute('aria-pressed', x===b?'true':'false')); await save(picked, ''); show('#heardMore', true); det.focus(); det.placeholder = picked==='tiktok_video'||picked==='youtube' ? 'Which video? Paste the link or the creator (optional)' : picked==='discord'||picked==='telegram_whatsapp' ? 'Which server or channel? (optional)' : 'Which one? (optional)'; }; }); $('#saveSource').onclick=async()=>{ const d=(det.value||'').trim(); if(picked && d) await save(picked, d); toast('Thank you — noted','ok'); localStorage.setItem('pw_source_asked','1'); show('#askSource',false); }; det.onkeydown=(e)=>{ if(e.key==='Enter'){ e.preventDefault(); $('#saveSource').click(); } }; $('#skipSource').onclick=()=>{ localStorage.setItem('pw_source_asked','1'); show('#askSource',false); }; // follow the person: the card sits at the top of the view that is open const place=()=>{ const v=document.querySelector('.view.active'); if(!v || el.classList.contains('hidden')) return; if(el.parentElement!==v) v.prepend(el); else if(v.firstElementChild!==el) v.prepend(el); }; place(); setInterval(place, 1500); } } /* ---- Which account is this? The handle, name and picture the network itself reports. ---- */ function connIdent(c){ const i=(c&&c.identity)||{}; return { handle:i.handle||null, name:i.display_name||null, avatar:i.avatar_url||null, url:i.profile_url||null, label:(c&&c.label)||i.handle||i.display_name||null, err:i.error||null }; } function avatarHtml(c){ const id=connIdent(c); const tile=`
    ${ni(c.platform)||esc(cap(c.platform)[0]||'?')}
    `; if(!id.avatar) return `
    ${tile}
    `; // The picture comes from the network's CDN; if it fails to load, fall back to the platform tile. return `
    ${ni(c.platform)}
    `; } // The brand the composer publishes for. With one brand it is that one; with several, the one picked. function composeBrandId(){ return activeBrand() || undefined; } function composeConns(){ const all=window.__connObjs||[]; return BRANDS.length>1 ? all.filter(c=>c.brand_id===activeBrand()) : all; } function brandNameOf(id){ return ((BRANDS.find(b=>b.id===id)||{}).name)||''; } // "Bluesky @renzom13.bsky.social" for each ticked network, from the brand the composer is on. function targetsFor(platforms){ const cs=composeConns(); return platforms.map(p=>{ const c=cs.find(x=>x.platform===p); const id=connIdent(c||{}); return { platform:p, label:id.label, connected:!!c }; }); } function targetSentence(platforms){ const t=targetsFor(platforms).filter(x=>x.connected); if(!t.length) return ''; return t.map(x=>nm(x.platform)+(x.label?' '+x.label:'')).join(' · '); } // Before anything goes out from an account with several brands: say the brand and every account, and ask. function confirmTargets(platforms, verb){ if(BRANDS.length<2) return true; const t=targetsFor(platforms).filter(x=>x.connected); const lines=t.map(x=>'• '+nm(x.platform)+' — '+(x.label||'account name unknown')).join('\n'); return confirm(verb+' for the brand "'+brandNameOf(activeBrand())+'":\n\n'+lines+'\n\nContinue?'); } function updateTargetLine(){ const el=$('#targetLine'); if(!el) return; const plats=selectedPlatforms(); const conns=composeConns(); if(!conns.length || !plats.length){ show('#targetLine',false); return; } const who=targetSentence(plats); el.innerHTML='Posts go to: '+esc(who||'—')+(BRANDS.length>1?' — brand '+esc(brandNameOf(activeBrand()))+'':''); show('#targetLine',true); } // The composer's list of accounts: only the chosen brand's, each named by its handle. function renderComposeTargets(){ const conns=composeConns(); window.__conns = conns.map(c=>c.platform); const row=$('#composeBrandRow'), sel=$('#composeBrand'); if(row && sel){ if(BRANDS.length>1){ const cur=activeBrand(); sel.innerHTML=BRANDS.map(b=>'').join(''); sel.onchange=()=>setActiveBrand(sel.value); show('#composeBrandRow',true); } else show('#composeBrandRow',false); } ttToggleConnectNote(); // Compose target = CHECKBOXES of the brand's connected accounts (no typing → no typos/confusion). // A-offer-moment (9-oct-2026): on a plan with networks per post (Free: 2) only that many are ticked when the composer // opens, so a FIRST post never runs into the cap; the rest are one click away. Only for an account that never posted // (review 9-oct-2026): afterwards every network is ticked again, so the held-networks note and its upgrade keep // working. No cap (paid, trial): all of them. const neverPosted=!(ME && ME.ever_posted) && !DID_POST; const preN=neverPosted ? planLim().nets : null, pre=(i)=>preN==null || i`+conns.map((c,i)=>{ const id=connIdent(c); // The chip names the network; the line under the chips names each account (#targetLine). return ``; }).join('')+`` : (BRANDS.length>1 && (window.__connObjs||[]).length ? `

    ${esc(brandNameOf(activeBrand()))} has no accounts connected yet. Pick another brand above, or connect one in Accounts.

    ` : `

    Nothing connected yet — pick networks to see what PostWire writes for each. Connecting is only needed to publish.

    `) + `
    `+TRY_PLATFORMS.map(pn=>{ if(!platformAvailable(pn)) return ``; const tryOn=['linkedin','bluesky','instagram'].filter(platformAvailable).slice(0, preN==null?3:preN); return ``; }).join('')+`
    `; updateTargetLine(); renderDraftsEmpty(); } document.addEventListener('change', e=>{ if(e.target && e.target.closest && e.target.closest('#postPlats')){ updateTargetLine(); renderDraftsEmpty(); } }); /* ---- Brands: one business with all its networks. The plan counts these, not logins. ---- */ let BRANDS = [], ACTIVE_BRAND = localStorage.getItem('pw_brand') || ''; function activeBrand(){ if(BRANDS.some(b=>b.id===ACTIVE_BRAND)) return ACTIVE_BRAND; return BRANDS.length ? BRANDS[0].id : ''; } function setActiveBrand(id){ ACTIVE_BRAND=id; localStorage.setItem('pw_brand', id); renderBrandBar(); renderBrandPicker(); if($('#postPlats')) renderComposeTargets(); } function renderBrandBar(){ const el=$('#brandBar'); if(!el) return; if(!BRANDS.length){ el.innerHTML=''; return; } const cur=activeBrand(); el.innerHTML = '
    ' + BRANDS.map(b=>'').join('') + '' + '
    '; const lim = (window.__brandLimit==null?1:window.__brandLimit); $('#brandCount').textContent = BRANDS.length+' of '+(lim<0?'\u221e':lim)+' brands used'; el.querySelectorAll('.bchip').forEach(b=>b.onclick=()=>setActiveBrand(b.dataset.b)); $('#addBrand').onclick=async()=>{ const name=prompt('Name this brand (the business, not the network):','Client name'); if(!name) return; try{ const r=await api('/api/brands',{method:'POST',body:JSON.stringify({name})}); toast('Brand created','ok'); await refresh(); setActiveBrand(r.brand.id); } catch(e){ toast(e.message,'bad'); msg('#connMsg',e.message,false); } }; } // The connect panel needs to know which brand the new login belongs to. function renderBrandPicker(){ const host=$('#platFields'); if(!host || BRANDS.length<2) { const old=$('#brandPick'); if(old) old.remove(); return; } let el=$('#brandPick'); if(!el){ el=document.createElement('div'); el.id='brandPick'; el.style.marginTop='10px'; host.parentNode.insertBefore(el, host.nextSibling); } const cur=activeBrand(); el.innerHTML=''; $('#brandPickSel').onchange=()=>setActiveBrand($('#brandPickSel').value); } /* ---- Running out of quota is the one moment upgrading is obviously worth it ---- Until now the only upgrade prompt appeared *after* a post had already been rejected for hitting the monthly limit — which means the user lost a post to find out. The person closest to the ceiling is also the person getting the most out of the product, so tell them while they still have room, in the place where they publish rather than buried in Settings. */ function renderQuotaNotice(me){ const el=$('#quotaNotice'); if(!el||!me.usage) return; const {posts, limit} = me.usage; if(limit<0 || me.plan!=='free'){ show('#quotaNotice', false); return; } const left = limit - posts; if(posts < 1){ show('#quotaNotice', false); return; } // 20-oct-2026: from the first post (it waited for 70 %; 0 of 151 paid) const headline = left > 0 ? `${left} post${left===1?'':'s'} left this month on the free plan (${posts} of ${limit} used).` : `You have used all ${limit} posts this month on the free plan.`; // The next plan, as /api/me (or the plans list) describes it: never a number written into the page. const nx = (me.upgrade && me.upgrade.name) ? me.upgrade : ((PLANS_INFO && PLANS_INFO.starter) ? {plan:'starter', name:PLANS_INFO.starter.name, price_usd_month:PLANS_INFO.starter.price, unlocks:fmtNum(PLANS_INFO.starter.limit)+' posts a month, '+PLANS_INFO.starter.brands+' brands'} : null); el.innerHTML = headline + (nx ? ' '+esc(nx.name)+' is '+((me.starter_half && me.starter_half.plan===nx.plan) ? '$'+esc(me.starter_half.first_month_usd.toFixed(2))+' the first month, then $'+esc(nx.price_usd_month)+'/month,' : '$'+esc(nx.price_usd_month)+'/month')+' for '+esc(nx.unlocks)+'.' : '') + '
    '; show('#quotaNotice'); const b=$('#quotaUpgrade'); // /api/me carries a signed checkout link once 80 % is used: the button opens that plan's checkout // in one click instead of the plans page (which asked for a second decision and a second click). const go = me.upgrade && /\/api\/billing\/go\?t=/.test(me.upgrade.url||'') ? me.upgrade : null; if(b && go){ b.textContent='Upgrade to '+go.name+' — $'+go.price_usd_month+'/mo →'; } if(b) b.onclick=()=>{ track('quota_upgrade_click', posts+'/'+limit); if(go){ location.href=go.url; return; } navTo('settings'); const pl=$('#plans'); if(pl) pl.scrollIntoView({behavior:'smooth'}); }; } /* ---- Activation: connected-but-silent is the state that quietly kills an account ---- */ function renderActivation(me){ const el=$('#activateBanner'); if(!el) return; // The four-step Setup card and the Get started view now carry this; two banners saying the same // thing one above the other was noise. show('#activateBanner', false); return; const conns=(me.connections||[]).length; const silent = conns>0 && !me.ever_posted; show('#activateBanner', silent); if(silent){ // Half the people stuck here connected only TikTok/Instagram/YouTube, and every one of those // refuses a text-only post. Sending them to "Fill my week" — which is text-only by design — // was sending them to a wall. Offer the thing their own accounts can actually do today. const plats = (me.connections||[]).map(c => c.platform); const textOk = plats.filter(p => !(MEDIA_REQ[p] && MEDIA_REQ[p].need)); const names = plats.map(cap).join(', '); if(textOk.length){ $('#activateWhy').textContent = names+' connected, nothing published yet. '+textOk.map(cap).join(' and ')+' can post text on their own — one takes about a minute, or queue a week in one go.'; show('#actWeek', true); } else { // Every connected network needs a file. A week plan cannot help them; say so instead of hiding it. $('#activateWhy').textContent = names+' connected, nothing published yet. '+(plats.length===1?'It needs':'They all need')+' a photo or video — text alone will be refused. Attach media in the composer, or connect a network that posts text (LinkedIn, Telegram, Bluesky, Mastodon, Discord) in about a minute.'; show('#actWeek', false); } } } $('#actPublish') && ($('#actPublish').onclick=()=>{ navTo('compose'); const el=$('#genPrompt'); if(el){ el.scrollIntoView({behavior:'smooth',block:'center'}); el.focus(); } }); $('#actWeek') && ($('#actWeek').onclick=()=>{ navTo('compose'); const el=$('#weekCard'); if(el){ el.scrollIntoView({behavior:'smooth',block:'center'}); $('#weekTopic').focus(); } }); /* ---- Fill my week: one topic → five angles → five days in the queue ---- */ (function initWeek(){ const sel=$('#weekHour'); if(!sel) return; for(let h=6;h<=22;h++){ const label=(h%12===0?12:h%12)+':00 '+(h<12?'am':'pm'); sel.insertAdjacentHTML('beforeend',''); } const tz=$('#weekTz'); if(tz){ try{ tz.textContent='('+Intl.DateTimeFormat().resolvedOptions().timeZone+')'; }catch(e){} } })(); $('#doWeek') && ($('#doWeek').onclick = async ()=>{ const topic=($('#weekTopic').value||'').trim(); if(topic.length<15) return msg('#weekMsg','Describe the topic in a sentence or two — the more specific, the less generic the posts.',false); const platforms=selectedPlatforms(); if(!platforms.length) return msg('#weekMsg','Tick the accounts to post to (above).',false); const blocked=mediaBlockers(platforms,'', 'feed'); if(blocked.length) return msg('#weekMsg','\u26a0\ufe0f '+blocked.map(b=>b.reason).join(' ')+' A week plan is text-only — untick those for now.',false); if(!confirmTargets(platforms, 'Queue 5 days of posts')) return msg('#weekMsg','Nothing queued.',false); $('#doWeek').disabled=true; msg('#weekMsg','\u2728 Writing five different posts and queueing them\u2026',true); try{ const r=await api('/api/week',{method:'POST',body:JSON.stringify({ topic, platforms, days:5, brand_id: composeBrandId(), start_hour: Number($('#weekHour').value), tz_offset_minutes: new Date().getTimezoneOffset(), brand_voice: brandVoice()||undefined, })}); const when=(r.queued||[]).map(q=>new Date(q.run_at).toLocaleDateString([], {weekday:'short'})).join(', '); msg('#weekMsg','\u2705 '+(r.queued||[]).length+' posts queued'+(when?' ('+when+')':'')+((r.targets||[]).filter(t=>!(r.held_networks||[]).includes(t.platform)).length?' to '+(r.targets||[]).filter(t=>!(r.held_networks||[]).includes(t.platform)).map(t=>t.label).join(', '):'')+'. Check them in Queue — you can edit or cancel any of them.',true); toast((r.held||[]).length ? 'Your week is written — '+(r.queued||[]).length+' queued, '+r.held.length+' held' : 'Your week is queued','ok'); if((r.failed||[]).length) msg('#weekMsg', $('#weekMsg').textContent+' \u2014 '+r.failed.length+' could not be written; try again in a moment.', true); weekHeldBox($('#weekMsg'), r); loadQueue(); refresh(); }catch(e){ msg('#weekMsg',e.message,false); toast(e.message,'bad'); } $('#doWeek').disabled=false; }); // A week plan whose later days were held (C2: the queue; or the month's posts), or that goes to fewer networks // than it was written for (C1): the days are written and saved; say which wait, and how to release them. function weekHeldBox(host, r){ if(!host) return; const held=r.held||[], nets=r.held_networks||[]; if(!held.length && !nets.length) return; const byQueue=held.filter(h=>h.held_for==='queue_limit').length; const days=held.map(h=>new Date(h.run_at).toLocaleDateString([], {weekday:'long'})); const parts=[]; if(held.length) parts.push(''+esc(andList(days))+' '+(held.length===1?'is':'are')+' written and held. ' +(byQueue ? 'Free keeps '+esc((r.limit||{}).scheduled_queue||planLim().queue||3)+' scheduled posts at a time; ' : 'This month’s free posts are used or queued; ') +(held.length===1?'it is':'they are')+' scheduled on '+(held.length===1?'its':'their')+' own day as soon as you unlock '+(held.length===1?'it':'them')+'.'); if(nets.length) parts.push((held.length?'':'Each day goes out to '+esc(andList((r.targets||[]).filter(t=>!nets.includes(t.platform)).map(t=>cap(t.platform))))+'. ') +esc(andList(nets.map(cap)))+' '+(nets.length===1?'is':'are')+' written for every day and '+(nets.length===1?'waits':'wait')+' behind the lock (Free: '+(r.networks_per_post||planLim().nets)+' networks per post).'); const box=document.createElement('div'); box.className='heldbox'; box.innerHTML=LOCK_IC+'

    '+parts.join(' ')+'

    '+lockBtn(held.length?(byQueue?'queue_limit':'limit_reached'):'networks_per_post')+'
    '; host.appendChild(box); wireLocks(box); lockSeen(held.length?(byQueue?'queue_limit':'week_over_limit'):'networks_per_post', 'week'); } /* ---- Scheduling: a queue is what turns this into a place your content lives ---- */ function composerBody(){ const platforms=(GEN_PLATFORMS.length?GEN_PLATFORMS:selectedPlatforms()); const media=$('#postVideo').value.trim(); let photo_url,video_url; const metaFmt=($('#metaFormat')&&$('#metaFormat').value)||'feed'; ({photo_url,video_url} = mediaFields(media, platforms, metaFmt)); const drafts=document.querySelectorAll('#drafts .card[data-plat]').length ? collectDrafts() : null; const per_platform = drafts || (function(){ const t=$('#genPrompt').value, o={}; platforms.forEach(p=>o[p]={text:t}); return o; })(); const body={platforms,photo_url,video_url,per_platform,options:{},brand_id:composeBrandId()}; const mlist=mediaListNow(); if(mlist.length) body.media=mlist; if(platforms.includes('tiktok')){ const priv=selectedPrivacy(); if(priv) body.options.tiktok={ privacy_level:priv, disable_comment:!($('#tt_comment')&&$('#tt_comment').checked), disable_duet:!($('#tt_duet')&&$('#tt_duet').checked), disable_stitch:!($('#tt_stitch')&&$('#tt_stitch').checked), brand_organic_toggle:!!($('#ttDisclose')&&$('#ttDisclose').checked&&$('#ttBrandOrganic')&&$('#ttBrandOrganic').checked), branded_content_toggle:!!($('#ttDisclose')&&$('#ttDisclose').checked&&$('#ttBranded')&&$('#ttBranded').checked), ...ttMethodOpt() }; } if(metaFmt!=='feed'){ if(platforms.includes('instagram')) body.options.instagram=Object.assign({},body.options.instagram,{post_type:metaFmt}); if(platforms.includes('facebook')) body.options.facebook =Object.assign({},body.options.facebook, {post_type:metaFmt}); } xReplyToOptions(body); return {body, platforms, media, metaFmt}; } (function initSchedUi(){ const tz=$('#schedTz'); if(tz){ try{ tz.textContent='('+Intl.DateTimeFormat().resolvedOptions().timeZone+', your local time)'; }catch(e){} } const at=$('#schedAt'); if(at && tz) at.title='Your local time '+tz.textContent; if(at){ const d=new Date(Date.now()+3600*1000); d.setSeconds(0,0); at.value=new Date(d.getTime()-d.getTimezoneOffset()*60000).toISOString().slice(0,16); } })(); $('#doSchedule') && ($('#doSchedule').onclick = ()=>schedulePost(false)); $('#doSlot') && ($('#doSlot').onclick = ()=>schedulePost(true)); function browserTz(){ try{ return Intl.DateTimeFormat().resolvedOptions().timeZone||undefined; }catch(e){ return undefined; } } async function schedulePost(nextSlot){ const when=nextSlot ? null : $('#schedAt').value; if(!nextSlot && !when) return msg('#publishMsg','Pick a date and time first.',false); const c=composerBody(); if(!c.platforms.length) return msg('#publishMsg','Pick at least one account first.',false); const empty=c.platforms.filter(p=>!(c.body.per_platform[p]&&(c.body.per_platform[p].text||'').trim())); if(empty.length===c.platforms.length) return msg('#publishMsg','Write your text (or generate drafts) before scheduling.',false); // C1: the networks behind the lock ride along with the post (they go out if the plan changes before it // runs), unless their media would be refused \u2014 then they are left out instead of blocking the others. const sp=splitNets(c.platforms); const lockedBad=mediaBlockers(sp.locked, c.media, c.metaFmt).map(b=>b.platform); if(lockedBad.length){ c.platforms=c.platforms.filter(p=>!lockedBad.includes(p)); c.body.platforms=c.platforms; } const blocked=mediaBlockers(sp.send, c.media, c.metaFmt); if(blocked.length) return msg('#publishMsg','\u26a0\ufe0f '+blocked.map(b=>b.reason).join(' '),false); if(c.platforms.includes('x')){ const xp=xThreadProblem(); if(xp){ blockPublish('#publishMsg', 'x_thread_over', xp.text, xp.el); return; } } { const cp=chainProblemClient(); if(cp){ blockPublish('#publishMsg', 'thread_over', cp.text, cp.el); return; } } if(!confirmTargets(sp.send, nextSlot ? 'Schedule in the next free slot' : 'Schedule for '+new Date(when).toLocaleString())) return msg('#publishMsg','Nothing queued.',false); $('#doSchedule').disabled=true; if($('#doSlot')) $('#doSlot').disabled=true; try{ const payload=Object.assign({ run_at: nextSlot ? 'next_slot' : new Date(when).toISOString(), label:($('#genPrompt').value||'').trim().slice(0,80) }, nextSlot ? { timezone: browserTz() } : {}, c.body); const r=await api('/api/schedule',{method:'POST',body:JSON.stringify(payload)}); const goes=(r.targets||[]).filter(t=>!(r.held_networks||[]).includes(t.platform)).map(t=>t.label); if(r.held){ // C2: the queue is full \u2014 the post is saved as held, written and visible in Queue, never lost. msg('#publishMsg','Saved as held — not scheduled yet.',true); const box=document.createElement('div'); box.className='heldbox'; box.innerHTML=LOCK_IC+'

    Your queue is full: Free keeps '+esc((r.scheduled_queue||{}).limit)+' scheduled posts at a time. This one is written and saved in Queue as held, and is scheduled for '+esc(new Date(r.scheduled.run_at).toLocaleString())+' as soon as you unlock it.

    '+lockBtn('queue_limit')+(r.coach_moment&&window.PWCoach?PWCoach.momentHtml(r.coach_moment,'schedule'):'')+'
    '; $('#publishMsg').appendChild(box); wireLocks(box); lockSeen('queue_limit','schedule'); toast('Saved as held \u2014 your queue is full','bad'); } else { msg('#publishMsg','\u2705 Queued for '+new Date(r.scheduled.run_at).toLocaleString()+(r.slot?' (the next free slot)':'')+(goes.length?' to '+goes.join(', '):'')+' \u2014 it will go out on its own.' +((r.held_networks||[]).length?' '+andList(r.held_networks.map(cap))+' '+(r.held_networks.length===1?'is':'are')+' written and waits behind the lock (Free: '+r.networks_per_post+' networks per post).':'') +(r.x_credits&&r.x_credits.sentence?' '+r.x_credits.sentence+' Charged when it goes out.':''),true); toast('Added to your queue','ok'); } loadQueue(); refresh().catch(()=>{}); }catch(e){ msg('#publishMsg',e.message,false); toast(e.message,'bad'); } $('#doSchedule').disabled=false; if($('#doSlot')) $('#doSlot').disabled=false; } // Queue slots of the active brand (GET/PUT /api/brands/:id/slots). One set of times per network, on the days // ticked; slots saved through the API with different times per day are shown as such and kept unless edited. const SLOT_DAYS=['mon','tue','wed','thu','fri','sat','sun']; let SLOTS=null; async function loadSlots(){ const rows=$('#slotsRows'); if(!rows) return; const id=activeBrand(); if(!id){ rows.innerHTML='

    Connect a network first.

    '; return; } try{ SLOTS=await api('/api/brands/'+id+'/slots?timezone='+encodeURIComponent(browserTz()||'')); $('#slotsBrand').textContent=BRANDS.length>1?'Brand: '+brandNameOf(id):''; $('#slotsTz').value=SLOTS.timezone; const dl=$('#slotsTzList'); if(dl && !dl.childElementCount){ try{ dl.innerHTML=Intl.supportedValuesOf('timeZone').map(z=>'