diff --git a/docs/artist-order.html b/docs/artist-order.html index 523c92a..c487e04 100644 --- a/docs/artist-order.html +++ b/docs/artist-order.html @@ -289,6 +289,7 @@ document.documentElement.setAttribute("data-theme",t);})();

Campaign

Fan price per copy THB
Runs for days
+
Goal fans
Limited edition copies at most, 0 = no cap
Copies for yourself

@@ -401,7 +402,7 @@ const S={ qty:300,inner:"paper",jacket:"printed",shrink:true,insert:false,sticker:false,numbered:false, mastering:true,tps:5,loud:false,artist:"Demo Artist",title:"Night Market Songs", tracks:[],demoTracks:true,rights:false,gate1:false,art:{jacket:null,label:null}, - fanPrice:1200,days:30,cap:0,artistCopies:0,vat:false, + fanPrice:1200,goalFans:0,days:30,cap:0,artistCopies:0,vat:false, mode:null,launched:false,launchDate:null,scen:"make",simDay:0,outcome:null,outcomeAct:null,finalFans:0,prod:null, visited:{1:true} }; @@ -464,9 +465,10 @@ function campEcon(){ const cAt=n=>quote(Math.max(1,n)).cSum; let be=null; for(let n=1;n<=5000;n++){if(n*net1-tSum-(n+ac)*cAt(n+ac)>=0){be=n;break;}} - const min=R.minRun.v,goal=be==null?null:Math.max(min,be); + /* the artist chooses the goal in fans; it can never go below the plant minimum or the break-even (the floor) */ + const min=R.minRun.v,floor=be==null?null:Math.max(min,be),goal=floor==null?null:Math.max(floor,Math.round(S.goalFans||0)); const at=n=>{const run=n+ac,c=cAt(run),net=n*net1,cost=tSum+run*c;return {n,run,c,net,cost,keep:net-cost,per:(net-cost)/n};}; - return {p,fee,vat,net1,tSum,be,min,goal,at,cAt}; + return {p,fee,vat,net1,tSum,be,min,floor,goal,at,cAt}; } function finalPledges(E){ if(!E.goal)return 0;const g=E.goal; @@ -725,12 +727,14 @@ function renderCamp(){ else{ const ns=[...new Set([E.goal,150,200,300,500,1000])].filter(n=>n>=E.goal&&(!S.cap||n<=S.cap)).sort((a,b)=>a-b); const rows=ns.map(E.at);const g=E.at(E.goal); - el.innerHTML=`
Your break-even${E.be} fans
Plant minimum run${E.min} copies
Campaign goal${E.goal} fans
You keep at the goal${money(g.per)}a copy
+ el.innerHTML=`
Your break-even${E.be} fans
Plant minimum run${E.min} copies
Campaign goal${E.goal} fans${money(E.goal*S.fanPrice)} in pre-orders
You keep at the goal${money(g.per)}a copy
${S.cap&&S.capThe edition is capped at ${S.cap}, below the goal of ${E.goal}: this campaign cannot succeed.`:""}
${rows.map(r=>``).join("")}
FansPressedFan money after fees${S.vat?" and VAT":""}Per titlePressing and packagingYou keepPer fan copy
${fmtInt(r.n)}${r.n===E.goal?" goal":""}${fmtInt(r.run)}${money(r.net)}${money(-E.tSum)}${money(-r.run*r.c)}${money(r.keep)}${money(r.per)}
-

The goal is the larger of the plant's minimum run and your break-even. Payment fees ${pctT(E.fee)} of the fan price${S.vat?", VAT 7/107 of it":""}. Over-subscription is good news: the per-title cost spreads over more copies. ${S.vat?"":"VAT is not taken out here, as in the artist platform example; tick the box above to see it."}

`; +

The artist sets the goal; the lowest it can go is the larger of the plant's minimum run and the break-even. A higher goal is all or nothing too. Payment fees ${pctT(E.fee)} of the fan price${S.vat?", VAT 7/107 of it":""}. Over-subscription is good news: the per-title cost spreads over more copies. ${S.vat?"":"VAT is not taken out here, as in the artist platform example; tick the box above to see it."}

`; } + const gi=$("#goalF");gi.disabled=E.floor==null; + if(E.floor!=null){gi.min=E.floor;if(document.activeElement!==gi)gi.value=E.goal;$("#goalHint").textContent=E.goal===E.floor?"the lowest possible: "+(E.be>E.min?"your break-even":"the plant minimum"):"lowest possible "+E.floor;}else $("#goalHint").textContent="raise the fan price first"; const can=S.gate1&&E.goal!=null&&!(S.cap&&S.cap{ else if(id==="artist"||id==="title"){S[id]=t.value;renderAll();} else if(t.classList.contains("tt")){trkOf(t).title=t.value;S.demoTracks=false;if(S.mode==="campaign")updateS5();} else if(id==="fanPrice"){const v=+t.value;if(v>=100&&v<=20000){S.fanPrice=v;if(S.mode==="campaign")resetCampaign();renderAll();}} + else if(id==="goalF"){const v=Math.round(+t.value),E=campEcon();if(E.floor!=null&&v>=E.floor&&v<=5000){S.goalFans=v===E.floor?0:v;t.classList.remove("err");if(S.mode==="campaign")resetCampaign();renderAll();}else t.classList.add("err");} else if(id==="days"){S.days=+t.value;if(S.mode==="campaign")resetCampaign();renderAll();} else if(id==="cap"){const v=Math.max(0,Math.round(+t.value)||0);S.cap=v;if(S.mode==="campaign")resetCampaign();renderAll();} else if(id==="acop"){const v=Math.max(0,Math.round(+t.value)||0);S.artistCopies=v;if(S.mode==="campaign")resetCampaign();renderAll();} @@ -925,6 +930,7 @@ document.addEventListener("change",e=>{ else if(t.classList.contains("tside")){trkOf(t).side=t.value;revokePlan();renderAll();} else if(t.dataset.b){S[t.dataset.b]=t.checked;if(S.mode)resetCampaign();renderAll();} else if(t.id==="rights"){S.rights=t.checked;if(!t.checked)revokePlan();renderAll();} + else if(t.id==="goalF"){const E=campEcon();if(E.floor==null)return;const v=Math.min(5000,Math.max(E.floor,Math.round(+t.value)||0));S.goalFans=v===E.floor?0:v;t.value=v;t.classList.remove("err");if(S.mode==="campaign")resetCampaign();renderAll();} else if(t.id==="vat"){S.vat=t.checked;if(S.mode==="campaign")resetCampaign();renderAll();} }); diff --git a/docs/artist-platform.md b/docs/artist-platform.md index e63756a..6bcf607 100644 --- a/docs/artist-platform.md +++ b/docs/artist-platform.md @@ -245,7 +245,8 @@ Nothing goes in front of fans that the plant cannot make: ### The goal -The goal is the larger of two numbers: +The artist sets the goal in fans. The lowest it can go is the larger of +two numbers: - **the plant's minimum campaign run**, set by setup and stamper economics on the semi-automatic line (**100 copies** as a planning @@ -253,10 +254,16 @@ The goal is the larger of two numbers: - **the artist's break-even**: the copies at which the fan price covers the per-title costs, the per-copy costs and the payment fees. +Above that floor the goal is the artist's choice, for example a bigger +first run or a round number to tell fans. A higher goal is all or nothing +as well: if it is missed, every fan is refunded, even when the floor was +passed. Both demos let the artist set it, with the goal shown in fans and +as the pre-order amount (fans × fan price). + With the illustration above and a fan price of 1,200 THB (an example, not a recommendation) and payment fees of about 3.5%: break-even is -38,000 / (1,200 − 42 − 232) ≈ **41 copies**, so the goal is the 100-copy -minimum. At 100 copies the artist keeps about **546 THB a copy**, at 300 +38,000 / (1,200 − 42 − 232) ≈ **41 copies**, so the lowest goal is the +100-copy minimum. At 100 copies the artist keeps about **546 THB a copy**, at 300 about **799 THB**, because the per-title cost spreads. The calculator shows the artist this before launch, for any price they type in. Shipping is charged to the fan separately at checkout, at cost. diff --git a/docs/make-a-record.html b/docs/make-a-record.html index 4ea9cd7..8f1c753 100644 --- a/docs/make-a-record.html +++ b/docs/make-a-record.html @@ -220,6 +220,8 @@ document.documentElement.setAttribute("data-theme",t);})(); .qchips { display: flex; gap: 6px; flex-wrap: wrap; } .qchips .chip { padding: 4px 12px; font-size: 13px; } .qchips .chip.on::before { content: ""; } + .qchips .chip:disabled { opacity: 0.35; cursor: not-allowed; } + #goalChips { margin: 2px 0 4px; } .kpis { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 16px 0 10px; } .kpis.k4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } @media (max-width: 640px) { .kpis, .kpis.k4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -584,6 +586,7 @@ document.documentElement.setAttribute("data-theme",t);})();
Fan price per copy
THB
Campaign length
days
+
Goal: how many fans you need
fans
Limited edition
copies at most, 0 = no limit
Copies for yourself
pressed with the run
@@ -696,7 +699,7 @@ const S={ qty:300,inner:"paper",jacket:"printed",shrink:true,insert:false,sticker:false,numbered:false, mastering:true,tps:5,loud:false,artist:"Mae Ping Collective",title:"Night Market Songs", tracks:[],demoTracks:true,rights:false,gate1:false,art:{jacket:null,label:null}, - fanPrice:1200,days:30,cap:0,artistCopies:0,vat:false, + fanPrice:1200,goalFans:0,days:30,cap:0,artistCopies:0,vat:false, mode:null,launched:false,launchDate:null,scen:"make",simDay:0,outcome:null,outcomeAct:null,finalFans:0,prod:null, visited:{1:true} }; @@ -764,9 +767,10 @@ function campEcon(){ const cAt=n=>quote(Math.max(1,n)).cSum; let be=null; for(let n=1;n<=5000;n++){if(n*net1-tSum-(n+ac)*cAt(n+ac)>=0){be=n;break;}} - const min=R.minRun.v,goal=be==null?null:Math.max(min,be); + /* the artist chooses the goal in fans; it can never go below the plant minimum or the break-even (the floor) */ + const min=R.minRun.v,floor=be==null?null:Math.max(min,be),goal=floor==null?null:Math.max(floor,Math.round(S.goalFans||0)); const at=n=>{const run=n+ac,c=cAt(run),net=n*net1,cost=tSum+run*c;return {n,run,c,net,cost,keep:net-cost,per:(net-cost)/n};}; - return {p,fee,vat,net1,tSum,be,min,goal,at,cAt}; + return {p,fee,vat,net1,tSum,be,min,floor,goal,at,cAt}; } function finalPledges(E){ if(!E.goal)return 0;const g=E.goal; @@ -1052,12 +1056,20 @@ function renderFund(){ if(E.goal==null)h=`
At ${money(E.p)} a copy, what is left after payment fees${S.vat?" and VAT":""} does not cover the cost per copy. Raise the fan price.
`; else{ const g=E.at(E.goal),ns=[...new Set([E.goal,200,300,500,1000])].filter(n=>n>=E.goal&&(!S.cap||n<=S.cap)).sort((a,b)=>a-b),rows=ns.map(E.at),mx=Math.max(...rows.map(r=>r.keep),1); - h=`
Campaign goal${E.goal} fans
Your break-even${E.be} fans
You keep at the goal${money(g.per)}a copy
That is${money(g.keep)}in total
+ h=`
Campaign goal${E.goal} fans${money(E.goal*S.fanPrice)} in pre-orders
Your break-even${E.be} fans
You keep at the goal${money(g.per)}a copy
That is${money(g.keep)}in total
${S.cap&&S.capThe edition is capped at ${S.cap}, below the goal of ${E.goal}: this campaign cannot succeed.`:""}

What you keep if more fans join

${rows.map(r=>`
${fmtInt(r.n)} fans
${money(r.keep)}${money(r.per)} a copy
`).join("")}
-

The goal is the larger of the plant's ${E.min}-copy minimum and your break-even. After payment fees of about ${(R.fee.v*100).toFixed(1)} %${S.vat?" and VAT":""}, the per-title cost and the pressing. Shipping is paid by each fan at cost.

`; +

You choose the goal; it cannot go below the plant's ${E.min}-copy minimum or your break-even, whichever is larger. A higher goal is all or nothing too: miss it and every fan is refunded. What you keep is after payment fees of about ${(R.fee.v*100).toFixed(1)} %${S.vat?" and VAT":""}, the per-title cost and the pressing. Shipping is paid by each fan at cost.

`; } $("#fundEcon").innerHTML=h; + const gi=$("#goalF"),gr=$("#goalR"),none=E.floor==null;gi.disabled=none;gr.disabled=none; + if(none){$("#goalAmt").textContent="";$("#goalHint").textContent="Raise the fan price first: below cost there is no goal to set.";$("#goalChips").innerHTML="";} + else{gi.min=E.floor;if(document.activeElement!==gi){gi.value=E.goal;gi.classList.remove("err");} + const top=Math.max(E.floor+50,S.cap&&S.cap>=E.floor?S.cap:1000,E.goal);gr.min=E.floor;gr.max=top;gr.value=E.goal; + $("#goalAmt").textContent="= "+money(E.goal*S.fanPrice)+" in pre-orders"; + const why=E.be>E.min?"your break-even of "+E.be+" fans":"the plant's "+E.min+"-copy minimum"; + $("#goalChips").innerHTML=``+[150,200,300,500,1000].map(n=>{const lo=n0&&n>S.cap,dis=lo||hi;return ``;}).join(""); + $("#goalHint").textContent=E.goal===E.floor?"The lowest goal possible: "+why+".":"Your choice, above the lowest possible goal of "+E.floor+" fans ("+why+").";} $("#bLaunch").disabled=!canLaunch(); $("#launchMsg").textContent=launchHint()||"Simulated: no page goes live and nobody is charged."; } @@ -1235,6 +1247,7 @@ function go(s){ } function approve(){if(!canApprove())return;S.gate1=true;go(3);} function launch(){if(!canLaunch()){if(!S.gate1)go(2);return;}resetCampaign();S.mode="campaign";S.launched=true;S.launchDate=new Date(TODAY);go(5);startSim();renderAll();} +function setGoal(n){const E=campEcon();S.goalFans=E.floor!=null&&n<=E.floor?0:Math.max(0,Math.round(n));if(S.mode==="campaign")resetCampaign();renderAll();} function setQty(q){S.qty=Math.min(5000,Math.max(100,Math.round(q)));if(S.mode==="direct")resetCampaign();renderAll();} function syncInputs(){ $("#artist").value=S.artist;$("#title").value=S.title;$("#qty").value=S.qty;$("#qtyR").value=Math.min(2000,S.qty); @@ -1286,6 +1299,7 @@ document.addEventListener("click",e=>{ const sw=e.target.closest(".swatch");if(sw){if(sw.disabled)return;const c=sw.closest("[data-c]").dataset.c;S[c]=sw.dataset.col;renderAll();return;} const chip=e.target.closest(".chip[data-b]");if(chip){S[chip.dataset.b]=!S[chip.dataset.b];if(S.mode)resetCampaign();renderAll();return;} const qc=e.target.closest(".chip[data-q]");if(qc){setQty(+qc.dataset.q);return;} + const gc=e.target.closest(".chip[data-g]");if(gc){if(gc.disabled)return;setGoal(+gc.dataset.g);return;} const b=e.target.closest("button");if(!b||b.disabled)return; const a=b.dataset.act;if(a&&ACT[a]){ACT[a](b);return;} const g=b.closest("[data-k]"); @@ -1300,6 +1314,8 @@ document.addEventListener("input",e=>{ else if(t.classList.contains("tt")){trkOf(t).title=t.value;S.demoTracks=false;renderSides();} else if(id==="fanPrice"){const v=+t.value;if(v>=100&&v<=20000){S.fanPrice=v;if(S.mode==="campaign")resetCampaign();renderAll();}} else if(id==="fanR"){S.fanPrice=+t.value;if(S.mode==="campaign")resetCampaign();renderAll();} + else if(id==="goalR")setGoal(+t.value); + else if(id==="goalF"){const v=Math.round(+t.value),E=campEcon();if(E.floor!=null&&v>=E.floor&&v<=5000){t.classList.remove("err");setGoal(v);}else t.classList.add("err");} else if(id==="days"){S.days=+t.value;if(S.mode==="campaign")resetCampaign();renderAll();} else if(id==="cap"){S.cap=Math.max(0,Math.round(+t.value)||0);if(S.mode==="campaign")resetCampaign();renderAll();} else if(id==="acop"){S.artistCopies=Math.max(0,Math.round(+t.value)||0);if(S.mode==="campaign")resetCampaign();renderAll();} @@ -1308,6 +1324,7 @@ document.addEventListener("input",e=>{ document.addEventListener("change",e=>{ const t=e.target; if(t.id==="qty"){setQty(Math.round((+t.value||100)/10)*10);t.value=S.qty;} + else if(t.id==="goalF"){const E=campEcon();if(E.floor==null)return;const v=Math.round(+t.value)||0;if(vE.min?"your break-even.":"the plant's "+E.min+"-copy minimum."));setGoal(Math.min(5000,Math.max(E.floor,v)));t.classList.remove("err");t.value=campEcon().goal;} else if(t.id==="fanPrice"){S.fanPrice=Math.min(20000,Math.max(100,Math.round(+t.value||1200)));t.value=S.fanPrice;renderAll();} else if(t.classList.contains("tdur")){const v=parseDur(t.value);if(v==null||v<=0){t.classList.add("err");return;}t.classList.remove("err");trkOf(t).sec=v;t.value=mmss(v);S.demoTracks=false;revokePlan();renderAll();} else if(t.classList.contains("tside")){trkOf(t).side=t.value;revokePlan();renderAll();} @@ -1366,7 +1383,8 @@ async function tour(){ approve(); cap(4,"An itemised price, every line visible. The price per copy falls fast with the run, because cutting and plating are in-house."); await w(900);for(const q of [100,150,200,300,500,700,1000,700,500,300]){setQty(q);await w(280);}await w(2000); - go(4);cap(5,"No money up front: fans pre-order instead. The goal is the plant's minimum run or the artist's break-even, whichever is larger.");await w(4400); + go(4);cap(5,"No money up front: fans pre-order instead. The artist picks the goal in fans, never below the plant's minimum run or their break-even.");await w(3200); + for(const g of [110,120,130,140,150]){setGoal(g);await w(220);}await w(2200); S.scen="sell";launch(); cap(6,"A 30-day campaign, simulated. Fan money sits in a separate account until the goal is met; if it is missed, everyone is refunded."); while(!S.outcome)await w(250);await w(1800);