Order tool: fan funding; deck: light and dark, 60/40 shareholding
Deploy test site / deploy (push) Successful in 1m2s
Deploy test site / deploy (push) Successful in 1m2s
User, 20 Oct 2026: can the deck also be made in a light and dark mode; the new order tool should also offer fan funding, so the platform screenshots match and use the same calculator; the main investor holds at least 60 %, so 40 % is for sale. Order tool (docs/order.html, /demos/order/): - New step 15 "How to pay": pay the plant directly (deposit) or let fans fund it; step 16 becomes "Review & launch" with the campaign simulation and its three outcomes. - Campaign maths ported from make-a-record (fan price, length, cap, own copies, VAT; fans needed = max(100, break-even); keep at the goal and with more fans; price hint); R gains fee (3.5 %), VAT and campaign length with sources; no price changed. - Quote card shows fan price, fans needed and the artist's keep on the fan route; route and campaign in the #q= hash; deep links #s=<step>&p=<preset>&route=fans. - Parity with make-a-record: 206/206 checks over 40 configurations; default quotes unchanged (37,836 / 217 / 102,936 THB; partner mastering 43,676). Investor deck (docs/pitch/): - Light and dark theme like the other site pages (cnx-theme, T key and button), presenter and overview follow; charts from CSS variables; text pairs at WCAG AA in both themes; tool screenshots in both themes. - Two PDFs: dark and light (print-friendly), each ~1.5 MB. - Platform slide: both pictures from the order tool (Splatter 12" order step and its fan-funding view: 100 fans at 1,200 THB, break-even 46, ~480 THB a copy kept). - BOI and financing slide: shareholding bar, lead investor at least 60 %, at most 40 % for co-investors (~32M / ~22M THB at the ~54M equity floor, arithmetic only); the ask offers up to 40 %. Docs: artist-platform.md (fan funding in the order tool), README and landing page entries.
This commit is contained in:
1 parent
c8307f9d5e
commit
744a56d22b
16 files changed
+646
-174
No files matched your search
+381
-54
@@ -60,12 +60,12 @@ document.documentElement.setAttribute("data-theme",t);})();
|
||||
.app.wide { grid-template-columns: 250px minmax(0, 1fr); }
|
||||
.app.wide .stage { display: none; }
|
||||
.app.wide .px { visibility: hidden; }
|
||||
.rail { overflow-y: auto; padding: 10px 10px 16px; background: var(--panel); border-right: 1px solid var(--stroke); display: flex; flex-direction: column; gap: 3px; }
|
||||
.ri { display: flex; align-items: center; gap: 9px; width: 100%; background: transparent; border: 1px solid transparent; border-radius: 9px; padding: 7px 8px; color: var(--mute); text-align: left; cursor: pointer; min-height: 46px; flex: none; }
|
||||
.rail { overflow-y: auto; padding: 10px 10px 12px; background: var(--panel); border-right: 1px solid var(--stroke); display: flex; flex-direction: column; gap: 2px; }
|
||||
.ri { display: flex; align-items: center; gap: 9px; width: 100%; background: transparent; border: 1px solid transparent; border-radius: 9px; padding: 5px 8px; color: var(--mute); text-align: left; cursor: pointer; min-height: 42px; flex: none; }
|
||||
.ri:hover { background: var(--panel2); color: var(--fg); }
|
||||
.ri .rn { flex: none; display: inline-grid; place-items: center; width: 23px; height: 23px; border-radius: 50%; background: var(--line); color: var(--mute); font-size: 12px; font-weight: 700; }
|
||||
.ri .ric { flex: none; width: 18px; height: 18px; color: var(--mute); }
|
||||
.ri .rt { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
|
||||
.ri .rt { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.ri .rt b { font-weight: 500; font-size: 14px; }
|
||||
.ri .rt small { font-size: 11.5px; color: var(--accent); opacity: 0.55; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ri .rch { flex: none; color: var(--mute); opacity: 0.6; font-size: 15px; }
|
||||
@@ -297,6 +297,7 @@ document.documentElement.setAttribute("data-theme",t);})();
|
||||
.sumcard { border: 1.5px solid var(--stroke); border-radius: 14px; padding: 16px 18px; background: var(--panel2); }
|
||||
.sumhd { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.sumhd h3 { margin: 0; font-size: 22px; font-weight: 500; letter-spacing: 0.01em; }
|
||||
.sumhd > div { flex: 1; min-width: 180px; }
|
||||
.chips { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
|
||||
.chips span { background: var(--panel); border: 1px solid var(--stroke); border-radius: 5px; padding: 1px 8px; font-size: 12px; color: var(--mute); }
|
||||
.rvgrid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||
@@ -316,6 +317,82 @@ document.documentElement.setAttribute("data-theme",t);})();
|
||||
.conf code { font-size: 12.5px; }
|
||||
details.pl summary { cursor: pointer; font-weight: 600; margin: 16px 0 6px; }
|
||||
.toast { position: fixed; top: 64px; left: 50%; transform: translateX(-50%); background: var(--panel); border: 1px solid var(--act); border-radius: 8px; padding: 8px 14px; font-size: 13.5px; z-index: 80; max-width: calc(100% - 30px); }
|
||||
/* how to pay: route cards and the fan campaign (same maths as make-a-record) */
|
||||
.tiles.route .tile { min-height: 112px; padding-left: 9px; padding-right: 9px; }
|
||||
.tiles.route .tile .pr { font-weight: 600; }
|
||||
fieldset.camp { border: 0; padding: 0; margin: 2px 0 0; }
|
||||
fieldset.camp:disabled { opacity: 0.5; }
|
||||
fieldset.camp:disabled .chip, fieldset.camp:disabled input { cursor: not-allowed; }
|
||||
input[type=range] { -webkit-appearance: none; appearance: none; height: 6px; border-radius: 3px; background: linear-gradient(to right, var(--act) var(--p, 50%), var(--stroke) var(--p, 50%)); outline: none; cursor: pointer; margin: 8px 0; }
|
||||
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--act); border: 3px solid var(--panel); box-shadow: 0 0 0 1px var(--act), 0 1px 3px rgba(0,0,0,0.3); }
|
||||
input[type=range]::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: var(--act); border: 3px solid var(--panel); box-shadow: 0 0 0 1px var(--act); }
|
||||
input[type=range]:focus-visible { box-shadow: 0 0 0 3px var(--actring); }
|
||||
input[type=range]:disabled { cursor: not-allowed; }
|
||||
.fprow { display: flex; align-items: center; gap: 12px; margin: 4px 0 6px; }
|
||||
.fprow input[type=range] { flex: 1; min-width: 0; }
|
||||
.stepper.fp input { width: 70px; }
|
||||
.chip.t { padding: 3px 11px; font-variant-numeric: tabular-nums; }
|
||||
.chip.on { border-color: var(--act); background: var(--actbg); box-shadow: 0 0 0 2px var(--actring); }
|
||||
.chip:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.need { font-size: 14.5px; margin: 8px 0 0; }
|
||||
.need b { font-size: 16px; background: var(--actbg); border-bottom: 2px solid var(--act); padding: 0 4px; border-radius: 3px; }
|
||||
.bad-t { color: var(--bad); font-weight: 600; }
|
||||
.camprow { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0 14px; margin: 4px 0 0; }
|
||||
.camprow .fld { margin: 6px 0; }
|
||||
.rngrow { display: flex; align-items: center; gap: 10px; min-height: 40px; }
|
||||
.rngrow input[type=range] { flex: 1; min-width: 0; }
|
||||
.daysv { font-weight: 700; color: var(--fg); font-size: 15px; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.limrow { display: flex; align-items: center; gap: 6px 12px; flex-wrap: wrap; margin: 4px 0; }
|
||||
.limrow .chk { margin: 4px 0; }
|
||||
.capf { display: inline-flex; align-items: center; gap: 6px; font-size: 13.5px; color: var(--fg); }
|
||||
.capf input { width: 80px; padding: 4px 7px; }
|
||||
.capf.off { opacity: 0.45; }
|
||||
.kpis { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 12px 0 4px; }
|
||||
.kpis.k3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.kpi { background: var(--panel2); border: 1.5px solid var(--stroke); border-radius: 11px; padding: 8px 11px 9px; min-width: 0; }
|
||||
.kpi small { display: block; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.kpi b { display: block; font-size: 21px; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.kpi em { display: block; font-style: normal; font-size: 11.5px; color: var(--mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.kpi.hl { border-color: var(--act); background: var(--actbg); box-shadow: 0 0 0 3px var(--actring); }
|
||||
.keep { margin: 2px 0 4px; }
|
||||
.krow { display: grid; grid-template-columns: 70px minmax(0, 1fr) 118px; gap: 10px; align-items: center; padding: 4px 0; font-size: 13px; border-bottom: 1px solid var(--line); }
|
||||
.krow:last-child { border-bottom: 0; }
|
||||
.krow > span { color: var(--mute); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.krow .kb { height: 10px; background: var(--line); border-radius: 5px; overflow: hidden; }
|
||||
.krow .kb i { display: block; height: 100%; background: var(--accent); border-radius: 5px; }
|
||||
.krow.goal > span { color: var(--fg); font-weight: 700; }
|
||||
.krow.goal .kb i { background: var(--act); }
|
||||
.krow .kv2 { text-align: right; line-height: 1.2; }
|
||||
.krow .kv2 b { display: block; font-variant-numeric: tabular-nums; }
|
||||
.krow .kv2 small { font-size: 11px; white-space: nowrap; }
|
||||
.safe { display: grid; gap: 9px; margin: 6px 0 4px; }
|
||||
.safe > div { display: flex; gap: 10px; align-items: flex-start; font-size: 13px; line-height: 1.35; }
|
||||
.safe .si { flex: none; width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; background: var(--okbg); color: var(--accent); border: 1px solid var(--line); }
|
||||
.safe b { display: block; font-weight: 600; }
|
||||
.safe small { display: block; font-size: 12.5px; }
|
||||
.launch { display: flex; flex-direction: column; gap: 4px; margin: 14px 0 2px; }
|
||||
.launch .btn { width: 100%; padding: 10px 14px; font-size: 15px; }
|
||||
.launch .hint { margin: 2px 0 0; text-align: center; }
|
||||
/* quote card on the fan route */
|
||||
.qn.info { color: var(--mute); }
|
||||
/* review: the launched campaign */
|
||||
.cgrid { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: 16px; align-items: start; margin-bottom: 14px; }
|
||||
.cgrid .conf { margin-bottom: 0; }
|
||||
.simhd { display: flex; align-items: center; justify-content: space-between; gap: 8px 12px; flex-wrap: wrap; }
|
||||
.simhd h3 { margin: 0; font-size: 18px; }
|
||||
.simhd h3 small { font-weight: 400; }
|
||||
.simrow { display: flex; align-items: center; gap: 10px; margin: 12px 0 6px; }
|
||||
.simrow input[type=range] { flex: 1; min-width: 0; }
|
||||
.simrow .hint { white-space: nowrap; margin: 0; }
|
||||
.bar.big { height: 12px; border-radius: 6px; }
|
||||
.ocard { border: 1.5px solid var(--accent); background: var(--okbg); border-radius: 12px; padding: 12px 14px; margin-top: 12px; }
|
||||
.ocard.warn { border-color: var(--warn); background: var(--warnbg); }
|
||||
.ocard.plain { border-color: var(--stroke); background: var(--panel); }
|
||||
.ocard h3 { margin: 0 0 4px; font-size: 16px; }
|
||||
.ocard p { font-size: 13.5px; margin: 4px 0; }
|
||||
.opts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 10px; margin-top: 8px; }
|
||||
.opt { border: 1px solid var(--stroke); border-radius: 10px; padding: 10px 12px; background: var(--panel); display: flex; flex-direction: column; align-items: flex-start; }
|
||||
.opt p { font-size: 13px; color: var(--mute); margin: 4px 0 8px; flex: 1; }
|
||||
/* phone bottom bar */
|
||||
.pbar { display: none; }
|
||||
/* tablet: compact rail */
|
||||
@@ -330,7 +407,7 @@ document.documentElement.setAttribute("data-theme",t);})();
|
||||
.qt { font-size: 21px; }
|
||||
.sinfo { max-width: calc(100% - 240px); }
|
||||
}
|
||||
@media (max-width: 1100px) and (min-width: 900px) { .rvgrid { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media (max-width: 1100px) and (min-width: 900px) { .rvgrid, .cgrid { grid-template-columns: minmax(0, 1fr); } }
|
||||
/* phone */
|
||||
@media (max-width: 899px) {
|
||||
body { overflow: visible; display: block; height: auto; padding-bottom: 74px; }
|
||||
@@ -363,6 +440,10 @@ document.documentElement.setAttribute("data-theme",t);})();
|
||||
.sumcard { padding: 12px 12px; }
|
||||
.grid2f { grid-template-columns: minmax(0, 1fr); }
|
||||
.tiles.c4 { --cols: 2; }
|
||||
.cgrid, .opts { grid-template-columns: minmax(0, 1fr); }
|
||||
.kpi b { font-size: 18px; }
|
||||
.krow { grid-template-columns: 62px minmax(0, 1fr) 112px; gap: 8px; }
|
||||
.simrow { flex-wrap: wrap; }
|
||||
.pbar { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 40; background: var(--panel); border-top: 1px solid var(--stroke); padding: 8px 10px; gap: 8px; align-items: center; box-shadow: 0 -6px 20px rgba(0,0,0,0.2); }
|
||||
.pbar .pt { flex: 1; min-width: 0; line-height: 1.2; }
|
||||
.pbar .pt b { font-size: 16px; display: block; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
@@ -389,7 +470,7 @@ document.documentElement.setAttribute("data-theme",t);})();
|
||||
<div class="pb" id="pBody"></div>
|
||||
<div class="pf" id="pFoot">
|
||||
<button type="button" class="nextb" data-act="next" id="nextB">Next step →</button>
|
||||
<div class="row2"><span class="foot">Demo page from docs/order.html; prices are indicative and not an offer; the platform's name is not decided yet.</span><button type="button" class="linkb" data-act="go" data-s="15" id="priceLink">Just show me the price →</button></div>
|
||||
<div class="row2"><span class="foot">Demo page from docs/order.html; prices are indicative and not an offer; the platform's name is not decided yet.</span><button type="button" class="linkb" data-act="go" data-s="16" id="priceLink">Just show me the price →</button></div>
|
||||
</div>
|
||||
<div class="rvbar" id="rvBar" hidden>
|
||||
<button type="button" class="btn sec" data-act="share">⤴ Share quote</button>
|
||||
@@ -404,12 +485,13 @@ document.documentElement.setAttribute("data-theme",t);})();
|
||||
<aside class="qcard" id="qcard">
|
||||
<button type="button" class="qmin" data-act="qmin" title="Make the card smaller">−</button>
|
||||
<div class="qa qhide"><button type="button" class="btn qsave" data-act="save" title="Save this quote in this browser">Save quote</button><button type="button" class="btn sec" data-act="share" title="Copy a link that opens this configuration">⤴ Share</button></div>
|
||||
<div class="ql">Estimated price</div>
|
||||
<div class="ql" id="qL1">Estimated price</div>
|
||||
<div class="qt" id="qTot"></div>
|
||||
<div class="qs" id="qPer"></div>
|
||||
<div class="qhide"><div class="ql g">Estimated ship date</div>
|
||||
<div class="qhide"><div class="ql g" id="qL2">Estimated ship date</div>
|
||||
<div class="qd" id="qShip"></div>
|
||||
<div class="qs" id="qShipN"></div></div>
|
||||
<div class="qhide" id="qKeepW" hidden><div id="qKeep"></div></div>
|
||||
<hr>
|
||||
<div class="qq"><div class="stepper"><button type="button" data-act="qty" data-d="-1" aria-label="Fewer copies">−</button><input type="number" class="qtyIn" min="25" max="5000" aria-label="Copies"><small>pcs</small><button type="button" data-act="qty" data-d="1" aria-label="More copies">+</button></div></div>
|
||||
<div class="qn" id="qNote"></div>
|
||||
@@ -473,7 +555,10 @@ const R={
|
||||
wCopy:{v:{12:0.30,10:0.22,7:0.07,x12:0.16,x10:0.12,x7:0.05,g180:0.04},u:"kg per copy",t:"Shipping weight per copy",k:"est",src:"Estimate, new on this page: a 12\" LP with its jacket weighs about 0.3 kg; each further disc in its sleeve adds about 0.16 kg (12\"), 180 g vinyl 0.04 kg a disc."},
|
||||
carton:{v:{12:25,10:30,7:50},u:"copies per carton",t:"Copies per shipping carton",k:"est",src:"Estimate, new on this page: one mailer carton of about 8 kg."},
|
||||
minRun:{v:100,u:"copies",t:"Minimum run for an order",k:"doc",src:"Planning figure, to be decided (artist platform, open decision 4). Smaller runs are quoted, at a higher price per copy."},
|
||||
deposit:{v:0.5,u:"of a direct order",t:"Deposit on a direct order",k:"est",src:"Placeholder; deposit terms are not set yet."}
|
||||
deposit:{v:0.5,u:"of a direct order",t:"Deposit on a direct order",k:"est",src:"Placeholder; deposit terms are not set yet."},
|
||||
fee:{v:0.035,u:"of the fan price",t:"Payment fees on fan pre-orders",k:"doc",src:"About 3 to 4 % on cards, less on PromptPay, to be confirmed with the provider (artist platform). Same rate as artist-order and make-a-record."},
|
||||
vatPct:{v:7,u:"% inside a fan price",t:"Thai VAT in a fan price",k:"doc",src:"With CNX as seller of record (artist platform, open decision 1) a fan price includes 7/107 of VAT, which comes out of the artist's margin; the switch on the fan funding step shows the numbers with it taken out, as make-a-record does. New on this page as a list entry."},
|
||||
campDays:{v:{min:14,max:45,def:30},u:"days",t:"Campaign length the artist chooses",k:"doc",src:"14 to 45 days, an estimate (artist platform, The campaign page); 30 days is the demo default, as in make-a-record. New on this page as a list entry."}
|
||||
};
|
||||
const tierIdx=q=>q>=1000?3:q>=500?2:q>=300?1:0;
|
||||
/* side length per format, minutes [comfortable, at most]: comfortable = lower end of the microcut.studio format guide;
|
||||
@@ -546,7 +631,10 @@ const S={
|
||||
srpm:{},stype:{},gaps:"files",genre:"pop",leadout:"",
|
||||
ship:"pickup",split:false,splitQty:50,ship2:"th",duty:"dap",same:true,
|
||||
addr:{b:Object.assign(blankAddr(),{country:"Thailand"}),s:Object.assign(blankAddr(),{country:"Thailand"}),x:Object.assign(blankAddr(),{country:"Thailand"})},
|
||||
notes:"",view:"record",viewMan:false,closed:false,qmin:false,placed:null,visited:{1:true}
|
||||
notes:"",view:"record",viewMan:false,closed:false,qmin:false,placed:null,visited:{1:true},
|
||||
/* how to pay: direct order or fan campaign; the campaign fields and defaults are make-a-record's */
|
||||
route:"direct",fanPrice:1200,days:R.campDays.v.def,limited:false,capN:300,artistCopies:0,vat:false,
|
||||
launched:false,launchDate:null,scen:"make",simDay:0,outcome:null,outcomeAct:null,finalFans:0
|
||||
};
|
||||
let tid=0;
|
||||
[["Doi Suthep at Dawn",262],["Ping River",241],["Nimman Rain",198],["Songthaew",225],["Old City Walls",284],["Lanna Lights",233],["Mae Rim Drive",256],["Last Train North",301]]
|
||||
@@ -661,6 +749,44 @@ function leadInfo(campaign){
|
||||
}
|
||||
function shipWindow(){const L=leadInfo(false);return [addWD(TODAY,L.tw[0]*5),addWD(TODAY,L.tw[1]*5)];}
|
||||
|
||||
/* ---- fan funding: campEcon() as in make-a-record.html (same maths, same rule: fans needed = max(minimum run, break-even)) ---- */
|
||||
const fansOn=()=>S.route==="fans";
|
||||
const capOn=()=>S.limited?S.capN:0; /* the effective edition cap, 0 when the limited edition is off (make-a-record's S.cap) */
|
||||
function campEcon(){
|
||||
const p=S.fanPrice,fee=R.fee.v,vat=S.vat?p*R.vatPct.v/(100+R.vatPct.v):0,net1=p-p*fee-vat,tSum=quote(R.minRun.v).tSum,ac=S.artistCopies;
|
||||
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;}}
|
||||
/* the artist sets the fan price; the fans needed follow: the plant minimum, or the break-even if that is higher */
|
||||
const min=R.minRun.v,goal=be==null?null:Math.max(min,be);
|
||||
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};
|
||||
}
|
||||
/* one campEcon per state: render passes ask for it several times */
|
||||
let ECK="",ECV=null;
|
||||
function econ(){const k=JSON.stringify(cfg())+"|"+audioTracks().length;if(k!==ECK||!ECV){ECK=k;ECV=campEcon();}return ECV;}
|
||||
/* the lowest fan price at which the plant minimum covers the costs; below it the break-even sets the fans needed (make-a-record pRaw) */
|
||||
function priceHint(E){E=E||campEcon();const ac=S.artistCopies,keepShare=1-R.fee.v-(S.vat?R.vatPct.v/(100+R.vatPct.v):0),pRaw=(E.tSum+(E.min+ac)*E.cAt(E.min+ac))/E.min/keepShare;
|
||||
const pMin=S.cur==="THB"?Math.ceil(pRaw/10)*10:Math.ceil(pRaw/rate())*rate();return {pRaw,pMin,t:money(pMin,{dec:0})};}
|
||||
function keepRows(E){const cap=capOn();if(E.goal==null)return [];return [...new Set([E.goal,200,300,500,1000])].filter(n=>n>=E.goal&&(!cap||n<=cap)).sort((a,b)=>a-b).map(E.at);}
|
||||
const ROUTEW="Choose \"Let fans fund it\" above to set up a campaign";
|
||||
/* why a campaign cannot launch: the route, a price below the cost per copy, an edition cap below the goal */
|
||||
function campWhy(E){E=E||econ();if(!fansOn())return ROUTEW;if(E.goal==null)return "Raise the fan price: it does not cover the cost per copy";const c=capOn();if(c&&c<E.goal)return "The edition cap of "+fmtInt(c)+" is below the goal of "+fmtInt(E.goal)+" fans";return "";}
|
||||
/* the fan price is kept in THB; it is typed and shown in the chosen currency (make-a-record) */
|
||||
const toTHB=v=>Math.round(v*rate()*100)/100;
|
||||
const rngP=el=>{const a=+el.min||0,b=+el.max||100;el.style.setProperty("--p",(Math.max(0,Math.min(1,(+el.value-a)/((b-a)||1)))*100).toFixed(1)+"%");};
|
||||
const fanIn=()=>S.cur==="THB"?Math.round(S.fanPrice):Math.round(S.fanPrice/rate()*100)/100;
|
||||
const FAN_IN={THB:[300,5000,50],USD:[10,150,1],EUR:[8,130,1]},FAN_RNG={THB:[300,3000,50],USD:[10,90,1],EUR:[8,80,1]},FP={THB:[800,1000,1200,1500,2000],USD:[25,30,35,45,60],EUR:[20,25,30,40,50]};
|
||||
/* campaign simulation (make-a-record): final pledges per scenario, share of them in by time t */
|
||||
function finalPledges(E){
|
||||
if(!E.goal)return 0;const g=E.goal;
|
||||
let P=S.scen==="sell"?Math.ceil(g*1.6):S.scen==="make"?Math.ceil(g*1.08):Math.floor(g*0.68);
|
||||
const c=capOn();if(c>0)P=Math.min(P,c);return P;
|
||||
}
|
||||
const frac=t=>0.38*(1-Math.exp(-t/0.07))/(1-Math.exp(-1/0.07))+0.37*t+0.25*Math.pow(t,6);
|
||||
function pledgesNow(E){const P=finalPledges(E);return S.simDay>=S.days?P:Math.round(P*frac(S.simDay/S.days));}
|
||||
function campWindow(){const close=addDays(S.launchDate||TODAY,S.days),L=leadInfo(true);return {close,L,ship:[addWD(close,L.tw[0]*5),addWD(close,L.tw[1]*5)]};}
|
||||
|
||||
/* ---- tracks and sides ---- */
|
||||
function parseDur(s){s=String(s).trim();let m=s.match(/^(\d+):(\d{1,2})$/);if(m)return +m[1]*60+ +m[2];m=s.match(/^\d+(\.\d+)?$/);if(m)return Math.round(parseFloat(s)*60);return null;}
|
||||
const gapSec=n=>S.gaps==="add"?Math.max(0,n-1)*2:0;
|
||||
@@ -1149,7 +1275,9 @@ function renderStage(){
|
||||
}
|
||||
|
||||
/* ---- steps ---- */
|
||||
const STEPS=[["type","Order type"],["size","Size"],["discs","Discs"],["colour","Vinyl colour"],["labels","Centre labels"],["inner","Inner sleeves"],["jacket","Jacket"],["finish","Jacket finish"],["extras","Extras"],["cut","Mastering & cutting"],["tp","Test pressings"],["ship","Shipping"],["tracks","Tracklist & audio"],["art","Artwork"],["review","Review & order"]];
|
||||
const STEPS=[["type","Order type"],["size","Size"],["discs","Discs"],["colour","Vinyl colour"],["labels","Centre labels"],["inner","Inner sleeves"],["jacket","Jacket"],["finish","Jacket finish"],["extras","Extras"],["cut","Mastering & cutting"],["tp","Test pressings"],["ship","Shipping"],["tracks","Tracklist & audio"],["art","Artwork"],["pay","How to pay"],["review","Review & order"]];
|
||||
const PAY=STEPS.findIndex(s=>s[0]==="pay")+1,RV=STEPS.length;
|
||||
const stT=n=>STEPS[n-1][0]==="review"&&fansOn()?"Review & launch":STEPS[n-1][1];
|
||||
const JVIEW=[6,7,8,9,14];
|
||||
const IC={
|
||||
type:'<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="2.5"/>',
|
||||
@@ -1167,6 +1295,12 @@ const IC={
|
||||
tracks:'<path d="M3 6h12M3 11h12M3 16h7"/><path d="M17 17V7l4-1"/><circle cx="15.5" cy="17" r="1.8"/>',
|
||||
art:'<rect x="3" y="4" width="18" height="16" rx="1.5"/><circle cx="8.5" cy="9" r="1.8"/><path d="M3 17l5-4 4 3 4-5 5 5"/>',
|
||||
review:'<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
||||
pay:'<rect x="2.5" y="6" width="19" height="13" rx="2"/><path d="M2.5 10.5h19"/><circle cx="16.5" cy="15" r="1.6"/>',
|
||||
fans:'<circle cx="8.5" cy="8" r="3"/><path d="M2.5 20c0-3.4 2.7-6 6-6s6 2.6 6 6"/><circle cx="17" cy="9" r="2.4"/><path d="M15.6 14.3c3 .2 5.9 2.4 5.9 5.7"/>',
|
||||
plant:'<path d="M3 20V10.5l5 3v-3l5 3V5h4.5v15z"/><path d="M2 20h20M17.5 5h3v15"/>',
|
||||
vault:'<rect x="4.5" y="10" width="15" height="10.5" rx="2"/><path d="M8 10V7.5a4 4 0 0 1 8 0V10"/><circle cx="12" cy="15" r="1.4"/>',
|
||||
refund:'<path d="M9 14L4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
|
||||
card:'<rect x="2.5" y="5.5" width="19" height="13" rx="2"/><path d="M2.5 9.5h19M6 15h4"/>',
|
||||
loop:'<path d="M4 12a8 8 0 0 1 14-5.3M20 12a8 8 0 0 1-14 5.3"/><path d="M18 3v4h-4M6 21v-4h4"/>',
|
||||
print:'<path d="M7 9V3h10v6M7 17H4v-7h16v7h-3"/><rect x="7" y="14" width="10" height="7"/>',
|
||||
file:'<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4M9 13h6M9 17h6"/>',
|
||||
@@ -1235,6 +1369,7 @@ function stepHTML(){
|
||||
<details class="faq"><summary>Does the plant check the records anyway?</summary><p>Yes. Every record is inspected optically after pressing, and a sample is played and compared with the master automatically (quality automation). Your test pressing approval is about how it sounds to you.</p></details>
|
||||
<details class="faq"><summary>Does it slow the order down?</summary><p>The quoted lead time is counted from your test-pressing approval, because the plant cannot be held to a clock that waits for approvals (production timeline). Listen within a few days and the order keeps its slot.</p></details>`;
|
||||
H[12]=`<div class="expl">Where the records go. Pick-up at the plant is free; delivery rates are estimates until carriers quote. Addresses are needed to order, not for a quote.</div>
|
||||
<div class="note info" id="shipFan" hidden><b>Fan-funded (step ${PAY}):</b> every fan pays shipping at checkout, at cost, and gets one tracked parcel. The fans decide how many are pressed; the choice below is for your own copies and the test pressings.</div>
|
||||
<div class="row"><span class="lbl">Copies</span><div class="stepper"><button type="button" data-act="qty" data-d="-1" aria-label="Fewer copies">−</button><input type="number" class="qtyIn" id="qtyShip" min="25" max="5000" aria-label="Copies"><small>pcs</small><button type="button" data-act="qty" data-d="1" aria-label="More copies">+</button></div></div>
|
||||
<div class="tiles" data-k="ship">${tile("pickup",icon("pickup",30)+"<b>Pick-up</b><small>At the plant, Chiang Mai</small>"+ptag("sh_pickup"))}${tile("th",icon("ship",30)+"<b>Thailand</b><small>Courier, per carton</small>"+ptag("sh_th"))}${tile("intl",icon("globe",30)+"<b>International</b><small>Air parcel, per kg</small>"+ptag("sh_intl"))}</div>
|
||||
<div class="note info" id="shipCalc"></div>
|
||||
@@ -1261,12 +1396,30 @@ function stepHTML(){
|
||||
<label class="drop" id="dropInner"><input type="file" id="fileInner" accept=".pdf,.ai,application/pdf,application/postscript,application/illustrator,image/*" hidden><span class="di">⬇</span><span><b>Inner sleeve</b>: the sleeve front, square, 300 dpi.</span></label>
|
||||
<div id="artRes"></div>
|
||||
<p class="hint">PDF previews are drawn with pdf.js. Template sizes follow published plant templates until the packaging partner's dielines replace them.</p>`;
|
||||
H[15]=`<div class="rv"><div id="rvConf"></div><div id="rvMiss"></div><div id="rvSoft"></div>
|
||||
H[15]=`<div class="expl"><b>Pay the plant</b> for the run yourself, or <b>let your fans fund it</b>: they pre-order into a separate account and are refunded automatically if the goal is missed.</div>
|
||||
<div class="tiles c2 route" data-k="route">${tile("direct",icon("plant",32)+"<b>Pay the plant directly</b><small>A deposit to start, the rest before dispatch</small>"+ptag("rt_direct"))}${tile("fans",icon("fans",32)+"<b>Let fans fund it</b><small>Fans pre-order; refunded if the goal is missed</small>"+ptag("rt_fans"))}</div>
|
||||
<fieldset class="camp" id="fsCamp">
|
||||
<h4>Your price to fans, per copy</h4>
|
||||
<div class="fprow"><div class="stepper fp"><button type="button" data-act="fp" data-d="-1" aria-label="Lower fan price">−</button><input type="number" id="fanPrice" aria-label="Fan price"><small id="fpCur">THB</small><button type="button" data-act="fp" data-d="1" aria-label="Higher fan price">+</button></div><input type="range" id="fanR" aria-label="Fan price"></div>
|
||||
<div class="try" id="fpChips"></div>
|
||||
<div class="need" id="need"></div><p class="hint" id="needHint"></p>
|
||||
<div id="fundKpi"></div>
|
||||
<h4>Campaign</h4>
|
||||
<div class="camprow"><div class="fld"><span>Campaign length</span><div class="rngrow"><input type="range" id="days" min="${R.campDays.v.min}" max="${R.campDays.v.max}" step="1" aria-label="Campaign length in days"><b class="daysv" id="daysV"></b></div></div>
|
||||
<div class="fld"><span>Copies for yourself</span><div class="rngrow"><div class="stepper"><button type="button" data-act="acop" data-d="-1" aria-label="Fewer copies for yourself">−</button><input type="number" id="acop" min="0" max="200" step="5" aria-label="Copies for yourself"><small>pcs</small><button type="button" data-act="acop" data-d="1" aria-label="More copies for yourself">+</button></div></div></div></div>
|
||||
<div class="limrow"><label class="chk"><input type="checkbox" id="limT"> <span><b>Limited edition</b></span></label><span class="capf" id="capF">of <input type="number" id="cap" min="1" max="5000" step="50" aria-label="Edition size"> numbered copies</span></div>
|
||||
<label class="chk"><input type="checkbox" id="vatT"> <span>Show the numbers with ${R.vatPct.v} % VAT taken out of the fan price</span></label>
|
||||
<div id="fundEcon"></div>
|
||||
<h4>Your fans' money is safe</h4>
|
||||
<div class="safe"><div><span class="si">${icon("vault",17)}</span><div><b>Held apart</b><small>Pre-orders sit in a separate account and are never used to run the plant until the campaign succeeds.</small></div></div><div><span class="si">${icon("refund",17)}</span><div><b>Refunded automatically</b><small>If the goal is missed, every fan gets their money back. Nobody ends up with a garage full of records.</small></div></div><div><span class="si">${icon("card",17)}</span><div><b>Pay the way fans pay</b><small>PromptPay QR, cards and wallets. Card details never touch the plant's servers.</small></div></div></div>
|
||||
<div class="launch"><button type="button" class="btn" data-act="launch" id="bLaunch">Launch the campaign →</button><p class="hint" id="launchMsg"></p></div>
|
||||
</fieldset>`;
|
||||
H[16]=`<div class="rv"><div id="rvConf"></div><div id="rvMiss"></div><div id="rvSoft"></div>
|
||||
<div class="rvgrid"><div class="sumcard"><div class="sumhd"><span id="rvDisc"></span><div><h3>Order summary</h3><div class="chips" id="rvChips"></div></div></div><div class="tscroll"><table id="rvTab"></table></div><p class="hint">Excludes 7 % VAT. <span class="pass">↗ pass-through</span> lines are the partner's price plus the handling margin shown. Badges give each rate's basis; hover for the source.</p></div>
|
||||
<div><div class="sumcard"><h4 style="margin-top:0">When it ships</h4><div id="rvLead"></div></div>
|
||||
<div class="sumcard" style="margin-top:14px"><h4 style="margin-top:0">Catalogue number</h4><input type="text" id="catno2" maxlength="15" class="mono" style="width:100%;text-transform:uppercase" spellcheck="false" autocomplete="off" aria-label="Catalogue number"><p class="hint">Your reference for this release, scribed in the dead wax: A to Z, 0 to 9 and hyphen, up to 15. Needed to order, not for a quote.</p>
|
||||
<h4>Notes for the plant</h4><textarea id="notes" rows="3" style="width:100%" placeholder="Anything we should know: deadlines, a release date, a pressing to match"></textarea>
|
||||
<h4>Let fans fund it instead?</h4><p class="hint">Fans pre-order on a campaign page; the record is pressed if enough of them do, and every fan is refunded if not.</p><a class="btn sec" href="../artist-order/">Fund it with fans instead →</a></div></div></div>
|
||||
<h4>How you pay</h4><div id="rvPay"></div></div></div></div>
|
||||
<details class="pl"><summary>How we price: price list ${PLV} and where every number comes from</summary><div id="plBox"></div></details>
|
||||
<p class="foot" style="margin:18px 0 6px">Demo page from docs/order.html; prices are indicative and not an offer; the platform's name is not decided yet.</p></div>`;
|
||||
return H.map((h,i)=>i?`<div class="stp" data-step="${i}" hidden>${h}</div>`:"").join("");
|
||||
@@ -1277,7 +1430,7 @@ const ENUM={otype:["new","repress","tponly","print"],size:["12","10","7"],weight
|
||||
jk:["none","plain","printed3","printed5","gatefold"],innerT:["paper-white","paper-black","poly-white","poly-black","printed"],inner:["paper","poly","printed"],innerCol:["white","black"],
|
||||
jacket:["none","plain","printed","gatefold"],spine:["thin","thick"],finish:["uncoated","matt","gloss","spotuv"],source:["digital","tape","lacquer"],master:["none","inhouse","partner"],
|
||||
cutStyle:["fidelity","balanced","loud"],tps:[0,3,5,10],ship:["pickup","th","intl"],ship2:["th","intl"],duty:["dap","ddp"],gaps:["files","add","none"],cleaning:[true,false],
|
||||
cur:["THB","USD","EUR"],view:["record","jacket"],genre:GENRES.map(g=>g[0]),discs:[1,2,3,4],rpm:["33","45"]};
|
||||
cur:["THB","USD","EUR"],view:["record","jacket"],genre:GENRES.map(g=>g[0]),discs:[1,2,3,4],rpm:["33","45"],route:["direct","fans"],scen:["sell","make","miss"]};
|
||||
const REPW="A repress presses from the archived metal: nothing new is mastered or cut";
|
||||
function why(k,v){
|
||||
switch(k){
|
||||
@@ -1322,6 +1475,8 @@ function setK(k,v){
|
||||
else if(k in S){if(S[k]===v){renderAll();return true;}S[k]=v;}
|
||||
else return false;
|
||||
if(k==="discs"){S.vd=Math.min(S.vd,v-1);normalize();autoSplit();renderTracks();}
|
||||
if(k==="route"){resetCampaign();S.placed=null;}
|
||||
if(k==="scen")resetSim();
|
||||
if(k==="size"||k==="cutStyle"||k==="ship"||k==="ship2")normalize();
|
||||
if(k==="size")renderTracks();
|
||||
if(k==="cur")renderPL();
|
||||
@@ -1350,7 +1505,9 @@ const PT={
|
||||
src_digital:()=>"included",src_tape:()=>plus(R.tape.v),src_lacquer:()=>"no blanks, cut or mastering",
|
||||
m_none:()=>"no mastering line",m_inhouse:()=>plus(R.master.v)+" / side",m_partner:()=>{const n=audioTracks().length;return money(n*R.masterTrack.v)+" for "+n+" tracks";},
|
||||
tp0:()=>"no charge",tp3:()=>money(3*R.tp.v),tp5:()=>money(5*R.tp.v),tp10:()=>money(10*R.tp.v),
|
||||
sh_pickup:()=>"free",sh_th:()=>money(R.shipTH.v)+" / carton, est.",sh_intl:()=>money(R.shipIntl.v)+" / kg, est."
|
||||
sh_pickup:()=>"free",sh_th:()=>money(R.shipTH.v)+" / carton, est.",sh_intl:()=>money(R.shipIntl.v)+" / kg, est.",
|
||||
rt_direct:()=>pctT(R.deposit.v)+" deposit: "+money(quote(S.qty).total*R.deposit.v),
|
||||
rt_fans:()=>{const E=econ();return E.goal?fmtInt(E.goal)+" fans at "+money(S.fanPrice):"Raise the fan price";}
|
||||
};
|
||||
["shrink","insert","sticker","numbered","dlcard","barcode","obi","bag"].forEach(k=>PT["x_"+k]=()=>perC(R[k].v));
|
||||
|
||||
@@ -1391,6 +1548,7 @@ function sync(){
|
||||
const G=GENRES.find(g=>g[0]===S.genre)||GENRES[0];$("#genreChk").innerHTML=G[2]==="bass"?(S.cutStyle==="loud"?"Bass-heavy music with the Loud style: keep sides short (limits at 60 %).":"Bass-heavy music: consider the <b>Loud</b> cutting style (step 10) and 45 rpm where a side allows."):G[2]==="dyn"?"Dynamic music: the <b>Fidelity</b> style keeps the most headroom.":"The level check below looks at each side's speed for this genre.";
|
||||
const aw=rep()?"A repress plays the archived master":"";$("#dropAudio").classList.toggle("off",!!aw);$("#fileAudio").disabled=!!aw;$("#dropAudio").title=aw;
|
||||
[["jacket","#dropJacket","#fileJacket"],["label","#dropLabel","#fileLabel"],["inner","#dropInner","#fileInner"]].forEach(([k,d,f])=>{const w=tplOff(k);$(d).classList.toggle("off",!!w);$(f).disabled=!!w;$(d).title=w;});
|
||||
$("#shipFan").hidden=!fansOn();
|
||||
}
|
||||
function renderFx(){
|
||||
const d=S.dl[Math.min(S.vd,S.discs-1)],open=S.fxOpen===undefined?d.fx:S.fxOpen;
|
||||
@@ -1418,40 +1576,57 @@ function summ(n){
|
||||
case "ship":return fmtInt(S.qty)+" pcs → "+({pickup:"pick-up",th:"Thailand",intl:"international"}[S.ship])+(S.split?" + 1":"");
|
||||
case "tracks":return S.tracks.length+" tracks, "+audioSides().length+" side"+(audioSides().length===1?"":"s");
|
||||
case "art":{const ks=["jacket","label","inner"].filter(k=>!tplOff(k)),n=ks.filter(k=>S.art[k]).length;return ks.length?n+" of "+ks.length+" files":"No artwork needed";}
|
||||
case "pay":{if(!fansOn())return "Direct, "+pctT(R.deposit.v)+" deposit";const E=econ();return E.goal?"Fans: "+fmtInt(E.goal)+" at "+money(S.fanPrice):"Fans: raise the price";}
|
||||
case "review":{const m=missing().length;return m?m+" to complete":"Ready to order";}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
function renderRail(){
|
||||
const M=missing(),rv=!!S.visited[15];
|
||||
$("#rail").innerHTML=STEPS.map(([id,t],i)=>{const n=i+1,on=n===S.step,vis=!!S.visited[n],miss=rv&&M.some(m=>m.s===n);
|
||||
const M=missing(),rv=!!S.visited[RV];
|
||||
$("#rail").innerHTML=STEPS.map(([id],i)=>{const n=i+1,t=stT(n),on=n===S.step,vis=!!S.visited[n],miss=rv&&M.some(m=>m.s===n);
|
||||
return `<button type="button" class="ri${on?" on":""}${vis?" vis":""}${vis&&!on&&!miss?" done":""}${miss?" miss":""}" data-act="go" data-s="${n}" title="${esc(t+": "+summ(n))}"><span class="rn">${n}</span><span class="ric">${icon(id,18)}</span><span class="rt"><b>${esc(t)}</b><small>${esc(summ(n))}</small></span><span class="rch">›</span></button>`;}).join("");
|
||||
const on=$("#rail .ri.on"),c=$("#rail");
|
||||
if(on&&c&&c.scrollWidth>c.clientWidth+4){const o=on.offsetLeft-c.offsetLeft;if(o<c.scrollLeft||o+on.offsetWidth>c.scrollLeft+c.clientWidth)c.scrollLeft=Math.max(0,o-30);}
|
||||
}
|
||||
function renderHead(){
|
||||
const n=S.step,app=$("#app");
|
||||
$("#pNum").textContent=n;$("#pIcon").innerHTML=icon(STEPS[n-1][0],20);$("#pTitle").textContent=STEPS[n-1][1];
|
||||
$("#pNum").textContent=n;$("#pIcon").innerHTML=icon(STEPS[n-1][0],20);$("#pTitle").textContent=stT(n);
|
||||
$$(".stp").forEach(x=>x.hidden=+x.dataset.step!==n);
|
||||
app.classList.toggle("wide",n===15);app.classList.toggle("closed",S.closed&&n!==15);
|
||||
$("#reopenB").hidden=!(S.closed&&n!==15);
|
||||
$("#pFoot").hidden=n===15;$("#rvBar").hidden=n!==15;
|
||||
$("#nextB").innerHTML=n<15?"Next: "+esc(STEPS[n].slice(1)[0])+" →":"";
|
||||
app.classList.toggle("wide",n===RV);app.classList.toggle("closed",S.closed&&n!==RV);
|
||||
$("#reopenB").hidden=!(S.closed&&n!==RV);
|
||||
$("#pFoot").hidden=n===RV;$("#rvBar").hidden=n!==RV;
|
||||
$("#nextB").innerHTML=n<RV?"Next: "+esc(stT(n+1))+" →":"";
|
||||
}
|
||||
const QTYW="Fan-funded: the fans decide how many are pressed; the goal follows from the fan price (step "+PAY+")";
|
||||
function renderCard(){
|
||||
const Q=quote(S.qty),sw=shipWindow(),L=leadInfo(false),M=missing();
|
||||
$("#qTot").textContent=money(Q.total);
|
||||
$("#qPer").textContent=money(Q.per)+" per copy"+(Q.hSum?", plus shipping":"")+", excl. VAT";
|
||||
$("#qShip").textContent=dRange(sw[0],sw[1]);
|
||||
$("#qShipN").textContent=L.repress?"Repress, quoted 2 to 3 weeks":"If ordered today; "+L.qw[0]+" to "+L.qw[1]+" weeks quoted from your test-pressing approval";
|
||||
$("#qNote").textContent=S.qty<R.minRun.v?"Below the "+R.minRun.v+"-copy minimum run: a quote only. Small runs cost more per copy.":"";
|
||||
const Q=quote(S.qty),sw=shipWindow(),L=leadInfo(false),M=missing(),F=fansOn(),E=F?econ():null,G=E&&E.goal?E.at(E.goal):null,cw=F?campWhy(E):"";
|
||||
if(F){
|
||||
$("#qL1").textContent="Fan price";$("#qTot").textContent=money(S.fanPrice);
|
||||
$("#qPer").textContent="per copy, plus shipping";
|
||||
$("#qL2").textContent="Fans needed";$("#qShip").textContent=E.goal?fmtInt(E.goal)+" fans":"Price too low";
|
||||
$("#qShipN").textContent=E.goal?money(E.goal*S.fanPrice)+" in pre-orders in "+S.days+" days"+(capOn()?", edition of "+fmtInt(capOn()):""):"The fan price does not cover the cost per copy";
|
||||
$("#qKeepW").hidden=false;
|
||||
$("#qKeep").innerHTML=G?`<div class="ql g">You keep at the goal</div><div class="qd">${money(G.per)} a copy</div><div class="qs">${money(G.keep)} in all, after the ${money(E.tSum)} one-off setup, pressing and fees</div>`:`<div class="ql g">You keep at the goal</div><div class="qd">${money(0)}</div><div class="qs">Raise the fan price in step ${PAY}.</div>`;
|
||||
$("#qNote").textContent=cw||"The run is the fans' pre-orders plus your own copies.";$("#qNote").classList.toggle("info",!cw);
|
||||
}else{
|
||||
$("#qL1").textContent="Estimated price";$("#qTot").textContent=money(Q.total);
|
||||
$("#qPer").textContent=money(Q.per)+" per copy"+(Q.hSum?", plus shipping":"")+", excl. VAT";
|
||||
$("#qL2").textContent="Estimated ship date";$("#qShip").textContent=dRange(sw[0],sw[1]);
|
||||
$("#qShipN").textContent=L.repress?"Repress, quoted 2 to 3 weeks":"If ordered today; "+L.qw[0]+" to "+L.qw[1]+" weeks quoted from your test-pressing approval";
|
||||
$("#qKeepW").hidden=true;
|
||||
$("#qNote").textContent=S.qty<R.minRun.v?"Below the "+R.minRun.v+"-copy minimum run: a quote only. Small runs cost more per copy.":"";$("#qNote").classList.remove("info");
|
||||
}
|
||||
$("#qcard").classList.toggle("min",S.qmin);
|
||||
let sv=null;try{sv=JSON.parse(localStorage.getItem("cnx-order-saved")||"null");}catch(e){}
|
||||
$("#qSaved").innerHTML=sv&&sv.h?`Saved ${new Date(sv.at).toLocaleString("en-GB",{day:"numeric",month:"short",hour:"2-digit",minute:"2-digit"})} · <button type="button" class="linkb" data-act="restore">Restore</button>`:"";
|
||||
$$(".qtyIn").forEach(i=>{if(document.activeElement!==i)i.value=S.qty;});
|
||||
$("#pbTot").textContent=money(Q.total);$("#pbPer").textContent=money(Q.per)+" / copy · ships "+fmtDs(sw[0]);$("#pbPer").title="Ships "+dRange(sw[0],sw[1]);
|
||||
const pb=$("#pbBtn");if(S.step===15){pb.innerHTML="Place order →";pb.disabled=M.length>0||!!S.placed;pb.title=M.length?"Complete the items in the yellow box first":"";}else{pb.innerHTML="Review →";pb.disabled=false;pb.title="";}
|
||||
const plb=$("#placeB");plb.disabled=M.length>0||!!S.placed;plb.title=M.length?"Complete the items in the yellow box first":S.placed?"Already placed":"";
|
||||
/* copies: set by the fans on the fan route, so the steppers stay in place, disabled, and show the run at the goal */
|
||||
$$(".qtyIn").forEach(i=>{i.disabled=F;i.title=F?QTYW:"";if(document.activeElement!==i)i.value=F?(E.goal?E.goal+S.artistCopies:""):S.qty;});
|
||||
$$('button[data-act="qty"]').forEach(b=>{b.disabled=F;b.title=F?QTYW:"";});
|
||||
if(F){$("#pbTot").textContent=money(S.fanPrice)+(E.goal?" · "+fmtInt(E.goal)+" fans":"");$("#pbPer").textContent=G?"keep "+money(G.per)+" a copy":"raise the fan price";$("#pbPer").title="";}
|
||||
else{$("#pbTot").textContent=money(Q.total);$("#pbPer").textContent=money(Q.per)+" / copy · ships "+fmtDs(sw[0]);$("#pbPer").title="Ships "+dRange(sw[0],sw[1]);}
|
||||
const done=F?S.launched:!!S.placed,wy=M.length?"Complete the items in the yellow box first":cw?cw:done?(F?"Already launched":"Already placed"):"";
|
||||
const pb=$("#pbBtn");if(S.step===RV){pb.innerHTML=F?"Launch →":"Place order →";pb.disabled=!!wy;pb.title=wy;}else{pb.innerHTML="Review →";pb.disabled=false;pb.title="";}
|
||||
const plb=$("#placeB");plb.dataset.act=F?"launch":"place";plb.innerHTML=F?"Launch the campaign →":"Place order →";plb.disabled=!!wy;plb.title=wy;
|
||||
}
|
||||
|
||||
/* ---- step 13: sides, tracks, checks ---- */
|
||||
@@ -1503,7 +1678,7 @@ const AF={name:"name",email:"e-mail",phone:"phone",line1:"address line",city:"ci
|
||||
function addrMiss(g,fields){const a=S.addr[g];return fields.filter(f=>!String(a[f]||"").trim()||(f==="email"&&!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(a[f]).trim())));}
|
||||
function missing(){
|
||||
const M=[];
|
||||
if(S.qty<R.minRun.v)M.push({s:12,t:"Copies: the minimum run for an order is "+R.minRun.v,f:"qtyShip"});
|
||||
if(!fansOn()&&S.qty<R.minRun.v)M.push({s:12,t:"Copies: the minimum run for an order is "+R.minRun.v,f:"qtyShip"});
|
||||
const ss=sideStats(),as=ss.filter(s=>s.type==="audio");
|
||||
if(!as.length)M.push({s:13,t:"Tracklist: no side has audio"});
|
||||
as.filter(s=>!s.count).forEach(s=>M.push({s:13,t:"Side "+s.n+": no tracks added"}));
|
||||
@@ -1527,22 +1702,47 @@ function soft(){
|
||||
const badge=id=>{const r=R[id];return r?`<span class="badge b-${r.k}" title="${esc(r.src)}">${{doc:"doc",team:"team",est:"est."}[r.k]}</span>`:"";};
|
||||
function orderNo(){let h=0;for(const c of stateHash())h=(h*31+c.charCodeAt(0))>>>0;return 1000+h%9000;}
|
||||
function renderReview(){
|
||||
const Q=quote(S.qty),M=missing(),L=leadInfo(false),sw=shipWindow(),q=S.qty;
|
||||
$("#rvMiss").innerHTML=M.length?`<div class="missbox"><h3>⚠ Please complete the following before ordering:</h3><ul>${M.map(m=>`<li><button type="button" class="linkb" data-act="miss" data-s="${m.s}" data-f="${m.f||(m.fs?m.fs[0][0]:"")}">${esc(m.t)}</button>${m.fs?": missing "+m.fs.map(([f,n])=>`<button type="button" class="linkb" data-act="miss" data-s="${m.s}" data-f="${f}">${n}</button>`).join(", "):""}</li>`).join("")}</ul></div>`
|
||||
:`<div class="missbox ok"><h3>✓ Everything needed to order is filled in.</h3><span class="hint">Place the order with the button at the bottom.</span></div>`;
|
||||
$("#rvSoft").innerHTML=`<div class="softbox"><b>Can follow after the order</b><ul>${soft().map(x=>`<li>${esc(x)}</li>`).join("")}</ul></div>`;
|
||||
const F=fansOn(),E=F?econ():null,G=E&&E.goal?E.at(E.goal):null,M=missing(),L=leadInfo(F),sw=shipWindow(),q=F?(E.goal||E.min)+S.artistCopies:S.qty,Q=quote(q),W=F?campWindow():null,cap=capOn();
|
||||
$("#rvMiss").innerHTML=M.length?`<div class="missbox"><h3>⚠ Please complete the following before ${F?"launching":"ordering"}:</h3><ul>${M.map(m=>`<li><button type="button" class="linkb" data-act="miss" data-s="${m.s}" data-f="${m.f||(m.fs?m.fs[0][0]:"")}">${esc(m.t)}</button>${m.fs?": missing "+m.fs.map(([f,n])=>`<button type="button" class="linkb" data-act="miss" data-s="${m.s}" data-f="${f}">${n}</button>`).join(", "):""}</li>`).join("")}</ul></div>`
|
||||
:`<div class="missbox ok"><h3>✓ Everything needed to ${F?"launch":"order"} is filled in.</h3><span class="hint">${F?(S.launched?"The campaign is running above (simulated).":"Launch the campaign with the button at the bottom."):"Place the order with the button at the bottom."}</span></div>`;
|
||||
$("#rvSoft").innerHTML=`<div class="softbox"><b>Can follow after the ${F?"launch":"order"}</b><ul>${soft().map(x=>`<li>${esc(x)}</li>`).join("")}</ul></div>`;
|
||||
$("#rvDisc").innerHTML=miniDisc(S.dl[0],92,{label:S.labels});
|
||||
$("#rvChips").innerHTML=[rep()?"Repress":"New release","Vinyl",S.size+"\"",S.discs>1?S.discs+" discs":"1 disc","Qty: "+fmtInt(q),PLV].map(c=>`<span>${esc(c)}</span>`).join("");
|
||||
$("#rvChips").innerHTML=[rep()?"Repress":"New release","Vinyl",S.size+"\"",S.discs>1?S.discs+" discs":"1 disc"].concat(F?["Fan-funded"].concat(E.goal?["Goal: "+fmtInt(E.goal)+" fans"]:[]):["Qty: "+fmtInt(q)],[PLV]).map(c=>`<span>${esc(c)}</span>`).join("");
|
||||
const tr=x=>`<tr><td>${esc(x.t)} ${badge(x.id)}${x.pass!=null?` <span class="pass">↗ partner ${money(x.pass)} + ${pctT(R.handling.v)}</span>`:""}<small class="qrm">${x.u?fmtInt(x.n)+" "+x.u+" × ":""}${money(x.r)}</small></td><td class="num qr">${x.u?fmtInt(x.n)+" "+x.u:""}</td><td class="num qr">${money(x.r)}</td><td class="num">${money(x.a)}</td></tr>`;
|
||||
$("#rvTab").innerHTML=`<tbody><tr class="hd"><th>One-off, for this release</th><th class="num qr">Qty</th><th class="num qr">Rate</th><th class="num">Amount</th></tr>${Q.T.map(tr).join("")}<tr class="sub"><td colspan="3">One-off total</td><td class="num">${money(Q.tSum)}</td></tr>
|
||||
<tr class="hd"><th>Per copy × ${fmtInt(q)}</th><th class="num qr"></th><th class="num qr">Rate</th><th class="num">Per copy</th></tr>${Q.C.map(tr).join("")}<tr class="sub"><td colspan="3">${money(Q.cSum)} per copy × ${fmtInt(q)} copies</td><td class="num">${money(Q.cSum*q)}</td></tr>
|
||||
const head=`<tbody><tr class="hd"><th>One-off, for this release</th><th class="num qr">Qty</th><th class="num qr">Rate</th><th class="num">Amount</th></tr>${Q.T.map(tr).join("")}<tr class="sub"><td colspan="3">One-off total</td><td class="num">${money(Q.tSum)}</td></tr>
|
||||
<tr class="hd"><th>Per copy × ${fmtInt(q)}${F?(E.goal?", the goal: "+fmtInt(E.goal)+" fans + "+fmtInt(S.artistCopies)+" for you":", the minimum run"):""}</th><th class="num qr"></th><th class="num qr">Rate</th><th class="num">Per copy</th></tr>${Q.C.map(tr).join("")}<tr class="sub"><td colspan="3">${money(Q.cSum)} per copy × ${fmtInt(q)} copies</td><td class="num">${money(Q.cSum*q)}</td></tr>`;
|
||||
$("#rvTab").innerHTML=F?head+`
|
||||
<tr class="hd"><th>Shipping</th><th class="num qr"></th><th class="num qr"></th><th class="num"></th></tr><tr><td colspan="3">One tracked parcel per fan, paid by the fan at checkout, at cost</td><td class="num"><small>paid by fans</small></td></tr>
|
||||
<tr class="tot"><td colspan="3">Cost of the run${E.goal?" at the goal":""}, excl. VAT</td><td class="num">${money(Q.tSum+q*Q.cSum)}</td></tr>`+(G?`<tr class="tot"><td colspan="3">Fan money after payment fees${S.vat?" and VAT":""}, ${fmtInt(E.goal)} × ${money(E.net1)} ${badge("fee")}</td><td class="num">${money(G.net)}</td></tr>
|
||||
<tr class="tot"><td colspan="3">You keep at the goal</td><td class="num">${money(G.keep)}</td></tr><tr><td colspan="3">That is, a copy</td><td class="num">${money(G.per)}</td></tr>
|
||||
<tr><td colspan="4"><small>Nothing is paid up front: the plant is paid from the fans' money when the campaign succeeds. More fans than the goal spread the one-off setup over more copies (step ${PAY}).</small></td></tr></tbody>`
|
||||
:`<tr><td colspan="4"><div class="note bad" style="margin:4px 0">At ${money(S.fanPrice)} a copy, what is left after payment fees${S.vat?" and VAT":""} does not cover the cost per copy. Raise the fan price in step ${PAY}.</div></td></tr></tbody>`)
|
||||
:head+`
|
||||
<tr class="hd"><th>Shipping</th><th class="num qr"></th><th class="num qr">Rate</th><th class="num">Amount</th></tr>${Q.H.map(tr).join("")}
|
||||
<tr class="tot"><td colspan="3">Order total, excl. VAT</td><td class="num">${money(Q.total)}</td></tr><tr class="tot"><td colspan="3">Per copy, before shipping</td><td class="num">${money(Q.per)}</td></tr>
|
||||
<tr><td colspan="3">Deposit to start, ${pctT(R.deposit.v)} ${badge("deposit")}</td><td class="num">${money(Q.total*R.deposit.v)}</td></tr><tr><td colspan="3">Balance before dispatch</td><td class="num">${money(Q.total*(1-R.deposit.v))}</td></tr></tbody>`;
|
||||
$("#rvLead").innerHTML=(L.repress?`<div class="kv"><span>Repress from the archived mother</span><b>2 to 3 weeks, quoted</b><span>Ordered today, ships about</span><b>${dRange(sw[0],sw[1])}</b></div><p class="hint">A repress skips mastering, cutting and all three approvals (production timeline): about 7 to 11 working days of work, quoted with queue and buffer.</p>`
|
||||
:`<div class="kv"><span>Before your test-pressing approval</span><b>typically ${L.pw[0]} to ${L.pw[1]} weeks</b><span>From your test-pressing approval</span><b>${L.qw[0]} to ${L.qw[1]} weeks, quoted</b><span>Ordered today, ships about</span><b>${dRange(sw[0],sw[1])}</b></div><p class="hint">The quote counts from your last approval: the plant cannot be held to a clock that waits for approvals. ${L.printed?"A printed jacket is on the critical path; supplying your own saves 1 to 2 weeks.":"No printed jacket to wait for, which saves 1 to 2 weeks."} Stage times: mature-plant column of the production timeline.</p>`)+
|
||||
const closeT=(S.launched?"Closes ":"If launched today, closes ");
|
||||
$("#rvLead").innerHTML=(F?(L.repress?`<div class="kv"><span>Campaign</span><b>${S.days} days</b><span>${closeT}</span><b>${fmtD(W.close)}</b><span>Repress from the archived mother</span><b>2 to 3 weeks, quoted</b><span>If funded, ships about</span><b>${dRange(W.ship[0],W.ship[1])}</b></div><p class="hint">A repress skips mastering, cutting and all three approvals (production timeline); it starts when the campaign succeeds.</p>`
|
||||
:`<div class="kv"><span>Campaign</span><b>${S.days} days</b><span>${closeT}</span><b>${fmtD(W.close)}</b><span>Then, before your test-pressing approval</span><b>typically ${L.pw[0]} to ${L.pw[1]} weeks</b><span>From your test-pressing approval</span><b>${L.qw[0]} to ${L.qw[1]} weeks, quoted</b><span>If funded, ships about</span><b>${dRange(W.ship[0],W.ship[1])}</b></div><p class="hint">Cutting starts only when the campaign succeeds, because a lacquer has to be plated within one to three days (artist platform). The quote counts from your last approval. Stage times: mature-plant column of the production timeline.</p>`)
|
||||
:(L.repress?`<div class="kv"><span>Repress from the archived mother</span><b>2 to 3 weeks, quoted</b><span>Ordered today, ships about</span><b>${dRange(sw[0],sw[1])}</b></div><p class="hint">A repress skips mastering, cutting and all three approvals (production timeline): about 7 to 11 working days of work, quoted with queue and buffer.</p>`
|
||||
:`<div class="kv"><span>Before your test-pressing approval</span><b>typically ${L.pw[0]} to ${L.pw[1]} weeks</b><span>From your test-pressing approval</span><b>${L.qw[0]} to ${L.qw[1]} weeks, quoted</b><span>Ordered today, ships about</span><b>${dRange(sw[0],sw[1])}</b></div><p class="hint">The quote counts from your last approval: the plant cannot be held to a clock that waits for approvals. ${L.printed?"A printed jacket is on the critical path; supplying your own saves 1 to 2 weeks.":"No printed jacket to wait for, which saves 1 to 2 weeks."} Stage times: mature-plant column of the production timeline.</p>`))+
|
||||
`<details><summary class="hint">Stages${L.repress?"":" before your test-pressing approval"}</summary><table>${L.pre.map(s=>`<tr><td>${s.gate?"◆ ":""}${esc(s.t)}</td><td class="num">${s.d[0]} to ${s.d[1]} days</td></tr>`).join("")}</table></details>`;
|
||||
if(S.placed){const P=S.placed,no=P.no,yr=P.at.getFullYear(),nm=S.addr.b.name||"the customer",soItems=Q.T.concat(Q.C.map(x=>Object.assign({},x,{n:x.n*q}))).concat(Q.H.filter(x=>x.r));
|
||||
$("#rvPay").innerHTML=F?`<div class="kv"><span>Fan-funded, fan price</span><b>${money(S.fanPrice)}</b><span>Fans needed</span><b>${E.goal?fmtInt(E.goal):"price too low"}</b><span>Your break-even</span><b>${E.be!=null?fmtInt(E.be)+" fans":"none"}</b><span>You keep at the goal</span><b>${G?money(G.per)+" a copy":money(0)}</b><span>Campaign</span><b>${S.days} days</b><span>Limited edition</span><b>${cap?fmtInt(cap)+" copies":"no"}</b><span>Your own copies</span><b>${fmtInt(S.artistCopies)}</b><span>Payment fees ${badge("fee")}</span><b>${pctT(R.fee.v)}</b></div><p class="hint">Set in step ${PAY}. <button type="button" class="linkb" data-act="go" data-s="${PAY}">Change the campaign or pay the plant directly</button></p>`
|
||||
:`<div class="kv"><span>Direct order, deposit ${pctT(R.deposit.v)} ${badge("deposit")}</span><b>${money(Q.total*R.deposit.v)}</b><span>Balance before dispatch</span><b>${money(Q.total*(1-R.deposit.v))}</b></div><p class="hint">Or let your fans pre-order it: the record is pressed when enough of them have, and every fan is refunded if not. <button type="button" class="linkb" data-act="go" data-s="${PAY}">Fan funding in step ${PAY}</button></p>`;
|
||||
if(F&&S.launched){const no=orderNo(),yr=S.launchDate.getFullYear(),nm=S.addr.b.name||"the artist";
|
||||
$("#rvConf").innerHTML=`<div class="cgrid"><div class="sumcard"><div class="simhd"><h3>Your campaign <small class="hint">simulated</small></h3><div class="seg cmp" data-k="scen"><button type="button" data-v="sell">Sells out</button><button type="button" data-v="make">Just makes it</button><button type="button" data-v="miss">Falls short</button></div></div>
|
||||
<p class="hint" style="margin:6px 0 0">Pick how it goes and press play: ${S.days} days in about 8 seconds, or drag the slider to any day.</p>
|
||||
<div class="simrow"><button type="button" class="btn sec sm" data-act="play" id="playBtn"></button><input type="range" id="simDay" min="0" max="${S.days}" step="0.1" aria-label="Campaign day"><span class="hint" id="dayLbl"></span></div>
|
||||
<div class="bar big"><i id="simBar"></i></div><div class="kpis k3" id="simKpis"></div><p id="simStatus" style="font-size:13.5px;margin:8px 0 0"></p><div id="simOut"></div></div>
|
||||
<div class="conf"><h3>✓ Campaign launched (simulated): CNX-${yr}-${no}</h3><p>No page went live and nobody was charged. In the real platform the campaign is written to <b>ERPNext</b>, the plant's only system of record, through the plant's integration layer and its outbox; the public side never talks to ERPNext directly (artist platform, digital factory).</p>
|
||||
<ol><li><b>Customer</b> <code>${esc(nm)}</code>: the artist, matched by e-mail or created, with the billing address.</li>
|
||||
<li><b>Campaign</b> <code>CMP-${yr}-0${no}</code>, a custom document from launch: ${money(S.fanPrice)} a copy, goal ${fmtInt(E.goal||0)} fans, closes ${fmtD(W.close)}${cap?", limited to "+fmtInt(cap):""}.</li>
|
||||
<li><b>Payment Entry</b> for each pledge: an advance into the ring-fenced account as it arrives, with the fan as a customer. A missed campaign's refunds are posted as payment entries too.</li>
|
||||
<li>If it succeeds: a <b>Sales Order</b> the advances are allocated to, for the fans' copies${S.artistCopies?" plus your "+fmtInt(S.artistCopies):""}, and a <b>Delivery Note</b> for each fan's parcel.</li>
|
||||
<li><b>Release</b> ${esc(S.catno)} with ${sideNames().length} <b>Side</b> records (custom document types in the <code>cnx_erp</code> app) and three <b>Artist Gate</b> records: master plan, master, test pressing.</li></ol>
|
||||
<p><button type="button" class="btn sec" data-act="unlaunch">Back to the campaign settings</button></p></div></div>`;
|
||||
updateSim();}
|
||||
else if(!F&&S.placed){const P=S.placed,no=P.no,yr=P.at.getFullYear(),nm=S.addr.b.name||"the customer",soItems=Q.T.concat(Q.C.map(x=>Object.assign({},x,{n:x.n*q}))).concat(Q.H.filter(x=>x.r));
|
||||
$("#rvConf").innerHTML=`<div class="conf"><h3>✓ Order placed (simulated): CNX-${yr}-${no}</h3><p>Nothing was sent or charged. In the real platform the order is written to <b>ERPNext</b>, the plant's only system of record, through the plant's integration layer and its outbox; the public side never talks to ERPNext directly (digital factory, artist platform).</p>
|
||||
<ol><li><b>Customer</b> <code>${esc(nm)}</code>: matched by e-mail or created, with the billing${S.ship!=="pickup"?" and shipping":""} address${S.split?" and the second address":""} as Address records.</li>
|
||||
<li><b>Quotation</b> <code>SAL-QTN-${yr}-0${no}</code>: this configuration priced from the ERPNext price list ${PLV} (this page's price list is its prototype), ${soItems.length} items, valid 30 days.</li>
|
||||
@@ -1553,6 +1753,83 @@ function renderReview(){
|
||||
<p><button type="button" class="btn sec" data-act="unplace">Back to the order</button></p></div>`;}
|
||||
else $("#rvConf").innerHTML="";
|
||||
}
|
||||
|
||||
/* ---- step 15: how to pay, the fan campaign panel ---- */
|
||||
function renderFund(){
|
||||
const F=fansOn(),E=econ(),fi=$("#fanPrice"),frg=$("#fanR"),fr=FAN_RNG[S.cur],cap=capOn(),cw=campWhy(E);
|
||||
$("#fsCamp").disabled=!F;$("#fsCamp").title=F?"":ROUTEW;
|
||||
if(fi.dataset.cur!==S.cur){const a=FAN_IN[S.cur];fi.dataset.cur=S.cur;fi.min=a[0];fi.max=a[1];fi.step=a[2];frg.min=fr[0];frg.max=fr[1];frg.step=fr[2];$("#fpCur").textContent=S.cur;
|
||||
$("#fpChips").innerHTML=`<span>Try:</span>`+FP[S.cur].map(v=>`<button type="button" class="chip t" data-act="fpc" data-fp="${v}">${fmtInt(v)}</button>`).join("");}
|
||||
if(document.activeElement!==fi)fi.value=fanIn();frg.value=Math.min(fr[1],Math.max(fr[0],fanIn()));rngP(frg);
|
||||
$$("#fpChips .chip").forEach(c=>c.classList.toggle("on",Math.abs(toTHB(+c.dataset.fp)-S.fanPrice)<0.01));
|
||||
$("#days").value=S.days;rngP($("#days"));$("#daysV").textContent=S.days+" days";
|
||||
const ai=$("#acop");if(document.activeElement!==ai)ai.value=S.artistCopies;
|
||||
$("#limT").checked=S.limited;const ci=$("#cap");if(document.activeElement!==ci)ci.value=S.capN;
|
||||
ci.disabled=!F||!S.limited;ci.title=S.limited?"":"Tick the limited edition to set its size";$("#capF").classList.toggle("off",!S.limited);$("#capF").title=ci.title;
|
||||
$("#vatT").checked=S.vat;
|
||||
const H=priceHint(E);
|
||||
$("#need").innerHTML=E.goal==null?`<span class="bad-t">This price does not cover the cost per copy.</span>`:`You need <b>${fmtInt(E.goal)} fans</b> to press it, ${money(E.goal*S.fanPrice)} in pre-orders`;
|
||||
$("#needHint").textContent=E.goal==null?"Raise the price.":E.be>E.min?"Your break-even is "+E.be+" fans at this price, above the plant's "+E.min+"-copy minimum. From "+H.t+" a copy, "+E.min+" fans are enough."
|
||||
:"The plant presses from "+E.min+" copies. Below about "+H.t+" a copy you would need more fans to cover the costs.";
|
||||
let h="",hk="";
|
||||
if(E.goal==null)hk=`<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{const g=E.at(E.goal),rows=keepRows(E),mx=Math.max(...rows.map(r=>r.keep),1);
|
||||
hk=`<div class="kpis"><div class="kpi hl"><small>Fans needed</small><b>${fmtInt(E.goal)} fans</b><em>${money(E.goal*S.fanPrice)} in pre-orders</em></div><div class="kpi"><small>Your break-even</small><b>${fmtInt(E.be)} fans</b><em>every cost covered</em></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>
|
||||
${cap&&cap<E.goal?`<div class="note bad">The edition is capped at ${fmtInt(cap)}, below the goal of ${fmtInt(E.goal)}: this campaign cannot succeed.</div>`:""}`;
|
||||
h=`<h4>What you keep if more fans join</h4><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="kv2"><b>${money(r.keep)}</b><small>${money(r.per)} a copy</small></div></div>`).join("")}</div>
|
||||
<p class="hint">What you keep is after payment fees of about ${(R.fee.v*100).toFixed(1)} %${S.vat?" and VAT":""}, the one-off setup and the pressing. More fans than needed is good news: the one-off setup spreads over more copies. Shipping is paid by each fan at cost.</p>`;}
|
||||
$("#fundKpi").innerHTML=hk;$("#fundEcon").innerHTML=h;
|
||||
const b=$("#bLaunch"),n=missing().length;b.disabled=!!cw;b.title=cw;
|
||||
$("#launchMsg").textContent=cw&&F?cw+".":n?"Launching opens the review first: "+n+" item"+(n>1?"s":"")+" to complete there.":"Simulated: no page goes live and nobody is charged.";
|
||||
}
|
||||
|
||||
/* ---- the campaign simulation (make-a-record): launch, play, outcome ---- */
|
||||
let TIMER=null;
|
||||
function stopSim(){if(TIMER){clearInterval(TIMER);TIMER=null;}}
|
||||
function resetSim(){stopSim();S.simDay=0;S.outcome=null;S.outcomeAct=null;S.finalFans=0;}
|
||||
function resetCampaign(){resetSim();S.launched=false;S.launchDate=null;}
|
||||
function checkEnd(){
|
||||
if(S.simDay>=S.days){if(!S.outcome){const E=econ(),P=finalPledges(E);S.finalFans=P;S.outcome=P>=E.goal?"funded":"missed";}}
|
||||
else if(S.outcome){S.outcome=null;S.outcomeAct=null;}
|
||||
}
|
||||
function startSim(){
|
||||
stopSim();if(S.simDay>=S.days)resetSim();
|
||||
TIMER=setInterval(()=>{S.simDay=Math.min(S.days,S.simDay+S.days/80);checkEnd();if(S.simDay>=S.days)stopSim();updateSim();},100);
|
||||
}
|
||||
function toggleSim(){if(TIMER)stopSim();else startSim();updateSim();}
|
||||
function launch(){
|
||||
if(campWhy(econ()))return;
|
||||
if(missing().length){S.visited[RV]=true;go(RV);toast("Complete the items in the yellow box first");return;}
|
||||
resetCampaign();S.launched=true;S.launchDate=new Date(TODAY);S.visited[RV]=true;go(RV);$("#pBody").scrollTop=0;window.scrollTo(0,0);
|
||||
}
|
||||
function campChanged(){if(S.launched)resetCampaign();renderAll();}
|
||||
function setFanPrice(v){S.fanPrice=v;campChanged();}
|
||||
function updateSim(){
|
||||
if(!S.launched||!$("#simKpis"))return;
|
||||
const E=econ(),ended=S.simDay>=S.days,n=pledgesNow(E),goal=E.goal||0,W=campWindow(),cap=capOn();
|
||||
$$('#rvConf .seg[data-k="scen"] button').forEach(b=>b.classList.toggle("on",b.dataset.v===S.scen));
|
||||
const sl=$("#simDay");sl.max=S.days;if(document.activeElement!==sl)sl.value=S.simDay;rngP(sl);
|
||||
$("#dayLbl").textContent="Day "+Math.floor(S.simDay)+" of "+S.days;
|
||||
$("#playBtn").innerHTML=TIMER?"❚❚ Pause":ended?"↺ Replay":"▶ Play";
|
||||
const funded=ended&&n>=goal,soldOut=cap&&n>=cap,bi=$("#simBar");
|
||||
bi.style.width=(goal?Math.min(100,n/goal*100):0).toFixed(1)+"%";bi.className=ended&&!funded?"bad":"";
|
||||
$("#simKpis").innerHTML=`<div class="kpi hl"><small>Fans</small><b>${fmtInt(n)} / ${fmtInt(goal)}</b><em>${n>goal?Math.round(n/goal*100)+" % of the goal":"pre-orders so far"}</em></div><div class="kpi"><small>Held apart</small><b>${money(n*S.fanPrice)}</b><em>in the separate account</em></div><div class="kpi"><small>${ended?"Closed":"Days left"}</small><b>${ended?fmtDs(W.close):Math.max(0,Math.ceil(S.days-S.simDay))}</b><em>${ended?"campaign over":"closes "+fmtDs(W.close)}</em></div>`;
|
||||
$("#simStatus").innerHTML=!ended?"If fewer than "+fmtInt(goal)+" fans pre-order by "+fmtD(W.close)+", everyone is refunded automatically."
|
||||
:funded?`<b style="color:var(--accent)">✓ Funded by ${fmtInt(n)} fans${soldOut?", sold out":""}.</b> Pressing starts now.`
|
||||
:S.outcomeAct==="min"?"<b>Going ahead at the "+E.min+"-copy minimum.</b>":S.outcomeAct==="refund"?"<b>Goal missed.</b> Every fan has been refunded.":"<b>Goal missed.</b> Refunds go out unless the artist covers the gap.";
|
||||
let oc="";
|
||||
if(ended&&goal){
|
||||
if(funded){const A=E.at(n);
|
||||
oc=`<div class="ocard"><h3>Funded</h3><p>${fmtInt(n)} fans pre-ordered. Their money leaves the separate account for production: the campaign becomes a production order and ships about ${dRange(W.ship[0],W.ship[1])}.</p><div class="kv"><span>Fan money after fees${S.vat?" and VAT":""}</span><b>${money(A.net)}</b><span>One-off setup</span><b>${money(-E.tSum)}</b><span>Pressing and packaging, ${fmtInt(A.run)} copies</span><b>${money(-A.run*A.c)}</b><span><b style="color:var(--fg)">You keep</b></span><b style="color:var(--accent);font-size:16px">${money(A.keep)}</b></div></div>`;}
|
||||
else{const run=Math.max(n,E.min)+S.artistCopies,c=E.cAt(run),cost=E.tSum+run*c,net=n*E.net1,diff=net-cost,spare=Math.max(0,E.min-n);
|
||||
if(S.outcomeAct==="refund")oc=`<div class="ocard plain"><h3>Refunded</h3><p>${fmtInt(n)} refunds of ${money(S.fanPrice)} went back to each fan's original payment method. Nothing was pressed; your master and artwork stay in your vault for another try.</p><button type="button" class="btn sec sm" data-act="retry">Run it again</button></div>`;
|
||||
else if(S.outcomeAct==="min")oc=`<div class="ocard"><h3>Going ahead at the minimum</h3><p>${diff<0?"You paid the gap of <b>"+money(-diff)+"</b>":"The fans' money covered the minimum run; you keep <b>"+money(diff)+"</b>"}, and you receive the ${fmtInt(spare)} copies no fan claimed.</p></div>`;
|
||||
else oc=`<div class="ocard warn"><h3>Goal missed: ${fmtInt(n)} of ${fmtInt(goal)}</h3><p>Two ways out. The artist chooses.</p><div class="opts"><div class="opt"><b>Refund everyone</b><p>Automatic, from the separate account. Nothing is pressed; your files stay in your vault.</p><button type="button" class="btn sec sm" data-act="refund">Refund all ${fmtInt(n)} fans</button></div><div class="opt"><b>Go ahead at the ${E.min}-copy minimum</b><p>${diff<0?"You pay the gap of <b>"+money(-diff)+"</b>":"Costs you nothing: the fans' money covers it and you keep "+money(diff)}. You receive the ${fmtInt(spare)} unclaimed copies.</p><button type="button" class="btn sm" data-act="goMin">Go ahead</button></div></div></div>`;
|
||||
}
|
||||
}
|
||||
$("#simOut").innerHTML=oc;
|
||||
}
|
||||
|
||||
function rateText(k){
|
||||
const v=R[k].v,m=x=>money(x,{bare:true});
|
||||
if(k==="press"||k==="innerPrint"||k==="carton")return `12" ${k==="carton"?v[12]:m(v[12])} · 10" ${k==="carton"?v[10]:m(v[10])} · 7" ${k==="carton"?v[7]:m(v[7])}${k==="carton"?"":" "+S.cur}`;
|
||||
@@ -1563,7 +1840,9 @@ function rateText(k){
|
||||
if(k==="spine")return `12" ${m(v[12])} · 10" ${m(v[10])} ${S.cur}`;
|
||||
if(k==="finish")return `matt or gloss ${m(v.matt[12])} / ${m(v.matt[10])} / ${m(v.matt[7])}, gatefold ${m(v.matt.gf)}; spot UV ${m(v.spotuv[12])} / ${m(v.spotuv[10])} / ${m(v.spotuv[7])}, gatefold ${m(v.spotuv.gf)} ${S.cur}`;
|
||||
if(k==="wCopy")return `12" ${v[12]}, 10" ${v[10]}, 7" ${v[7]} kg; extra disc ${v.x12} / ${v.x10} / ${v.x7} kg; 180 g +${v.g180} kg a disc`;
|
||||
if(k==="handling"||k==="deposit")return pctT(v);
|
||||
if(k==="handling"||k==="deposit"||k==="fee")return pctT(v);
|
||||
if(k==="vatPct")return v+" % (a fan price includes "+v+"/"+(100+v)+" of VAT)";
|
||||
if(k==="campDays")return v.min+" to "+v.max+" days, "+v.def+" by default";
|
||||
if(k==="minRun")return v+" copies";
|
||||
return money(v);
|
||||
}
|
||||
@@ -1584,18 +1863,19 @@ function renderAll(){
|
||||
const n=S.step;
|
||||
if(n===13){renderSides();renderChecks();}
|
||||
if(n===14)renderArt();
|
||||
if(n===15)renderReview();
|
||||
renderFund();
|
||||
if(n===RV)renderReview();
|
||||
renderCard();renderStage();
|
||||
}
|
||||
function normalizeView(){if(!S.viewMan)S.view=JVIEW.includes(S.step)?"jacket":"record";}
|
||||
function go(n){
|
||||
if(n<1||n>15)return;const ch=n!==S.step;S.step=n;S.visited[n]=true;S.closed=false;if(ch)S.viewMan=false;
|
||||
if(n<1||n>STEPS.length)return;const ch=n!==S.step;S.step=n;S.visited[n]=true;S.closed=false;if(ch)S.viewMan=false;
|
||||
if(n===13)renderTracks();
|
||||
renderAll();if(ch){$("#pBody").scrollTop=0;if(window.innerWidth<900){const p=$("#panel").getBoundingClientRect().top+window.scrollY-60;if(window.scrollY>p)window.scrollTo(0,Math.max(0,p-330));}}
|
||||
}
|
||||
|
||||
/* ---- share links: configuration only (no addresses), in the URL hash ---- */
|
||||
const CFG_KEYS=["otype","size","discs","weight","labels","inner","innerCol","jacket","spine","finish","qty","shrink","insert","sticker","numbered","dlcard","barcode","obi","bag","source","master","cutStyle","tps","cleaning","dinked","catno","artist","title","gaps","genre","leadout","ship","split","splitQty","ship2","duty","cur","rpm"];
|
||||
const CFG_KEYS=["otype","size","discs","weight","labels","inner","innerCol","jacket","spine","finish","qty","shrink","insert","sticker","numbered","dlcard","barcode","obi","bag","source","master","cutStyle","tps","cleaning","dinked","catno","artist","title","gaps","genre","leadout","ship","split","splitQty","ship2","duty","cur","rpm","route","fanPrice","days","limited","capN","artistCopies","vat"];
|
||||
function cfg(){const o={v:1};CFG_KEYS.forEach(k=>o[k]=S[k]);o.dl=discsOn().map(d=>({fx:d.fx,c:d.c[d.fx].slice()}));o.srpm=Object.assign({},S.srpm);o.stype=Object.assign({},S.stype);if(!S.demoTracks)o.tr=S.tracks.map(t=>[t.title,Math.round(t.sec||0),t.side]);return o;}
|
||||
const b64e=s=>{const u=new TextEncoder().encode(s);let b="";u.forEach(c=>b+=String.fromCharCode(c));return btoa(b).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");};
|
||||
const b64d=s=>new TextDecoder().decode(Uint8Array.from(atob(s.replace(/-/g,"+").replace(/_/g,"/")),c=>c.charCodeAt(0)));
|
||||
@@ -1606,7 +1886,13 @@ function applyCfg(o){
|
||||
if(!o||typeof o!=="object")return;
|
||||
const en=(k,v)=>ENUM[k]&&ENUM[k].some(x=>x===v);
|
||||
["otype","size","weight","labels","inner","innerCol","jacket","spine","finish","source","master","cutStyle","tps","ship","ship2","duty","gaps","genre","cur","rpm"].forEach(k=>{if(en(k,o[k]))S[k]=o[k];});
|
||||
["shrink","insert","sticker","numbered","dlcard","barcode","obi","bag","cleaning","dinked","split"].forEach(k=>{if(typeof o[k]==="boolean")S[k]=o[k];});
|
||||
["shrink","insert","sticker","numbered","dlcard","barcode","obi","bag","cleaning","dinked","split","limited","vat"].forEach(k=>{if(typeof o[k]==="boolean")S[k]=o[k];});
|
||||
if(en("route",o.route))S.route=o.route;
|
||||
if(Number.isFinite(o.fanPrice)&&o.fanPrice>=100&&o.fanPrice<=20000)S.fanPrice=Math.round(o.fanPrice*100)/100;
|
||||
if(Number.isInteger(o.days)&&o.days>=R.campDays.v.min&&o.days<=R.campDays.v.max)S.days=o.days;
|
||||
if(Number.isInteger(o.capN)&&o.capN>=1&&o.capN<=5000)S.capN=o.capN;
|
||||
if(Number.isInteger(o.artistCopies)&&o.artistCopies>=0&&o.artistCopies<=200)S.artistCopies=o.artistCopies;
|
||||
resetCampaign();S.placed=null;
|
||||
if(en("discs",o.discs))S.discs=o.discs;
|
||||
if(Number.isFinite(o.qty))S.qty=Math.max(25,Math.min(5000,Math.round(o.qty)));
|
||||
if(Number.isFinite(o.splitQty))S.splitQty=Math.max(1,Math.min(4999,Math.round(o.splitQty)));
|
||||
@@ -1630,8 +1916,24 @@ function syncInputs(){
|
||||
/* ---- actions ---- */
|
||||
function qStep(q,dir){const st=dir>0?(q<100?25:q<1000?50:q<2000?100:250):(q<=100?25:q<=1000?50:q<=2000?100:250);const n=dir>0?Math.floor(q/st)*st+st:Math.ceil(q/st)*st-st;return Math.max(25,Math.min(5000,n));}
|
||||
function setQty(v){v=Math.round(+v);if(!Number.isFinite(v))return;S.qty=Math.max(25,Math.min(5000,v));if(S.splitQty>=S.qty)S.splitQty=Math.max(1,Math.floor(S.qty/2));syncInputs();renderAll();}
|
||||
function applyPreset(k){const P=PRESETS[k];if(!P)return;S.size=P.size;S.discs=P.discs;if(P.jacket)S.jacket=P.jacket;P.dl.forEach((x,i)=>{S.dl[i]=pDisc(x);});
|
||||
S.vd=0;S.fxOpen=P.dl[0].fx;S.viewMan=false;normalize();autoSplit();renderTracks();renderAll();toast("Preset: "+P.t);}
|
||||
function applyPreset(k,quiet){const P=PRESETS[k];if(!P)return;S.size=P.size;S.discs=P.discs;if(P.jacket)S.jacket=P.jacket;P.dl.forEach((x,i)=>{S.dl[i]=pDisc(x);});
|
||||
S.vd=0;S.fxOpen=P.dl[0].fx;S.viewMan=false;normalize();autoSplit();renderTracks();renderAll();if(!quiet)toast("Preset: "+P.t);}
|
||||
/* short deep links for screenshots and demos: #s=<step>&p=<preset>&route=fans|direct&fp=<fan price THB>&theme=light|dark;
|
||||
#fans is short for #s=15&route=fans, #fans=splat adds the preset. Example: order.html#s=15&p=splat&route=fans */
|
||||
function applyShort(h){
|
||||
if(!h||h.length<2||h.indexOf("#q=")===0)return false;
|
||||
const P={};h.slice(1).split("&").forEach(x=>{if(!x)return;const i=x.indexOf("="),k=i<0?x:x.slice(0,i);let v="";try{v=i<0?"":decodeURIComponent(x.slice(i+1));}catch(e){}P[k]=v;});
|
||||
if("fans" in P){if(!P.route)P.route="fans";if(!P.s)P.s=String(PAY);if(P.fans&&!P.p)P.p=P.fans;}
|
||||
let any=false;
|
||||
if((P.theme==="light"||P.theme==="dark")&&setTheme){setTheme(P.theme,false);any=true;}
|
||||
if(P.p&&PRESETS[P.p]){applyPreset(P.p,true);any=true;}
|
||||
if(P.route&&ENUM.route.includes(P.route)){if(S.route!==P.route){S.route=P.route;resetCampaign();S.placed=null;}any=true;}
|
||||
if(P.fp){const v=Math.round(+P.fp);if(v>=100&&v<=20000){S.fanPrice=v;resetCampaign();any=true;}}
|
||||
const st=Math.round(+P.s);
|
||||
if(st>=1&&st<=STEPS.length){for(let i=1;i<st;i++)S.visited[i]=true;go(st);any=true;}
|
||||
else if(any)renderAll();
|
||||
return any;
|
||||
}
|
||||
const ACT={
|
||||
go:b=>go(+b.dataset.s),
|
||||
next:()=>go(S.step+1),
|
||||
@@ -1657,9 +1959,18 @@ const ACT={
|
||||
tpl:b=>downloadTpl(b.dataset.k),
|
||||
artRm:b=>{S.art[b.dataset.k]=null;renderAll();},
|
||||
miss:b=>{go(+b.dataset.s);const f=b.dataset.f;if(f)setTimeout(()=>{const e=$("#"+f);if(e){e.focus();e.scrollIntoView({block:"center"});if(e.tagName==="INPUT"&&!e.value)e.classList.add("err");}},60);},
|
||||
place:()=>{if(missing().length){S.visited[15]=true;go(15);toast("Complete the items in the yellow box first");return;}if(S.placed)return;S.placed={no:orderNo(),at:new Date(TODAY)};renderAll();$("#pBody").scrollTop=0;window.scrollTo(0,0);},
|
||||
place:()=>{if(fansOn())return launch();if(missing().length){S.visited[RV]=true;go(RV);toast("Complete the items in the yellow box first");return;}if(S.placed)return;S.placed={no:orderNo(),at:new Date(TODAY)};renderAll();$("#pBody").scrollTop=0;window.scrollTo(0,0);},
|
||||
unplace:()=>{S.placed=null;renderAll();},
|
||||
pbgo:()=>{if(S.step===15)ACT.place();else go(15);}
|
||||
pbgo:()=>{if(S.step===RV)ACT.place();else go(RV);},
|
||||
launch:()=>launch(),
|
||||
unlaunch:()=>{resetCampaign();go(PAY);},
|
||||
play:()=>toggleSim(),
|
||||
refund:()=>{S.outcomeAct="refund";updateSim();},
|
||||
goMin:()=>{S.outcomeAct="min";updateSim();},
|
||||
retry:()=>{resetSim();updateSim();},
|
||||
fp:b=>{const a=FAN_IN[S.cur],st=a[2],c=fanIn(),d=+b.dataset.d;let v=d>0?Math.floor(c/st+1e-9)*st+st:Math.ceil(c/st-1e-9)*st-st;v=Math.max(a[0],Math.min(a[1],v));setFanPrice(toTHB(v));},
|
||||
fpc:b=>setFanPrice(toTHB(+b.dataset.fp)),
|
||||
acop:b=>{S.artistCopies=Math.max(0,Math.min(200,S.artistCopies+5*(+b.dataset.d)));campChanged();}
|
||||
};
|
||||
document.addEventListener("click",e=>{
|
||||
const b=e.target.closest("button");if(!b||b.disabled)return;
|
||||
@@ -1677,6 +1988,12 @@ document.addEventListener("input",e=>{
|
||||
else if(/^a_[bsx]_[a-z0-9]+$/.test(id)){const p=id.split("_");S.addr[p[1]][p[2]]=t.value;t.classList.remove("err");renderRail();renderCard();if(S.step===15)renderReview();}
|
||||
else if(id==="splitQty"){const v=Math.round(+t.value);if(v>=1&&v<=4999){S.splitQty=v;renderAll();}}
|
||||
else if(t.classList.contains("tt")){trkOf(t).title=t.value;S.demoTracks=false;}
|
||||
else if(id==="fanPrice"){const v=toTHB(+t.value);if(v>=100&&v<=20000){S.fanPrice=v;campChanged();}}
|
||||
else if(id==="fanR"){S.fanPrice=toTHB(+t.value);campChanged();}
|
||||
else if(id==="days"){const v=Math.round(+t.value);if(v>=R.campDays.v.min&&v<=R.campDays.v.max){S.days=v;campChanged();}}
|
||||
else if(id==="cap"){const v=Math.round(+t.value)||0;if(v>=1&&v<=5000){S.capN=v;campChanged();}}
|
||||
else if(id==="acop"){const v=Math.round(+t.value);if(t.value!==""&&v>=0&&v<=200){S.artistCopies=v;campChanged();}}
|
||||
else if(id==="simDay"){stopSim();S.simDay=+t.value;checkEnd();updateSim();}
|
||||
});
|
||||
document.addEventListener("change",e=>{
|
||||
const t=e.target;
|
||||
@@ -1687,6 +2004,13 @@ document.addEventListener("change",e=>{
|
||||
else if(t.id==="splitQty"){S.splitQty=Math.max(1,Math.min(Math.max(1,S.qty-1),Math.round(+t.value)||1));t.value=S.splitQty;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;S.demoTracks=false;renderTracks();renderAll();}
|
||||
else if(t.classList.contains("tside")){trkOf(t).side=t.value;S.demoTracks=false;renderTracks();renderAll();}
|
||||
/* a change event only commits what the input event has not (a blur after launching must not reset the campaign) */
|
||||
else if(t.id==="fanPrice"){const v=toTHB(+t.value),n=Math.min(20000,Math.max(100,v>0?(S.cur==="THB"?Math.round(v):v):1200)),ch=n!==S.fanPrice;S.fanPrice=n;t.value=fanIn();if(ch)campChanged();}
|
||||
else if(t.id==="cap"){const n=Math.max(1,Math.min(5000,Math.round(+t.value)||S.capN)),ch=n!==S.capN;S.capN=n;t.value=n;if(ch)campChanged();}
|
||||
else if(t.id==="acop"){const n=Math.max(0,Math.min(200,Math.round(+t.value)||0)),ch=n!==S.artistCopies;S.artistCopies=n;t.value=n;if(ch)campChanged();}
|
||||
/* switching the limited edition on ticks the hand-numbered extra, as make-a-record does */
|
||||
else if(t.id==="limT"){S.limited=t.checked;if(S.limited&&!S.numbered){S.numbered=true;toast("Limited edition: hand-numbered copies added to the extras (step 9)");}campChanged();}
|
||||
else if(t.id==="vatT"){S.vat=t.checked;campChanged();}
|
||||
});
|
||||
document.addEventListener("keydown",e=>{if(e.key==="Enter"&&e.target.classList&&e.target.classList.contains("qtyIn")){setQty(e.target.value);e.target.blur();}});
|
||||
/* drag to reorder tracks */
|
||||
@@ -1718,10 +2042,11 @@ async function handleAudio(files){
|
||||
}
|
||||
|
||||
/* ---- theme ---- */
|
||||
let setTheme=null;
|
||||
(function(){
|
||||
const b=$("#themeBtn"),de=document.documentElement;
|
||||
function set(th,save){de.setAttribute("data-theme",th);if(save){try{localStorage.setItem("cnx-theme",th)}catch(e){}}b.textContent=th==="light"?"☾ Dark mode":"☀ Light mode";b.title="Switch to "+(th==="light"?"dark":"light")+" mode";}
|
||||
set(de.getAttribute("data-theme")||"dark",false);
|
||||
set(de.getAttribute("data-theme")||"dark",false);setTheme=set;
|
||||
b.addEventListener("click",()=>{set(de.getAttribute("data-theme")==="light"?"dark":"light",true);b.blur();});
|
||||
const mq=window.matchMedia&&matchMedia("(prefers-color-scheme: light)");
|
||||
if(mq&&mq.addEventListener)mq.addEventListener("change",e=>{let s=null;try{s=localStorage.getItem("cnx-theme")}catch(_){}if(!s)set(e.matches?"light":"dark",false);});
|
||||
@@ -1735,9 +2060,11 @@ dropZone($("#dropLabel"),$("#fileLabel"),f=>handleArt("label",f));
|
||||
dropZone($("#dropInner"),$("#fileInner"),f=>handleArt("inner",f));
|
||||
if(location.hash.indexOf("#q=")===0){lastHash=location.hash;let o=null;try{o=JSON.parse(b64d(location.hash.slice(3)));}catch(e){}if(o)applyCfg(o);}
|
||||
syncInputs();renderTracks();renderPL();renderAll();
|
||||
window.addEventListener("hashchange",()=>{if(location.hash!==lastHash){lastHash=location.hash;applyHash(location.hash);}});
|
||||
if(location.hash&&location.hash.indexOf("#q=")!==0){lastHash=location.hash;applyShort(location.hash);}
|
||||
window.addEventListener("hashchange",()=>{if(location.hash!==lastHash){lastHash=location.hash;if(!applyHash(location.hash))applyShort(location.hash);}});
|
||||
if(window.ResizeObserver)new ResizeObserver(()=>renderStage()).observe($("#stage"));else window.addEventListener("resize",renderStage);
|
||||
window.__ord={S,R,quote,leadInfo,go,setK,setSide,missing,stateHash,cfg,applyCfg,sideStats,checkList,why,spineMM,shipWindow,ACT,renderAll};
|
||||
window.__ord={S,R,quote,leadInfo,go,setK,setSide,missing,stateHash,cfg,applyCfg,sideStats,checkList,why,spineMM,shipWindow,ACT,renderAll,
|
||||
campEcon,econ,priceHint,keepRows,finalPledges,pledgesNow,campWhy,launch,startSim,stopSim,updateSim,fanIn,toTHB,setFanPrice,applyShort,PAY,RV};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user