diff --git a/README.md b/README.md index 9037c06..7db4a92 100644 --- a/README.md +++ b/README.md @@ -211,8 +211,9 @@ The detail lives in dedicated documents: enough have pre-ordered, with refunds if not - [Order a record](docs/order.html): the order tool as a step-by-step configurator after the Mobineko pattern (step rail, choices, a large live - record picture, running price and ship date, review with what is - missing); same price list as the artist demos + record picture, running price and ship date, a "how to pay" step with + direct payment or a fan-funded campaign on make-a-record's calculator, + review with what is missing); same price list as the artist demos - [Artist ordering demo](docs/artist-order.html): the artist platform as a clickable single page: configure a record (jacket with a 3 or 5 mm spine), type the track list or drop the master files and artwork @@ -322,9 +323,10 @@ The detail lives in dedicated documents: caveats behind the numbers - [Investor deck](docs/pitch/index.html) (October 2026): the plant in 16 web slides for BOI, bank and institutional readers, proposing Plan B - (~215M THB) with Plan A as the comparison, with a - presenter view (press P) and a - [PDF](docs/pitch/CNX-Vinyl-Investor-Deck.pdf); every figure carries its + (~215M THB) with Plan A as the comparison, in a light and a dark theme + (press T), with a presenter view (press P) and a PDF of each theme + ([dark](docs/pitch/CNX-Vinyl-Investor-Deck.pdf), + [light](docs/pitch/CNX-Vinyl-Investor-Deck-light.pdf)); every figure carries its source in the business plan or the owning doc - Business plan, draft 0.3 (October 2026), in two versions: [Plan A, ~180M THB](docs/business-plan/CNX-Vinyl-Business-Plan-A.pdf) diff --git a/docs/artist-platform.md b/docs/artist-platform.md index 10a68dc..f16b2ea 100644 --- a/docs/artist-platform.md +++ b/docs/artist-platform.md @@ -57,11 +57,27 @@ demo, so a rate change goes into both pages. plant's online order form: a rail of numbered steps (order type, size, discs, vinyl colour and effects, labels, inner sleeves, jacket and finish, extras, mastering and cutting, test pressings, shipping, track -list, artwork, review), each step's choices beside a large live picture -of the record, and a running price and ship date. The review lists what -is still missing before an order can be placed and links to fan funding -in the detailed demo. It carries the same price list, so a rate change -goes into all three pages. +list, artwork, how to pay, review), each step's choices beside a large +live picture of the record, and a running price and ship date. The review +lists what is still missing before an order can be placed. It carries the +same price list, so a rate change goes into all three pages. + +**Fan funding in the order tool** (user, 20 October 2026: the new tool +offers fan funding too, so the investor deck's platform pictures come +from one tool and one calculator). Step 15 "How to pay" chooses between +paying the plant directly (the deposit, the rest before dispatch) and a +fan-funded campaign with the same inputs and the same campaign maths as +[make-a-record](make-a-record.html): the artist sets the fan price +(default 1,200 THB) and the campaign length, the fans needed are the +larger of break-even and the 100-copy minimum run, and the tool shows +what the artist keeps at the goal and with more fans, after the payment +fees (~3.5 %, to confirm with the provider), the one-off setup and the +pressing. Step 16 becomes "Review & launch" and simulates the campaign's +outcome. For the same record both pages give the same numbers (checked +headless over 40 combinations, October 2026): the default record at +1,200 THB needs 100 fans (break-even 41) and keeps ~548 THB a copy; the +Splatter 12" preset needs 100 fans (break-even 46) and keeps ~480 THB a +copy. Indicative demo prices. **Mastering as a choice** (user, October 2026): the order tool lets the customer pick the mastering: none (the master is final and only prepared diff --git a/docs/order.html b/docs/order.html index 117c99e..d7f3128 100644 --- a/docs/order.html +++ b/docs/order.html @@ -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);})();
-
Demo page from docs/order.html; prices are indicative and not an offer; the platform's name is not decided yet.
+
Demo page from docs/order.html; prices are indicative and not an offer; the platform's name is not decided yet.
`; return H.map((h,i)=>i?``:"").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 Loud cutting style (step 10) and 45 rpm where a side allows."):G[2]==="dyn"?"Dynamic music: the Fidelity 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 ``;}).join(""); const on=$("#rail .ri.on"),c=$("#rail"); if(on&&c&&c.scrollWidth>c.clientWidth+4){const o=on.offsetLeft-c.offsetLeft;if(oc.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=nYou keep at the goal
${money(G.per)} a copy
${money(G.keep)} in all, after the ${money(E.tSum)} one-off setup, pressing and fees
`:`
You keep at the goal
${money(0)}
Raise the fan price in step ${PAY}.
`; + $("#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.qtyRestore`:""; - $$(".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.qtys.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?`${{doc:"doc",team:"team",est:"est."}[r.k]}`:"";}; 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?`

⚠ Please complete the following before ordering:

    ${M.map(m=>`
  • ${m.fs?": missing "+m.fs.map(([f,n])=>``).join(", "):""}
  • `).join("")}
` - :`

✓ Everything needed to order is filled in.

Place the order with the button at the bottom.
`; - $("#rvSoft").innerHTML=`
Can follow after the order
    ${soft().map(x=>`
  • ${esc(x)}
  • `).join("")}
`; + 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?`

⚠ Please complete the following before ${F?"launching":"ordering"}:

    ${M.map(m=>`
  • ${m.fs?": missing "+m.fs.map(([f,n])=>``).join(", "):""}
  • `).join("")}
` + :`

✓ Everything needed to ${F?"launch":"order"} is filled in.

${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."}
`; + $("#rvSoft").innerHTML=`
Can follow after the ${F?"launch":"order"}
    ${soft().map(x=>`
  • ${esc(x)}
  • `).join("")}
`; $("#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=>`${esc(c)}`).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=>`${esc(c)}`).join(""); const tr=x=>`${esc(x.t)} ${badge(x.id)}${x.pass!=null?` ↗ partner ${money(x.pass)} + ${pctT(R.handling.v)}`:""}${x.u?fmtInt(x.n)+" "+x.u+" × ":""}${money(x.r)}${x.u?fmtInt(x.n)+" "+x.u:""}${money(x.r)}${money(x.a)}`; - $("#rvTab").innerHTML=`One-off, for this releaseQtyRateAmount${Q.T.map(tr).join("")}One-off total${money(Q.tSum)} - Per copy × ${fmtInt(q)}RatePer copy${Q.C.map(tr).join("")}${money(Q.cSum)} per copy × ${fmtInt(q)} copies${money(Q.cSum*q)} + const head=`One-off, for this releaseQtyRateAmount${Q.T.map(tr).join("")}One-off total${money(Q.tSum)} + Per copy × ${fmtInt(q)}${F?(E.goal?", the goal: "+fmtInt(E.goal)+" fans + "+fmtInt(S.artistCopies)+" for you":", the minimum run"):""}RatePer copy${Q.C.map(tr).join("")}${money(Q.cSum)} per copy × ${fmtInt(q)} copies${money(Q.cSum*q)}`; + $("#rvTab").innerHTML=F?head+` + ShippingOne tracked parcel per fan, paid by the fan at checkout, at costpaid by fans + Cost of the run${E.goal?" at the goal":""}, excl. VAT${money(Q.tSum+q*Q.cSum)}`+(G?`Fan money after payment fees${S.vat?" and VAT":""}, ${fmtInt(E.goal)} × ${money(E.net1)} ${badge("fee")}${money(G.net)} + You keep at the goal${money(G.keep)}That is, a copy${money(G.per)} + 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}).` + :`
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}.
`) + :head+` ShippingRateAmount${Q.H.map(tr).join("")} Order total, excl. VAT${money(Q.total)}Per copy, before shipping${money(Q.per)} Deposit to start, ${pctT(R.deposit.v)} ${badge("deposit")}${money(Q.total*R.deposit.v)}Balance before dispatch${money(Q.total*(1-R.deposit.v))}`; - $("#rvLead").innerHTML=(L.repress?`
Repress from the archived mother2 to 3 weeks, quotedOrdered today, ships about${dRange(sw[0],sw[1])}

A repress skips mastering, cutting and all three approvals (production timeline): about 7 to 11 working days of work, quoted with queue and buffer.

` - :`
Before your test-pressing approvaltypically ${L.pw[0]} to ${L.pw[1]} weeksFrom your test-pressing approval${L.qw[0]} to ${L.qw[1]} weeks, quotedOrdered today, ships about${dRange(sw[0],sw[1])}

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.

`)+ + const closeT=(S.launched?"Closes ":"If launched today, closes "); + $("#rvLead").innerHTML=(F?(L.repress?`
Campaign${S.days} days${closeT}${fmtD(W.close)}Repress from the archived mother2 to 3 weeks, quotedIf funded, ships about${dRange(W.ship[0],W.ship[1])}

A repress skips mastering, cutting and all three approvals (production timeline); it starts when the campaign succeeds.

` + :`
Campaign${S.days} days${closeT}${fmtD(W.close)}Then, before your test-pressing approvaltypically ${L.pw[0]} to ${L.pw[1]} weeksFrom your test-pressing approval${L.qw[0]} to ${L.qw[1]} weeks, quotedIf funded, ships about${dRange(W.ship[0],W.ship[1])}

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.

`) + :(L.repress?`
Repress from the archived mother2 to 3 weeks, quotedOrdered today, ships about${dRange(sw[0],sw[1])}

A repress skips mastering, cutting and all three approvals (production timeline): about 7 to 11 working days of work, quoted with queue and buffer.

` + :`
Before your test-pressing approvaltypically ${L.pw[0]} to ${L.pw[1]} weeksFrom your test-pressing approval${L.qw[0]} to ${L.qw[1]} weeks, quotedOrdered today, ships about${dRange(sw[0],sw[1])}

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.

`))+ `
Stages${L.repress?"":" before your test-pressing approval"}${L.pre.map(s=>``).join("")}
${s.gate?"◆ ":""}${esc(s.t)}${s.d[0]} to ${s.d[1]} days
`; - 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?`
Fan-funded, fan price${money(S.fanPrice)}Fans needed${E.goal?fmtInt(E.goal):"price too low"}Your break-even${E.be!=null?fmtInt(E.be)+" fans":"none"}You keep at the goal${G?money(G.per)+" a copy":money(0)}Campaign${S.days} daysLimited edition${cap?fmtInt(cap)+" copies":"no"}Your own copies${fmtInt(S.artistCopies)}Payment fees ${badge("fee")}${pctT(R.fee.v)}

Set in step ${PAY}.

` + :`
Direct order, deposit ${pctT(R.deposit.v)} ${badge("deposit")}${money(Q.total*R.deposit.v)}Balance before dispatch${money(Q.total*(1-R.deposit.v))}

Or let your fans pre-order it: the record is pressed when enough of them have, and every fan is refunded if not.

`; + if(F&&S.launched){const no=orderNo(),yr=S.launchDate.getFullYear(),nm=S.addr.b.name||"the artist"; + $("#rvConf").innerHTML=`

Your campaign simulated

+

Pick how it goes and press play: ${S.days} days in about 8 seconds, or drag the slider to any day.

+
+

+

✓ Campaign launched (simulated): CNX-${yr}-${no}

No page went live and nobody was charged. In the real platform the campaign is written to ERPNext, 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).

+
  1. Customer ${esc(nm)}: the artist, matched by e-mail or created, with the billing address.
  2. +
  3. Campaign CMP-${yr}-0${no}, 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):""}.
  4. +
  5. Payment Entry 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.
  6. +
  7. If it succeeds: a Sales Order the advances are allocated to, for the fans' copies${S.artistCopies?" plus your "+fmtInt(S.artistCopies):""}, and a Delivery Note for each fan's parcel.
  8. +
  9. Release ${esc(S.catno)} with ${sideNames().length} Side records (custom document types in the cnx_erp app) and three Artist Gate records: master plan, master, test pressing.
+

`; + 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=`

✓ Order placed (simulated): CNX-${yr}-${no}

Nothing was sent or charged. In the real platform the order is written to ERPNext, 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).

  1. Customer ${esc(nm)}: matched by e-mail or created, with the billing${S.ship!=="pickup"?" and shipping":""} address${S.split?" and the second address":""} as Address records.
  2. Quotation SAL-QTN-${yr}-0${no}: this configuration priced from the ERPNext price list ${PLV} (this page's price list is its prototype), ${soItems.length} items, valid 30 days.
  3. @@ -1553,6 +1753,83 @@ function renderReview(){

`;} 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=`Try:`+FP[S.cur].map(v=>``).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?`This price does not cover the cost per copy.`:`You need ${fmtInt(E.goal)} fans 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=`
At ${money(E.p)} a copy, what is left after payment fees${S.vat?" and VAT":""} does not cover the cost per copy. Raise the fan price.
`; + else{const g=E.at(E.goal),rows=keepRows(E),mx=Math.max(...rows.map(r=>r.keep),1); + hk=`
Fans needed${fmtInt(E.goal)} fans${money(E.goal*S.fanPrice)} in pre-orders
Your break-even${fmtInt(E.be)} fansevery cost covered
You keep at the goal${money(g.per)}a copy
That is${money(g.keep)}in total
+ ${cap&&capThe edition is capped at ${fmtInt(cap)}, below the goal of ${fmtInt(E.goal)}: this campaign cannot succeed.`:""}`; + h=`

What you keep if more fans join

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

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.

`;} + $("#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=`
Fans${fmtInt(n)} / ${fmtInt(goal)}${n>goal?Math.round(n/goal*100)+" % of the goal":"pre-orders so far"}
Held apart${money(n*S.fanPrice)}in the separate account
${ended?"Closed":"Days left"}${ended?fmtDs(W.close):Math.max(0,Math.ceil(S.days-S.simDay))}${ended?"campaign over":"closes "+fmtDs(W.close)}
`; + $("#simStatus").innerHTML=!ended?"If fewer than "+fmtInt(goal)+" fans pre-order by "+fmtD(W.close)+", everyone is refunded automatically." + :funded?`✓ Funded by ${fmtInt(n)} fans${soldOut?", sold out":""}. Pressing starts now.` + :S.outcomeAct==="min"?"Going ahead at the "+E.min+"-copy minimum.":S.outcomeAct==="refund"?"Goal missed. Every fan has been refunded.":"Goal missed. Refunds go out unless the artist covers the gap."; + let oc=""; + if(ended&&goal){ + if(funded){const A=E.at(n); + oc=`

Funded

${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])}.

Fan money after fees${S.vat?" and VAT":""}${money(A.net)}One-off setup${money(-E.tSum)}Pressing and packaging, ${fmtInt(A.run)} copies${money(-A.run*A.c)}You keep${money(A.keep)}
`;} + 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=`

Refunded

${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.

`; + else if(S.outcomeAct==="min")oc=`

Going ahead at the minimum

${diff<0?"You paid the gap of "+money(-diff)+"":"The fans' money covered the minimum run; you keep "+money(diff)+""}, and you receive the ${fmtInt(spare)} copies no fan claimed.

`; + else oc=`

Goal missed: ${fmtInt(n)} of ${fmtInt(goal)}

Two ways out. The artist chooses.

Refund everyone

Automatic, from the separate account. Nothing is pressed; your files stay in your vault.

Go ahead at the ${E.min}-copy minimum

${diff<0?"You pay the gap of "+money(-diff)+"":"Costs you nothing: the fans' money covers it and you keep "+money(diff)}. You receive the ${fmtInt(spare)} unclaimed copies.

`; + } + } + $("#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=&p=&route=fans|direct&fp=&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;igo(+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}; })(); diff --git a/docs/pitch/CNX-Vinyl-Investor-Deck-light.pdf b/docs/pitch/CNX-Vinyl-Investor-Deck-light.pdf new file mode 100644 index 0000000..18b9f55 Binary files /dev/null and b/docs/pitch/CNX-Vinyl-Investor-Deck-light.pdf differ diff --git a/docs/pitch/CNX-Vinyl-Investor-Deck.pdf b/docs/pitch/CNX-Vinyl-Investor-Deck.pdf index 6911519..0b5a723 100644 Binary files a/docs/pitch/CNX-Vinyl-Investor-Deck.pdf and b/docs/pitch/CNX-Vinyl-Investor-Deck.pdf differ diff --git a/docs/pitch/img/make-a-record-fans.webp b/docs/pitch/img/make-a-record-fans.webp deleted file mode 100644 index cf4a405..0000000 Binary files a/docs/pitch/img/make-a-record-fans.webp and /dev/null differ diff --git a/docs/pitch/img/order-fans-dark.webp b/docs/pitch/img/order-fans-dark.webp new file mode 100644 index 0000000..08e5dad Binary files /dev/null and b/docs/pitch/img/order-fans-dark.webp differ diff --git a/docs/pitch/img/order-fans-light.webp b/docs/pitch/img/order-fans-light.webp new file mode 100644 index 0000000..3d69884 Binary files /dev/null and b/docs/pitch/img/order-fans-light.webp differ diff --git a/docs/pitch/img/order-splatter-dark.webp b/docs/pitch/img/order-splatter-dark.webp new file mode 100644 index 0000000..43f8116 Binary files /dev/null and b/docs/pitch/img/order-splatter-dark.webp differ diff --git a/docs/pitch/img/order-splatter-light.webp b/docs/pitch/img/order-splatter-light.webp new file mode 100644 index 0000000..69ee66f Binary files /dev/null and b/docs/pitch/img/order-splatter-light.webp differ diff --git a/docs/pitch/img/order-splatter.webp b/docs/pitch/img/order-splatter.webp deleted file mode 100644 index ea9f014..0000000 Binary files a/docs/pitch/img/order-splatter.webp and /dev/null differ diff --git a/docs/pitch/img/record-passport-dark.webp b/docs/pitch/img/record-passport-dark.webp new file mode 100644 index 0000000..e247529 Binary files /dev/null and b/docs/pitch/img/record-passport-dark.webp differ diff --git a/docs/pitch/img/record-passport-light.webp b/docs/pitch/img/record-passport-light.webp new file mode 100644 index 0000000..1835c1f Binary files /dev/null and b/docs/pitch/img/record-passport-light.webp differ diff --git a/docs/pitch/img/record-passport.webp b/docs/pitch/img/record-passport.webp deleted file mode 100644 index 9106a8e..0000000 Binary files a/docs/pitch/img/record-passport.webp and /dev/null differ diff --git a/docs/pitch/index.html b/docs/pitch/index.html index 0702694..d76efb6 100644 --- a/docs/pitch/index.html +++ b/docs/pitch/index.html @@ -5,24 +5,75 @@ CNX Vinyl: investment proposal +