Fan funding: the artist chooses the campaign goal in fans
Deploy test site / deploy (push) Successful in 1m9s

The goal is no longer fixed at the larger of the plant minimum and the
break-even; that is now the floor, and the artist sets any goal above it.

- make-a-record.html: "Goal: how many fans you need" with number input,
  slider and chips (Lowest, 150, 200, 300, 500, 1,000); chips below the
  floor or above the edition cap stay visible but disabled with the
  reason; the goal also shows as the pre-order amount (fans x fan price);
  typing below the floor snaps back with a note; the campaign, the
  outcome and the stage run against the chosen goal; the tour shows the
  goal being raised to 150
- artist-order.html: goal field in the campaign card, same floor rule
- artist-platform.md: the floor rule and that a higher goal is all or
  nothing as well

Defaults unchanged (goal 100 = the plant minimum at 1,200 THB, break-even
41, 548 THB kept a copy).
This commit is contained in:
grabowski committed 2026-10-15 16:40:49 +07:00
1 parent 4b20ca4f48
commit 6a62505aa6
3 files changed
+45 -14

No files matched your search

+11 -5
View File
@@ -289,6 +289,7 @@ document.documentElement.setAttribute("data-theme",t);})();
<h2>Campaign</h2> <h2>Campaign</h2>
<div class="row"><span class="lbl">Fan price per copy</span><span><input type="number" id="fanPrice" min="300" max="5000" step="50"> THB</span></div> <div class="row"><span class="lbl">Fan price per copy</span><span><input type="number" id="fanPrice" min="300" max="5000" step="50"> THB</span></div>
<div class="row"><span class="lbl">Runs for</span><span style="display:flex;gap:8px;align-items:center"><input type="range" id="days" min="14" max="45" step="1"> <b id="daysV"></b> days</span></div> <div class="row"><span class="lbl">Runs for</span><span style="display:flex;gap:8px;align-items:center"><input type="range" id="days" min="14" max="45" step="1"> <b id="daysV"></b> days</span></div>
<div class="row"><span class="lbl">Goal</span><span><input type="number" id="goalF" min="100" max="5000" step="10"> fans <small id="goalHint"></small></span></div>
<div class="row"><span class="lbl">Limited edition</span><span><input type="number" id="cap" min="0" max="5000" step="50"> <small>copies at most, 0 = no cap</small></span></div> <div class="row"><span class="lbl">Limited edition</span><span><input type="number" id="cap" min="0" max="5000" step="50"> <small>copies at most, 0 = no cap</small></span></div>
<div class="row"><span class="lbl">Copies for yourself</span><input type="number" id="acop" min="0" max="200" step="5"></div> <div class="row"><span class="lbl">Copies for yourself</span><input type="number" id="acop" min="0" max="200" step="5"></div>
<p><label><input type="checkbox" id="vat"> Take 7 % VAT out of the fan price (CNX as the seller)</label></p> <p><label><input type="checkbox" id="vat"> Take 7 % VAT out of the fan price (CNX as the seller)</label></p>
@@ -401,7 +402,7 @@ const S={
qty:300,inner:"paper",jacket:"printed",shrink:true,insert:false,sticker:false,numbered:false, 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", 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}, 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, mode:null,launched:false,launchDate:null,scen:"make",simDay:0,outcome:null,outcomeAct:null,finalFans:0,prod:null,
visited:{1:true} visited:{1:true}
}; };
@@ -464,9 +465,10 @@ function campEcon(){
const cAt=n=>quote(Math.max(1,n)).cSum; const cAt=n=>quote(Math.max(1,n)).cSum;
let be=null; let be=null;
for(let n=1;n<=5000;n++){if(n*net1-tSum-(n+ac)*cAt(n+ac)>=0){be=n;break;}} 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};}; 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){ function finalPledges(E){
if(!E.goal)return 0;const g=E.goal; if(!E.goal)return 0;const g=E.goal;
@@ -725,12 +727,14 @@ function renderCamp(){
else{ 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 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); const rows=ns.map(E.at);const g=E.at(E.goal);
el.innerHTML=`<div class="kpis"><div><small>Your break-even</small><b>${E.be} fans</b></div><div><small>Plant minimum run</small><b>${E.min} copies</b></div><div><small>Campaign goal</small><b>${E.goal} fans</b></div><div><small>You keep at the goal</small><b>${money(g.per)}</b><small>a copy</small></div></div> el.innerHTML=`<div class="kpis"><div><small>Your break-even</small><b>${E.be} fans</b></div><div><small>Plant minimum run</small><b>${E.min} copies</b></div><div><small>Campaign goal</small><b>${E.goal} fans</b><small>${money(E.goal*S.fanPrice)} in pre-orders</small></div><div><small>You keep at the goal</small><b>${money(g.per)}</b><small>a copy</small></div></div>
${S.cap&&S.cap<E.goal?`<div class="note bad">The edition is capped at ${S.cap}, below the goal of ${E.goal}: this campaign cannot succeed.</div>`:""} ${S.cap&&S.cap<E.goal?`<div class="note bad">The edition is capped at ${S.cap}, below the goal of ${E.goal}: this campaign cannot succeed.</div>`:""}
<div class="tscroll"><table><tbody><tr class="hd"><th>Fans</th><th class="num">Pressed</th><th class="num">Fan money after fees${S.vat?" and VAT":""}</th><th class="num">Per title</th><th class="num">Pressing and packaging</th><th class="num">You keep</th><th class="num">Per fan copy</th></tr> <div class="tscroll"><table><tbody><tr class="hd"><th>Fans</th><th class="num">Pressed</th><th class="num">Fan money after fees${S.vat?" and VAT":""}</th><th class="num">Per title</th><th class="num">Pressing and packaging</th><th class="num">You keep</th><th class="num">Per fan copy</th></tr>
${rows.map(r=>`<tr><td>${fmtInt(r.n)}${r.n===E.goal?" <small>goal</small>":""}</td><td class="num">${fmtInt(r.run)}</td><td class="num">${money(r.net)}</td><td class="num">${money(-E.tSum)}</td><td class="num">${money(-r.run*r.c)}</td><td class="num"><b>${money(r.keep)}</b></td><td class="num">${money(r.per)}</td></tr>`).join("")}</tbody></table></div> ${rows.map(r=>`<tr><td>${fmtInt(r.n)}${r.n===E.goal?" <small>goal</small>":""}</td><td class="num">${fmtInt(r.run)}</td><td class="num">${money(r.net)}</td><td class="num">${money(-E.tSum)}</td><td class="num">${money(-r.run*r.c)}</td><td class="num"><b>${money(r.keep)}</b></td><td class="num">${money(r.per)}</td></tr>`).join("")}</tbody></table></div>
<p class="hint">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."}</p>`; <p class="hint">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."}</p>`;
} }
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<E.goal); const can=S.gate1&&E.goal!=null&&!(S.cap&&S.cap<E.goal);
$("#bLaunch").disabled=!can; $("#bLaunch").disabled=!can;
msg=!S.gate1?"Approve the master plan in step 2 first.":!can?"Fix the campaign settings above first.":"Simulated: no page goes live and nobody is charged."; msg=!S.gate1?"Approve the master plan in step 2 first.":!can?"Fix the campaign settings above first.":"Simulated: no page goes live and nobody is charged.";
@@ -912,6 +916,7 @@ document.addEventListener("input",e=>{
else if(id==="artist"||id==="title"){S[id]=t.value;renderAll();} 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(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==="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==="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==="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();} 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.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.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==="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();} else if(t.id==="vat"){S.vat=t.checked;if(S.mode==="campaign")resetCampaign();renderAll();}
}); });
+10 -3
View File
@@ -245,7 +245,8 @@ Nothing goes in front of fans that the plant cannot make:
### The goal ### 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 - **the plant's minimum campaign run**, set by setup and stamper
economics on the semi-automatic line (**100 copies** as a planning 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 artist's break-even**: the copies at which the fan price covers
the per-title costs, the per-copy costs and the payment fees. 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, 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 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 38,000 / (1,200 − 42 − 232) ≈ **41 copies**, so the lowest goal is the
minimum. At 100 copies the artist keeps about **546 THB a copy**, at 300 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 about **799 THB**, because the per-title cost spreads. The calculator
shows the artist this before launch, for any price they type in. Shipping shows the artist this before launch, for any price they type in. Shipping
is charged to the fan separately at checkout, at cost. is charged to the fan separately at checkout, at cost.
+24 -6
View File
@@ -220,6 +220,8 @@ document.documentElement.setAttribute("data-theme",t);})();
.qchips { display: flex; gap: 6px; flex-wrap: wrap; } .qchips { display: flex; gap: 6px; flex-wrap: wrap; }
.qchips .chip { padding: 4px 12px; font-size: 13px; } .qchips .chip { padding: 4px 12px; font-size: 13px; }
.qchips .chip.on::before { content: ""; } .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 { 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)); } .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)); } } @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);})();
<div class="fgrid"> <div class="fgrid">
<div class="field"><span class="fl">Fan price per copy</span><div class="inline"><input type="number" id="fanPrice" min="300" max="5000" step="50" aria-label="Fan price"><span>THB</span></div><input type="range" id="fanR" min="500" max="3000" step="50" aria-label="Fan price"></div> <div class="field"><span class="fl">Fan price per copy</span><div class="inline"><input type="number" id="fanPrice" min="300" max="5000" step="50" aria-label="Fan price"><span>THB</span></div><input type="range" id="fanR" min="500" max="3000" step="50" aria-label="Fan price"></div>
<div class="field"><span class="fl">Campaign length</span><div class="inline"><b id="daysV"></b><span>days</span></div><input type="range" id="days" min="14" max="45" step="1" aria-label="Days"></div> <div class="field"><span class="fl">Campaign length</span><div class="inline"><b id="daysV"></b><span>days</span></div><input type="range" id="days" min="14" max="45" step="1" aria-label="Days"></div>
<div class="field" style="grid-column:1/-1"><span class="fl">Goal: how many fans you need</span><div class="inline"><input type="number" id="goalF" min="100" max="5000" step="10" aria-label="Goal in fans"><span>fans</span><span class="mute" id="goalAmt"></span></div><input type="range" id="goalR" min="100" max="1000" step="10" aria-label="Goal in fans"><div class="qchips" id="goalChips"></div><small class="mute" id="goalHint"></small></div>
<div class="field"><span class="fl">Limited edition</span><div class="inline"><input type="number" id="cap" min="0" max="5000" step="50" aria-label="Edition cap"><small class="mute">copies at most, 0 = no limit</small></div></div> <div class="field"><span class="fl">Limited edition</span><div class="inline"><input type="number" id="cap" min="0" max="5000" step="50" aria-label="Edition cap"><small class="mute">copies at most, 0 = no limit</small></div></div>
<div class="field"><span class="fl">Copies for yourself</span><div class="inline"><input type="number" id="acop" min="0" max="200" step="5" aria-label="Copies for yourself"><small class="mute">pressed with the run</small></div></div> <div class="field"><span class="fl">Copies for yourself</span><div class="inline"><input type="number" id="acop" min="0" max="200" step="5" aria-label="Copies for yourself"><small class="mute">pressed with the run</small></div></div>
</div> </div>
@@ -696,7 +699,7 @@ const S={
qty:300,inner:"paper",jacket:"printed",shrink:true,insert:false,sticker:false,numbered:false, 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", 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}, 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, mode:null,launched:false,launchDate:null,scen:"make",simDay:0,outcome:null,outcomeAct:null,finalFans:0,prod:null,
visited:{1:true} visited:{1:true}
}; };
@@ -764,9 +767,10 @@ function campEcon(){
const cAt=n=>quote(Math.max(1,n)).cSum; const cAt=n=>quote(Math.max(1,n)).cSum;
let be=null; let be=null;
for(let n=1;n<=5000;n++){if(n*net1-tSum-(n+ac)*cAt(n+ac)>=0){be=n;break;}} 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};}; 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){ function finalPledges(E){
if(!E.goal)return 0;const g=E.goal; if(!E.goal)return 0;const g=E.goal;
@@ -1052,12 +1056,20 @@ function renderFund(){
if(E.goal==null)h=`<div class="note bad">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.</div>`; if(E.goal==null)h=`<div class="note bad">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.</div>`;
else{ 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); 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=`<div class="kpis k4"><div class="kpi hl"><small>Campaign goal</small><b>${E.goal} fans</b></div><div class="kpi"><small>Your break-even</small><b>${E.be} fans</b></div><div class="kpi"><small>You keep at the goal</small><b>${money(g.per)}</b><em>a copy</em></div><div class="kpi"><small>That is</small><b>${money(g.keep)}</b><em>in total</em></div></div> h=`<div class="kpis k4"><div class="kpi hl"><small>Campaign goal</small><b>${E.goal} fans</b><em>${money(E.goal*S.fanPrice)} in pre-orders</em></div><div class="kpi"><small>Your break-even</small><b>${E.be} fans</b></div><div class="kpi"><small>You keep at the goal</small><b>${money(g.per)}</b><em>a copy</em></div><div class="kpi"><small>That is</small><b>${money(g.keep)}</b><em>in total</em></div></div>
${S.cap&&S.cap<E.goal?`<div class="note bad">The edition is capped at ${S.cap}, below the goal of ${E.goal}: this campaign cannot succeed.</div>`:""} ${S.cap&&S.cap<E.goal?`<div class="note bad">The edition is capped at ${S.cap}, below the goal of ${E.goal}: this campaign cannot succeed.</div>`:""}
<div class="panel"><h2>What you keep if more fans join</h2><div class="keep">${rows.map(r=>`<div class="krow${r.n===E.goal?" goal":""}"><span>${fmtInt(r.n)} fans</span><div class="kb"><i style="width:${Math.max(2,r.keep/mx*100).toFixed(1)}%"></i></div><div class="kv"><b>${money(r.keep)}</b><small>${money(r.per)} a copy</small></div></div>`).join("")}</div> <div class="panel"><h2>What you keep if more fans join</h2><div class="keep">${rows.map(r=>`<div class="krow${r.n===E.goal?" goal":""}"><span>${fmtInt(r.n)} fans</span><div class="kb"><i style="width:${Math.max(2,r.keep/mx*100).toFixed(1)}%"></i></div><div class="kv"><b>${money(r.keep)}</b><small>${money(r.per)} a copy</small></div></div>`).join("")}</div>
<p class="hint">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.</p></div>`; <p class="hint">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.</p></div>`;
} }
$("#fundEcon").innerHTML=h; $("#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=`<button type="button" class="chip${E.goal===E.floor?" on":""}" data-g="0" title="The lowest goal possible">Lowest: ${E.floor}</button>`+[150,200,300,500,1000].map(n=>{const lo=n<E.floor,hi=S.cap>0&&n>S.cap,dis=lo||hi;return `<button type="button" class="chip${!dis&&n===E.goal?" on":""}" data-g="${n}"${dis?` disabled title="${lo?"Below "+esc(why):"Above your edition cap of "+S.cap}"`:""}>${fmtInt(n)}</button>`;}).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(); $("#bLaunch").disabled=!canLaunch();
$("#launchMsg").textContent=launchHint()||"Simulated: no page goes live and nobody is charged."; $("#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 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 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 setQty(q){S.qty=Math.min(5000,Math.max(100,Math.round(q)));if(S.mode==="direct")resetCampaign();renderAll();}
function syncInputs(){ function syncInputs(){
$("#artist").value=S.artist;$("#title").value=S.title;$("#qty").value=S.qty;$("#qtyR").value=Math.min(2000,S.qty); $("#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 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 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 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 b=e.target.closest("button");if(!b||b.disabled)return;
const a=b.dataset.act;if(a&&ACT[a]){ACT[a](b);return;} const a=b.dataset.act;if(a&&ACT[a]){ACT[a](b);return;}
const g=b.closest("[data-k]"); 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(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==="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==="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==="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==="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();} 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=>{ document.addEventListener("change",e=>{
const t=e.target; const t=e.target;
if(t.id==="qty"){setQty(Math.round((+t.value||100)/10)*10);t.value=S.qty;} 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(v<E.floor)toast("The goal cannot go below "+E.floor+" fans: "+(E.be>E.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.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("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();} else if(t.classList.contains("tside")){trkOf(t).side=t.value;revokePlan();renderAll();}
@@ -1366,7 +1383,8 @@ async function tour(){
approve(); 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."); 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); 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(); 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."); 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); while(!S.outcome)await w(250);await w(1800);